Source code for pyfli.simulator.combined.simulator_engine

# simulator/combined/simulator_engine
"""
Drive the main FLI parameter sampler and TCSPC simulation engine.

This module belongs to :mod:`pyfli.simulator.combined` and is part of PyFLI synthetic
FLI/FLIM data generation, hardware noise modeling, calibration, and validation tools.
Public API includes classes :class:`FLIEngine`.

Shared IRF/timing setup, ``n_cycles`` range validation, and TCSPC photon-binning logic
live in :mod:`pyfli.simulator.sim_engine_common`.
"""

from typing import Any

import numpy as np

from ...reconstruction.common_reconstruct import bi_reconstruction
from ..distributions import ParameterSampler
from ..sim_engine_common import BaseFLIEngine


[docs] class FLIEngine(BaseFLIEngine): """ Run the fliengine routine. analytical decays, and simulates TCSPC counts from a shared IRF and configuration. Parameters ---------- irf_full : np.ndarray Full instrument response function sampled over the decay window. tau2 : tuple[int, float] Long lifetime component. efficiency : tuple[int, ...] FRET transfer efficiency used to derive simulated lifetime components. A1_fraction : tuple[int, ...] Amplitude fraction assigned to the first exponential component. photo_count : tuple[float, int] Expected photon count used to scale the simulated decay. mono_fraction : float Fraction of pixels or events assigned to the mono-exponential component. bit : int Bit depth or quantization setting for simulated detector output. n_cycles : int | tuple[int, int] Number of excitation cycles used when constructing the simulated decay. A bare int is the upper bound (lower bound fixed at 1000); a ``(low, high)`` tuple sets both bounds, and both must be >= 1000. The per-call cycle count is drawn from a Beta distribution (shaped by ``photo_count``) over that range. dcr : float Detector dark-count rate used by the noise model. laser_feq : int Laser repetition frequency used by the simulation. pileup_mode : str 'wrap' (default) folds photons back via modulo; 'truncate' drops them. seed : int | None Seed for reproducible random sampling. **kwargs : Any Additional keyword arguments forwarded to the underlying implementation. """ def __init__( self, irf_full: np.ndarray, tau2: tuple[int, float] = (1, 0.5), efficiency: tuple[int, ...] = (5, 5), A1_fraction: tuple[int, ...] = (5, 5), photo_count: tuple[float, int] = (1.0, 1.0), mono_fraction: float = 0.2, bit: int = 8, n_cycles: int | tuple[int, int] = 800_000, dcr: float = 0.05, laser_feq: int = 80, pileup_mode: str = "wrap", seed: int | None = None, **kwargs: Any, ) -> None: super().__init__(irf_full, laser_feq=laser_feq, seed=seed) cycles_range = self._normalize_cycles_range(n_cycles) # Parameters Storage self.params_cfg = { "tau2": tau2, "eff": efficiency, "A1": A1_fraction, "pc": photo_count, "mono": mono_fraction, "bit": bit, "cycles": cycles_range, "dcr": dcr, "pileup_mode": pileup_mode, **kwargs, }
[docs] def sample_all_params(self) -> dict[Any, Any]: """Samples lifetime and fraction parameters for a single pixel.""" t2 = ParameterSampler.truncated_normal(*self.params_cfg["tau2"]) is_mono = self.rng.random() < self.params_cfg["mono"] if is_mono: if self.rng.random() < 0.9: E, A1 = 0.0, self.rng.uniform(0.99, 0.9999) else: E, A1 = self.rng.uniform(0.99, 1.0), self.rng.uniform(0.0001, 0.01) t1 = t2 * (1 - E) f = self._steady_state_mix(A1, 1.0 - A1, t1, t2, self.laser_period) return { "mono": True, "E": E, "f": f, "tau1": t1, "tau2": t2, "A1": A1, "A2": 1.0 - A1, } E = ParameterSampler.sample_beta( *self.params_cfg["eff"], scale=0.9, offset=0.1, rng=self.rng ) A1 = ParameterSampler.sample_beta( *self.params_cfg["A1"], scale=0.9, offset=0.05, rng=self.rng ) A2 = 1.0 - A1 t1 = t2 * (1 - E) f = self._steady_state_mix(A1, A2, t1, t2, self.laser_period) return { "mono": False, "E": E, "f": f, "tau1": t1, "tau2": t2, "A1": A1, "A2": A2, }
[docs] def get_analytical_decay(self, p: Any) -> Any: """ Return analytical decay. Parameters ---------- p : Any Detector parameter object or fitted parameter vector. Returns ------- Any Object produced by get analytical decay. """ T = self.laser_period # steady-state scaling factor per component scaling_factor1 = 1.0 / (1.0 - np.exp(-T / p["tau1"])) scaling_factor2 = 1.0 / (1.0 - np.exp(-T / p["tau2"])) return bi_reconstruction( self.t, p["tau1"], p["tau2"], p["A1"] * scaling_factor1, p["A2"] * scaling_factor2, )
[docs] def simulate_tcspc(self, p: Any, n_cycles: int, mu_per_cycle: np.ndarray) -> Any: """Photon-by-photon logic for TCSPC mode.""" total_photons = self.rng.poisson(mu_per_cycle * n_cycles) if total_photons == 0: return np.zeros_like(self.t) # Emission (Inverse Transform Sampling) comp1 = self.rng.random(total_photons) < p["f"] times = np.empty(total_photons) times[comp1] = self.rng.exponential(p["tau1"], size=comp1.sum()) times[~comp1] = self.rng.exponential(p["tau2"], size=(~comp1).sum()) return self._bin_tcspc_photons(times)