pyfcfc wraps the two pair-counting components of the C package
FCFC (by Cheng Zhao) so that they can
be driven entirely from Python with in-memory NumPy catalogues:
| module | FCFC component | data | line of sight |
|---|---|---|---|
pyfcfc.boxes |
FCFC_2PT_BOX |
periodic-box Cartesian coordinates | box z axis |
pyfcfc.sky |
FCFC_2PT |
Cartesian comoving coordinates, or {RA, Dec, redshift} with on-the-fly cosmological conversion | pair midpoint |
Pair counts, correlation functions, multipoles and projected correlation functions are computed with k-d tree or ball tree dual-tree traversal, OpenMP parallelisation and (optionally) SIMD vectorisation, and returned as plain NumPy arrays in a Python dictionary.
The package also ships utilities (pyfcfc.utils) to accumulate
split-random measurements, integrate correlation functions, and convert
results into pycorr states, so that
pair counting can be done with (fast) FCFC while the analysis uses the
pycorr API (estimators, rebinning, covariance tools, saving...). The
optional pyfcfc.external module converts results into
lsstypes containers (Count2,
Count2Correlation, Count2CorrelationPoles, ...), giving access to
replica covariances and serialisation.
Compared to pycorr (Corrfunc engine), pyfcfc pair counts and correlation
functions agree to machine precision on identical inputs (see
examples/); the wall-clock speedup is typically 1.5–2.5x on the counting
step (and larger with SIMD enabled).
- Installation
- Quick start
- The
py_compute_cffunction - Conventions
- Working with pycorr
- Accumulating split-random measurements
- Tests
- Examples
- Gotchas and notes
- Changes with respect to the original port
- Acknowledgements
Requirements: a C compiler with OpenMP support (gcc/clang), Python ≥ 3.9,
NumPy, SciPy; Cython and NumPy at build time (handled automatically by
pip).
pip install . # from the repository root
# or, for development:
pip install -e .
# or with the Makefile:
makeA one-shot script that installs the system build dependencies, creates a virtual environment with all (optional) extras and installs pyfcfc in editable mode is provided:
bash scripts/setup_dev_env.sh [VENV_DIR]SIMD acceleration. FCFC's vectorised counting kernels are enabled with
PYFCFC_WITH_SIMD=1 pip install . --no-build-isolationThis implies -march=native: build on a machine whose instruction set
(AVX2/AVX512) is available at run time. Compilation of the SIMD kernels is
memory hungry (a couple of GB of RAM); PYFCFC_NO_LTO=1 and
PYFCFC_EXTRA_CFLAGS="-O2 -mno-avx512f" can reduce the footprint (the
latter restricts the build to AVX2).
Other build environment variables: PYFCFC_MARCH_NATIVE=1 (native code
generation without SIMD), PYFCFC_NO_LTO=1 (disable link-time
optimisation), PYFCFC_EXTRA_CFLAGS="..." (extra compiler flags).
Optional dependencies for the extras:
pycorr(+Corrfunc, its default engine) for the interoperability utilities:pip install git+https://github.com/cosmodesi/pycorr.gitandpip install git+https://github.com/cosmodesi/Corrfunc@desi;matplotlibto run the plotting examples.
The number of OpenMP threads is read from OMP_NUM_THREADS at run time.
import numpy as np
from pyfcfc.boxes import py_compute_cf
data = ... # (N_d, 3) float array, periodic-box coordinates
rand = ... # (N_r, 3) float array
w_d, w_rand = np.ones(len(data)), np.ones(len(rand))
s_edges = np.arange(0, 151, 5.)
results = py_compute_cf(
[data, rand], [w_d, w_rand],
s_edges, # separation bin edges
None, # pi bin edges (only for bin=2)
48, # number of mu bins in [0, 1) (bin=1)
label=['D', 'R'], # catalogue labels
bin=1, # binning scheme: 0 iso, 1 (s,mu), 2 (s_perp,pi)
pair=['DD', 'DR', 'RR'], # pair counts to evaluate
cf=['(DD - 2 * DR + RR) / RR'], # correlation function estimator(s)
multipole=[0, 2, 4], # Legendre multipoles to integrate
box=1000., # periodic box side(s)
)
s, xi0, xi2, xi4 = results['s'], *results['multipoles'][0]Survey-like data, converting {RA, Dec, z} internally:
from pyfcfc.sky import py_compute_cf
results = py_compute_cf(
[rdd_data, rdd_rand], [w_data, w_rand],
s_edges, None, 48,
label=['D', 'R'], bin=1, pair=['DD', 'DR', 'RR'],
cf=['(DD - 2 * DR + RR) / RR'], multipole=[0, 2, 4],
convert=True, omega_m=0.31, omega_l=0.69, eos_w=-1.0,
)Configuration files (conf=...) are still supported (catalogue/position
entries are ignored, since the data come from Python); keyword arguments
override the file, exactly as command-line options do in FCFC.
py_compute_cf(data_cats, data_wts, sedges, pedges=None, nmu=1, **kwargs)data_cats: sequence of catalogues, each convertible to an (N, 3) array. Non-contiguous arrays, lists and integer/float32 inputs are converted internally (a copy is made; your arrays are never modified). float32 is used natively only when all positions and weights are float32; otherwise everything is promoted to float64.data_wts: sequence of per-object weights, each of length N. Pass arrays of ones for unweighted catalogues (this automatically enables FCFC's faster, exact integer counting path).sedges: separation (or s_perp) bin edges; strictly increasing, non-negative, finite. For the box component,smax < box/2.pedges: pi bin edges, required forbin=2.nmu: number of mu bins in [0, 1), used forbin=1.**kwargs: FCFC configuration options (below). Python booleans are translated to'T'/'F', sequences to FCFC array literals ([0, 2, 4]), and unknown options raiseValueErrorwith suggestions instead of being silently ignored.
All options reach FCFC through a single libcfg parameter table, whether
they come from keyword arguments or from a configuration file: the
keywords are translated to command-line-style --key=value strings
(which take priority), and an optional conf= file is read afterwards
with lower priority, exactly as in the FCFC CLI. Since the catalogues
and the bin edges always come from the function arguments, the upstream
file-based inputs were removed from the table (CATALOG, POSITION,
ASCII_*, SELECTION, SEP_BIN_*, MU_BIN_NUM, PI_BIN_*), as were
the CLI-only --help/--version/--template callbacks and the
FITS/HDF5 catalogue readers (excluded from the build).
What remains of the file machinery, and stays supported:
- the
conf=overlay itself (any option in the table below); - the optional outputs
pair_output,cf_output,mp_output,wp_output(without_formatandoverwrite); when aPAIR_COUNT_FILEalready exists, FCFC reads the pair counts from it instead of recomputing them (tested: write/read round-trip); cmvdst_file(sky component): a {redshift, comoving distance} table replacing the internal distance integration;- the
verbose=Trueconfiguration summary.
| option (keyword) | boxes |
sky |
meaning |
|---|---|---|---|
label |
✔ | ✔ | single-uppercase-letter catalogue labels (defaults to A, B, C, ...) |
pair |
✔ | ✔ | pair counts to evaluate, e.g. ['DD','DR','RR'] |
bin |
✔ | ✔ | binning scheme: 0 = isotropic, 1 = (s, mu), 2 = (s_perp, pi) |
cf |
✔ | ✔ | correlation function estimator expression(s), e.g. ['(DD - 2*DR + RR)/RR']; '@@' denotes the analytic random-random counts (box) |
multipole |
✔ | ✔ | multipole orders to integrate (requires bin=1 and cf) |
wp |
✔ | ✔ | compute the projected correlation function (requires bin=2 and cf) |
box |
✔ | periodic box side length(s) | |
convert |
✔ | convert {RA, Dec, z} to comoving Cartesian coordinates | |
omega_m, omega_l, eos_w |
✔ | fiducial cosmology for the conversion | |
cmvdst_err, cmvdst_file |
✔ | accuracy / lookup table for the distance integration | |
data_struct |
✔ | ✔ | 0 = k-d tree (default), 1 = ball tree |
weight |
✔ | ✔ | per-catalogue weight flags; set automatically ('1' when all weights are exactly 1, enabling the integer path) |
pair_output, cf_output, mp_output, wp_output |
✔ | ✔ | optional files where FCFC writes its results |
out_format, overwrite |
✔ | ✔ | output file options |
verbose |
✔ | ✔ | print FCFC's configuration summary and progress (default: False) |
Errors inside the C code (bad configuration, missing pairs, ...) raise
Python exceptions (RuntimeError) and never crash the interpreter; all
allocated memory is released on both success and failure paths.
| key | shape / content |
|---|---|
labels |
list of catalogue labels |
number |
dict label → number of objects |
weighted_number |
dict label → sum of weights (equals number for unit weights) |
normalization |
dict pair → pair-count normalization (see conventions) |
pairs |
dict pair → normalized pair counts, shape (ns,), (ns, nmu) or (ns, np); plus bin edge arrays smin, smax and (mumin, mumax) or (pimin, pimax) |
s |
(ns,) centers of the separation (s_perp) bins |
cf |
(ncf, ns[, nmu or np]) estimator values, if cf was given |
multipoles |
(ncf, nl, ns) multipole moments, if multipole was given |
poles |
list of the multipole orders computed |
projected |
(ncf, ns) projected correlation function w_p, if wp=True |
The pair-count arrays are normalized counts; multiply by
normalization[pair] to recover the raw (weighted) pair counts.
Every pair listed in pair (or PAIR_COUNT in a configuration file)
is evaluated and returned in results['pairs'], whether or not a CF
estimator references it: the pair counts are the main product of the
code, and estimators/multipoles/projections are optional extras computed
from them (here, or externally with pyfcfc.utils, pycorr or lsstypes).
Passing a dummy estimator such as cf=['DD'] is therefore not needed
(for historical reasons the original port required an estimator to keep
the requested binning scheme; this was fixed in 0.2.0 and is covered by
regression tests). The only pairs not counted from scratch are those with
an existing PAIR_COUNT_FILE output, which FCFC reads instead.
- Auto pairs (e.g.
DD) are counted twice per unordered pair (ordered-pair convention), and the normalization is(Σw)² − Σw²— the same convention as pycorr'swnorm. - Cross pairs (e.g.
DR) are counted once per (i, j) combination, with normalizationΣw₁ × Σw₂. - For
bin=1andbin=2, onlymu ≥ 0(pi ≥ 0) is binned, with mu (pi) the absolute value of the cosine (parallel separation). - Bins are left-closed, right-open; pairs with exactly
mu == 1are dropped (as in FCFC compiled withoutWITH_MU_ONE). - The box component uses the periodic minimum-image metric; the sky
component defines the line of sight as the pair midpoint direction
(equivalent to pycorr's
los='midpoint').
FCFC integrates multipoles with the midpoint rule,
ξ_ℓ(s) = (2ℓ+1) Σ_j ξ(s, μ_j) P_ℓ(μ_j) Δμ. pycorr instead uses the
exact integral of the Legendre polynomial over each bin. The two agree up
to O(Δμ²): for ℓ = 0 they coincide (the difference cancels), while for
ℓ = 2, 4 the difference can reach ~10⁻² at small scales when ξ is large —
this is a scheme difference, not a counting error (the raw pair counts
of both codes agree to machine precision).
pyfcfc.utils.compute_multipoles supports both schemes:
from pyfcfc.utils import compute_multipoles
mp_mid = compute_multipoles(xi_smu, [0, 2, 4]) # FCFC's scheme
mp_exact = compute_multipoles(xi_smu, [0, 2, 4], method='exact') # pycorr's schemeso you can reproduce either code's numbers exactly from pyfcfc pair
counts (both also accept ignore_nan=True to skip empty bins, as pycorr
does).
from pyfcfc.utils import pairs_to_pycorr
from pycorr import TwoPointCorrelationFunction
state = pairs_to_pycorr(
results, # pyfcfc result dict
'landyszalay', # pycorr estimator name
dict(DD='D1D2', DR=('D1R2', 'R1D2'), RR='R1R2'), # label mapping
box_size=1000., # periodic box (optional)
)
np.save('counts.pkl.npy', state)
tpcf = TwoPointCorrelationFunction.load('counts.pkl.npy')
s, xi0 = tpcf(ell=0, return_sep=True)
rebinned = tpcf[::2, ::4] # pycorr rebinning worksNotes:
- Reversible pair counts must be mapped to both pycorr names (as for
DRabove); pyfcfc's |mu| counts are mirrored to pycorr's full [-1, 1] range (exactly for auto pairs, statistically for cross pairs). - For the
naturalestimator in a periodic box, the analytic random-random counts are generated automatically whenbox_sizeis given. - The pycorr state records
los_type='z'for box measurements and'midpoint'for sky measurements, matching FCFC's conventions. - In
rppimode, pycorr stores symmetric pi edges [-pimax, pimax]; the conversion mirrors pyfcfc's pi ≥ 0 counts accordingly.
lsstypes provides container types
for LSS measurements (replica algebra, covariances, serialisation). The
optional module pyfcfc.external converts pyfcfc results into them:
pip install git+https://github.com/adematti/lsstypes # optional extrafrom pyfcfc.external import to_lsstypes, to_lsstypes_correlation, to_lsstypes_counts
counts = to_lsstypes_counts(results) # {'DD': Count2, 'DR': ..., 'RD': ..., 'RR': ...}
corr = to_lsstypes_correlation(results, 'landyszalay') # Count2Correlation
poles = to_lsstypes(results, ells=[0, 2, 4]) # Count2CorrelationPoles
wp = to_lsstypes(results_spi, project='wp') # Count2CorrelationWpprojectaccepts 'poles' (default), 'wedges', 'wp', 'binned', or None (returns the un-projectedCount2Correlation);- multipoles use lsstypes' exact per-bin Legendre integration, i.e.
compute_multipoles(..., method='exact')on the pyfcfc side; - for the 'natural' estimator in a periodic box without RR counts, the
analytic random-random counts are synthesized (same convention as
FCFC's
@@); - catalogue labels other than D/R are handled with
pair_mapping, e.g.{'AA': 'DD', 'AB': ('DR', 'RD'), 'BB': 'RR'}; - replica tools work out of the box:
lsstypes.mean/cov/sumover converted objects, andlsstypes.write/read(.pkl,.h5,.txt). Note thatlsstypes.sumweights replicas by the DD normalization, whilepyfcfc.utils.add_pair_countsaccumulates each pair type with its own normalization (exact raw-count addition); the two coincide for unweighted, equal-size replicas.
See notebooks/04_lsstypes.ipynb for a complete walkthrough and
tests/test_lsstypes.py (11 tests, skipped when lsstypes is absent).
from pyfcfc.utils import add_pair_counts
total = None
for chunk in random_chunks:
res = py_compute_cf([data, chunk.pos], [w_data, chunk.w], ...)
total = res if total is None else add_pair_counts(total, res)add_pair_counts combines the raw pair counts exactly (it is a
normalization-weighted average of the normalized counts, with summed
normalizations), returns a new dictionary, and drops stale
estimator-dependent keys (cf, multipoles, ...), which must be
recomputed from the combined counts.
pip install pytest
python -m pytest tests -v # 61 testsThe suite covers, among other things:
- exact agreement with brute-force O(N²) counting (periodic box and survey-like data; iso/(s, mu)/(s_perp, pi); linear, logarithmic and irregular bins; unit and random weights; k-d and ball trees);
- cross-validation against pycorr/Corrfunc on identical catalogues (pair counts, natural and Landy-Szalay estimators, multipoles, w_p, isotropic mode, state conversion + save/load + rebinning);
- API robustness: input validation and conversion, error propagation, immutability of caller arrays, determinism, dtype handling;
- runtime hygiene: GIL release during counting and absence of gross memory leaks on success and failure paths.
Tests that need pycorr/Corrfunc are skipped automatically when those are not installed.
examples/README.md contains the guided tour of the examples, a
feature-coverage matrix and the benchmarking guide. In short:
| script | content | figure |
|---|---|---|
examples/example_features_tour.py |
start here: every feature in one commented script (result dict, binning schemes, weights, conf files, trees, dtypes, cross-pairs, integrations, sky, errors, verbose) | — |
examples/example_validation_bruteforce.py |
pyfcfc vs brute-force counting, all binning schemes | figures/validation_bruteforce.png |
examples/example_box_vs_pycorr.py |
periodic box: xi(s), xi_0/2/4, the 2D xi(s_perp, pi), w_p, natural & LS estimators, pyfcfc vs pycorr + timings | figures/box_vs_pycorr.png, figures/box_vs_pycorr_natural.png, figures/box_vs_pycorr_2d.png |
examples/example_survey_vs_pycorr.py |
survey-like data from {RA, Dec, z}: multipoles and w_p vs pycorr (midpoint LOS) | figures/survey_vs_pycorr.png |
examples/example_pycorr_interop.py |
split-random accumulation → pycorr state → save/load/rebin | figures/pycorr_interop.png |
examples/example_benchmark.py |
performance study vs pycorr: sizes, binning schemes, thread scaling, SIMD reporting | figures/benchmark.png, figures/benchmark_results.csv |
Run them all with make examples (or individually with
python examples/example_*.py; matplotlib and, for the pycorr ones,
pycorr + Corrfunc are required).
Interactive versions are provided as executed Jupyter notebooks in
notebooks/:
| notebook | content |
|---|---|
01_quickstart_box.ipynb |
result dictionary, xi(s), multipoles, w_p, conventions |
02_survey_and_pycorr.ipynb |
survey-like data, midpoint LOS, pycorr cross-check, state save/load/rebin |
03_benchmarking.ipynb |
how to benchmark properly (sizes, threads, SIMD) with a small in-notebook run |
04_lsstypes.ipynb |
conversion to lsstypes containers: counts, estimators, poles, replica covariance, serialisation |
Typical output of the box example (40k data / 80k randoms, 2 threads):
max |pyfcfc - pycorr|:
xi(s) : 6.5e-13
multipoles, exact per-bin Legendre integration: xi_0 1.8e-13, xi_2 9.1e-13, xi_4 1.2e-12
wp : 1.6e-11
multipole(andwp) require acfestimator: multipoles are integrated from the estimator's xi(s, mu), as in FCFC. If you only need pair counts but still want the correct binning, pass a trivial estimator such ascf=['DD'].- For
bin=1, passnmu ≥ 2; pycorr comparisons need linear mu bins.nmuis ignored (clamped to 1) forbin=0andbin=2, andpedgesis ignored unlessbin=2. - The
s-mode and multipole results are deterministic for unit weights (integer counting); with non-trivial weights, repeated runs can differ at the ~1e-15 relative level due to OpenMP reduction ordering. - Empty (s, mu) cells produce NaN in
cfwhen the random-random counts vanish there; useignore_nan=Truein the integration helpers or mask them. - Catalogue labels must be unique uppercase letters (at most 26 catalogues).
- The vendored
FCFC-main/etc/*.conffiles are the upstream CLI templates: several of their entries (CATALOG,POSITION, ...) do not apply to library mode (the data come from Python), and theirCATALOG_LABELis empty, so passlabel=(andpair=,bin=, ...) explicitly or copy the template and fill them in. Unknown entries in a configuration file are reported by libcfg and otherwise ignored.
| path | content |
|---|---|
pyfcfc/ |
the Python package: boxes and sky Cython extensions, utils, optional external |
FCFC-main/ |
vendored FCFC C sources (upstream v1.0.1 with selected v1.1.0 fixes backported and the Python-binding modifications described below), plus the new pyfcfc_helpers.{c,h} accessor layer |
tests/ |
pytest suite (brute-force and pycorr cross-validation, API robustness, lsstypes) |
examples/ |
runnable scripts + figures/ outputs (see examples/README.md) |
notebooks/ |
executed Jupyter notebooks |
scripts/ |
setup_dev_env.sh environment bootstrap |
setup.py, pyproject.toml, Makefile |
build & packaging |
Version 0.2.0 is a substantial hardening of the original wrapper. The most important fixes:
Correctness bugs
- Input arrays were mutated in place.
cf_setuprescaled the separation/pi bin edges inside the caller's NumPy buffers (the C code aliased them), silently corrupting any array reused across calls. Bins are now deep-copied on entry. - Uninitialized variable in the auto-pair normalization. The hack that
switched to pycorr's
(Σw)² − Σw²normalization read an uninitialized accumulator (wt2sum), i.e. undefined behaviour. Replaced by the clean upstream FCFC v1.1.0 implementation (aw2field computed when the trees are built). weighted_numberreported the number of objects, not the sum of weights, breaking normalizations and the pycorr state conversion for weighted catalogues.w_pused rescaled pi bins (upstream FCFC bug fixed in v1.1.0, backported):dpiis now taken from the unrescaled bins.- The multipole orders (
poles) and catalogue labels were read from freed memory (the configuration structure is destroyed before the results are extracted on the Python side). Both are deep-copied now. - Dangling pointers in the command-line argument array: the bytes
objects backing
argvwere freed when the helper function returned, before the C parser ran. They are now kept alive for the duration of the call (andargvis freed afterwards). - Memory leaks / ownership hazards: the initial
cf_init()result was leaked on every call; input catalogues leaked on configuration errors; the survey-like weight-buffer branch could leak/overwrite buffers. Ownership of the input data is now explicit and covered by tests. - Linear-bin detection compared only the first and last bin widths
with
==(and mutated the configuration structure); it is now a tolerant check over all widths, and nonlinear bins with coincidental first/last widths can no longer be misclassified. - Backported upstream v1.1.0 tree fix (
gather_nodesdropping shallow leaves), which can lose pairs in some tree shapes. - SIMD padding: with SIMD enabled, FCFC's kernels read a few elements past the end of the coordinate/weight arrays; the Python-provided buffers were unpadded (potential out-of-bounds reads). They are now padded exactly like FCFC's own input routines.
pairs_to_pycorr: therppiconversion used one-sided pi edges without the mirror+halve transformation (wrong normalization in pycorr), the isotropic mode halved counts that should not be halved,isostates crashed on 1-D bin arrays, andlos_typewas recorded asfirstpointalthough FCFC uses the midpoint convention. All fixed, and validated against native pycorr counts.
Robustness and usability
- The Cython layer no longer duplicates the C structure layouts (a
silent-corruption hazard): all access goes through an accessor layer
(
pyfcfc_helpers.c) that treatsCF/DATAas opaque. - Full input validation with informative Python exceptions: shapes, dtypes, monotonic/finite/non-negative bin edges, label consistency, unknown configuration keywords (with typo suggestions).
- Python booleans, lists, tuples and NumPy arrays are accepted as
keyword values;
weightis set automatically so that unweighted catalogues use the exact integer counting path. - The GIL is released during pair counting; the library is quiet by
default (
verbose=Truerestores FCFC's progress output). - Result arrays are plain contiguous NumPy arrays with deterministic
shapes (
cfis no longer squeezed), andpolesis returned. - Modern packaging:
pyproject.toml, nonumpy.compat/distutilsimports (works with NumPy 2 and Cython 3), no hardcoded system paths, optional SIMD/march/LTO flags, stale generated C sources removed. - Dead code from the CLI era was pruned:
read_bins(file-based bin edges), theusage/version/conf_templatecallbacks with their stale texts, the commented-outconf_printblocks, and the FITS/HDF5 catalogue readers (excluded from the build);read_ascii.cstays because it backscmvdst_file. - Pair counts read back from an existing
PAIR_COUNT_FILEnow get their catalogue indices resolved like computed ones; previously their labels were left unset (out-of-bounds label reads in the original port), so the results dictionary could not name them correctly. - The number of mu bins is now forced to 1 outside the (s, mu) scheme
(upstream enforced this in the configuration parser, which the Python
binding bypasses): passing
nmu > 1withbin=0used to overflow the analytic-RR buffer whenever an estimator with@@was requested, corrupting memory;piedges are likewise ignored unlessbin=2. - Pair counts requested via
pairare always evaluated and returned, even when no CF estimator references them (the estimator only controls the optionalcf/multipoles/projectedproducts); a defensive check now raises if FCFC ever returns an unevaluated pair. - Repository hygiene: the legacy
test/scripts and artefacts of the original port (NERSC-dependent scripts, stale pair-count/.pkl.npy/.pngoutputs,command_line_help.txt, duplicated*_original.conffiles) and thecython_debug/andmain.zipleftovers were removed; their functionality is superseded bytests/,examples/andnotebooks/.
FCFC is written by Cheng Zhao and distributed under the MIT licence
(see LICENSE.txt); this wrapper inherits it. If you use FCFC in
research, please cite the FCFC paper (Zhao et al.). pycorr is developed
by the DESI collaboration (cosmodesi/pycorr).