Source code for pyfli.simulator.separate.model_simulator

# simulator/separate/model_simulator
"""
Generate mono- and bi-exponential model parameters and simulated TCSPC observations.

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

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, mono_reconstruction
from ..distributions import ParameterSampler
from ..sim_engine_common import BaseFLIEngine


[docs] class FLIModelSimulator(BaseFLIEngine): """ Run the flimodel simulator routine. observations. The class is useful for controlled model-generation experiments independent of image geometry. Parameters ---------- irf_full : np.ndarray Full instrument response function sampled over the decay window. tau2 : tuple[int, float] Long lifetime component. tau2_dist : str Distribution used to sample donor-only lifetimes. tau2_beta_range : tuple[float, ...] Shape-parameter range for beta-distributed tau2 values. 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), tau2_dist: str = "normal", # 'normal' -> truncated_normal | 'beta' -> sample_beta tau2_beta_range: tuple[float, ...] = ( 4.8, 0.2, ), # (scale, offset), only used when tau2_dist='beta' 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, "tau2_dist": tau2_dist, "tau2_beta_range": tau2_beta_range, "eff": efficiency, "A1": A1_fraction, "pc": photo_count, "mono": mono_fraction, "bit": bit, "cycles": cycles_range, "dcr": dcr, "pileup_mode": pileup_mode, **kwargs, } def _sample_tau2(self) -> Any: """Draws tau2 from either a truncated normal or a beta prior, per tau2_dist.""" if self.params_cfg["tau2_dist"] == "beta": scale, offset = self.params_cfg["tau2_beta_range"] t2 = ParameterSampler.sample_beta( *self.params_cfg["tau2"], scale=scale, offset=offset, rng=self.rng ) else: t2 = ParameterSampler.truncated_normal(*self.params_cfg["tau2"]) # Guard against a zero (or negative) lifetime, which would blow up any 1/tau2 term downstream return max(t2, 1e-3)
[docs] def sample_mono_params(self) -> dict[Any, Any]: """Samples the lifetime parameter for a single pixel (pure single-exponential).""" t2 = self._sample_tau2() return {"mono": True, "tau": t2}
[docs] def sample_bi_params(self) -> dict[Any, Any]: """Samples lifetime and fraction parameters for a single pixel.""" t2 = self._sample_tau2() E = ParameterSampler.sample_beta( *self.params_cfg["eff"], scale=0.998, offset=0.001, rng=self.rng ) A1 = ParameterSampler.sample_beta( *self.params_cfg["A1"], scale=0.998, offset=0.001, 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 sample_params(self) -> Any: """ Sample params. Returns ------- Any Object produced by sample params. """ if self.rng.random() < self.params_cfg["mono"]: return self.sample_mono_params() return self.sample_bi_params()
[docs] def get_model_analytical_decay(self, p: Any) -> Any: """ Return model analytical decay. Parameters ---------- p : Any Detector parameter object or fitted parameter vector. Returns ------- Any Object produced by get model analytical decay. """ T = self.laser_period if p.get("mono", False): tau = p["tau"] scaling_factor = 1.0 / (1.0 - np.exp(-T / tau)) return mono_reconstruction(self.t, tau, scaling_factor) tau1, tau2 = p["tau1"], p["tau2"] scaling_factor1 = 1.0 / (1.0 - np.exp(-T / tau1)) scaling_factor2 = 1.0 / (1.0 - np.exp(-T / tau2)) return bi_reconstruction( self.t, tau1, tau2, p["A1"] * scaling_factor1, p["A2"] * scaling_factor2 )
[docs] def simulate_model_tcspc( self, p: Any, n_cycles: int, mu_per_cycle: np.ndarray ) -> Any: """ Simulate model TCSPC. Parameters ---------- p : Any Detector parameter object or fitted parameter vector. n_cycles : int Number of simulated laser cycles. mu_per_cycle : np.ndarray Expected photons per laser cycle in the TCSPC simulation. Returns ------- Any Object produced by simulate model TCSPC. """ total_photons = self.rng.poisson(mu_per_cycle * n_cycles) if total_photons == 0: return np.zeros_like(self.t) # Emission (Inverse Transform Sampling) times = np.empty(total_photons) if p.get("mono", False): times[:] = self.rng.exponential(p["tau"], size=total_photons) else: comp1 = self.rng.random(total_photons) < p["f"] 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)