use crate::error::OxiPhotonError;
const H_PLANCK: f64 = 6.62607015e-34;
const C0: f64 = 2.99792458e8;
#[allow(dead_code)]
const KB: f64 = 1.380649e-23;
const AVOGADRO: f64 = 6.02214076e23;
const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
const DEFAULT_FWHM_NM: f64 = 40.0;
#[derive(Debug, Clone)]
pub struct Fluorophore {
pub name: String,
pub excitation_peak_nm: f64,
pub emission_peak_nm: f64,
pub extinction_coefficient_m_per_cm: f64,
pub quantum_yield: f64,
pub lifetime_ns: f64,
pub photobleaching_threshold: f64,
}
impl Fluorophore {
pub fn new(
name: impl Into<String>,
ex_nm: f64,
em_nm: f64,
epsilon: f64,
qy: f64,
lifetime_ns: f64,
) -> Self {
Self {
name: name.into(),
excitation_peak_nm: ex_nm,
emission_peak_nm: em_nm,
extinction_coefficient_m_per_cm: epsilon,
quantum_yield: qy,
lifetime_ns,
photobleaching_threshold: 1.0,
}
}
pub fn fitc() -> Self {
Self::new("FITC", 494.0, 521.0, 73_000.0, 0.93, 4.0)
}
pub fn rhodamine_b() -> Self {
Self::new("Rhodamine B", 540.0, 625.0, 106_000.0, 0.65, 2.5)
}
pub fn gfp() -> Self {
Self::new("GFP", 488.0, 507.0, 56_000.0, 0.79, 2.4)
}
pub fn cy3() -> Self {
Self::new("Cy3", 550.0, 570.0, 150_000.0, 0.04, 0.3)
}
pub fn cy5() -> Self {
Self::new("Cy5", 650.0, 670.0, 250_000.0, 0.27, 1.0)
}
pub fn stokes_shift_nm(&self) -> f64 {
self.emission_peak_nm - self.excitation_peak_nm
}
pub fn brightness(&self) -> f64 {
self.extinction_coefficient_m_per_cm * self.quantum_yield
}
pub fn saturation_intensity_w_per_cm2(&self) -> f64 {
let sigma = self.absorption_cross_section_cm2();
let tau_s = self.lifetime_ns * 1.0e-9;
let lambda_m = self.excitation_peak_nm * 1.0e-9;
let h_nu = H_PLANCK * C0 / lambda_m;
h_nu / (sigma * tau_s)
}
pub fn absorption_cross_section_cm2(&self) -> f64 {
2303.0 * self.extinction_coefficient_m_per_cm / AVOGADRO
}
pub fn emission_rate(&self, irradiance_w_per_cm2: f64) -> f64 {
let lambda_m = self.excitation_peak_nm * 1.0e-9;
let h_nu = H_PLANCK * C0 / lambda_m;
let photon_flux = irradiance_w_per_cm2 / h_nu; let sigma = self.absorption_cross_section_cm2();
sigma * photon_flux * self.quantum_yield
}
pub fn emission_spectrum(&self, lambda_nm_range: (f64, f64), n_pts: usize) -> Vec<(f64, f64)> {
let (lambda_min, lambda_max) = lambda_nm_range;
let sigma_nm = DEFAULT_FWHM_NM / (2.0 * (2.0 * 2_f64.ln()).sqrt());
(0..n_pts)
.map(|i| {
let lambda = lambda_min
+ (lambda_max - lambda_min) * (i as f64) / ((n_pts - 1).max(1) as f64);
let delta = lambda - self.emission_peak_nm;
let intensity = (-delta * delta / (2.0 * sigma_nm * sigma_nm)).exp();
(lambda, intensity)
})
.collect()
}
pub fn microscopy_snr(&self, n_signal_photons: f64, n_bg: f64, n_dark: f64) -> f64 {
let noise = (n_signal_photons + n_bg + n_dark).sqrt();
if noise < 1.0e-30 {
return 0.0;
}
n_signal_photons / noise
}
}
pub struct FretPair {
pub donor: Fluorophore,
pub acceptor: Fluorophore,
pub forster_radius_nm: f64,
}
impl FretPair {
pub fn new(donor: Fluorophore, acceptor: Fluorophore, r0_nm: f64) -> Self {
Self {
donor,
acceptor,
forster_radius_nm: r0_nm,
}
}
pub fn fitc_rhodamine() -> Self {
Self::new(Fluorophore::fitc(), Fluorophore::rhodamine_b(), 5.5)
}
pub fn cfp_yfp() -> Self {
let cfp = Fluorophore::new("CFP", 433.0, 476.0, 32_500.0, 0.40, 2.5);
let yfp = Fluorophore::new("YFP", 514.0, 527.0, 83_400.0, 0.61, 3.0);
Self::new(cfp, yfp, 4.9)
}
pub fn cy3_cy5() -> Self {
Self::new(Fluorophore::cy3(), Fluorophore::cy5(), 6.0)
}
pub fn efficiency(&self, distance_nm: f64) -> f64 {
let r0_6 = self.forster_radius_nm.powi(6);
let r_6 = distance_nm.powi(6);
r0_6 / (r0_6 + r_6)
}
pub fn distance_from_efficiency_nm(&self, efficiency: f64) -> f64 {
if efficiency <= 0.0 {
return f64::INFINITY;
}
if efficiency >= 1.0 {
return 0.0;
}
self.forster_radius_nm * ((1.0 - efficiency) / efficiency).powf(1.0 / 6.0)
}
pub fn compute_forster_radius_nm(
donor: &Fluorophore,
acceptor: &Fluorophore,
orientation_factor_kappa2: f64,
n_medium: f64,
) -> f64 {
let lambda_min = donor.emission_peak_nm - 100.0;
let lambda_max = donor.emission_peak_nm + 150.0;
let n_pts = 1000_usize;
let d_lambda = (lambda_max - lambda_min) / n_pts as f64;
let sigma_d = DEFAULT_FWHM_NM / (2.0 * (2.0 * 2_f64.ln()).sqrt());
let sigma_a = DEFAULT_FWHM_NM / (2.0 * (2.0 * 2_f64.ln()).sqrt());
let mut j_num = 0.0_f64; let mut j_den = 0.0_f64;
for i in 0..n_pts {
let lambda_nm = lambda_min + (i as f64 + 0.5) * d_lambda;
let delta_d = lambda_nm - donor.emission_peak_nm;
let f_d = (-delta_d * delta_d / (2.0 * sigma_d * sigma_d)).exp();
let delta_a = lambda_nm - acceptor.excitation_peak_nm;
let eps_a = acceptor.extinction_coefficient_m_per_cm
* (-delta_a * delta_a / (2.0 * sigma_a * sigma_a)).exp();
let lambda_cm = lambda_nm * 1.0e-7;
j_num += f_d * eps_a * lambda_cm.powi(4) * d_lambda;
j_den += f_d * d_lambda;
}
let j = if j_den > 1.0e-30 { j_num / j_den } else { 0.0 };
let r0_6_cm6 = (9000.0 * 10_f64.ln() * orientation_factor_kappa2 * donor.quantum_yield * j)
/ (128.0 * std::f64::consts::PI.powi(5) * n_medium.powi(4) * AVOGADRO);
let r0_6_nm6 = r0_6_cm6 * 1.0e42;
r0_6_nm6.powf(1.0 / 6.0)
}
pub fn donor_lifetime_with_acceptor_ns(&self, distance_nm: f64) -> f64 {
let e = self.efficiency(distance_nm);
self.donor.lifetime_ns * (1.0 - e)
}
pub fn fret_rate_per_ns(&self, distance_nm: f64) -> f64 {
let r0_over_r = self.forster_radius_nm / distance_nm;
(1.0 / self.donor.lifetime_ns) * r0_over_r.powi(6)
}
}
pub struct TwoPhotonExcitation {
pub fluorophore: Fluorophore,
pub two_photon_cross_section_gm: f64,
pub pulse_width_fs: f64,
pub rep_rate_mhz: f64,
pub center_wavelength_nm: f64,
}
impl TwoPhotonExcitation {
pub fn new(
fluorophore: Fluorophore,
delta_gm: f64,
pulse_fs: f64,
rep_mhz: f64,
lambda_nm: f64,
) -> Self {
Self {
fluorophore,
two_photon_cross_section_gm: delta_gm,
pulse_width_fs: pulse_fs,
rep_rate_mhz: rep_mhz,
center_wavelength_nm: lambda_nm,
}
}
pub fn excitation_rate(&self, avg_power_mw: f64, beam_waist_um: f64) -> f64 {
let delta_cm4s = self.two_photon_cross_section_gm * 1.0e-50; let f_rep_hz = self.rep_rate_mhz * 1.0e6;
let tau_p_s = self.pulse_width_fs * 1.0e-15;
let p_avg_w = avg_power_mw * 1.0e-3;
let w0_cm = beam_waist_um * 1.0e-4;
let p_peak_w = p_avg_w / (f_rep_hz * tau_p_s);
let lambda_m = self.center_wavelength_nm * 1.0e-9;
let h_nu = H_PLANCK * C0 / lambda_m;
let beam_area_cm2 = std::f64::consts::PI * w0_cm * w0_cm;
let i_peak = p_peak_w / beam_area_cm2;
let photon_flux_peak = i_peak / h_nu;
delta_cm4s * photon_flux_peak * photon_flux_peak * self.fluorophore.quantum_yield
}
pub fn power_dependence_exponent() -> f64 {
2.0
}
pub fn excited_volume_um3(&self, numerical_aperture: f64) -> f64 {
let lambda_um = self.center_wavelength_nm * 1.0e-3;
let n_medium = 1.33; let w0_um = lambda_um / (std::f64::consts::PI * numerical_aperture);
let z_r_um = n_medium * lambda_um / (numerical_aperture * numerical_aperture);
(std::f64::consts::PI / 2.0_f64).powf(1.5) * w0_um * w0_um / std::f64::consts::SQRT_2
* z_r_um
/ std::f64::consts::SQRT_2
}
pub fn penetration_depth_um(
&self,
tissue: &crate::biophotonics::tissue::TissueOpticalProperties,
) -> f64 {
let lambda_ref = tissue.wavelength_nm;
let lambda_2pe = self.center_wavelength_nm;
let b = 1.5_f64; let scale = (lambda_ref / lambda_2pe).powf(b);
let mu_s_prime_2pe = tissue.reduced_scattering_coefficient() * scale;
let mu_a_2pe = tissue.absorption_coefficient_cm * 0.1; let mu_eff_2pe = (3.0 * mu_a_2pe * (mu_a_2pe + mu_s_prime_2pe)).sqrt();
if mu_eff_2pe < 1.0e-20 {
return f64::INFINITY;
}
(1.0 / mu_eff_2pe) * 1.0e4
}
pub fn photobleaching_rate_per_s(&self, avg_power_mw: f64) -> f64 {
let rate_at_1mw = self.fluorophore.photobleaching_threshold;
rate_at_1mw * avg_power_mw * avg_power_mw
}
pub fn action_cross_section_gm(&self) -> f64 {
self.two_photon_cross_section_gm * self.fluorophore.quantum_yield
}
}
fn solve_3x3(a: &[[f64; 3]; 3], b: &[f64; 3]) -> [f64; 3] {
let mut m = [
[a[0][0], a[0][1], a[0][2], b[0]],
[a[1][0], a[1][1], a[1][2], b[1]],
[a[2][0], a[2][1], a[2][2], b[2]],
];
for col in 0..3 {
let mut max_val = m[col][col].abs();
let mut max_row = col;
for (row, _) in m.iter().enumerate().skip(col + 1).take(3 - col - 1) {
if m[row][col].abs() > max_val {
max_val = m[row][col].abs();
max_row = row;
}
}
if max_val < 1.0e-30 {
return [0.0; 3]; }
m.swap(col, max_row);
for row in (col + 1)..3 {
let factor = m[row][col] / m[col][col];
let pivot_vals: [f64; 4] = m[col];
for (offset, &pv) in pivot_vals[col..4].iter().enumerate() {
m[row][col + offset] -= pv * factor;
}
}
}
let mut x = [0.0_f64; 3];
for i in (0..3).rev() {
let mut s = m[i][3];
for j in (i + 1)..3 {
s -= m[i][j] * x[j];
}
if m[i][i].abs() < 1.0e-30 {
return [0.0; 3];
}
x[i] = s / m[i][i];
}
x
}
pub struct FlimAnalysis;
impl FlimAnalysis {
pub fn single_exponential_fit(
t: &[f64],
intensity: &[f64],
) -> Result<(f64, f64, f64), OxiPhotonError> {
if t.len() != intensity.len() || t.len() < 3 {
return Err(OxiPhotonError::NumericalError(
"Need at least 3 data points with matching t and I arrays".into(),
));
}
let tail_start = (t.len() * 9 / 10).max(t.len() - 3);
let b_est = intensity[tail_start..]
.iter()
.cloned()
.fold(0.0_f64, |acc, v| acc + v)
/ (intensity.len() - tail_start) as f64;
let b_est = b_est.max(0.0);
let mut sum_t = 0.0;
let mut sum_ln_i = 0.0;
let mut sum_t2 = 0.0;
let mut sum_t_ln_i = 0.0;
let mut n_valid = 0usize;
for (&ti, &ii) in t.iter().zip(intensity.iter()) {
let i_corr = ii - b_est;
if i_corr > 1.0e-30 {
let ln_i = i_corr.ln();
sum_t += ti;
sum_ln_i += ln_i;
sum_t2 += ti * ti;
sum_t_ln_i += ti * ln_i;
n_valid += 1;
}
}
if n_valid < 2 {
return Err(OxiPhotonError::NumericalError(
"Insufficient non-zero data points for exponential fit".into(),
));
}
let n = n_valid as f64;
let denom = n * sum_t2 - sum_t * sum_t;
if denom.abs() < 1.0e-30 {
return Err(OxiPhotonError::NumericalError(
"Degenerate time axis in single-exponential fit".into(),
));
}
let slope = (n * sum_t_ln_i - sum_t * sum_ln_i) / denom;
let intercept = (sum_ln_i - slope * sum_t) / n;
let tau_init = if slope < -1.0e-30 { -1.0 / slope } else { 1.0 };
let a_init = intercept.exp();
let (mut a, mut tau, mut b) = (a_init, tau_init, b_est);
let mut lambda_lm = 0.01;
let sse_fn = |a: f64, tau: f64, b: f64| -> f64 {
t.iter()
.zip(intensity.iter())
.map(|(&ti, &ii)| {
let r = a * (-ti / tau).exp() + b - ii;
r * r
})
.sum()
};
let mut sse_cur = sse_fn(a, tau, b);
for _iter in 0..1000 {
let mut jtj = [[0.0_f64; 3]; 3];
let mut jtr = [0.0_f64; 3];
for (&ti, &ii) in t.iter().zip(intensity.iter()) {
let e = (-ti / tau).exp();
let j0 = e; let j1 = a * e * ti / (tau * tau); let j2 = 1.0_f64; let res = a * e + b - ii;
let jv = [j0, j1, j2];
for i in 0..3 {
jtr[i] += jv[i] * res;
for j in 0..3 {
jtj[i][j] += jv[i] * jv[j];
}
}
}
for (i, row) in jtj.iter_mut().enumerate() {
row[i] *= 1.0 + lambda_lm;
}
let delta = solve_3x3(&jtj, &jtr);
let a_new = (a - delta[0]).max(1.0e-10);
let tau_new = (tau - delta[1]).max(1.0e-9);
let b_new = b - delta[2];
let sse_new = sse_fn(a_new, tau_new, b_new);
if sse_new < sse_cur {
a = a_new;
tau = tau_new;
b = b_new;
sse_cur = sse_new;
lambda_lm *= 0.5;
if sse_cur < 1.0e-20 {
break;
}
} else {
lambda_lm *= 4.0;
if lambda_lm > 1.0e10 {
break;
}
}
}
Ok((a, tau, b))
}
pub fn double_exponential_fit(
t: &[f64],
intensity: &[f64],
) -> Result<(f64, f64, f64, f64, f64), OxiPhotonError> {
if t.len() != intensity.len() || t.len() < 5 {
return Err(OxiPhotonError::NumericalError(
"Need at least 5 data points for double-exponential fit".into(),
));
}
let half = t.len() / 2;
let t_tail = &t[half..];
let i_tail = &intensity[half..];
let (a1_long, tau_long, b_est) = Self::single_exponential_fit(t_tail, i_tail)?;
let i_short: Vec<f64> = t
.iter()
.zip(intensity.iter())
.map(|(&ti, &ii)| {
let long_component = a1_long * (-ti / tau_long).exp() + b_est;
(ii - long_component).max(1.0e-30)
})
.collect();
let (a2_short, tau_short, _) = Self::single_exponential_fit(t, &i_short)?;
if tau_long >= tau_short {
Ok((a1_long, tau_long, a2_short, tau_short, b_est))
} else {
Ok((a2_short, tau_short, a1_long, tau_long, b_est))
}
}
pub fn amplitude_weighted_lifetime(amplitudes: &[f64], lifetimes: &[f64]) -> f64 {
let num: f64 = amplitudes
.iter()
.zip(lifetimes.iter())
.map(|(&a, &tau)| a * tau * tau)
.sum();
let den: f64 = amplitudes
.iter()
.zip(lifetimes.iter())
.map(|(&a, &tau)| a * tau)
.sum();
if den.abs() < 1.0e-30 {
0.0
} else {
num / den
}
}
pub fn intensity_weighted_lifetime(amplitudes: &[f64], lifetimes: &[f64]) -> f64 {
let num: f64 = amplitudes
.iter()
.zip(lifetimes.iter())
.map(|(&a, &tau)| a * tau)
.sum();
let den: f64 = amplitudes.iter().sum();
if den.abs() < 1.0e-30 {
0.0
} else {
num / den
}
}
pub fn phasor_coordinates(lifetime_ns: f64, rep_rate_mhz: f64) -> (f64, f64) {
let omega = TWO_PI * rep_rate_mhz * 1.0e6 * 1.0e-9; let omega_tau = omega * lifetime_ns;
let denom = 1.0 + omega_tau * omega_tau;
let g = 1.0 / denom;
let s = omega_tau / denom;
(g, s)
}
pub fn lifetime_from_phasor(g: f64, s: f64, rep_rate_mhz: f64) -> f64 {
let omega = TWO_PI * rep_rate_mhz * 1.0e6 * 1.0e-9; if omega < 1.0e-30 || g < 1.0e-30 {
return 0.0;
}
s / (omega * g)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1.0e-10;
#[allow(dead_code)]
const RTOL: f64 = 1.0e-6;
#[test]
fn test_fitc_stokes_shift() {
let fitc = Fluorophore::fitc();
let shift = fitc.stokes_shift_nm();
assert!(
(shift - 27.0).abs() < TOL,
"FITC Stokes shift should be 27 nm, got {shift}"
);
}
#[test]
fn test_fluorophore_brightness() {
let fitc = Fluorophore::fitc();
let expected = fitc.extinction_coefficient_m_per_cm * fitc.quantum_yield;
let brightness = fitc.brightness();
assert!(
(brightness - expected).abs() < TOL,
"Brightness = ε × Φ, expected {expected}, got {brightness}"
);
}
#[test]
fn test_fret_efficiency_at_r0() {
let pair = FretPair::fitc_rhodamine();
let eff = pair.efficiency(pair.forster_radius_nm);
assert!(
(eff - 0.5).abs() < TOL,
"FRET efficiency at R0 should be 0.5, got {eff}"
);
}
#[test]
fn test_fret_efficiency_zero_at_large_r() {
let pair = FretPair::fitc_rhodamine();
let eff = pair.efficiency(pair.forster_radius_nm * 100.0);
assert!(
eff < 1.0e-10,
"FRET efficiency should approach 0 at large r, got {eff}"
);
}
#[test]
fn test_fret_efficiency_one_at_small_r() {
let pair = FretPair::fitc_rhodamine();
let eff = pair.efficiency(pair.forster_radius_nm * 0.001);
assert!(
eff > 0.999_999,
"FRET efficiency should approach 1 at small r, got {eff}"
);
}
#[test]
fn test_donor_lifetime_reduced_by_fret() {
let pair = FretPair::fitc_rhodamine();
let tau_d = pair.donor.lifetime_ns;
let tau_da = pair.donor_lifetime_with_acceptor_ns(pair.forster_radius_nm);
assert!(
tau_da < tau_d,
"Donor lifetime should be reduced by FRET: τ_D={tau_d}, τ_DA={tau_da}"
);
assert!(
(tau_da - tau_d * 0.5).abs() < TOL,
"At r=R0, τ_DA should be τ_D/2"
);
}
#[test]
fn test_two_photon_quadratic_power() {
let gfp = Fluorophore::gfp();
let tpe = TwoPhotonExcitation::new(gfp, 10.0, 100.0, 80.0, 920.0);
let rate_1mw = tpe.excitation_rate(1.0, 0.5);
let rate_2mw = tpe.excitation_rate(2.0, 0.5);
let ratio = rate_2mw / rate_1mw;
assert!(
(ratio - 4.0).abs() < 0.01,
"2PE rate should scale as P²: ratio = {ratio}, expected 4.0"
);
}
#[test]
fn test_phasor_unit_circle() {
for &tau_ns in &[0.5, 1.0, 2.5, 4.0, 10.0] {
let (g, s) = FlimAnalysis::phasor_coordinates(tau_ns, 80.0);
let dist = (g - 0.5) * (g - 0.5) + s * s;
assert!(
(dist - 0.25).abs() < 1.0e-12,
"Phasor for τ={tau_ns} ns not on unit semicircle: dist={dist}"
);
}
}
#[test]
fn test_lifetime_from_phasor_roundtrip() {
let tau_orig = 3.7_f64; let rep_rate = 80.0; let (g, s) = FlimAnalysis::phasor_coordinates(tau_orig, rep_rate);
let tau_recovered = FlimAnalysis::lifetime_from_phasor(g, s, rep_rate);
assert!(
(tau_recovered - tau_orig).abs() < 1.0e-10,
"Phasor roundtrip failed: original={tau_orig}, recovered={tau_recovered}"
);
}
#[test]
fn test_single_exp_fit_exact() {
let a_true = 1000.0_f64;
let tau_true = 3.5_f64; let b_true = 10.0_f64;
let n_pts = 100_usize;
let t_max = 15.0_f64;
let t: Vec<f64> = (0..n_pts)
.map(|i| t_max * (i as f64) / (n_pts - 1) as f64)
.collect();
let intensity: Vec<f64> = t
.iter()
.map(|&ti| a_true * (-ti / tau_true).exp() + b_true)
.collect();
let (a_fit, tau_fit, b_fit) = FlimAnalysis::single_exponential_fit(&t, &intensity)
.expect("Fit should succeed on clean data");
let tol_rel = 0.05;
assert!(
(tau_fit - tau_true).abs() / tau_true < tol_rel,
"Lifetime fit error too large: true={tau_true}, fitted={tau_fit}"
);
assert!(
(a_fit - a_true).abs() / a_true < tol_rel,
"Amplitude fit error too large: true={a_true}, fitted={a_fit}"
);
let _ = b_fit; }
}