Source code for pyfli.simulator.calibration.expected_decay

"""Analytic noise-free expected decay of the pyfli simulator for given lifetimes and IRF."""

from typing import Any

import numpy as np
from scipy.signal import fftconvolve

from pyfli.reconstruction.common_reconstruct import periodic_bin_exponential

from ..irf_sim.irf_generator import IRFGenerator


[docs] def wrapped_gate_exponential(tau: Any, n_bins: int, laser_period: float) -> np.ndarray: """ Probability that a photon emitted with an exponential delay of lifetime(s) `tau` (ns, any shape ``S``) falls in each of `n_bins` equal gates covering one laser period, with delays wrapped modulo the period (:func:`pyfli.reconstruction.common_reconstruct.periodic_bin_exponential`). Returns ``S + (n_bins,)``, summing to 1 along the last axis. """ return periodic_bin_exponential(tau, n_bins, laser_period)
[docs] def gated_bin_probabilities( tau: Any, irf: np.ndarray, laser_period: float ) -> np.ndarray: """ Expected per-gate photon distribution of ``PhotonCountSimulator`` for lifetime(s) `tau`: the wrapped, gate-integrated exponential (:func:`wrapped_gate_exponential`) circularly convolved with the IRF. Parameters ---------- tau : float | np.ndarray Lifetime(s) in ns; any shape ``S``. irf : np.ndarray IRF of shape ``(T,)`` (shared) or ``S + (T,)`` (one per lifetime); normalised to unit sum here. laser_period : float Laser repetition period in ns; the ``T`` gates cover ``[0, laser_period)``. Returns ------- np.ndarray Array of shape ``S + (T,)`` whose last axis sums to 1. """ irf = np.asarray(irf, dtype=float) irf = irf / irf.sum(axis=-1, keepdims=True) n = irf.shape[-1] p_emit = wrapped_gate_exponential(tau, n, laser_period) p = np.fft.irfft( np.fft.rfft(p_emit, axis=-1) * np.fft.rfft(irf, axis=-1), n=n, axis=-1 ) return np.clip(p, 0.0, None)
[docs] class DecayForwardModel: def __init__( self, n_bins: int, period_ns: float, detector_type: str, jitter: bool = True, inactive_bins=(), ) -> None: self.n_bins = n_bins self.period = period_ns self.dt = period_ns / n_bins self.t = np.arange(n_bins) * self.dt self.circular = detector_type == "discrete" self.jitter = jitter self.active = np.ones(n_bins, dtype=bool) self.active[np.asarray(inactive_bins, dtype=int)] = False @property def n_active(self) -> int: return int(self.active.sum()) # ------------------------------------------------------------------ IRF
[docs] def simulated_irf(self, mu: float, sigma_ns: float, tail_ns: float) -> np.ndarray: """Peak-normalised exponentially modified Gaussian IRF (pyfli ``IRFGenerator``).""" return IRFGenerator.gaussianExpIRF( mu=mu, T=self.period, num_bins=self.n_bins, sigma=sigma_ns, tau=max(tail_ns, 1e-3), )
[docs] @staticmethod def with_baseline(irf_peak1: np.ndarray, b: float) -> np.ndarray: """What ``OffsetGen`` does to the IRF (``+ b``) followed by the simulator's sum-normalisation.""" irf = irf_peak1 + b return irf / irf.sum()
# ------------------------------------------------------------- lifetimes
[docs] def components(self, taus, fracs) -> np.ndarray: taus = np.clip(np.atleast_1d(np.asarray(taus, float)), 1e-3, None) fracs = np.atleast_1d(np.asarray(fracs, float)) if self.circular: p = wrapped_gate_exponential(taus, self.n_bins, self.period) else: tt = taus[:, None] sf = 1.0 / (1.0 - np.exp(-self.period / tt)) p = sf / tt * np.exp(-self.t[None, :] / tt) return (fracs[:, None] * p).sum(0)
def _jitter_average(self, y: np.ndarray) -> np.ndarray: if not self.jitter: return y out = np.zeros_like(y) n = len(y) for s in range( -2, 3 ): # BaseDiscrete/ContinuousSimulator: shift ~ U{-2..2}, zero filled if s > 0: out[s:] += y[: n - s] elif s < 0: out[:s] += y[-s:] else: out += y return out / 5.0
[docs] def shape(self, irf_norm: np.ndarray, taus, fracs) -> np.ndarray: """Expected decay shape (sums to 1 over the active bins) for a sum-normalised IRF.""" if self.circular: taus = np.clip(np.atleast_1d(np.asarray(taus, float)), 1e-3, None) fracs = np.atleast_1d(np.asarray(fracs, float)) y = fracs @ gated_bin_probabilities(taus, irf_norm, self.period) else: y = fftconvolve(self.components(taus, fracs), irf_norm, mode="full")[ : self.n_bins ] y = self._jitter_average(np.clip(y, 0, None)) * self.active s = y.sum() return y / s if s > 0 else self.active / self.n_active
[docs] def flat(self) -> np.ndarray: """Flat background spread over the active bins (sums to 1).""" return self.active / self.n_active