use crate::error::{OxiPhotonError, Result};
const C0: f64 = 2.99792458e8;
const H_PLANCK: f64 = 6.62607015e-34;
const K_BOLTZMANN: f64 = 1.380649e-23;
#[derive(Debug, Clone)]
pub struct OtdrEvent {
pub position_km: f64,
pub loss_db: f64,
pub reflectance_db: f64,
}
impl OtdrEvent {
pub fn splice(position_km: f64, loss_db: f64) -> Self {
Self {
position_km,
loss_db,
reflectance_db: -80.0,
}
}
pub fn connector(position_km: f64, loss_db: f64) -> Self {
Self {
position_km,
loss_db,
reflectance_db: -14.0,
}
}
pub fn fiber_end(position_km: f64) -> Self {
Self {
position_km,
loss_db: 100.0,
reflectance_db: -14.3,
}
}
pub fn fault(position_km: f64) -> Self {
Self {
position_km,
loss_db: 10.0,
reflectance_db: -40.0,
}
}
}
#[derive(Debug, Clone)]
pub struct Otdr {
pub wavelength_nm: f64,
pub pulse_width_ns: f64,
pub peak_power_mw: f64,
pub fiber_loss_db_per_km: f64,
pub backscatter_coefficient_db: f64,
pub receiver_noise_floor_dbm: f64,
pub averaging_factor: usize,
pub group_index: f64,
}
impl Otdr {
pub fn new(
lambda_nm: f64,
pulse_ns: f64,
power_mw: f64,
loss_db_km: f64,
backscatter_db: f64,
noise_floor_dbm: f64,
) -> Result<Self> {
if lambda_nm <= 0.0 || !lambda_nm.is_finite() {
return Err(OxiPhotonError::InvalidWavelength(lambda_nm * 1e-9));
}
if pulse_ns <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"pulse_width_ns must be positive, got {pulse_ns}"
)));
}
if power_mw <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"peak_power_mw must be positive, got {power_mw}"
)));
}
if loss_db_km <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"fiber_loss_db_per_km must be positive, got {loss_db_km}"
)));
}
Ok(Self {
wavelength_nm: lambda_nm,
pulse_width_ns: pulse_ns,
peak_power_mw: power_mw,
fiber_loss_db_per_km: loss_db_km,
backscatter_coefficient_db: backscatter_db,
receiver_noise_floor_dbm: noise_floor_dbm,
averaging_factor: 1,
group_index: 1.4682,
})
}
pub fn smf28_1550() -> Result<Self> {
Self::new(1550.0, 100.0, 1.0, 0.2, -77.0, -45.0)
}
pub fn spatial_resolution_m(&self) -> f64 {
let tau_s = self.pulse_width_ns * 1e-9;
C0 * tau_s / (2.0 * self.group_index)
}
pub fn dynamic_range_db(&self) -> f64 {
let p_launch_dbm = 10.0 * (self.peak_power_mw).log10();
let snr_avg = if self.averaging_factor > 1 {
5.0 * (self.averaging_factor as f64).log10()
} else {
0.0
};
p_launch_dbm - self.receiver_noise_floor_dbm + snr_avg
}
pub fn max_distance_km(&self) -> f64 {
self.dynamic_range_db() / (2.0 * self.fiber_loss_db_per_km)
}
pub fn backscattered_power_dbm(&self, distance_km: f64) -> f64 {
let p_launch_dbm = 10.0 * self.peak_power_mw.log10();
p_launch_dbm + self.backscatter_coefficient_db
- 2.0 * self.fiber_loss_db_per_km * distance_km
}
pub fn simulate_trace(
&self,
fiber_length_km: f64,
events: &[OtdrEvent],
n_pts: usize,
) -> Vec<(f64, f64)> {
if n_pts == 0 || fiber_length_km <= 0.0 {
return Vec::new();
}
let p_launch_dbm = 10.0 * self.peak_power_mw.log10();
let _dead_zone_km = self.dead_zone_m() / 1000.0;
let mut sorted_events = events.to_vec();
sorted_events.sort_by(|a, b| {
a.position_km
.partial_cmp(&b.position_km)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut cum_loss_db = 0.0_f64;
let mut event_cum_losses: Vec<f64> = Vec::with_capacity(sorted_events.len());
for ev in &sorted_events {
cum_loss_db += ev.loss_db;
event_cum_losses.push(cum_loss_db);
}
let dead_zone_m = self.dead_zone_m();
let _ = event_cum_losses;
(0..n_pts)
.map(|i| {
let z_km = fiber_length_km * i as f64 / (n_pts - 1).max(1) as f64;
let z_m = z_km * 1000.0;
let mut power_dbm = p_launch_dbm + self.backscatter_coefficient_db
- 2.0 * self.fiber_loss_db_per_km * z_km;
let cumulative_event_loss: f64 = sorted_events
.iter()
.filter(|ev| ev.position_km <= z_km)
.map(|ev| ev.loss_db)
.sum();
power_dbm -= cumulative_event_loss;
let spike_mw: f64 = sorted_events
.iter()
.filter(|ev| {
let dz_m = (ev.position_km - z_km).abs() * 1000.0;
dz_m < dead_zone_m && ev.reflectance_db > -70.0
})
.map(|ev| {
let one_way_loss_db = 2.0 * self.fiber_loss_db_per_km * ev.position_km;
let p_spike_dbm = p_launch_dbm + ev.reflectance_db - one_way_loss_db;
10.0_f64.powf(p_spike_dbm / 10.0)
})
.sum();
let bs_mw = 10.0_f64.powf(power_dbm / 10.0);
let total_mw = bs_mw + spike_mw;
let total_dbm = 10.0 * total_mw.max(1e-30).log10();
(z_m, total_dbm)
})
.collect()
}
pub fn locate_reflection(&self, trace: &[(f64, f64)], threshold_db: f64) -> Vec<f64> {
if trace.len() < 3 {
return Vec::new();
}
let n = trace.len();
let mut peaks = Vec::new();
for i in 2..n - 2 {
let (z, p) = trace[i];
let left_avg = (trace[i - 2].1 + trace[i - 1].1) / 2.0;
let right_avg = (trace[i + 1].1 + trace[i + 2].1) / 2.0;
let baseline = (left_avg + right_avg) / 2.0;
if p - baseline > threshold_db && p >= trace[i - 1].1 && p >= trace[i + 1].1 {
peaks.push(z);
}
}
peaks
}
pub fn two_point_loss_db_per_km(
&self,
p1_dbm: f64,
p2_dbm: f64,
z1_km: f64,
z2_km: f64,
) -> f64 {
let dz = z2_km - z1_km;
if dz.abs() < 1e-9 {
return 0.0;
}
(p1_dbm - p2_dbm) / (2.0 * dz)
}
pub fn dead_zone_m(&self) -> f64 {
let tau_s = self.pulse_width_ns * 1e-9;
C0 * tau_s / self.group_index
}
}
#[derive(Debug, Clone)]
pub struct BotdaSensor {
pub fiber_length_km: f64,
pub brillouin_shift_ghz: f64,
pub brillouin_gain_coefficient: f64,
pub spatial_resolution_m: f64,
pub fiber_loss_db_per_km: f64,
pub linewidth_mhz: f64,
pub strain_coefficient_mhz_per_microstrain: f64,
pub temperature_coefficient_mhz_per_c: f64,
}
impl BotdaSensor {
#[allow(clippy::too_many_arguments)]
pub fn new(
length_km: f64,
brillouin_shift_ghz: f64,
gain_coeff: f64,
resolution_m: f64,
loss_db_km: f64,
linewidth_mhz: f64,
strain_coeff: f64,
temp_coeff: f64,
) -> Result<Self> {
if length_km <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"fiber_length_km must be positive, got {length_km}"
)));
}
if resolution_m <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"spatial_resolution_m must be positive, got {resolution_m}"
)));
}
Ok(Self {
fiber_length_km: length_km,
brillouin_shift_ghz,
brillouin_gain_coefficient: gain_coeff,
spatial_resolution_m: resolution_m,
fiber_loss_db_per_km: loss_db_km,
linewidth_mhz,
strain_coefficient_mhz_per_microstrain: strain_coeff,
temperature_coefficient_mhz_per_c: temp_coeff,
})
}
pub fn smf28_standard(length_km: f64, resolution_m: f64) -> Result<Self> {
Self::new(
length_km,
10.83, 5e-11, resolution_m,
0.2, 30.0, 0.05, 1.07, )
}
pub fn frequency_shift_from_temperature(&self, delta_t_c: f64) -> f64 {
self.temperature_coefficient_mhz_per_c * delta_t_c * 1e-3 }
pub fn frequency_shift_from_strain(&self, strain_microstrain: f64) -> f64 {
self.strain_coefficient_mhz_per_microstrain * strain_microstrain * 1e-3 }
pub fn temperature_from_shift(&self, delta_nu_ghz: f64) -> f64 {
let c_t_ghz = self.temperature_coefficient_mhz_per_c * 1e-3;
if c_t_ghz.abs() < 1e-30 {
return 0.0;
}
delta_nu_ghz / c_t_ghz
}
pub fn strain_from_shift(&self, delta_nu_ghz: f64) -> f64 {
let c_e_ghz = self.strain_coefficient_mhz_per_microstrain * 1e-3;
if c_e_ghz.abs() < 1e-30 {
return 0.0;
}
delta_nu_ghz / c_e_ghz
}
pub fn gain_spectrum(&self, freq_ghz: f64, center_ghz: f64) -> f64 {
let half_bw = self.linewidth_mhz / 2.0 * 1e-3; let detuning = freq_ghz - center_ghz;
half_bw * half_bw / (detuning * detuning + half_bw * half_bw)
}
fn brillouin_gain_linear(&self, z_km: f64, pump_power_w: f64) -> f64 {
let alpha_np = self.fiber_loss_db_per_km * std::f64::consts::LN_10 / 10_000.0;
let z_m = z_km * 1e3;
let l_eff = if alpha_np.abs() < 1e-30 {
z_m
} else {
(1.0 - (-alpha_np * z_m).exp()) / alpha_np
};
let aeff = 80e-12; (self.brillouin_gain_coefficient * pump_power_w * l_eff / aeff).exp()
}
pub fn snr_at_distance(&self, z_km: f64, pump_power_w: f64) -> f64 {
let gain = self.brillouin_gain_linear(z_km, pump_power_w);
let alpha_db_per_m = self.fiber_loss_db_per_km / 1000.0;
let loss_factor = 10.0_f64.powf(-2.0 * alpha_db_per_m * z_km * 1000.0 / 10.0);
let noise_figure = 2.0_f64;
gain * loss_factor / noise_figure
}
pub fn temperature_uncertainty_c(&self, snr_linear: f64) -> f64 {
if snr_linear <= 0.0 {
return f64::INFINITY;
}
let linewidth_ghz = self.linewidth_mhz * 1e-3;
let c_t_ghz = self.temperature_coefficient_mhz_per_c * 1e-3;
if c_t_ghz.abs() < 1e-30 {
return f64::INFINITY;
}
linewidth_ghz / (c_t_ghz * snr_linear.sqrt())
}
pub fn strain_uncertainty_microstrain(&self, snr_linear: f64) -> f64 {
if snr_linear <= 0.0 {
return f64::INFINITY;
}
let linewidth_ghz = self.linewidth_mhz * 1e-3;
let c_e_ghz = self.strain_coefficient_mhz_per_microstrain * 1e-3;
if c_e_ghz.abs() < 1e-30 {
return f64::INFINITY;
}
linewidth_ghz / (c_e_ghz * snr_linear.sqrt())
}
pub fn max_range_km(&self, required_snr_db: f64, pump_power_w: f64) -> f64 {
let snr_req = 10.0_f64.powf(required_snr_db / 10.0);
let mut lo = 0.0_f64;
let mut hi = self.fiber_length_km;
if self.snr_at_distance(hi, pump_power_w) >= snr_req {
return hi;
}
if self.snr_at_distance(lo, pump_power_w) < snr_req {
return 0.0;
}
for _ in 0..60 {
let mid = (lo + hi) / 2.0;
if self.snr_at_distance(mid, pump_power_w) >= snr_req {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
}
}
#[derive(Debug, Clone)]
pub struct RamanDts {
pub fiber_length_km: f64,
pub wavelength_nm: f64,
pub raman_shift_cm: f64,
pub spatial_resolution_m: f64,
pub fiber_loss_db_per_km: f64,
pub temperature_accuracy_c: f64,
pub integration_time_s: f64,
pub differential_loss_db_per_km: f64,
}
impl RamanDts {
pub fn new(length_km: f64, lambda_nm: f64, resolution_m: f64) -> Result<Self> {
if length_km <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"fiber_length_km must be positive, got {length_km}"
)));
}
if lambda_nm <= 0.0 || !lambda_nm.is_finite() {
return Err(OxiPhotonError::InvalidWavelength(lambda_nm * 1e-9));
}
if resolution_m <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"spatial_resolution_m must be positive, got {resolution_m}"
)));
}
Ok(Self {
fiber_length_km: length_km,
wavelength_nm: lambda_nm,
raman_shift_cm: 440.0,
spatial_resolution_m: resolution_m,
fiber_loss_db_per_km: 0.35,
temperature_accuracy_c: 1.0,
integration_time_s: 60.0,
differential_loss_db_per_km: 0.05,
})
}
pub fn silica_fiber_1064(length_km: f64, resolution_m: f64) -> Result<Self> {
let mut sys = Self::new(length_km, 1064.0, resolution_m)?;
sys.fiber_loss_db_per_km = 0.35;
sys.raman_shift_cm = 440.0;
Ok(sys)
}
pub fn anti_stokes_wavelength_nm(&self) -> f64 {
let nu_pump_per_m = 1.0 / (self.wavelength_nm * 1e-9); let raman_per_m = self.raman_shift_cm * 100.0; let nu_as = nu_pump_per_m + raman_per_m; 1.0 / nu_as * 1e9 }
pub fn stokes_wavelength_nm(&self) -> f64 {
let nu_pump_per_m = 1.0 / (self.wavelength_nm * 1e-9);
let raman_per_m = self.raman_shift_cm * 100.0;
let nu_s = nu_pump_per_m - raman_per_m; 1.0 / nu_s * 1e9 }
pub fn temperature_from_ratio(&self, stokes_intensity: f64, anti_stokes_intensity: f64) -> f64 {
if stokes_intensity <= 0.0 || anti_stokes_intensity <= 0.0 {
return f64::NAN;
}
let ratio = anti_stokes_intensity / stokes_intensity;
if ratio <= 0.0 {
return f64::NAN;
}
let raman_per_m = self.raman_shift_cm * 100.0;
let hc_delta_nu_over_k = H_PLANCK * C0 * raman_per_m / K_BOLTZMANN;
-hc_delta_nu_over_k / ratio.ln()
}
pub fn expected_ratio(&self, temperature_k: f64) -> f64 {
if temperature_k <= 0.0 {
return f64::NAN;
}
let raman_per_m = self.raman_shift_cm * 100.0;
let hc_delta_nu_over_k = H_PLANCK * C0 * raman_per_m / K_BOLTZMANN;
(hc_delta_nu_over_k / temperature_k).exp()
}
pub fn ratio_temperature_sensitivity(&self, temperature_k: f64) -> f64 {
if temperature_k <= 0.0 {
return f64::NAN;
}
let raman_per_m = self.raman_shift_cm * 100.0;
let hc_delta_nu_over_k = H_PLANCK * C0 * raman_per_m / K_BOLTZMANN;
let ratio = self.expected_ratio(temperature_k);
-ratio * hc_delta_nu_over_k / (temperature_k * temperature_k)
}
pub fn max_range_km(&self) -> f64 {
if self.differential_loss_db_per_km.abs() < 1e-12 {
return self.fiber_length_km;
}
let z_diff = 3.0 / self.differential_loss_db_per_km;
let z_loss = 20.0 / (2.0 * self.fiber_loss_db_per_km);
z_diff.min(z_loss).min(self.fiber_length_km)
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn smf28_otdr() -> Otdr {
Otdr::smf28_1550().expect("SMF-28 OTDR construction should succeed")
}
#[test]
fn test_otdr_spatial_resolution() {
let otdr = smf28_otdr();
let expected = C0 * 100e-9 / (2.0 * 1.4682);
assert_relative_eq!(otdr.spatial_resolution_m(), expected, max_relative = 1e-9);
}
#[test]
fn test_otdr_dynamic_range_positive() {
let otdr = smf28_otdr();
let dr = otdr.dynamic_range_db();
assert!(dr > 0.0, "Dynamic range should be positive, got {dr}");
assert!(dr < 100.0, "Dynamic range suspiciously large: {dr} dB");
}
#[test]
fn test_otdr_backscattered_power_decreases() {
let otdr = smf28_otdr();
let p1 = otdr.backscattered_power_dbm(1.0); let p2 = otdr.backscattered_power_dbm(5.0); let p3 = otdr.backscattered_power_dbm(10.0); assert!(
p1 > p2,
"Backscatter should decrease with distance: P(1km)={p1} P(5km)={p2}"
);
assert!(
p2 > p3,
"Backscatter should decrease with distance: P(5km)={p2} P(10km)={p3}"
);
}
#[test]
fn test_otdr_two_point_loss() {
let otdr = smf28_otdr();
let p1 = otdr.backscattered_power_dbm(1.0);
let p2 = otdr.backscattered_power_dbm(3.0);
let alpha = otdr.two_point_loss_db_per_km(p1, p2, 1.0, 3.0);
assert_relative_eq!(alpha, 0.2, max_relative = 1e-9);
}
#[test]
fn test_otdr_dead_zone_proportional_to_pulse() {
let otdr_short =
Otdr::new(1550.0, 10.0, 1.0, 0.2, -77.0, -45.0).expect("OTDR construction");
let otdr_long =
Otdr::new(1550.0, 100.0, 1.0, 0.2, -77.0, -45.0).expect("OTDR construction");
assert!(
otdr_long.dead_zone_m() > otdr_short.dead_zone_m(),
"Longer pulse → larger dead zone"
);
assert_relative_eq!(
otdr_long.dead_zone_m() / otdr_short.dead_zone_m(),
10.0,
max_relative = 1e-9
);
}
#[test]
fn test_otdr_trace_length() {
let otdr = smf28_otdr();
let trace = otdr.simulate_trace(10.0, &[], 500);
assert_eq!(trace.len(), 500);
}
#[test]
fn test_otdr_trace_distances_start_at_zero() {
let otdr = smf28_otdr();
let trace = otdr.simulate_trace(5.0, &[], 100);
assert!(!trace.is_empty());
assert_relative_eq!(trace[0].0, 0.0, max_relative = 1e-9);
}
#[test]
fn test_otdr_max_distance_positive() {
let otdr = smf28_otdr();
let z_max = otdr.max_distance_km();
assert!(z_max > 0.0, "Max distance should be positive, got {z_max}");
}
fn smf28_botda() -> BotdaSensor {
BotdaSensor::smf28_standard(25.0, 1.0).expect("BOTDA construction should succeed")
}
#[test]
fn test_botda_frequency_shift_temperature() {
let botda = smf28_botda();
let delta_nu = botda.frequency_shift_from_temperature(100.0); let expected = 1.07e-3 * 100.0; assert_relative_eq!(delta_nu, expected, max_relative = 1e-9);
}
#[test]
fn test_botda_temperature_from_shift() {
let botda = smf28_botda();
let dt = 50.0; let shift = botda.frequency_shift_from_temperature(dt);
let dt_recovered = botda.temperature_from_shift(shift);
assert_relative_eq!(dt_recovered, dt, max_relative = 1e-9);
}
#[test]
fn test_botda_gain_spectrum_lorentzian() {
let botda = smf28_botda();
let center = botda.brillouin_shift_ghz;
let g_center = botda.gain_spectrum(center, center);
let g_off = botda.gain_spectrum(center + 0.1, center); assert_relative_eq!(g_center, 1.0, max_relative = 1e-9);
assert!(g_off < g_center, "Off-center gain should be less than peak");
assert!(g_off > 0.0, "Off-center gain should be positive");
}
#[test]
fn test_botda_strain_frequency_shift() {
let botda = smf28_botda();
let shift = botda.frequency_shift_from_strain(100.0); let expected = 0.05e-3 * 100.0; assert_relative_eq!(shift, expected, max_relative = 1e-9);
}
#[test]
fn test_botda_temperature_uncertainty_decreases_with_snr() {
let botda = smf28_botda();
let unc_low = botda.temperature_uncertainty_c(10.0);
let unc_high = botda.temperature_uncertainty_c(100.0);
assert!(
unc_low > unc_high,
"Higher SNR should give lower temperature uncertainty"
);
}
fn silica_dts() -> RamanDts {
RamanDts::silica_fiber_1064(10.0, 1.0).expect("Raman DTS construction")
}
#[test]
fn test_raman_dts_stokes_anti_stokes_wavelengths() {
let dts = silica_dts();
let lambda_as = dts.anti_stokes_wavelength_nm();
let lambda_s = dts.stokes_wavelength_nm();
let lambda_p = dts.wavelength_nm;
assert!(
lambda_as < lambda_p,
"Anti-Stokes ({lambda_as:.2} nm) should be shorter than pump ({lambda_p} nm)"
);
assert!(
lambda_s > lambda_p,
"Stokes ({lambda_s:.2} nm) should be longer than pump ({lambda_p} nm)"
);
let nu_pump = 1.0 / (lambda_p * 1e-9);
let nu_as = 1.0 / (lambda_as * 1e-9);
let nu_s = 1.0 / (lambda_s * 1e-9);
let shift_as = (nu_pump - nu_as).abs();
let shift_s = (nu_s - nu_pump).abs();
assert_relative_eq!(shift_as, shift_s, max_relative = 1e-9);
}
#[test]
fn test_raman_temperature_from_ratio() {
let dts = silica_dts();
let t_ref = 300.0_f64; let ratio = dts.expected_ratio(t_ref);
let t_recovered = dts.temperature_from_ratio(ratio, 1.0);
let t2 = dts.temperature_from_ratio(ratio, 1.0);
let _ = t_recovered;
let raman_per_m = dts.raman_shift_cm * 100.0;
let hc_dk = H_PLANCK * C0 * raman_per_m / K_BOLTZMANN;
let t_check = dts.temperature_from_ratio(1.0, 1.0 / ratio);
let _ = hc_dk;
let _ = t2;
assert!(
(t_check - t_ref).abs() < 0.1,
"Round-trip temperature mismatch: expected {t_ref} K, got {t_check} K"
);
}
#[test]
fn test_raman_expected_ratio_increases_with_temp() {
let dts = silica_dts();
let r_cold = dts.expected_ratio(250.0); let r_warm = dts.expected_ratio(350.0); assert!(
r_cold > r_warm,
"Ratio R=I_S/I_AS should be larger at lower temperature: R(250K)={r_cold}, R(350K)={r_warm}"
);
}
#[test]
fn test_raman_ratio_sensitivity_negative() {
let dts = silica_dts();
let sens = dts.ratio_temperature_sensitivity(300.0);
assert!(
sens < 0.0,
"dR/dT should be negative (ratio decreases with heating), got {sens}"
);
}
#[test]
fn test_raman_max_range_positive() {
let dts = silica_dts();
let z_max = dts.max_range_km();
assert!(z_max > 0.0, "Max range should be positive, got {z_max}");
assert!(
z_max <= dts.fiber_length_km,
"Max range should not exceed fiber length"
);
}
#[test]
fn test_botda_gain_spectrum_at_zero_detuning() {
let botda = smf28_botda();
let center = botda.brillouin_shift_ghz;
let g = botda.gain_spectrum(center, center);
assert_relative_eq!(g, 1.0, max_relative = 1e-10);
}
}