use crate::error::OxiPhotonError;
const E_CHARGE: f64 = 1.602_176_634e-19;
#[derive(Debug, Clone)]
pub struct Snspd {
pub detection_efficiency: f64,
pub dark_count_rate_hz: f64,
pub timing_jitter_ps: f64,
pub dead_time_ns: f64,
pub operating_wavelength_nm: f64,
}
impl Snspd {
pub fn new(
detection_efficiency: f64,
dark_count_rate_hz: f64,
timing_jitter_ps: f64,
dead_time_ns: f64,
operating_wavelength_nm: f64,
) -> Result<Self, OxiPhotonError> {
if detection_efficiency <= 0.0 || detection_efficiency > 1.0 {
return Err(OxiPhotonError::NumericalError(
"detection_efficiency must be in (0, 1]".into(),
));
}
if dark_count_rate_hz < 0.0 || !dark_count_rate_hz.is_finite() {
return Err(OxiPhotonError::NumericalError(
"dark_count_rate_hz must be non-negative and finite".into(),
));
}
if timing_jitter_ps <= 0.0 {
return Err(OxiPhotonError::NumericalError(
"timing_jitter_ps must be positive".into(),
));
}
if dead_time_ns <= 0.0 {
return Err(OxiPhotonError::NumericalError(
"dead_time_ns must be positive".into(),
));
}
Ok(Self {
detection_efficiency,
dark_count_rate_hz,
timing_jitter_ps,
dead_time_ns,
operating_wavelength_nm,
})
}
pub fn typical_1550() -> Self {
Self {
detection_efficiency: 0.90,
dark_count_rate_hz: 100.0,
timing_jitter_ps: 50.0,
dead_time_ns: 50.0,
operating_wavelength_nm: 1550.0,
}
}
pub fn max_count_rate_mhz(&self) -> f64 {
let tau_s = self.dead_time_ns * 1e-9;
(1.0 / tau_s) * 1e-6
}
pub fn signal_rate_hz(&self, photon_flux_per_s: f64) -> f64 {
self.detection_efficiency * photon_flux_per_s
}
pub fn snr_after_time(&self, photon_flux: f64, integration_time_s: f64) -> f64 {
let r_s = self.signal_rate_hz(photon_flux);
if r_s == 0.0 {
return 0.0;
}
let dcr = self.dark_count_rate_hz;
let total_rate = r_s + dcr;
let signal_counts = r_s * integration_time_s;
let noise_counts = total_rate * integration_time_s;
signal_counts / noise_counts.sqrt()
}
pub fn min_detectable_rate_hz(&self) -> f64 {
let dcr = self.dark_count_rate_hz;
(dcr + dcr.sqrt()) / self.detection_efficiency
}
pub fn detection_probability(&self, n_photons: usize) -> f64 {
1.0 - (1.0 - self.detection_efficiency).powi(n_photons as i32)
}
pub fn g2_zero_measurement(&self, true_coincidence_rate: f64, window_ns: f64) -> f64 {
let tau_w = window_ns * 1e-9;
let total_rate = self.dark_count_rate_hz + true_coincidence_rate;
let r_acc = total_rate * total_rate * tau_w;
if true_coincidence_rate == 0.0 {
return f64::INFINITY;
}
r_acc / true_coincidence_rate
}
pub fn measured_timing_resolution_ps(&self, source_jitter_ps: f64) -> f64 {
(self.timing_jitter_ps * self.timing_jitter_ps + source_jitter_ps * source_jitter_ps).sqrt()
}
pub fn efficiency_at_wavelength(&self, lambda_nm: f64) -> f64 {
let sigma_nm = 200.0;
let delta = lambda_nm - self.operating_wavelength_nm;
let scale = (-0.5 * (delta / sigma_nm) * (delta / sigma_nm)).exp();
self.detection_efficiency * scale
}
}
#[derive(Debug, Clone)]
pub struct Pmt {
pub quantum_efficiency: f64,
pub dynode_gain: f64,
pub n_dynodes: usize,
pub dark_count_rate_hz: f64,
pub transit_time_spread_ps: f64,
pub cathode_area_mm2: f64,
pub spectral_range: (f64, f64),
}
impl Pmt {
pub fn new(
quantum_efficiency: f64,
dynode_gain: f64,
n_dynodes: usize,
dark_count_rate_hz: f64,
transit_time_spread_ps: f64,
cathode_area_mm2: f64,
spectral_range: (f64, f64),
) -> Result<Self, OxiPhotonError> {
if quantum_efficiency <= 0.0 || quantum_efficiency > 1.0 {
return Err(OxiPhotonError::NumericalError(
"quantum_efficiency must be in (0, 1]".into(),
));
}
if dynode_gain <= 1.0 {
return Err(OxiPhotonError::NumericalError(
"dynode_gain must exceed 1.0".into(),
));
}
if n_dynodes == 0 {
return Err(OxiPhotonError::NumericalError(
"n_dynodes must be at least 1".into(),
));
}
Ok(Self {
quantum_efficiency,
dynode_gain,
n_dynodes,
dark_count_rate_hz,
transit_time_spread_ps,
cathode_area_mm2,
spectral_range,
})
}
pub fn bialkali_visible() -> Self {
Self {
quantum_efficiency: 0.25,
dynode_gain: 5.0,
n_dynodes: 10,
dark_count_rate_hz: 500.0,
transit_time_spread_ps: 300.0,
cathode_area_mm2: 100.0,
spectral_range: (300.0, 650.0),
}
}
pub fn gaas_nir() -> Self {
Self {
quantum_efficiency: 0.12,
dynode_gain: 4.0,
n_dynodes: 12,
dark_count_rate_hz: 5000.0,
transit_time_spread_ps: 500.0,
cathode_area_mm2: 200.0,
spectral_range: (300.0, 900.0),
}
}
pub fn total_gain(&self) -> f64 {
self.dynode_gain.powi(self.n_dynodes as i32)
}
pub fn anode_pulse_charge_c(&self) -> f64 {
E_CHARGE * self.total_gain()
}
pub fn signal_rate_hz(&self, photon_flux: f64) -> f64 {
self.quantum_efficiency * photon_flux
}
pub fn signal_to_noise_dc(&self, photon_flux: f64) -> f64 {
let g = self.total_gain();
let r_sig = self.signal_rate_hz(photon_flux);
let r_dark = self.dark_count_rate_hz;
let i_sig = r_sig * E_CHARGE * g;
let i_dark = r_dark * E_CHARGE * g;
let i_noise = (2.0 * E_CHARGE * (i_sig + i_dark) * g).sqrt();
if i_noise == 0.0 {
return 0.0;
}
i_sig / i_noise
}
pub fn after_pulsing_fraction(&self) -> f64 {
0.01 * (self.n_dynodes as f64 / 10.0)
}
pub fn single_photon_spectrum(&self, n_pts: usize) -> Vec<(f64, f64)> {
if n_pts == 0 {
return Vec::new();
}
let g = self.total_gain();
let x_max = 3.0 * g;
let mut spectrum = Vec::with_capacity(n_pts);
let b = 0.5_f64;
let alpha = 1.0 / b - 1.0;
for i in 0..n_pts {
let x = (i as f64 + 0.5) / n_pts as f64 * x_max;
let t = x / (b * g);
let pdf = (t.powf(alpha) * (-t).exp()).max(0.0);
spectrum.push((x, pdf));
}
let dx = x_max / n_pts as f64;
let area: f64 = spectrum.iter().map(|(_, p)| p).sum::<f64>() * dx;
if area > 0.0 {
for (_, p) in spectrum.iter_mut() {
*p /= area;
}
}
spectrum
}
}
#[derive(Debug, Clone)]
pub struct Spad {
pub detection_efficiency: f64,
pub dark_count_rate_hz: f64,
pub timing_jitter_ps: f64,
pub dead_time_ns: f64,
pub after_pulsing_fraction: f64,
pub operating_wavelength_nm: f64,
pub excess_bias_v: f64,
}
impl Spad {
pub fn new(
detection_efficiency: f64,
dark_count_rate_hz: f64,
timing_jitter_ps: f64,
dead_time_ns: f64,
after_pulsing_fraction: f64,
operating_wavelength_nm: f64,
excess_bias_v: f64,
) -> Result<Self, OxiPhotonError> {
if detection_efficiency <= 0.0 || detection_efficiency > 1.0 {
return Err(OxiPhotonError::NumericalError(
"detection_efficiency must be in (0, 1]".into(),
));
}
if !(0.0..1.0).contains(&after_pulsing_fraction) {
return Err(OxiPhotonError::NumericalError(
"after_pulsing_fraction must be in [0, 1)".into(),
));
}
Ok(Self {
detection_efficiency,
dark_count_rate_hz,
timing_jitter_ps,
dead_time_ns,
after_pulsing_fraction,
operating_wavelength_nm,
excess_bias_v,
})
}
pub fn si_spad_700nm() -> Self {
Self {
detection_efficiency: 0.40,
dark_count_rate_hz: 100.0,
timing_jitter_ps: 50.0,
dead_time_ns: 20.0,
after_pulsing_fraction: 0.02,
operating_wavelength_nm: 700.0,
excess_bias_v: 5.0,
}
}
pub fn ingaas_spad_1550() -> Self {
Self {
detection_efficiency: 0.25,
dark_count_rate_hz: 1000.0,
timing_jitter_ps: 100.0,
dead_time_ns: 10_000.0,
after_pulsing_fraction: 0.05,
operating_wavelength_nm: 1550.0,
excess_bias_v: 3.0,
}
}
pub fn max_count_rate_mhz(&self) -> f64 {
let tau_s = self.dead_time_ns * 1e-9;
1e-6 / tau_s
}
pub fn snr_after_time(&self, photon_flux: f64, integration_time_s: f64) -> f64 {
let r_s = self.detection_efficiency * photon_flux;
if r_s == 0.0 {
return 0.0;
}
let dcr_eff = self.effective_dcr_with_ap();
let signal_counts = r_s * integration_time_s;
let noise_counts = (r_s + dcr_eff) * integration_time_s;
signal_counts / noise_counts.sqrt()
}
pub fn effective_dcr_with_ap(&self) -> f64 {
self.dark_count_rate_hz * (1.0 + self.after_pulsing_fraction)
}
}
#[derive(Debug, Clone)]
pub struct TcSpc {
pub detector: Snspd,
pub time_resolution_ps: f64,
pub n_bins: usize,
pub repetition_rate_mhz: f64,
}
impl TcSpc {
pub fn new(
detector: Snspd,
time_resolution_ps: f64,
n_bins: usize,
repetition_rate_mhz: f64,
) -> Result<Self, OxiPhotonError> {
if time_resolution_ps <= 0.0 {
return Err(OxiPhotonError::NumericalError(
"time_resolution_ps must be positive".into(),
));
}
if n_bins == 0 {
return Err(OxiPhotonError::NumericalError(
"n_bins must be at least 1".into(),
));
}
if repetition_rate_mhz <= 0.0 {
return Err(OxiPhotonError::NumericalError(
"repetition_rate_mhz must be positive".into(),
));
}
Ok(Self {
detector,
time_resolution_ps,
n_bins,
repetition_rate_mhz,
})
}
pub fn max_count_rate_khz(&self) -> f64 {
0.01 * self.repetition_rate_mhz * 1e3
}
pub fn time_axis_ns(&self) -> Vec<f64> {
(0..self.n_bins)
.map(|i| i as f64 * self.time_resolution_ps * 1e-3)
.collect()
}
pub fn expected_decay_histogram(&self, lifetime_ns: f64, total_counts: usize) -> Vec<f64> {
let t_axis = self.time_axis_ns();
let dt_ns = self.time_resolution_ps * 1e-3;
let tau = lifetime_ns;
let raw: Vec<f64> = t_axis.iter().map(|&t| (-t / tau).exp()).collect();
let norm: f64 = raw.iter().sum::<f64>() * dt_ns;
if norm == 0.0 {
return vec![0.0; self.n_bins];
}
raw.iter()
.map(|&v| v / norm * total_counts as f64 * dt_ns)
.collect()
}
pub fn irf_histogram(&self) -> Vec<f64> {
let fwhm_bins = self.detector.timing_jitter_ps / self.time_resolution_ps;
let two_ln2: f64 = 2.0_f64.ln();
let sigma_bins = fwhm_bins / (2.0 * (2.0 * two_ln2).sqrt());
let mut irf: Vec<f64> = (0..self.n_bins)
.map(|i| {
let x = i as f64;
(-0.5 * (x / sigma_bins) * (x / sigma_bins)).exp()
})
.collect();
let norm: f64 = irf.iter().sum();
if norm > 0.0 {
for v in irf.iter_mut() {
*v /= norm;
}
}
irf
}
pub fn lifetime_precision_ps(&self, lifetime_ns: f64, total_counts: usize) -> f64 {
if total_counts == 0 {
return f64::INFINITY;
}
let precision_ns = lifetime_ns / (total_counts as f64).sqrt();
precision_ns * 1e3 }
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn typical_snspd() -> Snspd {
Snspd::typical_1550()
}
#[test]
fn test_snspd_max_count_rate() {
let det = typical_snspd();
let f_max = det.max_count_rate_mhz();
assert_relative_eq!(f_max, 20.0, epsilon = 1e-9);
}
#[test]
fn test_snspd_detection_probability() {
let det = typical_snspd();
let p1 = det.detection_probability(1);
assert_relative_eq!(p1, det.detection_efficiency, epsilon = 1e-12);
let p2 = det.detection_probability(2);
let expected = 1.0 - (1.0 - det.detection_efficiency).powi(2);
assert_relative_eq!(p2, expected, epsilon = 1e-12);
assert!(det.detection_probability(10) > p2);
}
#[test]
fn test_snspd_timing_resolution() {
let det = typical_snspd(); let src = 30.0_f64; let total = det.measured_timing_resolution_ps(src);
let expected = (50.0_f64 * 50.0 + 30.0 * 30.0).sqrt();
assert_relative_eq!(total, expected, epsilon = 1e-10);
}
#[test]
fn test_pmt_total_gain() {
let pmt = Pmt::bialkali_visible();
let expected = pmt.dynode_gain.powi(pmt.n_dynodes as i32);
assert_relative_eq!(pmt.total_gain(), expected, epsilon = 1e-6);
assert!(pmt.total_gain() > 1e6);
}
#[test]
fn test_tcspc_max_count_rate() {
let det = typical_snspd();
let tcspc = TcSpc::new(det, 4.0, 4096, 80.0).expect("valid TCSPC");
assert_relative_eq!(tcspc.max_count_rate_khz(), 800.0, epsilon = 1e-9);
}
#[test]
fn test_decay_histogram_shape() {
let det = typical_snspd();
let tcspc = TcSpc::new(det, 4.0, 1024, 80.0).expect("valid TCSPC");
let hist = tcspc.expected_decay_histogram(2.0, 100_000);
let max_val = hist.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert_eq!(max_val, hist[0], "histogram peak should be at t=0");
for i in 1..hist.len() {
assert!(
hist[i] <= hist[i - 1] + 1e-10,
"histogram not monotone at bin {i}: {} > {}",
hist[i],
hist[i - 1]
);
}
}
#[test]
fn test_lifetime_precision_improves_with_counts() {
let det = typical_snspd();
let tcspc = TcSpc::new(det, 4.0, 4096, 80.0).expect("valid TCSPC");
let prec_100 = tcspc.lifetime_precision_ps(2.0, 100);
let prec_10k = tcspc.lifetime_precision_ps(2.0, 10_000);
assert!(
prec_10k < prec_100,
"precision should improve: {prec_10k} >= {prec_100}"
);
assert_relative_eq!(prec_100 / prec_10k, 10.0, epsilon = 0.01);
}
}