Source code for pyfli.phasor.phasorSEPL.lifetimes

"""
lifetimes.py
============
Extract fluorescence lifetimes from phasor coordinates (g, s).

For single-exponential species on the universal semicircle, two equivalent
estimators exist — phase lifetime and modulus lifetime.  For species off
the semicircle (multi-exponential, gated, truncated), both estimators are
biased and corrected expressions must be used (Michalet 2021, Sec. VII).

References
----------
    Michalet X., AIP Advances 11, 035331 (2021), Sec. VII.
    ISS Technical Note: "FLIM Analysis using the Phasor Plots" Eq. 10.
"""

from typing import Any

import numpy as np
from numpy.typing import ArrayLike, NDArray

from .config import AcquisitionConfig

# ──────────────────────────────────────────────────────────────────────────────
# Standard estimators
# ──────────────────────────────────────────────────────────────────────────────


[docs] def phase_lifetime( g: ArrayLike, s: ArrayLike, cfg: AcquisitionConfig, ) -> NDArray[np.float64]: """ Phase (angular) lifetime from phasor coordinates. Derived from φ = arctan(s/g) and tan(φ) = ωτ_φ: τ_φ = tan(φ) / ω = s / (g · ω) Valid exactly for single-exponential species on the universal semicircle. Parameters ---------- g, s : array_like Phasor coordinates. cfg : AcquisitionConfig Requires: omega. Returns ------- tau_phi : ndarray (ns) """ g = np.asarray(g, dtype=float) s = np.asarray(s, dtype=float) return s / (g * cfg.omega)
[docs] def modulus_lifetime( g: ArrayLike, s: ArrayLike, cfg: AcquisitionConfig, ) -> NDArray[np.float64]: """ Modulus (demodulation) lifetime from phasor coordinates. Derived from m = |z| = 1/√(1 + ω²τ_m²): τ_m = √(1/m² − 1) / ω = √( (1 − g² − s²) / (g² + s²) ) / ω Valid exactly for single-exponential species on the universal semicircle. Parameters ---------- g, s : array_like cfg : AcquisitionConfig Returns ------- tau_m : ndarray (ns) """ g = np.asarray(g, dtype=float) s = np.asarray(s, dtype=float) m2 = g**2 + s**2 with np.errstate(invalid="ignore", divide="ignore"): ratio = np.where(m2 > 0, (1.0 - m2) / m2, np.nan) return np.sqrt(np.maximum(ratio, 0.0)) / cfg.omega
# ────────────────────────────────────────────────────────────────────────────── # Combined / direct inversion # ──────────────────────────────────────────────────────────────────────────────
[docs] def lifetime_from_phasor( g: ArrayLike, s: ArrayLike, cfg: AcquisitionConfig, method: str = "phase", ) -> NDArray[np.float64]: """ Convenience wrapper: estimate lifetime from phasor coordinates. Parameters ---------- g, s : array_like cfg : AcquisitionConfig method : {"phase", "modulus", "mean"} "phase" → τ_φ (arctan estimator) "modulus" → τ_m (demodulation estimator) "mean" → arithmetic mean of τ_φ and τ_m Returns ------- tau : ndarray (ns) """ if method == "phase": return phase_lifetime(g, s, cfg) elif method == "modulus": return modulus_lifetime(g, s, cfg) elif method == "mean": return 0.5 * (phase_lifetime(g, s, cfg) + modulus_lifetime(g, s, cfg)) else: raise ValueError( f"method must be 'phase', 'modulus', or 'mean'. Got: {method!r}" )
# ────────────────────────────────────────────────────────────────────────────── # Corrected estimators for gated data (Michalet 2021, Sec. VII) # ──────────────────────────────────────────────────────────────────────────────
[docs] def phase_lifetime_gated( g: ArrayLike, s: ArrayLike, cfg: AcquisitionConfig, ) -> NDArray[np.float64]: """ Modified phase lifetime for a *single square gate* of width W. For gated data the standard arctan formula underestimates τ. Michalet 2021 (Sec. VII A) provides the correction via the implicit equation: tan(φ_gate) / ω ≠ τ (bias!) This function solves the forward model numerically: for each observed (g, s) we find τ such that phasor_gated_single(τ, cfg) == (g, s) by minimising |φ_forward − φ_measured|. Parameters ---------- g, s : array_like cfg : AcquisitionConfig (mode GATED_SINGLE expected) Returns ------- tau : ndarray (ns) """ from scipy.optimize import brentq from .phasors import phasor_gated_single # local import: scipy is optional g = np.asarray(g, dtype=float) s = np.asarray(s, dtype=float) phi_meas = np.arctan2(s, g) def solve_one(phi: np.ndarray) -> Any: """ Run the solve one routine. Parameters ---------- phi : np.ndarray Phasor phase angle used by the lifetime solver. Returns ------- Any Object produced by solve one. """ def residual(tau: np.ndarray) -> Any: """ Run the residual routine. Parameters ---------- tau : np.ndarray Lifetime value or lifetime map in nanoseconds. Returns ------- Any Object produced by residual. """ gg, ss = phasor_gated_single(tau, cfg) return np.arctan2(ss, gg) - phi try: return brentq(residual, cfg.tau_min_ns, cfg.tau_max_ns * 10) except ValueError: return np.nan return np.array([solve_one(p) for p in np.atleast_1d(phi_meas)])
# ────────────────────────────────────────────────────────────────────────────── # Fractional components for two-species mixtures # ──────────────────────────────────────────────────────────────────────────────
[docs] def fractional_components( g_mix: ArrayLike, s_mix: ArrayLike, g1: float, s1: float, g2: float, s2: float, ) -> tuple[NDArray, NDArray]: """ Estimate the fractional contributions (f₁, f₂) of two pure species using the linear combination property of phasors. For a mixture of two species with known phasors (g₁,s₁) and (g₂,s₂): g_mix = f₁·g₁ + f₂·g₂ s_mix = f₁·s₁ + f₂·s₂ f₁ + f₂ = 1 This system is overdetermined; we use the geometric lever-rule: the mixture point divides the line segment from species 1 to species 2 such that f₁ = d(mix→2) / d(1→2). Convention: f₁ is the fraction of *species 1*, i.e. it equals 1 when the mixture phasor coincides with (g₁, s₁). Parameters ---------- g_mix, s_mix : array_like Observed mixture phasor(s). g1, s1 : float Pure species 1 phasor. g2, s2 : float Pure species 2 phasor. Returns ------- f1, f2 : ndarray Fractional intensities of species 1 and 2. f2 = 1 − f1. """ g_mix = np.asarray(g_mix, dtype=float) s_mix = np.asarray(s_mix, dtype=float) dg = g2 - g1 ds = s2 - s1 norm2 = dg**2 + ds**2 if norm2 < 1e-20: raise ValueError("Species 1 and 2 are too close in the phasor plot.") # Projection of (mix - p1) onto unit vector (p2 - p1): # this gives the fraction *toward species 2*, i.e. f2. f2 = ((g_mix - g1) * dg + (s_mix - s1) * ds) / norm2 f2 = np.clip(f2, 0.0, 1.0) return 1.0 - f2, f2