Skip to main content

scirs2_interpolate/resampling/
mod.rs

1//! Grid resampling and extrapolation with configurable boundary modes.
2//!
3//! Provides:
4//! - 1-D resampling with linear, cubic spline, nearest-neighbour, and Lanczos methods
5//! - 2-D separable resampling on a regular grid
6//! - Scattered-to-grid conversion using inverse distance weighting (IDW)
7//! - Symbolic spline derivative (degree reduction)
8//! - Multiple extrapolation modes (Nearest, Linear, Polynomial, Reflection, Periodic, Zero, Constant)
9//! - N-D grid-spec resampling with `ArrayD` output (`grid_spec` sub-module)
10
11pub mod grid_spec;
12pub use grid_spec::{resample_scattered_to_grid, Aggregator, GridSpec, ResampleStrategy};
13
14use crate::error::InterpolateError;
15
16// ─── ExtrapolationMode ───────────────────────────────────────────────────────
17
18/// How to handle queries outside the data domain.
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq)]
21pub enum ExtrapolationMode {
22    /// Clamp to the nearest boundary value.
23    Nearest,
24    /// Linearly extrapolate using the slope at the boundary.
25    Linear,
26    /// Extrapolate with a polynomial of given degree fit to the last `degree+1` points.
27    Polynomial(usize),
28    /// Mirror / reflect the index about the boundary.
29    Reflection,
30    /// Wrap the index periodically.
31    Periodic,
32    /// Return zero outside the domain.
33    Zero,
34    /// Return a fixed constant value outside the domain.
35    Constant(f64),
36}
37
38// ─── ResamplingMethod ────────────────────────────────────────────────────────
39
40/// Interpolation method to use within the data domain.
41#[non_exhaustive]
42#[derive(Debug, Clone, PartialEq)]
43pub enum ResamplingMethod {
44    /// Bi/trilinear interpolation.
45    Linear,
46    /// Natural cubic spline.
47    CubicSpline,
48    /// Nearest-neighbour.
49    Nearest,
50    /// Lanczos windowed-sinc with the given number of lobes (a).
51    Lanczos(usize),
52}
53
54// ─── ResamplingConfig ────────────────────────────────────────────────────────
55
56/// Configuration for resampling operations.
57#[derive(Debug, Clone)]
58pub struct ResamplingConfig {
59    /// In-domain interpolation method.
60    pub method: ResamplingMethod,
61    /// Out-of-domain extrapolation strategy.
62    pub extrapolation: ExtrapolationMode,
63}
64
65impl Default for ResamplingConfig {
66    fn default() -> Self {
67        Self {
68            method: ResamplingMethod::Linear,
69            extrapolation: ExtrapolationMode::Nearest,
70        }
71    }
72}
73
74// ─── 1-D resampling ──────────────────────────────────────────────────────────
75
76/// Resample a 1-D signal from `(x_in, y_in)` to query points `x_out`.
77///
78/// `x_in` must be strictly increasing. `x_out` may be arbitrary.
79pub fn resample_1d(
80    x_in: &[f64],
81    y_in: &[f64],
82    x_out: &[f64],
83    config: &ResamplingConfig,
84) -> Result<Vec<f64>, InterpolateError> {
85    let n = x_in.len();
86    if n < 2 {
87        return Err(InterpolateError::InsufficientData(
88            "resample_1d requires at least 2 input points".to_string(),
89        ));
90    }
91    if n != y_in.len() {
92        return Err(InterpolateError::DimensionMismatch(format!(
93            "x_in length {} != y_in length {}",
94            n,
95            y_in.len()
96        )));
97    }
98
99    // Validate monotonicity
100    for i in 1..n {
101        if x_in[i] <= x_in[i - 1] {
102            return Err(InterpolateError::InvalidInput {
103                message: "x_in must be strictly increasing".to_string(),
104            });
105        }
106    }
107
108    // Precompute cubic spline coefficients if needed
109    let spline_coeffs: Option<Vec<[f64; 4]>> = match config.method {
110        ResamplingMethod::CubicSpline => Some(natural_cubic_spline_coeffs(x_in, y_in)?),
111        _ => None,
112    };
113
114    let x_min = x_in[0];
115    let x_max = x_in[n - 1];
116
117    let result: Result<Vec<f64>, InterpolateError> = x_out
118        .iter()
119        .map(|&xq| {
120            // Map possibly out-of-range xq to a resolved position
121            let xq_mapped = resolve_query(xq, x_min, x_max, &config.extrapolation);
122
123            match xq_mapped {
124                ResolvedQuery::InDomain(xr) => {
125                    interpolate_1d(x_in, y_in, xr, config, &spline_coeffs)
126                }
127                ResolvedQuery::Extrapolated(val) => Ok(val),
128                ResolvedQuery::ExtrapLinear(xr) => {
129                    // Linear extrapolation: use xr which may be outside domain
130                    interpolate_1d_linear_extrap(x_in, y_in, xr)
131                }
132                ResolvedQuery::ExtrapPolynomial(xr, deg) => {
133                    interpolate_1d_poly_extrap(x_in, y_in, xr, deg)
134                }
135            }
136        })
137        .collect();
138
139    result
140}
141
142// ─── Query resolution ────────────────────────────────────────────────────────
143
144enum ResolvedQuery {
145    InDomain(f64),
146    Extrapolated(f64),
147    ExtrapLinear(f64),
148    ExtrapPolynomial(f64, usize),
149}
150
151fn resolve_query(xq: f64, x_min: f64, x_max: f64, mode: &ExtrapolationMode) -> ResolvedQuery {
152    if xq >= x_min && xq <= x_max {
153        return ResolvedQuery::InDomain(xq);
154    }
155
156    match mode {
157        ExtrapolationMode::Nearest => ResolvedQuery::InDomain(xq.clamp(x_min, x_max)),
158        ExtrapolationMode::Linear => ResolvedQuery::ExtrapLinear(xq),
159        ExtrapolationMode::Polynomial(deg) => ResolvedQuery::ExtrapPolynomial(xq, *deg),
160        ExtrapolationMode::Reflection => {
161            let range = x_max - x_min;
162            if range < 1e-300 {
163                return ResolvedQuery::InDomain(x_min);
164            }
165            // Normalise to [0, 2*range) then reflect
166            let shifted = xq - x_min;
167            let period = 2.0 * range;
168            let t = shifted - (shifted / period).floor() * period;
169            let reflected = if t <= range { t } else { period - t };
170            ResolvedQuery::InDomain(x_min + reflected.clamp(0.0, range))
171        }
172        ExtrapolationMode::Periodic => {
173            let range = x_max - x_min;
174            if range < 1e-300 {
175                return ResolvedQuery::InDomain(x_min);
176            }
177            let shifted = xq - x_min;
178            let t = shifted - (shifted / range).floor() * range;
179            ResolvedQuery::InDomain(x_min + t.clamp(0.0, range))
180        }
181        ExtrapolationMode::Zero => ResolvedQuery::Extrapolated(0.0),
182        ExtrapolationMode::Constant(c) => ResolvedQuery::Extrapolated(*c),
183    }
184}
185
186// ─── 1-D interpolation methods ───────────────────────────────────────────────
187
188fn interpolate_1d(
189    x_in: &[f64],
190    y_in: &[f64],
191    xq: f64,
192    config: &ResamplingConfig,
193    spline_coeffs: &Option<Vec<[f64; 4]>>,
194) -> Result<f64, InterpolateError> {
195    let n = x_in.len();
196    let idx = binary_search_floor(x_in, xq);
197    let i = idx.min(n - 2);
198
199    match &config.method {
200        ResamplingMethod::Linear => {
201            let t = (xq - x_in[i]) / (x_in[i + 1] - x_in[i]);
202            Ok(y_in[i] * (1.0 - t) + y_in[i + 1] * t)
203        }
204        ResamplingMethod::Nearest => {
205            let i_near = if (xq - x_in[i]).abs() < (xq - x_in[(i + 1).min(n - 1)]).abs() {
206                i
207            } else {
208                (i + 1).min(n - 1)
209            };
210            Ok(y_in[i_near])
211        }
212        ResamplingMethod::CubicSpline => {
213            if let Some(coeffs) = spline_coeffs {
214                let dx = xq - x_in[i];
215                let [a, b, c, d] = coeffs[i];
216                Ok(a + b * dx + c * dx * dx + d * dx * dx * dx)
217            } else {
218                // Fallback to linear
219                let t = (xq - x_in[i]) / (x_in[i + 1] - x_in[i]);
220                Ok(y_in[i] * (1.0 - t) + y_in[i + 1] * t)
221            }
222        }
223        ResamplingMethod::Lanczos(a) => Ok(lanczos_interp(x_in, y_in, xq, *a)),
224    }
225}
226
227fn interpolate_1d_linear_extrap(
228    x_in: &[f64],
229    y_in: &[f64],
230    xq: f64,
231) -> Result<f64, InterpolateError> {
232    let n = x_in.len();
233    let x_min = x_in[0];
234    let x_max = x_in[n - 1];
235    if xq < x_min {
236        // Extrapolate left using first interval slope
237        let slope = (y_in[1] - y_in[0]) / (x_in[1] - x_in[0]);
238        Ok(y_in[0] + slope * (xq - x_min))
239    } else {
240        // Extrapolate right using last interval slope
241        let slope = (y_in[n - 1] - y_in[n - 2]) / (x_in[n - 1] - x_in[n - 2]);
242        Ok(y_in[n - 1] + slope * (xq - x_max))
243    }
244}
245
246fn interpolate_1d_poly_extrap(
247    x_in: &[f64],
248    y_in: &[f64],
249    xq: f64,
250    deg: usize,
251) -> Result<f64, InterpolateError> {
252    let n = x_in.len();
253    let x_min = x_in[0];
254    let pts = deg + 1;
255    // Pick boundary points
256    let (px, py): (Vec<f64>, Vec<f64>) = if xq < x_min {
257        // Use first `pts` points
258        let end = pts.min(n);
259        (x_in[..end].to_vec(), y_in[..end].to_vec())
260    } else {
261        // Use last `pts` points
262        let start = n.saturating_sub(pts);
263        (x_in[start..].to_vec(), y_in[start..].to_vec())
264    };
265
266    // Lagrange interpolation/extrapolation
267    Ok(lagrange_eval(&px, &py, xq))
268}
269
270// ─── Natural cubic spline coefficient computation ─────────────────────────────
271
272/// Compute natural cubic spline coefficients for `n-1` intervals.
273/// Returns coefficients `[a, b, c, d]` per interval such that
274/// `f(x) = a + b*(x-xi) + c*(x-xi)^2 + d*(x-xi)^3` for x in [xi, xi+1].
275fn natural_cubic_spline_coeffs(x: &[f64], y: &[f64]) -> Result<Vec<[f64; 4]>, InterpolateError> {
276    let n = x.len();
277    if n < 2 {
278        return Err(InterpolateError::InsufficientData(
279            "Need at least 2 points for spline".to_string(),
280        ));
281    }
282    let m = n - 1;
283    let mut h = vec![0.0f64; m];
284    for i in 0..m {
285        h[i] = x[i + 1] - x[i];
286        if h[i] <= 0.0 {
287            return Err(InterpolateError::InvalidInput {
288                message: "x must be strictly increasing".to_string(),
289            });
290        }
291    }
292
293    if n == 2 {
294        let b = (y[1] - y[0]) / h[0];
295        return Ok(vec![[y[0], b, 0.0, 0.0]]);
296    }
297
298    // Set up tridiagonal system for second derivatives σ
299    let mut alpha = vec![0.0f64; n];
300    for i in 1..m {
301        alpha[i] = 3.0 * ((y[i + 1] - y[i]) / h[i] - (y[i] - y[i - 1]) / h[i - 1]);
302    }
303
304    // Thomas algorithm (natural: σ_0 = σ_{n-1} = 0)
305    let mut l = vec![1.0f64; n];
306    let mut mu = vec![0.0f64; n];
307    let mut z = vec![0.0f64; n];
308
309    for i in 1..m {
310        l[i] = 2.0 * (x[i + 1] - x[i - 1]) - h[i - 1] * mu[i - 1];
311        if l[i].abs() < 1e-300 {
312            l[i] = 1e-300;
313        }
314        mu[i] = h[i] / l[i];
315        z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i];
316    }
317
318    let mut sigma = vec![0.0f64; n]; // second derivatives
319    for i in (1..m).rev() {
320        sigma[i] = z[i] - mu[i] * sigma[i + 1];
321    }
322
323    // Compute polynomial coefficients
324    let mut coeffs = Vec::with_capacity(m);
325    for i in 0..m {
326        let a = y[i];
327        let b = (y[i + 1] - y[i]) / h[i] - h[i] * (2.0 * sigma[i] + sigma[i + 1]) / 3.0;
328        let c = sigma[i];
329        let d = (sigma[i + 1] - sigma[i]) / (3.0 * h[i]);
330        coeffs.push([a, b, c, d]);
331    }
332
333    Ok(coeffs)
334}
335
336// ─── Lanczos windowed-sinc ────────────────────────────────────────────────────
337
338fn sinc(x: f64) -> f64 {
339    if x.abs() < 1e-12 {
340        1.0
341    } else {
342        let px = std::f64::consts::PI * x;
343        px.sin() / px
344    }
345}
346
347fn lanczos_kernel(x: f64, a: usize) -> f64 {
348    let af = a as f64;
349    if x.abs() >= af {
350        0.0
351    } else {
352        sinc(x) * sinc(x / af)
353    }
354}
355
356fn lanczos_interp(x_in: &[f64], y_in: &[f64], xq: f64, a: usize) -> f64 {
357    let n = x_in.len();
358    if n < 2 {
359        return y_in.first().copied().unwrap_or(0.0);
360    }
361    // Convert xq to fractional index in x_in (assume uniform spacing for simplicity,
362    // fallback to linear for non-uniform)
363    let i0 = binary_search_floor(x_in, xq);
364    let h = x_in[1] - x_in[0]; // approximate uniform step
365    if h.abs() < 1e-300 {
366        return y_in[i0.min(n - 1)];
367    }
368    let frac = (xq - x_in[i0.min(n - 1)]) / h;
369    let fi = i0 as f64 + frac;
370
371    let mut numer = 0.0f64;
372    let mut denom = 0.0f64;
373    let start = (fi as isize - a as isize).max(0) as usize;
374    let end = ((fi as isize + a as isize + 1) as usize).min(n);
375
376    for k in start..end {
377        let w = lanczos_kernel(fi - k as f64, a);
378        numer += w * y_in[k];
379        denom += w;
380    }
381
382    if denom.abs() < 1e-300 {
383        y_in[i0.min(n - 1)]
384    } else {
385        numer / denom
386    }
387}
388
389// ─── Helper: Lagrange interpolation ─────────────────────────────────────────
390
391fn lagrange_eval(px: &[f64], py: &[f64], xq: f64) -> f64 {
392    let n = px.len();
393    let mut result = 0.0f64;
394    for i in 0..n {
395        let mut li = 1.0f64;
396        for j in 0..n {
397            if i != j {
398                let denom = px[i] - px[j];
399                if denom.abs() < 1e-300 {
400                    continue;
401                }
402                li *= (xq - px[j]) / denom;
403            }
404        }
405        result += py[i] * li;
406    }
407    result
408}
409
410// ─── Helper: binary search (floor index) ─────────────────────────────────────
411
412/// Return index i such that x_in[i] <= xq < x_in[i+1].
413/// Clamps to [0, n-2].
414fn binary_search_floor(x_in: &[f64], xq: f64) -> usize {
415    let n = x_in.len();
416    if n == 0 {
417        return 0;
418    }
419    let mut lo = 0usize;
420    let mut hi = n - 1;
421    while lo + 1 < hi {
422        let mid = (lo + hi) / 2;
423        if x_in[mid] <= xq {
424            lo = mid;
425        } else {
426            hi = mid;
427        }
428    }
429    lo.min(n.saturating_sub(2))
430}
431
432// ─── 2-D resampling ──────────────────────────────────────────────────────────
433
434/// Resample a 2-D grid `grid[iy][ix]` from `(x_in, y_in)` axes to `(x_out, y_out)`.
435///
436/// Uses separable 1-D resampling: first along x, then along y.
437pub fn resample_2d(
438    grid: &[Vec<f64>],
439    x_in: &[f64],
440    y_in: &[f64],
441    x_out: &[f64],
442    y_out: &[f64],
443    config: &ResamplingConfig,
444) -> Result<Vec<Vec<f64>>, InterpolateError> {
445    let ny_in = y_in.len();
446    let nx_in = x_in.len();
447    if grid.len() != ny_in {
448        return Err(InterpolateError::DimensionMismatch(format!(
449            "grid has {} rows but y_in has {} elements",
450            grid.len(),
451            ny_in
452        )));
453    }
454    for (row_idx, row) in grid.iter().enumerate() {
455        if row.len() != nx_in {
456            return Err(InterpolateError::DimensionMismatch(format!(
457                "grid row {} has {} columns but x_in has {} elements",
458                row_idx,
459                row.len(),
460                nx_in
461            )));
462        }
463    }
464
465    // Step 1: resample along x for each input y row
466    // Produces intermediate grid of shape (ny_in, nx_out)
467    let mut intermediate: Vec<Vec<f64>> = Vec::with_capacity(ny_in);
468    for row in grid.iter() {
469        let resampled_row = resample_1d(x_in, row, x_out, config)?;
470        intermediate.push(resampled_row);
471    }
472
473    // Step 2: resample along y for each output x column
474    let nx_out = x_out.len();
475    let ny_out = y_out.len();
476    let mut output = vec![vec![0.0f64; nx_out]; ny_out];
477
478    for ix in 0..nx_out {
479        // Extract column from intermediate
480        let col: Vec<f64> = intermediate.iter().map(|row| row[ix]).collect();
481        let resampled_col = resample_1d(y_in, &col, y_out, config)?;
482        for iy in 0..ny_out {
483            output[iy][ix] = resampled_col[iy];
484        }
485    }
486
487    Ok(output)
488}
489
490// ─── Scattered to grid (IDW) ─────────────────────────────────────────────────
491
492/// Map scattered N-D points to a regular grid using inverse distance weighting.
493///
494/// `grid_ranges[d] = (min, max, n_points)` for dimension d.
495/// Returns a flattened array of shape `[n0 * n1 * ... * nd]` in row-major order.
496pub fn scattered_to_grid(
497    x: &[Vec<f64>],
498    y: &[f64],
499    grid_ranges: &[(f64, f64, usize)],
500    _config: &ResamplingConfig,
501) -> Result<Vec<f64>, InterpolateError> {
502    if x.is_empty() {
503        return Err(InterpolateError::InsufficientData(
504            "No scattered data points".to_string(),
505        ));
506    }
507    if x.len() != y.len() {
508        return Err(InterpolateError::DimensionMismatch(format!(
509            "x has {} rows but y has {} elements",
510            x.len(),
511            y.len()
512        )));
513    }
514    if grid_ranges.is_empty() {
515        return Err(InterpolateError::InvalidInput {
516            message: "grid_ranges must not be empty".to_string(),
517        });
518    }
519
520    let n_dims = grid_ranges.len();
521    let input_dims = x[0].len();
522    if input_dims != n_dims {
523        return Err(InterpolateError::DimensionMismatch(format!(
524            "x has {} dimensions but grid_ranges specifies {} dimensions",
525            input_dims, n_dims
526        )));
527    }
528
529    // Build grid axes
530    let axes: Vec<Vec<f64>> = grid_ranges
531        .iter()
532        .map(|&(lo, hi, n)| {
533            if n <= 1 {
534                vec![lo]
535            } else {
536                (0..n)
537                    .map(|i| lo + (hi - lo) * i as f64 / (n - 1) as f64)
538                    .collect()
539            }
540        })
541        .collect();
542
543    // Total grid size
544    let total: usize = axes.iter().map(|a| a.len()).product();
545    let mut result = vec![0.0f64; total];
546
547    // Enumerate multi-index
548    let shapes: Vec<usize> = axes.iter().map(|a| a.len()).collect();
549    let mut flat_idx = 0usize;
550
551    let mut multi = vec![0usize; n_dims];
552    loop {
553        // Build grid point coordinates
554        let gp: Vec<f64> = (0..n_dims).map(|d| axes[d][multi[d]]).collect();
555
556        // IDW with power p=2
557        let mut numer = 0.0f64;
558        let mut denom = 0.0f64;
559        for (xi, &yi) in x.iter().zip(y.iter()) {
560            let dist2: f64 = xi.iter().zip(gp.iter()).map(|(a, b)| (a - b).powi(2)).sum();
561            if dist2 < 1e-28 {
562                // Exact hit
563                numer = yi;
564                denom = 1.0;
565                break;
566            }
567            let w = 1.0 / dist2;
568            numer += w * yi;
569            denom += w;
570        }
571        result[flat_idx] = if denom > 1e-300 { numer / denom } else { 0.0 };
572
573        // Advance multi-index (row-major)
574        flat_idx += 1;
575        let mut carry = true;
576        for d in (0..n_dims).rev() {
577            if carry {
578                multi[d] += 1;
579                if multi[d] >= shapes[d] {
580                    multi[d] = 0;
581                } else {
582                    carry = false;
583                }
584            }
585        }
586        if carry {
587            break; // all indices wrapped
588        }
589    }
590
591    Ok(result)
592}
593
594// ─── SplineDerivative ────────────────────────────────────────────────────────
595
596/// A piecewise polynomial (spline) on a set of knot intervals.
597///
598/// Each segment `[knots[i], knots[i+1]]` is represented by a polynomial
599/// of degree `degree` with coefficients `coefficients[i]` stored in
600/// *ascending order* (coefficient of x^0 first).
601#[derive(Debug, Clone)]
602pub struct SplineDerivative {
603    /// Polynomial coefficients per segment, `coefficients[i][k]` = coeff of `(x - knots[i])^k`.
604    pub coefficients: Vec<Vec<f64>>,
605    /// Knot values (segment boundaries), length = n_segments + 1.
606    pub knots: Vec<f64>,
607    /// Polynomial degree of each segment.
608    pub degree: usize,
609}
610
611impl SplineDerivative {
612    /// Create a new spline from coefficients, knots, and degree.
613    pub fn new(
614        coefficients: Vec<Vec<f64>>,
615        knots: Vec<f64>,
616        degree: usize,
617    ) -> Result<Self, InterpolateError> {
618        if knots.len() < 2 {
619            return Err(InterpolateError::InsufficientData(
620                "SplineDerivative needs at least 2 knots".to_string(),
621            ));
622        }
623        let n_seg = knots.len() - 1;
624        if coefficients.len() != n_seg {
625            return Err(InterpolateError::DimensionMismatch(format!(
626                "Expected {} coefficient vectors for {} segments, got {}",
627                n_seg,
628                n_seg,
629                coefficients.len()
630            )));
631        }
632        Ok(Self {
633            coefficients,
634            knots,
635            degree,
636        })
637    }
638
639    /// Differentiate this spline, returning a new spline of `degree - 1`.
640    pub fn differentiate(spline: &SplineDerivative) -> Result<Self, InterpolateError> {
641        if spline.degree == 0 {
642            return Err(InterpolateError::InvalidOperation(
643                "Cannot differentiate a degree-0 spline".to_string(),
644            ));
645        }
646        let new_degree = spline.degree - 1;
647        let new_coeffs: Vec<Vec<f64>> = spline
648            .coefficients
649            .iter()
650            .map(|seg_coeffs| {
651                // Differentiate polynomial: d/dx (c_k (x-x_i)^k) = k * c_k * (x-x_i)^(k-1)
652                // Result has one fewer coefficient
653                let n = seg_coeffs.len().min(spline.degree + 1);
654                (1..n)
655                    .map(|k| k as f64 * seg_coeffs[k])
656                    .collect::<Vec<f64>>()
657            })
658            .collect();
659
660        Self::new(new_coeffs, spline.knots.clone(), new_degree)
661    }
662
663    /// Evaluate the spline at point `x`.
664    pub fn evaluate(&self, x: f64) -> Result<f64, InterpolateError> {
665        let n = self.knots.len();
666        if n < 2 {
667            return Err(InterpolateError::InsufficientData(
668                "No segments to evaluate".to_string(),
669            ));
670        }
671
672        // Find segment
673        let seg = if x <= self.knots[0] {
674            0
675        } else if x >= self.knots[n - 1] {
676            n - 2
677        } else {
678            binary_search_floor(&self.knots, x)
679        };
680
681        let dx = x - self.knots[seg];
682        let coeffs = &self.coefficients[seg];
683        // Horner's method
684        let mut val = 0.0f64;
685        for &c in coeffs.iter().rev() {
686            val = val * dx + c;
687        }
688        Ok(val)
689    }
690}
691
692// ─── Grid Resampling Convenience Functions (WS227) ──────────────────────────
693
694/// Resample scattered 1-D data onto a uniform grid of `n_grid_points`.
695///
696/// Returns `(grid_x, grid_y)` where `grid_x` is evenly spaced over
697/// `[min(scattered_x), max(scattered_x)]`.
698pub fn resample_to_regular(
699    scattered_x: &[f64],
700    scattered_y: &[f64],
701    n_grid_points: usize,
702    config: &ResamplingConfig,
703) -> Result<(Vec<f64>, Vec<f64>), InterpolateError> {
704    if scattered_x.len() < 2 {
705        return Err(InterpolateError::InsufficientData(
706            "resample_to_regular requires at least 2 input points".to_string(),
707        ));
708    }
709    if scattered_x.len() != scattered_y.len() {
710        return Err(InterpolateError::DimensionMismatch(format!(
711            "scattered_x len {} != scattered_y len {}",
712            scattered_x.len(),
713            scattered_y.len()
714        )));
715    }
716    if n_grid_points < 2 {
717        return Err(InterpolateError::InvalidInput {
718            message: "n_grid_points must be >= 2".to_string(),
719        });
720    }
721
722    // Sort the input data by x.
723    let mut pairs: Vec<(f64, f64)> = scattered_x
724        .iter()
725        .copied()
726        .zip(scattered_y.iter().copied())
727        .collect();
728    pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
729
730    // Deduplicate by x (keep last y for each unique x).
731    let mut sorted_x: Vec<f64> = Vec::with_capacity(pairs.len());
732    let mut sorted_y: Vec<f64> = Vec::with_capacity(pairs.len());
733    for &(px, py) in &pairs {
734        if let Some(&last_x) = sorted_x.last() {
735            if (px - last_x).abs() < 1e-15_f64 {
736                // Replace y for duplicate x.
737                if let Some(ly) = sorted_y.last_mut() {
738                    *ly = py;
739                }
740                continue;
741            }
742        }
743        sorted_x.push(px);
744        sorted_y.push(py);
745    }
746
747    if sorted_x.len() < 2 {
748        return Err(InterpolateError::InsufficientData(
749            "After deduplication, fewer than 2 unique x values remain".to_string(),
750        ));
751    }
752
753    let x_min = sorted_x[0];
754    let x_max = sorted_x[sorted_x.len() - 1];
755    let step = (x_max - x_min) / (n_grid_points - 1) as f64;
756    let grid_x: Vec<f64> = (0..n_grid_points)
757        .map(|i| x_min + i as f64 * step)
758        .collect();
759
760    let grid_y = resample_1d(&sorted_x, &sorted_y, &grid_x, config)?;
761    Ok((grid_x, grid_y))
762}
763
764/// Resample 1-D data onto arbitrary target x-coordinates.
765///
766/// `data_x` must be sortable; it will be sorted internally.
767pub fn resample_to_irregular(
768    data_x: &[f64],
769    data_y: &[f64],
770    target_x: &[f64],
771    config: &ResamplingConfig,
772) -> Result<Vec<f64>, InterpolateError> {
773    if data_x.len() < 2 {
774        return Err(InterpolateError::InsufficientData(
775            "resample_to_irregular requires at least 2 input points".to_string(),
776        ));
777    }
778    if data_x.len() != data_y.len() {
779        return Err(InterpolateError::DimensionMismatch(format!(
780            "data_x len {} != data_y len {}",
781            data_x.len(),
782            data_y.len()
783        )));
784    }
785
786    // Sort input by x.
787    let mut pairs: Vec<(f64, f64)> = data_x.iter().copied().zip(data_y.iter().copied()).collect();
788    pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
789
790    let sorted_x: Vec<f64> = pairs.iter().map(|p| p.0).collect();
791    let sorted_y: Vec<f64> = pairs.iter().map(|p| p.1).collect();
792
793    resample_1d(&sorted_x, &sorted_y, target_x, config)
794}
795
796/// Resample 2-D scattered data onto a regular nx × ny grid.
797///
798/// Uses inverse-distance weighting to map scattered points to a grid covering
799/// `[min_x, max_x] × [min_y, max_y]`.
800///
801/// Returns a `grid_ny × grid_nx` nested `Vec<Vec<f64>>` in row-major order.
802pub fn resample_scattered_2d(
803    scattered_xy: &[(f64, f64)],
804    values: &[f64],
805    grid_nx: usize,
806    grid_ny: usize,
807) -> Result<Vec<Vec<f64>>, InterpolateError> {
808    if scattered_xy.is_empty() {
809        return Err(InterpolateError::InsufficientData(
810            "No scattered data points for 2D resampling".to_string(),
811        ));
812    }
813    if scattered_xy.len() != values.len() {
814        return Err(InterpolateError::DimensionMismatch(format!(
815            "scattered_xy len {} != values len {}",
816            scattered_xy.len(),
817            values.len()
818        )));
819    }
820    if grid_nx < 2 || grid_ny < 2 {
821        return Err(InterpolateError::InvalidInput {
822            message: "grid_nx and grid_ny must each be >= 2".to_string(),
823        });
824    }
825
826    let x_vals: Vec<f64> = scattered_xy.iter().map(|p| p.0).collect();
827    let y_vals: Vec<f64> = scattered_xy.iter().map(|p| p.1).collect();
828
829    let x_min = x_vals.iter().copied().fold(f64::INFINITY, f64::min);
830    let x_max = x_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
831    let y_min = y_vals.iter().copied().fold(f64::INFINITY, f64::min);
832    let y_max = y_vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
833
834    // Prevent degenerate grids.
835    let dx = if (x_max - x_min).abs() < 1e-15 {
836        1.0
837    } else {
838        (x_max - x_min) / (grid_nx - 1) as f64
839    };
840    let dy = if (y_max - y_min).abs() < 1e-15 {
841        1.0
842    } else {
843        (y_max - y_min) / (grid_ny - 1) as f64
844    };
845
846    let mut grid = vec![vec![0.0f64; grid_nx]; grid_ny];
847
848    for iy in 0..grid_ny {
849        let gy = y_min + iy as f64 * dy;
850        for ix in 0..grid_nx {
851            let gx = x_min + ix as f64 * dx;
852
853            // IDW with power 2
854            let mut numer = 0.0_f64;
855            let mut denom = 0.0_f64;
856            let mut exact_hit = false;
857            for (idx, &(sx, sy)) in scattered_xy.iter().enumerate() {
858                let dist2 = (sx - gx).powi(2) + (sy - gy).powi(2);
859                if dist2 < 1e-28 {
860                    grid[iy][ix] = values[idx];
861                    exact_hit = true;
862                    break;
863                }
864                let w = 1.0 / dist2;
865                numer += w * values[idx];
866                denom += w;
867            }
868            if !exact_hit {
869                grid[iy][ix] = if denom > 1e-300 { numer / denom } else { 0.0 };
870            }
871        }
872    }
873
874    Ok(grid)
875}
876
877/// Downsample a 1-D signal by keeping every `factor`-th point.
878///
879/// The first point is always kept. Returns `(x_out, y_out)`.
880pub fn downsample(
881    x: &[f64],
882    y: &[f64],
883    factor: usize,
884) -> Result<(Vec<f64>, Vec<f64>), InterpolateError> {
885    if x.len() != y.len() {
886        return Err(InterpolateError::DimensionMismatch(format!(
887            "x len {} != y len {}",
888            x.len(),
889            y.len()
890        )));
891    }
892    if factor == 0 {
893        return Err(InterpolateError::InvalidInput {
894            message: "downsample factor must be >= 1".to_string(),
895        });
896    }
897    if x.is_empty() {
898        return Ok((Vec::new(), Vec::new()));
899    }
900
901    let x_out: Vec<f64> = x.iter().copied().step_by(factor).collect();
902    let y_out: Vec<f64> = y.iter().copied().step_by(factor).collect();
903    Ok((x_out, y_out))
904}
905
906/// Upsample a 1-D signal by inserting `factor - 1` interpolated points
907/// between each original pair.
908///
909/// Uses the configured resampling method (default: linear).
910pub fn upsample(
911    x: &[f64],
912    y: &[f64],
913    factor: usize,
914    config: &ResamplingConfig,
915) -> Result<(Vec<f64>, Vec<f64>), InterpolateError> {
916    if x.len() != y.len() {
917        return Err(InterpolateError::DimensionMismatch(format!(
918            "x len {} != y len {}",
919            x.len(),
920            y.len()
921        )));
922    }
923    if factor == 0 {
924        return Err(InterpolateError::InvalidInput {
925            message: "upsample factor must be >= 1".to_string(),
926        });
927    }
928    if x.len() < 2 {
929        return Ok((x.to_vec(), y.to_vec()));
930    }
931
932    // Sort input by x.
933    let mut pairs: Vec<(f64, f64)> = x.iter().copied().zip(y.iter().copied()).collect();
934    pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
935
936    let sorted_x: Vec<f64> = pairs.iter().map(|p| p.0).collect();
937    let sorted_y: Vec<f64> = pairs.iter().map(|p| p.1).collect();
938
939    let n = sorted_x.len();
940    // Total output points: (n - 1) * factor + 1
941    let n_out = (n - 1) * factor + 1;
942    let mut x_out = Vec::with_capacity(n_out);
943
944    for i in 0..(n - 1) {
945        let x0 = sorted_x[i];
946        let x1 = sorted_x[i + 1];
947        for j in 0..factor {
948            let t = j as f64 / factor as f64;
949            x_out.push(x0 + t * (x1 - x0));
950        }
951    }
952    // Include the last point.
953    x_out.push(sorted_x[n - 1]);
954
955    let y_out = resample_1d(&sorted_x, &sorted_y, &x_out, config)?;
956    Ok((x_out, y_out))
957}
958
959// ─── Tests ───────────────────────────────────────────────────────────────────
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964
965    #[test]
966    fn test_resample_1d_linear_identity() {
967        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
968        let y: Vec<f64> = x.clone();
969        let config = ResamplingConfig {
970            method: ResamplingMethod::Linear,
971            extrapolation: ExtrapolationMode::Nearest,
972        };
973        let x_out: Vec<f64> = (0..10).map(|i| i as f64 * 0.5 + 0.5).collect();
974        let result = resample_1d(&x, &y, &x_out, &config).expect("resample");
975        for (got, expected) in result.iter().zip(x_out.iter()) {
976            // y = x, so linear interpolation should be exact
977            let x_clamped = expected.clamp(x[0], x[x.len() - 1]);
978            assert!(
979                (got - x_clamped).abs() < 1e-10,
980                "Linear identity failed: got={got}, expected={x_clamped}"
981            );
982        }
983    }
984
985    #[test]
986    fn test_extrapolation_nearest_boundary() {
987        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
988        let y: Vec<f64> = vec![10.0, 20.0, 30.0, 40.0];
989        let config = ResamplingConfig {
990            method: ResamplingMethod::Linear,
991            extrapolation: ExtrapolationMode::Nearest,
992        };
993        let x_out = vec![-1.0, 5.0];
994        let result = resample_1d(&x, &y, &x_out, &config).expect("resample");
995        assert!(
996            (result[0] - 10.0).abs() < 1e-10,
997            "Left boundary clamped: {}",
998            result[0]
999        );
1000        assert!(
1001            (result[1] - 40.0).abs() < 1e-10,
1002            "Right boundary clamped: {}",
1003            result[1]
1004        );
1005    }
1006
1007    #[test]
1008    fn test_extrapolation_zero() {
1009        let x: Vec<f64> = vec![0.0, 1.0, 2.0];
1010        let y: Vec<f64> = vec![1.0, 2.0, 3.0];
1011        let config = ResamplingConfig {
1012            method: ResamplingMethod::Linear,
1013            extrapolation: ExtrapolationMode::Zero,
1014        };
1015        let x_out = vec![-1.0, 5.0];
1016        let result = resample_1d(&x, &y, &x_out, &config).expect("resample");
1017        assert!((result[0] - 0.0).abs() < 1e-10);
1018        assert!((result[1] - 0.0).abs() < 1e-10);
1019    }
1020
1021    #[test]
1022    fn test_extrapolation_constant() {
1023        let x: Vec<f64> = vec![0.0, 1.0, 2.0];
1024        let y: Vec<f64> = vec![1.0, 2.0, 3.0];
1025        let config = ResamplingConfig {
1026            method: ResamplingMethod::Linear,
1027            extrapolation: ExtrapolationMode::Constant(99.0),
1028        };
1029        let x_out = vec![-5.0, 10.0];
1030        let result = resample_1d(&x, &y, &x_out, &config).expect("resample");
1031        assert!((result[0] - 99.0).abs() < 1e-10);
1032        assert!((result[1] - 99.0).abs() < 1e-10);
1033    }
1034
1035    #[test]
1036    fn test_extrapolation_periodic() {
1037        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
1038        let y: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0]; // y = x in domain
1039        let config = ResamplingConfig {
1040            method: ResamplingMethod::Linear,
1041            extrapolation: ExtrapolationMode::Periodic,
1042        };
1043        // x=3.5 should map to x=0.5 (period = 3)
1044        let result = resample_1d(&x, &y, &[3.5], &config).expect("periodic");
1045        assert!(
1046            (result[0] - 0.5).abs() < 0.2,
1047            "Periodic wrap: got {} expected ~0.5",
1048            result[0]
1049        );
1050    }
1051
1052    #[test]
1053    fn test_extrapolation_linear() {
1054        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
1055        let y: Vec<f64> = vec![0.0, 2.0, 4.0, 6.0]; // y = 2x
1056        let config = ResamplingConfig {
1057            method: ResamplingMethod::Linear,
1058            extrapolation: ExtrapolationMode::Linear,
1059        };
1060        // x=4.0 should extrapolate to 8.0
1061        let result = resample_1d(&x, &y, &[4.0], &config).expect("linear extrap");
1062        assert!(
1063            (result[0] - 8.0).abs() < 1e-8,
1064            "Linear extrapolation: got {} expected 8.0",
1065            result[0]
1066        );
1067    }
1068
1069    #[test]
1070    fn test_cubic_spline_resample() {
1071        let x: Vec<f64> = (0..6).map(|i| i as f64).collect();
1072        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect(); // y = x²
1073        let config = ResamplingConfig {
1074            method: ResamplingMethod::CubicSpline,
1075            extrapolation: ExtrapolationMode::Nearest,
1076        };
1077        let x_out = vec![0.5, 1.5, 2.5, 3.5];
1078        let result = resample_1d(&x, &y, &x_out, &config).expect("cubic");
1079        for (xq, &yq) in x_out.iter().zip(result.iter()) {
1080            let exact = xq * xq;
1081            // Natural cubic spline imposes zero second derivatives at boundaries,
1082            // which introduces a boundary error for polynomial data.
1083            // Tolerance is relaxed near boundaries (x<1 or x>4).
1084            let tol = if *xq < 1.0 || *xq > 4.0 { 0.2 } else { 0.05 };
1085            assert!(
1086                (yq - exact).abs() < tol,
1087                "Cubic spline on y=x² at x={xq}: got {yq}, expected {exact}"
1088            );
1089        }
1090    }
1091
1092    #[test]
1093    fn test_nearest_method() {
1094        let x: Vec<f64> = vec![0.0, 1.0, 2.0];
1095        let y: Vec<f64> = vec![10.0, 20.0, 30.0];
1096        let config = ResamplingConfig {
1097            method: ResamplingMethod::Nearest,
1098            extrapolation: ExtrapolationMode::Nearest,
1099        };
1100        let result = resample_1d(&x, &y, &[0.3, 0.7], &config).expect("nearest");
1101        assert!(
1102            (result[0] - 10.0).abs() < 1e-10,
1103            "Nearest left: {}",
1104            result[0]
1105        );
1106        assert!(
1107            (result[1] - 20.0).abs() < 1e-10,
1108            "Nearest right: {}",
1109            result[1]
1110        );
1111    }
1112
1113    #[test]
1114    fn test_scattered_to_grid_2d() {
1115        // Simple scattered points: y = x1 + x2
1116        let x: Vec<Vec<f64>> = vec![
1117            vec![0.0, 0.0],
1118            vec![1.0, 0.0],
1119            vec![0.0, 1.0],
1120            vec![1.0, 1.0],
1121            vec![0.5, 0.5],
1122        ];
1123        let y: Vec<f64> = x.iter().map(|xi| xi[0] + xi[1]).collect();
1124        let grid_ranges = vec![(0.0, 1.0, 3), (0.0, 1.0, 3)];
1125        let config = ResamplingConfig::default();
1126        let result = scattered_to_grid(&x, &y, &grid_ranges, &config).expect("scattered_to_grid");
1127        assert_eq!(result.len(), 9); // 3×3
1128                                     // All values should be >= 0 and <= 2
1129        for &v in &result {
1130            assert!(v >= -0.1 && v <= 2.1, "Value out of expected range: {v}");
1131        }
1132    }
1133
1134    #[test]
1135    fn test_spline_derivative_differentiation() {
1136        // Quadratic: y = 3x² + 2x + 1 on [0, 2]
1137        // Coefficients: [1, 2, 3] for (x-0)^0, (x-0)^1, (x-0)^2
1138        let spline = SplineDerivative::new(vec![vec![1.0, 2.0, 3.0]], vec![0.0, 2.0], 2)
1139            .expect("create spline");
1140
1141        // Derivative: 2 + 6x => coeffs [2, 6]
1142        let deriv = SplineDerivative::differentiate(&spline).expect("differentiate");
1143        assert_eq!(deriv.degree, 1);
1144
1145        // Evaluate at x=1: should be 2 + 6*1 = 8
1146        let val = deriv.evaluate(1.0).expect("evaluate");
1147        assert!(
1148            (val - 8.0).abs() < 1e-10,
1149            "Derivative at x=1: got {val}, expected 8.0"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_spline_evaluate() {
1155        // Linear: y = 2x + 1 on [0, 1] and y = 3x - 0 on [1, 2]
1156        let spline =
1157            SplineDerivative::new(vec![vec![1.0, 2.0], vec![3.0, 0.0]], vec![0.0, 1.0, 2.0], 1)
1158                .expect("create spline");
1159        let v0 = spline.evaluate(0.5).expect("eval");
1160        assert!((v0 - 2.0).abs() < 1e-10, "Got {v0}"); // 1 + 2*0.5 = 2
1161    }
1162
1163    #[test]
1164    fn test_resample_2d() {
1165        // 3×3 grid with values row + col
1166        let grid: Vec<Vec<f64>> = (0..3)
1167            .map(|i| (0..3).map(|j| (i + j) as f64).collect())
1168            .collect();
1169        let x_in: Vec<f64> = vec![0.0, 1.0, 2.0];
1170        let y_in: Vec<f64> = vec![0.0, 1.0, 2.0];
1171        let x_out: Vec<f64> = vec![0.5, 1.0, 1.5];
1172        let y_out: Vec<f64> = vec![0.5, 1.0, 1.5];
1173        let config = ResamplingConfig::default();
1174        let result =
1175            resample_2d(&grid, &x_in, &y_in, &x_out, &y_out, &config).expect("resample_2d");
1176        assert_eq!(result.len(), 3);
1177        assert_eq!(result[0].len(), 3);
1178        // At (0.5, 0.5): value should be ~1.0
1179        assert!(
1180            (result[0][0] - 1.0).abs() < 0.1,
1181            "2D resample at (0.5,0.5): got {}",
1182            result[0][0]
1183        );
1184    }
1185
1186    #[test]
1187    fn test_reflection_extrapolation() {
1188        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
1189        let y: Vec<f64> = vec![0.0, 1.0, 4.0, 9.0];
1190        let config = ResamplingConfig {
1191            method: ResamplingMethod::Linear,
1192            extrapolation: ExtrapolationMode::Reflection,
1193        };
1194        // x=-0.5 should reflect to 0.5 within [0,3]
1195        let result = resample_1d(&x, &y, &[-0.5], &config).expect("reflection");
1196        // Should be in range since reflected
1197        assert!(
1198            result[0].is_finite(),
1199            "Reflection should produce finite value"
1200        );
1201    }
1202
1203    // ── WS227 Grid Resampling tests ──────────────────────────────────────
1204
1205    #[test]
1206    fn test_resample_to_regular_roundtrip() {
1207        // y = 2x on [0, 4] — resample to 5-point grid, then evaluate at original.
1208        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0];
1209        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
1210        let config = ResamplingConfig::default();
1211
1212        let (grid_x, grid_y) =
1213            resample_to_regular(&x, &y, 9, &config).expect("resample_to_regular");
1214        assert_eq!(grid_x.len(), 9);
1215        assert_eq!(grid_y.len(), 9);
1216
1217        // At each grid point the value should be close to 2*x.
1218        for (gx, gy) in grid_x.iter().zip(grid_y.iter()) {
1219            let expected = 2.0 * gx;
1220            assert!(
1221                (gy - expected).abs() < 0.1,
1222                "resample_to_regular roundtrip: at x={gx} got {gy}, expected {expected}"
1223            );
1224        }
1225    }
1226
1227    #[test]
1228    fn test_resample_to_irregular() {
1229        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0, 4.0];
1230        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect(); // y = x^2
1231        let config = ResamplingConfig::default();
1232
1233        let targets = vec![0.5, 1.5, 2.5, 3.5];
1234        let result =
1235            resample_to_irregular(&x, &y, &targets, &config).expect("resample_to_irregular");
1236        assert_eq!(result.len(), 4);
1237
1238        // Linear interpolation on x^2: approximate values.
1239        for (i, &tgt) in targets.iter().enumerate() {
1240            let exact = tgt * tgt;
1241            // Linear interp on x^2 has some error, but should be reasonable.
1242            assert!(
1243                (result[i] - exact).abs() < 1.0,
1244                "resample_to_irregular at x={tgt}: got {}, expected ~{exact}",
1245                result[i]
1246            );
1247        }
1248    }
1249
1250    #[test]
1251    fn test_resample_scattered_2d_grid_covers_domain() {
1252        let scattered: Vec<(f64, f64)> =
1253            vec![(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0), (0.5, 0.5)];
1254        let values: Vec<f64> = scattered.iter().map(|&(x, y)| x + y).collect();
1255
1256        let grid = resample_scattered_2d(&scattered, &values, 3, 3).expect("scattered 2d");
1257        assert_eq!(grid.len(), 3);
1258        assert_eq!(grid[0].len(), 3);
1259
1260        // All values should be in [0, 2] (since x+y ranges from 0 to 2).
1261        for row in &grid {
1262            for &v in row {
1263                assert!(v >= -0.1 && v <= 2.1, "2D grid value out of range: {v}");
1264            }
1265        }
1266    }
1267
1268    #[test]
1269    fn test_downsample() {
1270        let x: Vec<f64> = (0..10).map(|i| i as f64).collect();
1271        let y: Vec<f64> = x.iter().map(|&xi| xi * xi).collect();
1272
1273        let (dx, dy) = downsample(&x, &y, 3).expect("downsample");
1274        // With factor 3: indices 0, 3, 6, 9
1275        assert_eq!(dx.len(), 4);
1276        assert!((dx[0] - 0.0).abs() < 1e-12);
1277        assert!((dx[1] - 3.0).abs() < 1e-12);
1278        assert!((dx[2] - 6.0).abs() < 1e-12);
1279        assert!((dx[3] - 9.0).abs() < 1e-12);
1280        // Values should match y = x^2
1281        assert!((dy[1] - 9.0).abs() < 1e-12);
1282    }
1283
1284    #[test]
1285    fn test_upsample_preserves_function() {
1286        let x: Vec<f64> = vec![0.0, 1.0, 2.0, 3.0];
1287        let y: Vec<f64> = vec![0.0, 2.0, 4.0, 6.0]; // y = 2x
1288        let config = ResamplingConfig::default();
1289
1290        let (ux, uy) = upsample(&x, &y, 3, &config).expect("upsample");
1291        // (n-1)*factor + 1 = 3*3 + 1 = 10
1292        assert_eq!(ux.len(), 10);
1293        assert_eq!(uy.len(), 10);
1294
1295        // For y=2x, all upsampled values should be very close to 2*x.
1296        for (xi, yi) in ux.iter().zip(uy.iter()) {
1297            let expected = 2.0 * xi;
1298            assert!(
1299                (yi - expected).abs() < 0.1,
1300                "upsample: at x={xi} got {yi}, expected {expected}"
1301            );
1302        }
1303    }
1304
1305    #[test]
1306    fn test_downsample_factor_1_identity() {
1307        let x: Vec<f64> = vec![0.0, 1.0, 2.0];
1308        let y: Vec<f64> = vec![1.0, 2.0, 3.0];
1309        let (dx, dy) = downsample(&x, &y, 1).expect("factor 1");
1310        assert_eq!(dx.len(), 3);
1311        assert_eq!(dy.len(), 3);
1312    }
1313
1314    #[test]
1315    fn test_downsample_factor_zero_error() {
1316        let result = downsample(&[1.0], &[1.0], 0);
1317        assert!(result.is_err());
1318    }
1319
1320    #[test]
1321    fn test_upsample_factor_zero_error() {
1322        let config = ResamplingConfig::default();
1323        let result = upsample(&[1.0, 2.0], &[1.0, 2.0], 0, &config);
1324        assert!(result.is_err());
1325    }
1326
1327    #[test]
1328    fn test_resample_to_regular_too_few_points() {
1329        let config = ResamplingConfig::default();
1330        let result = resample_to_regular(&[1.0], &[1.0], 5, &config);
1331        assert!(result.is_err());
1332    }
1333}