Source code for pyfli.simulator.calibration_engine

"""
Calibrate simulator hardware parameters against experimental decay cubes.

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

import json
import os
from typing import Any

import matplotlib.pyplot as plt

# simulator/calibration_engine.py
import numpy as np
from scipy import stats
from scipy.optimize import minimize

from pyfli import logging

from .combined.sim_image_generator import FLIImageGenerator
from .sim_calibrator import FLIValidator


[docs] class FLICalibrator: """ Estimate simulator hardware parameters from experimental decay cubes. It optimizes noise and detector settings, reports calibration quality, cross-validates results, and can save reusable hardware profiles. Parameters ---------- irf_data : np.ndarray Instrument response data used to convolve or simulate decays. method : str Algorithm or model-selection method to use. threshold : int Threshold applied to counts, masks, or statistics. normalize_stats : bool Whether calibration statistics are normalized before comparison. """ def __init__( self, irf_data: np.ndarray, method: str = "analytical", threshold: int = 10, normalize_stats: bool = False, ) -> None: self.irf_data = irf_data self.method = method.lower() self.threshold = threshold self.normalize_stats = normalize_stats self.validator = FLIValidator(method=self.method, threshold=self.threshold) self.iteration = 0 self.opt_params = None def _get_valid_counts(self, data_cube: np.ndarray) -> np.ndarray: """ Return valid counts. Parameters ---------- data_cube : np.ndarray Array cube processed by the routine. Returns ------- np.ndarray Mask or count array identifying valid simulated pixels. """ _, counts = self.validator._preprocess_cube(data_cube) return counts
[docs] def objective_function( self, x: np.ndarray, exp_decay_cube: np.ndarray, base_cfg: np.ndarray ) -> Any: """ Run the objective function routine. Parameters ---------- x : np.ndarray Input array, coordinate, or signal being transformed. exp_decay_cube : np.ndarray Experimental decay cube used for calibration. base_cfg : np.ndarray Base simulator configuration copied during calibration. Returns ------- Any Object produced by objective function. """ self.iteration += 1 current_cfg = base_cfg.copy() # x = [DCR, Read_Sigma, Intensity_Alpha] current_cfg["dcr"] = x[0] current_cfg["read_sigma"] = x[1] pc_key = "pc" if "pc" in base_cfg else "photo_count" orig_pc = list(base_cfg.get(pc_key, (8, 2))) current_cfg[pc_key] = (x[2], orig_pc[1]) gen = FLIImageGenerator( self.irf_data, image_shape=(32, 32), roi_params=[current_cfg], method=self.method, verbose=False, ) sim_dataset = gen.generate_image() try: results = self.validator.run_comprehensive_test( sim_dataset, exp_decay_cube, normalize=self.normalize_stats ) if results is None: return 2.0 p_val = results["ks_p_value"] loss = (1.0 - p_val) + (1.0 - results["hist_intersection"]) return loss except Exception: return 2.0
[docs] def display_report(self, results: Any) -> None: """ Display report. Parameters ---------- results : Any Calibration, fitting, or validation results. Returns ------- None No object is returned; the function display report. """ if results is None: logging.info("No results to display.") return logging.info("\n" + "=" * 60) logging.info( f"STATISTICAL VALIDATION REPORT (N={results['sample_size']} Pixels)" ) logging.info("=" * 60) logging.info(f"{'Metric':<25} | {'Value':<15} | {'Target'}") logging.info("-" * 60) logging.info( f"{'Cosine Similarity':<25} | {results['cosine_similarity']:<15.4f} | >0.99" ) logging.info( f"{'KL Divergence':<25} | {results['kl_divergence']:<15.4f} | <0.01" ) logging.info(f"{'KS P-Value':<25} | {results['ks_p_value']:<15.4e} | >0.05") logging.info( f"{'Hist Intersection':<25} | {results['hist_intersection']:<15.4f} | -> 1.0" ) logging.info("=" * 60 + "\n") fig, axes = plt.subplots(1, 2, figsize=(15, 5)) axes[0].plot(results["sim_vec"], label="Simulated (Mean)", lw=2) axes[0].plot(results["exp_vec"], label="Experimental (Mean)", ls="--", lw=2) axes[0].set_yscale("log") axes[0].set_title("Temporal Profile Fidelity") axes[0].set_xlabel("Time Bin") axes[0].set_ylabel("Normalized Intensity") axes[0].grid(True, which="both", alpha=0.3) axes[0].legend() axes[1].hist( results["sim_counts"], bins=50, alpha=0.5, label="Simulated", density=True, color="tab:blue", ) axes[1].hist( results["exp_counts"], bins=50, alpha=0.5, label="Experimental", density=True, color="tab:orange", ) axes[1].set_title("Integrated Intensity PDF") axes[1].set_xlabel("Photon Counts (Integrated)") axes[1].set_ylabel("Probability Density") axes[1].legend() plt.tight_layout() plt.show()
[docs] def run_calibration( self, exp_decay_cube: np.ndarray, base_config: np.ndarray, initial_guess: np.ndarray | None = None, ) -> np.ndarray: """ Run calibration. Parameters ---------- exp_decay_cube : np.ndarray Experimental decay cube used for calibration. base_config : np.ndarray Base simulator configuration used for calibration or sensitivity analysis. initial_guess : np.ndarray | None Initial optimizer parameter vector. Returns ------- np.ndarray Calibration results for the configured simulator. """ logging.info( f"--- Starting Calibration: {self.method.upper()} (Norm: {self.normalize_stats}) ---" ) self.iteration = 0 pc_key = "pc" if "pc" in base_config else "photo_count" _, exp_counts = self.validator._preprocess_cube(exp_decay_cube) if initial_guess is None: # Smart guess for intensity alpha based on mean counts rough_alpha = ( np.mean(exp_counts) / 10 if self.method == "analytical" else 5.0 ) initial_guess = [0.01, 1.2, rough_alpha] # REVISED BOUNDS: Lowered DCR max to 0.1 to prevent overfitting in low-signal regimes bounds = [(0, 0.1), (0, 10.0), (0.1, 1000.0)] res = minimize( self.objective_function, x0=initial_guess, args=(exp_decay_cube, base_config), bounds=bounds, method="L-BFGS-B", tol=1e-2, ) self.opt_params = { "dcr": float(res.x[0]), "read_sigma": float(res.x[1]), pc_key: (float(res.x[2]), float(base_config[pc_key][1])), } final_cfg = base_config.copy() final_cfg.update(self.opt_params) logging.info("\nGenerating Final Optimized Calibration Report...") gen = FLIImageGenerator( self.irf_data, image_shape=(32, 32), roi_params=[final_cfg], method=self.method, ) final_sim = gen.generate_image() metrics = self.validator.run_comprehensive_test( final_sim, exp_decay_cube, normalize=self.normalize_stats ) self.display_report(metrics) return final_cfg
[docs] def cross_validate( self, calibrated_cfg: np.ndarray, test_exp_cube: np.ndarray ) -> np.ndarray: """ Run the cross validate routine. Parameters ---------- calibrated_cfg : np.ndarray Calibrated simulator configuration used for validation. test_exp_cube : np.ndarray Experimental decay cube used for cross-validation. Returns ------- np.ndarray Cross-validation scores for the calibration model. """ logging.info(f"\n--- Cross-Validation (Norm: {self.normalize_stats}) ---") gen = FLIImageGenerator( self.irf_data, image_shape=test_exp_cube.shape[:2], roi_params=[calibrated_cfg], method=self.method, ) sim_dataset = gen.generate_image() metrics = self.validator.run_comprehensive_test( sim_dataset, test_exp_cube, normalize=self.normalize_stats ) self.display_report(metrics) return metrics
[docs] def save_hardware_profile(self, filename: str = "hw_profile.json") -> None: """ Save hardware profile. Parameters ---------- filename : str File name used for saving or loading results. Returns ------- None No object is returned; the function save hardware profile. """ if self.opt_params is None: return profile = { "method": self.method, "threshold": self.threshold, "normalize_used": self.normalize_stats, "params": self.opt_params, } with open(filename, "w") as f: json.dump(profile, f, indent=4) logging.info(f"Profile saved to {filename}")
[docs] @staticmethod def load_hardware_profile(filename: str) -> Any: """ Load hardware profile. Parameters ---------- filename : str File name used for saving or loading results. Returns ------- Any Object produced by load hardware profile. """ if not os.path.exists(filename): return None with open(filename) as f: return json.load(f)
[docs] def plot_noise_sensitivity( self, train_exp_cube: np.ndarray, base_config: np.ndarray, dcr_range: tuple[float, float, int] = (0.001, 0.1, 10), sigma_range: tuple[float, float, int] = (0.5, 4.0, 10), ) -> tuple[Any, ...]: """ Plot noise sensitivity. Parameters ---------- train_exp_cube : np.ndarray Array cube processed by the routine. base_config : np.ndarray Base simulator configuration used for calibration or sensitivity analysis. dcr_range : tuple[float, float, int] Dark-count-rate values evaluated during sensitivity analysis. sigma_range : tuple[float, float, int] Read-noise sigma values evaluated by the sensitivity plot. Returns ------- tuple[Any, ...] Tuple containing noise-sensitivity figure data and summary metrics. """ logging.info("Generating Sensitivity Surface (Processing...)") dcr_vals = np.linspace(*dcr_range) sigma_vals = np.linspace(*sigma_range) p_matrix = np.zeros((len(dcr_vals), len(sigma_vals))) target_counts = self._get_valid_counts(train_exp_cube) for i, dcr in enumerate(dcr_vals): for j, sigma in enumerate(sigma_vals): cfg = base_config.copy() cfg["dcr"], cfg["read_sigma"] = dcr, sigma gen = FLIImageGenerator( self.irf_data, image_shape=(32, 32), roi_params=[cfg], method=self.method, verbose=False, ) sim_data = gen.generate_image() sim_counts = self._get_valid_counts(sim_data["raw_data"]["decay"]) _, p_val = stats.ks_2samp(sim_counts, target_counts) p_matrix[i, j] = p_val fig, ax = plt.subplots(figsize=(9, 7)) im = ax.imshow( p_matrix, extent=[sigma_vals[0], sigma_vals[-1], dcr_vals[0], dcr_vals[-1]], origin="lower", aspect="auto", cmap="magma", ) fig.colorbar(im, ax=ax, label="KS P-Value") ax.set_xlabel("Read Noise Sigma") ax.set_ylabel("DCR") if self.opt_params: ax.scatter( self.opt_params["read_sigma"], self.opt_params["dcr"], color="cyan", marker="x", s=100, label="Optimal Point", ) ax.legend() plt.show() return fig, p_matrix