Source code for pyfli.simulator.sim_engine_common

# pyfli/simulator/sim_engine_common.py

"""
Shared IRF/timing setup, cycles-range validation, and TCSPC photon-binning logic
for the sampling engines wrapped by the "combined" and "separate" simulator pairs.

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

:class:`~pyfli.simulator.combined.simulator_engine.FLIEngine` and
:class:`~pyfli.simulator.separate.model_simulator.FLIModelSimulator` both wrap a
single IRF into per-pixel decay/parameter sampling and TCSPC photon-histogram
simulation; they differ in how lifetime parameters are sampled and how many
exponential components the analytical decay supports. :class:`BaseFLIEngine`
owns the parts that are identical between them — IRF validation, timing setup,
``n_cycles`` range normalization, the steady-state pulse-repetition mixing
fraction, and the IRF-convolution/pile-up/binning tail of the TCSPC simulation —
so that logic is defined exactly once.
"""

from typing import Any

import numpy as np

from .noise_models import NoiseEngine
from .sim_helper import irf_picker


[docs] class BaseFLIEngine: """ Shared setup and helpers for FLI sampling engines. Parameters ---------- irf_full : np.ndarray Full instrument response function sampled over the decay window. laser_feq : int Laser repetition frequency used by the simulation. seed : int | None Seed for reproducible random sampling. """ def __init__( self, irf_full: np.ndarray, laser_feq: int = 80, seed: int | None = None, ) -> None: irf, _ = irf_picker(irf_full) # Timing and Normalization irf_sum = irf.sum() if not np.isfinite(irf_sum) or irf_sum <= 0: raise ValueError( f"Invalid IRF: sum={irf_sum}. IRF must be non-negative and non-zero." ) self.irf = irf / irf_sum self.rng = np.random.default_rng(seed) self.laser_period = 1000 / laser_feq # N bins each of width dt covering [0, laser_period); endpoint-exclusive self.dt = self.laser_period / len(self.irf) self.t = np.arange(len(self.irf)) * self.dt @staticmethod def _normalize_cycles_range(n_cycles: int | tuple[int, int]) -> tuple[int, int]: """ Normalizes ``n_cycles`` into a validated ``(low, high)`` range. A bare int is the upper bound, with the lower bound fixed at 1000. A ``(low, high)`` tuple sets both bounds explicitly. Both bounds must be >= 1000, and ``high`` must be >= ``low``. """ if isinstance(n_cycles, (int, np.integer)): cycles_range = (1000, int(n_cycles)) else: low_cycles, high_cycles = n_cycles cycles_range = (int(low_cycles), int(high_cycles)) if cycles_range[0] < 1000 or cycles_range[1] < 1000: raise ValueError(f"n_cycles bounds {cycles_range} must both be >= 1000.") if cycles_range[1] < cycles_range[0]: raise ValueError( f"n_cycles upper bound ({cycles_range[1]}) must be >= " f"lower bound ({cycles_range[0]})." ) return cycles_range @staticmethod def _steady_state_mix( A1: float, A2: float, tau1: float, tau2: float, laser_period: float ) -> float: """ Steady-state pulse-repetition mixing fraction for a two-component decay: the amplitude-weighted, per-pulse-buildup share of component 1 among both components' emitted photons. """ denom = A1 + A2 return A1 / denom if denom > 0 else A1 def _bin_tcspc_photons(self, times: np.ndarray) -> Any: """ Convolves photon emission times with the IRF (via inverse-transform sampling of the IRF's CDF), applies the pile-up/repetitive-excitation window filter, and bins the result into a TCSPC histogram. """ total_photons = len(times) irf_cdf = np.cumsum(self.irf) irf_cdf[-1] = 1.0 # pin to exactly 1.0 so searchsorted never returns len(irf) irf_shifts = np.searchsorted(irf_cdf, self.rng.random(total_photons)) * self.dt pileup_mode = self.params_cfg.get("pileup_mode", "wrap") arrival_times = NoiseEngine.tcspc_pileup_filter( times + irf_shifts, self.laser_period, mode=pileup_mode ) bins = (arrival_times / self.dt).astype(np.int32) hist = np.bincount(bins[bins < len(self.t)], minlength=len(self.t)) return hist.astype(np.float64)