phasesmith_core/
background.rs1use std::error::Error;
8use std::fmt::{Display, Formatter};
9
10#[derive(Clone, Debug, PartialEq, Eq)]
12pub enum BackgroundError {
13 EmptyInput,
15 NonFiniteSample {
17 index: usize,
19 },
20 SizeOverflow,
22 AllocationFailed,
24}
25
26impl Display for BackgroundError {
27 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
28 match self {
29 Self::EmptyInput => formatter.write_str("background input must not be empty"),
30 Self::NonFiniteSample { index } => {
31 write!(formatter, "background input sample {index} must be finite")
32 }
33 Self::SizeOverflow => formatter.write_str("background smoothing size overflow"),
34 Self::AllocationFailed => {
35 formatter.write_str("background smoothing work buffer allocation failed")
36 }
37 }
38 }
39}
40
41impl Error for BackgroundError {}
42
43pub fn smooth_bruckner(
55 y: &[f64],
56 smooth_points: usize,
57 iterations: usize,
58) -> Result<Vec<f64>, BackgroundError> {
59 if y.is_empty() {
60 return Err(BackgroundError::EmptyInput);
61 }
62 if let Some((index, _)) = y.iter().enumerate().find(|(_, value)| !value.is_finite()) {
63 return Err(BackgroundError::NonFiniteSample { index });
64 }
65
66 let double_padding = smooth_points
67 .checked_mul(2)
68 .ok_or(BackgroundError::SizeOverflow)?;
69 let extended_len = y
70 .len()
71 .checked_add(double_padding)
72 .ok_or(BackgroundError::SizeOverflow)?;
73 let window_len = double_padding
74 .checked_add(1)
75 .ok_or(BackgroundError::SizeOverflow)?;
76 let window_len_u32 = u32::try_from(window_len).map_err(|_| BackgroundError::SizeOverflow)?;
77
78 let mut extended = Vec::new();
79 extended
80 .try_reserve_exact(extended_len)
81 .map_err(|_| BackgroundError::AllocationFailed)?;
82 extended.resize(extended_len, 0.0);
83 extended[..smooth_points].fill(y[0]);
84 extended[smooth_points..smooth_points + y.len()].copy_from_slice(y);
85 extended[smooth_points + y.len()..].fill(y[y.len() - 1]);
86
87 let scan_end = y.len().saturating_sub(smooth_points.saturating_add(2));
88 let window_scale = f64::from(window_len_u32);
89 for _ in 0..iterations {
90 let mut window_sum = 0.0;
91 for value in &extended[..window_len] {
92 window_sum += value;
93 }
94 let mut window_average = window_sum / window_scale;
95
96 for index in smooth_points..scan_end {
97 let outgoing = extended[index - smooth_points];
98 let incoming = extended[index + smooth_points + 1];
99 if extended[index] > window_average {
100 let old_value = extended[index];
101 extended[index] = window_average;
102 window_average +=
103 ((window_average - old_value) + (incoming - outgoing)) / window_scale;
104 } else {
105 window_average += (incoming - outgoing) / window_scale;
106 }
107 }
108 }
109
110 Ok(extended[smooth_points..smooth_points + y.len()].to_vec())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn zero_iterations_only_round_trips_the_padded_center() {
119 let y = [1.0, 5.0, 2.0, 9.0];
120 assert_eq!(smooth_bruckner(&y, 3, 0), Ok(y.to_vec()));
121 }
122
123 #[test]
124 fn zero_half_window_preserves_every_sample() {
125 let y = [1.0, 5.0, 2.0, 9.0];
126 assert_eq!(smooth_bruckner(&y, 0, 20), Ok(y.to_vec()));
127 }
128
129 #[test]
130 fn clipping_and_trailing_compatibility_range_are_explicit() {
131 let y = [0.0, 0.0, 9.0, 0.0, 0.0, 7.0, 8.0, 9.0, 10.0];
132 let actual = smooth_bruckner(&y, 1, 1).expect("valid smoother input");
133 assert_eq!(actual, vec![0.0, 0.0, 3.0, 0.0, 0.0, 7.0, 8.0, 9.0, 10.0]);
134 }
135
136 #[test]
137 fn window_larger_than_signal_is_a_safe_noop() {
138 let y = [1.0, 4.0, 2.0];
139 assert_eq!(smooth_bruckner(&y, 20, 5), Ok(y.to_vec()));
140 }
141
142 #[test]
143 fn invalid_samples_are_rejected_with_their_index() {
144 assert_eq!(smooth_bruckner(&[], 1, 1), Err(BackgroundError::EmptyInput));
145 assert_eq!(
146 smooth_bruckner(&[1.0, f64::NAN], 1, 1),
147 Err(BackgroundError::NonFiniteSample { index: 1 })
148 );
149 }
150}