Skip to main content

spectral_io/
resample.rs

1//! Resampling of [`SpectrumRecord`] onto a new wavelength axis.
2
3use super::*;
4
5/// Method used when resampling a spectrum to a new wavelength axis.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ResampleMethod {
8    /// Linear interpolation between adjacent input samples.
9    ///
10    /// Each output value is computed by linearly interpolating between the two
11    /// nearest input wavelengths. Output wavelengths outside the input range
12    /// are clamped to the nearest endpoint (no extrapolation).
13    ///
14    /// Works for both upsampling and downsampling.
15    Linear,
16
17    /// Boxcar (rectangular window) averaging.
18    ///
19    /// For each output wavelength `λ`, all input samples within the half-step
20    /// window `[λ − step/2, λ + step/2]` are averaged, where `step` is the
21    /// mean spacing of the target axis.  Falls back to linear interpolation
22    /// for any output wavelength whose window contains no input samples.
23    ///
24    /// Most appropriate when downsampling to a coarser grid (e.g. 1 nm → 10 nm).
25    ///
26    /// **Assumes a regular (uniformly-spaced) target grid.** The window
27    /// half-width is derived from the mean spacing of the entire target axis;
28    /// for irregular target grids the bins may overlap or leave gaps.  Use a
29    /// `WavelengthAxis` with `range_nm` (start / end / interval) to guarantee
30    /// a regular grid.
31    BoxcarAverage,
32
33    /// Gaussian kernel resampling.
34    ///
35    /// Each output value is a weighted average of the input samples, where the
36    /// weight of input sample at `w` is `exp(−½ ((w − λ) / σ)²)`.  Samples
37    /// further than 3σ from the output wavelength are excluded.  Weights are
38    /// normalised by their sum, so output values at the edges of the input
39    /// range are not artificially attenuated.
40    ///
41    /// The kernel FWHM is resolved in this order:
42    ///
43    /// 1. `metadata.measurement_conditions.spectral_resolution_nm` — the
44    ///    instrument's optical resolution, physically the most meaningful
45    ///    choice.
46    /// 2. Mean step size of the target axis — used as a fallback when no
47    ///    resolution is recorded, matching the kernel width to the output
48    ///    sampling interval.
49    ///
50    /// σ is derived from the FWHM as `FWHM / (2√(2 ln 2)) ≈ FWHM / 2.355`.
51    ///
52    /// If the FWHM is much smaller than the input sampling interval the kernel
53    /// degenerates toward nearest-neighbour interpolation; if no input samples
54    /// fall within the 3σ window the method falls back to linear interpolation.
55    Gaussian,
56}
57
58impl SpectrumRecord {
59    /// Resample this spectrum onto `target`, returning a new [`SpectrumRecord`].
60    ///
61    /// The source spectrum's metadata, colour-science block, and provenance are
62    /// preserved; a [`ProcessingStep`] describing the operation is appended to
63    /// the provenance trail.
64    ///
65    /// # Preconditions
66    ///
67    /// The source wavelength axis must be sorted in ascending order.
68    /// `WavelengthAxis` values produced by this library always satisfy this
69    /// requirement; an unsorted axis will produce silently incorrect output.
70    ///
71    /// # Uncertainty
72    ///
73    /// Any `uncertainty` values on the source spectrum are **not** carried
74    /// forward — the returned `SpectralData` always has `uncertainty: None`.
75    /// Correct propagation of uncertainty through interpolation and averaging
76    /// requires knowledge of the correlation structure of the input errors and
77    /// is left to the caller.
78    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
149// ─────────────────────────────────────────────────────────────────────────────
150// Internal helpers
151// ─────────────────────────────────────────────────────────────────────────────
152
153fn 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
203// Mean half-step: (last − first) / (2 × (n − 1)).
204// For a regular grid this is exactly interval / 2.
205fn 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// ─────────────────────────────────────────────────────────────────────────────
244// Tests
245// ─────────────────────────────────────────────────────────────────────────────
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    // A minimal SpectrumRecord for testing.
252    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    // ── Linear ───────────────────────────────────────────────────────────────
297
298    #[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        // Linear function: val = (wl - 380) / 100 — interpolated midpoints should be exact.
311        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        // 380 nm is below the input range — should clamp to vals[0] = 0.5
335        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        // 400 nm is above the input range — should clamp to vals.last() = 0.6
344        assert_eq!(*out.spectral_data.values.last().unwrap(), 0.6);
345    }
346
347    // ── BoxcarAverage ─────────────────────────────────────────────────────────
348
349    #[test]
350    fn boxcar_downsample_averages_bins() {
351        // Input: 380–400 nm at 2 nm, constant value 0.5.  Downsample to 10 nm.
352        // Each output bin [lo, hi] contains 6 input points (e.g. 375–385 contains
353        // 376,378,380,382,384; but with lo=375 and first point at 380 the bin
354        // [375,385] contains 380,382,384 — let's just test the average equals 0.5.
355        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        // Input: 380, 382, 384, 386, 388, 390 at values 1,2,3,4,5,6.
368        // Target: 380–390 at 10 nm → two bins [375,385] and [385,395].
369        // Bin at 380: contains 380,382,384 → avg = 2.0
370        // Bin at 390: contains 386,388,390 → avg = 5.0
371        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        // Upsample: input at 380, 400 — output at 380, 390, 400 (step=10, half=5).
383        // Bin at 390 is [385, 395] — no input point falls there → linear fallback.
384        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        // Linear fallback at 390: 0.5
388        assert!((out.spectral_data.values[1] - 0.5).abs() < 1e-12);
389    }
390
391    // ── Provenance ────────────────────────────────────────────────────────────
392
393    #[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    // ── Gaussian ──────────────────────────────────────────────────────────────
442
443    #[test]
444    fn gaussian_constant_spectrum() {
445        // A constant input must remain constant regardless of kernel width.
446        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        // spectral_resolution_nm = 5.0 → FWHM 5.0 nm appears in provenance.
459        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        // No spectral_resolution_nm → FWHM falls back to mean target step (5.0 nm).
486        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        // Constant input: output at the very edges of the range must equal the
502        // constant (normalization by weight sum, not by a fixed count).
503        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        // Target extends slightly beyond input range — edge bins have fewer
507        // contributing input samples but weights still sum to 1.
508        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}