Source code for pyfli.phasor.phasorSEPL.phasors

"""
phasors.py
==========
Analytical and semi-analytical phasor coordinate calculators for each
acquisition mode described in Michalet 2021 (DOI: 10.1063/5.0027834).

Every public function has signature:

    phasor_*(tau, cfg) -> tuple[float | np.ndarray, float | np.ndarray]

returning  (g, s)  coordinates in the phasor plane.

Convention
----------
    g  =  Re[ z(τ) ]   (cosine / real component)
    s  =  Im[ z(τ) ]   (sine  / imaginary component)

    z(τ)  is the normalised first-harmonic Fourier coefficient of the
    luminescence decay  I(t).

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

import math

import numpy as np
from numpy.typing import ArrayLike

from .config import AcquisitionConfig, AcquisitionMode

# ══════════════════════════════════════════════════════════════════════════════
# 1.  CONTINUOUS  (canonical universal semicircle)
# ══════════════════════════════════════════════════════════════════════════════


[docs] def phasor_continuous( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor coordinates for a periodic single-exponential decay (PSED) in the continuous / ideal TCSPC limit (Dirac IRF, full-period recording). This is the canonical formula whose locus is the *universal semicircle* of radius ½ centred at (½, 0). Equations (Michalet 2021, Eq. 15 / ISS technical note Eq. 10): g(τ) = 1 / (1 + ω²τ²) s(τ) = ωτ / (1 + ω²τ²) where ω = 2π·n/T. Parameters ---------- tau : array_like Fluorescence lifetime(s) in nanoseconds. cfg : AcquisitionConfig Must supply: omega (derived from T_ns and harmonic). Returns ------- g, s : np.ndarray Real and imaginary phasor coordinates (shape matches *tau*). """ tau = np.asarray(tau, dtype=float) w = cfg.omega wt = w * tau denom = 1.0 + wt**2 return 1.0 / denom, wt / denom
# ══════════════════════════════════════════════════════════════════════════════ # 2. DISCRETE (binned TCSPC — arc of circle) # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_discrete( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor coordinates for a PSED sampled into N equal bins over one period T. The locus is an *arc of a circle* (not the universal semicircle). Its centre and radius depend on N and the harmonic n; it converges to the universal semicircle as N → ∞. Algorithm: direct discrete Fourier coefficient (Michalet 2021, Sec. III C): I_k = ∫_{k·Δt}^{(k+1)·Δt} e^{-t/τ} dt = e^{-k·Δt/τ} · (1 − e^{-Δt/τ}) z̃ = Σ_k I_k · e^{i 2π n k / N} / Σ_k I_k Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Requires: T_ns, N_bins, harmonic. Returns ------- g, s : np.ndarray """ tau = np.asarray(tau, dtype=float) scalar = tau.ndim == 0 tau = np.atleast_1d(tau) T, N, n = cfg.T_ns, cfg.N_bins, cfg.harmonic dt = T / N k = np.arange(N, dtype=float) # bin indices [0 … N-1] phase = 2.0 * np.pi * n * k / N # Fourier kernel phases # --- vectorised over tau # tau shape: (M,) → (M, 1) × k shape (N,) tau2 = tau[:, np.newaxis] e_kdt = np.exp(-k * dt / tau2) # (M, N) e_dt = np.exp(-dt / tau2) # (M, 1) I_k = e_kdt * (1.0 - e_dt) # (M, N) cos_k = np.cos(phase) # (N,) sin_k = np.sin(phase) # (N,) I_sum = I_k.sum(axis=1) # (M,) g = (I_k * cos_k).sum(axis=1) / I_sum s = (I_k * sin_k).sum(axis=1) / I_sum if scalar: return float(g[0]), float(s[0]) return g, s
# ══════════════════════════════════════════════════════════════════════════════ # 3. GATED_SINGLE (single square gate, continuous approximation) # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_gated_single( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor coordinates for a PSED measured through a *single square gate* of width W starting at t = 0, with Dirac IRF. From Michalet 2021, Sec. III B (Eq. for gated PSED phasor): The gate selects the interval [0, W]. The T-periodic normalisation factor is α(τ,W,T) = (1 − e^{-W/τ}) / (1 − e^{-T/τ}). g_gate(τ) = α · 1/(1+ω²τ²) · [1 − e^{-W/τ}(cos(ωW) + ωτ·sin(ωW))] / (1 − e^{-W/τ}) s_gate(τ) = α · 1/(1+ω²τ²) · [ωτ − e^{-W/τ}(ωτ·cos(ωW) − sin(ωW))] / (1 − e^{-W/τ}) Simplification gives the scaled form used here. Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Requires: T_ns, gate_width_frac, harmonic. Returns ------- g, s : np.ndarray """ tau = np.asarray(tau, dtype=float) w = cfg.omega T = cfg.T_ns W = cfg.gate_width_ns wt = w * tau eW = np.exp(-W / tau) eT = np.exp(-T / tau) wW = w * W # Exact analytical result (derived from Re/Im of ∫₀^W e^{-t/τ} e^{iωt} dt): # # ∫₀^W e^{-t/τ} cos(ωt) dt = τ · [1 − e^{-W/τ}·(cos(ωW) − ωτ·sin(ωW))] / (1+ω²τ²) # ∫₀^W e^{-t/τ} sin(ωt) dt = τ · [ωτ·(1 − e^{-W/τ}·cos(ωW)) − e^{-W/τ}·sin(ωW)] / (1+ω²τ²) # # Denominator (T-period normalisation): ∫₀^T e^{-t/τ} dt = τ·(1 − e^{-T/τ}) # The τ factors cancel, giving: denom = (1.0 + wt**2) * (1.0 - eT) cos_int = 1.0 - eW * (np.cos(wW) - wt * np.sin(wW)) sin_int = wt * (1.0 - eW * np.cos(wW)) - eW * np.sin(wW) g = cos_int / denom s = sin_int / denom return g, s
# ══════════════════════════════════════════════════════════════════════════════ # 4. GATED_N (N equidistant square gates) # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_gated_N( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor of a PSED measured with *N equidistant square gates* of width W. Gate k starts at t_k = k · θ where θ = T / N_gates. The (discrete) phasor is (Michalet 2021, Sec. III C 3): I_k = e^{-t_k/τ} − e^{-(t_k+W)/τ} z̃ = Σ_k I_k · e^{i 2π n k / N_gates} / Σ_k I_k Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Requires: T_ns, N_gates, gate_width_frac, harmonic. Returns ------- g, s : np.ndarray """ tau = np.asarray(tau, dtype=float) scalar = tau.ndim == 0 tau = np.atleast_1d(tau) T = cfg.T_ns Ng = cfg.N_gates W = cfg.gate_width_ns n = cfg.harmonic theta = T / Ng k = np.arange(Ng, dtype=float) t_k = k * theta phase = 2.0 * np.pi * n * k / Ng tau2 = tau[:, np.newaxis] # (M, 1) I_k = np.exp(-t_k / tau2) - np.exp(-(t_k + W) / tau2) # (M, Ng) I_sum = I_k.sum(axis=1) g = (I_k * np.cos(phase)).sum(axis=1) / I_sum s = (I_k * np.sin(phase)).sum(axis=1) / I_sum if scalar: return float(g[0]), float(s[0]) return g, s
# ══════════════════════════════════════════════════════════════════════════════ # 5. TRUNCATED (recording window shorter than laser period) # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_truncated( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor when only the first T_rec < T nanoseconds of the decay are recorded. This is a common experimental artefact with high-repetition-rate lasers. The SEPL deforms and phasors can fall *outside* the universal semicircle, leading to erroneous multi-exponential interpretations if uncorrected. Analytical form (Michalet 2021, Sec. V): Using partial integration of e^{-t/τ} · e^{iωt} over [0, T_rec]: ∫₀^{T_rec} e^{-t/τ} e^{iωt} dt = τ · [1 − e^{-(1/τ−iω)T_rec}] / (1 − iωτ) Normalised by ∫₀^{T_rec} e^{-t/τ} dt = τ(1 − e^{-T_rec/τ}) Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Requires: T_ns, T_rec_frac, harmonic. Returns ------- g, s : np.ndarray """ tau = np.asarray(tau, dtype=float) w = cfg.omega Trec = cfg.T_rec_ns wt = w * tau eTr = np.exp(-Trec / tau) wTr = w * Trec cos_int = (1.0 - eTr * (np.cos(wTr) + wt * np.sin(wTr))) / (1.0 + wt**2) sin_int = (wt - eTr * (wt * np.cos(wTr) - np.sin(wTr))) / (1.0 + wt**2) # Numerator integrals already carry the structure of 1/(1+ω²τ²). # The τ factors in numerator and denominator cancel, so we normalise # by (1 − e^{−T_rec/τ}) only (not τ · (1 − e^{−T_rec/τ})). norm = 1.0 - eTr g = cos_int / norm s = sin_int / norm return g, s
# ══════════════════════════════════════════════════════════════════════════════ # 6. OFFSET (IRF / excitation pulse time-shifted within recording window) # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_offset( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Phasor when the excitation pulse (or IRF peak) is offset by t₀ within the recording window. An offset of t₀ introduces a pure rotation in the phasor plane (Michalet 2021, Sec. IV): z_offset(τ) = z_continuous(τ) · e^{−iωt₀} which gives: g_off = g·cos(ωt₀) + s·sin(ωt₀) s_off = s·cos(ωt₀) − g·sin(ωt₀) Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Requires: T_ns, t0_frac, harmonic. Returns ------- g, s : np.ndarray """ g0, s0 = phasor_continuous(tau, cfg) phi = cfg.omega * cfg.t0_ns c, si = math.cos(phi), math.sin(phi) return g0 * c + s0 * si, s0 * c - g0 * si
# ══════════════════════════════════════════════════════════════════════════════ # 7. DISPATCHER # ══════════════════════════════════════════════════════════════════════════════
[docs] def phasor_from_config( tau: ArrayLike, cfg: AcquisitionConfig, ) -> tuple[np.ndarray, np.ndarray]: """ Dispatch to the correct phasor function based on *cfg.mode*. Parameters ---------- tau : array_like Lifetime(s) in ns. cfg : AcquisitionConfig Returns ------- g, s : np.ndarray """ _dispatch: dict = { AcquisitionMode.CONTINUOUS: phasor_continuous, AcquisitionMode.DISCRETE: phasor_discrete, AcquisitionMode.GATED_SINGLE: phasor_gated_single, AcquisitionMode.GATED_N: phasor_gated_N, AcquisitionMode.TRUNCATED: phasor_truncated, AcquisitionMode.OFFSET: phasor_offset, } fn = _dispatch.get(cfg.mode) if fn is None: raise NotImplementedError(f"No phasor function for mode {cfg.mode!r}") return fn(tau, cfg)