"""
Fit-free mono-exponential lifetimes from the photon histogram: mean arrival time
and phasor lifetime.
"""
from typing import Any
import numpy as np
from pyfli.phasor.phasorS import PhasorAnalyzer, discrete_phasor_lifetime
from pyfli.simulator.calibration.expected_decay import gated_bin_probabilities
[docs]
def gate_times(n_gates: int, laser_period: float) -> np.ndarray:
"""Gate start times ``k * laser_period / n_gates`` in ns (endpoint excluded)."""
return np.arange(n_gates) * (laser_period / n_gates)
def _signal(decay: np.ndarray, background_per_bin: float) -> np.ndarray:
"""Background-subtracted counts, deliberately not clipped at zero: clipping would
discard the negative noise excursions and bias the moments at low counts."""
return np.asarray(decay, dtype=float) - background_per_bin
def _shared_irf(irf: np.ndarray) -> np.ndarray:
irf = np.asarray(irf, dtype=float)
if irf.ndim != 1:
raise ValueError(
"Pass a shared 1-D IRF of shape (T,), e.g. sim_irf[r, c] or "
"sim_irf[mask].mean(axis=0)."
)
return irf / irf.sum()
[docs]
def mean_arrival_time_naive(
decay: np.ndarray,
irf: np.ndarray,
laser_period: float,
background_per_bin: float = 0.0,
) -> np.ndarray:
"""
``<t>_decay - <t>_IRF`` per decay (ns) -- the textbook first-moment lifetime,
exact only for an unlimited window without wrap-around or gating.
"""
irf = _shared_irf(irf)
t = gate_times(irf.size, laser_period)
d = _signal(decay, background_per_bin)
total = d.sum(axis=-1)
with np.errstate(invalid="ignore", divide="ignore"):
mean_t = (d * t).sum(axis=-1) / total
return np.where(total > 0, mean_t - np.sum(irf * t), np.nan)
def _mean_time_lookup(
irf: np.ndarray, laser_period: float, tau_grid: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
t = gate_times(irf.size, laser_period)
model_mean = gated_bin_probabilities(tau_grid, irf, laser_period) @ t
keep = np.concatenate([[True], np.diff(np.maximum.accumulate(model_mean)) > 0])
return model_mean[keep], tau_grid[keep]
[docs]
def mean_arrival_lifetime(
decay: np.ndarray,
irf: np.ndarray,
laser_period: float,
background_per_bin: float = 0.0,
tau_grid: np.ndarray | None = None,
) -> np.ndarray:
"""
Lifetime whose exact gated-model first moment equals the measured mean arrival
time (method of moments with the wrap-around / gate / IRF model).
Parameters
----------
decay : np.ndarray
Photon histograms, time axis last.
irf : np.ndarray
Shared IRF ``(T,)``.
laser_period : float
Laser repetition period in ns.
background_per_bin : float
Expected background counts per gate, subtracted before the moment.
tau_grid : np.ndarray | None
Lifetimes (ns) of the lookup table; defaults to 2000 log-spaced values in
``[0.01, 5 * laser_period]``.
Returns
-------
np.ndarray
Lifetime per decay (ns); NaN for empty decays or means outside the table.
"""
irf = _shared_irf(irf)
if tau_grid is None:
tau_grid = np.geomspace(0.01, 5 * laser_period, 2000)
model_mean, tau_tab = _mean_time_lookup(irf, laser_period, np.asarray(tau_grid))
t = gate_times(irf.size, laser_period)
d = _signal(decay, background_per_bin)
total = d.sum(axis=-1)
with np.errstate(invalid="ignore", divide="ignore"):
mean_t = (d * t).sum(axis=-1) / total
tau = np.interp(mean_t, model_mean, tau_tab, left=np.nan, right=np.nan)
return np.where(total > 0, tau, np.nan)
[docs]
def phasor_lifetime(
decay: np.ndarray,
irf: np.ndarray,
laser_period: float,
background_per_bin: float = 0.0,
bin_corrected: bool = False,
device: Any | None = "cpu",
) -> np.ndarray:
"""
First-harmonic phasor lifetime at the laser repetition frequency, calibrated
with the IRF, via :class:`~pyfli.phasor.phasorS.PhasorAnalyzer`.
Parameters
----------
decay : np.ndarray
Photon histograms, time axis last.
irf : np.ndarray
Shared IRF ``(T,)``.
laser_period : float
Laser repetition period in ns (the phasor frequency is ``1 / laser_period``).
background_per_bin : float
Expected background counts per gate, subtracted before the phasor.
bin_corrected : bool
False (default): the standard phase lifetime ``S / (omega * G)`` from
``PhasorAnalyzer.compute_lifetime``. True: invert the calibrated phasor
``z = (1 - q) / (1 - q * exp(i*omega*dt))`` of the exact wrapped, gated
exponential for ``q = exp(-dt / tau)``, which removes the gate-width bias.
device : Any | None
Torch device for ``PhasorAnalyzer`` (calibration runs in torch).
Returns
-------
np.ndarray
Lifetime per decay (ns), shape ``decay.shape[:-1]``.
"""
irf = _shared_irf(irf)
d = _signal(decay, background_per_bin)
spatial = d.shape[:-1]
n = irf.size
d_flat = d.reshape(1, -1, n)
t = gate_times(n, laser_period)
phasor = PhasorAnalyzer(
frequency_hz=1e9 / laser_period, time_axis_ns=t, n_harmonics=1, device=device
)
g, s = phasor.create_phasor_cpu(d_flat)
irf_cube = np.broadcast_to(irf, d_flat.shape)
gc, sc = phasor.calibrate_pixelwise(g, s, np.ascontiguousarray(irf_cube))
gc, sc = gc[0].astype(float), sc[0].astype(float)
if bin_corrected:
tau = discrete_phasor_lifetime(gc, sc, n, laser_period)
else:
tau = phasor.compute_lifetime(gc, sc)
empty = d_flat.sum(axis=-1) <= 0
tau = np.where(empty, np.nan, tau)
return tau.reshape(spatial)
[docs]
def fit_free_lifetime_maps(
decay: np.ndarray,
irf: np.ndarray,
laser_period: float,
mask: np.ndarray | None = None,
background_per_bin: float = 0.0,
) -> dict[str, np.ndarray]:
"""
All fit-free lifetime maps for an image cube, ready for ``CVPlot`` /
``FactorAnalysis``: ``{"tau_mean_arrival", "tau_mean_arrival_naive",
"tau_phasor", "tau_phasor_bin_corrected"}``, each ``(H, W)`` with NaN outside
`mask`.
`irf` may be a shared ``(T,)`` IRF or the ``(H, W, T)`` cube, in which case the
mean IRF over `mask` is used.
"""
decay = np.asarray(decay, dtype=float)
mask = decay.sum(axis=-1) > 0 if mask is None else np.asarray(mask, dtype=bool)
irf = np.asarray(irf, dtype=float)
if irf.ndim == 3:
irf = irf[mask].mean(axis=0)
sel = decay[mask]
estimates = {
"tau_mean_arrival": mean_arrival_lifetime(
sel, irf, laser_period, background_per_bin
),
"tau_mean_arrival_naive": mean_arrival_time_naive(
sel, irf, laser_period, background_per_bin
),
"tau_phasor": phasor_lifetime(sel, irf, laser_period, background_per_bin),
"tau_phasor_bin_corrected": phasor_lifetime(
sel, irf, laser_period, background_per_bin, bin_corrected=True
),
}
maps = {}
for key, values in estimates.items():
full = np.full(mask.shape, np.nan)
full[mask] = values
maps[key] = full
return maps