use crate::error::OxiPhotonError;
const C0: f64 = 2.99792458e8;
const H_PLANCK: f64 = 6.62607015e-34;
#[allow(dead_code)]
const E_CHARGE: f64 = 1.602176634e-19;
const R_GAS: f64 = 8.314462618;
const AVOGADRO: f64 = 6.02214076e23;
#[derive(Debug, Clone)]
pub struct TissueOpticalProperties {
pub name: String,
pub wavelength_nm: f64,
pub absorption_coefficient_cm: f64,
pub scattering_coefficient_cm: f64,
pub anisotropy_factor: f64,
pub refractive_index: f64,
}
impl TissueOpticalProperties {
pub fn new(
name: impl Into<String>,
lambda_nm: f64,
mu_a: f64,
mu_s: f64,
g: f64,
n: f64,
) -> Self {
Self {
name: name.into(),
wavelength_nm: lambda_nm,
absorption_coefficient_cm: mu_a,
scattering_coefficient_cm: mu_s,
anisotropy_factor: g,
refractive_index: n,
}
}
pub fn skin_dermis_630nm() -> Self {
Self::new("Skin Dermis @ 630 nm", 630.0, 0.2, 200.0, 0.9, 1.37)
}
pub fn muscle_630nm() -> Self {
Self::new("Muscle @ 630 nm", 630.0, 0.5, 100.0, 0.9, 1.37)
}
pub fn brain_gray_matter() -> Self {
Self::new("Brain Gray Matter @ 630 nm", 630.0, 0.1, 100.0, 0.9, 1.36)
}
pub fn liver() -> Self {
Self::new("Liver @ 630 nm", 630.0, 1.5, 150.0, 0.9, 1.38)
}
pub fn blood_oxygenated() -> Self {
Self::new(
"Blood (oxygenated) @ 630 nm",
630.0,
2.3,
400.0,
0.994,
1.40,
)
}
pub fn fat() -> Self {
Self::new("Fat @ 630 nm", 630.0, 0.06, 120.0, 0.9, 1.44)
}
pub fn reduced_scattering_coefficient(&self) -> f64 {
self.scattering_coefficient_cm * (1.0 - self.anisotropy_factor)
}
pub fn transport_mfp_cm(&self) -> f64 {
let mu_s_prime = self.reduced_scattering_coefficient();
1.0 / (self.absorption_coefficient_cm + mu_s_prime)
}
pub fn scattering_mfp_um(&self) -> f64 {
1.0 / self.scattering_coefficient_cm * 1.0e4
}
pub fn effective_attenuation_cm(&self) -> f64 {
let mu_s_prime = self.reduced_scattering_coefficient();
(3.0 * self.absorption_coefficient_cm * (self.absorption_coefficient_cm + mu_s_prime))
.sqrt()
}
pub fn penetration_depth_cm(&self) -> f64 {
1.0 / self.effective_attenuation_cm()
}
pub fn albedo(&self) -> f64 {
self.scattering_coefficient_cm
/ (self.absorption_coefficient_cm + self.scattering_coefficient_cm)
}
pub fn diffusion_coefficient_cm(&self) -> f64 {
let mu_s_prime = self.reduced_scattering_coefficient();
1.0 / (3.0 * (self.absorption_coefficient_cm + mu_s_prime))
}
pub fn phase_function(&self, theta_rad: f64) -> f64 {
let g = self.anisotropy_factor;
let cos_theta = theta_rad.cos();
let g2 = g * g;
let denom = (1.0 + g2 - 2.0 * g * cos_theta).powf(1.5);
(1.0 - g2) / (4.0 * std::f64::consts::PI * denom)
}
}
pub struct DiffusionModel {
pub tissue: TissueOpticalProperties,
pub domain_size_cm: (f64, f64, f64),
}
impl DiffusionModel {
pub fn new(tissue: TissueOpticalProperties, domain_cm: (f64, f64, f64)) -> Self {
Self {
tissue,
domain_size_cm: domain_cm,
}
}
pub fn point_source_fluence(
&self,
source_depth_cm: f64,
source_power_mw: f64,
observation_point: (f64, f64, f64),
) -> f64 {
let (ox, oy, oz) = observation_point;
let r = (ox * ox + oy * oy + (oz - source_depth_cm) * (oz - source_depth_cm)).sqrt();
if r < 1.0e-12 {
return f64::INFINITY;
}
let mu_eff = self.tissue.effective_attenuation_cm();
let d = self.tissue.diffusion_coefficient_cm();
source_power_mw / (4.0 * std::f64::consts::PI * d * r) * (-mu_eff * r).exp()
}
pub fn slab_fluence(&self, z_cm: f64, source_power_per_cm2: f64) -> f64 {
let mu_a = self.tissue.absorption_coefficient_cm;
let mu_s_prime = self.tissue.reduced_scattering_coefficient();
let mu_t = mu_a + mu_s_prime;
let mu_eff = self.tissue.effective_attenuation_cm();
let d = self.tissue.diffusion_coefficient_cm();
let z0 = 1.0 / mu_t;
let a_factor = (1.0 + self.tissue.refractive_index)
/ (1.0 - self.tissue.refractive_index + 1.0e-12).abs();
let a_boundary = if a_factor.is_finite() && a_factor > 0.0 {
a_factor.min(10.0)
} else {
3.0
};
let z_b = 2.0 * a_boundary * d;
let term1 = (-(mu_eff * (z_cm - z0).abs())).exp();
let term2 = (-(mu_eff * (z_cm + z0 + 2.0 * z_b))).exp();
3.0 * d * mu_t * source_power_per_cm2 * (term1 + term2) / (2.0 * d * mu_eff)
}
pub fn depth_profile(
&self,
source_irradiance: f64,
z_max_cm: f64,
n_pts: usize,
) -> Vec<(f64, f64)> {
let mu_eff = self.tissue.effective_attenuation_cm();
let d = self.tissue.diffusion_coefficient_cm();
let mu_s_prime = self.tissue.reduced_scattering_coefficient();
let mu_a = self.tissue.absorption_coefficient_cm;
let s = (mu_a + mu_s_prime) * source_irradiance;
(0..n_pts)
.map(|i| {
let z = z_max_cm * (i as f64) / ((n_pts - 1).max(1) as f64);
let phi = 3.0 * d * s * (-mu_eff * z).exp() / mu_eff;
(z, phi)
})
.collect()
}
pub fn effective_depth_cm(&self) -> f64 {
self.tissue.penetration_depth_cm()
}
pub fn absorbed_power_density(&self, z_cm: f64, source_irradiance: f64) -> f64 {
let profile = self.depth_profile(source_irradiance, z_cm, 2);
let phi_z = profile.last().map(|&(_, p)| p).unwrap_or(0.0);
self.tissue.absorption_coefficient_cm * phi_z
}
pub fn thermal_damage_integral(&self, temperature_history_k: &[(f64, f64)]) -> f64 {
const FREQ_FACTOR: f64 = 3.1e98; const ACTIVATION_ENERGY: f64 = 6.28e5;
if temperature_history_k.len() < 2 {
return 0.0;
}
let mut omega = 0.0;
for window in temperature_history_k.windows(2) {
let (t0, temp0) = window[0];
let (t1, temp1) = window[1];
let dt = t1 - t0;
if dt <= 0.0 {
continue;
}
let rate0 = FREQ_FACTOR * (-ACTIVATION_ENERGY / (R_GAS * temp0)).exp();
let rate1 = FREQ_FACTOR * (-ACTIVATION_ENERGY / (R_GAS * temp1)).exp();
omega += 0.5 * (rate0 + rate1) * dt;
}
omega
}
pub fn monte_carlo_fluence_1d(
&self,
n_photons: usize,
z_max_cm: f64,
n_bins: usize,
) -> Vec<(f64, f64)> {
let mu_a = self.tissue.absorption_coefficient_cm;
let mu_s = self.tissue.scattering_coefficient_cm;
let mu_t = mu_a + mu_s;
let bin_width = z_max_cm / n_bins as f64;
let albedo = mu_s / mu_t;
let mut fluence = vec![0.0_f64; n_bins];
let mut rng_state: u64 = 0x123456789ABCDEF0;
let lcg_next = |state: &mut u64| -> f64 {
*state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((*state >> 33) as f64) / (u32::MAX as f64)
};
for _ in 0..n_photons {
let mut z = 0.0_f64;
let mut weight = 1.0_f64;
let mut direction = 1.0_f64;
loop {
let u = lcg_next(&mut rng_state).max(1.0e-15);
let step = -u.ln() / mu_t;
let z_new = z + direction * step;
if z_new < 0.0 {
break;
}
if z_new > z_max_cm {
break;
}
z = z_new;
let bin_idx = ((z / z_max_cm) * n_bins as f64) as usize;
let bin_idx = bin_idx.min(n_bins - 1);
let absorbed_weight = weight * (1.0 - albedo);
fluence[bin_idx] += absorbed_weight;
weight *= albedo;
if weight < 0.01 {
let u_rr = lcg_next(&mut rng_state);
if u_rr < 0.1 {
weight /= 0.1;
} else {
break;
}
}
let u_cos = lcg_next(&mut rng_state);
let g = self.tissue.anisotropy_factor;
let cos_theta = if g.abs() < 1.0e-6 {
1.0 - 2.0 * u_cos
} else {
let s = (1.0 - g * g) / (1.0 - g + 2.0 * g * u_cos);
(1.0 + g * g - s * s) / (2.0 * g)
};
direction *= cos_theta;
direction = direction.clamp(-1.0, 1.0);
}
}
let norm = n_photons as f64 * bin_width;
(0..n_bins)
.map(|i| {
let z_center = (i as f64 + 0.5) * bin_width;
(z_center, fluence[i] / norm)
})
.collect()
}
}
pub struct HemoglobinModel {
pub hemoglobin_concentration_g_per_dl: f64,
pub oxygen_saturation: f64,
}
impl HemoglobinModel {
pub fn new(hb_conc_g_dl: f64, spo2: f64) -> Result<Self, OxiPhotonError> {
if hb_conc_g_dl <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"Hemoglobin concentration must be positive, got {}",
hb_conc_g_dl
)));
}
if !(0.0..=1.0).contains(&spo2) {
return Err(OxiPhotonError::NumericalError(format!(
"SpO2 must be in [0, 1], got {}",
spo2
)));
}
Ok(Self {
hemoglobin_concentration_g_per_dl: hb_conc_g_dl,
oxygen_saturation: spo2,
})
}
pub fn normal_blood() -> Self {
Self {
hemoglobin_concentration_g_per_dl: 15.0,
oxygen_saturation: 0.98,
}
}
pub fn absorption_coefficient_cm(&self, lambda_nm: f64) -> f64 {
let mw_hb = 64_458.0; let conc_mol_per_l = (self.hemoglobin_concentration_g_per_dl * 10.0) / mw_hb;
let eps_hbo2 = Self::epsilon_hbo2_cm_per_m(lambda_nm);
let eps_hb = Self::epsilon_hb_cm_per_m(lambda_nm);
let eps_mixed = eps_hbo2 * self.oxygen_saturation + eps_hb * (1.0 - self.oxygen_saturation);
eps_mixed * conc_mol_per_l
}
pub fn epsilon_hbo2_cm_per_m(lambda_nm: f64) -> f64 {
let table: &[(f64, f64)] = &[
(600.0, 1214.0),
(620.0, 808.0),
(630.0, 742.0),
(640.0, 830.0),
(660.0, 1214.0),
(680.0, 692.0),
(700.0, 476.0),
(720.0, 392.0),
(740.0, 392.0),
(760.0, 1060.0),
(780.0, 1528.0),
(800.0, 1068.0),
(820.0, 756.0),
(840.0, 542.0),
(860.0, 428.0),
(880.0, 380.0),
(900.0, 372.0),
(920.0, 420.0),
(940.0, 476.0),
(960.0, 536.0),
];
piecewise_linear_interp(table, lambda_nm)
}
pub fn epsilon_hb_cm_per_m(lambda_nm: f64) -> f64 {
let table: &[(f64, f64)] = &[
(600.0, 7140.0),
(620.0, 3276.0),
(630.0, 2288.0),
(640.0, 1680.0),
(660.0, 906.0),
(680.0, 536.0),
(700.0, 364.0),
(720.0, 282.0),
(740.0, 274.0),
(760.0, 392.0),
(780.0, 584.0),
(800.0, 1068.0), (820.0, 1350.0),
(840.0, 1304.0),
(860.0, 1148.0),
(880.0, 1028.0),
(900.0, 922.0),
(920.0, 836.0),
(940.0, 756.0),
(960.0, 680.0),
];
piecewise_linear_interp(table, lambda_nm)
}
pub fn isosbestic_wavelength_nm() -> f64 {
800.0
}
pub fn spo2_from_ratio(ratio_660_940: f64) -> f64 {
let spo2 = 1.10 - 0.25 * ratio_660_940;
spo2.clamp(0.0, 1.0)
}
}
pub struct PdtDosimetry {
pub tissue: TissueOpticalProperties,
pub photosensitizer_concentration_um: f64,
pub photosensitizer_epsilon_cm: f64,
pub quantum_yield_singlet_oxygen: f64,
pub irradiance_mw_per_cm2: f64,
}
impl PdtDosimetry {
pub fn new(
tissue: TissueOpticalProperties,
conc_um: f64,
epsilon: f64,
phi_delta: f64,
irradiance: f64,
) -> Self {
Self {
tissue,
photosensitizer_concentration_um: conc_um,
photosensitizer_epsilon_cm: epsilon,
quantum_yield_singlet_oxygen: phi_delta,
irradiance_mw_per_cm2: irradiance,
}
}
pub fn photofrin_630nm(irradiance_mw: f64) -> Self {
Self::new(
TissueOpticalProperties::skin_dermis_630nm(),
2.0, 1170.0, 0.89, irradiance_mw,
)
}
pub fn treatment_dose_j_per_cm2(&self, time_s: f64) -> f64 {
self.irradiance_mw_per_cm2 * 1.0e-3 * time_s
}
pub fn singlet_oxygen_rate_um_per_s(&self) -> f64 {
let lambda_m = self.tissue.wavelength_nm * 1.0e-9;
let photon_energy_j = H_PLANCK * C0 / lambda_m;
let photon_flux = self.irradiance_mw_per_cm2 * 1.0e-3 / photon_energy_j;
let conc_m = self.photosensitizer_concentration_um * 1.0e-6;
let abs_rate_per_vol = self.photosensitizer_epsilon_cm * conc_m * photon_flux;
self.quantum_yield_singlet_oxygen * abs_rate_per_vol * 1.0e6 / AVOGADRO
}
pub fn fluence_rate_at_depth(&self, z_cm: f64) -> f64 {
let mu_eff = self.tissue.effective_attenuation_cm();
self.irradiance_mw_per_cm2 * (-mu_eff * z_cm).exp()
}
pub fn effective_treatment_depth_cm(&self, threshold_j_per_cm2: f64) -> f64 {
let mu_eff = self.tissue.effective_attenuation_cm();
let i0_w = self.irradiance_mw_per_cm2 * 1.0e-3;
if threshold_j_per_cm2 <= 0.0 || i0_w <= 0.0 {
return 0.0;
}
let ratio = threshold_j_per_cm2 / i0_w;
if ratio >= 1.0 {
return 0.0;
}
-ratio.ln() / mu_eff
}
}
fn piecewise_linear_interp(table: &[(f64, f64)], x: f64) -> f64 {
if table.is_empty() {
return 0.0;
}
if x <= table[0].0 {
return table[0].1;
}
if x >= table[table.len() - 1].0 {
return table[table.len() - 1].1;
}
for i in 0..table.len() - 1 {
let (x0, y0) = table[i];
let (x1, y1) = table[i + 1];
if x >= x0 && x <= x1 {
let t = (x - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
table[table.len() - 1].1
}
#[cfg(test)]
mod tests {
use super::*;
const TOL: f64 = 1.0e-10;
#[test]
fn test_reduced_scattering() {
let tissue = TissueOpticalProperties::skin_dermis_630nm();
let mu_s_prime = tissue.reduced_scattering_coefficient();
let expected = tissue.scattering_coefficient_cm * (1.0 - tissue.anisotropy_factor);
assert!(
(mu_s_prime - expected).abs() < TOL,
"Expected {expected}, got {mu_s_prime}"
);
assert!((mu_s_prime - 20.0).abs() < 1.0e-10);
}
#[test]
fn test_effective_attenuation() {
let tissue = TissueOpticalProperties::skin_dermis_630nm();
let mu_eff = tissue.effective_attenuation_cm();
let mu_s_prime = tissue.reduced_scattering_coefficient();
let expected = (3.0
* tissue.absorption_coefficient_cm
* (tissue.absorption_coefficient_cm + mu_s_prime))
.sqrt();
assert!((mu_eff - expected).abs() < TOL);
assert!(mu_eff > 0.0);
}
#[test]
fn test_penetration_depth() {
let tissue = TissueOpticalProperties::skin_dermis_630nm();
let delta = tissue.penetration_depth_cm();
let mu_eff = tissue.effective_attenuation_cm();
assert!((delta - 1.0 / mu_eff).abs() < TOL);
assert!(delta > 0.0);
}
#[test]
fn test_phase_function_normalized() {
let tissue = TissueOpticalProperties::skin_dermis_630nm();
let n_pts = 10_000;
let mut integral = 0.0;
let d_theta = std::f64::consts::PI / n_pts as f64;
for i in 0..n_pts {
let theta = (i as f64 + 0.5) * d_theta;
integral += tissue.phase_function(theta) * theta.sin() * d_theta;
}
let full_sphere_integral = 2.0 * std::f64::consts::PI * integral;
assert!(
(full_sphere_integral - 1.0).abs() < 1.0e-5,
"Phase function normalization failed: got {full_sphere_integral}"
);
}
#[test]
fn test_diffusion_depth_profile_decreasing() {
let tissue = TissueOpticalProperties::skin_dermis_630nm();
let model = DiffusionModel::new(tissue, (1.0, 1.0, 2.0));
let profile = model.depth_profile(100.0, 2.0, 20);
for i in 1..profile.len() {
assert!(
profile[i].1 <= profile[i - 1].1,
"Profile not decreasing at index {i}: {} > {}",
profile[i].1,
profile[i - 1].1
);
}
let phi_0 = profile[0].1;
let phi_last = profile[profile.len() - 1].1;
assert!(
phi_last < phi_0 * 0.5,
"Fluence should decrease substantially with depth"
);
}
#[test]
fn test_hemoglobin_normal_blood_spo2() {
let hb = HemoglobinModel::normal_blood();
assert!((hb.oxygen_saturation - 0.98).abs() < TOL);
assert!((hb.hemoglobin_concentration_g_per_dl - 15.0).abs() < TOL);
}
#[test]
fn test_isosbestic_wavelength() {
let lambda = HemoglobinModel::isosbestic_wavelength_nm();
assert!(
(lambda - 800.0).abs() < 10.0,
"Isosbestic wavelength should be ~800 nm, got {lambda}"
);
let eps_hbo2 = HemoglobinModel::epsilon_hbo2_cm_per_m(lambda);
let eps_hb = HemoglobinModel::epsilon_hb_cm_per_m(lambda);
let rel_diff = (eps_hbo2 - eps_hb).abs() / eps_hbo2;
assert!(
rel_diff < 0.05,
"At isosbestic wavelength, ε_HbO2 ({eps_hbo2}) ≈ ε_Hb ({eps_hb})"
);
}
#[test]
fn test_pdt_dose_increases_with_time() {
let pdt = PdtDosimetry::photofrin_630nm(100.0);
let dose_10s = pdt.treatment_dose_j_per_cm2(10.0);
let dose_20s = pdt.treatment_dose_j_per_cm2(20.0);
let dose_100s = pdt.treatment_dose_j_per_cm2(100.0);
assert!(dose_20s > dose_10s, "Dose should increase with time");
assert!(dose_100s > dose_20s, "Dose should increase with time");
assert!((dose_10s - 1.0).abs() < 1.0e-10);
}
#[test]
fn test_monte_carlo_total_absorbed() {
let tissue = TissueOpticalProperties::muscle_630nm();
let model = DiffusionModel::new(tissue, (1.0, 1.0, 1.0));
let profile = model.monte_carlo_fluence_1d(1000, 1.0, 10);
for (_, phi) in &profile {
assert!(*phi >= 0.0, "Fluence must be non-negative");
}
let total: f64 = profile.iter().map(|(_, p)| p).sum();
assert!(total > 0.0, "Monte Carlo must deposit some energy");
}
}