use std::f64::consts::PI;
const KB: f64 = 1.380649e-23;
#[allow(dead_code)]
const NA: f64 = 6.02214076e23;
#[allow(dead_code)]
const C_LIGHT: f64 = 2.99792458e8;
#[derive(Debug, Clone)]
pub struct PaCell {
pub length_m: f64,
pub radius_m: f64,
pub c_sound: f64,
pub q_factor: f64,
}
impl PaCell {
pub fn fundamental_frequency_hz(&self) -> f64 {
self.c_sound / (2.0 * self.length_m)
}
pub fn radial_resonance_hz(&self) -> f64 {
1.22 * self.c_sound / (2.0 * self.radius_m)
}
pub fn signal_enhancement(&self) -> f64 {
self.q_factor
}
pub fn min_detectable_absorption(&self, power_w: f64, nep_pa_hz: f64) -> f64 {
let gamma_gas = 0.40; let v_cell = PI * self.radius_m * self.radius_m * self.length_m;
let denom = gamma_gas * self.q_factor * power_w * v_cell;
if denom.abs() < 1.0e-30 {
return f64::INFINITY;
}
nep_pa_hz / denom
}
pub fn nnea(&self, noise_pa_hz: f64, power_w: f64) -> f64 {
if power_w <= 0.0 {
return f64::INFINITY;
}
let v_cell_cm3 = PI * (self.radius_m * 100.0).powi(2) * (self.length_m * 100.0);
noise_pa_hz / (power_w * self.q_factor * v_cell_cm3.max(1.0e-20))
}
}
#[derive(Debug, Clone)]
pub struct PaGasSensor {
pub laser_wavelength_nm: f64,
pub absorption_cross_section_cm2: f64,
pub cell: PaCell,
pub power_w: f64,
}
impl PaGasSensor {
pub fn signal_per_ppm(&self, temperature_k: f64, pressure_pa: f64) -> f64 {
let n_per_m3_per_ppm = 1.0e-6 * pressure_pa / (KB * temperature_k.max(1.0));
let sigma_m2 = self.absorption_cross_section_cm2 * 1.0e-4;
let mu_a_per_ppm = n_per_m3_per_ppm * sigma_m2;
let gamma_gas = 0.40;
gamma_gas * mu_a_per_ppm * self.power_w * self.cell.q_factor
}
pub fn detection_limit_ppb(&self, nep_pa: f64, temperature_k: f64, pressure_pa: f64) -> f64 {
let sens_per_ppm = self.signal_per_ppm(temperature_k, pressure_pa);
if sens_per_ppm <= 0.0 {
return f64::INFINITY;
}
nep_pa / sens_per_ppm * 1000.0
}
pub fn methane_at_3311nm() -> (f64, f64) {
(2.0e-19, 3311.0)
}
pub fn co2_at_4260nm() -> (f64, f64) {
(1.6e-18, 4260.0)
}
pub fn no2_at_450nm() -> (f64, f64) {
(6.0e-19, 450.0)
}
pub fn for_methane(cell: PaCell, power_w: f64) -> Self {
let (sigma, lambda) = Self::methane_at_3311nm();
Self {
laser_wavelength_nm: lambda,
absorption_cross_section_cm2: sigma,
cell,
power_w,
}
}
pub fn for_co2(cell: PaCell, power_w: f64) -> Self {
let (sigma, lambda) = Self::co2_at_4260nm();
Self {
laser_wavelength_nm: lambda,
absorption_cross_section_cm2: sigma,
cell,
power_w,
}
}
pub fn absorption_coeff_per_m(
&self,
concentration_ppm: f64,
temperature_k: f64,
pressure_pa: f64,
) -> f64 {
let n_total = pressure_pa / (KB * temperature_k.max(1.0)); let n_gas = concentration_ppm * 1.0e-6 * n_total;
let sigma_m2 = self.absorption_cross_section_cm2 * 1.0e-4;
n_gas * sigma_m2
}
pub fn signal_pa(&self, concentration_ppm: f64, temperature_k: f64, pressure_pa: f64) -> f64 {
let gamma_gas = 0.40;
let mu_a = self.absorption_coeff_per_m(concentration_ppm, temperature_k, pressure_pa);
gamma_gas * mu_a * self.power_w * self.cell.q_factor
}
}
pub fn beer_lambert_absorptance(mu_a_per_m: f64, path_length_m: f64) -> f64 {
1.0 - (-mu_a_per_m * path_length_m).exp()
}
pub fn cross_section_cm2_to_m2(sigma_cm2: f64) -> f64 {
sigma_cm2 * 1.0e-4
}
pub fn ideal_gas_number_density(pressure_pa: f64, temperature_k: f64) -> f64 {
pressure_pa / (KB * temperature_k.max(1.0))
}
pub fn mole_fraction_to_partial_pressure(mole_fraction: f64, total_pressure_pa: f64) -> f64 {
mole_fraction * total_pressure_pa
}
#[cfg(test)]
mod tests {
use super::*;
fn test_cell() -> PaCell {
PaCell {
length_m: 0.15, radius_m: 0.005, c_sound: 343.0, q_factor: 50.0,
}
}
#[test]
fn pa_cell_longitudinal_resonance() {
let cell = test_cell();
let f0 = cell.fundamental_frequency_hz();
let expected = 343.0 / (2.0 * 0.15);
assert!(
(f0 - expected).abs() < 1.0e-9,
"f0={}Hz expected={}Hz",
f0,
expected
);
assert!(
f0 > 1000.0 && f0 < 2000.0,
"Longitudinal resonance out of range: {}",
f0
);
}
#[test]
fn pa_cell_radial_resonance() {
let cell = test_cell();
let f_rad = cell.radial_resonance_hz();
let expected = 1.22 * 343.0 / (2.0 * 0.005);
assert!((f_rad - expected).abs() < 1.0e-9, "f_rad={}", f_rad);
assert!(f_rad > 10.0e3 && f_rad < 100.0e3, "f_rad={}", f_rad);
}
#[test]
fn signal_enhancement_equals_q() {
let cell = test_cell();
assert!((cell.signal_enhancement() - cell.q_factor).abs() < 1.0e-10);
}
#[test]
fn gas_sensor_signal_scales_with_concentration() {
let sensor = PaGasSensor::for_methane(test_cell(), 0.01);
let s1 = sensor.signal_pa(1.0, 296.0, 101325.0);
let s10 = sensor.signal_pa(10.0, 296.0, 101325.0);
assert!(s10 > 0.0, "Signal must be positive");
assert!(
(s10 / s1 - 10.0).abs() < 1.0e-8,
"Signal should be linear in concentration"
);
}
#[test]
fn detection_limit_ppb_methane() {
let sensor = PaGasSensor::for_methane(test_cell(), 0.01);
let limit = sensor.detection_limit_ppb(1.0e-6, 296.0, 101325.0);
assert!(
limit > 0.0 && limit.is_finite(),
"Detection limit must be positive finite"
);
assert!(
limit < 1.0e9,
"Detection limit unreasonably large: {}ppb",
limit
);
}
#[test]
fn beer_lambert_absorptance_small() {
let mu_a = 0.001; let l = 0.1; let a = beer_lambert_absorptance(mu_a, l);
let approx = mu_a * l;
assert!((a - approx).abs() < 1.0e-6, "A={}≈{}", a, approx);
}
#[test]
fn ideal_gas_density_stp() {
let n = ideal_gas_number_density(101325.0, 273.15);
let loschmidt = 2.6867774e25;
let rel_err = (n - loschmidt).abs() / loschmidt;
assert!(rel_err < 0.01, "N={:.3e} expected {:.3e}", n, loschmidt);
}
#[test]
fn gas_references_positive_sigma() {
let (sigma_ch4, lambda_ch4) = PaGasSensor::methane_at_3311nm();
assert!(sigma_ch4 > 0.0 && lambda_ch4 > 0.0);
let (sigma_co2, lambda_co2) = PaGasSensor::co2_at_4260nm();
assert!(sigma_co2 > 0.0 && lambda_co2 > 0.0);
let (sigma_no2, lambda_no2) = PaGasSensor::no2_at_450nm();
assert!(sigma_no2 > 0.0 && lambda_no2 > 0.0);
assert!(lambda_no2 < lambda_ch4 && lambda_ch4 < lambda_co2);
}
#[test]
fn signal_per_ppm_positive() {
let sensor = PaGasSensor::for_co2(test_cell(), 0.1);
let s = sensor.signal_per_ppm(296.0, 101325.0);
assert!(s > 0.0 && s.is_finite(), "signal_per_ppm={}", s);
}
}