Source code for pyfli.simulator.calibration.sweep_combination

"""Combine per-experiment calibrations into one sweep-style simulator setup."""

import json
import os
from dataclasses import dataclass

import numpy as np
import pandas as pd
from scipy.stats import beta as beta_dist

from pyfli import logging
from pyfli.data_cc.config_combinations import ConfigCombinationGenerator

from ..sim_workflow import SimGenerator
from .config import SIM_FAMILY
from .coverage import coverage_fractions, resupport_beta
from .data_io import load_experimental_data
from .prior_estimation import BetaPrior
from .sim_bridge import (
    TUPLE_KEYS,
    TimeWindowedGenerator,
    draw_pixels,
    make_generator,
    stack_outputs,
    to_jsonable,
)
from .summary_stats import background_bins, compute_stats

SENSOR_FIXED = {
    "discrete": {"bit": 16, "qe_on": False},
    "continuous": {
        "qe_on": True,
        "read_noise_on": True,
        "round_on": True,
        "poisson": True,
    },
}
COMMON_FIXED = {
    "dcr_on": True,
    "clip_on": True,
    "tau2_dist": "beta",
    "pileup_mode": "wrap",
}
COMMON_SWEEP_KEYS = (
    "jitter",
    "efficiency",
    "A1_fraction",
    "tau2",
    "photo_count",
    "dcr",
)
RUN_SETTINGS = {
    "SAVING": False,
    "SANITY_PLOT": False,
    "COMPARE_PLOT": True,
    "BASE_OUT": "<select output folder path>",
    "GEN_MODE_DEFAULT": "weighted",
    "CARTESIAN_NUM_SAMPLE": 20000,
    "GEN_MODE": "weighted",
    "NUM_SAMPLE": 10,
    "TOTAL_SAMPLES": 20_000,
    "SEED": 0,
}
MAX_CARTESIAN = 100_000


[docs] @dataclass class ExperimentCalibration: name: str json_path: str payload: dict weight: float = 1.0 @property def config(self) -> dict: return self.payload["config"] @property def kwargs(self) -> dict: return self.payload["sim_generator_kwargs"] @property def detector(self) -> str: return self.payload["inputs"]["detector_type"]
[docs] def irf_template(self) -> np.ndarray: cube = np.load( os.path.join(os.path.dirname(self.json_path), self.payload["irf"]["file"]) ) return cube[0, 0].astype(float)
[docs] def load_experiment(json_path: str, name: str | None = None) -> ExperimentCalibration: with open(json_path) as f: payload = json.load(f) return ExperimentCalibration( name or os.path.basename(os.path.dirname(json_path)), json_path, payload )
def _prior(d: dict) -> BetaPrior: return BetaPrior(d["a"], d["b"], d["offset"], d["scale"])
[docs] def cdf_error(old: BetaPrior, new: BetaPrior, n: int = 2001) -> float: """Maximum difference between the two priors' CDFs, in physical units.""" x = np.linspace( min(old.offset, new.offset), max(old.offset + old.scale, new.offset + new.scale), n, ) f_old = beta_dist.cdf(np.clip((x - old.offset) / old.scale, 0, 1), old.a, old.b) f_new = beta_dist.cdf(np.clip((x - new.offset) / new.scale, 0, 1), new.a, new.b) return float(np.max(np.abs(f_old - f_new)))
def _sweep_entry(values: list, weights: list[float]) -> tuple[list, list[float]]: """Deduplicate values (keeping first-seen order) and add up their weights.""" out_v, out_w = [], [] for v, w in zip(values, weights): if v in out_v: out_w[out_v.index(v)] += w else: out_v.append(v) out_w.append(w) total = sum(out_w) return out_v, [round(w / total, 6) for w in out_w] def _split(per_key: dict[str, list], weights: list[float]) -> tuple[dict, dict, dict]: """Keys with one distinct value go to the fixed config, the rest to sweep + weights.""" fixed, sweep, sweep_w = {}, {}, {} for k, vals in per_key.items(): v, w = _sweep_entry(vals, weights) if len(v) == 1: fixed[k] = v[0] else: sweep[k], sweep_w[k] = v, w return fixed, sweep, sweep_w
[docs] def combine_calibrations( json_paths: dict[str, str], output_dir: str, weighting: str = "equal" ) -> dict: """ Build the combined sweep setup from per-experiment ``calibrated_sim_config.json`` files (``name -> path``) and write it to ``output_dir`` as ``combined_sim_setup.json``, ``combined_sim_setup.py`` (same constants, importable) and ``irfs/<name>.txt``. ``weighting``: "equal" (every experiment counts the same) or "pixels" (by number of sample pixels). Returns the setup dict. """ exps = [load_experiment(p, n) for n, p in json_paths.items()] if not exps: raise ValueError("No calibrations to combine") for key in ("detector_type", "model_type", "laser_freq_mhz"): vals = {str(e.payload["inputs"][key]) for e in exps} if len(vals) > 1: raise ValueError(f"Experiments disagree on {key}: {vals}") n_bins = {len(e.irf_template()) for e in exps} if len(n_bins) > 1: raise ValueError(f"Experiments have different numbers of time bins: {n_bins}") detector = exps[0].detector laser = float(exps[0].payload["inputs"]["laser_freq_mhz"]) T = n_bins.pop() if weighting == "pixels": w = np.array([e.payload["pixel_selection"]["n_selected"] for e in exps], float) else: w = np.ones(len(exps)) w = (w / w.sum()).tolist() for e, wi in zip(exps, w): e.weight = wi report = {} tau2 = {e.name: _prior(e.payload["priors"]["tau2"]) for e in exps} lo = min(p.offset for p in tau2.values()) hi = max(p.offset + p.scale for p in tau2.values()) tau2_new = {n: resupport_beta(p, lo, hi) for n, p in tau2.items()} report["tau2"] = {n: cdf_error(tau2[n], tau2_new[n]) for n in tau2} photon = {e.name: _prior(e.payload["priors"]["photon"]) for e in exps} sensor_fixed = dict(SENSOR_FIXED[detector]) dcr = {e.name: float(e.config["dcr"]) for e in exps} if detector == "discrete": c_lo = min(int(e.config["n_cycles"][0]) for e in exps) c_hi = max(int(e.config["n_cycles"][1]) for e in exps) photon_new = {n: resupport_beta(p, c_lo, c_hi) for n, p in photon.items()} report["photo_count"] = {n: cdf_error(photon[n], photon_new[n]) for n in photon} sensor_fixed["n_cycles"] = (c_lo, c_hi) else: bits = {e.name: int(e.config["bit"]) for e in exps} b_max = max(bits.values()) phys = { n: BetaPrior(p.a, p.b, 0.0, 2 ** bits[n] - 1) for n, p in photon.items() } photon_new = {n: resupport_beta(p, 0.0, 2**b_max - 1) for n, p in phys.items()} report["photo_count"] = {n: cdf_error(phys[n], photon_new[n]) for n in phys} dcr = {n: v * bits[n] / b_max for n, v in dcr.items()} sensor_fixed["bit"] = b_max per_key = { "jitter": [bool(e.config["jitter"]) for e in exps], "efficiency": [tuple(e.config["efficiency"]) for e in exps], "A1_fraction": [tuple(e.config["A1_fraction"]) for e in exps], "tau2": [tau2_new[e.name].as_tuple() for e in exps], "photo_count": [photon_new[e.name].as_tuple() for e in exps], "dcr": [round(dcr[e.name], 6) for e in exps], } fixed, sweep, sweep_w = _split(per_key, w) common_config = { **COMMON_FIXED, "laser_feq": laser, "tau2_beta_range": (round(hi - lo, 4), round(lo, 4)), **fixed, } irf_dir = os.path.join(output_dir, "irfs") os.makedirs(irf_dir, exist_ok=True) gate_delay = 1000.0 / laser / T irf_runs, irf_info = [], {} for e in exps: path = os.path.join(irf_dir, f"{e.name}.txt") np.savetxt(path, e.irf_template(), fmt="%.8g") irf_runs.append((path, detector, gate_delay)) irf_info[path] = { "experiment": e.name, "a_range": list(e.kwargs["a_range"]), "b_range": list(e.kwargs["b_range"]), "inactive_bins": e.kwargs.get("inactive_bins") or [], } a_range = ( min(i["a_range"][0] for i in irf_info.values()), max(i["a_range"][1] for i in irf_info.values()), ) b_range = (0.0, max(i["b_range"][1] for i in irf_info.values())) n_combos = int(np.prod([len(v) for v in sweep.values()])) if sweep else 1 worst = max(max(v.values()) for v in report.values()) if worst > 0.05: logging.warning( f"Re-expressing priors on the common support changed a CDF by up to {worst:.3f}: " "the experiments' ranges differ a lot; see resupport_cdf_error" ) if n_combos > MAX_CARTESIAN: logging.warning( f"{n_combos} sweep combinations: too many to enumerate. Use make_combined_generator " "(samples the keys independently, same distribution as GEN_MODE='weighted')." ) sensors = {"discrete": ({}, {}, {}), "continuous": ({}, {}, {})} sensors[detector] = (sensor_fixed, {}, {}) setup = { "IRF_RUNS": irf_runs, "IRF_RUN_INFO": irf_info, "MONO_FRACTIONS": sorted({float(e.config["mono_fraction"]) for e in exps}), "LASER_PERIOD_NS": 1000.0 / laser, "A_RANGE": a_range, "B_RANGE": b_range, **RUN_SETTINGS, "COMMON_CONFIG": common_config, "DISCRETE_CONFIG": sensors["discrete"][0], "CONTINUOUS_CONFIG": sensors["continuous"][0], "COMMON_SWEEP": sweep, "DISCRETE_SWEEP": sensors["discrete"][1], "CONTINUOUS_SWEEP": sensors["continuous"][1], "COMMON_WEIGHTS": sweep_w, "DISCRETE_WEIGHTS": sensors["discrete"][2], "CONTINUOUS_WEIGHTS": sensors["continuous"][2], "EXPERIMENT_CONFIGS": {e.name: os.path.abspath(e.json_path) for e in exps}, "EXPERIMENT_WEIGHTS": {e.name: round(e.weight, 6) for e in exps}, "EXPERIMENT_SWEEP_VALUES": { e.name: {k: per_key[k][i] for k in sweep} for i, e in enumerate(exps) }, "N_COMBINATIONS": n_combos, "RESUPPORT_CDF_ERROR": { k: {n: round(v, 4) for n, v in d.items()} for k, d in report.items() }, } os.makedirs(output_dir, exist_ok=True) with open(os.path.join(output_dir, "combined_sim_setup.json"), "w") as f: json.dump(to_jsonable(setup), f, indent=2) write_setup_module(setup, os.path.join(output_dir, "combined_sim_setup.py")) logging.info( f"Combined {len(exps)} calibrations into {output_dir}: sweep keys {list(sweep)}, " f"{n_combos} combinations, max re-expression CDF error {worst:.3f}" ) return setup
def _py(obj, indent: int = 0) -> str: pad = " " * indent if isinstance(obj, dict): if not obj: return "{}" rows = [ f"{pad} {k!r}: {_py(v, indent + 4).lstrip()}," for k, v in obj.items() ] return "{\n" + "\n".join(rows) + f"\n{pad}}}" if ( isinstance(obj, list) and obj and any(isinstance(v, (tuple, list, dict)) for v in obj) and len(repr(obj)) > 90 ): rows = [f"{pad} {_py(v, indent + 4).lstrip()}," for v in obj] return "[\n" + "\n".join(rows) + f"\n{pad}]" return repr(obj)
[docs] def write_setup_module(setup: dict, path: str) -> None: """Write ``setup`` as an importable Python module of constants, plus ``SENSOR_SETUP``.""" lines = [ '"""', "Combined pyfli simulator setup generated by pyfli.simulator.calibration.combine_calibrations.", "", "Calibrated values: IRF_RUNS, MONO_FRACTIONS, *_CONFIG, *_SWEEP, *_WEIGHTS (one sweep value per", "experiment; EXPERIMENT_CONFIGS lists each experiment's own calibrated config). IRF_RUN_INFO holds the", "exact OffsetGen ranges and inactive time bins of each IRF. SAVING ... SEED are run settings with", "default values, not calibration results.", '"""', "", ] for k, v in setup.items(): lines.append(f"{k} = {_py(v)}") lines.append("") lines.append("SENSOR_SETUP = {") lines.append(' "discrete": (DISCRETE_CONFIG, DISCRETE_SWEEP, DISCRETE_WEIGHTS),') lines.append( ' "continuous": (CONTINUOUS_CONFIG, CONTINUOUS_SWEEP, CONTINUOUS_WEIGHTS),' ) lines.append("}") with open(path, "w") as f: f.write("\n".join(lines) + "\n")
[docs] def load_combined_setup(path: str) -> dict: """Read ``combined_sim_setup.json`` back, restoring the tuples the simulator expects.""" with open(path) as f: setup = json.load(f) def fix(cfg: dict) -> dict: return { k: tuple(v) if (k in TUPLE_KEYS or k == "tau2_beta_range") and isinstance(v, list) else v for k, v in cfg.items() } def fix_sweep(sw: dict) -> dict: return { k: [tuple(x) if isinstance(x, list) else x for x in vals] for k, vals in sw.items() } for k in ("COMMON_CONFIG", "DISCRETE_CONFIG", "CONTINUOUS_CONFIG"): setup[k] = fix(setup[k]) for k in ("COMMON_SWEEP", "DISCRETE_SWEEP", "CONTINUOUS_SWEEP"): setup[k] = fix_sweep(setup[k]) setup["IRF_RUNS"] = [tuple(r) for r in setup["IRF_RUNS"]] setup["A_RANGE"], setup["B_RANGE"] = ( tuple(setup["A_RANGE"]), tuple(setup["B_RANGE"]), ) return setup
def _parts(setup: dict, sensor_type: str, mono_fraction: float | None): base = { **setup["COMMON_CONFIG"], **setup[f"{sensor_type.upper()}_CONFIG"], "sensor_type": sensor_type, } if mono_fraction is not None: base["mono_fraction"] = float(mono_fraction) sweep = {**setup["COMMON_SWEEP"], **setup[f"{sensor_type.upper()}_SWEEP"]} weights = {**setup["COMMON_WEIGHTS"], **setup[f"{sensor_type.upper()}_WEIGHTS"]} return base, sweep, weights
[docs] def combination_generator( setup: dict, sensor_type: str, mono_fraction: float | None = None ) -> ConfigCombinationGenerator: """``pyfli.data_cc.ConfigCombinationGenerator`` for the combined setup (enumerates every combination).""" base, sweep, weights = _parts(setup, sensor_type, mono_fraction) n = int(np.prod([len(v) for v in sweep.values()])) if sweep else 1 if n > MAX_CARTESIAN: raise ValueError( f"{n} combinations are too many to enumerate; use make_combined_generator instead" ) return ConfigCombinationGenerator( base, sweep or {"jitter": [base.get("jitter", True)]}, weights )
[docs] class IndependentSweepGenerator: """ Draws every swept key independently with its weights, then simulates one pixel with pyfli's ``SimGenerator``. The resulting distribution is that of ``ConfigCombinationGenerator.sample`` / ``WeightedConfigSimGenerator`` (combination weight = product of per-key weights), but nothing is enumerated, so any number of experiments works. ``config_index`` in the output gives the chosen value index per key. """ def __init__( self, irf_cube, base: dict, sweep: dict, weights: dict, a_range, b_range, seed: int | None = None, ): self.irf_cube, self.base, self.a_range, self.b_range = ( irf_cube, base, tuple(a_range), tuple(b_range), ) self.keys = list(sweep) self.values = [list(sweep[k]) for k in self.keys] self.probs = [ np.asarray(weights.get(k, np.ones(len(v))), float) for k, v in zip(self.keys, self.values) ] self.probs = [p / p.sum() for p in self.probs] self.rng = np.random.default_rng(seed) self.config = dict(base)
[docs] def simulate_once(self) -> dict: idx = [ int(self.rng.choice(len(v), p=p)) for v, p in zip(self.values, self.probs) ] cfg = {**self.base, **{k: v[i] for k, v, i in zip(self.keys, self.values, idx)}} if self.config.get("seed") is not None: cfg["seed"] = self.config["seed"] gen = SimGenerator( self.irf_cube, cfg, a_range=self.a_range, b_range=self.b_range, pixel=(0, 0), family=SIM_FAMILY, sensor_type=cfg["sensor_type"], ) out = gen.simulate_once() out["config_index"] = dict(zip(self.keys, idx)) return out
[docs] def make_combined_generator( setup_path: str, run: int = 0, mono_fraction: float | None = None, seed: int | None = None, exact_ranges: bool = True, ): """ Simulator for one IRF run of the combined setup: ``gen.simulate_once()`` draws a combination of the calibrated values and simulates one pixel with ``IRF_RUNS[run]``. ``exact_ranges`` uses that IRF's own calibrated ``a_range`` / ``b_range`` and inactive time bins (``IRF_RUN_INFO``); otherwise the global ``A_RANGE`` / ``B_RANGE``. """ setup = load_combined_setup(setup_path) irf_path, sensor_type, _ = setup["IRF_RUNS"][run] info = setup["IRF_RUN_INFO"][irf_path] irf_cube = np.loadtxt(irf_path)[None, None, :] if mono_fraction is None: mono_fraction = setup["MONO_FRACTIONS"][0] base, sweep, weights = _parts(setup, sensor_type, mono_fraction) a_range = info["a_range"] if exact_ranges else setup["A_RANGE"] b_range = info["b_range"] if exact_ranges else setup["B_RANGE"] gen = IndependentSweepGenerator( irf_cube, base, sweep, weights, a_range, b_range, seed ) if exact_ranges and info["inactive_bins"]: return TimeWindowedGenerator(gen, info["inactive_bins"]) return gen
[docs] def verify_combined( setup_path: str, n_sim: int = 10000, n_exp: int = 20000, seed: int = 0 ) -> pd.DataFrame: """ For every experiment of the combined setup, the coverage (:func:`coverage_fractions`) of its sample pixels by * ``own values``: the combination made of that experiment's sweep values, i.e. its own calibration after re-expression on the common supports; * ``combined``: the whole combined distribution (all combinations, with weights). Both use the experiment's own IRF run. The experimental pixels are re-loaded from the experiment's data file with its calibration's bin radius and sample mask. The table is also written next to the setup as ``coverage_per_experiment.csv``. """ setup = load_combined_setup(setup_path) rows = [] for run, (irf_path, sensor_type, _) in enumerate(setup["IRF_RUNS"]): info = setup["IRF_RUN_INFO"][irf_path] name = info["experiment"] cfg_path = setup["EXPERIMENT_CONFIGS"][name] with open(cfg_path) as f: inp = json.load(f)["inputs"] data = load_experimental_data( inp["data_path"], bg_path=inp.get("bg_path"), bin_radius=int(inp["bin_radius"]), ) mask = np.load(os.path.join(os.path.dirname(cfg_path), "signal_mask.npy")) coords = np.argwhere(mask) pick = coords[ np.random.default_rng(seed).choice( len(coords), min(n_exp, len(coords)), replace=False ) ] exp = data.decay[pick[:, 0], pick[:, 1]].astype(np.float64) bg = background_bins(exp.sum(0)) exp_stats = compute_stats(exp, bg) base, _, _ = _parts(setup, sensor_type, setup["MONO_FRACTIONS"][0]) own = { **base, **{ k: tuple(v) if isinstance(v, list) else v for k, v in setup["EXPERIMENT_SWEEP_VALUES"][name].items() }, } irf_cube = np.loadtxt(irf_path)[None, None, :] own_gen = make_generator( irf_cube, own, info["a_range"], info["b_range"], inactive_bins=info["inactive_bins"], ) comb_gen = make_combined_generator(setup_path, run=run, seed=seed) for label, gen in (("own values", own_gen), ("combined", comb_gen)): sim_stats = compute_stats( stack_outputs(draw_pixels(gen, n_sim, seed + 17 * run))["decay"], bg ) rows.append( { "experiment": name, "simulation": label, **coverage_fractions(exp_stats, sim_stats), } ) logging.info(f"Verified {name}: {rows[-2]} / {rows[-1]}") table = pd.DataFrame(rows).set_index(["experiment", "simulation"]) table.to_csv( os.path.join(os.path.dirname(setup_path), "coverage_per_experiment.csv") ) return table