1use super::*;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ResampleMethod {
8 Linear,
16
17 BoxcarAverage,
32
33 Gaussian,
56}
57
58impl SpectrumRecord {
59 pub fn resample(&self, target: &WavelengthAxis, method: ResampleMethod) -> Self {
79 let input_wls = self.wavelength_axis.wavelengths_nm();
80 let input_vals = &self.spectral_data.values;
81 let target_wls = target.wavelengths_nm();
82
83 let (values, fwhm_used): (Vec<f64>, Option<f64>) = match method {
84 ResampleMethod::Linear => (
85 target_wls
86 .iter()
87 .map(|&wl| linear_interp(&input_wls, input_vals, wl))
88 .collect(),
89 None,
90 ),
91 ResampleMethod::BoxcarAverage => {
92 let half_step = mean_half_step(&target_wls);
93 (
94 target_wls
95 .iter()
96 .map(|&wl| boxcar_avg(&input_wls, input_vals, wl, half_step))
97 .collect(),
98 None,
99 )
100 }
101 ResampleMethod::Gaussian => {
102 let fwhm = self
103 .metadata
104 .measurement_conditions
105 .as_ref()
106 .and_then(|mc| mc.spectral_resolution_nm)
107 .unwrap_or_else(|| mean_half_step(&target_wls) * 2.0);
108 let sigma = fwhm / (8.0_f64 * 2.0_f64.ln()).sqrt();
109 (
110 target_wls
111 .iter()
112 .map(|&wl| gaussian_avg(&input_wls, input_vals, wl, sigma))
113 .collect(),
114 Some(fwhm),
115 )
116 }
117 };
118
119 let step = provenance_step(&target_wls, method, fwhm_used);
120 let provenance = Some(match self.provenance.clone() {
121 Some(mut p) => {
122 let steps = p.processing_steps.get_or_insert_with(Vec::new);
123 steps.push(step);
124 p
125 }
126 None => Provenance {
127 software: None,
128 software_version: None,
129 source_file: None,
130 source_format: None,
131 processing_steps: Some(vec![step]),
132 notes: None,
133 },
134 });
135
136 Self {
137 wavelength_axis: target.clone(),
138 spectral_data: SpectralData {
139 values,
140 uncertainty: None,
141 scale: self.spectral_data.scale.clone(),
142 },
143 provenance,
144 ..self.clone()
145 }
146 }
147}
148
149fn linear_interp(wls: &[f64], vals: &[f64], target: f64) -> f64 {
154 debug_assert!(!wls.is_empty() && wls.len() == vals.len());
155 let i = wls.partition_point(|&w| w < target);
156 match i {
157 0 => vals[0],
158 i if i == wls.len() => vals[wls.len() - 1],
159 i => {
160 let t = (target - wls[i - 1]) / (wls[i] - wls[i - 1]);
161 vals[i - 1] + t * (vals[i] - vals[i - 1])
162 }
163 }
164}
165
166fn boxcar_avg(wls: &[f64], vals: &[f64], target: f64, half_step: f64) -> f64 {
167 let lo = target - half_step;
168 let hi = target + half_step;
169 let mut sum = 0.0_f64;
170 let mut count = 0usize;
171 for (&w, &v) in wls.iter().zip(vals.iter()) {
172 if w >= lo && w <= hi {
173 sum += v;
174 count += 1;
175 }
176 }
177 if count > 0 {
178 sum / count as f64
179 } else {
180 linear_interp(wls, vals, target)
181 }
182}
183
184fn gaussian_avg(wls: &[f64], vals: &[f64], target: f64, sigma: f64) -> f64 {
185 let cutoff = 3.0 * sigma;
186 let mut weight_sum = 0.0_f64;
187 let mut value_sum = 0.0_f64;
188 for (&w, &v) in wls.iter().zip(vals.iter()) {
189 let d = w - target;
190 if d.abs() <= cutoff {
191 let weight = (-0.5 * (d / sigma) * (d / sigma)).exp();
192 value_sum += weight * v;
193 weight_sum += weight;
194 }
195 }
196 if weight_sum > 0.0 {
197 value_sum / weight_sum
198 } else {
199 linear_interp(wls, vals, target)
200 }
201}
202
203fn mean_half_step(wls: &[f64]) -> f64 {
206 if wls.len() < 2 {
207 return 0.0;
208 }
209 (wls.last().unwrap() - wls[0]) / (2.0 * (wls.len() - 1) as f64)
210}
211
212fn provenance_step(
213 target_wls: &[f64],
214 method: ResampleMethod,
215 fwhm_nm: Option<f64>,
216) -> ProcessingStep {
217 let method_name = match method {
218 ResampleMethod::Linear => "linear interpolation",
219 ResampleMethod::BoxcarAverage => "boxcar average",
220 ResampleMethod::Gaussian => "Gaussian",
221 };
222 let n = target_wls.len();
223 let desc = if n >= 2 {
224 let base = format!(
225 "{method_name} to {n} points, {:.4}–{:.4} nm",
226 target_wls[0],
227 target_wls[n - 1]
228 );
229 match fwhm_nm {
230 Some(fwhm) => format!("{base}, FWHM {fwhm:.4} nm"),
231 None => base,
232 }
233 } else {
234 format!("{method_name} to {n} point(s)")
235 };
236 ProcessingStep {
237 step: "resample".into(),
238 description: desc,
239 parameters: None,
240 }
241}
242
243#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn make_record(wls: &[f64], vals: &[f64]) -> SpectrumRecord {
253 SpectrumRecord {
254 id: "test".into(),
255 metadata: SpectrumMetadata {
256 measurement_type: MeasurementType::Reflectance,
257 date: "2026-05-16".into(),
258 title: None,
259 description: None,
260 sample_id: None,
261 time: None,
262 operator: None,
263 instrument: None,
264 measurement_conditions: None,
265 surface: None,
266 sample_backing: None,
267 tags: None,
268 copyright: None,
269 custom: None,
270 },
271 wavelength_axis: WavelengthAxis {
272 values_nm: Some(wls.to_vec()),
273 range_nm: None,
274 },
275 spectral_data: SpectralData {
276 values: vals.to_vec(),
277 uncertainty: None,
278 scale: None,
279 },
280 color_science: None,
281 provenance: None,
282 }
283 }
284
285 fn regular_target(start: f64, end: f64, step: f64) -> WavelengthAxis {
286 WavelengthAxis {
287 values_nm: None,
288 range_nm: Some(WavelengthRange {
289 start,
290 end,
291 interval: step,
292 }),
293 }
294 }
295
296 #[test]
299 fn linear_identity() {
300 let wls = [380.0, 390.0, 400.0];
301 let vals = [0.1, 0.2, 0.3];
302 let sp = make_record(&wls, &vals);
303 let target = regular_target(380.0, 400.0, 10.0);
304 let out = sp.resample(&target, ResampleMethod::Linear);
305 assert_eq!(out.spectral_data.values, vals);
306 }
307
308 #[test]
309 fn linear_upsample_midpoint() {
310 let wls: Vec<f64> = (0..=4).map(|i| 380.0 + i as f64 * 10.0).collect();
312 let vals: Vec<f64> = wls.iter().map(|&w| (w - 380.0) / 100.0).collect();
313 let sp = make_record(&wls, &vals);
314 let target = regular_target(380.0, 420.0, 5.0);
315 let out = sp.resample(&target, ResampleMethod::Linear);
316 for (wl, &v) in target
317 .wavelengths_nm()
318 .iter()
319 .zip(out.spectral_data.values.iter())
320 {
321 let expected = (wl - 380.0) / 100.0;
322 assert!(
323 (v - expected).abs() < 1e-12,
324 "at {wl}: got {v}, expected {expected}"
325 );
326 }
327 }
328
329 #[test]
330 fn linear_clamps_below_range() {
331 let sp = make_record(&[390.0, 400.0], &[0.5, 0.6]);
332 let target = regular_target(380.0, 400.0, 10.0);
333 let out = sp.resample(&target, ResampleMethod::Linear);
334 assert_eq!(out.spectral_data.values[0], 0.5);
336 }
337
338 #[test]
339 fn linear_clamps_above_range() {
340 let sp = make_record(&[380.0, 390.0], &[0.5, 0.6]);
341 let target = regular_target(380.0, 400.0, 10.0);
342 let out = sp.resample(&target, ResampleMethod::Linear);
343 assert_eq!(*out.spectral_data.values.last().unwrap(), 0.6);
345 }
346
347 #[test]
350 fn boxcar_downsample_averages_bins() {
351 let wls: Vec<f64> = (0..=10).map(|i| 380.0 + i as f64 * 2.0).collect();
356 let vals = vec![0.5_f64; wls.len()];
357 let sp = make_record(&wls, &vals);
358 let target = regular_target(380.0, 400.0, 10.0);
359 let out = sp.resample(&target, ResampleMethod::BoxcarAverage);
360 for &v in &out.spectral_data.values {
361 assert!((v - 0.5).abs() < 1e-12);
362 }
363 }
364
365 #[test]
366 fn boxcar_downsample_correct_average() {
367 let wls = vec![380.0, 382.0, 384.0, 386.0, 388.0, 390.0];
372 let vals = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
373 let sp = make_record(&wls, &vals);
374 let target = regular_target(380.0, 390.0, 10.0);
375 let out = sp.resample(&target, ResampleMethod::BoxcarAverage);
376 assert!((out.spectral_data.values[0] - 2.0).abs() < 1e-12);
377 assert!((out.spectral_data.values[1] - 5.0).abs() < 1e-12);
378 }
379
380 #[test]
381 fn boxcar_empty_bin_falls_back_to_linear() {
382 let sp = make_record(&[380.0, 400.0], &[0.0, 1.0]);
385 let target = regular_target(380.0, 400.0, 10.0);
386 let out = sp.resample(&target, ResampleMethod::BoxcarAverage);
387 assert!((out.spectral_data.values[1] - 0.5).abs() < 1e-12);
389 }
390
391 #[test]
394 fn resample_appends_processing_step() {
395 let sp = make_record(&[380.0, 390.0, 400.0], &[0.1, 0.2, 0.3]);
396 let target = regular_target(380.0, 400.0, 5.0);
397 let out = sp.resample(&target, ResampleMethod::Linear);
398 let steps = out.provenance.unwrap().processing_steps.unwrap();
399 assert_eq!(steps.len(), 1);
400 assert_eq!(steps[0].step, "resample");
401 assert!(steps[0].description.contains("linear interpolation"));
402 }
403
404 #[test]
405 fn resample_appends_to_existing_provenance() {
406 let mut sp = make_record(&[380.0, 390.0, 400.0], &[0.1, 0.2, 0.3]);
407 sp.provenance = Some(Provenance {
408 software: Some("TestSuite".into()),
409 software_version: None,
410 source_file: None,
411 source_format: None,
412 processing_steps: Some(vec![ProcessingStep {
413 step: "trim".into(),
414 description: "trimmed to 380–400 nm".into(),
415 parameters: None,
416 }]),
417 notes: None,
418 });
419 let target = regular_target(380.0, 400.0, 5.0);
420 let out = sp.resample(&target, ResampleMethod::BoxcarAverage);
421 let prov = out.provenance.unwrap();
422 assert_eq!(prov.software.as_deref(), Some("TestSuite"));
423 let steps = prov.processing_steps.unwrap();
424 assert_eq!(steps.len(), 2);
425 assert_eq!(steps[1].step, "resample");
426 assert!(steps[1].description.contains("boxcar average"));
427 }
428
429 #[test]
430 fn resample_preserves_metadata_and_scale() {
431 let mut sp = make_record(&[380.0, 390.0, 400.0], &[0.1, 0.2, 0.3]);
432 sp.metadata.title = Some("My Sample".into());
433 sp.spectral_data.scale = Some("fractional".into());
434 let target = regular_target(380.0, 400.0, 5.0);
435 let out = sp.resample(&target, ResampleMethod::Linear);
436 assert_eq!(out.metadata.title.as_deref(), Some("My Sample"));
437 assert_eq!(out.spectral_data.scale.as_deref(), Some("fractional"));
438 assert_eq!(out.id, "test");
439 }
440
441 #[test]
444 fn gaussian_constant_spectrum() {
445 let wls: Vec<f64> = (0..=20).map(|i| 380.0 + i as f64).collect();
447 let vals = vec![0.5_f64; wls.len()];
448 let sp = make_record(&wls, &vals);
449 let target = regular_target(380.0, 400.0, 5.0);
450 let out = sp.resample(&target, ResampleMethod::Gaussian);
451 for &v in &out.spectral_data.values {
452 assert!((v - 0.5).abs() < 1e-12, "got {v}");
453 }
454 }
455
456 #[test]
457 fn gaussian_uses_spectral_resolution_nm() {
458 let mut sp = make_record(&[380.0, 390.0, 400.0], &[0.1, 0.2, 0.3]);
460 sp.metadata.measurement_conditions = Some(MeasurementConditions {
461 spectral_resolution_nm: Some(5.0),
462 integration_time_ms: None,
463 averaging: None,
464 temperature_celsius: None,
465 geometry: None,
466 specular_component: None,
467 measurement_aperture_mm: None,
468 measurement_filter: None,
469 });
470 let target = regular_target(380.0, 400.0, 5.0);
471 let out = sp.resample(&target, ResampleMethod::Gaussian);
472 let desc = out
473 .provenance
474 .unwrap()
475 .processing_steps
476 .unwrap()
477 .remove(0)
478 .description;
479 assert!(desc.contains("Gaussian"), "{desc}");
480 assert!(desc.contains("5.0000"), "{desc}");
481 }
482
483 #[test]
484 fn gaussian_fallback_fwhm_equals_target_step() {
485 let sp = make_record(&[380.0, 390.0, 400.0], &[0.1, 0.2, 0.3]);
487 let target = regular_target(380.0, 400.0, 5.0);
488 let out = sp.resample(&target, ResampleMethod::Gaussian);
489 let desc = out
490 .provenance
491 .unwrap()
492 .processing_steps
493 .unwrap()
494 .remove(0)
495 .description;
496 assert!(desc.contains("5.0000"), "{desc}");
497 }
498
499 #[test]
500 fn gaussian_normalized_at_boundary() {
501 let wls: Vec<f64> = (0..=20).map(|i| 380.0 + i as f64).collect();
504 let vals = vec![0.7_f64; wls.len()];
505 let sp = make_record(&wls, &vals);
506 let target = regular_target(378.0, 402.0, 2.0);
509 let out = sp.resample(&target, ResampleMethod::Gaussian);
510 for &v in &out.spectral_data.values {
511 assert!((v - 0.7).abs() < 1e-12, "got {v}");
512 }
513 }
514}