"""IRF template and shift range for the simulator, from a measured IRF or the data."""
from dataclasses import dataclass, field
import numpy as np
from scipy.optimize import minimize
from pyfli import logging
from pyfli.irf_deconvolution.fli_solver import fourier_shift
from pyfli.solver.shared_metrics import poisson_deviance
from .expected_decay import DecayForwardModel
from .summary_stats import rising_edge
[docs]
@dataclass
class IRFEstimate:
source: str # "experimental" | "simulated"
template: np.ndarray # (T,) peak-normalised IRF before the OffsetGen shift
a_center: float # bins; OffsetGen a ~ U(a_center - a_half, a_center + a_half)
a_half: float
b_max: float # OffsetGen b ~ U(0, b_max), as a fraction of the IRF peak
params: dict = field(
default_factory=dict
) # mu / sigma_ns / tail_ns for a simulated IRF
pooled_fit: dict = field(
default_factory=dict
) # lifetimes and background from the pooled fit
library: np.ndarray | None = (
None # (K, T) peak-normalised IRFs; one is drawn per simulated pixel
)
library_b_max: np.ndarray | None = (
None # (K,) baseline range per library IRF (background is per channel)
)
[docs]
def b_ranges(self) -> list[tuple[float, float]] | None:
if self.library is None or self.library_b_max is None:
return None
return [(0.0, float(b)) for b in self.library_b_max]
@property
def a_range(self) -> tuple[float, float]:
return (self.a_center - self.a_half, self.a_center + self.a_half)
@property
def b_range(self) -> tuple[float, float]:
return (0.0, self.b_max)
[docs]
def irf_cube(self) -> np.ndarray:
"""
(1, 1, T) cube in the layout ``SimGenerator`` / ``OffsetGen`` expect (pixel=(0, 0)),
or (1, K, T) for an IRF library (see ``sim_bridge.MultiIRFSimGenerator``).
"""
if self.library is not None:
return self.library[None, :, :].astype(np.float64)
return self.template[None, None, :].astype(np.float64)
[docs]
def effective_irf(self) -> np.ndarray:
"""Peak-normalised IRF at the centre of the shift range (what a typical pixel sees)."""
return (
_fourier_shift(self.template, self.a_center)
if self.a_center
else self.template
)
def _fourier_shift(y: np.ndarray, shift: float) -> np.ndarray:
"""Sub-bin circular delay of ``y`` by ``shift`` bins (pyfli's ``fourier_shift``), negative ringing clipped."""
return np.clip(fourier_shift(np.asarray(y, float), shift), 0, None)
def _peak_position(irf: np.ndarray) -> np.ndarray:
"""Sub-bin peak position (centroid of the 5 bins around the maximum) per trace."""
T = irf.shape[-1]
pk = irf.argmax(-1)
idx = (pk[:, None] + np.arange(-2, 3)[None, :]) % T
w = np.take_along_axis(irf, idx, -1)
w = np.clip(w - w.min(-1, keepdims=True), 0, None)
return pk + (w * np.arange(-2, 3)).sum(-1) / np.maximum(w.sum(-1), 1e-12)
[docs]
def experimental_template(
irf: np.ndarray, mask: np.ndarray | None
) -> tuple[np.ndarray, float]:
"""Peak-normalised IRF template and the half-width (bins) of the per-pixel IRF shift range."""
if irf.ndim == 1:
return irf / irf.max(), 0.5
traces = (
irf[mask]
if mask is not None and mask.shape == irf.shape[:2]
else irf.reshape(-1, irf.shape[-1])
)
peaks = traces.max(-1)
traces = traces[peaks >= 0.2 * np.percentile(peaks, 99)]
pos = _peak_position(traces)
ref = np.median(pos)
aligned = np.stack(
[np.roll(tr, int(np.rint(ref - p))) / tr.max() for tr, p in zip(traces, pos)]
)
template = aligned.mean(0)
lo, hi = np.percentile(pos, [2.5, 97.5])
return template / template.max(), max(0.5, (hi - lo) / 2)
def _fit_pooled(
fm: DecayForwardModel,
pooled: np.ndarray,
irf_from_theta,
theta0: list,
theta_bounds: list,
edge: float,
) -> tuple[np.ndarray, dict]:
"""
Fit the pooled decay with: IRF(theta) + baseline b, two lifetimes, photon fraction f,
and a background fraction beta. Several starts; returns the best parameter vector.
For a circular (TCSPC) model the baseline b only adds a flat floor, indistinguishable
from beta in a pooled decay, so b is held at 0 there and set later by
:func:`split_background`.
"""
N = pooled.sum()
T = len(pooled)
def unpack(x):
n = len(theta0)
theta = x[:n]
b, tau_s, dtau, f, beta = x[n:]
return theta, b, tau_s, tau_s + dtau, f, beta
def loss(x):
theta, b, tau_s, tau_l, f, beta = unpack(x)
irf = fm.with_baseline(irf_from_theta(theta), b)
mu = N * (
(1 - beta) * fm.shape(irf, [tau_s, tau_l], [f, 1 - f]) + beta * fm.flat()
)
return float(poisson_deviance(mu, pooled)) / T
b_hi, b0 = (0.0, 0.0) if fm.circular else (0.2, 1e-3)
bounds = [
*theta_bounds,
(0.0, b_hi),
(0.03, fm.period / 2),
(0.05, fm.period),
(0.0, 1.0),
(0.0, 0.95),
]
best = None
for tau_s0, dtau0, f0 in (
(0.4, 2.0, 0.4),
(1.0, 2.5, 0.5),
(0.2, 3.0, 0.3),
(1.5, 1.5, 0.6),
):
x0 = [*theta0, b0, tau_s0, dtau0, f0, 0.02]
res = minimize(
loss, x0, method="L-BFGS-B", bounds=bounds, options={"maxiter": 500}
)
if best is None or res.fun < best.fun:
best = res
theta, b, tau_s, tau_l, f, beta = unpack(best.x)
info = {
"tau_short_ns": float(tau_s),
"tau_long_ns": float(tau_l),
"frac_short": float(f),
"background_fraction": float(beta),
"irf_baseline_b": float(b),
"deviance_per_bin": float(best.fun),
"edge_bin": float(edge),
}
return np.asarray(theta), info
[docs]
def estimate_irf(
fm: DecayForwardModel,
decay: np.ndarray,
mask: np.ndarray,
irf: np.ndarray | None = None,
mode: str = "auto",
) -> IRFEstimate:
"""
``mode``: "experimental" uses the given IRF, "simulated" fits an EMG IRF, "auto" does
both when an IRF is given and keeps the one whose pooled fit has the lower deviance.
A measured IRF can be the wrong one for the data (other day, wavelength, detector
setting); "auto" catches that.
"""
pooled = decay[mask].sum(0).astype(float)
n_pix = int(mask.sum())
if irf is None and mode == "experimental":
raise ValueError("irf_mode='experimental' needs an IRF")
candidates = []
if irf is not None and mode in ("experimental", "auto"):
candidates.append(_estimate(fm, pooled, mask, irf))
if irf is None or mode in ("simulated", "auto"):
candidates.append(_estimate(fm, pooled, mask, None))
est = min(candidates, key=lambda e: e.pooled_fit["deviance_per_bin"])
if len(candidates) > 1:
est.pooled_fit["irf_choice"] = {
c.source: c.pooled_fit["deviance_per_bin"] for c in candidates
}
logging.info(
f"IRF auto mode: pooled deviance per bin {est.pooled_fit['irf_choice']} -> {est.source}"
)
# background fraction of the pooled decay -> dark counts per bin per pixel (initial guess)
est.pooled_fit["dcr_per_bin_per_pixel"] = float(
est.pooled_fit["background_fraction"] * pooled.sum() / fm.n_active / n_pix
)
logging.info(
f"IRF ({est.source}) initial estimate: {est.params}; pooled fit: {est.pooled_fit}"
)
return est
[docs]
def split_background(
est: IRFEstimate,
fm: DecayForwardModel,
bg_bins: np.ndarray,
slope: float,
intercept: float,
) -> float:
"""
Split the pre-edge background of a circular (TCSPC) model into dark counts and the
``OffsetGen`` IRF baseline ``b``, using the per-pixel regression
``pre-edge level = intercept + slope * counts``.
A baseline ``b`` on the peak-normalised IRF adds ``b / (sum(IRF) + b T)`` to every bin
per signal photon, the same flat floor as dark counts in a pooled decay, so the pooled
fit cannot separate them. Per pixel they differ: dark counts give a constant level (the
intercept), the baseline a level proportional to the signal (the slope left over after
the previous-pulse tail of the pooled-fit lifetimes). Sets ``est.b_max`` (``b ~ U(0,
b_max)``, so ``b_max = 2 b``) and ``pooled_fit["irf_baseline_b"]``; returns the dark-count
rate per bin per pixel (the pooled background when the intercept is not positive). The
counts include the dark counts themselves, so ``intercept = dcr (1 - slope n_active)``.
"""
pf = est.pooled_fit
shape = fm.shape(
fm.with_baseline(est.effective_irf(), 0.0),
[pf["tau_short_ns"], pf["tau_long_ns"]],
[pf["frac_short"], 1.0 - pf["frac_short"]],
)
tail = float(shape[np.asarray(bg_bins, dtype=int)].mean())
excess = max(float(slope) - tail, 0.0)
irf_sum = float(est.effective_irf().sum())
b = excess * irf_sum / max(1.0 - excess * fm.n_bins, 1e-6)
pf["tail_slope"] = tail
pf["irf_baseline_b"] = float(b)
est.b_max = max(2.0 * b, 1e-4)
if intercept <= 0:
return pf["dcr_per_bin_per_pixel"]
return float(intercept) / max(1.0 - float(slope) * fm.n_active, 1e-3)
def _estimate(
fm: DecayForwardModel, pooled: np.ndarray, mask: np.ndarray, irf: np.ndarray | None
) -> IRFEstimate:
edge = float(np.median(rising_edge(pooled[None, :])))
peak = int(np.argmax(pooled))
if irf is not None:
template, a_half = experimental_template(irf, mask)
tpk = float(_peak_position(template[None, :])[0])
def irf_from_theta(th):
return _fourier_shift(template, th[0])
theta, info = _fit_pooled(
fm,
pooled,
irf_from_theta,
[peak - tpk - 1.0],
[(-fm.n_bins / 4, fm.n_bins / 4)],
edge,
)
est = IRFEstimate(
source="experimental",
template=template,
a_center=float(theta[0]),
a_half=float(a_half),
b_max=max(2 * info["irf_baseline_b"], 1e-4),
params={"global_shift_bins": float(theta[0])},
pooled_fit=info,
)
else:
def irf_from_theta(th):
return fm.simulated_irf(th[0], th[1], th[2])
mu0 = edge + 1.0
best = None
for dmu in (-2.0, 0.0, 2.0):
theta, info = _fit_pooled(
fm,
pooled,
irf_from_theta,
[mu0 + dmu, 0.1, 0.05],
[(edge - 15, peak + 3), (0.3 * fm.dt, 1.0), (0.005, 0.6)],
edge,
)
if best is None or info["deviance_per_bin"] < best[1]["deviance_per_bin"]:
best = (theta, info)
theta, info = best
est = IRFEstimate(
source="simulated",
template=irf_from_theta(theta),
a_center=0.0,
a_half=0.5,
b_max=max(2 * info["irf_baseline_b"], 1e-4),
params={
"mu_bin": float(theta[0]),
"sigma_ns": float(theta[1]),
"tail_ns": float(theta[2]),
},
pooled_fit=info,
)
return est
[docs]
def irf_theta_names(source: str) -> list[str]:
return (
["mu_bin", "log_sigma_ns", "log_tail_ns"]
if source == "simulated"
else ["a_center"]
)
[docs]
def irf_theta_from_estimate(est: IRFEstimate) -> np.ndarray:
if est.source == "simulated":
p = est.params
return np.array([p["mu_bin"], np.log(p["sigma_ns"]), np.log(p["tail_ns"])])
return np.array([est.a_center])
[docs]
def apply_irf_theta(
est: IRFEstimate, fm: DecayForwardModel, theta: np.ndarray
) -> IRFEstimate:
"""Return a copy of ``est`` with the optimiser's IRF parameters applied."""
if est.source == "simulated":
mu, sigma, tail = (
float(theta[0]),
float(np.exp(theta[1])),
float(np.exp(theta[2])),
)
return IRFEstimate(
"simulated",
fm.simulated_irf(mu, sigma, tail),
est.a_center,
est.a_half,
est.b_max,
{"mu_bin": mu, "sigma_ns": sigma, "tail_ns": tail},
est.pooled_fit,
)
return IRFEstimate(
"experimental",
est.template,
float(theta[0]),
est.a_half,
est.b_max,
{"global_shift_bins": float(theta[0])},
est.pooled_fit,
)