use crate::error::OxiPhotonError;
use crate::oct::spectral_domain::SdOct;
use num_complex::Complex64;
use std::f64::consts::PI;
fn fft_inplace(buf: &mut [Complex64]) {
let n = buf.len();
debug_assert!(n.is_power_of_two());
let mut j = 0usize;
for i in 1..n {
let mut bit = n >> 1;
while j & bit != 0 {
j ^= bit;
bit >>= 1;
}
j ^= bit;
if i < j {
buf.swap(i, j);
}
}
let mut len = 2usize;
while len <= n {
let half = len / 2;
let w_step = -2.0 * PI / len as f64;
for chunk_start in (0..n).step_by(len) {
for k in 0..half {
let angle = w_step * k as f64;
let w = Complex64::new(angle.cos(), angle.sin());
let u = buf[chunk_start + k];
let v = w * buf[chunk_start + k + half];
buf[chunk_start + k] = u + v;
buf[chunk_start + k + half] = u - v;
}
}
len <<= 1;
}
}
fn next_pow2(n: usize) -> usize {
if n <= 1 {
return 1;
}
let mut p = 1usize;
while p < n {
p <<= 1;
}
p
}
#[derive(Debug, Clone)]
pub enum WindowFunction {
Rectangular,
Hann,
Hamming,
Gaussian {
sigma: f64,
},
BlackmanHarris,
}
impl WindowFunction {
fn coefficient(&self, i: usize, n: usize) -> f64 {
let x = i as f64 / (n - 1).max(1) as f64; match self {
WindowFunction::Rectangular => 1.0,
WindowFunction::Hann => 0.5 * (1.0 - (2.0 * PI * x).cos()),
WindowFunction::Hamming => 0.54 - 0.46 * (2.0 * PI * x).cos(),
WindowFunction::Gaussian { sigma } => {
let center = 0.5;
let s = *sigma;
let arg = (x - center) / s;
(-0.5 * arg * arg).exp()
}
WindowFunction::BlackmanHarris => {
let a0 = 0.358_750_287;
let a1 = 0.488_290_653;
let a2 = 0.141_279_508;
let a3 = 0.011_681_552;
a0 - a1 * (2.0 * PI * x).cos() + a2 * (4.0 * PI * x).cos()
- a3 * (6.0 * PI * x).cos()
}
}
}
}
#[derive(Debug, Clone)]
pub struct AScanProcessor {
pub n_pts: usize,
pub lambda_min_nm: f64,
pub lambda_max_nm: f64,
pub sample_index: f64,
pub window: WindowFunction,
}
impl AScanProcessor {
pub fn new(
n_pts: usize,
lambda_min_nm: f64,
lambda_max_nm: f64,
n_sample: f64,
window: WindowFunction,
) -> Result<Self, OxiPhotonError> {
if n_pts == 0 {
return Err(OxiPhotonError::NumericalError(
"n_pts must be > 0".to_string(),
));
}
if lambda_min_nm <= 0.0 || lambda_max_nm <= lambda_min_nm {
return Err(OxiPhotonError::NumericalError(format!(
"invalid wavelength range: [{lambda_min_nm}, {lambda_max_nm}] nm"
)));
}
if n_sample < 1.0 {
return Err(OxiPhotonError::NumericalError(format!(
"sample index must be >= 1.0, got {n_sample}"
)));
}
Ok(Self {
n_pts,
lambda_min_nm,
lambda_max_nm,
sample_index: n_sample,
window,
})
}
pub fn window_coefficients(&self) -> Vec<f64> {
(0..self.n_pts)
.map(|i| self.window.coefficient(i, self.n_pts))
.collect()
}
pub fn process(&self, raw_spectrum: &[f64]) -> Result<Vec<f64>, OxiPhotonError> {
if raw_spectrum.len() != self.n_pts {
return Err(OxiPhotonError::NumericalError(format!(
"input length {} ≠ n_pts {}",
raw_spectrum.len(),
self.n_pts
)));
}
let dc_removed = self.remove_dc(raw_spectrum);
let win = self.window_coefficients();
let windowed: Vec<Complex64> = dc_removed
.iter()
.zip(win.iter())
.map(|(&v, &w)| Complex64::new(v * w, 0.0))
.collect();
let fft_len = next_pow2(self.n_pts);
let mut buf = vec![Complex64::new(0.0, 0.0); fft_len];
for (i, &v) in windowed.iter().enumerate() {
buf[i] = v;
}
fft_inplace(&mut buf);
let result = buf[..fft_len / 2]
.iter()
.map(|c| {
let p = c.norm_sqr();
if p > 1e-30 {
10.0 * p.log10()
} else {
-300.0
}
})
.collect();
Ok(result)
}
pub fn depth_axis_um(&self) -> Vec<f64> {
let lambda_min_um = self.lambda_min_nm * 1e-3;
let lambda_max_um = self.lambda_max_nm * 1e-3;
let k_range = 2.0 * PI / lambda_min_um - 2.0 * PI / lambda_max_um;
let dz = PI / (self.sample_index * k_range);
let fft_len = next_pow2(self.n_pts);
(0..fft_len / 2).map(|i| i as f64 * dz).collect()
}
pub fn find_peaks(&self, a_scan_db: &[f64], min_prominence_db: f64) -> Vec<(f64, f64)> {
let depths = self.depth_axis_um();
let n = a_scan_db.len().min(depths.len());
let mut peaks = Vec::new();
for i in 1..n.saturating_sub(1) {
let amp = a_scan_db[i];
let left = a_scan_db[i - 1];
let right = a_scan_db[i + 1];
if amp > left && amp > right {
let local_min = left.min(right);
let prominence = amp - local_min;
if prominence >= min_prominence_db {
peaks.push((depths[i], amp));
}
}
}
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
peaks
}
pub fn remove_dc(&self, spectrum: &[f64]) -> Vec<f64> {
if spectrum.is_empty() {
return Vec::new();
}
let mean = spectrum.iter().sum::<f64>() / spectrum.len() as f64;
spectrum.iter().map(|&v| v - mean).collect()
}
pub fn dispersion_compensate(&self, spectrum: &[f64], a2_um2: f64, a3_um3: f64) -> Vec<f64> {
let n = spectrum.len();
let lambda_min_um = self.lambda_min_nm * 1e-3;
let lambda_max_um = self.lambda_max_nm * 1e-3;
let k_min = 2.0 * PI / lambda_max_um;
let k_max = 2.0 * PI / lambda_min_um;
let k0 = (k_min + k_max) / 2.0;
let dk = (k_max - k_min) / (n - 1).max(1) as f64;
spectrum
.iter()
.enumerate()
.map(|(i, &s)| {
let k = k_min + i as f64 * dk;
let dk_rel = k - k0;
let phase = -(a2_um2 * dk_rel * dk_rel + a3_um3 * dk_rel * dk_rel * dk_rel);
let correction = Complex64::new(phase.cos(), phase.sin());
let e = Complex64::new(s, 0.0) * correction;
e.re
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct DopplerOct {
pub sd_oct: SdOct,
pub a_scan_rate_hz: f64,
}
impl DopplerOct {
pub fn new(sd_oct: SdOct, rate_hz: f64) -> Result<Self, OxiPhotonError> {
if rate_hz <= 0.0 {
return Err(OxiPhotonError::NumericalError(format!(
"A-scan rate must be positive, got {rate_hz} Hz"
)));
}
Ok(Self {
sd_oct,
a_scan_rate_hz: rate_hz,
})
}
pub fn max_velocity_mm_per_s(&self) -> f64 {
let lambda0_um = self.sd_oct.center_wavelength_nm * 1e-3;
let delta_t = 1.0 / self.a_scan_rate_hz;
let v_um_per_s = lambda0_um / (4.0 * self.sd_oct.sample_index * delta_t);
v_um_per_s * 1e-3 }
pub fn min_velocity_mm_per_s(&self, phase_noise_rad: f64) -> f64 {
let lambda0_um = self.sd_oct.center_wavelength_nm * 1e-3;
let delta_t = 1.0 / self.a_scan_rate_hz;
let v_um_per_s =
phase_noise_rad * lambda0_um / (4.0 * PI * self.sd_oct.sample_index * delta_t);
v_um_per_s * 1e-3
}
pub fn phase_to_velocity_mm_per_s(&self, delta_phi_rad: f64) -> f64 {
let lambda0_um = self.sd_oct.center_wavelength_nm * 1e-3;
let delta_t = 1.0 / self.a_scan_rate_hz;
let v_um_per_s =
delta_phi_rad * lambda0_um / (4.0 * PI * self.sd_oct.sample_index * delta_t);
v_um_per_s * 1e-3
}
pub fn doppler_bandwidth_hz(&self) -> f64 {
self.a_scan_rate_hz / 2.0
}
pub fn velocity_resolution_mm_per_s(&self, snr_linear: f64) -> f64 {
if snr_linear <= 0.0 {
return f64::INFINITY;
}
let lambda0_um = self.sd_oct.center_wavelength_nm * 1e-3;
let delta_t = 1.0 / self.a_scan_rate_hz;
let v_um_per_s =
lambda0_um / (4.0 * PI * self.sd_oct.sample_index * delta_t * snr_linear.sqrt());
v_um_per_s * 1e-3
}
pub fn phase_difference(a_scan1: &[Complex64], a_scan2: &[Complex64]) -> Vec<f64> {
a_scan1
.iter()
.zip(a_scan2.iter())
.map(|(&e1, &e2)| {
let cross = e2 * e1.conj();
cross.arg()
})
.collect()
}
pub fn poiseuille_profile(v_max_mm_per_s: f64, r_max_um: f64, n_pts: usize) -> Vec<(f64, f64)> {
if n_pts == 0 || r_max_um <= 0.0 {
return Vec::new();
}
(0..n_pts)
.map(|i| {
let r = i as f64 / (n_pts - 1).max(1) as f64 * r_max_um;
let v = v_max_mm_per_s * (1.0 - (r / r_max_um).powi(2));
(r, v)
})
.collect()
}
}
pub struct OctMetrics;
impl OctMetrics {
pub fn snr_db(a_scan_db: &[f64], signal_idx: usize, noise_region: (usize, usize)) -> f64 {
if a_scan_db.is_empty() || signal_idx >= a_scan_db.len() {
return f64::NEG_INFINITY;
}
let signal = a_scan_db[signal_idx];
let noise = Self::noise_floor_db(a_scan_db, noise_region);
signal - noise
}
pub fn dynamic_range_db(a_scan_db: &[f64]) -> f64 {
if a_scan_db.is_empty() {
return 0.0;
}
let max = a_scan_db.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min = a_scan_db.iter().cloned().fold(f64::INFINITY, f64::min);
(max - min).max(0.0)
}
pub fn measure_axial_resolution_um(a_scan: &[f64], depth_axis: &[f64]) -> f64 {
let n = a_scan.len().min(depth_axis.len());
if n < 3 {
return 0.0;
}
let (peak_idx, &peak_val) = a_scan[..n]
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, &0.0));
let half_max = peak_val - 3.0;
let left_depth = (1..=peak_idx)
.rev()
.find(|&i| a_scan[i - 1] <= half_max)
.map(|i| {
let d0 = depth_axis[i - 1];
let d1 = depth_axis[i];
let a0 = a_scan[i - 1];
let a1 = a_scan[i];
if (a1 - a0).abs() < 1e-12 {
d0
} else {
d0 + (half_max - a0) / (a1 - a0) * (d1 - d0)
}
})
.unwrap_or(depth_axis[0]);
let right_depth = (peak_idx..n - 1)
.find(|&i| a_scan[i + 1] <= half_max)
.map(|i| {
let d0 = depth_axis[i];
let d1 = depth_axis[i + 1];
let a0 = a_scan[i];
let a1 = a_scan[i + 1];
if (a1 - a0).abs() < 1e-12 {
d1
} else {
d0 + (half_max - a0) / (a1 - a0) * (d1 - d0)
}
})
.unwrap_or(depth_axis[n - 1]);
(right_depth - left_depth).abs()
}
pub fn cnr_db(signal_mean: f64, background_mean: f64, noise_std: f64) -> f64 {
if noise_std <= 0.0 {
return f64::INFINITY;
}
let contrast = (signal_mean - background_mean).abs();
if contrast <= 0.0 {
return f64::NEG_INFINITY;
}
20.0 * (contrast / noise_std).log10()
}
pub fn noise_floor_db(a_scan_db: &[f64], noise_region: (usize, usize)) -> f64 {
let (start, end) = noise_region;
let end = end.min(a_scan_db.len());
if start >= end {
return f64::NEG_INFINITY;
}
let slice = &a_scan_db[start..end];
slice.iter().sum::<f64>() / slice.len() as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn make_sd_oct() -> SdOct {
SdOct::new(830.0, 70.0, 1024, 10.0, 0.1, 1.35).unwrap()
}
fn make_processor() -> AScanProcessor {
AScanProcessor::new(1024, 795.0, 865.0, 1.35, WindowFunction::Hann).unwrap()
}
#[test]
fn test_window_hann_endpoints() {
let proc = make_processor();
let win = proc.window_coefficients();
assert!(!win.is_empty());
assert_relative_eq!(win[0], 0.0, epsilon = 1e-10);
assert_relative_eq!(*win.last().unwrap(), 0.0, epsilon = 1e-10);
}
#[test]
fn test_window_rectangular_uniform() {
let proc =
AScanProcessor::new(64, 795.0, 865.0, 1.35, WindowFunction::Rectangular).unwrap();
let win = proc.window_coefficients();
for &w in &win {
assert_relative_eq!(w, 1.0, epsilon = 1e-12);
}
}
#[test]
fn test_dc_removal() {
let proc = make_processor();
let spectrum: Vec<f64> = (0..1024).map(|i| 5.0 + (i as f64 * 0.01).sin()).collect();
let dc_removed = proc.remove_dc(&spectrum);
let mean = dc_removed.iter().sum::<f64>() / dc_removed.len() as f64;
assert_relative_eq!(mean, 0.0, epsilon = 1e-9);
}
#[test]
fn test_a_scan_processor_output_length() {
let proc = make_processor();
let spectrum = vec![1.0f64; 1024];
let out = proc.process(&spectrum).unwrap();
assert_eq!(out.len(), 512);
}
#[test]
fn test_doppler_max_velocity() {
let sd_oct = make_sd_oct();
let rate_hz = 20_000.0; let dop = DopplerOct::new(sd_oct.clone(), rate_hz).unwrap();
let lambda0_um = sd_oct.center_wavelength_nm * 1e-3;
let delta_t = 1.0 / rate_hz;
let expected_mm_per_s = lambda0_um / (4.0 * sd_oct.sample_index * delta_t) * 1e-3;
assert_relative_eq!(
dop.max_velocity_mm_per_s(),
expected_mm_per_s,
epsilon = 1e-6
);
}
#[test]
fn test_phase_to_velocity_conversion() {
let sd_oct = make_sd_oct();
let dop = DopplerOct::new(sd_oct, 20_000.0).unwrap();
let v_max = dop.max_velocity_mm_per_s();
let v_from_phase = dop.phase_to_velocity_mm_per_s(PI);
assert_relative_eq!(v_from_phase, v_max, epsilon = 1e-6);
}
#[test]
fn test_poiseuille_profile_max_at_center() {
let v_max = 10.0; let r_max = 50.0; let n_pts = 101;
let profile = DopplerOct::poiseuille_profile(v_max, r_max, n_pts);
assert_eq!(profile.len(), n_pts);
let (r0, v0) = profile[0];
assert_relative_eq!(r0, 0.0, epsilon = 1e-12);
assert_relative_eq!(v0, v_max, epsilon = 1e-10);
let (r_last, v_last) = *profile.last().unwrap();
assert_relative_eq!(r_last, r_max, epsilon = 1e-10);
assert_relative_eq!(v_last, 0.0, epsilon = 1e-10);
let velocities: Vec<f64> = profile.iter().map(|&(_, v)| v).collect();
for i in 1..velocities.len() {
assert!(
velocities[i] <= velocities[i - 1] + 1e-10,
"velocity not monotonically decreasing at i={i}"
);
}
}
#[test]
fn test_dynamic_range_db_positive() {
let mut a_scan = vec![-80.0f64; 512];
a_scan[100] = -20.0; let dr = OctMetrics::dynamic_range_db(&a_scan);
assert!(dr > 0.0, "dynamic range should be positive, got {dr}");
assert_relative_eq!(dr, 60.0, epsilon = 1e-10);
}
}