use crate::error::OxiPhotonError;
const KB: f64 = 1.380_649e-23; const E_CHARGE: f64 = 1.602_176_634e-19; const H_PLANCK: f64 = 6.626_070_15e-34; const C0: f64 = 2.997_924_58e8;
pub struct NoiseAnalysis;
impl NoiseAnalysis {
pub fn shot_noise_a(current_a: f64, bandwidth_hz: f64) -> f64 {
(2.0 * E_CHARGE * current_a * bandwidth_hz).sqrt()
}
pub fn johnson_noise_v(resistance_ohm: f64, temperature_k: f64, bandwidth_hz: f64) -> f64 {
(4.0 * KB * temperature_k * resistance_ohm * bandwidth_hz).sqrt()
}
pub fn johnson_noise_a(resistance_ohm: f64, temperature_k: f64, bandwidth_hz: f64) -> f64 {
(4.0 * KB * temperature_k * bandwidth_hz / resistance_ohm).sqrt()
}
pub fn flicker_noise_a(
current_a: f64,
k_factor: f64,
bandwidth_hz: f64,
corner_freq_hz: f64,
) -> f64 {
if corner_freq_hz <= 0.0 {
return 0.0;
}
current_a * k_factor * (bandwidth_hz / corner_freq_hz).sqrt()
}
pub fn total_noise_a(components: &[f64]) -> f64 {
let sum_sq: f64 = components.iter().map(|&x| x * x).sum();
sum_sq.sqrt()
}
pub fn noise_figure_db(f_linear: f64) -> f64 {
10.0 * f_linear.log10()
}
pub fn cascaded_noise_figure(
noise_figures: &[f64],
gains: &[f64],
) -> Result<f64, OxiPhotonError> {
if noise_figures.is_empty() {
return Err(OxiPhotonError::NumericalError(
"noise_figures slice must not be empty".into(),
));
}
if noise_figures.len() != gains.len() {
return Err(OxiPhotonError::NumericalError(format!(
"noise_figures length ({}) must equal gains length ({})",
noise_figures.len(),
gains.len()
)));
}
let mut f_total = noise_figures[0];
let mut cumulative_gain = gains[0];
for i in 1..noise_figures.len() {
f_total += (noise_figures[i] - 1.0) / cumulative_gain;
cumulative_gain *= gains[i];
}
Ok(f_total)
}
pub fn rc_noise_bandwidth_hz(f_3db_hz: f64) -> f64 {
std::f64::consts::FRAC_PI_2 * f_3db_hz
}
pub fn photon_number_uncertainty(mean_photon_number: f64) -> f64 {
mean_photon_number.sqrt()
}
pub fn blackbody_radiance_w_per_m2_per_sr_per_nm(lambda_nm: f64, temperature_k: f64) -> f64 {
let lambda_m = lambda_nm * 1e-9;
let exponent = H_PLANCK * C0 / (lambda_m * KB * temperature_k);
let denom = exponent.exp() - 1.0;
if denom <= 0.0 {
return 0.0;
}
let radiance_per_m = 2.0 * H_PLANCK * C0 * C0 / (lambda_m.powi(5) * denom);
radiance_per_m * 1e-9 }
}
#[derive(Debug, Clone)]
pub struct DetectorNoiseModel {
pub shot_noise_fraction: f64,
pub thermal_noise_a2_per_hz: f64,
pub flicker_corner_hz: f64,
pub total_bandwidth_hz: f64,
}
impl DetectorNoiseModel {
pub fn new(
thermal_noise_a2_per_hz: f64,
flicker_corner_hz: f64,
total_bandwidth_hz: f64,
) -> Result<Self, OxiPhotonError> {
if thermal_noise_a2_per_hz < 0.0 || !thermal_noise_a2_per_hz.is_finite() {
return Err(OxiPhotonError::NumericalError(
"thermal_noise_a2_per_hz must be non-negative and finite".into(),
));
}
if total_bandwidth_hz <= 0.0 {
return Err(OxiPhotonError::NumericalError(
"total_bandwidth_hz must be positive".into(),
));
}
Ok(Self {
shot_noise_fraction: 0.0, thermal_noise_a2_per_hz,
flicker_corner_hz,
total_bandwidth_hz,
})
}
pub fn shot_noise_dominated_power_w(&self, responsivity: f64) -> f64 {
if responsivity == 0.0 {
return f64::INFINITY;
}
let i_cross = self.thermal_noise_a2_per_hz / (2.0 * E_CHARGE);
i_cross / responsivity
}
pub fn noise_current_density_a_per_sqrt_hz(&self, signal_current_a: f64, freq_hz: f64) -> f64 {
let i_shot_sq = 2.0 * E_CHARGE * signal_current_a;
let i_th_sq = self.thermal_noise_a2_per_hz;
let i_flicker_sq = if freq_hz > 0.0 && self.flicker_corner_hz > 0.0 {
i_th_sq * self.flicker_corner_hz / freq_hz
} else {
0.0
};
(i_shot_sq + i_th_sq + i_flicker_sq).sqrt()
}
pub fn integrated_noise_a(&self, signal_current_a: f64) -> f64 {
let i_shot_sq_hz = 2.0 * E_CHARGE * signal_current_a;
let i_th_sq_hz = self.thermal_noise_a2_per_hz;
let white = (i_shot_sq_hz + i_th_sq_hz) * self.total_bandwidth_hz;
let flicker =
if self.flicker_corner_hz > 0.0 && self.total_bandwidth_hz > self.flicker_corner_hz {
let f_low = self.flicker_corner_hz * 0.001; i_th_sq_hz * self.flicker_corner_hz * (self.total_bandwidth_hz / f_low).ln()
} else {
0.0
};
(white + flicker).sqrt()
}
pub fn shot_noise_mdp_w(&self, responsivity: f64) -> f64 {
if responsivity == 0.0 {
return f64::INFINITY;
}
let i_noise = (2.0 * E_CHARGE * self.total_bandwidth_hz).sqrt();
i_noise / responsivity
}
pub fn thermal_noise_mdp_w(&self, responsivity: f64) -> f64 {
if responsivity == 0.0 {
return f64::INFINITY;
}
let i_noise = (self.thermal_noise_a2_per_hz * self.total_bandwidth_hz).sqrt();
i_noise / responsivity
}
}
pub struct PhotonCounting;
impl PhotonCounting {
pub fn poisson_probability(n: usize, mean: f64) -> f64 {
if mean < 0.0 {
return 0.0;
}
if mean == 0.0 {
return if n == 0 { 1.0 } else { 0.0 };
}
let log_p = n as f64 * mean.ln() - mean - Self::log_factorial(n);
log_p.exp()
}
pub fn detection_probability(mean_photons: f64, efficiency: f64) -> f64 {
1.0 - (-(efficiency * mean_photons)).exp()
}
pub fn false_positive_rate(dark_count_rate: f64, time_window_s: f64) -> f64 {
1.0 - (-(dark_count_rate * time_window_s)).exp()
}
pub fn optimal_threshold(signal_mean: f64, dark_mean: f64) -> usize {
if signal_mean <= dark_mean || dark_mean == 0.0 || signal_mean == 0.0 {
return 1;
}
let n_opt = (signal_mean - dark_mean) / (signal_mean / dark_mean).ln();
(n_opt.round() as usize).max(1)
}
pub fn photon_counting_ber(signal_mean: f64, dark_mean: f64) -> f64 {
let n_th = Self::optimal_threshold(signal_mean, dark_mean);
let p_detect = (n_th..=1000)
.map(|n| Self::poisson_probability(n, signal_mean))
.sum::<f64>();
let p_miss = 1.0 - p_detect;
let p_false: f64 = (n_th..=1000)
.map(|n| Self::poisson_probability(n, dark_mean))
.sum();
0.5 * (p_miss + p_false)
}
pub fn required_photons_for_ber(ber_target: f64, dark_mean: f64) -> f64 {
let mut lo = dark_mean.max(1e-3);
let mut hi = 1000.0_f64;
for _ in 0..64 {
let mid = 0.5 * (lo + hi);
let ber = Self::photon_counting_ber(mid, dark_mean);
if ber > ber_target {
lo = mid;
} else {
hi = mid;
}
}
0.5 * (lo + hi)
}
fn log_factorial(n: usize) -> f64 {
const EXACT_LIMIT: usize = 20;
if n <= EXACT_LIMIT {
(1..=n).map(|k| (k as f64).ln()).sum()
} else {
let nf = n as f64;
nf * nf.ln() - nf + 0.5 * (2.0 * std::f64::consts::PI * nf).ln() + 1.0 / (12.0 * nf)
- 1.0 / (360.0 * nf * nf * nf)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_shot_noise_scaling() {
let i1 = NoiseAnalysis::shot_noise_a(1e-6, 1e9);
let i4 = NoiseAnalysis::shot_noise_a(4e-6, 1e9);
assert_relative_eq!(i4 / i1, 2.0, epsilon = 1e-10);
}
#[test]
fn test_johnson_noise_temp_dependence() {
let v1 = NoiseAnalysis::johnson_noise_v(1000.0, 300.0, 1e9);
let v4 = NoiseAnalysis::johnson_noise_v(1000.0, 1200.0, 1e9);
assert_relative_eq!(v4 / v1, 2.0, epsilon = 1e-10);
}
#[test]
fn test_friis_formula() {
let f_total =
NoiseAnalysis::cascaded_noise_figure(&[2.0, 3.0], &[10.0, 10.0]).expect("valid inputs");
assert_relative_eq!(f_total, 2.2, epsilon = 1e-12);
assert!(f_total > 2.0, "cascaded NF should exceed first stage");
}
#[test]
fn test_poisson_probability_normalization() {
let mean = 3.5_f64;
let sum: f64 = (0..=500)
.map(|n| PhotonCounting::poisson_probability(n, mean))
.sum();
assert!(
(sum - 1.0).abs() < 1e-4,
"Poisson sum = {sum}, expected ≈ 1.0 (tol 1e-4)"
);
}
#[test]
fn test_detection_probability() {
let p = PhotonCounting::detection_probability(20.0, 1.0);
assert!(p > 0.999, "P_det = {p}, expected > 0.999");
let p0 = PhotonCounting::detection_probability(10.0, 0.0);
assert_relative_eq!(p0, 0.0, epsilon = 1e-15);
}
#[test]
fn test_photon_counting_ber_decreases_with_signal() {
let dark = 0.1;
let ber_low = PhotonCounting::photon_counting_ber(2.0, dark);
let ber_high = PhotonCounting::photon_counting_ber(10.0, dark);
assert!(
ber_high < ber_low,
"BER should decrease with increasing signal: {ber_high} >= {ber_low}"
);
}
#[test]
fn test_total_noise_quadrature_sum() {
let total = NoiseAnalysis::total_noise_a(&[3.0, 4.0]);
assert_relative_eq!(total, 5.0, epsilon = 1e-12);
}
}