Source code for pyfli.simulator.calibration.coverage

"""Check that the experimental data lies within what the calibrated simulator produces."""

from dataclasses import replace

import numpy as np
from scipy import ndimage
from scipy.stats import beta as beta_dist

from pyfli import logging

from .config import SIM_MIN_CYCLES, SIM_MU_PER_CYCLE
from .optimizer import (
    CalibrationState,
    ObjectiveContext,
    active_photon_fraction,
    evaluate,
)
from .prior_estimation import BetaPrior, photon_prior_continuous, photon_prior_discrete

COVERAGE_STATS = ("phasor1", "phasor2", "log_counts", "edge", "bg_frac")
LIFETIME_PRIORS = ("tau2", "efficiency", "alpha1")


[docs] def hdr_covered( e: np.ndarray, s: np.ndarray, q: float = 1e-3, bins: int = 120 ) -> np.ndarray: """ 2D points ``e`` inside the highest-density region that holds ``1 - q`` of the simulated points ``s``. The simulated density is a binned Gaussian kernel estimate with Scott's bandwidth (``sd * n ** (-1/6)`` per axis), so the region converges as the number of simulated pixels grows instead of depending on it, unlike a raw cell-count rule, which marks sparse but supported regions as uncovered. """ rng = [ [min(e[:, 0].min(), s[:, 0].min()), max(e[:, 0].max(), s[:, 0].max())], [min(e[:, 1].min(), s[:, 1].min()), max(e[:, 1].max(), s[:, 1].max())], ] H, xe, ye = np.histogram2d(s[:, 0], s[:, 1], bins=bins, range=rng) bandwidth = s.std(0) * len(s) ** (-1 / 6) cell = np.array([xe[1] - xe[0], ye[1] - ye[0]]) sigma = np.where(cell > 0, bandwidth / np.where(cell > 0, cell, 1.0), 0.0) D = ndimage.gaussian_filter(H, sigma, mode="constant") def lookup(p): ix = np.clip(np.digitize(p[:, 0], xe) - 1, 0, bins - 1) iy = np.clip(np.digitize(p[:, 1], ye) - 1, 0, bins - 1) return D[ix, iy] level = np.quantile(lookup(s), q) return lookup(e) >= max(level, 1e-12)
[docs] def covered_pixels( exp_stats: dict, sim_stats: dict, q: float = 1e-3 ) -> dict[str, np.ndarray]: """ Per experimental pixel and statistic: is it inside the region holding ``1 - q`` of the simulated pixels? Raw phasors use the 2D highest-density region (:func:`hdr_covered`); photons, edge and background use the central ``[q, 1 - q]`` quantile range. """ out = { h: hdr_covered(exp_stats[h], sim_stats[h], q) for h in ("phasor1", "phasor2") } for k in ("log_counts", "edge", "bg_frac"): s = sim_stats[k][np.isfinite(sim_stats[k])] lo, hi = np.quantile(s, [q, 1 - q]) e = exp_stats[k] with np.errstate(invalid="ignore"): out[k] = (e >= lo) & (e <= hi) return out
[docs] def coverage_fractions( exp_stats: dict, sim_stats: dict, q: float = 1e-3 ) -> dict[str, float]: """Fraction of experimental pixels inside the region populated by the simulation, per statistic.""" flags = covered_pixels(exp_stats, sim_stats, q) out = {k: float(np.mean(v)) for k, v in flags.items()} for k in ("log_counts", "edge", "bg_frac"): out[k] = float(np.mean(flags[k][np.isfinite(exp_stats[k])])) return out
def _tails( exp_stats: dict, sim_stats: dict, key: str, q: float = 1e-3 ) -> tuple[float, float]: """Fractions of experimental values below / above the simulated [q, 1 - q] range.""" s = sim_stats[key][np.isfinite(sim_stats[key])] lo, hi = np.quantile(s, [q, 1 - q]) e = exp_stats[key][np.isfinite(exp_stats[key])] return float(np.mean(e < lo)), float(np.mean(e > hi))
[docs] def flatten_beta(p: BetaPrior, factor: float) -> BetaPrior: """Move a Beta toward Beta(1, 1) on the same support: shape -> 1 + factor * (shape - 1).""" return BetaPrior(1 + factor * (p.a - 1), 1 + factor * (p.b - 1), p.offset, p.scale)
[docs] def flatten_lifetime_priors(priors, factor: float): out = priors for name in LIFETIME_PRIORS: p = getattr(priors, name) if p is not None: out = replace(out, **{name: flatten_beta(p, factor)}) return out
[docs] def resupport_beta(p: BetaPrior, lo: float, hi: float) -> BetaPrior: """The same mean and SD expressed as a Beta on the new support ``[lo, hi]``.""" m = (p.mean - lo) / (hi - lo) sd = p.sd / (hi - lo) k = m * (1 - m) / max(sd**2, 1e-10) - 1 return BetaPrior.from_mean_conc(m, k, lo, hi - lo)
[docs] def cover_photon_range( state: CalibrationState, exp_counts: np.ndarray, n_active_bins: int, margin: float = 0.2, active_fraction: float = 1.0, ) -> CalibrationState: """ Discrete: stretch ``n_cycles`` so that the signal photons of every experimental pixel are reachable, given that only ``active_fraction`` of them land in active bins. """ sig = ( np.clip(np.asarray(exp_counts, float) - state.dcr * n_active_bins, 0, None) / active_fraction ) lo_c = max( int(np.floor(sig.min() * (1 - margin) / SIM_MU_PER_CYCLE)), SIM_MIN_CYCLES ) hi_c = int(np.ceil(sig.max() * (1 + margin) / SIM_MU_PER_CYCLE)) cur_lo, cur_hi = state.priors.photon_range lo_c, hi_c = ( min(lo_c, int(cur_lo)), max(hi_c, int(cur_hi), lo_c + 10 * SIM_MIN_CYCLES), ) if (lo_c, hi_c) == (int(cur_lo), int(cur_hi)): return state photon = resupport_beta(state.priors.photon, lo_c, hi_c) return replace( state, priors=replace(state.priors, photon=photon, photon_range=(lo_c, hi_c)) )
[docs] def stretch_tau2_support( state: CalibrationState, below: float, above: float, step: float = 0.15 ) -> CalibrationState: """Extend ``tau2_beta_range`` on the side(s) where experimental phasors are uncovered.""" p = state.priors.tau2 lo, hi = p.offset, p.offset + p.scale width = hi - lo new_lo = max(0.05, lo - step * width) if below > 0 else lo new_hi = hi + step * width if above > 0 else hi if (new_lo, new_hi) == (lo, hi): return state return replace( state, priors=replace(state.priors, tau2=resupport_beta(p, new_lo, new_hi)) )
def _lifetime_side(exp_stats: dict, sim_stats: dict) -> tuple[float, float]: """ Which side of the lifetime range is uncovered, as fractions ``(short, long)`` of experimental pixels beyond the simulated range. Raw phasors are rotated by the IRF delay, so the rotation-invariant first-harmonic modulus is used: it decreases with the lifetime. """ s = np.hypot(*sim_stats["phasor1"].T) e = np.hypot(*exp_stats["phasor1"].T) lo, hi = np.quantile(s, [1e-3, 1 - 1e-3]) return float(np.mean(e > hi)), float(np.mean(e < lo))
[docs] def step_factor( coverage: float, target: float, slowest: float = 0.9, fastest: float = 0.4 ) -> float: """Flattening factor for one step: large steps far below the target, small steps near it.""" return float(np.clip((coverage / target) ** 4, fastest, slowest))
[docs] def refit_photon_prior( state: CalibrationState, ctx: ObjectiveContext, exp_counts: np.ndarray ) -> CalibrationState: """Photon prior re-derived from the observed photon counts (or peaks) of the experiment.""" if ctx.detector_type == "discrete": f_active = active_photon_fraction(state, ctx) prior, cyc = photon_prior_discrete( exp_counts, ctx.fm.n_active, state.dcr, active_fraction=f_active ) state = replace( state, priors=replace(state.priors, photon=prior, photon_range=cyc) ) return cover_photon_range( state, exp_counts, ctx.fm.n_active, active_fraction=f_active ) prior, _ = photon_prior_continuous( ctx.exp_peaks, ctx.data_max, state.priors.bit, scale_corr=state.photon_scale ) return replace(state, priors=replace(state.priors, photon=prior))
[docs] def widen( state: CalibrationState, failing: list[str], exp_stats: dict, sim_stats: dict, ctx: ObjectiveContext, exp_counts: np.ndarray, cov: dict, target: float, photon_refitted: bool, ) -> tuple[CalibrationState, list[str]]: """One widening step for every failing statistic; returns the new state and what was changed.""" actions = [] if "log_counts" in failing: if not photon_refitted: state = refit_photon_prior(state, ctx, exp_counts) actions.append("photon prior refitted to the observed counts") else: f = step_factor(cov["log_counts"], target) state = replace( state, priors=replace( state.priors, photon=flatten_beta(state.priors.photon, f) ), ) actions.append(f"photon prior flattened ({f:.2f})") if "phasor1" in failing or "phasor2" in failing: factor = step_factor(min(cov["phasor1"], cov["phasor2"]), target) short_side, long_side = _lifetime_side(exp_stats, sim_stats) p = state.priors.tau2 near_edge = beta_dist.ppf([1e-3, 1 - 1e-3], p.a, p.b) below = short_side if near_edge[0] < 0.05 else 0.0 above = long_side if near_edge[1] > 0.95 else 0.0 state = stretch_tau2_support(state, below, above) state = replace(state, priors=flatten_lifetime_priors(state.priors, factor)) actions.append(f"lifetime priors flattened ({factor:.2f})") if "edge" in failing: irf = state.irf a_half = float(min(irf.a_half * 1.3 + 0.5, ctx.fm.n_bins / 8)) state = replace(state, irf=replace(irf, a_half=a_half)) actions.append(f"a_half -> {a_half:.2f} bins") if "bg_frac" in failing: below, above = _tails(exp_stats, sim_stats, "bg_frac") if above >= below: irf = state.irf state = replace( state, irf=replace(irf, b_max=float(min(irf.b_max * 1.5 + 1e-5, 0.5))) ) actions.append(f"b_max -> {state.irf.b_max:.2e}") else: state = replace(state, dcr=state.dcr * 0.7) actions.append(f"dcr -> {state.dcr:.2e}") return state, actions
[docs] def ensure_coverage( state: CalibrationState, ctx: ObjectiveContext, exp_stats: dict, exp_counts: np.ndarray, target: float = 0.99, n_sim: int = 20000, max_iter: int = 10, seed: int = 0, ) -> tuple[CalibrationState, list[dict], dict, dict, dict]: """ Widen ``state`` until every statistic in :data:`COVERAGE_STATS` covers at least ``target`` of the experimental pixels (or ``max_iter`` widening steps were made). Returns ``(state, trace, coverage, sim, sim_stats)``; ``trace`` has one row per iteration with the coverage of each statistic, the distance to the experiment and the widening applied. """ if ctx.detector_type == "discrete": state = cover_photon_range( state, exp_counts, ctx.fm.n_active, active_fraction=active_photon_fraction(state, ctx), ) trace = [] photon_refitted = False for it in range(max_iter + 1): dist, parts, sim, sim_stats = evaluate(state, ctx, n=n_sim, seed=seed) cov = coverage_fractions(exp_stats, sim_stats) failing = [k for k in COVERAGE_STATS if cov[k] < target] row = { "iteration": it, "distance": dist, **{f"cover {k}": v for k, v in cov.items()}, } logging.info( f"Coverage iteration {it}: " + " ".join(f"{k}={v:.1%}" for k, v in cov.items()) + f" distance={dist:.3f}" ) if not failing or it == max_iter: row["widened"] = "" trace.append(row) break state, actions = widen( state, failing, exp_stats, sim_stats, ctx, exp_counts, cov, target, photon_refitted, ) photon_refitted = photon_refitted or "log_counts" in failing row["widened"] = "; ".join(actions) trace.append(row) if failing: logging.warning( f"Coverage target {target:.0%} not reached for {failing} after {max_iter} widening steps" ) return state, trace, cov, sim, sim_stats