Source code for pyfli.simulator.calibration.config
"""Inputs and constants for calibrating the pyfli simulator against an experimental cube."""
from dataclasses import dataclass, field
DETECTOR_TYPES = ("continuous", "discrete")
MODEL_TYPES = ("bi_exponential", "mono_exponential")
REFERENCE_SOURCES = ("auto", "alligator", "spcimage", "pyfli")
# pyfli.simulator constants that the calibration has to respect.
# Only the "separate" family (ContinuousSimulator / PhotonCountSimulator) supports a Beta
# prior on tau2; the "combined" family draws tau2 from a truncated normal.
SIM_FAMILY = "separate"
SIM_MU_PER_CYCLE = 0.01 # BaseDiscreteSimulator: expected photons per laser cycle
SIM_MIN_CYCLES = 1000 # BaseFLIEngine._normalize_cycles_range lower bound
SIM_FRACTION_SCALE = 0.998 # FLIModelSimulator: E / A1 = Beta * 0.998 + 0.001
SIM_FRACTION_OFFSET = 0.001
SIM_QE_RANGE = {
"continuous": (0.15, 0.35),
"discrete": (0.70, 0.90),
} # ParameterSampler.sample_qe
[docs]
def normalize_model_type(model_type: str) -> str:
key = model_type.lower().replace("-", "_").replace(" ", "_")
aliases = {
"bi": "bi_exponential",
"bi_exponential": "bi_exponential",
"biexponential": "bi_exponential",
"mono": "mono_exponential",
"mono_exponential": "mono_exponential",
"monoexponential": "mono_exponential",
}
if key not in aliases:
raise ValueError(f"model_type must be one of {MODEL_TYPES}, got {model_type!r}")
return aliases[key]
[docs]
def normalize_detector_type(detector_type: str) -> str:
key = detector_type.lower().strip()
key = {"continous": "continuous", "tcspc": "discrete", "iccd": "continuous"}.get(
key, key
)
if key not in DETECTOR_TYPES:
raise ValueError(
f"detector_type must be one of {DETECTOR_TYPES}, got {detector_type!r}"
)
return key
[docs]
@dataclass
class PixelSelectionCriteria:
"""
Thresholds that decide whether a pixel carries sample fluorescence or only
detector/background noise. See :mod:`pixel_selection` for the tests.
"""
min_signal_photons: float = 50.0 # background-subtracted counts per pixel
snr_min: float = 5.0 # signal / sqrt(total): Poisson significance above background
flatness_sigma: float = (
5.0 # reject decays statistically consistent with a flat (dark) trace
)
peak_window_bins: int | None = (
None # |peak - global peak| tolerance; None -> n_bins // 8
)
use_otsu: str = "auto" # "auto" | "on" | "off"; auto applies Otsu only to clearly bimodal images
otsu_min_separability: float = 0.6
hot_pixel_factor: float = 10.0 # intensity > factor * 3x3 median -> hot pixel
min_component_size: int = 9 # drop isolated specks smaller than this (pixels)
refine_user_mask: bool = (
False # True: intersect a provided mask with the automatic criteria
)
[docs]
@dataclass
class CalibrationInputs:
"""
Everything the calibration needs.
Parameters
----------
data_path : str
Raw 3D decay data (.sdt, .ptu, .tif, .npy, ...), loaded via
``pyfli.io.DataOperations``.
detector_type : str
"continuous" (gated/ICCD-like) or "discrete" (TCSPC photon counting).
model_type : str
"bi_exponential" or "mono_exponential".
irf_path : str | None
Experimental IRF. When omitted the IRF is simulated with
``pyfli.simulator.IRFGenerator.gaussianExpIRF`` and its parameters are optimised.
mask_path : str | None
Boolean mask of sample pixels. When omitted, one is derived automatically.
reference_path : str | None
Optional parameter maps (AlliGator folder, SPCImage folder, or a pyfli
session .npy file / folder) used to initialise and regularise the priors.
target_coverage : float
Required fraction of experimental pixels inside the simulated distribution for
every coverage statistic (raw phasors h=1/2, photons, rising edge, background); see
:mod:`coverage`. After the best-fit calibration the config is widened until this is
reached, so that the experiment is a subset of what the simulator produces. 0 keeps
the best-fit config.
n_coverage_pixels : int
Simulated pixels per coverage check.
max_coverage_iter : int
Maximum number of widening steps.
"""
data_path: str
detector_type: str
model_type: str
irf_path: str | None = None
irf_mode: str = "auto" # "experimental" | "simulated" | "auto" (given IRF vs fitted EMG: better pooled fit wins)
bg_path: str | None = None
mask_path: str | None = None
reference_path: str | None = None
reference_source: str = "auto"
reference_alpha: str = "auto" # "intensity" | "amplitude" | "auto" (per source)
laser_freq_mhz: float = 80.0
bin_radius: int = (
0 # spatial binning (pyfli FLIBinner); 3 matches the *bin3* sessions
)
bit_depth: int | None = None # continuous only; None -> inferred from the data
jitter: bool | str = (
"auto" # simulator's +/-2 bin jitter; "auto": off when the measured edge spread is too narrow
)
mono_fraction: float | None = None # None -> 0 for bi, 1 for mono
prior_inflation: float = (
1.0 # >1 widens the fitted Beta priors (variance multiplier)
)
optimize_irf: bool = True
optimize_noise: bool = True
optimize_priors: bool = True
reference_weight: float = (
0.05 # penalty keeping optimised prior means near the reference maps
)
n_exp_pixels: int = 20000 # experimental pixels used for summary statistics
n_sim_pixels: int = 2000 # simulated pixels per objective evaluation
n_fit_pixels: int = 1500 # pixels for the quick per-pixel fits (no reference maps)
n_validation_pixels: int = 6000
n_bank_pixels: int = 20000 # importance-weighted bank for the prior fit (stage 2)
bank_rounds: int = 3 # alternating prior (bank) / IRF-noise rounds
max_evals_stage1: int = (
120 # IRF + detector noise, real simulations (later rounds use half)
)
max_evals_stage2: int = (
1500 # Beta priors on the re-weighted bank (~ms each), per round
)
target_coverage: float = 0.99
n_coverage_pixels: int = 20000
max_coverage_iter: int = 10
seed: int = 0
output_dir: str | None = None
selection: PixelSelectionCriteria = field(default_factory=PixelSelectionCriteria)
def __post_init__(self) -> None:
self.detector_type = normalize_detector_type(self.detector_type)
self.model_type = normalize_model_type(self.model_type)
if self.reference_source not in REFERENCE_SOURCES:
raise ValueError(f"reference_source must be one of {REFERENCE_SOURCES}")
if self.mono_fraction is None:
self.mono_fraction = 1.0 if self.model_type == "mono_exponential" else 0.0
@property
def laser_period_ns(self) -> float:
return 1000.0 / self.laser_freq_mhz
@property
def is_bi(self) -> bool:
return self.model_type == "bi_exponential"