diff --git a/config/columns/shapepipe_v2.yaml b/config/columns/shapepipe_v2.yaml index 7d9c74ecb..0bae5465f 100644 --- a/config/columns/shapepipe_v2.yaml +++ b/config/columns/shapepipe_v2.yaml @@ -110,6 +110,17 @@ star: DEC: Declination [deg]. HSM_G1_PSF: PSF model ellipticity g1 (HSM). HSM_G2_PSF: PSF model ellipticity g2 (HSM). + HSM_G1_STAR: Stellar ellipticity g1 (HSM). + HSM_G2_STAR: Stellar ellipticity g2 (HSM). + HSM_T_PSF: PSF model size T (HSM). + HSM_T_STAR: Stellar size T (HSM). + HSM_FLAG_PSF: HSM PSF measurement flag. + HSM_FLAG_STAR: HSM stellar measurement flag. + HSM_M4_1_PSF: PSF model fourth-moment component 1. + HSM_M4_2_PSF: PSF model fourth-moment component 2. + HSM_M4_1_STAR: Stellar fourth-moment component 1. + HSM_M4_2_STAR: Stellar fourth-moment component 2. + MAG: Star magnitude. shear: about: >- diff --git a/docs/validation/catalogue-paper/additive_bias.png b/docs/validation/catalogue-paper/additive_bias.png new file mode 100644 index 000000000..d8433cd9f Binary files /dev/null and b/docs/validation/catalogue-paper/additive_bias.png differ diff --git a/docs/validation/catalogue-paper/object_leakage_bins.png b/docs/validation/catalogue-paper/object_leakage_bins.png new file mode 100644 index 000000000..3ad3d0fc0 Binary files /dev/null and b/docs/validation/catalogue-paper/object_leakage_bins.png differ diff --git a/docs/validation/catalogue-paper/psf_alpha_mag.png b/docs/validation/catalogue-paper/psf_alpha_mag.png new file mode 100644 index 000000000..acd72a0d4 Binary files /dev/null and b/docs/validation/catalogue-paper/psf_alpha_mag.png differ diff --git a/docs/validation/catalogue-paper/psf_xi_sys_comparison.png b/docs/validation/catalogue-paper/psf_xi_sys_comparison.png new file mode 100644 index 000000000..51e346e63 Binary files /dev/null and b/docs/validation/catalogue-paper/psf_xi_sys_comparison.png differ diff --git a/docs/validation/catalogue-paper/response_histogram.png b/docs/validation/catalogue-paper/response_histogram.png new file mode 100644 index 000000000..cb8dbe426 Binary files /dev/null and b/docs/validation/catalogue-paper/response_histogram.png differ diff --git a/docs/validation/catalogue-paper/selection_hist.png b/docs/validation/catalogue-paper/selection_hist.png new file mode 100644 index 000000000..f18846264 Binary files /dev/null and b/docs/validation/catalogue-paper/selection_hist.png differ diff --git a/docs/validation/catalogue-paper/sky_magnitude.png b/docs/validation/catalogue-paper/sky_magnitude.png new file mode 100644 index 000000000..237c5f14a Binary files /dev/null and b/docs/validation/catalogue-paper/sky_magnitude.png differ diff --git a/docs/validation/catalogue-paper/sky_stars.png b/docs/validation/catalogue-paper/sky_stars.png new file mode 100644 index 000000000..da876812a Binary files /dev/null and b/docs/validation/catalogue-paper/sky_stars.png differ diff --git a/docs/validation/catalogue-paper/star_residuals.png b/docs/validation/catalogue-paper/star_residuals.png new file mode 100644 index 000000000..02df2d27c Binary files /dev/null and b/docs/validation/catalogue-paper/star_residuals.png differ diff --git a/docs/validation/catalogue-paper/tan_gaia.png b/docs/validation/catalogue-paper/tan_gaia.png new file mode 100644 index 000000000..f48c2be90 Binary files /dev/null and b/docs/validation/catalogue-paper/tan_gaia.png differ diff --git a/docs/validation/catalogue-paper/tan_lambda.png b/docs/validation/catalogue-paper/tan_lambda.png new file mode 100644 index 000000000..22b3cc1fb Binary files /dev/null and b/docs/validation/catalogue-paper/tan_lambda.png differ diff --git a/docs/validation/catalogue-paper/tan_psf_stars.png b/docs/validation/catalogue-paper/tan_psf_stars.png new file mode 100644 index 000000000..9e4c5f782 Binary files /dev/null and b/docs/validation/catalogue-paper/tan_psf_stars.png differ diff --git a/docs/validation/catalogue-paper/tan_tiles.png b/docs/validation/catalogue-paper/tan_tiles.png new file mode 100644 index 000000000..52fdbdb7f Binary files /dev/null and b/docs/validation/catalogue-paper/tan_tiles.png differ diff --git a/docs/validation/catalogue-paper/weights.png b/docs/validation/catalogue-paper/weights.png new file mode 100644 index 000000000..047b0a85f Binary files /dev/null and b/docs/validation/catalogue-paper/weights.png differ diff --git a/scripts/dashboard/build_correlations.sbatch b/scripts/dashboard/build_correlations.sbatch new file mode 100644 index 000000000..4b6c805f9 --- /dev/null +++ b/scripts/dashboard/build_correlations.sbatch @@ -0,0 +1,51 @@ +#!/bin/bash +#SBATCH --job-name=sp-correlations +#SBATCH --account=def-sfabbro +#SBATCH --time=01:30:00 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=128G +#SBATCH --output=%x-%j.log +# +# Catalogue-paper position-shear and alpha(theta) products +# (sp_validation.dashboard.correlations), one measurement per step so each +# process holds only what it needs. +# +# Measured on the #425 snapshot (10,094 tiles, 73.0M galaxies, 5.76M valid-HSM +# star detections), 8 cores; see MEASURED below. Pass the variables with +# --export, e.g. +# sbatch --export=ALL,CATALOGUE_DIR=.../run-r425/catalogue,VERSION=SP_v2.0,\ +# OUT_DIR=.../catpaper/products,GAIA=.../gaia_dr3_G20_ruwe1p4_unions20k.fits \ +# scripts/dashboard/build_correlations.sbatch +# Stage Gaia beforehand on a login node (compute nodes may lack internet): +# uv run scripts/dashboard/stage_gaia.py --tiles --out +# +# Measured at 10k tiles (#425, 8 cores; wall time incl. a 45 s catalogue read +# per step; peak RSS of the Python process): +# stars + tiles 4 min 38 GB (4.77M unique stars as lenses; tiles 6 s) +# lambda 2.5 min 15 GB (7.3M galaxy lenses, 7.3M randoms) +# alpha 7 min 44 GB (rho0 + tau0 for all and 3 magnitude bins) +# gaia 6 min 39 GB (7.23M Gaia stars, 7.3M randoms) +# total ~20 min +# Extrapolated to 20.5k tiles (~2x galaxies, stars, Gaia stars and randoms; +# TreeCorr NG/GG time scales roughly with the lens and source counts): +# ~40-50 min and ~75-90 GB peak (alpha, stars), hence --mem=128G for headroom and 1.5 h. + +set -euo pipefail +: "${CATALOGUE_DIR:?}" "${VERSION:?}" "${OUT_DIR:?}" +SIF=${SIF:-/project/6001537/cdaley/containers/sp_validation-ddd58fe0.sif} +SRC=${SRC:-$SLURM_SUBMIT_DIR/src} # submit from the repository root +module load apptainer + +run() { + apptainer exec --bind /scratch,/project,/home --env PYTHONPATH="$SRC" "$SIF" \ + python -m sp_validation.dashboard.correlations \ + --catalogue-dir "$CATALOGUE_DIR" --version "$VERSION" --out-dir "$OUT_DIR" \ + --threads "$SLURM_CPUS_PER_TASK" "$@" +} + +run --only stars tiles +run --only lambda +run --only alpha +if [ -n "${GAIA:-}" ]; then + run --only gaia --gaia "$GAIA" +fi diff --git a/scripts/dashboard/build_dashboard.sh b/scripts/dashboard/build_dashboard.sh new file mode 100755 index 000000000..ee1430460 --- /dev/null +++ b/scripts/dashboard/build_dashboard.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Build every cosmo_val dashboard figure for one catalogue snapshot through Slurm. +# +# Submits the catalogue-level products (build_products.sbatch), the +# position-shear products (build_correlations.sbatch) and the COSEBIs +# scale-cut PTE grid (from the run's ξ± and covariance) in parallel, then a +# render job that runs once all succeed. A grid already in OUT_DIR/products is +# kept. With --after, all of it waits for that job +# (e.g. the catalogue extraction) to finish successfully. +# +# Every cosmological product is checked against the blind its version is +# declared under in the run's catalogue config: --cat-config, else RUN_DIR's +# cat_config.yaml. With neither, the script refuses to start. +# +# Usage, from the repository root on a login node: +# scripts/dashboard/build_dashboard.sh CATALOGUE_DIR RUN_DIR VERSION OUT_DIR \ +# [--gaia GAIA_FITS] [--after JOBID] [--cat-config CAT_CONFIG] +# Products go to OUT_DIR/products, figures and figures.json to OUT_DIR/figures. +# ACCOUNT (default def-sfabbro) and SPV_IMAGE (default the pinned SIF) are read +# from the environment. +set -euo pipefail +catdir=$(realpath -m "${1:?catalogue directory}") +rundir=$(realpath -m "${2:?cosmo_val run directory}") +version=${3:?version} +out=$(realpath -m "${4:?output directory}") +shift 4 +gaia="" +after="" +catconfig="" +while [ $# -gt 0 ]; do + case $1 in + --gaia) gaia=$(realpath "$2"); shift 2 ;; + --after) after=$2; shift 2 ;; + --cat-config) catconfig=$(realpath "$2"); shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +root=$(git rev-parse --show-toplevel) +cd "$root" +catconfig=${catconfig:-$rundir/cat_config.yaml} +[ -e "$catconfig" ] || { + echo "no catalogue config at $catconfig: pass --cat-config with the config the run declared its blinds in" >&2 + exit 2 +} +echo "blinds declared by $catconfig" +account=${ACCOUNT:-def-sfabbro} +export SPV_IMAGE=${SPV_IMAGE:-/project/6001537/cdaley/containers/sp_validation-ddd58fe0.sif} +mkdir -p "$out/products" "$out/figures" "$out/logs" +dep=() +[ -n "$after" ] && dep=(--dependency="afterok:$after") + +products=$(sbatch --parsable --account="$account" "${dep[@]}" \ + --output="$out/logs/%x-%j.log" --export=ALL \ + scripts/dashboard/build_products.sbatch "$catdir" "$version" "$out/products") +correlations=$(sbatch --parsable --account="$account" "${dep[@]}" \ + --output="$out/logs/%x-%j.log" \ + --export=ALL,CATALOGUE_DIR="$catdir",VERSION="$version",OUT_DIR="$out/products",GAIA="$gaia",SIF="$SPV_IMAGE",SRC="$root/src" \ + scripts/dashboard/build_correlations.sbatch) +grid="$out/products/cosebis_grid_$version.npz" +after_grid="" +if [ ! -e "$grid" ]; then + # #425 full grid, 210 cuts: see the cosebis-grid log for timing. + cosebis=$(sbatch --parsable --account="$account" --job-name=dashboard-cosebis-grid \ + --time=01:30:00 --cpus-per-task=16 --mem=32G \ + --output="$out/logs/%x-%j.log" \ + --wrap="module load apptainer && apptainer exec --bind /scratch,/project,/home \ +--env PYTHONPATH=$root/src $SPV_IMAGE python -m sp_validation.dashboard.cosebis_grid \ +--run-dir $rundir --version $version --cat-config $catconfig --out $grid") + after_grid=":$cosebis" +fi +render=$(sbatch --parsable --account="$account" --job-name=dashboard-render \ + --dependency="afterok:$products:$correlations$after_grid" \ + --time=00:30:00 --cpus-per-task=2 --mem=8G \ + --output="$out/logs/%x-%j.log" \ + --wrap="module load apptainer && apptainer exec --bind /scratch,/project,/home \ +--env PYTHONPATH=$root/src $SPV_IMAGE python -m sp_validation.dashboard.render \ +--run-dir $rundir --version $version --cat-config $catconfig --maps-dir $out/products \ +--correlations-dir $out/products --out $out/figures") +echo "products $products correlations $correlations cosebis-grid ${cosebis:-kept} render $render" diff --git a/scripts/dashboard/build_products.sbatch b/scripts/dashboard/build_products.sbatch new file mode 100644 index 000000000..58d4c91b1 --- /dev/null +++ b/scripts/dashboard/build_products.sbatch @@ -0,0 +1,38 @@ +#!/bin/bash +#SBATCH --job-name=dashboard-products +#SBATCH --cpus-per-task=4 +#SBATCH --mem=48G +#SBATCH --time=04:00:00 + +# Usage: sbatch build_products.sbatch CATALOGUE_DIR VERSION PRODUCT_DIR +# SPV_IMAGE must name the sp_validation Apptainer image. Run from the checkout. +# #425, 10,094 tiles: galaxy + star pass 3m42s, 7.45 GiB peak RSS +# (Slurm step 25103890.71, 4 CPUs). Linear 20,500-tile extrapolation: +# ~7m31s, ~15.1 GiB; quantile buffers scale with galaxy count. +# Comprehensive pass: 59m15s, 2.99 GiB peak RSS (step 25103890.23, +# 2 CPUs); linear 20,500-tile extrapolation ~2h00m20s, with streaming +# memory approximately constant. Wall time is sensitive to filesystem I/O. +set -euo pipefail +catdir=$(realpath "${1:?catalogue directory}") +version=${2:?version} +out=$(realpath -m "${3:?product directory}") +root=$(git rev-parse --show-toplevel) +: "${SPV_IMAGE:?set SPV_IMAGE to the container image}" +module load apptainer +mkdir -p "$out" +stage=$(mktemp -d "$out/.build.XXXXXXXX") +trap 'rm -rf "$stage"' EXIT +run() { + apptainer exec --bind /scratch,/project,/home \ + --env "PYTHONPATH=$root/src" "$SPV_IMAGE" python "$@" +} +galaxies="$catdir/shape_catalog_cut_ngmix.fits" +expected=$(run -c 'import fitsio,sys; print(fitsio.FITS(sys.argv[1])[1].get_nrows())' "$galaxies") +run -m sp_validation.dashboard.field_maps \ + --galaxies "$galaxies" --stars "$catdir/stars.hdf5" \ + --version "$version" --out-dir "$stage" +run -m sp_validation.dashboard.selection_hist \ + --catalogue "$catdir/shape_catalog_comprehensive_ngmix.hdf5" \ + --config "$catdir/config_mask.yaml" --expected "$expected" --version "$version" \ + --out "$stage/selection_hist_${version}.npz" +mv "$stage"/*.npz "$out/" diff --git a/scripts/dashboard/stage_gaia.py b/scripts/dashboard/stage_gaia.py new file mode 100644 index 000000000..6fcdc59ad --- /dev/null +++ b/scripts/dashboard/stage_gaia.py @@ -0,0 +1,149 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyvo", "astropy", "healpy", "numpy"] +# /// +"""Stage Gaia DR3 stars (G < 20, RUWE < 1.4) over the UNIONS tiles. + +Runs on a login node (the query executes on the TAP server); compute nodes +read the staged FITS. The region is every HEALPix level-7 (nside 128, +nested) pixel touched by a tile, from one or more tile lists: a text file of +``XXX.YYY`` names or a JSON manifest whose ``tiles`` mapping is keyed by name. +Gaia ``source_id`` encodes the level-12 nested pixel in its high bits, so each +level-7 pixel is a contiguous ``source_id`` range and the query is a union of +indexed range scans, run synchronously in chunks. Each chunk is cached in ``.parts/`` so a rerun resumes, and removed once the table is written. Writes ``.fits`` and ``.json`` (ADQL template, +pixel list digest, row count). + +Usage:: + + uv run scripts/dashboard/stage_gaia.py --tiles tiles.txt MANIFEST.json \\ + --out catpaper/aux/gaia_dr3_G20_ruwe1p4_unions20k +""" + +import argparse +import hashlib +import json +import shutil +import time +from pathlib import Path + +import healpy as hp +import numpy as np +import pyvo +from astropy.table import Table, vstack + +TAP = "https://dc.zah.uni-heidelberg.de/tap" +TABLE = "gaia.dr3lite" +LEVEL = 7 +NSIDE = 2**LEVEL +SOURCE_ID_PER_PIXEL = 2**35 * 4 ** (12 - LEVEL) +TILE_HALF_DIAGONAL_DEG = 0.37 # 10000 px x 0.187"/px tiles, half-diagonal +ADQL = ( + "SELECT source_id, ra, dec, phot_g_mean_mag, ruwe FROM {table} " + "WHERE phot_g_mean_mag < 20 AND ruwe < 1.4 AND ({ranges})" +) + + +def read_tiles(path): + path = Path(path) + if path.suffix == ".json": + return list(json.loads(path.read_text())["tiles"]) + return [line.strip() for line in path.read_text().split() if line.strip()] + + +def tile_centre(name): + """CFIS tile centre (cs_util ``cfis.get_tile_coord_from_nixy``).""" + xi, yi = (int(part) for part in name.replace("-", ".").split(".")) + dec = yi / 2 - 90 + return xi / 2 / np.cos(np.deg2rad(dec)), dec + + +def tile_pixels(names): + pixels = set() + radius = np.deg2rad(TILE_HALF_DIAGONAL_DEG) + for name in names: + ra, dec = tile_centre(name) + vec = hp.ang2vec(ra, dec, lonlat=True) + pixels.update(hp.query_disc(NSIDE, vec, radius, inclusive=True, nest=True)) + return np.array(sorted(pixels)) + + +def ranges(pixels): + """Contiguous pixel runs as (first, last) pairs.""" + breaks = np.flatnonzero(np.diff(pixels) != 1) + starts = np.r_[pixels[0], pixels[breaks + 1]] + ends = np.r_[pixels[breaks], pixels[-1]] + return list(zip(starts, ends)) + + +def where(runs): + return " OR ".join( + f"source_id BETWEEN {a * SOURCE_ID_PER_PIXEL} " + f"AND {(b + 1) * SOURCE_ID_PER_PIXEL - 1}" + for a, b in runs + ) + + +def fetch(service, pixels, attempts=4): + """Rows for ``pixels``; a chunk that keeps failing is fetched in halves.""" + query = ADQL.format(table=TABLE, ranges=where(ranges(pixels))) + for attempt in range(attempts): + try: + return service.run_sync(query, maxrec=16_000_000).to_table() + except (pyvo.dal.DALServiceError, pyvo.dal.DALQueryError) as error: + print(f"retry {attempt + 1} ({len(pixels)} pixels): {error}", flush=True) + time.sleep(30) + if len(pixels) == 1: + raise RuntimeError(f"pixel {pixels[0]} failed after {attempts} attempts") + half = len(pixels) // 2 + return vstack([fetch(service, pixels[:half]), fetch(service, pixels[half:])]) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--tiles", nargs="+", required=True) + parser.add_argument("--out", required=True, help="output path without suffix") + parser.add_argument("--pixels-per-query", type=int, default=300) + args = parser.parse_args(argv) + + names = sorted({n for path in args.tiles for n in read_tiles(path)}) + pixels = tile_pixels(names) + print(f"{len(names)} tiles -> {len(pixels)} level-{LEVEL} pixels", flush=True) + service = pyvo.dal.TAPService(TAP) + out = Path(args.out) + parts_dir = out.parent / f"{out.name}.parts" + parts_dir.mkdir(parents=True, exist_ok=True) + parts = [] + for start in range(0, len(pixels), args.pixels_per_query): + chunk = pixels[start : start + args.pixels_per_query] + part = parts_dir / f"{chunk[0]:06d}-{chunk[-1]:06d}.fits" + if not part.exists(): + rows = fetch(service, chunk) + rows.meta.clear() + rows.write(part) + table = Table.read(part) + print(f"pixels {start}-{start + len(chunk)}: {len(table)} rows", flush=True) + parts.append(table) + table = vstack(parts) + table.meta.clear() + out.parent.mkdir(parents=True, exist_ok=True) + table.write(out.with_suffix(".fits"), overwrite=True) + record = { + "service": TAP, + "table": TABLE, + "adql_template": ADQL.format(table=TABLE, ranges=""), + "healpix_level": LEVEL, + "nest": True, + "n_tiles": len(names), + "tile_lists": [str(Path(p).resolve()) for p in args.tiles], + "n_pixels": int(len(pixels)), + "pixels_sha256": hashlib.sha256(pixels.astype(" start_bin and start_bin < nbins and stop_bin > 0: - # Add cross-hatching at the fiducial scale cut matrix element - rect_x = start_bin - rect_y = stop_bin - 1 + if start_bin in starts and stop_bin in stops: + # Mark the cached selected cut. + rect_x = starts.index(start_bin) + rect_y = stops.index(stop_bin) rect_width = 1 rect_height = 1 @@ -1025,15 +1021,10 @@ def plot_cosebis_scale_cut_heatmap( ) # Set angular scale ticks - tick_indices = np.arange(0, nbins) - x_tick_labels = [ - f"{left_edges[i]:.1f}" for i in tick_indices if i < len(left_edges) - ] - y_tick_labels = [ - f"{right_edges[i]:.1f}" for i in tick_indices if i < len(right_edges) - ] - x_tick_positions = tick_indices + 0.5 - y_tick_positions = tick_indices + 0.5 + x_tick_labels = [f"{left_edges[i]:.2f}" for i in starts] + y_tick_labels = [f"{right_edges[i - 1]:.2f}" for i in stops] + x_tick_positions = np.arange(nx) + 0.5 + y_tick_positions = np.arange(ny) + 0.5 for ax in axs: ax.set_xticks(x_tick_positions[: len(x_tick_labels)]) @@ -1283,7 +1274,10 @@ def plot_cosebis_modes(results, version, output_path, fiducial_scale_cut=None): plt.legend() plt.xlabel("n (mode)") plt.ylabel("E_n, B_n") - plt.ylim(-0.5e-10, 3e-10) + errors = np.sqrt(np.diag(results["cov"])) + values = np.concatenate([results["En"], results["Bn"]]) + limit = 1.1 * np.nanmax(np.abs(values) + errors) + plt.ylim(-limit, limit) # Add scale cut information to title - use actual scale cut from results scale_info = "" @@ -1299,7 +1293,7 @@ def plot_cosebis_modes(results, version, output_path, fiducial_scale_cut=None): plt.savefig(output_path, dpi=300, bbox_inches="tight") -def save_pure_eb_results(results, output_path): +def save_pure_eb_results(results, output_path, *, derived_from): """ Save pure E/B mode data vectors and covariance to .npz. @@ -1310,12 +1304,13 @@ def save_pure_eb_results(results, output_path): output_path : str Output .npz file path """ - # Data vectors and covariance + # Include the sealed fine/reporting vectors and variances used by the + # comparison figures, not just the transformed modes. save_dict = { - key: results[key] for key in ("theta", "left_edges", "right_edges", "cov") + key: value + for key, value in results.items() + if key not in ("pte_matrices", "npatch") and value is not None } - for key in _EB_KEYS: - save_dict[key] = results[key] # PTE matrices for key, matrix in results.get("pte_matrices", {}).items(): @@ -1325,10 +1320,48 @@ def save_pure_eb_results(results, output_path): if results["npatch"] is not None: save_dict["npatch"] = np.array(results["npatch"]) - np.savez(output_path, **save_dict) + from . import sacc_io + + sacc_io.save_npz(save_dict, output_path, derived_from=derived_from) print(f"Saved pure E/B results to {output_path}") +def load_pure_eb_results(path, *, blind): + """Read a sealed plot cache only after checking the expected blind.""" + from . import sacc_io + + data = sacc_io.load_npz(path, blind=blind) + results = {k: v for k, v in data.items() if not k.startswith("pte_matrices_")} + results["pte_matrices"] = { + k.removeprefix("pte_matrices_"): v + for k, v in data.items() + if k.startswith("pte_matrices_") + } + results["npatch"] = int(results["npatch"]) if "npatch" in results else None + return results + + +def load_cosebis_results(path, *, blind): + """Read cached scale cuts after checking their inherited blind stamp.""" + from . import sacc_io + + data = sacc_io.load_npz(path, blind=blind) + results = {} + for lo, hi in data["scale_cuts"]: + # Match the writer's scalar formatting (including integer cuts). + suffix = next( + k.removeprefix("En") + for k in data + if k.startswith("En_") and tuple(map(float, k[3:].split("_"))) == (lo, hi) + ) + results[lo, hi] = { + k: data[k + suffix] + for k in ("En", "Bn", "cov", "chi2_E", "chi2_B", "pte_B", "bin_indices") + } + edges = data["left_edges"], data["right_edges"] + return results, edges + + def _cosebis_result_to_dict(r, suffix=""): """Build save dict entries from a single COSEBIs result.""" return { @@ -1338,10 +1371,13 @@ def _cosebis_result_to_dict(r, suffix=""): f"chi2_E{suffix}": np.array(r["chi2_E"]), f"chi2_B{suffix}": np.array(r["chi2_B"]), f"pte_B{suffix}": np.array(r["pte_B"]), + f"bin_indices{suffix}": np.asarray(r["bin_indices"]), } -def save_cosebis_results(results, output_path, fiducial_scale_cut=None): +def save_cosebis_results( + results, output_path, fiducial_scale_cut=None, edges=None, *, derived_from +): """ Save COSEBIs data vectors and covariance to .npz. @@ -1353,16 +1389,22 @@ def save_cosebis_results(results, output_path, fiducial_scale_cut=None): output_path : str Output .npz file path fiducial_scale_cut : tuple, optional - If results has multiple scale cuts, save only this one. - If None and results has multiple scale cuts, saves all. + If given, also save untagged entries for this fiducial cut (used by + summary consumers). All scanned cuts are always retained. + edges : tuple of arrays, optional + Integration-bin left/right edges used by the scale-cut heatmap. """ is_multi = all(isinstance(k, tuple) for k in results) if is_multi and fiducial_scale_cut is not None: - # Select the single best-matching scale cut + # Keep the untagged fiducial entries for summary consumers, and every + # scanned cut for the plot-only heatmap. key = find_conservative_scale_cut_key(results, fiducial_scale_cut) save_dict = _cosebis_result_to_dict(results[key]) save_dict["scale_cut"] = np.array(key) + for sc, r in results.items(): + save_dict.update(_cosebis_result_to_dict(r, f"_{sc[0]}_{sc[1]}")) + save_dict["scale_cuts"] = np.array(list(results.keys())) elif is_multi: # Save all scale cuts with tagged keys save_dict = {} @@ -1375,7 +1417,11 @@ def save_cosebis_results(results, output_path, fiducial_scale_cut=None): if "scale_cut" in results: save_dict["scale_cut"] = np.array(results["scale_cut"]) - np.savez(output_path, **save_dict) + if edges is not None: + save_dict["left_edges"], save_dict["right_edges"] = edges + from . import sacc_io + + sacc_io.save_npz(save_dict, output_path, derived_from=derived_from) print(f"Saved COSEBIs results to {output_path}") diff --git a/src/sp_validation/cosmo_val/catalog_characterization.py b/src/sp_validation/cosmo_val/catalog_characterization.py index e2df6d8e0..e6983f878 100644 --- a/src/sp_validation/cosmo_val/catalog_characterization.py +++ b/src/sp_validation/cosmo_val/catalog_characterization.py @@ -280,25 +280,32 @@ def calculate_ellipticity_dispersion(self, tomography=False): f"Ellipticity dispersion for tomo_bin_{tomo_bin_id} = {self._ellipticity_dispersion[ver][f'tomo_bin_{tomo_bin_id}']:.4f}" ) - def plot_footprints(self): - self.print_start("Plotting footprints:") - for ver in self.versions: - self.print_magenta(ver) - - fp = cs_plots.FootprintPlotter() + def footprint_maps(self): + """Build one sparse count map per version, shared by every region.""" + from .diagnostic_products import footprint_counts - for region in fp._regions: - out_path = self._output_path(f"footprint_{ver}_{region}.png") - if os.path.exists(out_path): - self.print_done(f"Skipping footprint plot, {out_path} exists") - else: - with self.results[ver].temporarily_read_data(): - hsp_map = fp.create_hsp_map( + for ver in self.versions: + with self.results[ver].temporarily_read_data(): + yield ( + ver, + footprint_counts( self.results[ver].dat_shear["RA"], self.results[ver].dat_shear["Dec"], - ) - fp.plot_region(hsp_map, fp._regions[region], outpath=out_path) - self.print_done("Footprint plot saved to " + out_path) + ), + ) + + def plot_footprints(self): + """Notebook convenience wrapper; workflow plots use persisted maps.""" + from .diagnostic_plots import plot_footprint + + for ver, count_map in self.footprint_maps(): + plot_footprint( + count_map, + { + region: self._output_path(f"footprint_{ver}_{region}.png") + for region in cs_plots.FootprintPlotter._regions + }, + ) def plot_ellipticity(self, nbins=200): out_path = self._output_path("ell_hist.png") @@ -349,37 +356,22 @@ def plot_ellipticity(self, nbins=200): cs_plots.show() self.print_done("Ellipticity histograms saved to " + out_path) - def plot_weights(self, nbins=200): - out_path = self._output_path("weight_hist.png") - if os.path.exists(out_path): - self.print_done(f"Skipping weight histograms, {out_path} exists") - else: - self.print_start("Computing weight histograms:") - - plt.figure(figsize=(10, 7)) - for ver in self.versions: - self.print_magenta(ver) - with self.results[ver].temporarily_read_data(): - w = self._read_shear_cols(ver, "w_col") + def weight_histograms(self, nbins=200): + """Return weight-weighted counts and edges, with independent bins per version.""" + histograms = {} + for ver in self.versions: + with self.results[ver].temporarily_read_data(): + w = self._read_shear_cols(ver, "w_col") + histograms[ver] = np.histogram(w, bins=nbins, weights=w) + return histograms - plt.hist( - w, - bins=nbins, - density=False, - histtype="step", - weights=w, - label=ver, - color=self.cc[ver]["colour"], - ) + def plot_weights(self, nbins=200): + """Notebook convenience wrapper; workflow plots read cached histograms.""" + from .diagnostic_plots import plot_weights - plt.xlabel("$w$") - plt.ylabel("frequency") - plt.yscale("log") - plt.legend() - # plt.xlim([-0.01, 1.2]) - cs_plots.savefig(out_path, close_fig=False) - cs_plots.show() - self.print_done("Weight histograms saved to " + out_path) + plot_weights( + self.weight_histograms(nbins), self.cc, self._output_path("weight_hist.png") + ) def plot_separation(self, nbins=200): self.print_start("Separation histograms") diff --git a/src/sp_validation/cosmo_val/cosebis.py b/src/sp_validation/cosmo_val/cosebis.py index bea1df164..efce2cfc0 100644 --- a/src/sp_validation/cosmo_val/cosebis.py +++ b/src/sp_validation/cosmo_val/cosebis.py @@ -297,19 +297,23 @@ def plot_cosebis( plot_results, version, var_method, bin_stub + "_covariance.png" ) + # Stored bin indices refer to the integration grid, including when + # the requested cuts were generated from a coarser reporting grid. + edges = log_bin_edges(min_sep_int, max_sep_int, nbins_int) if multiple_scale_cuts and len(bin_results) > 1: - binning = self._binning(min_sep, max_sep, nbins) plot_cosebis_scale_cut_heatmap( bin_results, - log_bin_edges( - binning["min_sep"], binning["max_sep"], binning["nbins"] - ), + edges, version, bin_stub + "_scalecut_ptes.png", fiducial_scale_cut=fiducial_scale_cut, ) save_cosebis_results( - bin_results, bin_stub + "_data.npz", fiducial_scale_cut + bin_results, + bin_stub + "_data.npz", + fiducial_scale_cut, + edges, + derived_from=[self.xi_parts[version, "integration"]], ) self._cosebis_results[version] = results_tomo diff --git a/src/sp_validation/cosmo_val/diagnostic_plots.py b/src/sp_validation/cosmo_val/diagnostic_plots.py new file mode 100644 index 000000000..6634c03a8 --- /dev/null +++ b/src/sp_validation/cosmo_val/diagnostic_plots.py @@ -0,0 +1,258 @@ +"""Catalogue-free renderers for cached validation diagnostics. + +Inputs are arrays, summary dictionaries or a sparse count map, never a +CosmologyValidation instance. No renderer skips an existing output. +""" + +import matplotlib.pyplot as plt +import numpy as np +from cs_util.plots import FootprintPlotter + + +def plot_footprint(count_map, outputs): + """Render every requested region from one cached HealSparse count map.""" + plotter = FootprintPlotter() + for region, path in outputs.items(): + plotter.plot_region( + count_map, + plotter._regions[region], + outpath=str(path), + colorbar_label=f"Galaxies per pixel (NSIDE={count_map.nside_sparse})", + ) + plt.close() + + +def plot_weights(histograms, styles, output): + """Draw weight-weighted bin sums; binning is fixed in the compute step.""" + fig, ax = plt.subplots(figsize=(10, 7)) + for ver, (counts, edges) in histograms.items(): + ax.stairs(counts, edges, label=ver, color=styles[ver]["colour"]) + ax.set(xlabel="$w$", ylabel="frequency", yscale="log") + ax.legend() + fig.savefig(output, bbox_inches="tight") + plt.close(fig) + + +def plot_objectwise_leakage(coefficients, styles, output): + """Draw cached regression and scale-dependent alpha summaries. + + Each coefficient is a (nominal value, standard deviation) pair. + """ + fig, ax = plt.subplots(figsize=(15, 15)) + summaries = { + "alpha_mean": (r"$\bar\alpha$", "-", "full"), + "alpha_1": (r"$\alpha(\theta_{\rm min})$", "--", "none"), + "alpha_0": (r"$\alpha(0)$", ":", "left"), + } + handles = [] + for ver, bins in coefficients.items(): + style = styles[ver] + for coeff in bins.values(): + x, dx = coeff["aii_mean"] + for key, (_, ls, fillstyle) in summaries.items(): + y, dy = coeff[key] + eb = ax.errorbar( + x, + y, + xerr=dx, + yerr=dy, + fmt=style["marker"], + color=style["colour"], + fillstyle=fillstyle, + ) + eb[-1][0].set_linestyle(ls) + handles.append( + plt.Line2D( + [], + [], + marker=style["marker"], + ls="", + color=style["colour"], + label=style.get("label", ver), + ) + ) + handles += [ + plt.Line2D( + [], + [], + marker="o", + ls=ls, + color="grey", + fillstyle=fs, + label=label, + ) + for label, ls, fs in summaries.values() + ] + ax.plot([-0.02, 0.02], [-0.02, 0.02], "k:", linewidth=0.5) + ax.legend(handles=handles) + ax.set( + xlabel=r"$\langle a_{ii} \rangle$ (object-wise)", + ylabel=r"$\alpha$ (scale-dependent, $\tau_0 / \rho_0$)", + ) + fig.savefig(output, bbox_inches="tight") + plt.close(fig) + + +def plot_rho_tau_fits(products, styles, contours, samples, summary): + """Render posterior contours and propagated PSF errors from cached arrays.""" + from shear_psf_leakage import plots as psf_plots + + versions = list(products) + psf_plots.plot_contours( + [products[v]["flat_samples"] for v in versions], + names=["x0", "x1", "x2"], + labels=[r"\alpha", r"\beta", r"\eta"], + savefig=str(contours), + legend_labels=versions, + legend_loc="upper right", + contour_colors=[styles[v]["colour"] for v in versions], + markers={"x0": 0, "x1": 1, "x2": 1}, + show=False, + close=True, + ) + for path, draw_samples in ((samples, True), (summary, False)): + fig, axes = plt.subplots(1, 2, figsize=(14, 6), layout="constrained") + for ver, data in products.items(): + theta, colour = data["theta"], styles[ver]["colour"] + for ax, component, sign in zip(axes, ("plus", "minus"), ("+", "-")): + if draw_samples: + ax.plot( + theta, + data[f"samples_{component}"][-100:].T, + color=colour, + alpha=0.3, + ) + else: + ax.plot(theta, data[f"mean_{component}"], color=colour, label=ver) + lo, hi = data[f"quantiles_{component}"] + ax.plot(theta, lo, color=colour) + ax.plot(theta, hi, color=colour) + ax.fill_between(theta, lo, hi, color=colour, alpha=0.3) + ax.set( + xscale="log", + yscale="linear", + xlabel=r"$\theta$ [arcmin]", + ylabel=rf"$\xi^{{\rm PSF,sys}}_{sign}(\theta)$", + ) + if not draw_samples: + axes[0].legend() + fig.savefig(path, bbox_inches="tight") + plt.close(fig) + + +def plot_ratio_xi_sys_xi( + products, correlations, styles, output, offset=0.1, threshold=0.1 +): + """Draw PSF/signal ratios directly from sealed reporting correlations. + + Ratios aren't persisted separately: the only cosmological input stays the + existing sealed SACC. Propagating cached variances is just presentation. + """ + fig, axes = plt.subplots(1, 2, figsize=(14, 6)) + for idx, (ver, data) in enumerate(products.items()): + gg = correlations[ver] + theta = gg.meanr * (1 + offset * (idx - (len(products) - 1) / 2)) + for ax, xi, var_xi, component, sign in zip( + axes, + (gg.xip, gg.xim), + (gg.varxip, gg.varxim), + ("plus", "minus"), + ("+", "-"), + ): + mean, var = data[f"mean_{component}"], data[f"var_{component}"] + ax.errorbar( + theta, + mean / xi, + yerr=np.sqrt(var / xi**2 + mean**2 * var_xi / xi**4), + fmt=styles[ver]["marker"], + color=styles[ver]["colour"], + markersize=3, + capsize=2, + label=ver, + ) + ax.set( + xscale="log", + xlabel=r"$\theta$ [arcmin]", + ylabel=rf"$\xi^{{\rm PSF,sys}}_{sign}/\xi_{sign}$", + ) + for ax in axes: + ax.axhspan(-threshold, threshold, color="black", alpha=0.1) + ax.legend() + fig.savefig(output, bbox_inches="tight") + plt.close(fig) + + +def plot_2pcf(correlations, styles, outputs): + """Draw sealed reporting xi±, both plain and theta-weighted.""" + for times_theta, output in enumerate(outputs): + fig, axes = plt.subplots(1, 2, figsize=(14, 6), layout="constrained") + for ver, gg in correlations.items(): + factor = gg.meanr if times_theta else 1 + for ax, xi, var, sign in zip( + axes, (gg.xip, gg.xim), (gg.varxip, gg.varxim), ("+", "-") + ): + ax.errorbar( + gg.meanr, + xi * factor, + yerr=np.sqrt(var) * factor, + fmt=styles[ver]["marker"], + color=styles[ver]["colour"], + label=ver, + ) + ax.set( + xscale="log", + xlabel=r"$\theta$ [arcmin]", + ylabel=(r"$\theta$ " if times_theta else "") + + rf"$\xi_{sign}(\theta)$", + ) + axes[0].legend() + fig.savefig(output, bbox_inches="tight") + plt.close(fig) + + +def plot_psf_stats(tables, styles, output, kind, covariances=()): + """Overlay cached rho/tau FITS columns, with optional tau covariance errors.""" + indices = (0, 1, 2, 3, 4, 5) if kind == "rho" else (0, 2, 5) + fig, axes = plt.subplots( + 2 if kind == "rho" else 1, + 3, + figsize=(15, 10 if kind == "rho" else 6), + squeeze=False, + layout="constrained", + ) + for i, stat in enumerate(indices): + ax = axes.flat[i] + for j, (ver, table) in enumerate(tables.items()): + theta = table["theta"] + key = f"{kind}_{stat}_p" + variance = table[f"var{key}"] + if covariances: + n = len(theta) + variance = np.diag(covariances[j])[i * n : (i + 1) * n] + ax.errorbar( + theta, + table[key], + yerr=np.sqrt(variance), + color=styles[ver]["colour"], + label=ver, + capsize=2, + ) + ax.set( + xscale="log", + xlabel=r"$\theta$ [arcmin]", + ylabel=rf"$\{kind}_{{{stat},+}}(\theta)$", + ) + ax.legend() + fig.savefig(output, bbox_inches="tight") + plt.close(fig) + + +def plot_covariance(matrix, output): + """Draw a correlation matrix from the cached analytic covariance.""" + from sp_validation.statistics import corr_from_cov + + fig, ax = plt.subplots(figsize=(9, 9)) + image = ax.imshow(corr_from_cov(matrix), vmin=-1, vmax=1, cmap="RdBu_r") + fig.colorbar(image, ax=ax, label="correlation") + fig.savefig(output, bbox_inches="tight") + plt.close(fig) diff --git a/src/sp_validation/cosmo_val/diagnostic_products.py b/src/sp_validation/cosmo_val/diagnostic_products.py new file mode 100644 index 000000000..a70fb7725 --- /dev/null +++ b/src/sp_validation/cosmo_val/diagnostic_products.py @@ -0,0 +1,45 @@ +"""Small non-cosmological diagnostic products shared by compute and plot steps.""" + +import healpy as hp +import healsparse +import numpy as np + + +def footprint_counts(ra, dec, nside_coverage=32, nside_sparse=2048): + """Bin coordinates once, using a finite HealSparse sentinel for valid pixels.""" + pixels, counts = np.unique( + hp.ang2pix(nside_sparse, ra, dec, lonlat=True, nest=True), return_counts=True + ) + count_map = healsparse.HealSparseMap.make_empty( + nside_coverage, nside_sparse, dtype=np.float32 + ) + count_map[pixels] = counts.astype(np.float32) + return count_map + + +def save_footprint(count_map, path): + """Store occupied nested pixels, counts and both HealSparse resolutions. + + A sparse NPZ avoids writing the plotter's NaN sentinel to a FITS header + (CFITSIO cannot encode it). No per-object coordinates are retained. + """ + pixels = count_map.valid_pixels + np.savez_compressed( + path, + pixels=pixels, + counts=count_map[pixels], + nside_coverage=count_map.nside_coverage, + nside_sparse=count_map.nside_sparse, + ) + + +def load_footprint(path): + """Reconstruct the sparse map from occupied pixels, without a catalogue.""" + with np.load(path, allow_pickle=False) as data: + count_map = healsparse.HealSparseMap.make_empty( + int(data["nside_coverage"]), + int(data["nside_sparse"]), + dtype=np.float32, + ) + count_map[data["pixels"]] = data["counts"] + return count_map diff --git a/src/sp_validation/cosmo_val/pseudo_cl.py b/src/sp_validation/cosmo_val/pseudo_cl.py index 361369ac4..6ddc3507d 100644 --- a/src/sp_validation/cosmo_val/pseudo_cl.py +++ b/src/sp_validation/cosmo_val/pseudo_cl.py @@ -51,7 +51,7 @@ def plot_pseudo_cl_spectrum(datasets, spectrum, output_path): ``datasets`` maps a version to ``{"ell", "cl", "cov", "style"}``, where ``style`` carries the ``marker`` and ``colour`` the version is drawn with. """ - fig, ax = plt.subplots(nrows=2, ncols=1, figsize=(8, 8)) + fig, ax = plt.subplots(nrows=2, ncols=1, figsize=(8, 8), layout="constrained") minor_ticks = [i * 10 for i in range(1, 10)] + [i * 100 for i in range(1, 21)] for panel, scaled in ((ax[0], True), (ax[1], False)): @@ -77,7 +77,15 @@ def plot_pseudo_cl_spectrum(datasets, spectrum, output_path): panel.xaxis.set_ticks(minor_ticks, minor=True) ax[1].set_xlabel(r"$\ell$") - ax[1].set_yscale("log") + # A signed scale preserves negative spectra and both ends of error bars. + amplitudes = np.concatenate([np.abs(d["cl"]) for d in datasets.values()]) + positive = amplitudes[np.isfinite(amplitudes) & (amplitudes > 0)] + threshold = np.median(positive) if positive.size else 1.0 + ax[1].set_yscale("symlog", linthresh=threshold) + ax[1].yaxis.get_major_locator().set_params(numticks=7) + ticks = ax[1].get_yticks() + ax[1].set_yticks([t for t in ticks if t == 0 or abs(t) >= 100 * threshold]) + ax[1].axhline(0, color="grey", linewidth=0.5) plt.suptitle(f"Pseudo-Cl {spectrum} (Gaussian covariance)") plt.legend() plt.savefig(output_path) diff --git a/src/sp_validation/cosmo_val/psf_systematics.py b/src/sp_validation/cosmo_val/psf_systematics.py index 991567fbb..ef0e004b6 100644 --- a/src/sp_validation/cosmo_val/psf_systematics.py +++ b/src/sp_validation/cosmo_val/psf_systematics.py @@ -303,7 +303,23 @@ def _objectwise_leakage_bin(self, results_obj, ver, tomo_bin_id, mix, order): else: self.print_cyan(f"Computing object-wise leakage regression, {suffix}") with results_obj.temporarily_read_data(selection=selection): - results_obj.PSF_leakage(mix=mix, order=order, suffix=suffix) + # Use the regression primitive, not PSF_leakage: that driver + # also fits unrelated 1D models and writes exploratory plots. + e, weights = results_obj.get_ellipticity_weights() + x = [ + results_obj._dat[results_obj._params[key]] + for key in ("e1_PSF_col", "e2_PSF_col") + ] + results_obj.par_best_fit = leakage.corr_2d( + x, + e, + weights=weights, + order=order, + mix=mix, + stats_file=results_obj._stats_file, + verbose=results_obj._params["verbose"], + ) + leakage.save_to_file(results_obj.par_best_fit, out_path) par_best_fit = results_obj.par_best_fit a11 = ufloat(par_best_fit["a11"].value, par_best_fit["a11"].stderr) diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index 793fa8268..96a80faa9 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -287,5 +287,9 @@ def plot_pure_eb( ) # Save data products and store on instance - save_pure_eb_results(version_results, out_stub + "_data.npz") + save_pure_eb_results( + version_results, + out_stub + "_data.npz", + derived_from=[self.xi_parts[version, "integration"]], + ) self._pure_eb_results[version][bin_key] = version_results diff --git a/src/sp_validation/dashboard/__init__.py b/src/sp_validation/dashboard/__init__.py new file mode 100644 index 000000000..424c36e3e --- /dev/null +++ b/src/sp_validation/dashboard/__init__.py @@ -0,0 +1 @@ +"""Web figure set for the cosmo_val section of the production dashboard.""" diff --git a/src/sp_validation/dashboard/correlations.py b/src/sp_validation/dashboard/correlations.py new file mode 100644 index 000000000..3dc22185c --- /dev/null +++ b/src/sp_validation/dashboard/correlations.py @@ -0,0 +1,570 @@ +"""Position–shear and scale-dependent leakage products for the dashboard. + +One catalogue pass builds the TreeCorr catalogues once and runs the +catalogue-paper correlation tests, all on the same k-means jackknife patches +(computed once from the galaxy positions and saved): + +``stars`` + Galaxy γ_t, γ_× around PSF validation stars (valid HSM, one entry per star + after merging per-exposure detections within ``--dedup-arcsec``), for all + stars and in star-magnitude tertiles. No random subtraction. +``lambda`` + PSF-model ellipticity (λ₁) and star-minus-model residual (λ₃) of the PSF + validation stars, tangential and cross, around galaxy positions (a random + subsample of ``--lens-fraction``), minus the same around footprint randoms. +``tiles`` + Galaxy γ_t, γ_× around the centres of the tiles in the catalogue. +``gaia`` + Galaxy γ_t, γ_× around Gaia DR3 stars (G < 20, RUWE < 1.4) inside the + footprint, in G bins, minus the same around footprint randoms. +``alpha`` + Scale-dependent leakage α(θ) = τ₀(θ)/ρ₀(θ) for all galaxies and in + galaxy-magnitude tertiles; ρ₀ from the PSF model at the stars, τ₀ the + galaxy–PSF-model correlation, both as in :mod:`sp_validation.rho_tau`. + +Galaxy shapes are the calibrated ``e1``, ``e2`` with ``w_des``; in the α +magnitude bins they are rescaled to the bin's own shear response. Each +measurement writes ``_.npz`` with θ, the correlations, their +jackknife errors and joint covariance, pair counts and a JSON provenance. + +Usage (inside the container):: + + python -m sp_validation.dashboard.correlations \\ + --catalogue-dir run-r425/catalogue --version SP_v2.0 \\ + --out-dir catpaper/products --gaia gaia_dr3_G20_ruwe1p4_unions20k.fits +""" + +import argparse +import json +import os +import resource +import time +from pathlib import Path + +import fitsio +import healpy as hp +import numpy as np +import treecorr +from cs_util import cfis +from scipy.sparse import coo_matrix +from scipy.sparse.csgraph import connected_components +from scipy.spatial import cKDTree + +from sp_validation.io import read_catalogue +from sp_validation.statistics import jackknife_patch_centers + +from .position_shear import measure + +GALAXY_COLUMNS = ["RA", "Dec", "e1", "e2", "w_des", "mag", "R_g11", "R_g22"] +STAR_COLUMNS = [ + "RA", + "DEC", + "MAG", + "HSM_G1_PSF", + "HSM_G2_PSF", + "HSM_G1_STAR", + "HSM_G2_STAR", + "HSM_FLAG_PSF", + "HSM_FLAG_STAR", +] +MEASUREMENTS = ("stars", "lambda", "tiles", "gaia", "alpha") +GAIA_G_EDGES = (3.0, 17.9, 19.2, 20.0) +TC = dict(min_sep=1.0, max_sep=250.0, nbins=20) + + +def log(message): + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1e6 + print(f"[{time.strftime('%H:%M:%S')} peak {peak:.1f} GB] {message}", flush=True) + + +def in_region(ra, dec, region): + if region is None: + return np.ones(len(ra), dtype=bool) + ra_min, ra_max, dec_min, dec_max = region + return (ra >= ra_min) & (ra < ra_max) & (dec >= dec_min) & (dec < dec_max) + + +def tile_name(tile_id): + """``XXX.YYY`` from a ``TILE_ID`` written as a float string (``51.34``).""" + x, _, y = str(tile_id).partition(".") + return f"{int(x):03d}.{y.ljust(3, '0')}" + + +def tile_centres(tile_ids): + """RA, Dec (deg) of the CFIS tile centres for unique ``TILE_ID`` values.""" + names = sorted({tile_name(t) for t in tile_ids}) + nix, niy = zip(*(cfis.get_tile_number(n) for n in names)) + ra, dec = cfis.get_tile_coord_from_nixy(list(nix), list(niy)) + return names, np.asarray(ra.deg), np.asarray(dec.deg) + + +def merge_detections(ra, dec, radius_arcsec): + """Group detections closer than ``radius_arcsec`` (friends of friends). + + Returns the group label of each detection and the number of groups. + """ + xyz = hp.ang2vec(ra, dec, lonlat=True) + chord = 2 * np.sin(np.deg2rad(radius_arcsec / 3600) / 2) + pairs = cKDTree(xyz).query_pairs(chord, output_type="ndarray") + n = len(ra) + graph = coo_matrix( + (np.ones(len(pairs), dtype=bool), (pairs[:, 0], pairs[:, 1])), shape=(n, n) + ) + return connected_components(graph, directed=False)[::-1] + + +def group_mean(labels, n_groups, values): + counts = np.bincount(labels, minlength=n_groups) + return np.bincount(labels, weights=values, minlength=n_groups) / counts + + +def unique_stars(stars, radius_arcsec): + """One position and magnitude per star from its per-exposure detections.""" + labels, n = merge_detections(stars["RA"], stars["DEC"], radius_arcsec) + xyz = hp.ang2vec(stars["RA"], stars["DEC"], lonlat=True) + mean = np.column_stack([group_mean(labels, n, xyz[:, i]) for i in range(3)]) + ra, dec = hp.vec2ang(mean, lonlat=True) + return ra, dec, group_mean(labels, n, stars["MAG"].astype(float)) + + +def read_galaxies(path, region, chunk=5_000_000): + """Galaxy columns (float64) and the set of tile IDs, streamed in chunks.""" + parts, tiles = {k: [] for k in GALAXY_COLUMNS}, set() + with fitsio.FITS(path) as fits: + table = fits[1] + for start in range(0, table.get_nrows(), chunk): + rows = table[GALAXY_COLUMNS + ["TILE_ID"]][start : start + chunk] + keep = in_region(rows["RA"], rows["Dec"], region) + for key in GALAXY_COLUMNS: + parts[key].append(rows[key][keep].astype(np.float64)) + tiles.update(np.unique(rows["TILE_ID"][keep]).tolist()) + return {k: np.concatenate(v) for k, v in parts.items()}, tiles + + +def read_stars(path, region): + stars = read_catalogue(path, columns=STAR_COLUMNS) + keep = (stars["HSM_FLAG_PSF"] == 0) & (stars["HSM_FLAG_STAR"] == 0) + keep &= in_region(stars["RA"], stars["DEC"], region) + return stars[keep] + + +def footprint_pixels(path): + """Occupied pixels (RING) and nside of an ``occupied-mask-nside*.npz``.""" + mask = np.load(path) + assert str(mask["ordering"]) == "RING" + return int(mask["nside"]), np.asarray(mask["pixel"]) + + +def in_footprint(ra, dec, nside, pixels): + return np.isin(hp.ang2pix(nside, ra, dec, lonlat=True), pixels) + + +def footprint_randoms(n, nside, pixels, region, rng, depth=6): + """Points uniform over the footprint pixels, to 1/2**depth of a pixel.""" + nest = hp.ring2nest(nside, pixels) + fine = nest[rng.integers(len(nest), size=n)] * 4**depth + fine += rng.integers(4**depth, size=n) + ra, dec = hp.pix2ang(nside * 2**depth, fine, nest=True, lonlat=True) + keep = in_region(ra, dec, region) + return ra[keep], dec[keep] + + +def catalog(ra, dec, centres, **fields): + return treecorr.Catalog( + ra=ra, dec=dec, ra_units="deg", dec_units="deg", patch_centers=centres, **fields + ) + + +def save(out, name, version, results, provenance): + """Write one measurement: ``results`` maps a label to a ``measure`` dict.""" + arrays = {} + for label, result in results.items(): + for key, value in result.items(): + arrays[f"{label}__{key}"] = np.asarray(value) + path = Path(out) / f"{name}_{version}.npz" + np.savez_compressed( + path, + labels=np.array(list(results)), + provenance=json.dumps(provenance), + **arrays, + ) + log(f"wrote {path}") + + +def quantile_edges(values, n): + """Edges of ``n`` equal-count bins; the last edge just above the maximum.""" + edges = np.quantile(values, np.linspace(0, 1, n + 1)) + edges[-1] = np.nextafter(edges[-1], np.inf) + return edges + + +def alpha_measurement(gal, stars, centres, n_bins, threads, r_selection): + """τ₀ per magnitude bin, ρ₀, and α = τ₀/ρ₀ with a joint jackknife covariance.""" + config = dict( + TC, sep_units="arcmin", var_method="jackknife", cross_patch_weight="match" + ) + psf = catalog( + stars["RA"], + stars["DEC"], + centres, + g1=stars["HSM_G1_PSF"], + g2=stars["HSM_G2_PSF"], + ) + rho0 = treecorr.GGCorrelation(config) + rho0.process(psf, psf, num_threads=threads) + log("rho0 done") + edges = quantile_edges(gal["mag"], n_bins) + selections = [("all", np.ones(len(gal["mag"]), dtype=bool))] + [ + (f"mag{i}", (gal["mag"] >= lo) & (gal["mag"] < hi)) + for i, (lo, hi) in enumerate(zip(edges[:-1], edges[1:])) + ] + r_global = None + taus, responses = [], {} + for label, sel in selections: + w = gal["w_des"][sel] + r_shear = np.array( + [ + np.average(gal["R_g11"][sel], weights=w), + np.average(gal["R_g22"][sel], weights=w), + ] + ) + r_bin = r_shear + r_selection + if r_global is None: + r_global = r_bin + # Calibrated e is (e_uncal - c)/R_global; rescale to the bin's response. + scale = r_global / r_bin + g = [] + for i, key in enumerate(("e1", "e2")): + e = gal[key][sel] * scale[i] + g.append(e - np.average(e, weights=w)) + cat = catalog(gal["RA"][sel], gal["Dec"][sel], centres, g1=g[0], g2=g[1], w=w) + tau0 = treecorr.GGCorrelation(config) + tau0.process(cat, psf, num_threads=threads) + taus.append(tau0) + responses[label] = { + "n": int(sel.sum()), + "R_shear": r_shear.tolist(), + "R": r_bin.tolist(), + "rescale": scale.tolist(), + } + del cat + log(f"tau0 {label} done") + n = TC["nbins"] + + def vector(corrs): + rho = corrs[0].xip + return np.concatenate([c.xip / rho for c in corrs[1:]]) + + def raw(corrs): + return np.concatenate([c.xip for c in corrs]) + + corrs = [rho0, *taus] + alpha = vector(corrs) + cov = treecorr.estimate_multi_cov(corrs, "jackknife", func=vector) + raw_cov = treecorr.estimate_multi_cov(corrs, "jackknife", func=raw) + results = {} + for i, (label, _) in enumerate(selections): + block = slice(i * n, (i + 1) * n) + results[label] = dict( + theta=np.exp(rho0.meanlogr), + alpha=alpha[block], + alpha_error=np.sqrt(np.diag(cov)[block]), + tau0=taus[i].xip, + tau0_error=np.sqrt(np.diag(raw_cov)[(i + 1) * n : (i + 2) * n]), + npairs=taus[i].npairs, + ) + results["rho0"] = dict( + theta=np.exp(rho0.meanlogr), + rho0=rho0.xip, + rho0_error=np.sqrt(np.diag(raw_cov)[:n]), + npairs=rho0.npairs, + ) + results["joint"] = dict(alpha_covariance=cov, raw_covariance=raw_cov) + provenance = { + "galaxy_mag_edges": edges.tolist(), + "responses": responses, + "R_selection_global": r_selection.tolist(), + "shapes": "calibrated e1, e2 rescaled by R_global/R_bin, weighted mean " + "subtracted per bin (as rho_tau TauStat); w_des", + "stars": "PSF validation stars, HSM_FLAG_STAR == 0 & HSM_FLAG_PSF == 0, " + "every exposure detection (as the dashboard rho/tau)", + "rho0": "xi+ of HSM_G*_PSF x HSM_G*_PSF", + "tau0": "xi+ of galaxy e x HSM_G*_PSF", + "raw_covariance_order": ["rho0"] + [s for s, _ in selections], + } + return results, provenance + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--catalogue-dir", required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--out-dir", required=True) + parser.add_argument("--gaia", help="staged Gaia DR3 FITS (stage_gaia.py)") + parser.add_argument("--only", nargs="+", choices=MEASUREMENTS, default=None) + parser.add_argument( + "--region", + type=float, + nargs=4, + default=None, + metavar=("RA_MIN", "RA_MAX", "DEC_MIN", "DEC_MAX"), + help="restrict every sample to a box (pilot runs)", + ) + parser.add_argument("--npatch", type=int, default=100) + parser.add_argument( + "--lens-fraction", + type=float, + default=0.1, + help="galaxy subsample used as lenses for lambda", + ) + parser.add_argument( + "--random-factor", type=float, default=1.0, help="randoms per lambda lens" + ) + parser.add_argument("--dedup-arcsec", type=float, default=0.5) + parser.add_argument("--mag-bins", type=int, default=3) + parser.add_argument("--threads", type=int, default=len(os.sched_getaffinity(0))) + parser.add_argument("--seed", type=int, default=425) + args = parser.parse_args(argv) + todo = args.only or [m for m in MEASUREMENTS if m != "gaia" or args.gaia] + cdir = Path(args.catalogue_dir) + out = Path(args.out_dir) + out.mkdir(parents=True, exist_ok=True) + rng = np.random.default_rng(args.seed) + base = { + "version": args.version, + "catalogue_dir": str(cdir.resolve()), + "region": args.region, + "npatch": args.npatch, + "treecorr": dict( + TC, sep_units="arcmin", var_method="jackknife", version=treecorr.__version__ + ), + "units": "theta in arcmin; shears dimensionless", + } + timings = {} + + t0 = time.time() + gal, tile_ids = read_galaxies(cdir / "shape_catalog_cut_ngmix.fits", args.region) + log(f"{len(gal['RA'])} galaxies, {len(tile_ids)} tiles") + stars = read_stars(cdir / "stars.hdf5", args.region) + log(f"{len(stars)} valid-HSM star detections") + timings["read"] = time.time() - t0 + + patch_path = out / f"patches_{args.version}.npz" + n_gal = len(gal["RA"]) + saved = dict(np.load(patch_path)) if patch_path.exists() else {} + if ( + saved + and len(saved["centres"]) == args.npatch + and int(saved.get("n_galaxies", -1)) == n_gal + ): + centres = saved["centres"] + else: + every = max(1, len(gal["RA"]) // 2_000_000) + sub = treecorr.Catalog( + ra=gal["RA"][::every], + dec=gal["Dec"][::every], + ra_units="deg", + dec_units="deg", + ) + centres = jackknife_patch_centers(sub, args.npatch, seed=args.seed) + np.savez( + patch_path, + centres=centres, + npatch=args.npatch, + n_galaxies=n_gal, + every_nth=every, + seed=args.seed, + region=str(args.region), + ) + log(f"patches -> {patch_path}") + + sources = None + if {"stars", "tiles", "gaia"} & set(todo): + sources = catalog( + gal["RA"], gal["Dec"], centres, g1=gal["e1"], g2=gal["e2"], w=gal["w_des"] + ) + nside, pixels = footprint_pixels(cdir / "occupied-mask-nside4096.npz") + + if "stars" in todo: + t = time.time() + ra, dec, mag = unique_stars(stars, args.dedup_arcsec) + log(f"{len(ra)} unique stars from {len(stars)} detections") + edges = quantile_edges(mag, args.mag_bins) + results = { + "all": measure( + catalog(ra, dec, centres), sources, num_threads=args.threads, **TC + ) + } + for i, (lo, hi) in enumerate(zip(edges[:-1], edges[1:])): + sel = (mag >= lo) & (mag < hi) + results[f"mag{i}"] = measure( + catalog(ra[sel], dec[sel], centres), + sources, + num_threads=args.threads, + **TC, + ) + timings["stars"] = time.time() - t + save( + out, + "gamma_stars", + args.version, + results, + dict( + base, + lenses="PSF validation stars (valid HSM), detections merged within " + f"{args.dedup_arcsec} arcsec", + n_lenses=len(ra), + n_detections=len(stars), + star_mag_edges=edges.tolist(), + sources="galaxies, calibrated e1/e2, w_des", + random_subtraction=False, + seconds=timings["stars"], + ), + ) + + if "tiles" in todo: + t = time.time() + names, ra, dec = tile_centres(tile_ids) + results = { + "all": measure( + catalog(ra, dec, centres), sources, num_threads=args.threads, **TC + ) + } + timings["tiles"] = time.time() - t + save( + out, + "gamma_tiles", + args.version, + results, + dict( + base, + lenses="CFIS tile centres of the tiles in the catalogue", + n_lenses=len(names), + sources="galaxies, calibrated e1/e2, w_des", + random_subtraction=False, + seconds=timings["tiles"], + ), + ) + + randoms = None + if {"lambda", "gaia"} & set(todo): + n_lens = int(args.lens_fraction * len(gal["RA"])) + rra, rdec = footprint_randoms( + int(args.random_factor * n_lens * 1.05), nside, pixels, args.region, rng + ) + randoms = catalog( + rra[: int(args.random_factor * n_lens)], + rdec[: int(args.random_factor * n_lens)], + centres, + ) + log(f"{randoms.ntot} randoms") + + if "gaia" in todo: + t = time.time() + gaia = fitsio.read(args.gaia, columns=["ra", "dec", "phot_g_mean_mag", "ruwe"]) + keep = (gaia["phot_g_mean_mag"] < 20) & (gaia["ruwe"] < 1.4) + keep &= in_region(gaia["ra"], gaia["dec"], args.region) + keep &= in_footprint(gaia["ra"], gaia["dec"], nside, pixels) + gaia = gaia[keep] + g = gaia["phot_g_mean_mag"] + results, counts = {}, {} + for i, (lo, hi) in enumerate(zip(GAIA_G_EDGES[:-1], GAIA_G_EDGES[1:])): + sel = (g >= lo) & (g < hi) + counts[f"G{i}"] = int(sel.sum()) + results[f"G{i}"] = measure( + catalog( + gaia["ra"][sel].astype(float), + gaia["dec"][sel].astype(float), + centres, + ), + sources, + randoms=randoms, + num_threads=args.threads, + **TC, + ) + log(f"gaia G{i} done") + timings["gaia"] = time.time() - t + save( + out, + "gamma_gaia", + args.version, + results, + dict( + base, + lenses="Gaia DR3, G < 20, RUWE < 1.4, inside the occupied " + "nside-4096 footprint", + gaia_file=str(Path(args.gaia).resolve()), + g_edges=list(GAIA_G_EDGES), + n_lenses=counts, + n_randoms=randoms.ntot, + sources="galaxies, calibrated e1/e2, w_des", + random_subtraction=True, + seconds=timings["gaia"], + ), + ) + + del sources + if "lambda" in todo: + t = time.time() + pick = rng.random(len(gal["RA"])) < args.lens_fraction + lenses = catalog(gal["RA"][pick], gal["Dec"][pick], centres) + results = {} + for label, g1, g2 in ( + ("psf", stars["HSM_G1_PSF"], stars["HSM_G2_PSF"]), + ( + "residual", + stars["HSM_G1_STAR"] - stars["HSM_G1_PSF"], + stars["HSM_G2_STAR"] - stars["HSM_G2_PSF"], + ), + ): + # The survey-mean ellipticity is an additive offset, not structure + # correlated with galaxy positions; subtract it as TauStat does. + g1 = g1 - np.mean(g1) + g2 = g2 - np.mean(g2) + src = catalog(stars["RA"], stars["DEC"], centres, g1=g1, g2=g2) + results[label] = measure( + lenses, src, randoms=randoms, num_threads=args.threads, **TC + ) + log(f"lambda {label} done") + timings["lambda"] = time.time() - t + save( + out, + "lambda_stars", + args.version, + results, + dict( + base, + lenses=f"random {args.lens_fraction:g} of the galaxies", + n_lenses=lenses.ntot, + n_randoms=randoms.ntot, + sources="PSF validation star detections (valid HSM): psf = HSM_G*_PSF " + "(lambda_1), residual = HSM_G*_STAR - HSM_G*_PSF (lambda_3), " + "each with its mean subtracted", + n_sources=len(stars), + random_subtraction=True, + seconds=timings["lambda"], + ), + ) + del lenses + + if "alpha" in todo: + t = time.time() + calib = json.loads((cdir / "calibration.json").read_text()) + r_sel = np.diag(np.asarray(calib["R_selection"])) + results, prov = alpha_measurement( + gal, stars, centres, args.mag_bins, args.threads, r_sel + ) + timings["alpha"] = time.time() - t + save( + out, + "alpha_mag", + args.version, + results, + dict(base, **prov, seconds=timings["alpha"]), + ) + + log(f"timings (s): {json.dumps({k: round(v) for k, v in timings.items()})}") + + +if __name__ == "__main__": + main() diff --git a/src/sp_validation/dashboard/cosebis_grid.py b/src/sp_validation/dashboard/cosebis_grid.py new file mode 100644 index 000000000..6179034a8 --- /dev/null +++ b/src/sp_validation/dashboard/cosebis_grid.py @@ -0,0 +1,173 @@ +"""COSEBIs B-mode PTEs over every reporting-bin scale cut (Paper II Fig. 8). + +For each contiguous range of reporting bins ``[i, j]`` on the 20-bin +1′–250′ grid, the COSEBIs are recomputed on the integration-grid ξ± between +those reporting edges, and the B-mode χ² against zero is taken over the first +6 and over all 20 modes with the analytic covariance carried through the same +kernel. Where the 20-mode filters fail to converge (narrow cuts), the n ≤ 6 +PTE comes from a 6-mode computation and the n ≤ 20 PTE is NaN. + +The product is a cache read by :func:`sp_validation.dashboard.scale_cuts.config_space`; +computing it needs only the run's integration-grid ξ± part and its OneCovariance +covariance, never the catalogue. Usage:: + + python -m sp_validation.dashboard.cosebis_grid --run-dir RUN --version V \\ + --out cosebis_grid_V.npz [--nproc N] +""" + +import argparse +import json +import os +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import numpy as np + +SUBSETS = (6, 20) + + +def _paths(run_dir, version): + run_dir = Path(run_dir) + (xi,) = (run_dir / "cosmo_val").glob(f"{version}_xi_minsep=*_npatch=1.sacc") + (cov,) = (run_dir / "cosmo_inference/data/covariance").glob( + f"covariance_{version}_g_minsep=*_masked/covariance_{version}_g_minsep=*_masked.dat" + ) + return xi, cov + + +def _grid(run_dir): + """Reporting edges and integration binning from the run's configuration.""" + import yaml + + cfg = yaml.safe_load( + json.loads((Path(run_dir) / "provenance.json").read_text())["config"] + ) + cv = cfg["cosmo_val"] + edges = np.geomspace(cv["theta_min"], cv["theta_max"], cv["nbins"] + 1) + integ = cv["integration"] + return ( + edges, + (integ["min_sep"], integ["max_sep"], integ["nbins"]), + cv["cosebis"]["nmodes"], + ) + + +_STATE = {} + + +def _init(xi_path, cov_path, blind, integ, nmodes): + from sp_validation import sacc_io + from sp_validation.b_modes import log_bin_edges + + part = sacc_io.load(xi_path, blind=blind) + theta, xip, xim = sacc_io.get_xi(part, (0, 0), grid="integration") + _STATE.update( + theta=theta, + xip=xip, + xim=xim, + cov=np.loadtxt(cov_path), + edges=log_bin_edges(*integ), + nmodes=nmodes, + ) + + +def _modes(cut, nmodes): + """B-mode COSEBIs and their covariance for one cut, or None if the filter + roots fail to converge.""" + from sp_validation.b_modes import cosebis_scan_from_xi + + s = _STATE + try: + (r,) = cosebis_scan_from_xi( + s["theta"], + s["xip"], + s["xim"], + s["cov"], + *s["edges"], + nmodes=nmodes, + scale_cuts=[cut], + ).values() + except Exception as e: # sympy NoConvergence in the filter roots + print(f"cut {cut}, {nmodes} modes: {type(e).__name__}: {e}", flush=True) + return None + return r["Bn"], r["cov"][nmodes:, nmodes:] + + +def _pair(cut): + """B PTEs over the first k modes for each k in SUBSETS. + + The log-COSEBIs filter of mode n does not depend on how many modes are + requested, so a cut whose full mode set fails to converge is retried + with only the modes a smaller subset needs. + """ + from scipy import stats + + full = _modes(cut, _STATE["nmodes"]) + out = [] + for k in SUBSETS: + r = full if full is not None or k >= _STATE["nmodes"] else _modes(cut, k) + if r is None: + out.append(np.nan) + continue + b, cov_B = r[0][:k], r[1][:k, :k] + out.append(float(stats.chi2.sf(b @ np.linalg.solve(cov_B, b), k))) + return out + + +def compute(run_dir, version, cat_config, nproc=1): + from sp_validation.dashboard.products import declared_blind + + xi_path, cov_path = _paths(run_dir, version) + blind = declared_blind(cat_config, version) + edges, integ, nmodes = _grid(run_dir) + n = len(edges) - 1 + pairs = [(i, j) for i in range(n) for j in range(i, n)] + cuts = [(float(edges[i]), float(edges[j + 1])) for i, j in pairs] + init = (str(xi_path), str(cov_path), blind, integ, nmodes) + if nproc > 1: + with ProcessPoolExecutor(nproc, initializer=_init, initargs=init) as pool: + results = list(pool.map(_pair, cuts)) + else: + _init(*init) + results = [_pair(c) for c in cuts] + pte = {k: np.full((n, n), np.nan) for k in SUBSETS} + for (i, j), vals in zip(pairs, results): + for k, v in zip(SUBSETS, vals): + pte[k][i, j] = v + return { + "left_edges": edges[:-1], + "right_edges": edges[1:], + **{f"pte_B_n{k}": pte[k] for k in SUBSETS}, + "blind": np.array(blind), + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--run-dir", required=True) + ap.add_argument("--version", required=True) + ap.add_argument("--out", required=True) + ap.add_argument( + "--cat-config", + required=True, + help="catalogue config declaring the version's blind", + ) + ap.add_argument( + "--nproc", type=int, default=int(os.environ.get("SLURM_CPUS_PER_TASK", 1)) + ) + a = ap.parse_args(argv) + # One numba thread per worker; the parallelism is over cuts. + os.environ.setdefault( + "NUMBA_NUM_THREADS", "1" if a.nproc > 1 else str(os.cpu_count()) + ) + data = compute(a.run_dir, a.version, a.cat_config, a.nproc) + out = Path(a.out) + out.parent.mkdir(parents=True, exist_ok=True) + tmp = out.with_suffix(".tmp.npz") + np.savez(tmp, **data) + tmp.replace(out) + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/src/sp_validation/dashboard/covariance.py b/src/sp_validation/dashboard/covariance.py new file mode 100644 index 000000000..016b60c16 --- /dev/null +++ b/src/sp_validation/dashboard/covariance.py @@ -0,0 +1,70 @@ +"""Correlation matrices of the covariances behind each statistic. + +One square figure per statistic, drawn as the correlation coefficient with +Paper II's diverging ``icefire`` map, block labels at the block centres and +hairline block separators. +""" + +import matplotlib.pyplot as plt +import numpy as np + +from sp_validation.b_modes import correlation_from_covariance + +from . import style as S + + +def correlation(cov, blocks, out, label): + """Draw ``cov`` as a correlation matrix; ``blocks`` is ``[(name, size)]``.""" + corr = correlation_from_covariance(np.asarray(cov)) + n = corr.shape[0] + sizes = [size for _, size in blocks] + if sum(sizes) != n: + raise ValueError(f"blocks cover {sum(sizes)} rows of a {n}-row matrix") + starts = np.concatenate(([0], np.cumsum(sizes))) + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 3.25)) + image = ax.imshow( + corr, + cmap=S.correlation_cmap(), + vmin=-1, + vmax=1, + interpolation="nearest", + origin="upper", + ) + for edge in starts[1:-1]: + ax.axhline(edge - 0.5, color=S.RAG, lw=0.5, alpha=0.8) + ax.axvline(edge - 0.5, color=S.RAG, lw=0.5, alpha=0.8) + centres = (starts[:-1] + starts[1:]) / 2 - 0.5 + names = [name for name, _ in blocks] + ax.set_xticks(centres) + ax.set_xticklabels(names, fontsize=8.5) + ax.set_yticks(centres) + ax.set_yticklabels(names, fontsize=8.5) + ax.tick_params(which="both", length=0, top=False, right=False) + S.header(ax, label) + cbar = S.colorbar( + fig, image, ax, "correlation coefficient", shrink=0.92, aspect=22, pad=0.03 + ) + cbar.set_ticks([-1, -0.5, 0, 0.5, 1]) + return S.save(fig, out, "half") + + +def xi_blocks(n): + return [(r"$\xi_+$", n), (r"$\xi_-$", n)] + + +def pure_eb_blocks(n): + return [(rf"$\xi_{s}^{{{m}}}$", n) for m in ("E", "B", r"\rm amb") for s in "+-"] + + +def cosebis_blocks(n): + return [(r"$E_n$", n), (r"$B_n$", n)] + + +def cl_matrix(cov, keys=("EE", "EB", "BB")): + """Assemble the NaMaster blocks for ``keys`` into one matrix.""" + return np.block([[cov[f"{a}_{b}"] for b in keys] for a in keys]) + + +def cl_blocks(n, keys=("EE", "EB", "BB")): + return [(rf"$C_\ell^{{{k}}}$", n) for k in keys] diff --git a/src/sp_validation/dashboard/data_vectors.py b/src/sp_validation/dashboard/data_vectors.py new file mode 100644 index 000000000..3a1e1654e --- /dev/null +++ b/src/sp_validation/dashboard/data_vectors.py @@ -0,0 +1,269 @@ +"""Data-vector figures: the ξ± and C_ℓ two-point functions, and the three +B-mode data vectors as Paper II draws them. + +Each function draws one figure from loaded products (see +:mod:`sp_validation.dashboard.products`) and writes it to ``out``. The B-mode +figures reproduce Paper II's single-data-vector figures -- pure E/B (Fig. 2), +COSEBIs (Fig. 3), C_ℓ BB/EB (Fig. 4) -- with the same estimator-family hues, +marker fills, normalisation, and grey shading of what lies outside the +fiducial selection; their PTEs live in the PTE table, not in the figures. +``layout="narrow"`` stacks multi-panel figures for phones. +""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.lines import Line2D + +from sp_validation.b_modes import bins_from_scale_cut + +from . import style as S + +XI_SCALE = 1e-4 + + +def _legend(fig, handles, ncol): + fig.legend( + handles=handles, + loc="outside upper center", + ncol=ncol, + handletextpad=0.2, + columnspacing=1.0, + borderaxespad=0.2, + ) + + +def _marker(marker, color, label, filled=True, alpha=1.0): + return Line2D( + [], + [], + ls="none", + marker=marker, + color=color, + alpha=alpha, + markerfacecolor=color if filled else "none", + markeredgecolor=color, + markersize=4, + markeredgewidth=0.8, + label=label, + ) + + +def xi_pm(xi, out, layout="wide", blind=None): + """Blinded reporting-grid θξ± with analytic error bars.""" + with S.figure_style(): + if layout == "wide": + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.9), sharey=True) + else: + fig, axes = plt.subplots(2, 1, figsize=(3.6, 4.6), sharex=True) + theta = xi["theta"] + for ax, key, sign in zip(axes, ("xip", "xim"), "+-"): + S.zero_line(ax) + ax.errorbar( + theta, + theta * xi[key] / XI_SCALE, + yerr=theta * xi[f"sigma_{key}"] / XI_SCALE, + marker="o", + color=S.COLOR_TOTAL, + **{**S.DATA_VECTOR_MARK, "markersize": 3.8}, + ) + S.theta_axis(ax) + S.theta_ticks(ax) + S.label_box(ax, rf"$\xi_{sign}$") + for ax in axes if layout == "wide" else axes[-1:]: + ax.set_xlabel(r"$\theta$ [arcmin]") + for ax in axes[:1] if layout == "wide" else axes: + ax.set_ylabel(r"$\theta\,\xi_\pm \times 10^{4}$") + lo = min(np.min(theta * (xi[k] - xi[f"sigma_{k}"])) for k in ("xip", "xim")) + hi = max(np.max(theta * (xi[k] + xi[f"sigma_{k}"])) for k in ("xip", "xim")) + pad = 0.08 * (hi - lo) + axes[0].set_ylim((lo - pad) / XI_SCALE, (hi + 2.2 * pad) / XI_SCALE) + if blind: + fig.suptitle(" ", fontsize=7) + S.blind_stamp(fig, blind) + return S.save(fig, out, "full" if layout == "wide" else "half") + + +def _cl_cut_mask(cl, ell_cut): + return (cl["ell_lo"] >= ell_cut[0]) & (cl["ell_hi"] <= ell_cut[1]) + + +def _cl_shade(cl, ell_cut): + mask = _cl_cut_mask(cl, ell_cut) + return cl["ell_lo"][mask][0], cl["ell_hi"][mask][-1] + + +def ell_cl(cl, out, ell_cut, layout="wide", blind=None): + """Blinded ℓ C_ℓ^EE on a linear ℓ axis; BB and EB are drawn with the B modes.""" + ell = cl["ell"] + top = np.max(ell * (cl["EE"] + cl["sigma_EE"])) + p = int(np.floor(np.log10(top))) + scale = 10.0**p + size = (7.2, 2.7) if layout == "wide" else (3.6, 2.7) + with S.figure_style(): + fig, ax = plt.subplots(figsize=size) + S.zero_line(ax) + ax.errorbar( + ell, + ell * cl["EE"] / scale, + yerr=ell * cl["sigma_EE"] / scale, + marker="o", + color=S.COLOR_TOTAL, + **{**S.DATA_VECTOR_MARK, "markersize": 3.8}, + ) + ax.set_xlim(0, cl["ell_hi"][-1] + 30) + S.shade_outside(ax, *_cl_shade(cl, ell_cut)) + ax.set_xlabel(r"$\ell$") + ax.set_ylabel(rf"$\ell\, C_\ell \times 10^{{{-p}}}$") + S.label_box(ax, r"$C_\ell^{EE}$") + if blind: + fig.suptitle(" ", fontsize=7) + S.blind_stamp(fig, blind) + return S.save(fig, out, "full" if layout == "wide" else "half") + + +def _jitter(theta, i, n, spread=0.07): + return theta * np.exp(spread * (i - (n - 1) / 2)) + + +PURE_EB_SERIES = [ + ("", "o", S.COLOR_TOTAL, 1.0, r"$\xi_\pm$ (total)"), + ("_E", "s", S.COLOR_E, 0.5, r"$\xi_\pm^E$"), + ("_B", "X", S.COLOR_B, 1.0, r"$\xi_\pm^B$"), + ("_amb", "v", S.COLOR_AMB, 0.55, r"$\xi_\pm^{\rm amb}$"), +] + + +def pure_eb(pe, out, scale_cut, layout="wide", blind=None): + """Pure E / B / ambiguous decomposition of θξ± (Paper II Fig. 2).""" + theta = pe["theta"] + start, stop = bins_from_scale_cut(pe["left_edges"], pe["right_edges"], scale_cut) + shade = (pe["left_edges"][start], pe["right_edges"][stop - 1]) + with S.figure_style(): + if layout == "wide": + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.7), sharey=True) + else: + fig, axes = plt.subplots(2, 1, figsize=(3.6, 4.6), sharex=True, sharey=True) + bounds = [] + for ax, xi, sign in zip(axes, ("xip", "xim"), "+-"): + S.zero_line(ax) + for i, (suffix, marker, color, alpha, _) in enumerate(PURE_EB_SERIES): + y = theta * pe[f"{xi}{suffix}"] / XI_SCALE + dy = theta * pe[f"sigma_{xi}{suffix}"] / XI_SCALE + bounds.append((y - dy)[start:stop].min()) + bounds.append((y + dy)[start:stop].max()) + ax.errorbar( + _jitter(theta, i, len(PURE_EB_SERIES)), + y, + yerr=dy, + marker=marker, + color=color, + alpha=alpha, + **S.DATA_VECTOR_MARK, + ) + S.theta_axis(ax) + S.theta_ticks(ax) + S.shade_outside(ax, *shade) + S.label_box(ax, rf"$\xi_{sign}$") + for ax in axes if layout == "wide" else axes[-1:]: + ax.set_xlabel(r"$\theta$ [arcmin]") + for ax in axes[:1] if layout == "wide" else axes: + ax.set_ylabel(r"$\theta\,\xi \times 10^{4}$") + # The in-cut points set the range, as in Paper II. + lo, hi = min(bounds), max(bounds) + pad = 0.12 * (hi - lo) + axes[0].set_ylim(lo - pad, hi + 2.2 * pad) + _legend( + fig, [_marker(m, c, lab, alpha=a) for _, m, c, a, lab in PURE_EB_SERIES], 4 + ) + if blind: + S.blind_stamp(fig, blind, where="bottom") + return S.save(fig, out, "full" if layout == "wide" else "half") + + +def cosebis(co, out, full_cut=(1.0, 250.0), n_subset=6): + """B-mode COSEBIs in units of their uncertainty (Paper II Fig. 3). + + Fiducial cuts filled in the COSEBIs purple, the full range open in its + tint; modes beyond the ``n <= n_subset`` subset are shaded, the way scales + outside the fiducial cuts are shaded elsewhere. + """ + fid = co["fiducial"] + rows = [ + (fid, -0.15, "o", True, S.COLOR_COSEBIS, "fiducial"), + (full_cut, 0.15, "D", False, S.COLOR_COSEBIS_ALL, "full range"), + ] + n = np.arange(1, co["nmodes"] + 1) + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 2.45)) + extent = 0 + handles = [] + for cut, off, marker, filled, color, name in rows: + r = co["cuts"][cut] + y = r["Bn"] / r["sigma_B"] + extent = max(extent, np.max(np.abs(y)) + 1) + ax.errorbar( + n + off, + y, + yerr=np.ones_like(y), + marker=marker, + color=color, + markerfacecolor=color if filled else "none", + markeredgecolor=color, + **S.DATA_VECTOR_MARK, + ) + handles.append( + _marker(marker, color, rf"{name} ${cut[0]:g}'\!-\!{cut[1]:g}'$", filled) + ) + S.zero_line(ax) + ax.set_xlim(0.4, n[-1] + 0.6) + ax.axvspan(n_subset + 0.5, n[-1] + 0.6, **S.EXCLUDED_SHADE) + ax.set_xticks(n) + ax.set_xticklabels([f"${i}$" if i % 2 == 0 else "" for i in n]) + ax.set_xlabel(r"COSEBI mode $n$") + ax.set_ylabel(r"$B_n / \sigma_n$") + lim = np.ceil(extent + 0.4) + ax.set_ylim(-lim, lim) + ax.yaxis.set_major_locator(plt.MultipleLocator(2)) + S.label_box(ax, "COSEBIs") + _legend(fig, handles, 2) + return S.save(fig, out, "half") + + +def cl_bmodes(cl, out, ell_cut): + """C_ℓ^BB and C_ℓ^EB in units of their uncertainty (Paper II Fig. 4).""" + rows = [ + ("BB", "o", True, S.COLOR_CL_BB, r"$C_\ell^{BB}$"), + ("EB", "D", False, S.COLOR_CL_EB, r"$C_\ell^{EB}$"), + ] + ell = cl["ell"] + with S.figure_style(): + fig, axes = plt.subplots(2, 1, figsize=(3.6, 2.9), sharex=True, sharey=True) + extent = 0 + for ax, (key, marker, filled, color, label) in zip(axes, rows): + y = cl[key] / cl[f"sigma_{key}"] + extent = max(extent, np.max(np.abs(y)) + 1) + S.zero_line(ax) + ax.errorbar( + ell, + y, + yerr=np.ones_like(y), + marker=marker, + color=color, + markerfacecolor=color if filled else "none", + markeredgecolor=color, + **S.DATA_VECTOR_MARK, + ) + ax.set_xscale("function", functions=(np.sqrt, np.square)) + ax.set_ylabel(rf"{label}$/\sigma$") + S.label_box(ax, label) + for ax in axes: + ax.set_xlim(ell.min() * 0.85, cl["ell_hi"][-1] * 1.02) + S.shade_outside(ax, *_cl_shade(cl, ell_cut)) + ax.set_xticks([100, 400, 900, 1600]) + ax.set_xticklabels(["$100$", "$400$", "$900$", "$1600$"]) + ax.set_xticks([i * 100 for i in range(1, 21)], minor=True) + lim = np.ceil(extent + 0.4) + axes[0].set_ylim(-lim, lim) + axes[0].yaxis.set_major_locator(plt.MultipleLocator(2)) + axes[-1].set_xlabel(r"$\ell$") + return S.save(fig, out, "half") diff --git a/src/sp_validation/dashboard/field_maps.py b/src/sp_validation/dashboard/field_maps.py new file mode 100644 index 000000000..e801a0d09 --- /dev/null +++ b/src/sp_validation/dashboard/field_maps.py @@ -0,0 +1,393 @@ +"""Catalogue-level products behind the dashboard sky maps and weight figure. + +Two cached products, each computed once from the calibrated catalogue and +read by the renderers in :mod:`sp_validation.dashboard`: + +``field_maps_.npz`` + Per-pixel sums on a nested HEALPix grid: galaxy counts and weight sums, + weighted ellipticity and ellipticity-squared sums, and the PSF model + (ellipticity, FWHM) at the galaxy positions; plus star counts and the + star-minus-PSF-model residual sums. Means, dispersions and effective + densities are ratios of these sums, so a renderer can coarsen the grid + by summing children before dividing. Only occupied pixels are stored. + +``weight_cells_.npz`` + The galaxy weight on the SNR x size-ratio grid it is built on + (:func:`sp_validation.calibration.get_w_des`): per-cell counts and the + weight range within each cell, the grid edges, and the distinct weight + values with their multiplicities. Also contains response histograms, + equal-count leakage-cell regression sums and binned additive-bias sums. + Quantile boundaries use a temporary float32 buffer of the seven leakage + inputs; only sufficient statistics are saved. + +``star_residuals_.npz`` + Histograms and full-sample moments of valid-HSM star residuals. + +Usage (inside the container):: + + python -m sp_validation.dashboard.field_maps \\ + --galaxies shape_catalog_cut_ngmix.fits --stars stars.hdf5 \\ + --version SP_v2.0 --out-dir cosmo_val/ +""" + +import argparse +import json +from pathlib import Path + +import fitsio +import healpy as hp +import numpy as np + +from sp_validation.io import read_catalogue + +GALAXY_COLUMNS = [ + "RA", + "Dec", + "w_des", + "e1", + "e2", + "e1_uncal", + "e2_uncal", + "R_g11", + "R_g12", + "R_g21", + "R_g22", + "e1_PSF", + "e2_PSF", + "fwhm_PSF", + "mag", + "snr", + "NGMIX_T_NOSHEAR", + "NGMIX_T_PSF_RECONV_NOSHEAR", +] +BIAS_EDGES = { + "PSF e1": np.linspace(-0.2, 0.2, 21), + "PSF e2": np.linspace(-0.2, 0.2, 21), + "PSF FWHM": np.linspace(0.3, 2.0, 21), + "r magnitude": np.linspace(18, 27, 21), + "SNR": np.geomspace(10, 500, 21), + "size ratio": np.linspace(0.707, 3, 21), + "galaxy size": np.linspace(0, 5, 21), + "RA": np.linspace(0, 360, 21), + "Dec": np.linspace(-30, 90, 21), + "weight": np.linspace(0, 50, 21), +} + +STAR_COLUMNS = [ + "RA", + "DEC", + "HSM_G1_PSF", + "HSM_G2_PSF", + "HSM_T_PSF", + "HSM_G1_STAR", + "HSM_G2_STAR", + "HSM_T_STAR", + "HSM_M4_1_STAR", + "HSM_M4_2_STAR", + "HSM_M4_1_PSF", + "HSM_M4_2_PSF", + "HSM_FLAG_PSF", + "HSM_FLAG_STAR", +] +GALAXY_SUMS = [ + "n_gal", + "sum_w", + "sum_w2", + "sum_w_e1", + "sum_w_e2", + "sum_w2_ee", + "sum_w_e1_psf", + "sum_w_e2_psf", + "sum_w_fwhm_psf", + "sum_mag", +] +STAR_SUMS = [ + "n_star", + "sum_de1", + "sum_de2", + "sum_dT_over_T_psf", + "sum_T_psf", +] + + +def weight_grid_edges(n_bins, snr_range, size_ratio_range): + """Log-spaced SNR and size-ratio edges of the weight grid.""" + snr_edges = np.logspace(*np.log10(snr_range), n_bins + 1) + size_edges = np.logspace(*np.log10(size_ratio_range), n_bins + 1) + return snr_edges, size_edges + + +def weight_cell_index(values, edges): + """Cell index of each value, matching ``calibration.cut_to_bins``. + + Bins are right-closed, ``(edge[i], edge[i+1]]``; a value outside the + edges belongs to the last cell, as ``cut_to_bins`` assigns it. + """ + n_bins = len(edges) - 1 + index = np.searchsorted(edges, values, side="left") - 1 + outside = (index < 0) | (index >= n_bins) | ~np.isfinite(values) + index[outside] = n_bins - 1 + return index + + +def _accumulate(total, pixels, weights, npix): + total += np.bincount(pixels, weights=weights, minlength=npix) + + +def leakage_cells(data, n_bins=8): + """Equal-count resolution bins, then equal-count SNR within each bin.""" + valid = np.all(np.isfinite(data), axis=1) & (data[:, 2] > 0) + data = data[valid] + resolution_edges = np.quantile(data[:, 1], np.linspace(0, 1, n_bins + 1)) + if np.any(np.diff(resolution_edges) <= 0): + raise ValueError("resolution quantiles are not distinct") + rbin = np.clip( + np.searchsorted(resolution_edges, data[:, 1], side="right") - 1, 0, n_bins - 1 + ) + snr_edges = np.zeros((n_bins, n_bins + 1)) + sums = np.zeros((2, 7, n_bins, n_bins)) + for r in range(n_bins): + d = data[rbin == r].astype(np.float64) + snr_edges[r] = np.quantile(d[:, 0], np.linspace(0, 1, n_bins + 1)) + if np.any(np.diff(snr_edges[r]) <= 0): + raise ValueError("SNR quantiles are not distinct") + idx = np.clip( + np.searchsorted(snr_edges[r], d[:, 0], side="right") - 1, 0, n_bins - 1 + ) + w = d[:, 2] + for component in range(2): + x, y = d[:, 3 + 2 * component], d[:, 4 + 2 * component] + for k, v in enumerate( + (w, w * x, w * y, w * x * x, w * x * y, w * y * y, np.ones_like(w)) + ): + sums[component, k, r] = np.bincount(idx, weights=v, minlength=n_bins) + return resolution_edges, snr_edges, sums + + +def galaxy_products(path, nside, n_bins, snr_range, size_ratio_range, chunk): + """Stream the galaxy catalogue once into map sums and weight-cell tallies.""" + npix = hp.nside2npix(nside) + sums = {key: np.zeros(npix) for key in GALAXY_SUMS} + snr_edges, size_edges = weight_grid_edges(n_bins, snr_range, size_ratio_range) + cell_count = np.zeros((n_bins, n_bins), dtype=np.int64) + cell_w_min = np.full((n_bins, n_bins), np.inf) + cell_w_max = np.full((n_bins, n_bins), -np.inf) + out_of_range = {"snr_low": 0, "snr_high": 0, "size_low": 0, "size_high": 0} + weights = [] + cell_response = np.zeros((n_bins, n_bins)) + cell_ee = np.zeros((n_bins, n_bins)) + response_edges = np.linspace(-5, 5, 501) + response_hist = np.zeros((4, 500), dtype=np.int64) + response_outside = np.zeros(4, dtype=np.int64) + bias_sums = np.zeros((len(BIAS_EDGES), 2, 10, 20)) + + with fitsio.FITS(path) as fits: + table = fits[1] + n_rows = table.get_nrows() + leakage_data = np.empty((n_rows, 7), dtype=np.float32) + for start in range(0, n_rows, chunk): + rows = table[GALAXY_COLUMNS][start : start + chunk] + pix = hp.ang2pix(nside, rows["RA"], rows["Dec"], lonlat=True, nest=True) + w = rows["w_des"].astype(np.float64) + e1, e2 = rows["e1"], rows["e2"] + for key, value in ( + ("n_gal", None), + ("sum_w", w), + ("sum_w2", w**2), + ("sum_w_e1", w * e1), + ("sum_w_e2", w * e2), + ("sum_w2_ee", w**2 * (e1**2 + e2**2)), + ("sum_w_e1_psf", w * rows["e1_PSF"]), + ("sum_w_e2_psf", w * rows["e2_PSF"]), + ("sum_w_fwhm_psf", w * rows["fwhm_PSF"]), + ("sum_mag", rows["mag"]), + ): + _accumulate(sums[key], pix, value, npix) + + snr = rows["snr"] + size_ratio = rows["NGMIX_T_NOSHEAR"] / rows["NGMIX_T_PSF_RECONV_NOSHEAR"] + out_of_range["snr_low"] += int(np.sum(snr <= snr_range[0])) + out_of_range["snr_high"] += int(np.sum(snr > snr_range[1])) + out_of_range["size_low"] += int(np.sum(size_ratio <= size_ratio_range[0])) + out_of_range["size_high"] += int(np.sum(size_ratio > size_ratio_range[1])) + i = weight_cell_index(snr, snr_edges) + j = weight_cell_index(size_ratio, size_edges) + flat = i * n_bins + j + cell_count += np.bincount(flat, minlength=n_bins**2).reshape(n_bins, -1) + response = 0.5 * (rows["R_g11"] + rows["R_g22"]) + ee = 0.5 * (rows["e1_uncal"] ** 2 + rows["e2_uncal"] ** 2) + cell_response += np.bincount( + flat, weights=response, minlength=n_bins**2 + ).reshape(n_bins, -1) + cell_ee += np.bincount(flat, weights=ee, minlength=n_bins**2).reshape( + n_bins, -1 + ) + for k, name in enumerate(("R_g11", "R_g22", "R_g12", "R_g21")): + hist, _ = np.histogram(rows[name], bins=response_edges) + response_hist[k] += hist + response_outside[k] += len(rows) - hist.sum() + with np.errstate(invalid="ignore", divide="ignore"): + resolution = 1 / (1 + np.sqrt(size_ratio)) + for column, data in enumerate( + (snr, resolution, w, rows["e1_PSF"], e1, rows["e2_PSF"], e2) + ): + leakage_data[start : start + len(rows), column] = data + props = ( + rows["e1_PSF"], + rows["e2_PSF"], + rows["fwhm_PSF"], + rows["mag"], + snr, + size_ratio, + np.sqrt(np.maximum(rows["NGMIX_T_NOSHEAR"], 0)), + rows["RA"], + rows["Dec"], + w, + ) + for p, (x, edges) in enumerate(zip(props, BIAS_EDGES.values())): + idx = np.searchsorted(edges, x, side="right") - 1 + ok = (idx >= 0) & (idx < 20) & np.isfinite(x) + b, xx, ww = idx[ok], x[ok].astype(float), w[ok] + for component in range(2): + y = rows[f"e{component + 1}"][ok].astype(float) + for k, v in enumerate( + ( + ww, + ww * xx, + ww * y, + ww * xx * xx, + ww * xx * y, + ww * y * y, + ww * ww, + np.ones_like(ww), + ww * ww * y, + ww * ww * y * y, + ) + ): + bias_sums[p, component, k] += np.bincount( + b, weights=v, minlength=20 + ) + np.minimum.at(cell_w_min.ravel(), flat, w) + np.maximum.at(cell_w_max.ravel(), flat, w) + weights.append(w) + + resolution_edges, leakage_snr_edges, leakage_sums = leakage_cells(leakage_data) + del leakage_data + values, multiplicity = np.unique(np.concatenate(weights), return_counts=True) + cells = { + "snr_edges": snr_edges, + "size_ratio_edges": size_edges, + "count": cell_count, + "sum_response": cell_response, + "sum_uncal_ee": cell_ee, + "response_edges": response_edges, + "response_hist": response_hist, + "response_outside": response_outside, + "resolution_edges": resolution_edges, + "leakage_snr_edges": leakage_snr_edges, + "leakage_sums": leakage_sums, + "bias_names": np.array(list(BIAS_EDGES)), + "bias_edges": np.array(list(BIAS_EDGES.values())), + "bias_sums": bias_sums, + "w_min": np.where(cell_count > 0, cell_w_min, np.nan), + "w_max": np.where(cell_count > 0, cell_w_max, np.nan), + "w_values": values, + "w_multiplicity": multiplicity, + "n_rows": n_rows, + "out_of_range": json.dumps(out_of_range), + } + return sums, cells + + +def star_sums(path, nside): + """Star counts and star-minus-PSF-model residual sums per pixel.""" + npix = hp.nside2npix(nside) + stars = read_catalogue(path, columns=STAR_COLUMNS) + keep = (stars["HSM_FLAG_PSF"] == 0) & (stars["HSM_FLAG_STAR"] == 0) + stars = stars[keep] + pix = hp.ang2pix(nside, stars["RA"], stars["DEC"], lonlat=True, nest=True) + t_star, t_psf = stars["HSM_T_STAR"], stars["HSM_T_PSF"] + sums = {key: np.zeros(npix) for key in STAR_SUMS} + for key, value in ( + ("n_star", None), + ("sum_de1", stars["HSM_G1_STAR"] - stars["HSM_G1_PSF"]), + ("sum_de2", stars["HSM_G2_STAR"] - stars["HSM_G2_PSF"]), + ("sum_dT_over_T_psf", (t_star - t_psf) / t_psf), + ("sum_T_psf", t_psf), + ): + _accumulate(sums[key], pix, value, npix) + residuals = [ + stars["HSM_G1_STAR"] - stars["HSM_G1_PSF"], + stars["HSM_G2_STAR"] - stars["HSM_G2_PSF"], + stars["HSM_M4_1_STAR"] - stars["HSM_M4_1_PSF"], + stars["HSM_M4_2_STAR"] - stars["HSM_M4_2_PSF"], + np.sqrt(np.where((t_star > 0) & (t_psf > 0), t_star / t_psf, np.nan)) - 1, + ] + edges = ( + [np.linspace(-0.5, 0.5, 401)] * 2 + + [np.linspace(-0.5, 0.5, 401)] * 2 + + [np.linspace(-1, 1, 401)] + ) + histograms = {} + for i, (values, bins) in enumerate(zip(residuals, edges)): + values = values[np.isfinite(values)] + histograms[f"edges_{i}"] = bins + histograms[f"hist_{i}"] = np.histogram(values, bins=bins)[0] + histograms[f"mean_{i}"] = values.mean() + histograms[f"std_{i}"] = values.std() + histograms[f"outside_{i}"] = len(values) - histograms[f"hist_{i}"].sum() + return sums, int(keep.sum()), histograms + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--galaxies", required=True, help="calibrated galaxy FITS") + parser.add_argument("--stars", required=True, help="star/PSF catalogue") + parser.add_argument("--version", required=True) + parser.add_argument("--out-dir", required=True) + parser.add_argument("--nside", type=int, default=512) + parser.add_argument("--weight-bins", type=int, default=20) + parser.add_argument("--snr-range", type=float, nargs=2, default=(10.0, 500.0)) + parser.add_argument("--size-ratio-range", type=float, nargs=2, default=(0.707, 3.0)) + parser.add_argument("--chunk", type=int, default=4_000_000) + args = parser.parse_args(argv) + + out_dir = Path(args.out_dir) + gal, cells = galaxy_products( + args.galaxies, + args.nside, + args.weight_bins, + args.snr_range, + args.size_ratio_range, + args.chunk, + ) + stars, n_stars, star_hist = star_sums(args.stars, args.nside) + occupied = np.flatnonzero((gal["n_gal"] > 0) | (stars["n_star"] > 0)) + np.savez_compressed( + out_dir / f"field_maps_{args.version}.npz", + version=args.version, + selection="calibrated cut catalogue; stars with both HSM flags zero", + nside=args.nside, + nest=True, + pixels=occupied, + n_stars_used=n_stars, + **{key: value[occupied] for key, value in {**gal, **stars}.items()}, + ) + np.savez_compressed( + out_dir / f"star_residuals_{args.version}.npz", + version=args.version, + selection="PSF validation stars with both HSM flags zero", + units="dimensionless HSM ellipticity, fourth moments and radius ratio", + **star_hist, + ) + np.savez_compressed( + out_dir / f"weight_cells_{args.version}.npz", + version=args.version, + selection="calibrated cut catalogue", + units="SNR dimensionless; sizes are ngmix T ratios", + **cells, + ) + + +if __name__ == "__main__": + main() diff --git a/src/sp_validation/dashboard/object_bias.py b/src/sp_validation/dashboard/object_bias.py new file mode 100644 index 000000000..9d1bfbcd1 --- /dev/null +++ b/src/sp_validation/dashboard/object_bias.py @@ -0,0 +1,130 @@ +"""Object-wise PSF leakage and binned additive-bias regressions.""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import PolyCollection +from matplotlib.patches import Rectangle +from matplotlib.ticker import MaxNLocator, ScalarFormatter + +from . import style as S +from .sky import DIVERGING + + +def slopes(sums): + """Weighted least-squares slopes with an intercept, from raw sums.""" + sw, sx, sy, sxx, sxy = sums[:5] + with np.errstate(divide="ignore", invalid="ignore"): + return (sxy - sx * sy / sw) / (sxx - sx * sx / sw) + + +def leakage_fit(sums): + """Component WLS slopes and conventional residual-based fit errors.""" + values = np.array([slopes(s) for s in sums]) + sw, sx, sy, sxx, sxy, syy, count = np.moveaxis(sums, 1, 0) + with np.errstate(divide="ignore", invalid="ignore"): + sxxc = sxx - sx * sx / sw + rss = syy - sy * sy / sw - values * (sxy - sx * sy / sw) + errors = np.sqrt(np.maximum(rss, 0) / (count - 2) / sxxc) + return values, errors + + +def leakage(cells, out): + """Weighted component-wise PSF-ellipticity regressions in each cell.""" + values, errors = leakage_fit(cells["leakage_sums"]) + redges, sedges = cells["resolution_edges"], cells["leakage_snr_edges"] + limit = np.nanpercentile(np.abs(values), 95) + with S.figure_style(): + fig, axes = plt.subplots(1, 2, figsize=(7.2, 3)) + for ax, val, err, title in zip( + axes, values, errors, (r"$\alpha_1$", r"$\alpha_2$") + ): + polygons = [] + for i in range(len(redges) - 1): + for j in range(sedges.shape[1] - 1): + x0, x1 = sedges[i, j : j + 2] + y0, y1 = redges[i : i + 2] + polygons.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + if not np.isfinite(err[i, j]) or abs(val[i, j]) < 2 * err[i, j]: + ax.add_patch( + Rectangle( + (x0, y0), + x1 - x0, + y1 - y0, + fill=False, + hatch="///", + edgecolor="#777777", + lw=0, + zorder=3, + ) + ) + mesh = PolyCollection( + polygons, + array=val.ravel(), + cmap=DIVERGING, + clim=(-limit, limit), + edgecolors="none", + ) + ax.add_collection(mesh) + ax.set_xscale("log") + ax.set_xlim(sedges.min(), sedges.max()) + ax.set_ylim(redges[0], redges[-1]) + ax.set_xlabel("SNR") + ax.set_ylabel(r"$r_{\rm PSF}/(r_{\rm PSF}+r_{\rm gal})$") + S.colorbar(fig, mesh, ax, title) + return S.save(fig, out, "full") + + +def bias_fit(s): + """Fit weighted mean ellipticity versus property across populated bins.""" + sw, sx, sy, sxx, _, _, sw2, count, sw2y, sw2yy = s + with np.errstate(divide="ignore", invalid="ignore"): + x, y = sx / sw, sy / sw + var = np.maximum(sw2yy - 2 * y * sw2y + y * y * sw2, 0) / (sw * sw) + ok = (count > 1) & np.isfinite(x) & np.isfinite(y) & (var > 0) + if ok.sum() < 3: + return np.nan, np.nan + mean = np.sum(sx[ok]) / np.sum(sw[ok]) + std = np.sqrt(max(np.sum(sxx[ok]) / np.sum(sw[ok]) - mean**2, 0)) + x, y, weight = x[ok], y[ok], 1 / var[ok] + if len(x) < 3: + return np.nan, np.nan + x -= np.average(x, weights=weight) + den = np.sum(weight * x * x) + return std * np.sum(weight * x * y) / den, std * np.sqrt(1 / den) + + +def bias(cells, out): + """Slopes and fit significances for weighted binned means.""" + names = [ + r"galaxy $\sqrt{T}$" if name == "galaxy size" else name + for name in cells["bias_names"] + ] + fits = np.array([[bias_fit(s) for s in row] for row in cells["bias_sums"]]) + with S.figure_style(): + fig, axes = plt.subplots(1, 2, figsize=(7.2, 4.5), sharey=True) + y = np.arange(len(names)) + for component, color in enumerate(("#3D5BA0", "#BC4538")): + value, err = fits[:, component, 0], fits[:, component, 1] + axes[0].errorbar( + value, + y + (component - 0.5) * 0.18, + xerr=err, + fmt="o", + ms=3, + color=color, + label=rf"$e_{component + 1}$", + ) + axes[1].plot( + value / err, y + (component - 0.5) * 0.18, "o", ms=3, color=color + ) + for ax in axes: + ax.axvline(0, color=S.INK_MUTED, lw=0.7) + axes[0].set_yticks(y, names) + axes[0].set_xlabel(r"$\Delta e$ per $1\sigma$ of property") + formatter = ScalarFormatter(useMathText=True) + formatter.set_powerlimits((-2, 2)) + axes[0].xaxis.set_major_formatter(formatter) + axes[0].xaxis.set_major_locator(MaxNLocator(5)) + axes[1].set_xlabel("Slope / fit uncertainty") + axes[0].legend() + return S.save(fig, out, "full") diff --git a/src/sp_validation/dashboard/position_shear.py b/src/sp_validation/dashboard/position_shear.py new file mode 100644 index 000000000..8ce9eb675 --- /dev/null +++ b/src/sp_validation/dashboard/position_shear.py @@ -0,0 +1,76 @@ +"""TreeCorr position–shear estimator and its joint jackknife covariance. + +The caller constructs both catalogues using the same patch centres. This module +is deliberately independent of catalogue layout and does not generate randoms. +""" + +import numpy as np +import treecorr + + +def measure( + lenses, + sources, + *, + randoms=None, + min_sep=1.0, + max_sep=250.0, + nbins=20, + num_threads=None, +): + """Measure tangential and cross shear, optionally subtracting random lenses. + + All catalogues must use the same spatial patch centres. The returned + covariance has block order (tangential bins, cross bins), including the + covariance between the two and, when supplied, the random subtraction. + Separations are in arcminutes. + """ + centers = sources.patch_centers + if len(centers) < 2: + raise ValueError("jackknife requires at least two source patches") + for catalogue in (lenses,) if randoms is None else (lenses, randoms): + other = catalogue.patch_centers + if other.shape != centers.shape or not np.allclose(other, centers): + raise ValueError("all catalogues must share the source patch centres") + + config = dict( + min_sep=min_sep, + max_sep=max_sep, + nbins=nbins, + sep_units="arcmin", + var_method="jackknife", + ) + correlations = [] + for positions in (lenses,) if randoms is None else (lenses, randoms): + corr = treecorr.NGCorrelation(config) + corr.process(positions, sources, num_threads=num_threads) + correlations.append(corr) + + def vector(corrs): + result = np.concatenate((corrs[0].xi, corrs[0].xi_im)) + if len(corrs) == 2: + result = result - np.concatenate((corrs[1].xi, corrs[1].xi_im)) + return result + + values = vector(correlations) + covariance = treecorr.estimate_multi_cov( + correlations, "jackknife", func=vector, cross_patch_weight="simple" + ) + error = np.sqrt(np.maximum(np.diag(covariance), 0)) + dg = correlations[0] + result = dict( + theta=np.exp(dg.meanlogr), + xi_t=values[:nbins], + xi_x=values[nbins:], + error_t=error[:nbins], + error_x=error[nbins:], + covariance=covariance, + npairs=dg.npairs.copy(), + weight=dg.weight.copy(), + ) + if randoms is not None: + result.update( + random_npairs=correlations[1].npairs.copy(), + random_weight=correlations[1].weight.copy(), + ) + return result diff --git a/src/sp_validation/dashboard/products.py b/src/sp_validation/dashboard/products.py new file mode 100644 index 000000000..20cfd5597 --- /dev/null +++ b/src/sp_validation/dashboard/products.py @@ -0,0 +1,320 @@ +"""Loaders from cached cosmo_val products to the plain arrays the figures draw. + +Every loader takes explicit paths and returns a dict; none measures, fits or +transforms anything. Cosmological products are read through the blind-stamp +checks of :mod:`sp_validation.sacc_io`, so a figure can only draw vectors +saved under the blind its catalogue is declared under +(:func:`declared_blind`). + +:class:`RunProducts` resolves those paths inside a cosmo_val run directory for +one catalogue version; the figure functions never see the directory layout. +""" + +import glob +import json +import re +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +from sp_validation import sacc_io +from sp_validation.b_modes import load_cosebis_results, load_pure_eb_results + + +def _one(pattern): + matches = sorted(glob.glob(str(pattern))) + if len(matches) != 1: + raise FileNotFoundError(f"expected one match for {pattern}, found {matches}") + return Path(matches[0]) + + +def _maybe(pattern): + try: + return _one(pattern) + except FileNotFoundError: + return None + + +@dataclass +class RunProducts: + """Paths of one version's cached products in a cosmo_val run directory.""" + + run_dir: Path + version: str + extra_dir: Path | None = None + + @property + def cv(self): + return Path(self.run_dir) / "cosmo_val" + + @property + def assembled(self): + return self.cv / f"{self.version}.sacc" + + @property + def pure_eb(self): + return _one(self.cv / f"{self.version}_eb_minsep=*_data.npz") + + @property + def cosebis(self): + return _one(self.cv / f"{self.version}_cosebis_minsep=*_data.npz") + + @property + def cl_cov(self): + """NaMaster covariance of the C_ell band powers.""" + prefix = f"pseudo_cl_cov_{self.version}_" + binning = re.compile(r"(powspace|logspace|linear)_nbins=\d+\.fits") + matches = [ + p + for p in self.cv.glob(prefix + "*.fits") + if binning.fullmatch(p.name[len(prefix) :]) + ] + if len(matches) != 1: + raise FileNotFoundError( + f"expected one {prefix}.fits, found {matches}" + ) + return matches[0] + + @property + def psf_fit(self): + return _one(self.cv / f"psf_fit_{self.version}_minsep=*.npz") + + @property + def tau_cov(self): + return _one( + self.cv / "rho_tau_stats" / f"cov_tau_{self.version}_tomo_bin_all_*.npy" + ) + + @property + def leakage(self): + return self.cv / "leakage_coefficients.json" + + @property + def summary(self): + return self.cv / "bmode_summary.json" + + @property + def xi_covariance(self): + """OneCovariance reporting-grid ξ± covariance (.dat), if present.""" + return _maybe( + Path(self.run_dir) + / "cosmo_inference/data/covariance" + / f"covariance_{self.version}_ng_minsep=*_masked" + / f"covariance_{self.version}_ng_minsep=*_masked.dat" + ) + + @property + def field_maps(self): + return _maybe( + Path(self.extra_dir or self.cv) / f"field_maps_{self.version}.npz" + ) + + @property + def cosebis_grid(self): + """COSEBIs B PTEs over every reporting-bin cut (:mod:`.cosebis_grid`).""" + return _maybe( + Path(self.extra_dir or self.cv) / f"cosebis_grid_{self.version}.npz" + ) + + @property + def weight_cells(self): + return _maybe( + Path(self.extra_dir or self.cv) / f"weight_cells_{self.version}.npz" + ) + + +def catalogue_name(catalogues, version): + """The catalogue-config entry describing ``version``: its own, or the one + its ``_leak_corr`` / ``_seed`` variant derives from (as the workflow + resolves it).""" + if version in catalogues: + return version + return re.sub(r"_seed\d+$", "", re.sub(r"_leak_corr$", "", version)) + + +def declared_blind(cat_config, version): + """The blind ``version`` is declared under in the catalogue config. + + The expected blind comes from the declaration, never from a product's own + stamp, so a stale product of a catalogue that has since been put under a + blind is refused by the loaders rather than drawn. + """ + import yaml + + from sp_validation import blinding + + catalogues = yaml.safe_load(Path(cat_config).read_text()) + return blinding.blind_of(catalogues, catalogue_name(catalogues, version)) + + +def load_xi(sacc_path, blind): + """Reporting-grid ξ± with the covariance carried by the assembled SACC.""" + s = sacc_io.load(sacc_path, blind=blind) + view = sacc_io.xi_correlation(s, grid="reporting") + n = len(view.xip) + cov = np.asarray(view.cov) + return { + "theta": view.meanr, + "xip": view.xip, + "xim": view.xim, + "sigma_xip": np.sqrt(np.diag(cov)[:n]), + "sigma_xim": np.sqrt(np.diag(cov)[n:]), + "cov": cov, + "blind": blind, + } + + +def load_pure_eb(npz_path, blind): + r = load_pure_eb_results(npz_path, blind=blind) + return { + "theta": r["theta"], + "left_edges": r["left_edges"], + "right_edges": r["right_edges"], + "xip": r["xip"], + "xim": r["xim"], + "sigma_xip": np.sqrt(r["var_xip"]), + "sigma_xim": np.sqrt(r["var_xim"]), + **{ + f"{xi}_{m}": r[f"{xi}_{m}"] + for xi in ("xip", "xim") + for m in ("E", "B", "amb") + }, + **{ + f"sigma_{xi}_{m}": r[f"std_{xi}_{m}"] + for xi in ("xip", "xim") + for m in ("E", "B", "amb") + }, + "cov": r["cov"], + "pte_matrices": r["pte_matrices"], + } + + +def load_cosebis(npz_path, blind): + """Every cached COSEBIs scale cut, keyed by ``(theta_min, theta_max)``.""" + cuts, _ = load_cosebis_results(npz_path, blind=blind) + data = sacc_io.load_npz(npz_path, blind=blind) + out = {} + for cut, r in cuts.items(): + n = len(r["Bn"]) + cov = r["cov"] + out[tuple(float(c) for c in cut)] = { + "En": r["En"], + "Bn": r["Bn"], + "sigma_E": np.sqrt(np.diag(cov)[:n]), + "sigma_B": np.sqrt(np.diag(cov)[n:]), + "cov": cov, + "pte_B": float(r["pte_B"]), + } + return { + "cuts": out, + "fiducial": tuple(float(c) for c in data["scale_cut"]), + "nmodes": len(data["Bn"]), + } + + +def powspace_edges(ell_eff, lmin=None, lmax=2048, power=0.5): + """Inclusive multipole edges of the powspace band powers. + + Reconstructs the NaMaster binning of + :func:`sp_validation.pseudo_cl.make_namaster_bin` and checks that each + band's mean multipole reproduces the stored effective ell. + """ + from sp_validation.pseudo_cl import LMIN + + lmin = LMIN if lmin is None else lmin + n = len(ell_eff) + ells = np.arange(lmin, lmax + 1) + bins_ell = np.power(np.linspace(lmin**power, lmax**power, n + 1), 1 / power) + bpws = np.digitize(ells.astype(float), bins_ell) - 1 + bpws[0], bpws[-1] = 0, n - 1 + lo = np.array([ells[bpws == i].min() for i in range(n)]) + hi = np.array([ells[bpws == i].max() for i in range(n)]) + means = np.array([ells[bpws == i].mean() for i in range(n)]) + if not np.allclose(means, ell_eff, atol=0.51): + raise ValueError("powspace edges do not reproduce the stored ell_eff") + return lo, hi + + +def load_cosebis_grid(npz_path, blind): + """The COSEBIs scale-cut PTE grid, checked against the expected blind.""" + with np.load(npz_path) as f: + d = {k: f[k] for k in f.files} + if str(d.pop("blind")) != blind: + raise ValueError(f"{npz_path} was computed under another blind") + return d + + +def load_cl(sacc_path, cov_path, blind): + """Band powers, their multipole edges, and the NaMaster covariance blocks.""" + from astropy.io import fits + + s = sacc_io.load(sacc_path, blind=blind) + ell, cl_ee, cl_bb, cl_eb, _ = sacc_io.get_pseudo_cl(s, (0, 0)) + ell = np.asarray(ell) + lo, hi = powspace_edges(ell) + with fits.open(cov_path) as hdus: + cov = { + h.name.removeprefix("COVAR_"): np.asarray(h.data) + for h in hdus + if h.name.startswith("COVAR_") + } + return { + "ell": ell, + "ell_lo": lo, + "ell_hi": hi, + "EE": cl_ee, + "BB": cl_bb, + "EB": cl_eb, + "cov": cov, + "sigma_BB": np.sqrt(np.diag(cov["BB_BB"])), + "sigma_EB": np.sqrt(np.diag(cov["EB_EB"])), + "sigma_EE": np.sqrt(np.diag(cov["EE_EE"])), + } + + +def load_rho_tau(sacc_path, tau_cov_path, psf_fit_path, blind): + """ρ/τ statistics with their covariance blocks, and the PSF-fit samples.""" + s = sacc_io.load(sacc_path, blind=blind) + cov = s.covariance.dense + rho = {} + for k in range(6): + theta, p, m = sacc_io.get_rho(s, k) + idx = s.indices(sacc_io.RHO_PLUS.format(k=k)) + rho[k] = {"p": p, "m": m, "sigma_p": np.sqrt(np.abs(np.diag(cov)[idx]))} + tau = {} + for k in (0, 2, 5): + theta_tau, p, m = sacc_io.get_tau(s, (0,), k) + tau[k] = {"p": p, "m": m} + tau_cov = np.load(tau_cov_path) + n = len(theta_tau) + for i, k in enumerate((0, 2, 5)): + var = np.diag(tau_cov)[i * n : (i + 1) * n] + tau[k]["var_p"] = var + fit = dict(np.load(psf_fit_path)) + return { + "theta": theta, + "theta_tau": theta_tau, + "rho": rho, + "tau": tau, + "tau_cov": tau_cov, + "fit": fit, + } + + +def load_json(path): + with open(path) as f: + return json.load(f) + + +def load_correlation(path): + """A :mod:`sp_validation.dashboard.correlations` product as + ``{label: {quantity: array}}`` plus ``"provenance"``.""" + with np.load(path, allow_pickle=False) as data: + out = {"provenance": json.loads(str(data["provenance"]))} + for label in data["labels"]: + prefix = f"{label}__" + out[str(label)] = { + k[len(prefix) :]: data[k] for k in data.files if k.startswith(prefix) + } + return out diff --git a/src/sp_validation/dashboard/psf.py b/src/sp_validation/dashboard/psf.py new file mode 100644 index 000000000..6887b7b65 --- /dev/null +++ b/src/sp_validation/dashboard/psf.py @@ -0,0 +1,366 @@ +"""PSF leakage: ρ statistics, the τ fit and its posterior, the propagated +systematic, and every estimate of the leakage coefficient α side by side. + +The τ model is the one the ρ/τ fit uses (shear_psf_leakage, with η): + + τ0 = α ρ0 + β ρ2 + η ρ5, τ2 = α ρ2 + β ρ1 + η ρ4, τ5 = α ρ5 + β ρ4 + η ρ3 + +evaluated here on the cached posterior samples; nothing is refitted. +""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.lines import Line2D +from scipy.ndimage import gaussian_filter + +from . import style as S + +# PSF figures carry no E/B mode, so they use the page accents rather than the +# mode hues: one accent for the fitted model, three validated categorical +# slots for the ρ statistics within a panel. +MODEL = "#3D5BA0" # page cobalt +SERIES = ["#2a78d6", "#eb6834", "#1baf7a"] + +TAU_MODEL = { + 0: ((0, 0), (1, 2), (2, 5)), + 2: ((0, 2), (1, 1), (2, 4)), + 5: ((0, 5), (1, 4), (2, 3)), +} +PARAMS = (r"\alpha", r"\beta", r"\eta") + + +def tau_model_samples(rt, k, samples): + """τ_{k,+}(θ) for each posterior sample, shape ``(n_samples, n_theta)``.""" + model = 0 + for param, rho_k in TAU_MODEL[k]: + model = model + samples[:, [param]] * rt["rho"][rho_k]["p"][None, :] + return model + + +def _power_of_ten(values): + top = np.nanmax(np.abs(values)) + return int(np.floor(np.log10(top))) if top > 0 else 0 + + +def tau_fit(rt, out): + """τ_{0,2,5}+ with the posterior band of the fitted model.""" + theta = rt["theta_tau"] + samples = rt["fit"]["flat_samples"] + with S.figure_style(): + fig, axes = plt.subplots(1, 3, figsize=(7.2, 2.5)) + for ax, k in zip(axes, (0, 2, 5)): + tau = rt["tau"][k]["p"] + sigma = np.sqrt(np.clip(rt["tau"][k]["var_p"], 0, None)) + model = tau_model_samples(rt, k, samples) + lo, med, hi = np.percentile(model, [16, 50, 84], axis=0) + p = _power_of_ten(np.concatenate([tau + sigma, tau - sigma])) + scale = 10.0**p + S.zero_line(ax) + ax.fill_between(theta, lo / scale, hi / scale, color=MODEL, alpha=0.3, lw=0) + ax.plot(theta, med / scale, color=MODEL, lw=1.1) + ax.errorbar( + theta, + tau / scale, + yerr=sigma / scale, + marker="o", + color=S.INK, + **S.DATA_VECTOR_MARK, + ) + S.theta_axis(ax) + ax.set_xticks([1, 10, 100]) + ax.set_xticklabels(["$1$", "$10$", "$100$"]) + ax.set_xlabel(r"$\theta$ [arcmin]") + ax.set_ylabel(rf"$\tau_{{{k},+}} \times 10^{{{-p}}}$") + S.label_box(ax, rf"$\tau_{{{k}}}$", loc="upper right") + handles = [ + Line2D( + [], + [], + ls="none", + marker="o", + color=S.INK, + markersize=3.5, + label=r"measured $\tau_{k,+}$", + ), + Line2D( + [], + [], + color=MODEL, + lw=4, + alpha=0.4, + label=r"$\alpha, \beta, \eta$ posterior (68%)", + ), + ] + fig.legend(handles=handles, loc="outside upper center", ncol=2) + return S.save(fig, out, "full") + + +def _contour_levels(h, fractions=(0.68, 0.95)): + flat = np.sort(h.ravel())[::-1] + cum = np.cumsum(flat) / flat.sum() + return sorted(flat[np.searchsorted(cum, f)] for f in fractions) + + +def posterior(rt, out, markers=(0, 1, 1)): + """Corner plot of the (α, β, η) posterior from the cached samples.""" + samples = rt["fit"]["flat_samples"] + n = samples.shape[1] + with S.figure_style(): + fig, axes = plt.subplots(n, n, figsize=(3.6, 3.6)) + lims = [np.percentile(samples[:, i], [0.3, 99.7]) for i in range(n)] + for i in range(n): + for j in range(n): + ax = axes[i, j] + ax.tick_params(labelsize=7, top=False, right=False) + if j > i: + ax.set_visible(False) + continue + if i == j: + x = samples[:, i] + ax.hist( + x, + bins=40, + range=lims[i], + histtype="stepfilled", + color=MODEL, + alpha=0.25, + ) + ax.hist( + x, bins=40, range=lims[i], histtype="step", color=MODEL, lw=0.9 + ) + lo, med, hi = np.percentile(x, [16, 50, 84]) + ax.set_title( + rf"${PARAMS[i]} = {med:.3g}^{{+{hi - med:.2g}}}" + rf"_{{-{med - lo:.2g}}}$", + fontsize=7.5, + ) + ax.set_yticks([]) + if markers[i] is not None: + ax.axvline(markers[i], color=S.INK_MUTED, lw=0.7, ls=":") + else: + h, xe, ye = np.histogram2d( + samples[:, j], samples[:, i], bins=40, range=[lims[j], lims[i]] + ) + h = gaussian_filter(h, 1.0) + levels = _contour_levels(h) + xc, yc = (xe[1:] + xe[:-1]) / 2, (ye[1:] + ye[:-1]) / 2 + ax.contourf( + xc, + yc, + h.T, + levels=[levels[0], h.max()], + colors=[MODEL], + alpha=0.18, + ) + ax.contourf( + xc, + yc, + h.T, + levels=[levels[1], h.max()], + colors=[MODEL], + alpha=0.38, + ) + ax.contour( + xc, yc, h.T, levels=levels, colors=[MODEL], linewidths=0.7 + ) + for v, axis in ((markers[j], ax.axvline), (markers[i], ax.axhline)): + if v is not None: + axis(v, color=S.INK_MUTED, lw=0.7, ls=":") + ax.set_ylim(lims[i]) + ax.set_xlim(lims[j]) + ax.locator_params(nbins=3) + if i == n - 1: + ax.set_xlabel(rf"${PARAMS[j]}$") + else: + ax.set_xticklabels([]) + if j == 0 and i > 0: + ax.set_ylabel(rf"${PARAMS[i]}$") + elif j > 0 and i != j: + ax.set_yticklabels([]) + fig.get_layout_engine().set(hspace=0.02, wspace=0.02) + return S.save(fig, out, "half") + + +def rho_stats(rt, out): + """|ρ_{k,+}| on a log axis: filled markers positive, open negative.""" + theta = rt["theta"] + groups = [ + ((0, 1, 2), r"$\rho_{0,1,2}$: ellipticity"), + ((3, 4, 5), r"$\rho_{3,4,5}$: size-weighted"), + ] + with S.figure_style(): + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.7), sharey=True) + for ax, (ks, label) in zip(axes, groups): + for c, k in zip(SERIES, ks): + rho = rt["rho"][k]["p"] + sig = rt["rho"][k]["sigma_p"] + pos = rho > 0 + ax.errorbar( + theta, + np.abs(rho), + yerr=sig, + color=c, + ls="-", + lw=0.7, + marker="none", + elinewidth=0.6, + capsize=0, + alpha=0.9, + ) + ax.plot( + theta[pos], + rho[pos], + "o", + color=c, + ms=3.4, + mec=c, + label=rf"$\rho_{k}$", + ) + ax.plot( + theta[~pos], + -rho[~pos], + "o", + color=c, + ms=3.4, + mfc=S.RAG, + mec=c, + mew=0.9, + ) + ax.set_yscale("log") + S.theta_axis(ax) + ax.set_xticks([1, 10, 100]) + ax.set_xticklabels(["$1$", "$10$", "$100$"]) + ax.set_xlabel(r"$\theta$ [arcmin]") + ax.legend(loc="upper right", ncol=3, handletextpad=0.2, columnspacing=0.8) + S.header(ax, label) + axes[0].set_ylabel(r"$|\rho_{k,+}(\theta)|$") + axes[0].set_ylim(top=axes[0].get_ylim()[1] * 8) + S.header(axes[1], r"open markers: $\rho < 0$", side="right") + return S.save(fig, out, "full") + + +def xi_sys_ratio(rt, xi, out, reference=0.1): + """Propagated PSF systematic relative to the blinded ξ±.""" + fit = rt["fit"] + theta = fit["theta"] + with S.figure_style(): + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.5)) + for ax, comp, key, sign in zip(axes, ("plus", "minus"), ("xip", "xim"), "+-"): + ratio = fit[f"samples_{comp}"] / xi[key][None, :] + lo, med, hi = np.percentile(ratio, [16, 50, 84], axis=0) + for r in (-reference, reference): + ax.axhline(r, color=S.INK_MUTED, lw=0.7, ls=(0, (2, 2))) + S.zero_line(ax) + ax.fill_between(theta, lo, hi, color=MODEL, alpha=0.3, lw=0) + ax.plot(theta, med, color=MODEL, lw=1.1) + S.theta_axis(ax) + ax.set_xticks([1, 10, 100]) + ax.set_xticklabels(["$1$", "$10$", "$100$"]) + ax.set_xlabel(r"$\theta$ [arcmin]") + ax.set_ylabel(rf"$\xi_{sign}^{{\rm sys}} / \xi_{sign}$") + top = max(np.nanmax(hi), reference) * 1.25 + bot = min(np.nanmin(lo), -reference) * 1.25 + ax.set_ylim(max(bot, -1.0), min(top, 1.0)) + S.label_box(ax, rf"$\xi_{sign}$", loc="upper left") + return S.save(fig, out, "full") + + +def xi_sys_comparison(rt, out): + """Compare the full fitted systematic with tau_0 squared over rho_0.""" + theta = np.asarray(rt["theta"]) + # Pair-weighted mean separations differ slightly within the same bins. + if not ( + np.allclose(theta, rt["theta_tau"], rtol=0.002) + and np.allclose(theta, rt["fit"]["theta"]) + ): + raise ValueError("rho, tau and PSF fit angular grids differ") + rho = np.asarray(rt["rho"][0]["p"]) + tau = np.asarray(rt["tau"][0]["p"]) + with np.errstate(divide="ignore", invalid="ignore"): + simple = np.where(rho != 0, tau**2 / rho, np.nan) + lo, med, hi = np.percentile(rt["fit"]["samples_plus"], [16, 50, 84], axis=0) + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 2.8)) + ax.fill_between(theta, lo, hi, color=MODEL, alpha=0.3, lw=0) + ax.plot(theta, med, color=MODEL, label=r"$\alpha,\beta,\eta$ model") + ax.plot( + theta, + simple, + color=SERIES[1], + marker="o", + ms=3, + label=r"$\tau_{0,+}^2/\rho_{0,+}$", + ) + S.theta_axis(ax) + if np.all(np.concatenate((lo, med, hi, simple)) > 0): + ax.set_yscale("log") + else: + S.zero_line(ax) + ax.set_yscale("symlog", linthresh=1e-8) + ax.set_xlabel(r"$\theta$ [arcmin]") + ax.set_ylabel(r"$\xi^{\rm sys}_+(\theta)$") + ax.legend() + return S.save(fig, out, "half") + + +ALPHA_ROWS = [ + ("aii_mean", r"$\langle a_{ii} \rangle$", "object-wise"), + ("a11", r"$a_{11}$", "object-wise"), + ("a22", r"$a_{22}$", "object-wise"), + ("alpha_mean", r"$\bar\alpha$", "scale-dependent"), + ("alpha_0", r"$\alpha(0)$", "scale-dependent"), + ("alpha_1", r"$\alpha(\theta_{\min})$", "scale-dependent"), +] + + +def alpha_estimates(leakage, rt, out): + """Forest plot of every PSF-leakage coefficient estimate (± 1σ).""" + rows = [] + for key, label, group in ALPHA_ROWS: + if key in leakage: + value, err = leakage[key] + rows.append((label, group, value, err, err)) + a = rt["fit"]["flat_samples"][:, 0] + lo, med, hi = np.percentile(a, [16, 50, 84]) + rows.append((r"$\alpha$", r"$\rho/\tau$ fit", med, med - lo, hi - med)) + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 2.9)) + y = np.arange(len(rows))[::-1].astype(float) + groups = [r[1] for r in rows] + # a small gap between estimator groups + for i in range(1, len(rows)): + if groups[i] != groups[i - 1]: + y[i:] -= 0.6 + ax.axvline(0, color=S.INK, lw=0.7, ls=(0, (4, 3)), alpha=0.6) + for yi, (label, group, v, em, ep) in zip(y, rows): + ax.errorbar( + [v], + [yi], + xerr=[[em], [ep]], + fmt="o", + color=S.INK, + ms=3.6, + elinewidth=0.9, + capsize=1.8, + ) + ax.set_yticks(y) + ax.set_yticklabels([r[0] for r in rows]) + ax.tick_params(axis="y", length=0, right=False) + ax.tick_params(axis="x", top=False) + prev = None + for yi, g in zip(y, groups): + if g != prev: + ax.text( + 0.98, + yi + 0.42, + g, + transform=ax.get_yaxis_transform(), + ha="right", + va="bottom", + fontsize=7.5, + color=S.INK_MUTED, + ) + prev = g + ax.set_ylim(y.min() - 0.7, y.max() + 0.9) + ax.set_xlabel("PSF leakage coefficient") + return S.save(fig, out, "half") diff --git a/src/sp_validation/dashboard/ptes.py b/src/sp_validation/dashboard/ptes.py new file mode 100644 index 000000000..61f07cc10 --- /dev/null +++ b/src/sp_validation/dashboard/ptes.py @@ -0,0 +1,82 @@ +"""B-mode PTE table in the layout of Paper II's PTE table. + +One row per catalogue version and scale selection (fiducial, full range); +columns COSEBIs B_n for n <= 6 and n <= 20, pure-B ξ+, ξ− and joint ξ±, and +C_ℓ^BB. Pure E/B PTEs come from the cached scale-cut matrices and the +COSEBIs n <= 20 PTE from the cached transform; the COSEBIs n <= 6 and C_ℓ^BB +PTEs are the same χ² against zero over the corresponding subset of the cached +data vector and covariance (no Hartlap factor: both covariances are +analytic). +""" + +import numpy as np +from scipy import stats + +from sp_validation.b_modes import bins_from_scale_cut + +# Column labels as (symbol, subscript, superscript, qualifier) so the page +# can typeset them with /. +COLUMNS = [ + {"key": "cosebis_n6", "group": "COSEBIs", "label": ["B", "n", "", "n ≤ 6"]}, + {"key": "cosebis_n20", "group": "COSEBIs", "label": ["B", "n", "", "n ≤ 20"]}, + {"key": "xip_B", "group": "Pure E/B", "label": ["ξ", "+", "B", ""]}, + {"key": "xim_B", "group": "Pure E/B", "label": ["ξ", "−", "B", ""]}, + {"key": "combined", "group": "Pure E/B", "label": ["ξ", "±", "B", "joint"]}, + {"key": "cl_BB", "group": "Cℓ", "label": ["C", "ℓ", "BB", ""]}, +] + + +def chi2_pte(data, cov): + return float(stats.chi2.sf(data @ np.linalg.solve(cov, data), len(data))) + + +def cosebis_pte(cut, nmodes): + """B_n PTE over the first ``nmodes`` modes of one cached cut.""" + n = len(cut["Bn"]) + block = cut["cov"][n:, n:] + return chi2_pte(cut["Bn"][:nmodes], block[:nmodes, :nmodes]) + + +def cl_pte(cl, key="BB", ell_cut=None): + """C_ℓ PTE over bands lying wholly inside ``ell_cut`` (all bands if None).""" + mask = np.ones(len(cl["ell"]), bool) + if ell_cut is not None: + mask = (cl["ell_lo"] >= ell_cut[0]) & (cl["ell_hi"] <= ell_cut[1]) + return chi2_pte(cl[key][mask], cl["cov"][f"{key}_{key}"][np.ix_(mask, mask)]) + + +def rows(pe, co, cl, theta_cut, ell_cut, full_cut): + """The fiducial and full-range rows for one version.""" + start, stop = bins_from_scale_cut(pe["left_edges"], pe["right_edges"], theta_cut) + n_theta = len(pe["theta"]) + selections = [ + ("Fiducial", (start, stop - 1), co["fiducial"], ell_cut), + ("Full range", (0, n_theta - 1), full_cut, None), + ] + out = [] + for name, (i, j), co_cut, l_cut in selections: + cut = co["cuts"][co_cut] + values = { + "cosebis_n6": cosebis_pte(cut, 6), + "cosebis_n20": cosebis_pte(cut, co["nmodes"]), + **{ + k: float(pe["pte_matrices"][k][i, j]) + for k in ("xip_B", "xim_B", "combined") + }, + "cl_BB": cl_pte(cl, "BB", l_cut), + } + ell_range = ( + f"{l_cut[0]} ≤ ℓ ≤ {l_cut[1]}" + if l_cut + else f"{cl['ell_lo'][0]} ≤ ℓ ≤ {cl['ell_hi'][-1]}" + ) + out.append( + { + "selection": name, + "theta": f"{pe['left_edges'][i]:.0f}′–{pe['right_edges'][j]:.0f}′", + "cosebis_theta": f"{co_cut[0]:g}′–{co_cut[1]:g}′", + "ell": ell_range, + "values": values, + } + ) + return out diff --git a/src/sp_validation/dashboard/render.py b/src/sp_validation/dashboard/render.py new file mode 100644 index 000000000..ff4c9f670 --- /dev/null +++ b/src/sp_validation/dashboard/render.py @@ -0,0 +1,618 @@ +"""Render the dashboard figure set for a cosmo_val run. + +Reads only cached products (see :mod:`sp_validation.dashboard.products`) and +writes the PNGs plus ``figures.json``, the manifest the page generator lays +out: the sections in page order, one entry per figure (section, optional +folded group, caption, size class) and the B-mode PTE table. A run with two +or more versions also gets the version comparisons and one pair of PTE rows +per version; the first version is the reference for every other figure. + +Usage (inside the container):: + + python -m sp_validation.dashboard.render --run-dir RUN --version SP_v2.0 \\ + --maps-dir PRODUCTS --out RUN/dashboard-figures +""" + +import argparse +import json +from pathlib import Path + +import numpy as np +import yaml + +from . import covariance as CV +from . import data_vectors as DV +from . import object_bias, ptes, selection_hist, sky, star_residuals +from . import products as P +from . import psf as PSF +from . import scale_cuts as SC +from . import tangential as TG +from . import versions as V +from . import weights as W + +SECTIONS = [ + ("data_vectors", "Data vectors"), + ("bmodes", "B modes"), + ("leakage", "PSF leakage"), + ("tangential", "Tangential shear tests"), + ("survey", "Weights, footprint and covariances"), +] +GROUPS = { + "scale_cuts": "PTEs across scale cuts", + "versions": "Catalogue versions", + "covariance": "Covariances", +} + + +def run_config(run_dir): + """Scale cuts from the run's recorded configuration, when present.""" + prov = Path(run_dir) / "provenance.json" + cuts = {"theta": (12.0, 83.0), "ell": (300, 1600), "theta_full": (1.0, 250.0)} + if prov.exists(): + cfg = yaml.safe_load(json.loads(prov.read_text())["config"]) + cuts["theta"] = tuple(map(float, cfg["cosmo_val"]["fiducial_scale_cut"])) + cuts["ell"] = ( + int(cfg["cl"]["fiducial_ell_min"]), + int(cfg["cl"]["fiducial_ell_max"]), + ) + cuts["theta_full"] = ( + float(cfg["cosebis"]["theta_min"]), + float(cfg["cosebis"]["theta_max"]), + ) + return cuts + + +class Figures: + """Collects figure entries while rendering.""" + + def __init__(self, out): + self.out = Path(out) + self.out.mkdir(parents=True, exist_ok=True) + self.entries = [] + + def path(self, name): + return self.out / f"{name}.png" + + def add(self, fid, section, caption, size, narrow=False, group=None): + entry = { + "id": fid, + "section": section, + "group": group, + "file": f"{fid}.png", + "caption": caption, + "size": size, + "alt": caption, + } + if narrow: + entry["narrow"] = f"{fid}_narrow.png" + self.entries.append(entry) + + +def load_version(run_dir, version, maps_dir, cat_config): + r = P.RunProducts(run_dir, version, maps_dir) + blind = P.declared_blind(cat_config, version) + return ( + r, + blind, + { + "pe": P.load_pure_eb(r.pure_eb, blind), + "co": P.load_cosebis(r.cosebis, blind), + "cl": P.load_cl(r.assembled, r.cl_cov, blind), + }, + ) + + +CORRELATIONS = [ + # (product stem, renderer, figure id, section, caption, size) + ( + "alpha_mag", + TG.alpha_mag, + "psf_alpha_mag", + "leakage", + "Scale-dependent leakage α(θ) = τ₀/ρ₀ for all galaxies and in galaxy-magnitude " + "tertiles (calibrated e₁, e₂ rescaled to each bin's shear response plus the " + "global selection response, w_des; jackknife errors). The selection " + "response is larger for the faint bin, so its |α| is likely underestimated " + "by ~10–20%. The paper's DES-Y3 comparison is omitted.", + "full", + ), + ( + "gamma_stars", + TG.gamma_stars, + "tan_psf_stars", + "tangential", + "Galaxy γt and γ× around PSF validation stars (valid HSM, one entry per star), " + "all and by star magnitude; calibrated e₁, e₂ with w_des, no random " + "subtraction; jackknife errors, χ²/ν against zero.", + "full", + ), + ( + "lambda_stars", + TG.lambda_stars, + "tan_lambda", + "tangential", + "PSF validation stars around galaxy positions: tangential and cross PSF-model " + "ellipticity (λ₁) and star-minus-model residual (λ₃), minus the same around " + "footprint randoms. Not a null test: it traces the PSF field.", + "full", + ), + ( + "gamma_tiles", + TG.gamma_tiles, + "tan_tiles", + "tangential", + "θγt and θγ× of galaxies around tile centres; jackknife errors.", + "full", + ), + ( + "gamma_gaia", + TG.gamma_gaia, + "tan_gaia", + "tangential", + "Galaxy γt and γ× around Gaia DR3 stars (RUWE < 1.4) in three G bins, minus " + "the same around footprint randoms; jackknife errors.", + "full", + ), +] + + +def render_correlations(figs, corr_dir, version): + """Figures from the correlation products present in ``corr_dir``.""" + for stem, renderer, fid, section, caption, size in CORRELATIONS: + path = Path(corr_dir) / f"{stem}_{version}.npz" + if path.exists(): + renderer(P.load_correlation(path), figs.path(fid)) + figs.add(fid, section, caption, size) + + +def render(run_dir, versions, out, cat_config, maps_dir=None, corr_dir=None): + cuts = run_config(run_dir) + theta_cut, ell_cut, full_cut = cuts["theta"], cuts["ell"], cuts["theta_full"] + loaded = [load_version(run_dir, v, maps_dir, cat_config) for v in versions] + ref, blind, d = loaded[0] + pe, co, cl = d["pe"], d["co"], d["cl"] + xi = P.load_xi(ref.assembled, blind) + figs = Figures(out) + tcut = f"{theta_cut[0]:g}′–{theta_cut[1]:g}′" + lcut = f"{ell_cut[0]} ≤ ℓ ≤ {ell_cut[1]}" + + # Data vectors + for layout, suffix in (("wide", ""), ("narrow", "_narrow")): + DV.xi_pm(xi, figs.path("xi_pm" + suffix), layout=layout, blind=blind) + DV.ell_cl(cl, figs.path("cl" + suffix), ell_cut, layout=layout, blind=blind) + figs.add( + "xi_pm", + "data_vectors", + "Blinded ξ± on the 20-bin reporting grid; errors from the analytic covariance.", + "full", + narrow=True, + ) + figs.add( + "cl", + "data_vectors", + f"Blinded Cℓ EE (NaMaster, mode-decoupled) as ℓCℓ. Shaded: outside {lcut}.", + "full", + narrow=True, + ) + + # B modes + DV.pure_eb(pe, figs.path("bmode_pure_eb"), theta_cut, blind=blind) + DV.pure_eb( + pe, figs.path("bmode_pure_eb_narrow"), theta_cut, layout="narrow", blind=blind + ) + figs.add( + "bmode_pure_eb", + "bmodes", + f"Pure-mode decomposition of ξ±. Shaded: outside the fiducial {tcut}; full range 1′–250′.", + "full", + narrow=True, + ) + DV.cosebis(co, figs.path("bmode_cosebis"), full_cut) + figs.add( + "bmode_cosebis", + "bmodes", + f"COSEBIs Bₙ/σₙ at the fiducial {tcut} (filled) and over the full range (open). " + "Shaded: modes beyond n = 6.", + "half", + ) + DV.cl_bmodes(cl, figs.path("bmode_cl"), ell_cut) + figs.add( + "bmode_cl", + "bmodes", + f"Cℓ BB and EB in units of their uncertainty. Shaded: outside {lcut}.", + "half", + ) + cg = P.load_cosebis_grid(ref.cosebis_grid, blind) if ref.cosebis_grid else None + for layout, suffix in (("wide", ""), ("narrow", "_narrow")): + SC.config_space( + pe, cg, figs.path("ptes_config" + suffix), theta_cut, layout=layout + ) + SC.harmonic(cl, figs.path("ptes_harmonic"), ell_cut) + figs.add( + "ptes_config", + "bmodes", + "B-mode PTE for every (θmin, θmax) cut on the 20-bin reporting grid: pure " + "ξ+B, pure ξ−B" + + (" and COSEBIs Bₙ (n ≤ 6)" if cg is not None else "") + + "; the fiducial cut is outlined.", + "full", + narrow=True, + group="scale_cuts", + ) + figs.add( + "ptes_harmonic", + "bmodes", + f"Cℓ BB PTE for every band range; the fiducial {lcut} is outlined.", + "half", + group="scale_cuts", + ) + if len(versions) > 1: + labelled = [(v, dd) for v, (_, _, dd) in zip(versions, loaded)] + V.pure_eb( + [(v, dd["pe"]) for v, dd in labelled], + figs.path("versions_pure_eb"), + theta_cut, + ) + V.cosebis( + [(v, dd["co"]) for v, dd in labelled], + figs.path("versions_cosebis"), + full_cut, + ) + V.cl([(v, dd["cl"]) for v, dd in labelled], figs.path("versions_cl"), ell_cut) + figs.add( + "versions_pure_eb", + "bmodes", + "θξ± and pure-B ξ±/σ across versions; boxes span the versions, bars mark the reference.", + "full", + group="versions", + ) + figs.add( + "versions_cosebis", + "bmodes", + "COSEBIs Bₙ/σₙ across versions.", + "full", + group="versions", + ) + figs.add( + "versions_cl", + "bmodes", + "Cℓ BB/σ and EB/σ across versions.", + "full", + group="versions", + ) + + table = { + "threshold": 0.05, + "columns": ptes.COLUMNS, + "rows": [ + {"version": v, **row} + for v, (_, _, dd) in zip(versions, loaded) + for row in ptes.rows( + dd["pe"], dd["co"], dd["cl"], theta_cut, ell_cut, full_cut + ) + ], + } + + # PSF leakage + star_path = Path(maps_dir or ref.cv) / f"star_residuals_{versions[0]}.npz" + stars = dict(np.load(star_path, allow_pickle=False)) if star_path.exists() else {} + star_keys = { + f"{kind}_{i}" for i in range(5) for kind in ("edges", "hist", "mean", "std") + } + if star_keys <= stars.keys(): + star_residuals.plot(stars, figs.path("star_residuals")) + figs.add( + "star_residuals", + "leakage", + "PSF validation stars (valid HSM): " + "star-minus-model ellipticity and fourth moments, and half-light " + "radius residual from √(T★/T_PSF) − 1. Means and σ use all finite detections.", + "full", + ) + else: + print("Skipping star_residuals: product missing") + rt = P.load_rho_tau(ref.assembled, ref.tau_cov, ref.psf_fit, blind) + PSF.tau_fit(rt, figs.path("psf_tau")) + figs.add( + "psf_tau", + "leakage", + "Galaxy–PSF correlations τ₀,₂,₅ with the posterior band of the α, β, η leakage model.", + "full", + ) + PSF.posterior(rt, figs.path("psf_posterior")) + figs.add( + "psf_posterior", + "leakage", + "Posterior of the leakage parameters (68 and 95%).", + "half", + ) + leakage = P.load_json(ref.leakage).get(versions[0], {}).get("tomo_bin_all", {}) + PSF.alpha_estimates(leakage, rt, figs.path("psf_alpha")) + figs.add( + "psf_alpha", + "leakage", + "Every estimate of the leakage coefficient: object-wise, scale-dependent and ρ/τ fit.", + "half", + ) + PSF.rho_stats(rt, figs.path("psf_rho")) + figs.add( + "psf_rho", "leakage", "ρ statistics of the PSF model and its residuals.", "full" + ) + PSF.xi_sys_ratio(rt, xi, figs.path("psf_xi_sys")) + figs.add( + "psf_xi_sys", + "leakage", + "Propagated PSF systematic relative to the blinded ξ±; dotted lines at ±10%.", + "full", + ) + + try: + PSF.xi_sys_comparison(rt, figs.path("psf_xi_sys_comparison")) + except ValueError as exc: + print(f"Skipping psf_xi_sys_comparison: {exc}") + else: + figs.add( + "psf_xi_sys_comparison", + "leakage", + "PSF systematic ξ⁺ from the α–β–η posterior (68% band) compared " + "with τ₀,+²/ρ₀,+; no cosmological signal is shown.", + "half", + ) + + cell_path = ref.weight_cells + if cell_path is not None: + bias_cells = dict(np.load(cell_path, allow_pickle=False)) + if { + "leakage_sums", + "leakage_snr_edges", + "resolution_edges", + } <= bias_cells.keys(): + object_bias.leakage(bias_cells, figs.path("object_leakage_bins")) + figs.add( + "object_leakage_bins", + "leakage", + "Weighted regressions of calibrated e₁, e₂ against the corresponding " + "PSF model ellipticity at each galaxy, without an object-wise " + "leakage correction. Eight equal-count resolution bins each have " + "eight equal-count SNR bins. Hatching marks |α| < 2σ; σ is the " + "weighted least-squares fit uncertainty.", + "full", + ) + else: + print("Skipping object_leakage_bins: regression sums missing") + if {"bias_sums", "bias_names"} <= bias_cells.keys() and bias_cells[ + "bias_sums" + ].shape[2] == 10: + object_bias.bias(bias_cells, figs.path("additive_bias")) + figs.add( + "additive_bias", + "leakage", + "Linear fits to weighted means of calibrated ellipticity in " + "20 fixed bins of each standardised property. " + "Error bars and significances use inverse-variance fits to binned " + "means with independent-galaxy shape-noise errors; they omit " + "spatially correlated noise, notably for RA and Dec. " + "Galaxy size is √T and the size ratio is T/T_PSF; only objects " + "inside the fixed property ranges are used.", + "full", + ) + else: + print("Skipping additive_bias: regression sums missing") + else: + print("Skipping object_leakage_bins and additive_bias: product missing") + + if corr_dir is not None: + render_correlations(figs, corr_dir, versions[0]) + + # Weights, footprint and field maps, covariances + selection_path = Path(maps_dir or ref.cv) / f"selection_hist_{versions[0]}.npz" + if selection_path.exists(): + selection = dict(np.load(selection_path, allow_pickle=False)) + selection_hist.plot(selection, figs.path("selection_hist")) + figs.add( + "selection_hist", + "survey", + "Cumulative v2 catalogue selections; histograms show magnitudes 15–30. " + "The paper's chip-defect/IMA_FLAGS, stellar-halo and pointing-count " + "stages have no corresponding cut in this run. The configured tile " + "overlap and r-band coverage cuts are included.", + "full", + ) + else: + print("Skipping selection_hist: product missing") + if ref.weight_cells is not None: + cells = dict(np.load(ref.weight_cells, allow_pickle=False)) + if "sum_response" not in cells or "sum_uncal_ee" not in cells: + print("Skipping weights: weight cells lack response and noise sums") + else: + W.weights(cells, figs.path("weights")) + W.weights(cells, figs.path("weights_narrow"), layout="narrow") + figs.add( + "weights", + "survey", + "Counts, weights, mean diagonal galaxy response and per-component " + "uncalibrated ellipticity RMS on the weight grid.", + "full", + narrow=True, + ) + if { + "response_hist", + "response_edges", + "response_outside", + "n_rows", + } <= cells.keys(): + W.response_histogram(cells, figs.path("response_histogram")) + figs.add( + "response_histogram", + "survey", + "Galaxy metacal response components over −5 to 5; at most " + f"{100 * np.max(cells['response_outside']) / int(cells['n_rows']):.2f}% " + "of entries per component lie outside this range or are non-finite. " + "v2 has no metacal star shapes.", + "half", + ) + else: + print("Skipping response_histogram: response histogram missing") + if ref.field_maps is not None: + maps = dict(np.load(ref.field_maps, allow_pickle=False)) + footprint = dict(np.load(ref.cv / f"footprint_{versions[0]}.npz")) + if "sum_dT_over_T_psf" in maps: + sky.paper_map_pair(maps, figs.path("sky_stars"), "stars") + figs.add( + "sky_stars", + "survey", + "PSF validation stars (valid HSM): star " + "detections per sky area, counted once per exposure, and " + "star-minus-model T divided by model T; HEALPix nside 256.", + "full", + ) + else: + print("Skipping sky_stars: model-normalised size residual missing") + if "sum_mag" in maps: + sky.paper_map_pair(maps, figs.path("sky_magnitude"), "magnitude") + figs.add( + "sky_magnitude", + "survey", + "PSF FWHM and unweighted mean galaxy r magnitude per " + "HEALPix nside-256 pixel.", + "full", + ) + else: + print("Skipping sky_magnitude: magnitude sums missing") + sky.footprint_map(maps, footprint, figs.path("sky_footprint")) + figs.add( + "sky_footprint", + "survey", + "Effective source density in a zenithal equal-area projection centred on the patch.", + "full", + ) + field_sets = ["galaxies"] + if "sum_dT_over_T_psf" in maps: + field_sets.append("psf") + else: + print("Skipping sky_psf: field maps lack model-normalised size residuals") + for which in field_sets: + sky.field_maps(maps, figs.path(f"sky_{which}"), which, footprint) + sky.field_maps( + maps, + figs.path(f"sky_{which}_narrow"), + which, + footprint, + layout="narrow", + ) + figs.add( + "sky_galaxies", + "survey", + "Galaxy fields: ellipticity dispersion, mean weight and weighted mean e₁, e₂.", + "full", + narrow=True, + ) + if "psf" in field_sets: + dt = sky.median_quantity(maps, "dT_over_T", 128) + figs.add( + "sky_psf", + "survey", + "PSF fields: model FWHM at galaxies, and star-minus-model size and " + "ellipticity residuals for PSF validation stars (valid HSM); " + f"size residual normalised by the model (median {100 * dt:+.2f}%).", + "full", + narrow=True, + ) + n = len(xi["theta"]) + CV.correlation( + xi["cov"], CV.xi_blocks(n), figs.path("cov_xi"), r"$\xi_\pm$ reporting grid" + ) + figs.add( + "cov_xi", + "survey", + "ξ± reporting-grid covariance (analytic, masked).", + "half", + group="covariance", + ) + CV.correlation( + pe["cov"], + CV.pure_eb_blocks(len(pe["theta"])), + figs.path("cov_pure_eb"), + "pure E/B modes", + ) + figs.add( + "cov_pure_eb", + "survey", + "Pure E, B and ambiguous modes of ξ±.", + "half", + group="covariance", + ) + fc = co["fiducial"] + CV.correlation( + co["cuts"][fc]["cov"], + CV.cosebis_blocks(co["nmodes"]), + figs.path("cov_cosebis"), + rf"COSEBIs, ${fc[0]:g}'\!-\!{fc[1]:g}'$", + ) + figs.add( + "cov_cosebis", + "survey", + "COSEBIs E and B modes at the fiducial cut.", + "half", + group="covariance", + ) + CV.correlation( + CV.cl_matrix(cl["cov"]), + CV.cl_blocks(len(cl["ell"])), + figs.path("cov_cl"), + r"$C_\ell$", + ) + figs.add( + "cov_cl", + "survey", + "Cℓ EE, EB and BB (Gaussian, NaMaster).", + "half", + group="covariance", + ) + + manifest = { + "schema_version": 2, + "versions": list(versions), + "blind": blind, + "sections": [{"id": s, "title": t} for s, t in SECTIONS], + "groups": GROUPS, + "pte_table": table, + "figures": figs.entries, + } + (figs.out / "figures.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + ) + return manifest + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--run-dir", required=True) + parser.add_argument( + "--version", + action="append", + required=True, + help="catalogue version; repeat for a comparison run", + ) + parser.add_argument( + "--cat-config", + required=True, + help="catalogue config declaring each version's blind (the run's cat_config.yaml)", + ) + parser.add_argument("--maps-dir", help="directory of field_maps/weight_cells NPZs") + parser.add_argument("--correlations-dir", help="directory of correlations NPZs") + parser.add_argument("--out", required=True) + args = parser.parse_args(argv) + manifest = render( + args.run_dir, + args.version, + args.out, + args.cat_config, + args.maps_dir, + args.correlations_dir, + ) + print(f"{len(manifest['figures'])} figures -> {args.out}") + + +if __name__ == "__main__": + main() diff --git a/src/sp_validation/dashboard/scale_cuts.py b/src/sp_validation/dashboard/scale_cuts.py new file mode 100644 index 000000000..678640c74 --- /dev/null +++ b/src/sp_validation/dashboard/scale_cuts.py @@ -0,0 +1,136 @@ +"""B-mode PTEs as a function of the scale cut (Paper II Figs. 8-9). + +Each heatmap cell is the PTE of the B-mode null test over the bins between a +lower cut (x) and an upper cut (y); the fiducial cut is outlined. The colour +map is anchored at the PTE threshold: solid blue below it, solid red above +one minus it, a smooth gradient between. +""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import Rectangle +from scipy import stats + +from sp_validation.b_modes import bins_from_scale_cut + +from . import style as S + + +def cl_pte_matrix(cl, key="BB"): + """PTE of ``key`` against zero for every contiguous band range [i, j].""" + data = cl[key] + cov = cl["cov"][f"{key}_{key}"] + n = len(data) + pte = np.full((n, n), np.nan) + for i in range(n): + for j in range(i, n): + d = data[i : j + 1] + c = cov[i : j + 1, i : j + 1] + pte[i, j] = stats.chi2.sf(d @ np.linalg.solve(c, d), j - i + 1) + return pte + + +def _tick_subset(n, k=6): + return np.unique(np.round(np.linspace(0, n - 1, k)).astype(int)) + + +def _heatmap(ax, pte, lo_labels, hi_labels, fid, label, cmap, norm): + """Triangular PTE heatmap on bin indices; ``fid`` is ``(i, j)`` inclusive.""" + n = pte.shape[0] + grid = np.arange(n + 1) - 0.5 + mesh = ax.pcolormesh( + grid, + grid, + pte.T, + cmap=cmap, + norm=norm, + edgecolors="face", + linewidth=0, + rasterized=True, + ) + ax.set_facecolor("#e4dccb") + ax.set_aspect("equal") + ax.set_xlim(-0.5, n - 0.5) + ax.set_ylim(-0.5, n - 0.5) + ticks = _tick_subset(n) + ax.set_xticks(ticks) + ax.set_xticklabels( + [f"${lo_labels[t]}$" for t in ticks], + rotation=45, + ha="right", + rotation_mode="anchor", + ) + ax.set_yticks(ticks) + ax.set_yticklabels([f"${hi_labels[t]}$" for t in ticks]) + ax.tick_params(which="both", top=False, right=False, length=2.5) + if fid is not None: + i, j = fid + ax.add_patch( + Rectangle( + (i - 0.5, j - 0.5), 1, 1, fill=False, edgecolor=S.INK, lw=1.6, zorder=5 + ) + ) + S.label_box(ax, label, loc="lower right") + return mesh + + +def _fmt_angle(v): + return f"{v:.0f}" if v >= 10 else f"{v:.1f}".rstrip("0").rstrip(".") + + +def config_space(pe, cg, out, scale_cut, layout="wide"): + """ξ+^B, ξ−^B and COSEBIs B_n (n ≤ 6) PTEs over all reporting-bin scale cuts. + + ``cg`` is the COSEBIs grid product (:mod:`.cosebis_grid`); without it the + COSEBIs panel is left out. + """ + lo = [_fmt_angle(v) for v in pe["left_edges"]] + hi = [_fmt_angle(v) for v in pe["right_edges"]] + start, stop = bins_from_scale_cut(pe["left_edges"], pe["right_edges"], scale_cut) + panels = [ + (pe["pte_matrices"]["xip_B"], (start, stop - 1), r"$\xi_+^B$"), + (pe["pte_matrices"]["xim_B"], (start, stop - 1), r"$\xi_-^B$"), + ] + if cg is not None: + c0, c1 = bins_from_scale_cut(cg["left_edges"], cg["right_edges"], scale_cut) + panels.append((cg["pte_B_n6"], (c0, c1 - 1), r"COSEBIs $B_n$")) + cmap, norm = S.pte_cmap(), S.pte_norm() + n = len(panels) + with S.figure_style(): + if layout == "wide": + fig, axes = plt.subplots(1, n, figsize=(2.4 * n, 2.75), sharey=True) + else: + fig, axes = plt.subplots(n, 1, figsize=(3.6, 3.2 * n), sharex=True) + for ax, (pte, fid, label) in zip(axes, panels): + mesh = _heatmap(ax, pte, lo, hi, fid, label, cmap, norm) + for ax in axes if layout == "wide" else axes[-1:]: + ax.set_xlabel(r"$\theta_{\min}$ [arcmin]") + for ax in axes[:1] if layout == "wide" else axes: + ax.set_ylabel(r"$\theta_{\max}$ [arcmin]") + S.pte_colorbar( + fig, + mesh, + axes, + shrink=0.9 if layout == "wide" else 0.5, + aspect=25, + pad=0.02, + location="right" if layout == "wide" else "bottom", + ) + return S.save(fig, out, "full" if layout == "wide" else "half") + + +def harmonic(cl, out, ell_cut): + """C_ℓ^BB PTEs over every contiguous band range (Paper II Fig. 9).""" + pte = cl_pte_matrix(cl, "BB") + inside = np.flatnonzero((cl["ell_lo"] >= ell_cut[0]) & (cl["ell_hi"] <= ell_cut[1])) + fid = (inside[0], inside[-1]) + lo = [f"{v:.0f}" for v in cl["ell_lo"]] + hi = [f"{v:.0f}" for v in cl["ell_hi"]] + cmap, norm = S.pte_cmap(), S.pte_norm() + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 3.2)) + mesh = _heatmap(ax, pte, lo, hi, fid, r"$C_\ell^{BB}$", cmap, norm) + ax.set_xlabel(r"$\ell_{\min}$") + ax.set_ylabel(r"$\ell_{\max}$") + S.pte_colorbar(fig, mesh, [ax], aspect=25, pad=0.02, shrink=0.9) + return S.save(fig, out, "half") diff --git a/src/sp_validation/dashboard/selection_hist.py b/src/sp_validation/dashboard/selection_hist.py new file mode 100644 index 000000000..9c584fd3c --- /dev/null +++ b/src/sp_validation/dashboard/selection_hist.py @@ -0,0 +1,128 @@ +"""Stream cumulative selection histograms from a comprehensive catalogue.""" + +import argparse +from pathlib import Path + +import h5py +import numpy as np +import yaml + + +def build(path, config, expected, chunk=500_000): + """Apply recorded data cuts and the noshear metacal cuts cumulatively.""" + cfg = yaml.safe_load(Path(config).read_text()) + cuts = cfg["dat"] + cols = list( + dict.fromkeys( + [x["col_name"] for x in cuts] + + [ + "NGMIX_FLAGS_NOSHEAR", + "NGMIX_FLUX_NOSHEAR", + "NGMIX_FLUX_ERR_NOSHEAR", + "NGMIX_T_NOSHEAR", + "NGMIX_T_PSF_RECONV_NOSHEAR", + "mag", + ] + ) + ) + edges = np.linspace(15, 30, 151) + hist = np.zeros((len(cuts) + 2, len(edges) - 1), dtype=np.int64) + counts = np.zeros(len(cuts) + 2, dtype=np.int64) + with h5py.File(path) as f: + data = f["data"] + for start in range(0, len(data), chunk): + rows = data.fields(cols)[start : start + chunk] + mask = np.ones(len(rows), dtype=bool) + stages = [mask.copy()] + for cut in cuts: + x, v = rows[cut["col_name"]], cut["value"] + kind = cut["kind"] + if kind == "equal": + keep = x == v + elif kind == "not_equal": + keep = x != v + elif kind == "smaller_equal": + keep = x <= v + elif kind == "greater_equal": + keep = x >= v + elif kind == "range": + keep = (x >= v[0]) & (x <= v[1]) + else: + raise ValueError(f"Unsupported selection {kind}") + mask &= keep + stages.append(mask.copy()) + mc = cfg["metacal"] + with np.errstate(divide="ignore", invalid="ignore"): + snr = rows["NGMIX_FLUX_NOSHEAR"] / rows["NGMIX_FLUX_ERR_NOSHEAR"] + size = rows["NGMIX_T_NOSHEAR"] / rows["NGMIX_T_PSF_RECONV_NOSHEAR"] + mask &= ( + (rows["NGMIX_FLAGS_NOSHEAR"] == 0) + & (snr > mc["gal_snr_min"]) + & (snr < mc["gal_snr_max"]) + & (size > mc["gal_rel_size_min"]) + & (size < mc["gal_rel_size_max"]) + ) + stages.append(mask) + for i, stage in enumerate(stages): + counts[i] += stage.sum() + hist[i] += np.histogram(rows["mag"][stage], bins=edges)[0] + if counts[-1] != expected: + raise ValueError(f"Selection retained {counts[-1]} rows; expected {expected}") + return { + "edges": edges, + "hist": hist, + "counts": counts, + "labels": np.array( + ["All detections"] + [c["label"] for c in cuts] + ["Metacal SNR and size"] + ), + } + + +def plot(data, out): + """Plot cumulative histogram steps from the validated product.""" + import matplotlib as mpl + import matplotlib.pyplot as plt + + from . import style as S + + with S.figure_style(): + fig, ax = plt.subplots(figsize=(7.2, 4.0)) + n = len(data["labels"]) + for i, (hist, label) in enumerate(zip(data["hist"], data["labels"])): + label = str(label) + if label.startswith('r"') and label.endswith('"'): + label = label[2:-1] + color = S.INK if i == n - 1 else mpl.colormaps["tab20"](i / max(n - 2, 1)) + ax.stairs( + hist, + data["edges"], + label=label, + color=color, + linewidth=1.5 if i == n - 1 else 0.8, + ) + ax.set_yscale("log") + ax.set_xlabel("r magnitude") + ax.set_ylabel("Detections") + ax.legend(ncol=2, fontsize=6) + return S.save(fig, out, "full") + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--catalogue", required=True) + p.add_argument("--config", required=True) + p.add_argument("--expected", type=int, required=True) + p.add_argument("--out", required=True) + p.add_argument("--version", required=True) + args = p.parse_args() + np.savez_compressed( + args.out, + version=args.version, + selection="cumulative config_mask cuts and noshear metacal cuts", + units="r-band magnitude, AB", + **build(args.catalogue, args.config, args.expected), + ) + + +if __name__ == "__main__": + main() diff --git a/src/sp_validation/dashboard/sky.py b/src/sp_validation/dashboard/sky.py new file mode 100644 index 000000000..b67d39c40 --- /dev/null +++ b/src/sp_validation/dashboard/sky.py @@ -0,0 +1,571 @@ +"""Footprint and field maps in a projection that suits the survey. + +The footprint is split into its disconnected parts (e.g. the main patch and +a long thin strip). Each part gets its own Lambert azimuthal equal-area +(zenithal equal-area) projection centred on it, rotated so its long axis is +horizontal unless it is the main part, which keeps north up; RA increases to +the left as on the sky. All parts are drawn at the same angular scale, the +main part on top and any others below it. + +Map products are ``field_maps_.npz`` (see +:mod:`sp_validation.dashboard.field_maps`) and the cosmo_val footprint +``footprint_.npz``. Quantities are ratios of pixel sums, coarsened +by summing nested children before dividing. +""" + +from dataclasses import dataclass + +import healpy as hp +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm + +from . import style as S + +DEG = np.pi / 180 + +SEQUENTIAL = LinearSegmentedColormap.from_list( + "parchment_teal", ["#f3ead6", "#9cc3b5", "#3F8278", "#1d4a44", "#0f2421"] +) +DIVERGING = LinearSegmentedColormap.from_list( + "cobalt_cinnabar", + ["#22386e", "#3D5BA0", "#a9b6d6", "#efe6d2", "#e4ab9f", "#BC4538", "#6e2119"], +) + + +# --------------------------------------------------------------------------- +# Projection +# --------------------------------------------------------------------------- +@dataclass +class Zenithal: + """Lambert azimuthal equal-area projection about ``(ra0, dec0)``. + + Output coordinates are in degrees on the projection plane, x increasing + to the east-on-sky-left convention (x grows toward decreasing RA), then + rotated by ``rotation`` degrees. + """ + + ra0: float + dec0: float + rotation: float = 0.0 + + def forward(self, ra, dec): + a = (np.asarray(ra) - self.ra0) * DEG + d = np.asarray(dec) * DEG + d0 = self.dec0 * DEG + cosc = np.sin(d0) * np.sin(d) + np.cos(d0) * np.cos(d) * np.cos(a) + k = np.sqrt(2 / np.clip(1 + cosc, 1e-12, None)) + x = -k * np.cos(d) * np.sin(a) + y = k * (np.cos(d0) * np.sin(d) - np.sin(d0) * np.cos(d) * np.cos(a)) + return self._rotate(x / DEG, y / DEG, self.rotation) + + def inverse(self, x, y): + x, y = self._rotate(np.asarray(x), np.asarray(y), -self.rotation) + x, y = -x * DEG, y * DEG + rho = np.hypot(x, y) + valid = rho < 2 + c = 2 * np.arcsin(np.clip(rho / 2, 0, 1)) + d0 = self.dec0 * DEG + with np.errstate(invalid="ignore", divide="ignore"): + sin_d = np.cos(c) * np.sin(d0) + np.where( + rho > 0, y * np.sin(c) * np.cos(d0) / rho, 0 + ) + dec = np.arcsin(np.clip(sin_d, -1, 1)) + ra = self.ra0 * DEG + np.arctan2( + x * np.sin(c), rho * np.cos(d0) * np.cos(c) - y * np.sin(d0) * np.sin(c) + ) + return np.mod(ra / DEG, 360), dec / DEG, valid + + @staticmethod + def _rotate(x, y, angle): + t = angle * DEG + return x * np.cos(t) - y * np.sin(t), x * np.sin(t) + y * np.cos(t) + + +# --------------------------------------------------------------------------- +# Footprint parts +# --------------------------------------------------------------------------- +def footprint_parts(pixels, nside, nest=True, coarse=64, min_fraction=0.01): + """Split occupied pixels into connected parts, largest first. + + Connectivity is judged on a ``coarse`` grid so masking holes do not split + a patch. Returns a list of boolean masks over ``pixels``; parts smaller + than ``min_fraction`` of the total are attached to the nearest part. + """ + ratio = (nside // coarse) ** 2 + parent = (pixels // ratio) if nest else hp.ring2nest(nside, pixels) // ratio + occupied = np.unique(parent) + occ_set = set(occupied.tolist()) + label = {} + current = 0 + for p in occupied: + if p in label: + continue + stack = [p] + label[p] = current + while stack: + q = stack.pop() + for nb in hp.get_all_neighbours(coarse, q, nest=True): + if nb >= 0 and nb in occ_set and nb not in label: + label[nb] = current + stack.append(nb) + current += 1 + labels = np.array([label[p] for p in parent]) + sizes = np.bincount(labels) + order = np.argsort(sizes)[::-1] + keep = [lab for lab in order if sizes[lab] >= min_fraction * sizes.sum()] + if len(keep) < len(order): + # Fold small islands into the nearest kept part. + vec = np.array(hp.pix2vec(nside, pixels, nest=nest)).T + centres = np.array([vec[labels == lab].mean(0) for lab in keep]) + small = ~np.isin(labels, keep) + nearest = np.argmax(vec[small] @ centres.T, axis=1) + labels[small] = np.array(keep)[nearest] + return [labels == lab for lab in keep] + + +@dataclass +class Part: + projection: Zenithal + xlim: tuple + ylim: tuple + + @property + def width(self): + return self.xlim[1] - self.xlim[0] + + @property + def height(self): + return self.ylim[1] - self.ylim[0] + + +def _centre(ra, dec): + v = np.array(hp.ang2vec(ra, dec, lonlat=True)) + m = v.mean(0) + ra0, dec0 = hp.vec2ang(m / np.linalg.norm(m), lonlat=True) + return float(ra0[0]), float(dec0[0]) + + +def make_parts(pixels, nside, nest=True, margin=1.5): + """Projection and extent for each disconnected part of the footprint.""" + parts = [] + for i, mask in enumerate(footprint_parts(pixels, nside, nest)): + ra, dec = hp.pix2ang(nside, pixels[mask], nest=nest, lonlat=True) + ra0, dec0 = _centre(ra, dec) + proj = Zenithal(ra0, dec0) + x, y = proj.forward(ra, dec) + if i > 0: + # Long axis horizontal: rotate by the principal-axis angle. + cov = np.cov(np.vstack([x, y])) + evals, evecs = np.linalg.eigh(cov) + major = evecs[:, np.argmax(evals)] + angle = np.degrees(np.arctan2(major[1], major[0])) + angle = (angle + 90) % 180 - 90 # keep RA increasing to the left + proj = Zenithal(ra0, dec0, -angle) + x, y = proj.forward(ra, dec) + parts.append( + Part( + proj, + (x.min() - margin, x.max() + margin), + (y.min() - margin, y.max() + margin), + ) + ) + return parts + + +# --------------------------------------------------------------------------- +# Maps +# --------------------------------------------------------------------------- +def coarsen(pixels, sums, nside, nside_out): + """Sum nested-pixel sums onto ``nside_out``; returns (pixels, sums).""" + ratio = (nside // nside_out) ** 2 + parent = pixels // ratio + out_pix, inverse = np.unique(parent, return_inverse=True) + return out_pix, { + key: np.bincount(inverse, weights=value, minlength=len(out_pix)) + for key, value in sums.items() + } + + +def raster(part, pixels, values, nside, resolution, nest=True): + """Sample a partial HEALPix map on the part's projection-plane grid.""" + nx = int(np.ceil(part.width / resolution)) + ny = int(np.ceil(part.height / resolution)) + x = np.linspace(part.xlim[0], part.xlim[1], nx) + y = np.linspace(part.ylim[0], part.ylim[1], ny) + xx, yy = np.meshgrid(x, y) + ra, dec, valid = part.projection.inverse(xx, yy) + pix = hp.ang2pix(nside, ra, dec, lonlat=True, nest=nest) + order = np.argsort(pixels) + pos = np.searchsorted(pixels, pix, sorter=order) + pos = np.clip(pos, 0, len(pixels) - 1) + found = valid & (pixels[order][pos] == pix) + img = np.full(pix.shape, np.nan) + img[found] = values[order][pos[found]] + return img + + +def graticule(ax, part, step_ra=None, step_dec=10, color=S.RULE, label=True): + """Draw RA/Dec lines across the part and label them along the frame.""" + dec_c = part.projection.dec0 + span = max(part.width, part.height) + step_ra = step_ra or (30 if abs(dec_c) > 65 else 15 if span > 25 else 10) + decs = np.arange(-90 + step_dec, 90, step_dec) + ras = np.arange(0, 360, step_ra) + t = np.linspace(0, 360, 721) + s = np.linspace(-89.5, 89.5, 360) + kw = dict(color=color, lw=0.5, zorder=1) + x0, x1 = part.xlim + y0, y1 = part.ylim + for d in decs: + x, y = part.projection.forward(t, np.full_like(t, d)) + far = _far_side(part, t, np.full_like(t, d)) + x, y = np.where(far, np.nan, x), np.where(far, np.nan, y) + ax.plot(x, y, **kw) + if label: + _edge_label(ax, x, y, (x0, x1, y0, y1), rf"${d:g}^\circ$", side="left") + for r in ras: + x, y = part.projection.forward(np.full_like(s, r), s) + far = _far_side(part, np.full_like(s, r), s) + x, y = np.where(far, np.nan, x), np.where(far, np.nan, y) + ax.plot(x, y, **kw) + if label: + _edge_label(ax, x, y, (x0, x1, y0, y1), rf"${r:g}^\circ$", side="bottom") + + +def _far_side(part, ra, dec): + v = np.array(hp.ang2vec(ra, dec, lonlat=True)) + c = hp.ang2vec(part.projection.ra0, part.projection.dec0, lonlat=True) + return v @ c < np.cos(80 * DEG) + + +def _edge_label(ax, x, y, box, text, side): + x0, x1, y0, y1 = box + ok = np.isfinite(x) & (x >= x0) & (x <= x1) & (y >= y0) & (y <= y1) + if not ok.any(): + return + if side == "left": + i = np.flatnonzero(ok)[np.argmin(x[ok])] + if x[i] - x0 > 0.08 * (x1 - x0): + return + ax.text( + x0, + y[i], + text, + ha="right", + va="center", + fontsize=7, + color=S.INK_MUTED, + clip_on=False, + ) + else: + i = np.flatnonzero(ok)[np.argmin(y[ok])] + if y[i] - y0 < 0.08 * (y1 - y0): + ax.text( + x[i], + y0, + text, + ha="center", + va="top", + fontsize=7, + color=S.INK_MUTED, + clip_on=False, + ) + + +def _scale_bar(ax, part, length=10): + x1 = part.xlim[1] - 0.04 * part.width + y0 = part.ylim[0] + 0.05 * part.height + ax.plot([x1 - length, x1], [y0, y0], color=S.INK, lw=1.2, solid_capstyle="butt") + ax.text( + x1 - length / 2, + y0 + 0.012 * part.height + 0.3, + rf"${length}^\circ$", + ha="center", + va="bottom", + fontsize=7, + color=S.INK, + ) + + +def draw_map( + ax, + part, + pixels, + values, + nside, + cmap, + norm, + resolution=None, + labels=True, + scale_bar=False, + grid_color=S.RULE, +): + resolution = resolution or max(hp.nside2resol(nside, arcmin=True) / 60 / 2, 0.03) + img = raster(part, pixels, values, nside, resolution) + graticule(ax, part, color=grid_color, label=labels) + im = ax.imshow( + img, + origin="lower", + extent=(*part.xlim, *part.ylim), + cmap=cmap, + norm=norm, + interpolation="nearest", + zorder=2, + rasterized=True, + ) + ax.set_xlim(part.xlim) + ax.set_ylim(part.ylim) + ax.set_aspect("equal") + ax.set_axis_off() + if scale_bar: + _scale_bar(ax, part) + return im + + +def map_grid(fig, spec, parts): + """Axes for every part inside ``spec``: main part above, others below.""" + if len(parts) == 1: + return [fig.add_subplot(spec)] + heights = [parts[0].height] + [ + p.height * parts[0].width / max(p.width, parts[0].width) for p in parts[1:] + ] + sub = spec.subgridspec(len(parts), 1, height_ratios=heights, hspace=0.05) + return [fig.add_subplot(sub[i]) for i in range(len(parts))] + + +# --------------------------------------------------------------------------- +# Quantities +# --------------------------------------------------------------------------- +def _ratio(num, den): + with np.errstate(invalid="ignore", divide="ignore"): + return np.where(den > 0, num / den, np.nan) + + +def coverage(footprint, nside_out): + """Fraction of each ``nside_out`` pixel covered by the occupied footprint.""" + pix = footprint["pixels"] + nside = int(footprint["nside_sparse"]) + ratio = (nside // nside_out) ** 2 + out, counts = np.unique(pix // ratio, return_counts=True) + return out, counts / ratio + + +def median_quantity(maps, name, nside_out, footprint=None): + """Median over pixels of one map quantity.""" + return float(np.median(quantity(maps, name, nside_out, footprint)[1])) + + +def quantity(maps, name, nside_out, footprint=None): + """Pixels and values of one named map quantity at ``nside_out``.""" + nside = int(maps["nside"]) + keys = [k for k in maps if k.startswith(("n_", "sum_")) and np.ndim(maps[k]) == 1] + pix, sums = coarsen(maps["pixels"], {k: maps[k] for k in keys}, nside, nside_out) + area = hp.nside2pixarea(nside_out, degrees=True) * 3600 + if name == "n_eff": + frac = np.ones(len(pix)) + if footprint is not None: + cpix, cfrac = coverage(footprint, nside_out) + lookup = dict(zip(cpix.tolist(), cfrac.tolist())) + frac = np.array([lookup.get(p, np.nan) for p in pix.tolist()]) + values = _ratio(sums["sum_w"] ** 2, sums["sum_w2"]) / (area * frac) + keep = (sums["n_gal"] > 0) & (frac > 0.5) + elif name == "sigma_e": + values = np.sqrt(_ratio(sums["sum_w2_ee"], 2 * sums["sum_w2"])) + keep = sums["n_gal"] > 0 + elif name == "mean_w": + values = _ratio(sums["sum_w"], sums["n_gal"]) + keep = sums["n_gal"] > 0 + elif name in ("e1", "e2"): + values = _ratio(sums[f"sum_w_{name}"], sums["sum_w"]) + keep = sums["n_gal"] > 0 + elif name == "star_density": + values = sums["n_star"] / area + keep = sums["n_star"] >= 5 + elif name == "mag": + values = _ratio(sums["sum_mag"], sums["n_gal"]) + keep = sums["n_gal"] > 0 + elif name == "fwhm_psf": + values = _ratio(sums["sum_w_fwhm_psf"], sums["sum_w"]) + keep = sums["n_gal"] > 0 + elif name in ("de1", "de2", "dT_over_T"): + key = "sum_dT_over_T_psf" if name == "dT_over_T" else f"sum_{name}" + values = _ratio(sums[key], sums["n_star"]) + keep = sums["n_star"] >= 5 + else: + raise KeyError(name) + # Drop sparsely filled pixels (footprint edges), whose means are noise. + n = sums[ + "n_star" if name in ("de1", "de2", "dT_over_T", "star_density") else "n_gal" + ] + keep &= n >= 0.3 * np.median(n[n > 0]) + return pix[keep], values[keep] + + +def _robust_limits(values, symmetric=False, q=(1, 99)): + lo, hi = np.nanpercentile(values, q) + if symmetric: + m = max(abs(lo), abs(hi)) + return -m, m + return lo, hi + + +def _colorbar(fig, im, ax, label, symmetric): + cbar = S.colorbar( + fig, + im, + ax, + label, + orientation="horizontal", + fraction=0.05, + pad=0.02, + aspect=28, + extend="both", + ) + cbar.locator = mpl.ticker.MaxNLocator(5, symmetric=symmetric) + cbar.update_ticks() + return cbar + + +def footprint_map(maps, footprint, out, nside=512, stats=None): + """Effective galaxy density over the footprint.""" + parts = make_parts(maps["pixels"][maps["n_gal"] > 0], int(maps["nside"])) + pix, val = quantity(maps, "n_eff", nside, footprint) + with S.figure_style(): + fig = plt.figure(figsize=(7.2, 7.2 * _aspect(parts) + 0.6)) + gs = fig.add_gridspec(1, 1) + axes = map_grid(fig, gs[0], parts) + norm = mpl.colors.Normalize(*_robust_limits(val, q=(0.5, 99.5))) + for i, (ax, part) in enumerate(zip(axes, parts)): + im = draw_map(ax, part, pix, val, nside, SEQUENTIAL, norm, scale_bar=i == 0) + _colorbar(fig, im, axes, r"$n_{\rm eff}$ [arcmin$^{-2}$]", False) + if stats: + axes[0].text( + 0.0, + 1.0, + stats, + transform=axes[0].transAxes, + ha="left", + va="top", + fontsize=8.5, + color=S.INK_MUTED, + linespacing=1.5, + ) + return S.save(fig, out, "full") + + +def _aspect(parts): + main = parts[0] + h = main.height + sum( + p.height * main.width / max(p.width, main.width) for p in parts[1:] + ) + return h / main.width + + +FIELD_SETS = { + "galaxies": [ + ("sigma_e", 256, r"$\sigma_e$ per component", False), + ("mean_w", 256, r"$\langle w \rangle$", False), + ("e1", 64, r"$\langle e_1 \rangle$", True), + ("e2", 64, r"$\langle e_2 \rangle$", True), + ], + "psf": [ + ("fwhm_psf", 256, r"PSF FWHM at galaxies [arcsec]", False), + ( + "dT_over_T", + 128, + r"stars: $\langle (T_\star - T_{\rm PSF})/T_{\rm PSF} \rangle$", + True, + ), + ("de1", 128, r"stars: $\langle e_{1,\star} - e_{1,{\rm PSF}} \rangle$", True), + ("de2", 128, r"stars: $\langle e_{2,\star} - e_{2,{\rm PSF}} \rangle$", True), + ], +} + + +def paper_map_pair(maps, out, which): + """Draw the catalogue paper's two related sky-map quantities.""" + fields = { + "stars": [ + ("star_density", "Star detections [arcmin$^{-2}$]", False), + ("dT_over_T", r"$\langle(T_\star-T_{\rm PSF})/T_{\rm PSF}\rangle$", True), + ], + "magnitude": [ + ("fwhm_psf", "PSF FWHM [arcsec]", False), + ("mag", "Mean galaxy r magnitude", False), + ], + } + gal_pix = maps["pixels"][maps["n_gal"] > 0] + parts = make_parts(gal_pix, int(maps["nside"])) + nside = 256 + with S.figure_style(): + fig = plt.figure(figsize=(7.2, 3.6 * _aspect(parts) + 0.6)) + gs = fig.add_gridspec(1, 2, wspace=0.05) + for cell, (name, label, symmetric) in zip(gs, fields[which]): + pix, val = quantity(maps, name, nside) + inside = np.isin( + pix, np.unique(gal_pix // (int(maps["nside"]) // nside) ** 2) + ) + pix, val = pix[inside], val[inside] + lo, hi = _robust_limits(val, symmetric) + norm = ( + TwoSlopeNorm(0, lo, hi) if symmetric else mpl.colors.Normalize(lo, hi) + ) + axes = map_grid(fig, cell, parts) + for ax, part in zip(axes, parts): + im = draw_map( + ax, + part, + pix, + val, + nside, + DIVERGING if symmetric else SEQUENTIAL, + norm, + labels=False, + ) + _colorbar(fig, im, axes, label, symmetric) + return S.save(fig, out, "full") + + +def field_maps(maps, out, which, footprint=None, layout="wide"): + """2 x 2 small multiples of map quantities over the main footprint part. + + Diverging quantities use a symmetric range about zero; all ranges are + robust (1st-99th percentile) so single pixels do not set the scale. + """ + gal_pix = maps["pixels"][maps["n_gal"] > 0] + parts = make_parts(gal_pix, int(maps["nside"])) + with S.figure_style(): + if layout == "wide": + fig = plt.figure(figsize=(7.2, 7.2 * _aspect(parts) + 1.0)) + gs = fig.add_gridspec(2, 2, hspace=0.08, wspace=0.04) + else: + fig = plt.figure(figsize=(3.6, 4 * (3.6 * _aspect(parts) + 0.6))) + gs = fig.add_gridspec(4, 1, hspace=0.06) + for cell, (name, nside, label, symmetric) in zip(gs, FIELD_SETS[which]): + pix, val = quantity(maps, name, nside, footprint) + # Restrict star-based maps to the galaxy footprint. + inside = np.isin( + pix, np.unique(gal_pix // (int(maps["nside"]) // nside) ** 2) + ) + pix, val = pix[inside], val[inside] + lo, hi = _robust_limits(val, symmetric) + norm = ( + TwoSlopeNorm(0, lo, hi) if symmetric else mpl.colors.Normalize(lo, hi) + ) + axes = map_grid(fig, cell, parts) + for ax, part in zip(axes, parts): + im = draw_map( + ax, + part, + pix, + val, + nside, + DIVERGING if symmetric else SEQUENTIAL, + norm, + labels=False, + grid_color="#ddd2bb", + ) + _colorbar(fig, im, axes, label, symmetric) + return S.save(fig, out, "full" if layout == "wide" else "half") diff --git a/src/sp_validation/dashboard/star_residuals.py b/src/sp_validation/dashboard/star_residuals.py new file mode 100644 index 000000000..1511bf187 --- /dev/null +++ b/src/sp_validation/dashboard/star_residuals.py @@ -0,0 +1,36 @@ +"""PSF validation-star residual distributions.""" + +import matplotlib.pyplot as plt + +from . import style as S + + +def plot(data, out): + """Draw residual histograms with moments computed over finite detections.""" + with S.figure_style(): + fig = plt.figure(figsize=(7.2, 5.0)) + gs = fig.add_gridspec(2, 2) + axes = [ + fig.add_subplot(gs[0, 0]), + fig.add_subplot(gs[0, 1]), + fig.add_subplot(gs[1, :]), + ] + labels = [ + (r"$\Delta e_1$", r"$\Delta e_2$"), + (r"$\Delta M^{(4)}_1$", r"$\Delta M^{(4)}_2$"), + (r"$\sqrt{T_\star/T_{\rm PSF}}-1$",), + ] + for ax, indices, names in zip(axes, ((0, 1), (2, 3), (4,)), labels): + for i, name in zip(indices, names): + ax.stairs( + data[f"hist_{i}"], + data[f"edges_{i}"], + label=f"{name}: mean {float(data[f'mean_{i}']):.3g}, " + rf"$\sigma$ {float(data[f'std_{i}']):.3g}", + ) + ax.set_yscale("log") + ax.set_xlim((-0.15, 0.15) if indices != (4,) else (-0.3, 0.3)) + ax.set_xlabel("Residual" if len(indices) == 2 else names[0]) + ax.set_ylabel("Star detections") + ax.legend(fontsize=7) + return S.save(fig, out, "full") diff --git a/src/sp_validation/dashboard/style.py b/src/sp_validation/dashboard/style.py new file mode 100644 index 000000000..04a667f2b --- /dev/null +++ b/src/sp_validation/dashboard/style.py @@ -0,0 +1,286 @@ +"""Shared look of the dashboard figures. + +Typography and colour semantics follow the UNIONS Paper II (B-modes) figures: +Computer Modern via mathtext (no TeX dependency), one hue per estimator +family in the B-mode data vectors, grey shading over excluded scales, a boxed +in-panel annotation naming the statistic, and the threshold-anchored PTE +colormap. Surfaces and ink come from the production page's parchment +palette so the PNGs sit on its cards without a white slab. + +Sizes are in inches at print-like font sizes; :func:`save` fixes the tight +saved width, and the resolution is chosen per size class so a figure stays +crisp at the width the page shows it. +""" + +from contextlib import contextmanager + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.colors import BoundaryNorm, ListedColormap + +# Page palette (production/status.html) +RAG = "#FBF6E9" # card surface; figure background +INK = "#1B1611" +INK_MUTED = "#7A6F5C" +RULE = "#C5B89E" +CINNABAR = "#BC4538" + +# Paper II mode / family hues (Reproductions UNIONS/pure_eb plotting_utils) +COLOR_TOTAL = INK +COLOR_E = "#2a78d6" +COLOR_B = "crimson" +COLOR_AMB = "#9a9a9a" +COLOR_XI_B_MINUS = "#e8792b" +COLOR_COSEBIS = "#7b3fa0" +COLOR_COSEBIS_ALL = "#b48cce" +COLOR_CL_BB = "#008080" +COLOR_CL_EB = "#4d4d4d" + +# Catalogue versions, only in version-comparison figures (Paper II Figs 7-9) +VERSION_MARKERS = ["o", "s", "D", "^"] + +# Neutral series inside one figure where no mode semantics apply +NEUTRAL = INK +NEUTRAL_LIGHT = "#a89f8c" + +DATA_VECTOR_MARK = { + "ls": "none", + "markersize": 3.4, + "markeredgewidth": 0.8, + "elinewidth": 0.75, + "capsize": 1.6, + "capthick": 0.75, +} +EXCLUDED_SHADE = {"color": INK, "alpha": 0.07, "lw": 0, "zorder": 0} + +# Width classes (inches) and their resolutions: about 1600 px for the full +# column, about 2x the displayed size for the row of three and phones. +WIDTH = {"full": 7.2, "half": 3.6, "third": 3.0} +DPI = {"full": 220, "half": 220, "third": 250} + +RC = { + "figure.facecolor": RAG, + "axes.facecolor": RAG, + "savefig.facecolor": RAG, + "text.color": INK, + "axes.edgecolor": INK, + "axes.labelcolor": INK, + "xtick.color": INK, + "ytick.color": INK, + "font.family": "serif", + "font.serif": ["cmr10"], + "mathtext.fontset": "cm", + "axes.formatter.use_mathtext": True, + "axes.unicode_minus": True, + "font.size": 10, + "axes.titlesize": 10, + "axes.labelsize": 10, + "xtick.labelsize": 9, + "ytick.labelsize": 9, + "legend.fontsize": 8.5, + "legend.frameon": False, + "axes.linewidth": 0.8, + "xtick.major.width": 0.7, + "ytick.major.width": 0.7, + "xtick.minor.width": 0.5, + "ytick.minor.width": 0.5, + "xtick.major.size": 3.5, + "ytick.major.size": 3.5, + "xtick.minor.size": 2, + "ytick.minor.size": 2, + "xtick.direction": "in", + "ytick.direction": "in", + "xtick.top": True, + "ytick.right": True, + "lines.linewidth": 1.2, + "figure.constrained_layout.use": True, + "savefig.pad_inches": 0.04, +} + + +@contextmanager +def figure_style(): + """Apply the dashboard rcParams for the duration of a render.""" + with mpl.rc_context(RC): + yield + + +def save(fig, path, size="full", width=None): + """Save ``fig`` with a tight bounding box exactly the class width wide. + + Scales the canvas (keeping its aspect) until the cropped width equals the + target, so font sizes in the style are the sizes on the page at that width + (the Paper II ``savefig_at_width`` recipe). + """ + width = width or WIDTH[size] + pad = mpl.rcParams["savefig.pad_inches"] + for _ in range(10): + fig.canvas.draw() + saved = fig.get_tightbbox(fig.canvas.get_renderer()).width + 2 * pad + if abs(saved / width - 1.0) < 1e-3: + break + fig.set_size_inches(fig.get_size_inches() * (width / saved)) + fig.savefig(path, dpi=DPI.get(size, 220), bbox_inches="tight") + plt.close(fig) + return path + + +def label_box(ax, text, loc="upper left", fontsize=None): + """Boxed in-panel annotation naming the statistic (Paper II convention).""" + x, ha = (0.04, "left") if "left" in loc else (0.96, "right") + y, va = (0.95, "top") if "upper" in loc else (0.05, "bottom") + ax.text( + x, + y, + text, + transform=ax.transAxes, + ha=ha, + va=va, + fontsize=fontsize or mpl.rcParams["font.size"], + zorder=10, + bbox=dict( + boxstyle="round,pad=0.28", + facecolor=RAG, + edgecolor=INK, + linewidth=0.7, + ), + ) + + +def note(ax, text, loc="lower right", color=INK_MUTED, fontsize=8): + """Small unboxed annotation, e.g. a PTE, in muted ink.""" + x, ha = (0.04, "left") if "left" in loc else (0.96, "right") + y, va = (0.95, "top") if "upper" in loc else (0.05, "bottom") + ax.text( + x, + y, + text, + transform=ax.transAxes, + ha=ha, + va=va, + color=color, + fontsize=fontsize, + zorder=10, + ) + + +def blind_stamp(fig, blind, where="top"): + """Mark a figure that shows blinded data vectors, outside the axes.""" + y, va = (1.0, "top") if where == "top" else (0.0, "bottom") + x, ha = (1.0, "right") if where == "top" else (0.0, "left") + fig.text( + x, + y, + f"BLINDED · {blind}", + ha=ha, + va=va, + fontsize=6.5, + family="monospace", + color=CINNABAR, + ) + + +def header(ax, text, side="left"): + """Panel header above the frame: the panel's subject on the left in ink, + a qualifier (a cut, a sign convention) on the right in muted ink.""" + if side == "left": + ax.set_title(text, loc="left", fontsize=9, color=INK, pad=4) + else: + ax.set_title(text, loc="right", fontsize=8.5, color=INK_MUTED, pad=4) + + +def colorbar(fig, mappable, ax, label, **kwargs): + """Colour bar in the house style: thin outline, small ticks, ink label.""" + cbar = fig.colorbar(mappable, ax=ax, **kwargs) + cbar.outline.set_linewidth(0.6) + cbar.ax.tick_params( + labelsize=8, length=2.5, width=0.6, which="both", direction="out" + ) + cbar.set_label(label, fontsize=9) + if cbar.solids is not None: + cbar.solids.set_edgecolor("face") + return cbar + + +def frame_only(ax): + """Frame without mirrored ticks, for images (heatmaps, maps, matrices).""" + ax.tick_params(which="both", top=False, right=False) + + +def theta_ticks(ax): + ax.set_xticks([1, 10, 100]) + ax.set_xticklabels(["$1$", "$10$", "$100$"]) + + +def zero_line(ax): + ax.axhline(0, color=INK, ls=(0, (4, 3)), lw=0.7, alpha=0.6, zorder=1) + + +def shade_outside(ax, lo, hi): + """Grey-shade the parts of the x range outside ``[lo, hi]``.""" + x0, x1 = ax.get_xlim() + if lo is not None and lo > x0: + ax.axvspan(x0, lo, **EXCLUDED_SHADE) + if hi is not None and hi < x1: + ax.axvspan(hi, x1, **EXCLUDED_SHADE) + ax.set_xlim(x0, x1) + + +def theta_axis(ax, lo=0.9, hi=280): + ax.set_xscale("log") + ax.set_xlim(lo, hi) + ax.xaxis.set_major_formatter( + mpl.ticker.FuncFormatter(lambda v, _: f"${v:g}$" if v >= 1 else "") + ) + + +def fmt_pte(value): + """PTE to two significant figures, in mathtext.""" + if value is None or not np.isfinite(value): + return "n/a" + if value < 1e-3: + return rf"{value:.1e}".replace("e-0", r"\times10^{-") + "}" + return f"{value:#.2g}".rstrip(".") + + +# Threshold-anchored PTE colormap (Paper II): solid poles below the threshold +# and above one minus it, a vlag gradient between. +PTE_THRESHOLD = 0.05 +_PTE_LEVELS = 512 + + +def pte_cmap(threshold=PTE_THRESHOLD): + import seaborn as sns + + vlag = sns.color_palette("vlag", as_cmap=True) + colors = ( + [vlag(0.0)] + + [vlag(v) for v in np.linspace(0.15, 0.85, _PTE_LEVELS)] + + [vlag(1.0)] + ) + cmap = ListedColormap(colors, name="pte_threshold") + cmap.set_bad(color="#e4dccb") + return cmap + + +def pte_norm(threshold=PTE_THRESHOLD): + bounds = np.concatenate( + ([0.0], np.linspace(threshold, 1 - threshold, _PTE_LEVELS + 1), [1.0]) + ) + return BoundaryNorm(bounds, ncolors=_PTE_LEVELS + 2, clip=True) + + +def pte_colorbar(fig, mappable, ax, threshold=PTE_THRESHOLD, **kwargs): + cbar = colorbar(fig, mappable, ax, "PTE", spacing="proportional", **kwargs) + ticks = [threshold, 0.5, 1 - threshold] + cbar.set_ticks(ticks) + cbar.set_ticklabels([f"${t:g}$" for t in ticks]) + cbar.minorticks_off() + return cbar + + +def correlation_cmap(): + import seaborn as sns + + return sns.color_palette("icefire", as_cmap=True) diff --git a/src/sp_validation/dashboard/tangential.py b/src/sp_validation/dashboard/tangential.py new file mode 100644 index 000000000..b0f6ea216 --- /dev/null +++ b/src/sp_validation/dashboard/tangential.py @@ -0,0 +1,218 @@ +"""Tangential-shear null tests and scale-dependent leakage in magnitude bins. + +Renderers for the products of :mod:`sp_validation.dashboard.correlations` +(loaded by :func:`sp_validation.dashboard.products.load_correlation`): galaxy +shear around PSF stars, tile centres and Gaia stars; the PSF field around +galaxies; and α(θ) = τ₀/ρ₀ by galaxy magnitude. Each panel quotes χ²/ν +against zero from the jackknife covariance (Hartlap-corrected). +""" + +import matplotlib.pyplot as plt +import numpy as np + +from . import style as S + +ALL = S.INK +BINS = ["#2a78d6", "#eb6834", "#1baf7a"] # validated categorical slots (psf.py) + + +def chi2_null(values, cov, npatch): + """χ² of ``values`` against zero, with the Hartlap factor for ``npatch``.""" + p = len(values) + if npatch <= p + 2: + return np.nan, p + hartlap = (npatch - p - 2) / (npatch - 1) + return float(values @ np.linalg.solve(cov, values) * hartlap), p + + +def _chi2_label(chi2, nu): + value = "n/a" if not np.isfinite(chi2) else f"{chi2 / nu:.2f}" + return rf"$\chi^2/\nu = {value}$" + + +def _blocks(result): + n = len(result["theta"]) + cov = result["covariance"] + return { + "t": (result["xi_t"], result["error_t"], cov[:n, :n]), + "x": (result["xi_x"], result["error_x"], cov[n:, n:]), + } + + +def _offsets(n, spread=0.035): + return 10 ** (spread * (np.arange(n) - (n - 1) / 2)) + + +def _theta_panel(ax): + S.zero_line(ax) + S.theta_axis(ax) + S.theta_ticks(ax) + ax.set_xlabel(r"$\theta$ [arcmin]") + + +def _gamma_panels( + series, npatch, out, scale=1e4, theta_weight=False, size="full", header=None +): + """γ_t and γ_× panels for ``series`` = [(label, colour, result)].""" + with S.figure_style(): + fig, axes = plt.subplots(1, 2, figsize=(7.2, 2.7), sharey=True) + offsets = _offsets(len(series)) + ncol = 2 if len(series) > 2 else 1 + for ax, comp, sym in zip(axes, "tx", ("t", r"\times")): + _theta_panel(ax) + for (label, colour, result), off in zip(series, offsets): + theta = result["theta"] + value, error, cov = _blocks(result)[comp] + chi2, nu = chi2_null(value, cov, npatch) + factor = theta if theta_weight else 1.0 + ax.errorbar( + theta * off, + value * factor * scale, + yerr=error * factor * scale, + color=colour, + marker="o", + label=f"{label} {_chi2_label(chi2, nu)}", + **S.DATA_VECTOR_MARK, + ) + exponent = int(round(np.log10(scale))) + if theta_weight: + ylabel = ( + rf"$\theta\,\gamma_{{{sym}}}\ [10^{{-{exponent}}}\,\rm arcmin]$" + ) + else: + ylabel = rf"$\gamma_{{{sym}}}\ [10^{{-{exponent}}}]$" + ax.set_ylabel(ylabel) + S.label_box(ax, rf"$\gamma_{{{sym}}}$", loc="upper right") + ax.legend( + loc="lower center", + ncol=ncol, + fontsize=6.5, + handletextpad=0.3, + columnspacing=0.8, + borderaxespad=0.3, + frameon=False, + ) + # Room below the data for the legend (the y axis is shared). + lo, hi = axes[0].get_ylim() + rows = -(-len(series) // ncol) + axes[0].set_ylim(lo - 0.16 * rows * (hi - lo), hi) + axes[1].yaxis.set_tick_params(labelleft=True) + if header: + S.header(axes[0], header) + return S.save(fig, out, size) + + +def _bin_labels(edges, fmt, name): + return [ + rf"${lo:{fmt}} \leq {name} < {hi:{fmt}}$" + for lo, hi in zip(edges[:-1], edges[1:]) + ] + + +def gamma_stars(product, out): + """Galaxy γ_t, γ_× around PSF validation stars, all and by star magnitude.""" + prov = product["provenance"] + labels = _bin_labels(prov["star_mag_edges"], ".1f", "m") + series = [("all", ALL, product["all"])] + [ + (lab, c, product[f"mag{i}"]) for i, (lab, c) in enumerate(zip(labels, BINS)) + ] + return _gamma_panels( + series, + prov["npatch"], + out, + header=f"{prov['n_lenses']:,} PSF validation stars (valid HSM)", + ) + + +def gamma_tiles(product, out): + """θ γ_t and θ γ_× of galaxies around tile centres.""" + prov = product["provenance"] + series = [("tile centres", ALL, product["all"])] + return _gamma_panels( + series, + prov["npatch"], + out, + scale=1e3, + theta_weight=True, + header=f"{prov['n_lenses']:,} tiles", + ) + + +def gamma_gaia(product, out): + """Galaxy γ_t, γ_× around Gaia DR3 stars in three G bins, randoms subtracted.""" + prov = product["provenance"] + labels = _bin_labels(prov["g_edges"], "g", "G") + series = [ + (lab, c, product[f"G{i}"]) for i, (lab, c) in enumerate(zip(labels, BINS)) + ] + n = sum(prov["n_lenses"].values()) + return _gamma_panels( + series, prov["npatch"], out, header=f"{n:,} Gaia DR3 stars, randoms subtracted" + ) + + +def lambda_stars(product, out): + """PSF-model (λ₁) and residual (λ₃) ellipticity around galaxy positions.""" + prov = product["provenance"] + rows = [ + ("psf", r"\lambda_1", r"$e^{\rm PSF}$", 1e4), + ("residual", r"\lambda_3", r"$e^{\star} - e^{\rm PSF}$", 1e5), + ] + with S.figure_style(): + fig, axes = plt.subplots(2, 2, figsize=(7.2, 4.8), sharex=True) + for (key, sym, what, scale), row in zip(rows, axes): + result = product[key] + exponent = int(round(np.log10(scale))) + for ax, comp, csym in zip(row, "tx", ("t", r"\times")): + _theta_panel(ax) + value, error, cov = _blocks(result)[comp] + chi2, nu = chi2_null(value, cov, prov["npatch"]) + ax.errorbar( + result["theta"], + value * scale, + yerr=error * scale, + color=ALL, + marker="o", + **S.DATA_VECTOR_MARK, + ) + ax.set_ylabel(rf"${sym},{{\,{csym}}}\ [10^{{-{exponent}}}]$") + S.label_box(ax, rf"{what}, ${csym}$", loc="upper right") + S.note(ax, _chi2_label(chi2, nu), loc="lower right") + if ax in axes[0]: + ax.set_xlabel("") + S.header(axes[0, 0], "PSF validation stars around galaxies, randoms subtracted") + return S.save(fig, out, "full") + + +def alpha_mag(product, out): + """α(θ) = τ₀/ρ₀ for all galaxies and in magnitude tertiles.""" + prov = product["provenance"] + labels = _bin_labels(prov["galaxy_mag_edges"], ".1f", "m") + series = [("all", ALL, product["all"])] + [ + (lab, c, product[f"mag{i}"]) for i, (lab, c) in enumerate(zip(labels, BINS)) + ] + with S.figure_style(): + fig, ax = plt.subplots(figsize=(7.2, 3.0)) + _theta_panel(ax) + for (label, colour, result), off in zip(series, _offsets(len(series))): + ax.errorbar( + result["theta"] * off, + result["alpha"], + yerr=result["alpha_error"], + color=colour, + marker="o", + label=label, + **S.DATA_VECTOR_MARK, + ) + ax.set_ylabel(r"$\alpha(\theta) = \tau_{0,+}/\rho_{0,+}$") + ax.legend( + loc="upper left", + fontsize=7.5, + ncol=4, + frameon=False, + handletextpad=0.3, + columnspacing=1.0, + ) + top = ax.get_ylim() + ax.set_ylim(top[0], top[1] + 0.25 * (top[1] - top[0])) + return S.save(fig, out, "full") diff --git a/src/sp_validation/dashboard/versions.py b/src/sp_validation/dashboard/versions.py new file mode 100644 index 000000000..6a3f54b95 --- /dev/null +++ b/src/sp_validation/dashboard/versions.py @@ -0,0 +1,192 @@ +"""Catalogue-version comparisons (Paper II Figs. 7-9), drawn only when a run +carries two or more versions. + +Each figure overlays the versions with jittered markers in the version +palette and, per bin, a box spanning the versions' values with a bar at the +reference (first) version. ``datasets`` is a list of ``(label, products)`` +with the reference first; products are the dicts of +:mod:`sp_validation.dashboard.products`. +""" + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import Rectangle + +from sp_validation.b_modes import bins_from_scale_cut + +from . import style as S +from .data_vectors import XI_SCALE, _cl_cut_mask + + +def version_colors(n): + """Paper II version palette: ``seaborn.husl_palette(n, l=0.4)``.""" + import seaborn as sns + + return sns.husl_palette(n, l=0.4) + + +def _boxes(ax, x_left, x_right, values, ref): + """Box spanning ``values`` (versions x bins) per bin, bar at ``ref``.""" + lo, hi = values.min(0), values.max(0) + for xl, xr, a, b, r in zip(x_left, x_right, lo, hi, ref): + ax.add_patch( + Rectangle( + (xl, a), + xr - xl, + b - a, + fill=False, + edgecolor=S.INK, + lw=0.8, + alpha=0.75, + zorder=1, + ) + ) + ax.hlines(r, xl, xr, color=S.INK, lw=1.3, zorder=1) + + +def _series(ax, x, y, yerr, i, color, label=None): + ax.errorbar( + x, + y, + yerr=yerr, + marker=S.VERSION_MARKERS[i % 4], + color=color, + markerfacecolor="none", + markeredgecolor=color, + label=label, + zorder=2, + **S.DATA_VECTOR_MARK, + ) + + +def _log_offsets(n, spread=0.05): + return np.exp(spread * (np.arange(n) - (n - 1) / 2)) + + +def pure_eb(datasets, out, scale_cut): + """Total θξ± (top) and ξ±^B/σ (bottom) across versions.""" + n = len(datasets) + colors = version_colors(n) + off = _log_offsets(n) + pad = np.exp(0.05 * (n - 1) / 2 + 0.04) + ref = datasets[0][1] + theta = ref["theta"] + start, stop = bins_from_scale_cut(ref["left_edges"], ref["right_edges"], scale_cut) + shade = (ref["left_edges"][start], ref["right_edges"][stop - 1]) + with S.figure_style(): + fig, axes = plt.subplots( + 2, 2, figsize=(7.2, 4.0), sharex=True, height_ratios=(3, 2) + ) + for col, xi in enumerate(("xip", "xim")): + top = np.array([theta * d[xi] / XI_SCALE for _, d in datasets]) + bot = np.array([d[f"{xi}_B"] / d[f"sigma_{xi}_B"] for _, d in datasets]) + _boxes(axes[0, col], theta / pad, theta * pad, top, top[0]) + _boxes(axes[1, col], theta / pad, theta * pad, bot, bot[0]) + for i, (label, d) in enumerate(datasets): + _series( + axes[0, col], + theta * off[i], + top[i], + theta * d[f"sigma_{xi}"] / XI_SCALE, + i, + colors[i], + label if col == 0 else None, + ) + _series( + axes[1, col], + theta * off[i], + bot[i], + np.ones_like(theta), + i, + colors[i], + ) + sign = "+" if xi == "xip" else "-" + S.label_box(axes[0, col], rf"$\xi_{sign}$") + S.zero_line(axes[1, col]) + for ax in axes[:, col]: + S.theta_axis(ax) + S.shade_outside(ax, *shade) + axes[1, col].set_xticks([1, 10, 100]) + axes[1, col].set_xticklabels(["$1$", "$10$", "$100$"]) + axes[1, col].set_xlabel(r"$\theta$ [arcmin]") + axes[0, 0].set_ylabel(r"$\theta\,\xi \times 10^{4}$") + axes[1, 0].set_ylabel(r"$\xi^B / \sigma$") + for row in range(2): + axes[row, 1].sharey(axes[row, 0]) + axes[row, 1].tick_params(labelleft=False) + fig.legend(loc="outside upper center", ncol=min(n, 4)) + return S.save(fig, out, "full") + + +def cosebis(datasets, out, full_cut=(1.0, 250.0)): + """B_n/σ_n across versions, all scales (top) and fiducial cut (bottom).""" + n = len(datasets) + colors = version_colors(n) + offsets = 0.13 * (np.arange(n) - (n - 1) / 2) + half = 0.13 * (n - 1) / 2 + 0.12 + fid = datasets[0][1]["fiducial"] + nmodes = datasets[0][1]["nmodes"] + modes = np.arange(1, nmodes + 1) + with S.figure_style(): + fig, axes = plt.subplots(2, 1, figsize=(7.2, 3.6), sharex=True, sharey=True) + for ax, cut in zip(axes, (full_cut, fid)): + vals = np.array( + [d["cuts"][cut]["Bn"] / d["cuts"][cut]["sigma_B"] for _, d in datasets] + ) + _boxes(ax, modes - half, modes + half, vals, vals[0]) + for i, (label, _) in enumerate(datasets): + _series( + ax, + modes + offsets[i], + vals[i], + np.ones(nmodes), + i, + colors[i], + label if cut == full_cut else None, + ) + S.zero_line(ax) + ax.set_ylabel(r"$B_n / \sigma_n$") + S.header(ax, rf"${cut[0]:g}' < \theta < {cut[1]:g}'$", side="right") + axes[-1].set_xlim(0.4, nmodes + 0.6) + axes[-1].set_xticks(modes) + axes[-1].set_xlabel(r"COSEBI mode $n$") + fig.legend(loc="outside upper center", ncol=min(n, 4)) + return S.save(fig, out, "full") + + +def cl(datasets, out, ell_cut): + """C_ℓ^BB/σ and C_ℓ^EB/σ across versions.""" + n = len(datasets) + colors = version_colors(n) + ref = datasets[0][1] + ell = ref["ell"] + width = ref["ell_hi"] - ref["ell_lo"] + 1 + offsets = 0.16 * (np.arange(n) - (n - 1) / 2) + half = (0.16 * (n - 1) / 2 + 0.1) * width + mask = _cl_cut_mask(ref, ell_cut) + with S.figure_style(): + fig, axes = plt.subplots(2, 1, figsize=(7.2, 3.6), sharex=True, sharey=True) + for ax, key in zip(axes, ("BB", "EB")): + vals = np.array([d[key] / d[f"sigma_{key}"] for _, d in datasets]) + _boxes(ax, ell - half, ell + half, vals, vals[0]) + for i, (label, _) in enumerate(datasets): + _series( + ax, + ell + offsets[i] * width, + vals[i], + np.ones_like(ell), + i, + colors[i], + label if key == "BB" else None, + ) + S.zero_line(ax) + ax.set_xscale("function", functions=(np.sqrt, np.square)) + ax.set_xlim(ell.min() * 0.85, ell.max() * 1.04) + S.shade_outside(ax, ref["ell_lo"][mask][0], ref["ell_hi"][mask][-1]) + ax.set_ylabel(rf"$C_\ell^{{{key}}} / \sigma$") + S.label_box(ax, rf"$C_\ell^{{{key}}}$") + axes[-1].set_xticks([100, 400, 900, 1600]) + axes[-1].set_xticklabels(["$100$", "$400$", "$900$", "$1600$"]) + axes[-1].set_xlabel(r"$\ell$") + fig.legend(loc="outside upper center", ncol=min(n, 4)) + return S.save(fig, out, "full") diff --git a/src/sp_validation/dashboard/weights.py b/src/sp_validation/dashboard/weights.py new file mode 100644 index 000000000..31006d911 --- /dev/null +++ b/src/sp_validation/dashboard/weights.py @@ -0,0 +1,76 @@ +"""SNR–size weight-cell diagnostics and shear-response distributions.""" + +import json + +import matplotlib as mpl +import matplotlib.pyplot as plt +import numpy as np + +from . import style as S +from .sky import SEQUENTIAL + + +def weights(cells, out, layout="wide"): + """Counts, weights, response and uncalibrated shape noise per cell.""" + count = cells["count"] + with np.errstate(divide="ignore", invalid="ignore"): + response = np.where(count > 0, cells["sum_response"] / count, np.nan) + noise = np.where(count > 0, np.sqrt(cells["sum_uncal_ee"] / count), np.nan) + panels = [ + (np.where(count > 0, count, np.nan), "Number of galaxies"), + (cells["w_min"], "Weight"), + (response, "Mean response"), + (noise, r"$\sqrt{\langle e_1^2+e_2^2\rangle/2}$"), + ] + with S.figure_style(): + if layout == "wide": + fig, axes = plt.subplots(2, 2, figsize=(7.2, 5.8)) + else: + fig, axes = plt.subplots(4, 1, figsize=(3.6, 9.5)) + for ax, (values, label) in zip(axes.flat, panels): + norm = mpl.colors.LogNorm() if label == "Number of galaxies" else None + mesh = ax.pcolormesh( + cells["snr_edges"], + cells["size_ratio_edges"], + values.T, + cmap=SEQUENTIAL, + norm=norm, + ) + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlabel("SNR") + ax.set_ylabel("Galaxy / PSF T") + S.colorbar(fig, mesh, ax, label, pad=0.02) + return S.save(fig, out, "full" if layout == "wide" else "half") + + +def response_histogram(cells, out): + """Selected galaxies' metacal response components.""" + edges = cells["response_edges"] + with S.figure_style(): + fig, ax = plt.subplots(figsize=(3.6, 2.8)) + for hist, label in zip( + cells["response_hist"], (r"$R_{11}$", r"$R_{22}$", r"$R_{12}$", r"$R_{21}$") + ): + ax.stairs(hist, edges, label=label) + ax.set_yscale("log") + ax.set_xlabel("Galaxy shear response") + ax.set_ylabel("Number of galaxies") + ax.legend(ncol=2) + return S.save(fig, out, "half") + + +def summary(cells): + """Distinct values, grid size and whether every cell holds one weight.""" + count = cells["count"] + occupied = count > 0 + spread = np.nanmax(np.abs(cells["w_max"] - cells["w_min"])[occupied]) + return { + "n_values": int(len(cells["w_values"])), + "n_cells": int(count.size), + "n_occupied": int(occupied.sum()), + "max_within_cell_spread": float(spread), + "w_min": float(cells["w_values"].min()), + "w_max": float(cells["w_values"].max()), + "out_of_range": json.loads(str(cells["out_of_range"])), + } diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index 71c552a9b..2ae123220 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -1010,6 +1010,31 @@ def seal(s, blind, theory=None): return out +def derived_stamp(parts): + """The common stamp inherited by a derivation, for SACC and NPZ alike.""" + stamps = {stamp(p) for p in parts} + if len(stamps) != 1: + raise ValueError(f"input parts carry different blinds: {sorted(stamps)}") + return stamps.pop() + + +def save_npz(arrays, path, *, derived_from): + """Save a cosmological array cache with the same inheritance as SACC.""" + np.savez(path, **{**arrays, STAMP_KEY: derived_stamp(derived_from)}) + + +def load_npz(path, *, blind): + """Check explicit provenance before reading any cached signal arrays.""" + from types import SimpleNamespace + + with np.load(path, allow_pickle=False) as data: + if STAMP_KEY not in data: + raise ValueError("cosmological NPZ is missing its blind stamp") + part = SimpleNamespace(metadata={STAMP_KEY: str(data[STAMP_KEY].item())}) + expect(part, blind) + return {k: data[k] for k in data.files if k != STAMP_KEY} + + def save(s, path, *, blind=None, theory=None, derived_from=None): """Write ``s`` to ``path`` (FITS) and return what was written. @@ -1028,10 +1053,7 @@ def save(s, path, *, blind=None, theory=None, derived_from=None): signal never leaves that function. """ if derived_from is not None: - stamps = {stamp(p) for p in derived_from} - if len(stamps) != 1: - raise ValueError(f"input parts carry different blinds: {sorted(stamps)}") - (name,) = stamps + name = derived_stamp(derived_from) out = s.copy() out.metadata[STAMP_KEY] = name if blind is not None: diff --git a/src/sp_validation/tests/regression/test_tomo_rho_tau_driver_external_checkout.py b/src/sp_validation/tests/regression/test_tomo_rho_tau_driver_external_checkout.py index 8343238fe..fe7ee8434 100644 --- a/src/sp_validation/tests/regression/test_tomo_rho_tau_driver_external_checkout.py +++ b/src/sp_validation/tests/regression/test_tomo_rho_tau_driver_external_checkout.py @@ -53,6 +53,7 @@ def fake_cv(*args, **kwargs): "max_sep": "250.0", "nbins": "20", "npatch": "20", + "cov_estimate_method": "jackknife", "cat_config": str(checkout / "cosmo_val" / "cat_config.yaml"), "output_dir": str(checkout / "cosmo_val" / "output"), }, diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index 2ed8a3410..01c30aae8 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -715,7 +715,11 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): results = b_modes.calculate_eb_statistics(results) out = tmp_path / "pure_eb_data.npz" - b_modes.save_pure_eb_results(results, str(out)) + from types import SimpleNamespace + + b_modes.save_pure_eb_results( + results, str(out), derived_from=[SimpleNamespace(metadata={"blind": "none"})] + ) saved = np.load(out) for stat in ("xip_B", "xim_B", "combined"): diff --git a/src/sp_validation/tests/test_blind_consumers.py b/src/sp_validation/tests/test_blind_consumers.py index 3d3b7a8d8..d5ffbc8b4 100644 --- a/src/sp_validation/tests/test_blind_consumers.py +++ b/src/sp_validation/tests/test_blind_consumers.py @@ -11,6 +11,23 @@ SCRIPTS = Path(__file__).resolve().parents[3] / "workflow" / "scripts" +@pytest.mark.parametrize("loader", ["load_pure_eb_results", "load_cosebis_results"]) +@pytest.mark.parametrize("stamp", [None, "wrong"]) +@pytest.mark.parametrize("expected", ["none", "expected"]) +def test_npz_consumers_refuse_missing_or_wrong_blind(tmp_path, loader, stamp, expected): + import numpy as np + + from sp_validation import b_modes + + path = tmp_path / "cosmological.npz" + data = dict(scale_cuts=np.empty((0, 2)), left_edges=[], right_edges=[]) + if stamp is not None: + data["blind"] = stamp + np.savez(path, **data) + with pytest.raises(ValueError, match="stamp"): + getattr(b_modes, loader)(path, blind=expected) + + @pytest.fixture def part_path(tmp_path): part = sacc_io.new_sacc({0: ([0.1, 1.0], [1.0, 1.0])}) @@ -20,23 +37,6 @@ def part_path(tmp_path): return path -@pytest.mark.parametrize("expected", ["none", "toy"]) -def test_reporting_parts(part_path, monkeypatch, expected): - monkeypatch.syspath_prepend(str(SCRIPTS)) - from cv_runner import take_reporting_parts - - cv = SimpleNamespace( - versions=["v"], xi_parts={}, blind=lambda v: blinding.Blind(expected) - ) - if expected == "toy": - with pytest.raises(ValueError, match="stamped none.*toy"): - take_reporting_parts(cv, [part_path]) - assert not cv.xi_parts - else: - take_reporting_parts(cv, [part_path]) - assert sacc_io.stamp(cv.xi_parts["v", "reporting"]) == "none" - - @pytest.mark.parametrize( "script, input_key, reader", [ @@ -83,3 +83,64 @@ def test_load_checks_expected_blind(part_path, as_name, stamp): sacc_io.load(part_path, blind=expected) else: assert sacc_io.stamp(sacc_io.load(part_path, blind=expected)) == "toy" + + +def _cat_config(tmp_path, blind): + import yaml + + path = tmp_path / "cat_config.yaml" + path.write_text(yaml.safe_dump({"SP_v": {"blind": blind}, "paths": {}})) + return path + + +@pytest.mark.parametrize("version", ["SP_v", "SP_v_leak_corr"]) +def test_dashboard_takes_blind_from_declaration(tmp_path, version): + from sp_validation.dashboard import products + + assert products.declared_blind(_cat_config(tmp_path, "toy"), version) == "toy" + + +@pytest.mark.parametrize("declared", ["none", "toy"]) +def test_dashboard_refuses_stale_public_part( + part_path, tmp_path, monkeypatch, declared +): + """A product saved public, of a catalogue since declared under a blind, is + refused before its signal is read.""" + from sp_validation.dashboard import products + + class ReachedSignal(Exception): + pass + + def read_signal(*args, **kwargs): + raise ReachedSignal + + monkeypatch.setattr(sacc_io, "xi_correlation", read_signal) + blind = products.declared_blind(_cat_config(tmp_path, declared), "SP_v") + error = ReachedSignal if declared == "none" else ValueError + with pytest.raises(error): + products.load_xi(part_path, blind) + + +@pytest.mark.parametrize("declared", ["none", "toy"]) +def test_cosebis_grid_refuses_stale_public_xi( + part_path, tmp_path, monkeypatch, declared +): + """The scale-cut grid derives from ξ under the declared blind, not ξ's stamp.""" + import numpy as np + + from sp_validation.dashboard import cosebis_grid + + class ReachedSignal(Exception): + pass + + def read_signal(*args, **kwargs): + raise ReachedSignal + + monkeypatch.setattr(sacc_io, "get_xi", read_signal) + monkeypatch.setattr(cosebis_grid, "_paths", lambda r, v: (part_path, part_path)) + monkeypatch.setattr( + cosebis_grid, "_grid", lambda r: (np.geomspace(1, 100, 3), (1, 100, 10), 6) + ) + error = ReachedSignal if declared == "none" else ValueError + with pytest.raises(error): + cosebis_grid.compute(tmp_path, "SP_v", _cat_config(tmp_path, declared)) diff --git a/src/sp_validation/tests/test_column_schema.py b/src/sp_validation/tests/test_column_schema.py index da073b802..6181c886e 100644 --- a/src/sp_validation/tests/test_column_schema.py +++ b/src/sp_validation/tests/test_column_schema.py @@ -46,8 +46,14 @@ "R_S21", "R_S22", "TTYPE{}", + # Gaia magnitude-bin keys of the position-shear product + "G{}", # pseudo-Cl, COSEBIs and SACC products "BB", + "EE", + "BB_BB", + "EB_EB", + "EE_EE", "COSEBIS", "COVAR_BB_BB", "COVAR_EE_EE", diff --git a/src/sp_validation/tests/test_correlations.py b/src/sp_validation/tests/test_correlations.py new file mode 100644 index 000000000..3476cb6c1 --- /dev/null +++ b/src/sp_validation/tests/test_correlations.py @@ -0,0 +1,96 @@ +import healpy as hp +import numpy as np +import pytest + +from sp_validation.dashboard import correlations as C +from sp_validation.dashboard import products as P +from sp_validation.dashboard import tangential as TG + + +@pytest.mark.parametrize( + "tile_id, name", + [ + ("51.344", "051.344"), + ("51.34", "051.340"), + ("251.3", "251.300"), + ("7.312", "007.312"), + ], +) +def test_tile_name_restores_float_formatted_ids(tile_id, name): + assert C.tile_name(tile_id) == name + + +def test_tile_centres_follow_cfis_grid(): + names, ra, dec = C.tile_centres(["51.344", "51.344", "180.300"]) + assert names == ["051.344", "180.300"] + np.testing.assert_allclose(dec, [344 / 2 - 90, 300 / 2 - 90]) + np.testing.assert_allclose(ra[0], 51 / 2 / np.cos(np.deg2rad(82))) + + +def test_merge_detections_groups_repeat_exposures(): + rng = np.random.default_rng(1) + ra0 = rng.uniform(150, 151, 50) + dec0 = rng.uniform(40, 41, 50) + # Three detections per star, jittered by < 0.1 arcsec. + ra = np.repeat(ra0, 3) + rng.normal(0, 0.03 / 3600, 150) + dec = np.repeat(dec0, 3) + rng.normal(0, 0.03 / 3600, 150) + stars = np.zeros(150, dtype=[("RA", "f8"), ("DEC", "f8"), ("MAG", "f4")]) + stars["RA"], stars["DEC"], stars["MAG"] = ra, dec, np.repeat(np.arange(50), 3) + ura, udec, umag = C.unique_stars(stars, 0.5) + assert len(ura) == 50 + order = np.argsort(umag) + np.testing.assert_allclose(umag[order], np.arange(50)) + np.testing.assert_allclose(udec[order], dec0, atol=1e-4) + + +def test_quantile_edges_split_equal_counts(): + values = np.arange(300, dtype=float) + edges = C.quantile_edges(values, 3) + counts = np.histogram(values, edges)[0] + np.testing.assert_array_equal(counts, [100, 100, 100]) + + +def test_footprint_randoms_stay_in_footprint(): + nside = 64 + pixels = np.array([hp.ang2pix(nside, 200.0, 45.0, lonlat=True)]) + ra, dec = C.footprint_randoms(5000, nside, pixels, None, np.random.default_rng(0)) + assert len(ra) == 5000 + assert np.all(hp.ang2pix(nside, ra, dec, lonlat=True) == pixels[0]) + + +def test_chi2_null_hartlap_and_guard(): + cov = np.eye(4) + chi2, nu = TG.chi2_null(np.ones(4), cov, npatch=100) + assert nu == 4 + np.testing.assert_allclose(chi2, 4 * (100 - 6) / 99) + assert np.isnan(TG.chi2_null(np.ones(4), cov, npatch=5)[0]) + + +def _result(n=20, amplitude=1e-4): + theta = np.geomspace(1, 250, n) + return dict( + theta=theta, + xi_t=np.full(n, amplitude), + xi_x=np.zeros(n), + error_t=np.full(n, amplitude), + error_x=np.full(n, amplitude), + covariance=np.eye(2 * n) * amplitude**2, + npairs=np.ones(n), + weight=np.ones(n), + ) + + +def test_save_load_and_render_roundtrip(tmp_path): + prov = dict(npatch=100, n_lenses=10, star_mag_edges=[17.0, 19.0, 20.0, 22.0]) + C.save( + tmp_path, + "gamma_stars", + "v", + {k: _result() for k in ("all", "mag0", "mag1", "mag2")}, + prov, + ) + product = P.load_correlation(tmp_path / "gamma_stars_v.npz") + assert product["provenance"]["npatch"] == 100 + np.testing.assert_allclose(product["mag1"]["xi_t"], 1e-4) + out = TG.gamma_stars(product, tmp_path / "stars.png") + assert out.exists() diff --git a/src/sp_validation/tests/test_dashboard_figures.py b/src/sp_validation/tests/test_dashboard_figures.py new file mode 100644 index 000000000..09493bc34 --- /dev/null +++ b/src/sp_validation/tests/test_dashboard_figures.py @@ -0,0 +1,192 @@ +"""Geometry and binning helpers behind the dashboard figures.""" + +import healpy as hp +import numpy as np +import pandas as pd +import pytest + +from sp_validation.dashboard.field_maps import ( + leakage_cells, + weight_cell_index, + weight_grid_edges, +) +from sp_validation.dashboard.object_bias import bias_fit, leakage_fit, slopes +from sp_validation.dashboard.psf import xi_sys_comparison +from sp_validation.dashboard.sky import Zenithal, coarsen, footprint_parts, quantity + + +def test_xi_sys_comparison_rejects_incompatible_grids(tmp_path): + rt = { + "theta": np.array([1.0, 2.0]), + "theta_tau": np.array([1.0, 3.0]), + "fit": {"theta": np.array([1.0, 2.0])}, + } + with pytest.raises(ValueError, match="angular grids differ"): + xi_sys_comparison(rt, tmp_path / "plot.png") + + +def test_leakage_quantile_bins(): + rng = np.random.default_rng(17) + data = rng.normal(size=(6400, 7)) + data[:, 0] = np.exp(data[:, 0]) + data[:, 2] = 1 + data[:, 4] = 0.2 * data[:, 3] + 0.4 + data[:, 6] = -0.1 * data[:, 5] - 0.2 + _, _, sums = leakage_cells(data) + np.testing.assert_array_equal(sums[0, 6], np.full((8, 8), 100)) + np.testing.assert_allclose(slopes(sums[0]), 0.2, atol=1e-12) + np.testing.assert_allclose(slopes(sums[1]), -0.1, atol=1e-12) + + +def test_weighted_leakage_regression_has_intercept(): + x = np.array([-2.0, -1.0, 1.0, 2.0]) + y = 0.3 * x + 0.7 + w = np.array([1.0, 2.0, 3.0, 4.0]) + sums = np.array( + [w.sum(), (w * x).sum(), (w * y).sum(), (w * x * x).sum(), (w * x * y).sum()] + ) + np.testing.assert_allclose(slopes(sums), 0.3) + + +def test_leakage_fit_matches_statsmodels(): + import statsmodels.api as sm + + rng = np.random.default_rng(42) + x = rng.normal(size=200) + y = 0.03 * x + 0.4 + rng.normal(size=200) + w = rng.uniform(0.1, 3, size=200) + s = np.array( + [ + w.sum(), + (w * x).sum(), + (w * y).sum(), + (w * x * x).sum(), + (w * x * y).sum(), + (w * y * y).sum(), + len(x), + ] + ) + value, error = leakage_fit(np.tile(s, (2, 1))) + fit = sm.WLS(y, sm.add_constant(x), weights=w).fit() + np.testing.assert_allclose(value, fit.params[1]) + np.testing.assert_allclose(error, fit.bse[1]) + + +def test_binned_bias_fit(): + x = np.array([-1.0, 0.0, 1.0]) + y = 0.4 * x + 0.2 + s = np.array( + [ + np.full(3, 10.0), + 10 * x, + 10 * y, + 10 * x * x, + 10 * x * y, + 10 * (y * y + 0.1), + np.full(3, 10.0), + np.full(3, 10.0), + 10 * y, + 10 * (y * y + 0.1), + ] + ) + slope, error = bias_fit(s) + np.testing.assert_allclose(slope, 0.4 * np.sqrt(2 / 3)) + assert error > 0 + + +def test_size_residual_uses_model_normalised_sum(): + maps = { + "nside": 128, + "pixels": np.array([0]), + "n_star": np.array([10.0]), + "sum_dT_over_T_psf": np.array([-2.0]), + } + pixels, values = quantity(maps, "dT_over_T", 128) + np.testing.assert_array_equal(pixels, [0]) + np.testing.assert_allclose(values, [-0.2]) + + +def test_weight_cells_match_calibration_binning(): + """Cell indices reproduce cut_to_bins: right-closed, outside -> last cell.""" + snr_edges, _ = weight_grid_edges(20, (10, 500), (0.707, 3)) + rng = np.random.default_rng(1) + values = np.concatenate([rng.uniform(5, 700, 2000), snr_edges, [np.nan]]) + expected = pd.cut(values, snr_edges, labels=False) + expected = np.where(np.isnan(expected), 19, expected).astype(int) + np.testing.assert_array_equal(weight_cell_index(values.copy(), snr_edges), expected) + + +@pytest.mark.parametrize("rotation", [0.0, 37.0, -80.0]) +def test_zenithal_round_trip(rotation): + proj = Zenithal(229.0, 57.0, rotation) + rng = np.random.default_rng(2) + ra = rng.uniform(150, 290, 500) + dec = rng.uniform(25, 85, 500) + x, y = proj.forward(ra, dec) + ra2, dec2, valid = proj.inverse(x, y) + assert valid.all() + np.testing.assert_allclose(dec2, dec, atol=1e-8) + np.testing.assert_allclose(np.mod(ra2 - ra + 180, 360) - 180, 0, atol=1e-8) + + +def test_zenithal_ra_increases_to_the_left(): + x, _ = Zenithal(200.0, 40.0).forward( + np.array([199.0, 201.0]), np.array([40.0, 40.0]) + ) + assert x[1] < x[0] + + +def test_footprint_parts_separates_patch_and_strip(): + nside = 256 + patch = hp.query_disc( + nside, hp.ang2vec(220, 55, lonlat=True), np.radians(10), nest=True + ) + ra = np.mod(np.linspace(-30, 60, 4000), 360) + strip = np.unique( + np.concatenate( + [ + hp.ang2pix(nside, ra, np.full_like(ra, d), lonlat=True, nest=True) + for d in np.arange(30, 34, 0.2) + ] + ) + ) + parts = footprint_parts(np.concatenate([patch, strip]), nside) + assert len(parts) == 2 + assert parts[0].sum() == len(patch) + + +def test_coarsen_sums_children(): + pixels = np.arange(64) + out, sums = coarsen(pixels, {"n": np.ones(64)}, nside=4, nside_out=2) + np.testing.assert_array_equal(out, np.arange(16)) + np.testing.assert_array_equal(sums["n"], np.full(16, 4)) + + +def test_cosebis_grid_panel_and_blind_check(tmp_path): + from sp_validation.dashboard.products import load_cosebis_grid + from sp_validation.dashboard.scale_cuts import config_space + + edges = np.geomspace(1, 250, 21) + n = 20 + tri = np.where(np.triu(np.ones((n, n))) > 0, 0.5, np.nan) + path = tmp_path / "cosebis_grid_V.npz" + np.savez( + path, + left_edges=edges[:-1], + right_edges=edges[1:], + pte_B_n6=tri, + pte_B_n20=tri, + blind=np.array("unions"), + ) + with pytest.raises(ValueError, match="another blind"): + load_cosebis_grid(path, "other") + cg = load_cosebis_grid(path, "unions") + pe = { + "left_edges": edges[:-1], + "right_edges": edges[1:], + "pte_matrices": {"xip_B": tri, "xim_B": tri}, + } + for layout in ("wide", "narrow"): + out = tmp_path / f"ptes_{layout}.png" + config_space(pe, cg, out, (12, 83), layout=layout) + assert out.exists() diff --git a/src/sp_validation/tests/test_dashboard_selection.py b/src/sp_validation/tests/test_dashboard_selection.py new file mode 100644 index 000000000..484196e7b --- /dev/null +++ b/src/sp_validation/tests/test_dashboard_selection.py @@ -0,0 +1,55 @@ +"""Tests for the cumulative comprehensive-catalogue selection.""" + +import h5py +import numpy as np +import pytest +import yaml + +from sp_validation.dashboard.selection_hist import build + + +def test_cumulative_selection_and_expected_count(tmp_path): + names = [ + "mag", + "FLAGS", + "NGMIX_FLAGS_NOSHEAR", + "NGMIX_FLUX_NOSHEAR", + "NGMIX_FLUX_ERR_NOSHEAR", + "NGMIX_T_NOSHEAR", + "NGMIX_T_PSF_RECONV_NOSHEAR", + ] + rows = np.zeros(4, dtype=[(name, "f8") for name in names]) + rows["mag"] = [20, 21, 22, 23] + rows["FLAGS"] = [0, 0, 3, 0] + rows["NGMIX_FLUX_NOSHEAR"] = [20, 5, 20, 20] + rows["NGMIX_FLUX_ERR_NOSHEAR"] = 1 + rows["NGMIX_T_NOSHEAR"] = 1 + rows["NGMIX_T_PSF_RECONV_NOSHEAR"] = 1 + path = tmp_path / "data.hdf5" + with h5py.File(path, "w") as f: + f["data"] = rows + config = tmp_path / "config.yaml" + config.write_text( + yaml.safe_dump( + { + "dat": [ + { + "col_name": "FLAGS", + "label": "flags", + "kind": "smaller_equal", + "value": 2, + } + ], + "metacal": { + "gal_snr_min": 10, + "gal_snr_max": 500, + "gal_rel_size_min": 0.707, + "gal_rel_size_max": 3, + }, + } + ) + ) + result = build(path, config, expected=2, chunk=2) + np.testing.assert_array_equal(result["counts"], [4, 3, 2]) + with pytest.raises(ValueError, match="expected 3"): + build(path, config, expected=3, chunk=2) diff --git a/src/sp_validation/tests/test_plot_products.py b/src/sp_validation/tests/test_plot_products.py new file mode 100644 index 000000000..2b72f2e03 --- /dev/null +++ b/src/sp_validation/tests/test_plot_products.py @@ -0,0 +1,390 @@ +"""Small, catalogue-free products exercise the compute/render boundary.""" + +from pathlib import Path +from types import SimpleNamespace + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from sp_validation import b_modes +from sp_validation.cosmo_val import diagnostic_plots as plots +from sp_validation.cosmo_val.catalog_characterization import ( + CatalogCharacterizationMixin, +) + +STYLES = {"synthetic": {"colour": "C0", "marker": "o"}} + + +@pytest.fixture(autouse=True) +def no_catalogues(monkeypatch): + from sp_validation import io + + def forbidden(*args, **kwargs): + raise AssertionError("A renderer attempted a catalogue read") + + monkeypatch.setattr(io, "open_entry", forbidden) + yield + plt.close("all") + + +def assert_figures(*paths): + for path in paths: + assert Path(path).stat().st_size > 100 + + +def psf_product(): + rng = np.random.default_rng(42) + data = { + "theta": np.geomspace(1, 100, 4), + "flat_samples": rng.normal(size=(1000, 3)), + } + for component in ("plus", "minus"): + samples = np.exp(rng.normal(-10, 0.1, (100, 4))) + data.update( + { + f"samples_{component}": samples, + f"mean_{component}": samples.mean(axis=0), + f"var_{component}": samples.var(axis=0), + f"quantiles_{component}": np.quantile(samples, [0.16, 0.84], axis=0), + } + ) + return data + + +def test_cached_psf_handlers_do_not_initialize_catalogues(tmp_path): + import importlib.util + + root = Path(__file__).resolve().parents[3] + spec = importlib.util.spec_from_file_location( + "plot_test_runner", root / "workflow/scripts/cv_runner.py" + ) + runner = importlib.util.module_from_spec(spec) + spec.loader.exec_module(runner) + cv = SimpleNamespace( + cc={"paths": {"output": str(tmp_path)}}, + treecorr_config={"min_sep": 1.0, "max_sep": 100.0, "nbins": 4}, + ) + runner.prepare_cached_psf(cv) + assert cv._rho_stat_handler.catalogs._output == str(tmp_path / "rho_tau_stats") + assert cv._tau_stat_handler.catalogs is cv._rho_stat_handler.catalogs + + +def test_objectwise_compute_uses_regression_not_plot_driver(tmp_path, monkeypatch): + from contextlib import nullcontext + + from sp_validation.cosmo_val.psf_systematics import PSFSystematicsMixin, leakage + + params = {k: SimpleNamespace(value=0.01, stderr=0.001) for k in ("a11", "a22")} + calls = [] + + def regress(*args, **kwargs): + calls.append(kwargs) + return params + + monkeypatch.setattr(leakage, "corr_2d", regress) + monkeypatch.setattr(leakage, "save_to_file", lambda *args: None) + obj = SimpleNamespace( + get_out_base=lambda *args, **kwargs: str(tmp_path / "fit"), + temporarily_read_data=lambda **kwargs: nullcontext(), + get_ellipticity_weights=lambda: ([np.zeros(4), np.zeros(4)], np.ones(4)), + _dat={"p1": np.zeros(4), "p2": np.zeros(4)}, + _params={"e1_PSF_col": "p1", "e2_PSF_col": "p2", "verbose": False}, + _stats_file=None, + ) + cv = PSFSystematicsMixin() + cv.print_cyan = lambda *args: None + result = cv._objectwise_leakage_bin(obj, "synthetic", "all", True, "lin") + assert len(calls) == 1 + assert result["aii_mean"].nominal_value == pytest.approx(0.01) + + +def test_weights_roundtrip_and_overwrite(tmp_path): + path = tmp_path / "weights.npz" + w = np.array([0.1, 0.2, 0.2, 0.5, 0.9]) + + # This is exactly the compute seam; no matplotlib histogram in the renderer. + class CV(CatalogCharacterizationMixin): + versions = ["synthetic"] + results = { + "synthetic": SimpleNamespace( + temporarily_read_data=lambda: __import__("contextlib").nullcontext() + ) + } + + def _read_shear_cols(self, *args): + return w + + counts, edges = CV().weight_histograms(nbins=3)["synthetic"] + assert counts.sum() == pytest.approx(w.sum()) + np.savez(path, counts=counts, edges=edges) + with np.load(path, allow_pickle=False) as cache: + hist = {"synthetic": (cache["counts"], cache["edges"])} + output = tmp_path / "weights.png" + output.write_text("stale") + plots.plot_weights(hist, STYLES, output) + assert_figures(output) + + +def test_footprint_every_region_from_one_map(tmp_path, monkeypatch): + from sp_validation.cosmo_val.diagnostic_products import ( + footprint_counts, + load_footprint, + save_footprint, + ) + + count_map = footprint_counts([150.0, 150.0, 20.0], [40.0, 40.0, 30.0], 4, 16) + assert count_map[count_map.valid_pixels].sum() == 3 + cache = tmp_path / "map.npz" + save_footprint(count_map, cache) + loaded = load_footprint(cache) + calls = [] + + def draw(self, data, region, outpath, colorbar_label): + assert colorbar_label == "Galaxies per pixel (NSIDE=16)" + np.testing.assert_array_equal(data.valid_pixels, count_map.valid_pixels) + calls.append(region) + Path(outpath).write_bytes(b"rendered" * 100) + + monkeypatch.setattr(plots.FootprintPlotter, "plot_region", draw) + outputs = {r: tmp_path / f"{r}.png" for r in plots.FootprintPlotter._regions} + plots.plot_footprint(loaded, outputs) + assert calls == list(plots.FootprintPlotter._regions.values()) + assert_figures(*outputs.values()) + + +def test_scalar_diagnostics_and_covariance(tmp_path): + coeff = { + "synthetic": { + "tomo_bin_all": { + key: [0.001, 0.0001] + for key in ("aii_mean", "alpha_mean", "alpha_1", "alpha_0") + } + } + } + leakage, covariance = tmp_path / "leakage.png", tmp_path / "covariance.pdf" + plots.plot_objectwise_leakage(coeff, STYLES, leakage) + plots.plot_covariance(np.eye(4), covariance) + assert_figures(leakage, covariance) + + +def test_psf_fit_and_sealed_ratio_rendering(tmp_path): + data = psf_product() + path = tmp_path / "psf.npz" + np.savez(path, **data) + with np.load(path, allow_pickle=False) as cache: + products = {"synthetic": dict(cache)} + contours, samples, summary, ratio = [ + tmp_path / f"{x}.png" for x in ("contours", "samples", "summary", "ratio") + ] + plots.plot_rho_tau_fits(products, STYLES, contours, samples, summary) + gg = SimpleNamespace( + meanr=data["theta"], + xip=np.ones(4) * 1e-4, + xim=np.ones(4) * 1e-4, + varxip=np.ones(4) * 1e-12, + varxim=np.ones(4) * 1e-12, + ) + plots.plot_ratio_xi_sys_xi(products, {"synthetic": gg}, STYLES, ratio) + xi_paths = [tmp_path / f"xi{i}.png" for i in range(2)] + plots.plot_2pcf({"synthetic": gg}, STYLES, xi_paths) + assert_figures(contours, samples, summary, ratio, *xi_paths) + + +@pytest.mark.parametrize("kind,indices", [("rho", range(6)), ("tau", (0, 2, 5))]) +def test_psf_overlay_arrays(tmp_path, kind, indices): + table = {"theta": np.geomspace(1, 100, 4)} + for i in indices: + table[f"{kind}_{i}_p"] = np.ones(4) * 1e-6 + table[f"var{kind}_{i}_p"] = np.ones(4) * 1e-14 + output = tmp_path / f"{kind}.png" + plots.plot_psf_stats( + {"synthetic": table}, + STYLES, + output, + kind, + [np.eye(12) * 1e-14] if kind == "tau" else [], + ) + assert_figures(output) + + +def test_pure_eb_plot_cache_is_complete(tmp_path): + n = 3 + edges = np.geomspace(1, 100, n + 1) + theta = np.sqrt(edges[:-1] * edges[1:]) + result = dict( + theta=theta, + left_edges=edges[:-1], + right_edges=edges[1:], + theta_int=theta, + xip_int=np.ones(n) * 1e-5, + xim_int=np.ones(n) * 1e-5, + xip=np.ones(n) * 1e-5, + xim=np.ones(n) * 1e-5, + var_xip=np.ones(n) * 1e-12, + var_xim=np.ones(n) * 1e-12, + cov=np.eye(6 * n) * 1e-12, + npatch=None, + ) + result.update({k: np.ones(n) * 1e-6 for k in b_modes._EB_KEYS}) + result = b_modes.calculate_eb_statistics(result) + path = tmp_path / "sealed_eb.npz" + parent = SimpleNamespace(metadata={"blind": "toy"}) + b_modes.save_pure_eb_results(result, path, derived_from=[parent]) + loaded = b_modes.load_pure_eb_results(path, blind="toy") + with np.load(path, allow_pickle=False) as cache: + assert cache["blind"].item() == "toy" + assert loaded["npatch"] is None + for key in ("theta_int", "xip_int", "xim_int", "xip", "var_xip", "cov"): + np.testing.assert_array_equal(result[key], loaded[key]) + outputs = [tmp_path / f"eb{i}.png" for i in range(4)] + b_modes.plot_integration_vs_reporting(loaded, outputs[0], "synthetic") + b_modes.plot_pure_eb_correlations(loaded, outputs[1], "synthetic") + lo, hi = plt.gcf().axes[0].get_ylim() + assert lo == -hi + assert hi > np.max(theta * (loaded["xip"] + np.sqrt(loaded["var_xip"])) / 1e-4) + b_modes.plot_pte_2d_heatmaps(loaded, "synthetic", outputs[2]) + b_modes.plot_eb_covariance_matrix( + loaded["cov"], "analytic", outputs[3], "synthetic" + ) + assert_figures(*outputs) + + +def test_cosebis_transform_caches_selected_bins(tmp_path, monkeypatch): + from cosmo_numba.B_modes import cosebis + + class SyntheticKernel: + def __init__(self, **kwargs): + pass + + def cosebis_from_xipm(self, theta, xip, xim, **kwargs): + return np.ones(2), np.zeros(2) + + def cosebis_covariance_from_xipm_covariance(self, theta, cov): + return np.eye(4) + + monkeypatch.setattr(cosebis, "COSEBIS", SyntheticKernel) + edges = b_modes.log_bin_edges(0.08, 300, 1000) + theta = np.sqrt(edges[0] * edges[1]) + results = b_modes.cosebis_scan_from_xi( + theta, + np.zeros(1000), + np.zeros(1000), + np.eye(2000), + *edges, + nmodes=2, + scale_cuts=[(12, 83)], + ) + np.testing.assert_array_equal(results[12, 83]["bin_indices"], [609, 843]) + path = tmp_path / "cuts.npz" + b_modes.save_cosebis_results( + results, + path, + (12, 83), + edges, + derived_from=[SimpleNamespace(metadata={"blind": "toy"})], + ) + loaded, _ = b_modes.load_cosebis_results(path, blind="toy") + np.testing.assert_array_equal(loaded[12, 83]["bin_indices"], [609, 843]) + + +def test_cosebis_heatmap_uses_stored_inward_bins(tmp_path, monkeypatch): + # Nearest edges would put this at (0, 2); the transform selected (1, 2). + result = {(1.1, 7.9): dict(chi2_E=4.0, pte_B=0.7, bin_indices=[1, 2])} + edges = (np.array([1.0, 2.0, 4.0]), np.array([2.0, 4.0, 8.0])) + b_modes.plot_cosebis_scale_cut_heatmap( + result, edges, "synthetic", tmp_path / "cut.png", (1.1, 7.9) + ) + ax = plt.gcf().axes[1] + matrix = ax.images[0].get_array() + assert matrix.shape == (1, 1) + assert matrix[0, 0] == pytest.approx(0.7) + assert np.ma.count(matrix) == 1 + assert ax.patches[0].get_xy() == (0, 0) + assert ax.get_xticklabels()[0].get_text() == "2.00" + assert ax.get_yticklabels()[0].get_text() == "4.00" + + +def test_notebook_cosebis_cache_and_heatmap_use_integration_edges( + tmp_path, monkeypatch +): + from sp_validation.cosmo_val import cosebis as module + + class CV(module.CosebisMixin): + versions = ["synthetic"] + cc = {"paths": {"output": str(tmp_path)}} + treecorr_config = {"npatch": 1} + xi_parts = { + ("synthetic", "integration"): SimpleNamespace(metadata={"blind": "toy"}) + } + _cosebis_results = {} + + def _binning(self, *args): + return dict(min_sep=1.0, max_sep=8.0, nbins=3) + + edges = b_modes.log_bin_edges(1.0, 8.0, 10) + results = { + cut: dict( + En=np.ones(2), + Bn=np.zeros(2), + cov=np.eye(4), + chi2_E=2.0, + chi2_B=0.0, + pte_B=1.0, + bin_indices=indices, + ) + for cut, indices in [((1.0, 8.0), [0, 10]), ((1.0, 4.0), [0, 6])] + } + monkeypatch.setattr(module, "plot_cosebis_modes", lambda *args, **kwargs: None) + monkeypatch.setattr(module, "plot_cosebis_covariance_matrix", lambda *args: None) + + def heatmap(data, used_edges, *args, **kwargs): + np.testing.assert_array_equal(used_edges, edges) + + monkeypatch.setattr(module, "plot_cosebis_scale_cut_heatmap", heatmap) + CV().plot_cosebis(results=results, min_sep_int=1.0, max_sep_int=8.0, nbins_int=10) + _, cached_edges = b_modes.load_cosebis_results( + next(tmp_path.glob("*.npz")), blind="toy" + ) + np.testing.assert_array_equal(cached_edges, edges) + + +def test_cosebis_preserves_all_cuts_for_plotting(tmp_path): + edges = np.array([1.0, 2.0, 4.0, 8.0]) + results = { + (lo, hi): dict( + En=np.array([1.0, 2.0]), + Bn=np.array([0.1, 0.2]), + cov=np.eye(4), + chi2_E=5.0, + chi2_B=0.05, + pte_B=0.9, + bin_indices=np.array([i, j]), + ) + for i, lo in enumerate(edges[:-1]) + for j, hi in enumerate(edges[i + 1 :], start=i + 1) + } + path = tmp_path / "sealed_cosebis.npz" + parent = SimpleNamespace(metadata={"blind": "toy"}) + b_modes.save_cosebis_results( + results, path, (1.0, 8.0), (edges[:-1], edges[1:]), derived_from=[parent] + ) + loaded, loaded_edges = b_modes.load_cosebis_results(path, blind="toy") + with np.load(path, allow_pickle=False) as cache: + assert cache["blind"].item() == "toy" + assert loaded.keys() == results.keys() + np.testing.assert_array_equal(loaded_edges, (edges[:-1], edges[1:])) + with np.load(path, allow_pickle=False) as cache: + assert float(cache["pte_B"]) == 0.9 # Existing summary consumer. + outputs = [tmp_path / f"cosebis{i}.png" for i in range(3)] + b_modes.plot_cosebis_modes(loaded[1.0, 8.0], "synthetic", outputs[0]) + b_modes.plot_cosebis_covariance_matrix( + loaded[1.0, 8.0], "synthetic", "analytic", outputs[1] + ) + b_modes.plot_cosebis_scale_cut_heatmap( + loaded, loaded_edges, "synthetic", outputs[2] + ) + assert_figures(*outputs) diff --git a/src/sp_validation/tests/test_plot_workflow.py b/src/sp_validation/tests/test_plot_workflow.py new file mode 100644 index 000000000..0aee0c3cc --- /dev/null +++ b/src/sp_validation/tests/test_plot_workflow.py @@ -0,0 +1,181 @@ +"""Dry-run the real plotting DAG with caches present and no catalogues. + +Set SPV_TEST_SNAKEMAKE to a host Snakemake executable if it isn't in the image. +The test never launches a workflow job or opens a real science product. +""" + +import importlib.util +import os +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[3] +PLOT_RULES = [ + "cosmo_val_plots", + "cv_plot_rho_stats", + "cv_plot_tau_stats", + "cv_plot_rho_tau_fits", + "cv_plot_footprints", + "cv_plot_objectwise_leakage", + "cv_plot_weights", + "cv_plot_2pcf", + "cv_ratio_xi_sys_xi", + "cv_plot_pure_eb", + "cv_plot_cosebis", + "cv_plot_pseudo_cl", + "covariance_plot", +] + + +@pytest.mark.parametrize("pseudo_cl", [False, True]) +def test_cached_plot_dag_has_no_compute_jobs(tmp_path, monkeypatch, pseudo_cl): + executable = os.environ.get("SPV_TEST_SNAKEMAKE") or shutil.which("snakemake") + if not executable: + pytest.skip("Snakemake not installed; set SPV_TEST_SNAKEMAKE") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("COSMO_VAL", str(tmp_path / "products")) + monkeypatch.setenv("COSMO_INFERENCE", str(tmp_path / "inference")) + monkeypatch.setenv("PYTHONUNBUFFERED", "1") + # common.configure expects this fixed relative cosmology file at parse time. + cosmology = tmp_path / "results/cosmology/planck18.json" + cosmology.parent.mkdir(parents=True) + shutil.copy(ROOT / "papers/cosmo_val/results/cosmology/planck18.json", cosmology) + config = yaml.safe_load((ROOT / "papers/cosmo_val/config/config.yaml").read_text()) + config["versions"] = [ + "SP_v1.4.6.3" + ] # Public fixture entry; no real input is opened. + config["cosmo_val"]["include_pseudo_cl"] = pseudo_cl + configfile = tmp_path / "config.yaml" + configfile.write_text(yaml.safe_dump(config)) + + spec = importlib.util.spec_from_file_location( + "plot_test_common", ROOT / "workflow/common.py" + ) + common = importlib.util.module_from_spec(spec) + spec.loader.exec_module(common) + # Set the path-helper context without importing Snakemake in the image; + # the subprocess below runs the real configure() in the host environment. + common.CATALOG_CONFIG = yaml.safe_load(Path(common.CAT_CONFIG).read_text()) + common.FIDUCIAL = config["fiducial"] + common.DEFAULT_MASK_SUFFIX = ( + "_masked" if config["covariance"].get("default_masked", False) else "" + ) + namespace = dict(vars(common), config=config) + # Execute only the ordinary Python path helpers, before the first rule. + twopoint = (ROOT / "workflow/rules/twopoint.smk").read_text().split("rule xi:")[0] + exec(twopoint, namespace) + diagnostics = ( + (ROOT / "workflow/rules/cosmo_val.smk") + .read_text() + .split("rule cv_plot_rho_stats:")[0] + ) + exec(diagnostics, namespace) + ns = namespace + version = config["versions"][0] + products = [ + *ns["CV_PSF_PRODUCTS"], + *ns["CV_FOOTPRINT_MAPS"], + str(common.COSMO_VAL / "weight_hist.npz"), + str(common.COSMO_VAL / "leakage_coefficients.json"), + common.cv_rho_stats(version, ns["CV_FIDUCIAL"]), + common.cv_tau_stats(version, ns["CV_FIDUCIAL"]), + *ns["cv_tau_covariances"](), + ns["cv_xi_sacc"](version, "reporting"), + ns["cv_pure_eb_npz"](version), + ns["cv_cosebis_npz"](version), + ns["cv_xi_cov"](version), + ns["cv_xi_cov_integration"](version), + ] + if pseudo_cl: + products += [ + ns["cv_pseudo_cl_analysis_sacc"](version), + ns["cv_pseudo_cl_cov"](version), + ] + for product in products: + path = Path(product) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + + command = [ + executable, + "-s", + str(ROOT / "workflow/Snakefile"), + "--configfile", + str(configfile), + "--cores", + "1", + "--dry-run", + "--forceall", + "--workflow-profile", + "none", + "--rerun-triggers", + "mtime", + "--allowed-rules", + *PLOT_RULES, + "--", + "cosmo_val_plots", + ] + result = subprocess.run(command, text=True, capture_output=True, timeout=120) + log = result.stdout + result.stderr + assert result.returncode == 0, log + assert "cv_plot_weights" in log + assert "cv_plot_footprints" in log + assert "cv_plot_pure_eb" in log + assert "cv_plot_cosebis" in log + assert ("rule cv_plot_pseudo_cl:" in log) == pseudo_cl + for forbidden in ( + "cv_weights", + "cv_footprints", + "cv_rho_tau_fits", + "cv_cosebis", + "cv_pure_eb", + "xi", + "rho_tau_stats", + "covariance_run", + ): + assert f"rule {forbidden}:" not in log + # Existing PNGs must never hide a missing cache when allowed-rules prunes + # a plot job. Populate *all* figures, then remove each cache in turn. + figures = [ + *ns["CV_FOOTPRINT_FIGURES"], + *ns["cv_pure_eb_figures"](version).values(), + *ns["cv_cosebis_figures"](version).values(), + *[ + str(common.COSMO_VAL / name) + for name in ( + "weight_hist.png", + "leakage_coefficients.png", + "ratio_xi_sys_xi.png", + "xi_pm_tomography_False.png", + "xi_pm_theta_tomography_False.png", + "rho_tau_stats/rho_stats.png", + "rho_tau_stats/tau_stats.png", + "rho_tau_stats/contours_tau_stat.png", + "rho_tau_stats/xi_psf_sys_tomography_False_sample.png", + "rho_tau_stats/xi_psf_sys_tomography_False_mean_and_std.png", + ) + ], + ns["cv_xi_cov"](version).removesuffix(".dat") + "_corr_plot.pdf", + ns["cv_xi_cov_integration"](version).removesuffix(".dat") + "_corr_plot.pdf", + ] + if pseudo_cl: + figures += [ + str(common.COSMO_VAL / f"cell_{name}.png") for name in ("ee", "eb", "bb") + ] + for figure in figures: + path = Path(figure) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("existing figure") + for product in products: + path = Path(product) + path.unlink() + missing = subprocess.run(command, text=True, capture_output=True, timeout=120) + log = missing.stdout + missing.stderr + assert missing.returncode != 0, f"Missing cache {product} was ignored:\n{log}" + assert "MissingInputException" in log + assert "existing figure" == Path(figures[0]).read_text() + path.touch() diff --git a/src/sp_validation/tests/test_plotfix_cell.py b/src/sp_validation/tests/test_plotfix_cell.py new file mode 100644 index 000000000..e652d1597 --- /dev/null +++ b/src/sp_validation/tests/test_plotfix_cell.py @@ -0,0 +1,29 @@ +"""Pseudo-Cl plots must preserve signs, including error bars.""" + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from sp_validation.cosmo_val.pseudo_cl import plot_pseudo_cl_spectrum + + +def test_signed_cell(tmp_path, monkeypatch): + figures = [] + monkeypatch.setattr(plt, "close", lambda fig: figures.append(fig)) + plot_pseudo_cl_spectrum( + { + "toy": { + "ell": np.array([100.0, 400.0]), + "cl": np.array([-1.0, 1.0]), + "cov": np.eye(2) * 4, + } + }, + "EB", + tmp_path / "eb.png", + ) + fig = next(fig for fig in reversed(figures) if hasattr(fig, "axes")) + assert fig.axes[1].get_yscale() == "symlog" + lo, hi = fig.axes[1].get_ylim() + assert lo < -3 and hi > 3 diff --git a/src/sp_validation/tests/test_plotfix_layout.py b/src/sp_validation/tests/test_plotfix_layout.py new file mode 100644 index 000000000..2610b5e35 --- /dev/null +++ b/src/sp_validation/tests/test_plotfix_layout.py @@ -0,0 +1,42 @@ +"""Presentation regressions with synthetic arrays only.""" + +from types import SimpleNamespace + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from sp_validation.cosmo_val import diagnostic_plots as plots + + +def test_xi_panels_use_constrained_layout(tmp_path, monkeypatch): + figures = [] + monkeypatch.setattr(plt, "close", lambda fig: figures.append(fig)) + gg = SimpleNamespace( + meanr=np.array([1.0, 10.0]), + xip=np.array([1.0, -1.0]), + xim=np.array([-1.0, 1.0]), + varxip=np.ones(2), + varxim=np.ones(2), + ) + plots.plot_2pcf( + {"toy": gg}, + {"toy": {"marker": "o", "colour": "C0"}}, + [tmp_path / "xi.png", tmp_path / "theta_xi.png"], + ) + assert all(fig.get_constrained_layout() for fig in figures if hasattr(fig, "axes")) + + +def test_tau_panels_use_constrained_layout(tmp_path, monkeypatch): + figures = [] + monkeypatch.setattr(plt, "close", lambda fig: figures.append(fig)) + table = {"theta": np.array([1.0, 10.0])} + for i in (0, 2, 5): + table[f"tau_{i}_p"] = np.zeros(2) + table[f"vartau_{i}_p"] = np.ones(2) + plots.plot_psf_stats( + {"toy": table}, {"toy": {"colour": "C0"}}, tmp_path / "tau.png", "tau" + ) + assert all(fig.get_constrained_layout() for fig in figures if hasattr(fig, "axes")) diff --git a/src/sp_validation/tests/test_plotfix_psf.py b/src/sp_validation/tests/test_plotfix_psf.py new file mode 100644 index 000000000..5b6c33594 --- /dev/null +++ b/src/sp_validation/tests/test_plotfix_psf.py @@ -0,0 +1,33 @@ +"""Signed PSF contamination must remain visible.""" + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from sp_validation.cosmo_val import diagnostic_plots as plots + + +def test_signed_psf_summary(tmp_path, monkeypatch): + from shear_psf_leakage import plots as external + + monkeypatch.setattr(external, "plot_contours", lambda *a, **kw: None) + figures = [] + monkeypatch.setattr(plt, "close", lambda fig: figures.append(fig)) + data = {"theta": np.array([1.0, 10.0]), "flat_samples": np.ones((10, 3))} + for component in ("plus", "minus"): + data[f"samples_{component}"] = np.array([[1.0, -1.0]]) + data[f"mean_{component}"] = np.array([1.0, -1.0]) + data[f"quantiles_{component}"] = np.array([[0.0, -2.0], [2.0, 0.0]]) + plots.plot_rho_tau_fits( + {"toy": data}, + {"toy": {"colour": "C0"}}, + tmp_path / "contours.png", + tmp_path / "samples.png", + tmp_path / "summary.png", + ) + for fig in figures: + if hasattr(fig, "axes"): + assert all(ax.get_yscale() == "linear" for ax in fig.axes) + assert all(ax.get_ylim()[0] < 0 for ax in fig.axes) diff --git a/src/sp_validation/tests/test_position_shear.py b/src/sp_validation/tests/test_position_shear.py new file mode 100644 index 000000000..ac7c0d8b8 --- /dev/null +++ b/src/sp_validation/tests/test_position_shear.py @@ -0,0 +1,94 @@ +import numpy as np +import pytest +import treecorr + +from sp_validation.dashboard.position_shear import measure + + +def test_injected_tangential_shear_and_joint_covariance(): + rng = np.random.default_rng(42) + # Four well-separated lens groups, each with sources in a tangential pattern. + centres = np.array([[0, 0], [4, 0], [0, 4], [4, 4]], dtype=float) + ra, dec, g1, g2 = [], [], [], [] + for x, y in centres: + angle = rng.uniform(0, 2 * np.pi, 200) + radius = rng.uniform(0.03, 0.12, 200) + ra.extend(x + radius * np.cos(angle) / np.cos(np.deg2rad(y))) + dec.extend(y + radius * np.sin(angle)) + g1.extend(-0.05 * np.cos(2 * angle)) + g2.extend(0.05 * np.sin(2 * angle)) + lon, lat = np.deg2rad(centres).T + patch_centers = np.column_stack( + (np.cos(lat) * np.cos(lon), np.cos(lat) * np.sin(lon), np.sin(lat)) + ) + lens = treecorr.Catalog( + ra=centres[:, 0], + dec=centres[:, 1], + ra_units="deg", + dec_units="deg", + patch_centers=patch_centers, + ) + source = treecorr.Catalog( + ra=ra, + dec=dec, + g1=g1, + g2=g2, + ra_units="deg", + dec_units="deg", + patch_centers=patch_centers, + ) + result = measure(lens, source, min_sep=2, max_sep=7, nbins=3, num_threads=1) + np.testing.assert_allclose(result["xi_t"], 0.05, atol=2e-4) + np.testing.assert_allclose(result["xi_x"], 0, atol=1e-4) + assert result["covariance"].shape == (6, 6) + assert np.all(result["npairs"] > 0) + + +def test_rejects_mismatched_patch_centres(): + source = treecorr.Catalog( + ra=[0, 1, 2], + dec=[0, 1, 2], + g1=[0, 0, 0], + g2=[0, 0, 0], + ra_units="deg", + dec_units="deg", + npatch=2, + ) + lens = treecorr.Catalog( + ra=[10, 11, 12], + dec=[0, 1, 2], + ra_units="deg", + dec_units="deg", + npatch=2, + ) + with pytest.raises(ValueError, match="patch centres"): + measure(lens, source, num_threads=1) + + +def test_random_subtraction_identical_catalogues(): + rng = np.random.default_rng(3) + ra = rng.uniform(0, 3, 500) + dec = rng.uniform(0, 3, 500) + source = treecorr.Catalog( + ra=ra, + dec=dec, + g1=rng.normal(size=500), + g2=rng.normal(size=500), + ra_units="deg", + dec_units="deg", + npatch=6, + rng=rng, + ) + lens = treecorr.Catalog( + ra=ra[::5], + dec=dec[::5], + ra_units="deg", + dec_units="deg", + patch_centers=source.patch_centers, + ) + result = measure( + lens, source, randoms=lens, min_sep=2, max_sep=60, nbins=4, num_threads=1 + ) + np.testing.assert_allclose(result["xi_t"], 0, atol=1e-14) + np.testing.assert_allclose(result["xi_x"], 0, atol=1e-14) + np.testing.assert_allclose(result["covariance"], 0, atol=1e-14) diff --git a/workflow/README.md b/workflow/README.md index e3e15c6c6..8336d02c0 100644 --- a/workflow/README.md +++ b/workflow/README.md @@ -159,6 +159,39 @@ ln -s /n17data/cdaley/unions/code/sp_validation/cosmo_inference/data/mask **Caveat:** `rerun-triggers: code` watches rule bodies and `script:` files, not `src/`. Editing a module under `src/` does not by itself mark outputs stale — force with `-F` or `--forcerun `. +The `cosmo_val` plot rules are an exception: their plotting modules are explicit inputs, so editing those modules invalidates figures without invalidating computations. + +### Re-render the cosmo_val figures from cache + +`cosmo_val_all` runs the computations and every companion plot. +`cosmo_val_plots` gathers only figures, including the reporting/integration covariance correlation plots. +Plots read the sealed SACC/NPZ products, sparse footprint counts, weight histograms and PSF/leakage summaries; no plot rule reads a catalogue or fits a model. +Every figure is a declared output and is overwritten when its plot rule runs. + +After a full run, use the same config and output roots to render changed plots: + +```bash +snakemake --profile workflow/profiles/default -s workflow/Snakefile \ + --configfile --cores 4 cosmo_val_plots +``` + +To force every figure to be restyled, while **refusing to run any computation even if a cache is missing**, restrict the allowed rules: + +```bash +plot_rules=(cosmo_val_plots cv_plot_rho_stats cv_plot_tau_stats \ + cv_plot_rho_tau_fits cv_plot_footprints cv_plot_objectwise_leakage \ + cv_plot_weights cv_plot_2pcf cv_ratio_xi_sys_xi cv_plot_pure_eb \ + cv_plot_cosebis cv_plot_pseudo_cl covariance_plot) +snakemake --profile workflow/profiles/default -s workflow/Snakefile \ + --configfile --cores 4 --forceall \ + --allowed-rules "${plot_rules[@]}" -- cosmo_val_plots +``` + +Run this on an allocation, using the host's container/bind settings as usual. +Without the rule restriction, requesting a missing data product can schedule its producer. +A pre-split run needs one compute pass to create the new caches: old pure-E/B NPZs lack the comparison vectors and old COSEBIs NPZs contain only the fiducial cut. +That pass derives cosmological products only from the already-sealed integration SACC; no unsealed vector is added for plotting. +The objectwise rule now calls the spin-2 regression directly rather than the external all-in-one exploratory driver, so it produces the suite's leakage-coefficient comparison rather than unrelated 1D-fit/scatter side effects. To reproduce a run from the image alone, opt out: diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 26cefb0dc..fb05f7ef5 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -22,13 +22,11 @@ # NaMaster pseudo-Cℓ) are what assembly puts in the terminal file, replacing # the estimates a part was born with. # -# Methods that only emit figures, or whose figure paths derive from internal -# handler state (rho/tau plots, rho_tau_fits, objectwise leakage, 2pcf -# overlay), declare a sentinel under COSMO_VAL/snakemake_sentinels so they stay -# DAG-trackable. Lazy cv state the original code never persists (c1/c2, -# xi_psf_sys) is either materialized to a small JSON (additive bias) or -# recomputed in the one rule that needs it — recompute is cheap next to the -# science it depends on. See workflow/scripts/cv_runner.py. +# Every compute rule writes a data product. Every figure is declared by a +# separate rule consuming those products, with no catalogue inputs or lazy cv +# state. cosmo_val_plots gathers all figures; cosmo_val_all also assembles the +# analysis products. Cosmological caches contain only already-sealed vectors. +# Plot modules are explicit inputs so edits to them invalidate figures only. CV = config["cosmo_val"] CV_VERSIONS = config["versions"] @@ -183,6 +181,31 @@ def cv_analysis_sacc(version): # The CosmologyValidation constructor kwargs every cv_runner rule passes. CV_INIT = cv_init_params(config) +CV_STYLES = { + v: {k: catalogue_entry(v).get(k, default) for k, default in + (("colour", "C0"), ("marker", "o"), ("label", v))} + for v in CV_VERSIONS +} +CV_PLOT_CODE = str(REPO_ROOT / "src/sp_validation/cosmo_val/diagnostic_plots.py") +CV_REGIONS = ("NGC", "SGC", "fullsky") +CV_PSF_PRODUCTS = [str(COSMO_VAL / f"psf_fit_{v}_{CV_BINNING}.npz") for v in CV_VERSIONS] +CV_FOOTPRINT_MAPS = [str(COSMO_VAL / f"footprint_{v}.npz") for v in CV_VERSIONS] +CV_FOOTPRINT_FIGURES = [ + str(COSMO_VAL / f"footprint_{v}_{r}.png") for v in CV_VERSIONS for r in CV_REGIONS +] + + +def cv_shear_catalogues(): + """Catalogue inputs belong to compute rules only.""" + return [str(Path(catalogue_entry(v)["subdir"]) / catalogue_entry(v)["shear"]["path"]) + for v in CV_VERSIONS] + + +def cv_tau_covariances(): + method = CV.get("cov_estimate_method", "th") + return [str(COSMO_VAL / "rho_tau_stats" / + f"cov_tau_{cv_basename(v, CV_FIDUCIAL)}_{method}.npy") + for v in CV_VERSIONS] # --------------------------------------------------------------------------- @@ -198,29 +221,35 @@ CV_INIT = cv_init_params(config) rule cv_plot_rho_stats: """Overlay rho statistics across all versions.""" input: - rho=[cv_rho_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + stats=[cv_rho_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + covariances=[], + plot_code=CV_PLOT_CODE, output: - sentinel=str(CV_SENTINELS / "plot_rho_stats.done"), + figure=str(COSMO_VAL / "rho_tau_stats/rho_stats.png"), params: - cv_init=CV_INIT, + kind="rho", + styles=CV_STYLES, resources: runtime=20, script: - "../scripts/cv_plot_rho_stats.py" + "../scripts/cv_plot_diagnostic.py" rule cv_plot_tau_stats: """Overlay tau statistics across all versions.""" input: - tau=[cv_tau_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + stats=[cv_tau_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + covariances=cv_tau_covariances(), + plot_code=CV_PLOT_CODE, output: - sentinel=str(CV_SENTINELS / "plot_tau_stats.done"), + figure=str(COSMO_VAL / "rho_tau_stats/tau_stats.png"), params: - cv_init=CV_INIT, + kind="tau", + styles=CV_STYLES, resources: runtime=20, script: - "../scripts/cv_plot_tau_stats.py" + "../scripts/cv_plot_diagnostic.py" rule cv_rho_tau_fits: @@ -228,8 +257,11 @@ rule cv_rho_tau_fits: input: rho=[cv_rho_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], tau=[cv_tau_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + covariances=cv_tau_covariances(), + rho_covariances=[str(COSMO_VAL / "rho_tau_stats" / + f"cov_rho_{cv_basename(v, CV_FIDUCIAL)}_jk.npy") for v in CV_VERSIONS], output: - sentinel=str(CV_SENTINELS / "rho_tau_fits.done"), + products=CV_PSF_PRODUCTS, params: cv_init=CV_INIT, resources: @@ -244,9 +276,11 @@ rule cv_rho_tau_fits: # --------------------------------------------------------------------------- rule cv_footprints: - """Per-version survey footprint maps.""" + """Cache per-version sparse survey count maps.""" + input: + catalogues=cv_shear_catalogues(), output: - sentinel=str(CV_SENTINELS / "footprints.done"), + maps=CV_FOOTPRINT_MAPS, params: cv_init=CV_INIT, resources: @@ -257,12 +291,14 @@ rule cv_footprints: rule cv_objectwise_leakage: - """Object-wise PSF-leakage regression vs scale-dependent alpha (all versions).""" + """Cache object-wise regression and scale-dependent alpha summaries.""" input: + catalogues=cv_shear_catalogues(), + covariances=cv_tau_covariances(), rho=[cv_rho_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], tau=[cv_tau_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], output: - sentinel=str(CV_SENTINELS / "objectwise_leakage.done"), + coefficients=str(COSMO_VAL / "leakage_coefficients.json"), params: cv_init=CV_INIT, threads: 12 @@ -274,9 +310,11 @@ rule cv_objectwise_leakage: rule cv_weights: - """Weighted shear-weight histograms across versions.""" + """Cache weighted shear-weight histogram counts and edges.""" + input: + catalogues=cv_shear_catalogues(), output: - weight_hist=str(COSMO_VAL / "weight_hist.png"), + histograms=str(COSMO_VAL / "weight_hist.npz"), params: cv_init=CV_INIT, resources: @@ -295,6 +333,61 @@ rule cv_weights: # standalone diagnostic (the original driver's cv.calculate_additive_bias() # call); the xi rule does not depend on it. +rule cv_plot_footprints: + input: + maps=CV_FOOTPRINT_MAPS, + plot_code=CV_PLOT_CODE, + output: + figures=CV_FOOTPRINT_FIGURES, + params: + kind="footprints", + styles=CV_STYLES, + regions=CV_REGIONS, + script: + "../scripts/cv_plot_diagnostic.py" + + +rule cv_plot_weights: + input: + histograms=rules.cv_weights.output.histograms, + plot_code=CV_PLOT_CODE, + output: + figure=str(COSMO_VAL / "weight_hist.png"), + params: + kind="weights", + styles=CV_STYLES, + script: + "../scripts/cv_plot_diagnostic.py" + + +rule cv_plot_objectwise_leakage: + input: + coefficients=rules.cv_objectwise_leakage.output.coefficients, + plot_code=CV_PLOT_CODE, + output: + figure=str(COSMO_VAL / "leakage_coefficients.png"), + params: + kind="objectwise_leakage", + styles=CV_STYLES, + script: + "../scripts/cv_plot_diagnostic.py" + + +rule cv_plot_rho_tau_fits: + input: + products=CV_PSF_PRODUCTS, + plot_code=CV_PLOT_CODE, + output: + contours=str(COSMO_VAL / "rho_tau_stats/contours_tau_stat.png"), + samples=str(COSMO_VAL / "rho_tau_stats/xi_psf_sys_tomography_False_sample.png"), + summary=str(COSMO_VAL / "rho_tau_stats/xi_psf_sys_tomography_False_mean_and_std.png"), + params: + kind="rho_tau_fits", + styles=CV_STYLES, + script: + "../scripts/cv_plot_diagnostic.py" + + rule cv_additive_bias: """Weighted mean ellipticity c1/c2 per version (standalone diagnostic).""" output: @@ -310,35 +403,40 @@ rule cv_additive_bias: # LG: DEPRACATED since we do not plot unblinded data vectors. rule cv_plot_2pcf: - """n_pairs / xi± overlay across versions, from the reporting parts.""" + """Xi± overlays across versions, from the sealed reporting parts.""" input: xi=[cv_xi_sacc(v, "reporting") for v in CV_VERSIONS], + plot_code=CV_PLOT_CODE, output: - sentinel=str(CV_SENTINELS / "plot_2pcf.done"), + figures=[str(COSMO_VAL / name) for name in + ("xi_pm_tomography_False.png", "xi_pm_theta_tomography_False.png")], params: - cv_init=CV_INIT, + kind="xi", + styles=CV_STYLES, + blinds=[blind_of(v) for v in CV_VERSIONS], resources: runtime=20, script: - "../scripts/cv_plot_2pcf.py" + "../scripts/cv_plot_diagnostic.py" # LG: DEPRACATED since we do not plot unblinded data vectors. rule cv_ratio_xi_sys_xi: """Ratio of PSF systematics (xi_psf_sys) to the cosmic-shear signal (xi+).""" input: xi=[cv_xi_sacc(v, "reporting") for v in CV_VERSIONS], - rho=[cv_rho_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], - tau=[cv_tau_stats(v, CV_FIDUCIAL) for v in CV_VERSIONS], + products=CV_PSF_PRODUCTS, + plot_code=CV_PLOT_CODE, output: - ratio=str(COSMO_VAL / "ratio_xi_sys_xi.png"), + figure=str(COSMO_VAL / "ratio_xi_sys_xi.png"), params: - offset=0.1, - cv_init=CV_INIT, + kind="ratio", + styles=CV_STYLES, + blinds=[blind_of(v) for v in CV_VERSIONS], resources: mem_mb=16000, runtime=120, script: - "../scripts/cv_ratio_xi_sys_xi.py" + "../scripts/cv_plot_diagnostic.py" # --------------------------------------------------------------------------- @@ -358,6 +456,7 @@ rule cv_plot_pseudo_cl: input: pseudo_cl=[cv_pseudo_cl_analysis_sacc(v) for v in CV_VERSIONS], pseudo_cl_cov=[cv_pseudo_cl_cov(v) for v in CV_VERSIONS], + plot_code=str(REPO_ROOT / "src/sp_validation/cosmo_val/pseudo_cl.py"), output: **cv_pseudo_cl_figures(), params: @@ -389,7 +488,6 @@ rule cv_pure_eb: output: npz=cv_pure_eb_npz("{version}"), sacc=cv_pure_eb_sacc("{version}"), - **cv_pure_eb_figures("{version}"), params: version="{version}", blind=lambda w: blind_of(w.version), @@ -417,7 +515,6 @@ rule cv_cosebis: output: npz=cv_cosebis_npz("{version}"), sacc=cv_cosebis_sacc("{version}"), - **cv_cosebis_figures("{version}"), params: version="{version}", blind=lambda w: blind_of(w.version), @@ -435,6 +532,36 @@ rule cv_cosebis: "../scripts/cv_cosebis.py" +rule cv_plot_pure_eb: + """Render pure E/B figures without rerunning the transform.""" + input: + npz=lambda w: cv_pure_eb_npz(w.version), + plot_code=str(REPO_ROOT / "src/sp_validation/b_modes.py"), + output: + **cv_pure_eb_figures("{version}"), + params: + version="{version}", + blind=lambda w: blind_of(w.version), + fiducial_scale_cut=CV["fiducial_scale_cut"], + script: + "../scripts/cv_plot_pure_eb.py" + + +rule cv_plot_cosebis: + """Render COSEBIs figures without rerunning the transform.""" + input: + npz=lambda w: cv_cosebis_npz(w.version), + plot_code=str(REPO_ROOT / "src/sp_validation/b_modes.py"), + output: + **cv_cosebis_figures("{version}"), + params: + version="{version}", + blind=lambda w: blind_of(w.version), + fiducial_scale_cut=CV["fiducial_scale_cut"], + script: + "../scripts/cv_plot_cosebis.py" + + rule cv_summarize_bmodes: """Collect B-mode PTEs (pure E/B, COSEBIs, pseudo-Cl) into one summary.""" input: @@ -522,23 +649,49 @@ rule assemble_sacc_all: # Aggregate target: the whole validation suite # --------------------------------------------------------------------------- +rule cosmo_val_plots: + """Render every validation figure from cached data products.""" + input: + rules.cv_plot_rho_stats.output, + rules.cv_plot_tau_stats.output, + rules.cv_plot_rho_tau_fits.output, + rules.cv_plot_footprints.output, + rules.cv_plot_objectwise_leakage.output, + rules.cv_plot_weights.output, + rules.cv_plot_2pcf.output, + rules.cv_ratio_xi_sys_xi.output, + [path for v in CV_VERSIONS for path in cv_pure_eb_figures(v).values()], + [path for v in CV_VERSIONS for path in cv_cosebis_figures(v).values()], + list(cv_pseudo_cl_figures().values()) if CV.get("include_pseudo_cl", False) else [], + [cv_xi_cov(v).removesuffix(".dat") + "_corr_plot.pdf" for v in CV_VERSIONS], + [cv_xi_cov_integration(v).removesuffix(".dat") + "_corr_plot.pdf" for v in CV_VERSIONS], + # Direct dependencies are intentional. With --allowed-rules Snakemake + # can prune a plot whose figure exists even when its cache is missing. + # The aggregate must validate caches independently of those plot jobs. + caches=[ + *rules.cv_plot_rho_stats.input.stats, + *rules.cv_plot_tau_stats.input.stats, + *rules.cv_plot_tau_stats.input.covariances, + *rules.cv_plot_rho_tau_fits.input.products, + *rules.cv_plot_footprints.input.maps, + rules.cv_plot_objectwise_leakage.input.coefficients, + rules.cv_plot_weights.input.histograms, + *rules.cv_plot_2pcf.input.xi, + *[cv_pure_eb_npz(v) for v in CV_VERSIONS], + *[cv_cosebis_npz(v) for v in CV_VERSIONS], + *[cv_xi_cov(v) for v in CV_VERSIONS], + *[cv_xi_cov_integration(v) for v in CV_VERSIONS], + *([cv_pseudo_cl_analysis_sacc(v) for v in CV_VERSIONS] + if CV.get("include_pseudo_cl", False) else []), + *([cv_pseudo_cl_cov(v) for v in CV_VERSIONS] + if CV.get("include_pseudo_cl", False) else []), + ], + + rule cosmo_val_all: - """Run the full CosmologyValidation diagnostic suite.""" + """Run the full suite, including every plot-only rule.""" input: - # PSF diagnostics - str(CV_SENTINELS / "footprints.done"), - str(CV_SENTINELS / "plot_rho_stats.done"), - str(CV_SENTINELS / "plot_tau_stats.done"), - str(CV_SENTINELS / "rho_tau_fits.done"), - # Shear diagnostics - str(CV_SENTINELS / "objectwise_leakage.done"), - str(COSMO_VAL / "weight_hist.png"), + rules.cosmo_val_plots.input, str(COSMO_VAL / "additive_bias.json"), - # Two-point - str(CV_SENTINELS / "plot_2pcf.done"), - str(COSMO_VAL / "ratio_xi_sys_xi.png"), - # B-modes str(COSMO_VAL / "bmode_summary.json"), - list(cv_pseudo_cl_figures().values()) if CV.get("include_pseudo_cl", False) else [], - # Terminal analysis file: the assembled {version}.sacc per version [cv_analysis_sacc(v) for v in CV_VERSIONS], diff --git a/workflow/rules/covariance.smk b/workflow/rules/covariance.smk index 7bca4bba2..a02bf338b 100644 --- a/workflow/rules/covariance.smk +++ b/workflow/rules/covariance.smk @@ -121,7 +121,6 @@ rule covariance_ini: params: outdir=lambda w: covariance_dir(*_onecov_args(w)), out_filename=lambda w: covariance_base(*_onecov_args(w)) + ".dat", - out_plot_filename=lambda w: covariance_base(*_onecov_args(w)) + "_corr_plot.pdf", ng_value=lambda w: "1" if w.gaussian == "ng" else "0", omega_m=PLANCK18["Omega_m"], omega_v=PLANCK18["Omega_v"], @@ -171,7 +170,7 @@ directory = {params.outdir} file = {params.out_filename} style = matrix save_configs = False -corrmatrix_plot = {params.out_plot_filename} +corrmatrix_plot = False [covTHETAspace settings] theta_min = {wildcards.min_sep} @@ -285,6 +284,17 @@ rule covariance_run: """ +rule covariance_plot: + """Render the correlation matrix from cached OneCovariance output only.""" + input: + matrix=cov_output(".dat"), + plot_code=str(REPO_ROOT / "src/sp_validation/cosmo_val/diagnostic_plots.py"), + output: + figure=cov_output("_corr_plot.pdf"), + script: + "../scripts/covariance_plot.py" + + def fiducial_covariance_outputs(mask_suffix="", tomo_suffix=""): """Return the covariance matrices for the fiducial version: non-Gaussian on the reporting grid, Gaussian on the integration grid.""" diff --git a/workflow/rules/twopoint.smk b/workflow/rules/twopoint.smk index 9f627b2d2..09bc89831 100644 --- a/workflow/rules/twopoint.smk +++ b/workflow/rules/twopoint.smk @@ -82,11 +82,14 @@ rule rho_tau_stats: output: rho_stats=str(COSMO_VAL / "rho_tau_stats/rho_stats_{version}_tomo_bin_all_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), tau_stats=str(COSMO_VAL / "rho_tau_stats/tau_stats_{version}_tomo_bin_all_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.fits"), + covariance=str(COSMO_VAL / ("rho_tau_stats/cov_tau_{version}_tomo_bin_all_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}_" + config.get("cosmo_val", {}).get("cov_estimate_method", "th") + ".npy")), + rho_covariance=str(COSMO_VAL / "rho_tau_stats/cov_rho_{version}_tomo_bin_all_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}_jk.npy"), # Born-as-SACC ρ/τ part, written alongside the FITS. rho_tau=str(COSMO_VAL / "rho_tau_stats/rho_tau_{version}_tomo_bin_all_minsep={min_sep}_maxsep={max_sep}_nbins={nbins}_npatch={npatch}.sacc"), threads: 48 params: ver="{version}", + cov_estimate_method=config.get("cosmo_val", {}).get("cov_estimate_method", "th"), min_sep="{min_sep}", max_sep="{max_sep}", nbins="{nbins}", diff --git a/workflow/scripts/covariance_plot.py b/workflow/scripts/covariance_plot.py new file mode 100644 index 000000000..2af67d05c --- /dev/null +++ b/workflow/scripts/covariance_plot.py @@ -0,0 +1,7 @@ +"""Plot an analytic covariance without invoking its producer.""" + +import numpy as np + +from sp_validation.cosmo_val.diagnostic_plots import plot_covariance + +plot_covariance(np.loadtxt(snakemake.input.matrix), snakemake.output.figure) diff --git a/workflow/scripts/cv_cosebis.py b/workflow/scripts/cv_cosebis.py index 0223de82a..1f58a304a 100644 --- a/workflow/scripts/cv_cosebis.py +++ b/workflow/scripts/cv_cosebis.py @@ -15,9 +15,6 @@ cosebis_scan_from_xi, find_conservative_scale_cut_key, log_bin_edges, - plot_cosebis_covariance_matrix, - plot_cosebis_modes, - plot_cosebis_scale_cut_heatmap, save_cosebis_results, ) from sp_validation.cosmo_val.sacc_writers import cosebis_to_sacc @@ -44,24 +41,9 @@ fiducial_key = find_conservative_scale_cut_key(results, fiducial_scale_cut) fiducial = results[fiducial_key] -plot_cosebis_modes( - fiducial, - version, - snakemake.output["figure_modes"], - fiducial_scale_cut=fiducial_scale_cut, +save_cosebis_results( + results, snakemake.output["npz"], fiducial_scale_cut, edges, derived_from=[part] ) -plot_cosebis_covariance_matrix( - fiducial, version, "analytic", snakemake.output["figure_covariance"] -) -plot_cosebis_scale_cut_heatmap( - results, - edges, - version, - snakemake.output["figure_scalecut_ptes"], - fiducial_scale_cut=fiducial_scale_cut, -) - -save_cosebis_results(results, snakemake.output["npz"], fiducial_scale_cut) s = cosebis_to_sacc({0: sacc_io.get_nz(part, 0)}, part.metadata, fiducial, fiducial_key) sacc_io.save(s, snakemake.output["sacc"], derived_from=[part]) diff --git a/workflow/scripts/cv_footprints.py b/workflow/scripts/cv_footprints.py index e4a1af6a4..90d360150 100644 --- a/workflow/scripts/cv_footprints.py +++ b/workflow/scripts/cv_footprints.py @@ -1,13 +1,12 @@ -"""Rule cv_footprints: plot per-version survey footprints. +"""Build one sparse count map per catalogue; no figures.""" -Leaf rule. Reads the shear catalog (RA/Dec) for each version; writes -footprint_{version}_{region}.png. Sentinel output keeps the DAG trackable -because region names are internal to FootprintPlotter. -""" +from cv_runner import make_cv, verify_outputs -from cv_runner import _unbuffer_streams, make_cv, touch_sentinels +from sp_validation.cosmo_val.diagnostic_products import save_footprint -_unbuffer_streams() cv = make_cv(snakemake) -cv.plot_footprints() -touch_sentinels(snakemake) +for (_, count_map), path in zip( + cv.footprint_maps(), snakemake.output.maps, strict=True +): + save_footprint(count_map, path) +verify_outputs(snakemake) diff --git a/workflow/scripts/cv_objectwise_leakage.py b/workflow/scripts/cv_objectwise_leakage.py index ae0f012ab..2c5701919 100644 --- a/workflow/scripts/cv_objectwise_leakage.py +++ b/workflow/scripts/cv_objectwise_leakage.py @@ -1,15 +1,20 @@ -"""Rule cv_objectwise_leakage: object-wise vs scale-dependent PSF leakage. +"""Cache regression coefficients and alpha summaries, without rendering.""" -plot_objectwise_leakage triggers calculate_objectwise_leakage, which itself -triggers calculate_scale_dependent_leakage — the whole leakage chain runs here. -Writes leakage_{version}/ products (the object-wise regression .pkl is the -durable artifact) and leakage_coefficients.png. Sentinel-tracked because the -per-version leakage product paths are built inside shear_psf_leakage. -""" +import json -from cv_runner import _unbuffer_streams, make_cv, touch_sentinels +from cv_runner import make_cv, prepare_cached_psf, verify_outputs -_unbuffer_streams() cv = make_cv(snakemake) -cv.plot_objectwise_leakage() -touch_sentinels(snakemake) +prepare_cached_psf(cv) +cv.calculate_objectwise_leakage(tomography=False) +cv.calculate_alpha_leakage_summaries(tomography=False) +coefficients = { + ver: { + bin_id: {k: [v.nominal_value, v.std_dev] for k, v in coeff.items()} + for bin_id, coeff in bins.items() + } + for ver, bins in cv.leakage_coeff.items() +} +with open(snakemake.output.coefficients, "w") as stream: + json.dump(coefficients, stream, indent=2) +verify_outputs(snakemake) diff --git a/workflow/scripts/cv_plot_2pcf.py b/workflow/scripts/cv_plot_2pcf.py deleted file mode 100644 index 90c098366..000000000 --- a/workflow/scripts/cv_plot_2pcf.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Rule cv_plot_2pcf: xi± overlay across versions. - -Draws each version's reporting ξ± part (the declared inputs) for the -non-tomographic ("all", "all") pair; plot_2pcf draws the parts it is handed -rather than measuring. Writes figures under the output dir. Sentinel-tracked: -plot_2pcf emits several figures whose names are internal. -""" - -from cv_runner import _unbuffer_streams, make_cv, take_reporting_parts, touch_sentinels - -# LG : This is now DEPRECATED, NOT TO BE USED, since we are no longer plotting unblinded data vectors. -_unbuffer_streams() -cv = make_cv(snakemake) -take_reporting_parts(cv, snakemake.input["xi"]) -cv.plot_2pcf(tomography=False, show=False) -touch_sentinels(snakemake) diff --git a/workflow/scripts/cv_plot_cosebis.py b/workflow/scripts/cv_plot_cosebis.py new file mode 100644 index 000000000..0d2085a50 --- /dev/null +++ b/workflow/scripts/cv_plot_cosebis.py @@ -0,0 +1,21 @@ +"""Render every COSEBIs figure from the already-sealed multi-cut NPZ.""" + +from sp_validation.b_modes import ( + find_conservative_scale_cut_key, + load_cosebis_results, + plot_cosebis_covariance_matrix, + plot_cosebis_modes, + plot_cosebis_scale_cut_heatmap, +) + +p = snakemake.params +results, edges = load_cosebis_results(snakemake.input.npz, blind=p.blind) +cut = tuple(p.fiducial_scale_cut) +fiducial = results[find_conservative_scale_cut_key(results, cut)] +plot_cosebis_modes(fiducial, p.version, snakemake.output.figure_modes, cut) +plot_cosebis_covariance_matrix( + fiducial, p.version, "analytic", snakemake.output.figure_covariance +) +plot_cosebis_scale_cut_heatmap( + results, edges, p.version, snakemake.output.figure_scalecut_ptes, cut +) diff --git a/workflow/scripts/cv_plot_diagnostic.py b/workflow/scripts/cv_plot_diagnostic.py new file mode 100644 index 000000000..7c7683b21 --- /dev/null +++ b/workflow/scripts/cv_plot_diagnostic.py @@ -0,0 +1,66 @@ +"""Catalogue-free plotting entry point for cached scalar/PSF/map diagnostics.""" + +import json + +import numpy as np +from astropy.io import fits + +from sp_validation import sacc_io +from sp_validation.cosmo_val import diagnostic_plots as plots +from sp_validation.cosmo_val.diagnostic_products import load_footprint + +p, inputs, outputs = snakemake.params, snakemake.input, snakemake.output +styles = dict(p.styles) +versions = list(styles) + + +def psf_products(): + products = {} + for ver, path in zip(versions, inputs.products, strict=True): + with np.load(path, allow_pickle=False) as data: + products[ver] = dict(data) + return products + + +if p.kind == "weights": + with np.load(inputs.histograms, allow_pickle=False) as data: + histograms = { + ver: (data[f"counts_{i}"], data[f"edges_{i}"]) + for i, ver in enumerate(data["versions"].tolist()) + } + plots.plot_weights(histograms, styles, outputs.figure) +elif p.kind == "footprints": + for i, (ver, path) in enumerate(zip(versions, inputs.maps, strict=True)): + plots.plot_footprint( + load_footprint(path), + { + region: outputs.figures[i * len(p.regions) + j] + for j, region in enumerate(p.regions) + }, + ) +elif p.kind == "objectwise_leakage": + with open(inputs.coefficients) as stream: + coefficients = json.load(stream) + plots.plot_objectwise_leakage(coefficients, styles, outputs.figure) +elif p.kind == "rho_tau_fits": + plots.plot_rho_tau_fits( + psf_products(), styles, outputs.contours, outputs.samples, outputs.summary + ) +elif p.kind in ("ratio", "xi"): + correlations = { + ver: sacc_io.xi_correlation(sacc_io.load(path, blind=blind), grid="reporting") + for ver, path, blind in zip(versions, inputs.xi, p.blinds, strict=True) + } + if p.kind == "ratio": + plots.plot_ratio_xi_sys_xi(psf_products(), correlations, styles, outputs.figure) + else: + plots.plot_2pcf(correlations, styles, outputs.figures) +elif p.kind in ("rho", "tau"): + tables = { + ver: fits.getdata(path) + for ver, path in zip(versions, inputs.stats, strict=True) + } + covariances = [np.load(path) for path in inputs.covariances] + plots.plot_psf_stats(tables, styles, outputs.figure, p.kind, covariances) +else: + raise ValueError(f"Unknown diagnostic: {p.kind}") diff --git a/workflow/scripts/cv_plot_pure_eb.py b/workflow/scripts/cv_plot_pure_eb.py new file mode 100644 index 000000000..b0c2b9a1d --- /dev/null +++ b/workflow/scripts/cv_plot_pure_eb.py @@ -0,0 +1,28 @@ +"""Render pure E/B figures from the already-sealed NPZ only.""" + +from sp_validation.b_modes import ( + covariance_label, + load_pure_eb_results, + plot_eb_covariance_matrix, + plot_integration_vs_reporting, + plot_pte_2d_heatmaps, + plot_pure_eb_correlations, +) + +p = snakemake.params +results = load_pure_eb_results(snakemake.input.npz, blind=p.blind) +kwargs = dict( + fiducial_xip_scale_cut=tuple(p.fiducial_scale_cut), + fiducial_xim_scale_cut=tuple(p.fiducial_scale_cut), +) +plot_integration_vs_reporting( + results, snakemake.output.figure_integration_vs_reporting, p.version +) +plot_pure_eb_correlations(results, snakemake.output.figure_xis, p.version, **kwargs) +plot_pte_2d_heatmaps(results, p.version, snakemake.output.figure_ptes, **kwargs) +plot_eb_covariance_matrix( + results["cov"], + covariance_label(results["npatch"]), + snakemake.output.figure_covariance, + p.version, +) diff --git a/workflow/scripts/cv_plot_rho_stats.py b/workflow/scripts/cv_plot_rho_stats.py deleted file mode 100644 index a55a13777..000000000 --- a/workflow/scripts/cv_plot_rho_stats.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Rule cv_plot_rho_stats: rho-statistics overlay across versions. - -Reads rho_stats_{base}.fits for every version (declared inputs); writes -rho_stats.png into the leakage output dir. Sentinel-tracked because the figure -lands beside the first version's leakage products, not at a fixed path. -""" - -from cv_runner import _unbuffer_streams, make_cv, touch_sentinels - -_unbuffer_streams() -cv = make_cv(snakemake) -cv.plot_rho_stats(savefig="rho_stats.png", show=False) -touch_sentinels(snakemake) diff --git a/workflow/scripts/cv_plot_tau_stats.py b/workflow/scripts/cv_plot_tau_stats.py deleted file mode 100644 index a2c6f365c..000000000 --- a/workflow/scripts/cv_plot_tau_stats.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Rule cv_plot_tau_stats: tau-statistics overlay across versions. - -Reads tau_stats_{base}.fits for every version (declared inputs), with error -bars from the version's tau covariance of the configured type; writes -tau_stats.png into the leakage output dir. Sentinel-tracked (see rho_stats). -""" - -from cv_runner import _unbuffer_streams, make_cv, touch_sentinels - -_unbuffer_streams() -cv = make_cv(snakemake) -cv.plot_tau_stats( - tomography=False, - cov_type=cv.cov_estimate_method, - savefig="tau_stats.png", - show=False, -) -touch_sentinels(snakemake) diff --git a/workflow/scripts/cv_pure_eb.py b/workflow/scripts/cv_pure_eb.py index 25ee3f729..50a65fbcf 100644 --- a/workflow/scripts/cv_pure_eb.py +++ b/workflow/scripts/cv_pure_eb.py @@ -15,11 +15,6 @@ from sp_validation.b_modes import ( calculate_eb_statistics, calculate_pure_eb_correlation, - covariance_label, - plot_eb_covariance_matrix, - plot_integration_vs_reporting, - plot_pte_2d_heatmaps, - plot_pure_eb_correlations, save_pure_eb_results, ) from sp_validation.cosmo_val.sacc_writers import pure_eb_to_sacc @@ -46,31 +41,7 @@ ) results = calculate_eb_statistics(results) -plot_integration_vs_reporting( - results, snakemake.output["figure_integration_vs_reporting"], version -) -plot_pure_eb_correlations( - results, - snakemake.output["figure_xis"], - version, - fiducial_xip_scale_cut=fiducial_scale_cut, - fiducial_xim_scale_cut=fiducial_scale_cut, -) -plot_pte_2d_heatmaps( - results, - version, - snakemake.output["figure_ptes"], - fiducial_xip_scale_cut=fiducial_scale_cut, - fiducial_xim_scale_cut=fiducial_scale_cut, -) -plot_eb_covariance_matrix( - results["cov"], - covariance_label(results["npatch"]), - snakemake.output["figure_covariance"], - version, -) - -save_pure_eb_results(results, snakemake.output["npz"]) +save_pure_eb_results(results, snakemake.output["npz"], derived_from=[part]) sacc_io.save( pure_eb_to_sacc( diff --git a/workflow/scripts/cv_ratio_xi_sys_xi.py b/workflow/scripts/cv_ratio_xi_sys_xi.py deleted file mode 100644 index 9709445d8..000000000 --- a/workflow/scripts/cv_ratio_xi_sys_xi.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Rule cv_ratio_xi_sys_xi: ratio of PSF systematics to cosmic-shear signal. - -Joins two upstream chains for the non-tomographic ("all", "all") pair: each -version's reporting ξ± part and xi_psf_sys (recomputed in memory from the -rho/tau FITS via the lazy cv.xi_psf_sys property — the PSF-error fit is not -persisted). Both are declared as inputs so the DAG shows the join. Writes -ratio_xi_sys_xi.png at a fixed path (declared output). -""" - -from cv_runner import _unbuffer_streams, make_cv, take_reporting_parts, verify_outputs - -# LG : This is now DEPRECATED, NOT TO BE USED, since we are no longer plotting unblinded data vectors. -_unbuffer_streams() -cv = make_cv(snakemake) -take_reporting_parts(cv, snakemake.input["xi"]) -cv.plot_ratio_xi_sys_xi( - tomography=False, offset=snakemake.params.get("offset", 0.1), show=False -) -verify_outputs(snakemake) diff --git a/workflow/scripts/cv_rho_tau_fits.py b/workflow/scripts/cv_rho_tau_fits.py index 6e02ba10c..bcdbff82a 100644 --- a/workflow/scripts/cv_rho_tau_fits.py +++ b/workflow/scripts/cv_rho_tau_fits.py @@ -1,22 +1,30 @@ -"""Rule cv_rho_tau_fits: PSF-error model fit and xi_psf_sys contours. +"""Fit PSF parameters and persist all non-cosmological plot arrays.""" -Conditional in the original driver on rho_tau_method != "none". Reads -rho_stats/tau_stats FITS (declared inputs), runs the MCMC fit -(calculate_rho_tau_fits via plot_rho_tau_fits), and writes contour / -xi_psf_sys figures into the leakage output dir. Sentinel-tracked: figure paths -derive from internal handler state. The fitted xi_psf_sys it produces is -recomputed (not persisted) by the cv_ratio_xi_sys_xi rule. -""" +import numpy as np +from cv_runner import make_cv, prepare_cached_psf, verify_outputs -from cv_runner import _unbuffer_streams, make_cv, touch_sentinels - -_unbuffer_streams() cv = make_cv(snakemake) -if cv.rho_tau_method != "none": - cv.plot_rho_tau_fits( - tomography=False, - savefig_contours="contours_tau_stat.png", - savefig_xi_psf_sys="xi_psf_sys", - show=False, +prepare_cached_psf(cv) +for ver, path in zip(cv.versions, snakemake.output.products, strict=True): + params = cv.set_params_rho_tau(ver, cv.results[ver]._params, cv.cc[ver]["psf"]) + flat_samples = cv.get_samples(ver, params, "all") + plus, minus = cv.get_xi_psf_sys_samples(ver, params, "all", "all") + data = dict( + theta=cv.psf_fitter.rho_stat_handler.rho_stats["theta"], + flat_samples=flat_samples, + theta_edges=np.geomspace( + cv.treecorr_config["min_sep"], + cv.treecorr_config["max_sep"], + cv.treecorr_config["nbins"] + 1, + ), + theta_unit="arcmin", ) -touch_sentinels(snakemake) + for component, samples in (("plus", plus), ("minus", minus)): + data[f"samples_{component}"] = samples + data[f"mean_{component}"] = samples.mean(axis=0) + data[f"var_{component}"] = samples.var(axis=0) + data[f"quantiles_{component}"] = np.quantile( + samples, [1 - cv.quantile, cv.quantile], axis=0 + ) + np.savez_compressed(path, **data) +verify_outputs(snakemake) diff --git a/workflow/scripts/cv_runner.py b/workflow/scripts/cv_runner.py index b4ffcea26..dda72778f 100644 --- a/workflow/scripts/cv_runner.py +++ b/workflow/scripts/cv_runner.py @@ -6,12 +6,10 @@ owns, and (3) verifies the declared outputs landed. This module carries the common machinery so each rule script stays a few readable lines. -The in-memory ``cv`` object is re-instantiated per rule. The DAG links rules -through the *real* data products each method writes (rho/tau FITS, ξ± parts, -pseudo-Cl FITS, E/B npz, leakage pkl); the lazy ``cv`` properties that aren't -persisted (``c1``/``c2``, ``xi_psf_sys``, ``rho_tau_fits``) are recomputed in -whichever rule needs them, which is cheap next to the science compute they -depend on. See common.cv_init_params for the constructor contract. +Compute rules instantiate ``cv`` independently and link through explicit +cached data products. Plot rules read those products directly; they don't +instantiate ``cv`` or enter its lazy catalogue/measurement paths. +See common.cv_init_params for the compute constructor contract. """ import os @@ -36,13 +34,20 @@ def make_cv(snakemake): return CosmologyValidation(**dict(snakemake.params["cv_init"])) -def take_reporting_parts(cv, paths): - """Hand ``cv`` the reporting ξ± parts at ``paths``, one per version, in order.""" - from sp_validation import sacc_io +def prepare_cached_psf(cv): + """Attach FITS-only handlers; never enter the catalogue/pair-count path.""" + from shear_psf_leakage.rho_tau_stat import RhoStat, TauStat - for version, path in zip(cv.versions, paths, strict=True): - part = sacc_io.load(path, blind=cv.blind(version)) - cv.xi_parts[version, "reporting"] = part + output = str(Path(cv.cc["paths"]["output"]) / "rho_tau_stats") + cv._rho_stat_handler = RhoStat( + output=output, treecorr_config=cv.treecorr_config, verbose=True + ) + cv._tau_stat_handler = TauStat( + catalogs=cv._rho_stat_handler.catalogs, + output=output, + treecorr_config=cv.treecorr_config, + verbose=True, + ) def verify_outputs(snakemake): @@ -53,12 +58,3 @@ def verify_outputs(snakemake): "cosmo_val rule finished but these declared outputs are absent:\n " + "\n ".join(missing) ) - - -def touch_sentinels(snakemake): - """Create any sentinel outputs (pure-plot rules with no data product).""" - for out in snakemake.output: - path = Path(out) - if path.suffix == ".done": - path.parent.mkdir(parents=True, exist_ok=True) - path.touch() diff --git a/workflow/scripts/cv_summarize_bmodes.py b/workflow/scripts/cv_summarize_bmodes.py index 8175e0d6b..f91648d28 100644 --- a/workflow/scripts/cv_summarize_bmodes.py +++ b/workflow/scripts/cv_summarize_bmodes.py @@ -26,7 +26,7 @@ for i, version in enumerate(p["versions"]): row = {} - pure_eb = np.load(snakemake.input["pure_eb"][i]) + pure_eb = sacc_io.load_npz(snakemake.input["pure_eb"][i], blind=p["blinds"][i]) # The bins the PTE matrices are indexed on: the snapped reporting edges. edges = (pure_eb["left_edges"], pure_eb["right_edges"]) for stat in ("xip_B", "xim_B", "combined"): @@ -41,7 +41,7 @@ # The COSEBIs .npz is written at the fiducial cut, so its PTE is the one # this table wants. - cosebis = np.load(snakemake.input["cosebis"][i]) + cosebis = sacc_io.load_npz(snakemake.input["cosebis"][i], blind=p["blinds"][i]) row["COSEBIS"] = float(cosebis["pte_B"]) cov_methods.add("COSEBIs: analytic (OneCovariance ξ± through the COSEBIs kernel)") diff --git a/workflow/scripts/cv_weights.py b/workflow/scripts/cv_weights.py index 0316fe1e9..293e91d66 100644 --- a/workflow/scripts/cv_weights.py +++ b/workflow/scripts/cv_weights.py @@ -1,12 +1,12 @@ -"""Rule cv_weights: weighted shear-weight histograms across versions. +"""Cache weight-weighted bin sums and bin edges; no figures.""" -Leaf rule. Reads each version's shear weights; writes weight_hist.png at a -fixed path under the output dir (declared output). -""" +import numpy as np +from cv_runner import make_cv, verify_outputs -from cv_runner import _unbuffer_streams, make_cv, verify_outputs - -_unbuffer_streams() cv = make_cv(snakemake) -cv.plot_weights() +data = {"versions": np.asarray(cv.versions)} +for i, (counts, edges) in enumerate(cv.weight_histograms().values()): + data[f"counts_{i}"] = counts + data[f"edges_{i}"] = edges +np.savez(snakemake.output.histograms, **data) verify_outputs(snakemake) diff --git a/workflow/scripts/run_rho_tau.py b/workflow/scripts/run_rho_tau.py index d04da3f02..995fbdc17 100644 --- a/workflow/scripts/run_rho_tau.py +++ b/workflow/scripts/run_rho_tau.py @@ -18,6 +18,7 @@ npatch=int(params["npatch"]), catalog_config=params["cat_config"], output_dir=params["output_dir"], + cov_estimate_method=params.get("cov_estimate_method", "th"), ) cv.calculate_rho_tau_stats(tomography=False) verify_outputs(snakemake)