"""End-to-end calibration: experimental 3D decay cube -> pyfli simulator config."""
import os
from dataclasses import asdict, dataclass, field, replace
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.ndimage import uniform_filter1d
from scipy.stats import beta as beta_dist
from pyfli import logging
from ..sim_calibrator import FLIValidator
from ..sim_workflow import SimGenerator
from .config import SIM_FAMILY, CalibrationInputs
from .coverage import coverage_fractions, ensure_coverage
from .data_io import (
ExperimentalData,
ReferenceMaps,
load_experimental_data,
load_reference_maps,
)
from .expected_decay import DecayForwardModel
from .irf_estimation import estimate_irf, split_background
from .optimizer import (
CalibrationState,
ObjectiveContext,
evaluate,
refresh_photon_prior,
run_calibration_rounds,
stage_parameter_names,
)
from .pixel_selection import select_signal_pixels
from .prior_estimation import BetaPrior, PriorSet, lifetime_priors, quick_pixel_fits
from .sim_bridge import build_sim_config, make_generator, save_calibration
from .summary_stats import (
StatsComparator,
background_bins,
compute_stats,
inactive_bins,
)
[docs]
@dataclass
class CalibrationResult:
config: dict
sim_generator_kwargs: dict
state: CalibrationState
mask: np.ndarray
selection_report: dict
validation: dict
history: pd.DataFrame
output_dir: str
files: dict = field(default_factory=dict)
best_fit: dict = field(default_factory=dict)
[docs]
def make_generator(self) -> SimGenerator:
kw = self.sim_generator_kwargs
return make_generator(
self.state.irf.irf_cube(),
self.config,
kw["a_range"],
kw["b_range"],
inactive_bins=kw.get("inactive_bins"),
)
[docs]
class DetectorSimCalibrator:
def __init__(self, inputs: CalibrationInputs) -> None:
self.inp = inputs
self.rng = np.random.default_rng(inputs.seed)
data_path = os.path.abspath(inputs.data_path.rstrip("/"))
base = os.path.splitext(os.path.basename(data_path))[0]
self.output_dir = inputs.output_dir or os.path.join(
os.path.dirname(data_path),
f"{base}_sim_calibration_bin{inputs.bin_radius}_{inputs.detector_type}_{inputs.model_type}",
)
os.makedirs(self.output_dir, exist_ok=True)
# ------------------------------------------------------------------ steps
def _initial_lifetimes(
self, fm, data: ExperimentalData, mask, irf_est, dcr, ref: ReferenceMaps | None
):
"""Parameter values used to initialise the Beta priors, plus where they came from."""
inp = self.inp
if ref is not None:
sel = mask if ref.aligned_with(data.spatial_shape) else None
values = {
k: ref.values(k, sel)
for k in ("tau1", "tau2", "alpha1", "efficiency", "tau")
}
key = "tau2" if inp.is_bi else "tau"
if len(values[key]) >= 50:
if sel is None:
logging.warning(
"Reference maps are not pixel-aligned with the data: using all their valid pixels"
)
if not inp.is_bi and len(values["tau"]) == 0:
values["tau"] = values["tau2"]
return values, f"reference:{ref.source}"
logging.warning(
"Reference maps have too few valid pixels: falling back to quick fits"
)
coords = np.argwhere(mask)
pick = coords[
self.rng.choice(
len(coords), min(inp.n_fit_pixels, len(coords)), replace=False
)
]
decays = data.decay[pick[:, 0], pick[:, 1]]
pf = irf_est.pooled_fit
init = {
"tau2": pf["tau_long_ns"],
"efficiency": 1 - pf["tau_short_ns"] / pf["tau_long_ns"],
"alpha1": pf["frac_short"],
"tau": pf["frac_short"] * pf["tau_short_ns"]
+ (1 - pf["frac_short"]) * pf["tau_long_ns"],
}
irf_norm = fm.with_baseline(irf_est.effective_irf(), irf_est.b_max / 2)
values = quick_pixel_fits(
fm, decays, irf_norm, np.full(len(decays), dcr), inp.is_bi, init
)
return values, "quick_fit"
[docs]
def run(self) -> CalibrationResult:
inp = self.inp
data = load_experimental_data(
inp.data_path, inp.irf_path, inp.bg_path, inp.mask_path, inp.bin_radius
)
T = data.n_bins
adc_ceiling = (
(2**inp.bit_depth - 1)
if (inp.detector_type == "continuous" and inp.bit_depth)
else None
)
# 1. sample pixels only
mask, sel_report = select_signal_pixels(
data.decay, data.mask, inp.selection, adc_ceiling
)
if mask.sum() < 20:
raise RuntimeError(
"Too few sample pixels selected; see the selection report / relax the criteria"
)
# 2. experimental statistics on a random subset of the sample pixels
coords = np.argwhere(mask)
order = self.rng.permutation(len(coords))
n_cal = min(inp.n_exp_pixels, len(coords))
sub = coords[order[:n_cal]]
held_out = coords[order[n_cal : n_cal + inp.n_exp_pixels]]
exp_decays = data.decay[sub[:, 0], sub[:, 1]].astype(np.float64)
inactive = inactive_bins(data.decay.sum((0, 1)))
sel_report["inactive_bins"] = inactive.tolist()
if len(inactive):
logging.info(
f"{len(inactive)} time bins never record a count (outside the acquisition window): "
"they are zeroed in the simulated decays"
)
bg_bins = background_bins(exp_decays.sum(0))
exp_stats = compute_stats(exp_decays, bg_bins)
# pyfli's jitter shifts every decay by U{-2..2} bins (edge SD ~1.4 bins). Data whose
# rising edges are tighter than that cannot have been produced with jitter on.
edge_sd = float(
np.subtract(*np.nanpercentile(exp_stats["edge"], [75, 25])) / 1.349
)
if inp.jitter == "auto":
inp.jitter = edge_sd >= 1.0
logging.info(
f"Rising-edge SD {edge_sd:.2f} bins -> simulator jitter {'on' if inp.jitter else 'off'}"
)
sel_report["edge_sd_bins"] = edge_sd
# 3. IRF (experimental template or simulated EMG) + pooled lifetimes / background
fm = DecayForwardModel(
T, inp.laser_period_ns, inp.detector_type, inp.jitter, inactive
)
irf_est = estimate_irf(fm, data.decay, mask, data.irf, inp.irf_mode)
# dark counts: pre-edge level = dcr + slope * counts across pixels (the slope is the
# signal-proportional previous-pulse tail), so the intercept is the dark-count rate
pre = exp_decays[:, bg_bins].mean(-1)
slope, intercept = np.polyfit(exp_stats["counts"], pre, 1)
if fm.circular:
dcr0 = max(split_background(irf_est, fm, bg_bins, slope, intercept), 1e-4)
else:
dcr0 = max(intercept, irf_est.pooled_fit["dcr_per_bin_per_pixel"], 1e-4)
sel_report["dcr_regression"] = {
"intercept": float(intercept),
"slope": float(slope),
}
# 4. initial lifetime priors from reference maps or quick fits
ref = (
load_reference_maps(
inp.reference_path, inp.reference_source, inp.reference_alpha
)
if inp.reference_path
else None
)
values, prior_source = self._initial_lifetimes(
fm, data, mask, irf_est, dcr0, ref
)
tau2_p, eff_p, a1_p, moments = lifetime_priors(
values, inp.is_bi, inp.laser_period_ns, inp.prior_inflation
)
exp_counts = data.decay[mask].sum(-1).astype(float)
exp_peaks = uniform_filter1d(exp_decays, 3, axis=-1).max(-1) - dcr0
ctx = ObjectiveContext(
fm=fm,
detector_type=inp.detector_type,
laser_freq_mhz=inp.laser_freq_mhz,
jitter=inp.jitter,
comparator=StatsComparator(exp_stats),
bg_bins=bg_bins,
exp_counts=exp_counts,
exp_peaks=exp_peaks,
data_max=float(exp_decays.max()),
bit_depth=inp.bit_depth,
prior_inflation=inp.prior_inflation,
n_sim=inp.n_sim_pixels,
seed=inp.seed,
reference_moments=moments if ref is not None else {},
reference_weight=inp.reference_weight if ref is not None else 0.0,
inactive_bins=inactive,
)
priors = PriorSet(
tau2_p,
eff_p,
a1_p,
BetaPrior(2, 2),
(0, 1),
16,
inp.mono_fraction,
prior_source,
moments,
)
if inp.detector_type == "continuous":
# pyfli scales the configured dcr by bit / 8; store the config value
state = refresh_photon_prior(CalibrationState(irf_est, priors, dcr0), ctx)
state = replace(state, dcr=dcr0 / (state.priors.bit / 8.0))
ctx = replace(ctx, bit_depth=state.priors.bit)
else:
state = refresh_photon_prior(CalibrationState(irf_est, priors, dcr0), ctx)
init_state = state
init_dist, init_parts, *_ = evaluate(state, ctx)
logging.info(f"Initial distance {init_dist:.4f}: {init_parts}")
# 5. simulation-based optimisation
nuisance, prior_names = stage_parameter_names(
state,
ctx,
inp.optimize_irf,
inp.optimize_noise,
inp.optimize_priors,
inp.is_bi,
)
state, hist = run_calibration_rounds(
state,
ctx,
nuisance,
prior_names,
inp.n_bank_pixels,
inp.bank_rounds,
inp.max_evals_stage1,
inp.max_evals_stage2,
)
history = pd.DataFrame(hist)
best_fit = state
cov_seed = inp.seed + 4243
coverage_trace = []
if inp.target_coverage > 0:
state, coverage_trace, cov_cal, _, cov_sim_stats = ensure_coverage(
state,
ctx,
exp_stats,
exp_counts,
inp.target_coverage,
inp.n_coverage_pixels,
inp.max_coverage_iter,
seed=cov_seed,
)
else:
_, _, _, cov_sim_stats = evaluate(
state, ctx, n=inp.n_coverage_pixels, seed=cov_seed
)
cov_cal = coverage_fractions(exp_stats, cov_sim_stats)
cov_held = None
if len(held_out) >= 100:
held_stats = compute_stats(
data.decay[held_out[:, 0], held_out[:, 1]].astype(np.float64), bg_bins
)
cov_held = coverage_fractions(held_stats, cov_sim_stats)
_, _, _, best_sim_stats = evaluate(
best_fit, ctx, n=inp.n_coverage_pixels, seed=cov_seed
)
cov_best = coverage_fractions(exp_stats, best_sim_stats)
logging.info(
f"Coverage of the experiment: best fit {cov_best} -> final {cov_cal}; held-out pixels {cov_held}"
)
# 6. independent validation (fresh random numbers, more pixels)
val_seed = inp.seed + 7919
dist, parts, sim, sim_stats = evaluate(
state, ctx, n=inp.n_validation_pixels, seed=val_seed
)
best_val, best_val_parts, *_ = evaluate(
best_fit, ctx, n=inp.n_validation_pixels, seed=val_seed
)
init_val, init_val_parts, *_ = evaluate(
init_state, ctx, n=inp.n_validation_pixels, seed=val_seed
)
validator = FLIValidator(method="calibration", threshold=1)
vm = validator.run_comprehensive_test(
{"raw_data": {"decay": sim["decay"]}}, exp_decays
)
validation = {
"distance_initial": init_val,
"distance_initial_parts": init_val_parts,
"distance_best_fit": best_val,
"distance_best_fit_parts": best_val_parts,
"distance_final": dist,
"distance_final_parts": parts,
"coverage_target": inp.target_coverage,
"coverage_best_fit": cov_best,
"coverage_final": cov_cal,
"coverage_held_out": cov_held,
"n_held_out_pixels": len(held_out),
"coverage_trace": coverage_trace,
"fli_validator": {
k: float(vm[k])
for k in (
"cosine_similarity",
"kl_divergence",
"ks_p_value",
"hist_intersection",
)
},
"n_validation_pixels": inp.n_validation_pixels,
}
logging.info(
f"Validation: distance {init_val:.3f} (initial) -> {best_val:.3f} (best fit) -> {dist:.3f} "
f"(covering); {validation['fli_validator']}"
)
# 7. save
config = build_sim_config(
state.priors, inp.detector_type, state.dcr, inp.laser_freq_mhz, inp.jitter
)
kw = {
"a_range": [round(v, 3) for v in state.a_range()],
"b_range": [0.0, float(state.irf.b_max)],
"pixel": [0, 0],
"family": SIM_FAMILY,
"sensor_type": inp.detector_type,
"inactive_bins": inactive.tolist(),
}
result = CalibrationResult(
config, kw, state, mask, sel_report, validation, history, self.output_dir
)
result.best_fit = {
"config": build_sim_config(
best_fit.priors,
inp.detector_type,
best_fit.dcr,
inp.laser_freq_mhz,
inp.jitter,
),
"sim_generator_kwargs": {
**kw,
"a_range": [round(v, 3) for v in best_fit.a_range()],
"b_range": [0.0, float(best_fit.irf.b_max)],
},
"priors": best_fit.priors.summary(),
"dcr_config": best_fit.dcr,
}
self._save(
result, data, exp_decays, exp_stats, sim, sim_stats, values, init_state
)
return result
# ----------------------------------------------------------------- output
def _save(
self,
res: CalibrationResult,
data,
exp_decays,
exp_stats,
sim,
sim_stats,
values,
init_state,
) -> None:
out = self.output_dir
inp_dict = asdict(self.inp)
files = {
"irf": "calibrated_irf.npy",
"mask": "signal_mask.npy",
"history": "optimization_history.csv",
"validation_sample": "validation_simulated_pixels.npz",
}
np.save(os.path.join(out, files["irf"]), res.state.irf.irf_cube())
np.save(os.path.join(out, files["mask"]), res.mask)
res.history.to_csv(os.path.join(out, files["history"]), index=False)
np.savez_compressed(os.path.join(out, files["validation_sample"]), **sim)
st = res.state
payload = {
"inputs": inp_dict,
"config": res.config,
"sim_generator_kwargs": res.sim_generator_kwargs,
"irf": {
"source": st.irf.source,
"file": files["irf"],
"params": st.irf.params,
"a_range": list(st.a_range()),
"b_range": list(st.b_range()),
"pooled_fit": st.irf.pooled_fit,
},
"priors": st.priors.summary(),
"priors_initial": init_state.priors.summary(),
"best_fit": res.best_fit,
"dcr_config": st.dcr,
"photon_scale": st.photon_scale,
"pixel_selection": res.selection_report,
"validation": res.validation,
"usage": "from pyfli.simulator.calibration import load_calibrated_generator; "
"gen = load_calibrated_generator('<this file>'); gen.simulate_once()",
}
files["config"] = "calibrated_sim_config.json"
save_calibration(os.path.join(out, files["config"]), payload)
files.update(
self._plots(res, data, exp_decays, exp_stats, sim, sim_stats, values)
)
res.files = {k: os.path.join(out, v) for k, v in files.items()}
logging.info(f"Calibration saved to {out}")
def _plots(self, res, data, exp_decays, exp_stats, sim, sim_stats, values) -> dict:
out, st = self.output_dir, res.state
files = {}
# pixel selection
inten = data.decay.sum(-1)
fig, ax = plt.subplots(1, 3, figsize=(16, 4.6))
im = ax[0].imshow(np.log10(inten + 1), cmap="magma")
ax[0].set_title("log10 intensity")
fig.colorbar(im, ax=ax[0])
ax[1].imshow(res.mask, cmap="gray")
ax[1].set_title(
f"sample mask ({res.selection_report['mode']}): {int(res.mask.sum())} px"
)
bins = np.linspace(0, np.log10(inten.max() + 1), 120)
ax[2].hist(
np.log10(inten[~res.mask] + 1),
bins,
alpha=0.6,
label="rejected (background / noise)",
)
ax[2].hist(np.log10(inten[res.mask] + 1), bins, alpha=0.6, label="sample")
ax[2].set(xlabel="log10 counts / pixel", yscale="log")
ax[2].legend()
for a in ax[:2]:
a.axis("off")
fig.tight_layout()
files["plot_selection"] = "pixel_selection.png"
fig.savefig(os.path.join(out, files["plot_selection"]), dpi=130)
plt.close(fig)
# simulated vs experimental decays
fig, ax = plt.subplots(2, 3, figsize=(17, 9))
ax[0, 0].semilogy(exp_stats["mean_decay"], label="experimental (sample pixels)")
ax[0, 0].semilogy(sim_stats["mean_decay"], "--", label="calibrated simulation")
irf_n = st.irf.effective_irf()
ax[0, 0].semilogy(
irf_n / irf_n.max() * exp_stats["mean_decay"].max(),
":",
label=f"IRF ({st.irf.source})",
)
ax[0, 0].set(
title="pooled decay shape",
xlabel="time bin",
ylim=(
exp_stats["mean_decay"].max() * 1e-4,
exp_stats["mean_decay"].max() * 3,
),
)
ax[0, 0].legend()
b = np.linspace(
min(exp_stats["log_counts"].min(), sim_stats["log_counts"].min()),
max(exp_stats["log_counts"].max(), sim_stats["log_counts"].max()),
70,
)
ax[0, 1].hist(
exp_stats["log_counts"], b, density=True, alpha=0.55, label="experimental"
)
ax[0, 1].hist(
sim_stats["log_counts"], b, density=True, alpha=0.55, label="simulated"
)
ax[0, 1].set(title="photons / pixel", xlabel="log10 counts")
ax[0, 1].legend()
e1, s1 = exp_stats["phasor1"], sim_stats["phasor1"]
ax[0, 2].hexbin(
s1[:, 0], s1[:, 1], gridsize=70, cmap="Blues", mincnt=1, bins="log"
)
k = slice(None, None, max(1, len(e1) // 4000))
ax[0, 2].scatter(e1[k, 0], e1[k, 1], s=1, c="r", alpha=0.3)
ax[0, 2].set(
title="raw phasor (h=1): simulated (blue) vs experimental (red)",
xlabel="G",
ylabel="S",
)
for a, key, title in (
(ax[1, 0], "edge", "rising-edge position (bins)"),
(ax[1, 1], "bg_frac", "pre-edge background / mean level"),
):
e, s = (
exp_stats[key][np.isfinite(exp_stats[key])],
sim_stats[key][np.isfinite(sim_stats[key])],
)
bb = np.linspace(
np.percentile(np.r_[e, s], 0.5), np.percentile(np.r_[e, s], 99.5), 70
)
a.hist(e, bb, density=True, alpha=0.55, label="experimental")
a.hist(s, bb, density=True, alpha=0.55, label="simulated")
a.set_title(title)
a.legend()
e2, s2 = exp_stats["phasor2"], sim_stats["phasor2"]
ax[1, 2].hexbin(
s2[:, 0], s2[:, 1], gridsize=70, cmap="Blues", mincnt=1, bins="log"
)
ax[1, 2].scatter(e2[k, 0], e2[k, 1], s=1, c="r", alpha=0.3)
ax[1, 2].set(title="raw phasor (h=2)", xlabel="G", ylabel="S")
v = res.validation
fig.suptitle(
f"{self.inp.detector_type} / {self.inp.model_type}: distance {v['distance_initial']:.2f} -> {v['distance_final']:.2f}"
)
fig.tight_layout()
files["plot_validation"] = "sim_vs_experiment.png"
fig.savefig(os.path.join(out, files["plot_validation"]), dpi=130)
plt.close(fig)
# priors vs initial values
names = [
(
"tau2",
"tau2" if self.inp.is_bi else "tau",
"tau2 (ns)" if self.inp.is_bi else "tau (ns)",
)
]
if self.inp.is_bi:
names += [
("efficiency", "efficiency", "efficiency E = 1 - tau1/tau2"),
("alpha1", "alpha1", "A1 (photon fraction of tau1)"),
]
fig, ax = plt.subplots(1, len(names) + 1, figsize=(4.8 * (len(names) + 1), 4))
ax = np.atleast_1d(ax)
for a, (pname, vkey, title) in zip(ax, names):
p = getattr(st.priors, pname)
x = np.linspace(p.offset, p.offset + p.scale, 300)
vals = values.get(vkey, np.array([]))
vals = vals[np.isfinite(vals)]
if len(vals):
a.hist(
vals,
60,
range=(p.offset, p.offset + p.scale),
density=True,
alpha=0.45,
label=f"initial ({st.priors.source})",
)
sim_key = {"tau2": "tau2", "efficiency": "Efficiency", "alpha1": "alpha1"}[
pname
]
a.hist(
sim[sim_key],
60,
range=(p.offset, p.offset + p.scale),
density=True,
histtype="step",
lw=1.5,
label="calibrated simulation",
)
a.plot(
x,
beta_dist.pdf((x - p.offset) / p.scale, p.a, p.b) / p.scale,
"k",
lw=2,
label=f"Beta({p.a:.2f}, {p.b:.2f})",
)
a.set_title(title)
a.legend(fontsize=8)
ax[-1].hist(sim["photon_count"], 60, density=True, alpha=0.6)
ax[-1].set_title(
"photon prior: "
+ (
"expected photons (0.01 x n_cycles)"
if self.inp.detector_type == "discrete"
else "peak A (ADC)"
)
)
fig.tight_layout()
files["plot_priors"] = "priors.png"
fig.savefig(os.path.join(out, files["plot_priors"]), dpi=130)
plt.close(fig)
if len(res.history):
fig, a = plt.subplots(figsize=(8, 3.5))
for stage, g in res.history.groupby("stage", sort=False):
a.plot(g.index, g["objective"].cummin(), label=f"{stage} (best so far)")
a.plot(g.index, g["objective"], ".", ms=2, alpha=0.4)
a.set(xlabel="evaluation", ylabel="objective", yscale="log")
a.legend()
fig.tight_layout()
files["plot_history"] = "optimization_history.png"
fig.savefig(os.path.join(out, files["plot_history"]), dpi=130)
plt.close(fig)
return files