Source code for pyfli.analysis.precision.crb_precision

"""Cramér-Rao lower bound on mono-exponential lifetime precision for gated / TCSPC data."""

from typing import Any

import numpy as np
import pandas as pd

from pyfli.simulator.calibration.expected_decay import gated_bin_probabilities

from .fisher_crlb import crlb_cv, f_value, fisher_information, select_parameters

_CHUNK = 4096

PARAMS = ("N", "tau", "b")
"""Parameter order of :func:`gated_decay_model`: signal photons, lifetime, background."""
TAU_INDEX = PARAMS.index("tau")


[docs] def gated_decay_model( params: np.ndarray, irf: np.ndarray, laser_period: float ) -> np.ndarray: """ Expected counts per gate ``mu_k = N * p_k(tau) + b`` for parameter vectors ``params`` ``(P, 3)`` ordered as :data:`PARAMS` (``N``, ``tau``, ``b``); `irf` is ``(T,)`` or ``(P, T)``. This is the model handed to the generic :func:`.fisher_crlb.fisher_information`. """ params = np.atleast_2d(np.asarray(params, dtype=float)) n, tau, b = params[:, 0], params[:, 1], params[:, 2] p = gated_bin_probabilities(tau, irf, laser_period) return n[:, None] * p + b[:, None]
def _crb_cv_chunk( tau: np.ndarray, n: np.ndarray, b: np.ndarray, irf: np.ndarray, laser_period: float, fit_background: bool, ) -> np.ndarray: info = fisher_information( lambda theta: gated_decay_model(theta, irf, laser_period), np.column_stack([n, tau, b]), ) estimated = [PARAMS.index("N"), TAU_INDEX] if fit_background: estimated.append(PARAMS.index("b")) return crlb_cv(select_parameters(info, estimated), tau, estimated.index(TAU_INDEX))
[docs] def crb_lifetime_cv( tau: Any, n_photons: Any, irf: np.ndarray, laser_period: float, background_per_bin: Any = 0.0, fit_background: bool = False, ) -> np.ndarray: """ Ideal (Cramér-Rao) coefficient of variation of the lifetime, ``sigma_tau / tau``. The unknowns are the photon count ``N`` and ``tau`` (plus the background level when `fit_background` is True, which is what NLSF / MLE fits with a free offset ``v_shift`` do). The background level itself always enters the Poisson noise. Parameters ---------- tau, n_photons : float | np.ndarray True lifetime (ns) and expected number of detected *signal* photons; broadcast against each other. irf : np.ndarray IRF of shape ``(T,)``, or one IRF per value with shape ``S + (T,)``. laser_period : float Laser repetition period in ns. background_per_bin : float | np.ndarray Expected background (dark) counts per gate, e.g. the simulator's ``dcr``. fit_background : bool Treat the background level as an extra unknown parameter. Returns ------- np.ndarray Ideal CV with the broadcast shape of the inputs; NaN where ``tau <= 0``, ``N <= 0`` or an input is not finite. ``CV * sqrt(N)`` is the F-value. """ tau, n, b = np.broadcast_arrays( np.asarray(tau, dtype=float), np.asarray(n_photons, dtype=float), np.asarray(background_per_bin, dtype=float), ) irf = np.asarray(irf, dtype=float) shape = tau.shape tau_f, n_f, b_f = tau.ravel(), n.ravel(), b.ravel() irf_f = irf if irf.ndim == 1 else irf.reshape(-1, irf.shape[-1]) out = np.full(tau_f.shape, np.nan) valid = ( np.isfinite(tau_f) & np.isfinite(n_f) & np.isfinite(b_f) & (tau_f > 0) & (n_f > 0) ) idx = np.flatnonzero(valid) for start in range(0, idx.size, _CHUNK): sel = idx[start : start + _CHUNK] irf_sel = irf_f if irf_f.ndim == 1 else irf_f[sel] out[sel] = _crb_cv_chunk( tau_f[sel], n_f[sel], b_f[sel], irf_sel, laser_period, fit_background ) return out.reshape(shape)
[docs] def crb_f_value( tau: Any, irf: np.ndarray, laser_period: float, n_photons: Any = 1e4, background_per_bin: Any = 0.0, fit_background: bool = False, ) -> np.ndarray: """ Ideal F-value ``CV_ideal * sqrt(N)``. Without background it does not depend on `n_photons`; with background it rises as the signal-to-background ratio drops. """ cv = crb_lifetime_cv( tau, n_photons, irf, laser_period, background_per_bin, fit_background ) return f_value(cv, n_photons)
[docs] def crb_cv_map( tau_map: np.ndarray, n_photons_map: np.ndarray, irf: np.ndarray, laser_period: float, mask: np.ndarray | None = None, background_per_bin: float = 0.0, fit_background: bool = False, ) -> np.ndarray: """ Per-pixel ideal CV map. Parameters ---------- tau_map : np.ndarray True lifetime per pixel, shape ``(H, W)`` (e.g. the simulator's ground-truth ``maps["tau_map"]``). n_photons_map : np.ndarray Expected signal photons per pixel, shape ``(H, W)`` (e.g. the simulator's ``maps["photon_count_map"]``, or the decay sum minus ``T * background``). irf : np.ndarray Shared IRF ``(T,)`` or per-pixel IRF cube ``(H, W, T)``. laser_period : float Laser repetition period in ns. mask : np.ndarray | None Pixels to compute; others are NaN. Defaults to ``tau_map > 0``. background_per_bin, fit_background See :func:`crb_lifetime_cv`. Returns ------- np.ndarray ``(H, W)`` ideal CV map; multiply by ``sqrt(n_photons_map)`` for the F-value. """ tau_map = np.asarray(tau_map, dtype=float) mask = (tau_map > 0) if mask is None else np.asarray(mask, dtype=bool) irf = np.asarray(irf, dtype=float) out = np.full(tau_map.shape, np.nan) irf_sel = irf if irf.ndim == 1 else irf[mask] out[mask] = crb_lifetime_cv( tau_map[mask], np.asarray(n_photons_map, dtype=float)[mask], irf_sel, laser_period, background_per_bin, fit_background, ) return out
[docs] def crb_for_cv_bins( cv_df: pd.DataFrame, tau_map: np.ndarray, photon_map: np.ndarray, irf: np.ndarray, laser_period: float, mask: np.ndarray | None = None, cluster_mask: np.ndarray | None = None, cluster_names: dict[int, str] | None = None, n_photons_map: np.ndarray | None = None, background_per_bin: float = 0.0, fit_background: bool = False, ) -> pd.DataFrame: """ Ideal CV for every photon-count bin of a :meth:`pyfli.data_vnp.CVPlot.compute` table, so it can be plotted against (or merged with) the measured curves. Bin membership is rebuilt from the table's ``bin_low`` / ``bin_high`` edges on `photon_map` (the same photon map given to ``CVPlot``), per ``cluster`` when the table has clusters. Within a bin, the per-pixel CRB variances are averaged, so ``cv_crb = sqrt(mean((cv_i * tau_i) ** 2)) / mean(tau_i)`` -- the CV an ideal estimator would show across those pixels, excluding the true lifetime spread. Parameters ---------- cv_df : pd.DataFrame Output of ``CVPlot.compute`` (columns ``cluster``, ``bin_rank``, ``bin_low``, ``bin_high``, ``bin_center``). tau_map : np.ndarray True lifetime per pixel (ground truth), ``(H, W)``. photon_map : np.ndarray Photon map used for binning in ``CVPlot`` (e.g. ``decay.sum(axis=-1)``). irf, laser_period See :func:`crb_lifetime_cv`. mask : np.ndarray | None Pixel mask used in ``CVPlot``. cluster_mask, cluster_names The same cluster labels and names given to ``CVPlot``, if any. n_photons_map : np.ndarray | None Expected *signal* photons per pixel for the bound; defaults to `photon_map`. Pass the ground-truth ``photon_count_map`` to exclude dark counts. background_per_bin, fit_background See :func:`crb_lifetime_cv`. Returns ------- pd.DataFrame One row per (cluster, bin): ``cluster``, ``bin_rank``, ``bin_center``, ``tau_true``, ``n_signal``, ``cv_crb`` and ``F_crb`` (``cv_crb * sqrt(bin_center)``, directly comparable with a measured ``cv * sqrt(bin_center)``). Merge with the measured table on ``["cluster", "bin_rank"]``. """ tau_map = np.asarray(tau_map, dtype=float) photon_map = np.asarray(photon_map, dtype=float) base = (tau_map > 0) if mask is None else np.asarray(mask, dtype=bool) n_map = photon_map if n_photons_map is None else np.asarray(n_photons_map, float) cv_map = crb_cv_map( tau_map, n_map, irf, laser_period, base, background_per_bin, fit_background ) name_to_id = {v: k for k, v in (cluster_names or {}).items()} bins = cv_df.drop_duplicates(subset=["cluster", "bin_rank"]) rows = [] for _, r in bins.iterrows(): sel = base & np.isfinite(photon_map) cluster = r["cluster"] if cluster is not None and not pd.isna(cluster) and cluster_mask is not None: cid = name_to_id.get(cluster, cluster) sel = sel & (np.asarray(cluster_mask) == cid) in_bin = (photon_map > r["bin_low"]) & (photon_map <= r["bin_high"]) if r["bin_rank"] == 0: in_bin |= photon_map == r["bin_low"] sel = sel & in_bin & np.isfinite(cv_map) if not np.any(sel): continue tau_i, cv_i = tau_map[sel], cv_map[sel] cv_bin = np.sqrt(np.mean((cv_i * tau_i) ** 2)) / np.mean(tau_i) rows.append( { "cluster": cluster, "bin_rank": r["bin_rank"], "bin_center": r["bin_center"], "tau_true": float(np.mean(tau_i)), "n_signal": float(np.mean(n_map[sel])), "cv_crb": float(cv_bin), "F_crb": float(f_value(cv_bin, r["bin_center"])), } ) return pd.DataFrame(rows)