Skip to main content

scirs2_interpolate/
interp2d.rs

1//! 2D interpolation - SciPy-compatible interp2d implementation
2//!
3//! This module provides 2D interpolation functionality compatible with
4//! SciPy's interp2d function for interpolating data on regular grids.
5
6use crate::error::{InterpolateError, InterpolateResult};
7use crate::interp1d::linear_interpolate;
8use crate::numerical_stability::solve_with_stability_monitoring;
9use crate::spline::CubicSpline;
10use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
11use scirs2_core::numeric::{Float, FromPrimitive};
12use std::fmt::{Debug, Display};
13use std::ops::{AddAssign, DivAssign, MulAssign, SubAssign};
14
15/// 2D interpolation methods
16#[derive(Debug, Clone, Copy, PartialEq)]
17pub enum Interp2dKind {
18    /// Linear interpolation
19    Linear,
20    /// Cubic interpolation using splines
21    Cubic,
22    /// Quintic interpolation: a natural, C4-continuous, degree-5 piecewise
23    /// polynomial spline (the direct higher-order analogue of `Cubic`'s
24    /// natural cubic spline), applied separably along each axis.
25    Quintic,
26}
27
28/// 2D interpolator for data on regular grids
29///
30/// This struct provides functionality similar to SciPy's interp2d for
31/// interpolating 2D data defined on regular grids.
32#[derive(Debug, Clone)]
33pub struct Interp2d<F> {
34    /// X coordinates (must be sorted)
35    x: Array1<F>,
36    /// Y coordinates (must be sorted)
37    y: Array1<F>,
38    /// Z values with shape (len(y), len(x))
39    z: Array2<F>,
40    /// Interpolation method
41    kind: Interp2dKind,
42}
43
44impl<F> Interp2d<F>
45where
46    F: Float + FromPrimitive + Debug + Clone + crate::traits::InterpolationFloat,
47{
48    /// Create a new 2D interpolator
49    ///
50    /// # Arguments
51    ///
52    /// * `x` - X coordinates (must be sorted), length n_x
53    /// * `y` - Y coordinates (must be sorted), length n_y  
54    /// * `z` - Z values with shape (n_y, n_x)
55    /// * `kind` - Interpolation method
56    ///
57    /// # Returns
58    ///
59    /// New 2D interpolator
60    ///
61    /// # Errors
62    ///
63    /// * `ShapeMismatch` - If z.shape() != (y.len(), x.len())
64    /// * `InvalidInput` - If x or y are not sorted
65    ///
66    /// # Examples
67    ///
68    /// ```
69    /// use scirs2_core::ndarray::{array, Array2};
70    /// use scirs2_interpolate::interp2d::{Interp2d, Interp2dKind};
71    ///
72    /// // Define grid
73    /// let x = array![0.0, 1.0, 2.0];
74    /// let y = array![0.0, 1.0];
75    ///
76    /// // Define function z = x + y on the grid
77    /// let z = Array2::from_shape_fn((2, 3), |(i, j)| {
78    ///     y[i] + x[j]
79    /// });
80    ///
81    /// let interp = Interp2d::new(&x.view(), &y.view(), &z.view(),
82    ///                           Interp2dKind::Linear)?;
83    ///
84    /// // Interpolate at a point
85    /// let result = interp.evaluate(0.5, 0.5)?;
86    /// # Ok::<(), Box<dyn std::error::Error>>(())
87    /// ```
88    pub fn new(
89        x: &ArrayView1<F>,
90        y: &ArrayView1<F>,
91        z: &ArrayView2<F>,
92        kind: Interp2dKind,
93    ) -> InterpolateResult<Self> {
94        // Validate shapes
95        if z.nrows() != y.len() || z.ncols() != x.len() {
96            return Err(InterpolateError::shape_mismatch(
97                format!("({}, {})", y.len(), x.len()),
98                format!("({}, {})", z.nrows(), z.ncols()),
99                "interp2d z array shape",
100            ));
101        }
102
103        // Check that x and y are sorted
104        if !is_sorted(x) {
105            return Err(InterpolateError::invalid_input(
106                "x coordinates must be sorted in ascending order",
107            ));
108        }
109
110        if !is_sorted(y) {
111            return Err(InterpolateError::invalid_input(
112                "y coordinates must be sorted in ascending order",
113            ));
114        }
115
116        // Check for minimum grid size
117        if x.len() < 2 || y.len() < 2 {
118            return Err(InterpolateError::invalid_input(
119                "need at least 2 points in each dimension",
120            ));
121        }
122
123        Ok(Self {
124            x: x.to_owned(),
125            y: y.to_owned(),
126            z: z.to_owned(),
127            kind,
128        })
129    }
130
131    /// Evaluate the interpolator at a single point
132    ///
133    /// # Arguments
134    ///
135    /// * `x_new` - X coordinate for evaluation
136    /// * `ynew` - Y coordinate for evaluation
137    ///
138    /// # Returns
139    ///
140    /// Interpolated value at (x_new, ynew)
141    ///
142    /// # Examples
143    ///
144    /// ```
145    /// use scirs2_core::ndarray::{array, Array2};
146    /// use scirs2_interpolate::interp2d::{Interp2d, Interp2dKind};
147    ///
148    /// let x = array![0.0, 1.0, 2.0];
149    /// let y = array![0.0, 1.0];
150    /// let z = Array2::from_shape_fn((2, 3), |(i, j)| {
151    ///     y[i] + x[j] // z = x + y
152    /// });
153    ///
154    /// let interp = Interp2d::new(&x.view(), &y.view(), &z.view(),
155    ///                           Interp2dKind::Linear)?;
156    ///
157    /// let result = interp.evaluate(0.5, 0.5)?;
158    /// // Should be approximately 1.0 (0.5 + 0.5)
159    /// # Ok::<(), Box<dyn std::error::Error>>(())
160    /// ```
161    pub fn evaluate(&self, x_new: F, ynew: F) -> InterpolateResult<F> {
162        match self.kind {
163            Interp2dKind::Linear => self.evaluate_linear(x_new, ynew),
164            Interp2dKind::Cubic => self.evaluate_cubic(x_new, ynew),
165            Interp2dKind::Quintic => self.evaluate_quintic(x_new, ynew),
166        }
167    }
168
169    /// Evaluate at multiple points
170    ///
171    /// # Arguments
172    ///
173    /// * `x_new` - X coordinates for evaluation
174    /// * `ynew` - Y coordinates for evaluation (must have same length as x_new)
175    ///
176    /// # Returns
177    ///
178    /// Array of interpolated values
179    pub fn evaluate_array(
180        &self,
181        x_new: &ArrayView1<F>,
182        ynew: &ArrayView1<F>,
183    ) -> InterpolateResult<Array1<F>> {
184        if x_new.len() != ynew.len() {
185            return Err(InterpolateError::shape_mismatch(
186                format!("x_new.len() = {}", x_new.len()),
187                format!("ynew.len() = {}", ynew.len()),
188                "interp2d coordinate arrays",
189            ));
190        }
191
192        let mut result = Array1::zeros(x_new.len());
193        for i in 0..x_new.len() {
194            result[i] = self.evaluate(x_new[i], ynew[i])?;
195        }
196        Ok(result)
197    }
198
199    /// Evaluate on a regular grid
200    ///
201    /// # Arguments
202    ///
203    /// * `x_new` - X coordinates for output grid
204    /// * `ynew` - Y coordinates for output grid
205    ///
206    /// # Returns
207    ///
208    /// 2D array with shape (len(ynew), len(x_new))
209    pub fn evaluate_grid(
210        &self,
211        x_new: &ArrayView1<F>,
212        ynew: &ArrayView1<F>,
213    ) -> InterpolateResult<Array2<F>> {
214        let mut result = Array2::zeros((ynew.len(), x_new.len()));
215
216        for (i, &y_val) in ynew.iter().enumerate() {
217            for (j, &x_val) in x_new.iter().enumerate() {
218                result[[i, j]] = self.evaluate(x_val, y_val)?;
219            }
220        }
221
222        Ok(result)
223    }
224
225    /// Linear interpolation implementation
226    fn evaluate_linear(&self, x_new: F, ynew: F) -> InterpolateResult<F> {
227        // Find y index and interpolate along x for neighboring y values
228        let y_idx = find_interval(&self.y.view(), ynew);
229
230        let result = if y_idx == 0 && ynew < self.y[0] {
231            // Extrapolate below
232            let row = self.z.slice(scirs2_core::ndarray::s![0, ..]);
233            linear_interpolate(&self.x.view(), &row, &Array1::from_vec(vec![x_new]).view())?[0]
234        } else if y_idx >= self.y.len() - 1 && ynew > self.y[self.y.len() - 1] {
235            // Extrapolate above
236            let row = self.z.slice(scirs2_core::ndarray::s![self.y.len() - 1, ..]);
237            linear_interpolate(&self.x.view(), &row, &Array1::from_vec(vec![x_new]).view())?[0]
238        } else {
239            // Interpolate between two y values
240            let y_idx = y_idx.min(self.y.len() - 2);
241
242            // Interpolate along x for both y levels
243            let row0 = self.z.slice(scirs2_core::ndarray::s![y_idx, ..]);
244            let row1 = self.z.slice(scirs2_core::ndarray::s![y_idx + 1, ..]);
245
246            let val0 =
247                linear_interpolate(&self.x.view(), &row0, &Array1::from_vec(vec![x_new]).view())?
248                    [0];
249            let val1 =
250                linear_interpolate(&self.x.view(), &row1, &Array1::from_vec(vec![x_new]).view())?
251                    [0];
252
253            // Interpolate along y
254            let y0 = self.y[y_idx];
255            let y1 = self.y[y_idx + 1];
256
257            if (y1 - y0).abs() < F::epsilon() {
258                val0
259            } else {
260                let t = (ynew - y0) / (y1 - y0);
261                val0 + t * (val1 - val0)
262            }
263        };
264
265        Ok(result)
266    }
267
268    /// Cubic interpolation implementation
269    fn evaluate_cubic(&self, x_new: F, ynew: F) -> InterpolateResult<F> {
270        // Create cubic splines for each x value across y
271        let mut values_at_x = Array1::zeros(self.y.len());
272
273        for (i, &_y_val) in self.y.iter().enumerate() {
274            let row = self.z.slice(scirs2_core::ndarray::s![i, ..]);
275            let spline = CubicSpline::new(&self.x.view(), &row)?;
276            values_at_x[i] = spline.evaluate(x_new)?;
277        }
278
279        // Create cubic spline along y direction
280        let y_spline = CubicSpline::new(&self.y.view(), &values_at_x.view())?;
281        y_spline.evaluate(ynew)
282    }
283
284    /// Quintic interpolation implementation
285    ///
286    /// Mirrors [`Self::evaluate_cubic`]'s separable (tensor-product)
287    /// construction: a 1D quintic spline is built along `x` for every `y`
288    /// row to get the value at `x_new` on each row, and a second 1D quintic
289    /// spline is then built along `y` through those values and evaluated at
290    /// `ynew`. Each 1D quintic spline is a true C4-continuous, degree-5
291    /// piecewise polynomial (see [`QuinticSpline1D`]), not a cubic spline in
292    /// disguise.
293    fn evaluate_quintic(&self, x_new: F, ynew: F) -> InterpolateResult<F> {
294        let n_x = self.x.len();
295        let n_y = self.y.len();
296
297        if n_x < 3 || n_y < 3 {
298            return Err(InterpolateError::invalid_input(
299                "quintic interpolation requires at least 3 points in each dimension",
300            ));
301        }
302
303        // Build a quintic spline along x for each y row and evaluate at x_new.
304        let mut values_at_x = Array1::zeros(n_y);
305        for i in 0..n_y {
306            let row = self.z.slice(scirs2_core::ndarray::s![i, ..]);
307            let spline = QuinticSpline1D::new(&self.x.view(), &row)?;
308            values_at_x[i] = spline.evaluate(x_new);
309        }
310
311        // Build a quintic spline along y through those values and evaluate at ynew.
312        let y_spline = QuinticSpline1D::new(&self.y.view(), &values_at_x.view())?;
313        Ok(y_spline.evaluate(ynew))
314    }
315}
316
317/// Build a small non-negative integer constant for a generic float type
318/// without any fallible conversion: `FromPrimitive::from_u32` is tried
319/// first, falling back to repeated addition of `F::one()` (which can never
320/// fail) if that conversion is somehow unavailable.
321fn small_const<F: Float + FromPrimitive>(value: u32) -> F {
322    F::from_u32(value).unwrap_or_else(|| {
323        let mut acc = F::zero();
324        for _ in 0..value {
325            acc = acc + F::one();
326        }
327        acc
328    })
329}
330
331/// A single degree-5 polynomial segment of a [`QuinticSpline1D`], expressed
332/// in the local variable `t = x - x_i` and valid on `t in [0, h_i]`.
333#[derive(Debug, Clone)]
334struct QuinticSegment<F> {
335    /// Coefficients `[a0, a1, a2, a3, a4, a5]` such that
336    /// `p(t) = a0 + a1*t + a2*t^2 + a3*t^3 + a4*t^4 + a5*t^5`.
337    coeffs: [F; 6],
338}
339
340impl<F: Float> QuinticSegment<F> {
341    /// Evaluate the segment polynomial at local coordinate `t` via Horner's
342    /// method.
343    fn evaluate(&self, t: F) -> F {
344        let mut result = self.coeffs[5];
345        for k in (0..5).rev() {
346            result = result * t + self.coeffs[k];
347        }
348        result
349    }
350
351    /// Build the segment from Hermite-style endpoint data: values `y0`,
352    /// `y1`, first derivatives `m0`, `m1`, and second derivatives `mm0`,
353    /// `mm1` at the two ends of an interval of width `h`.
354    #[allow(clippy::too_many_arguments)]
355    fn from_hermite_quintic(y0: F, y1: F, m0: F, m1: F, mm0: F, mm1: F, h: F) -> Self
356    where
357        F: FromPrimitive,
358    {
359        let two = small_const::<F>(2);
360        let three = small_const::<F>(3);
361        let six = small_const::<F>(6);
362        let seven = small_const::<F>(7);
363        let eight = small_const::<F>(8);
364        let twelve = small_const::<F>(12);
365        let fifteen = small_const::<F>(15);
366        let twenty = small_const::<F>(20);
367
368        let h2 = h * h;
369        let h3 = h2 * h;
370        let h4 = h2 * h2;
371        let h5 = h4 * h;
372
373        let a0 = y0;
374        let a1 = m0;
375        let a2 = mm0 / two;
376        let a3 = (-three * mm0 * h2 + mm1 * h2 - twelve * h * m0 - eight * h * m1 - twenty * y0
377            + twenty * y1)
378            / (two * h3);
379        let a4 =
380            (three / two * mm0 * h2 - mm1 * h2 + eight * h * m0 + seven * h * m1 + fifteen * y0
381                - fifteen * y1)
382                / h4;
383        let a5 = (-mm0 * h2 + mm1 * h2 - six * h * m0 - six * h * m1 - twelve * y0 + twelve * y1)
384            / (two * h5);
385
386        Self {
387            coeffs: [a0, a1, a2, a3, a4, a5],
388        }
389    }
390}
391
392/// A natural quintic spline: a C4-continuous, degree-5 piecewise polynomial
393/// interpolant through 1D data.
394///
395/// This is the direct higher-order generalization of the classical
396/// "natural" cubic spline (which enforces continuity of the function value,
397/// first, and second derivatives, and sets the second derivative to zero at
398/// the two endpoints). Here, continuity is additionally enforced for the
399/// third and fourth derivatives at every interior knot, and the "natural"
400/// boundary condition sets the third *and* fourth derivatives to zero at
401/// the two endpoints (the two missing degrees of freedom needed to close
402/// the system).
403///
404/// Internally this is built by solving a single linear system for the
405/// first and second derivatives at every knot (`m_i`, `M_i`), then
406/// constructing each segment as a quintic Hermite polynomial matching
407/// `y`, `m`, and `M` at both of its endpoints -- which automatically
408/// guarantees continuity of the function value and its first two
409/// derivatives, while the linear system enforces continuity of the third
410/// and fourth derivatives as well.
411struct QuinticSpline1D<F> {
412    x: Array1<F>,
413    segments: Vec<QuinticSegment<F>>,
414}
415
416impl<F> QuinticSpline1D<F>
417where
418    F: Float
419        + FromPrimitive
420        + Debug
421        + Display
422        + AddAssign
423        + SubAssign
424        + MulAssign
425        + DivAssign
426        + Clone
427        + 'static,
428{
429    fn new(x: &ArrayView1<F>, y: &ArrayView1<F>) -> InterpolateResult<Self> {
430        let n = x.len();
431        if n != y.len() {
432            return Err(InterpolateError::ShapeMismatch {
433                expected: format!("{n} elements"),
434                actual: format!("{} elements", y.len()),
435                object: "quintic spline y values".to_string(),
436            });
437        }
438        if n < 3 {
439            return Err(InterpolateError::invalid_input(
440                "quintic spline construction requires at least 3 points",
441            ));
442        }
443
444        let h: Vec<F> = (0..n - 1).map(|i| x[i + 1] - x[i]).collect();
445        for (i, &hi) in h.iter().enumerate() {
446            if hi <= F::zero() {
447                return Err(InterpolateError::invalid_input(format!(
448                    "quintic spline requires strictly increasing x values \
449                     (non-increasing step between indices {i} and {})",
450                    i + 1
451                )));
452            }
453        }
454
455        let two = small_const::<F>(2);
456        let three = small_const::<F>(3);
457        let eight = small_const::<F>(8);
458        let twelve = small_const::<F>(12);
459        let fourteen = small_const::<F>(14);
460        let sixteen = small_const::<F>(16);
461        let twenty = small_const::<F>(20);
462        let thirty = small_const::<F>(30);
463
464        // Unknowns, in order: [m_0, M_0, m_1, M_1, ..., m_{n-1}, M_{n-1}]
465        // (first and second derivatives at every knot).
466        let dim = 2 * n;
467        let mut a = Array2::<F>::zeros((dim, dim));
468        let mut rhs = Array1::<F>::zeros(dim);
469        let idx_m = |i: usize| 2 * i;
470        let idx_mm = |i: usize| 2 * i + 1;
471
472        let mut row = 0usize;
473
474        // Left natural boundary condition: third and fourth derivatives of
475        // the first segment vanish at its left end (t = 0).
476        {
477            let h0 = h[0];
478            let h0_2 = h0 * h0;
479
480            a[(row, idx_m(0))] = -twelve * h0;
481            a[(row, idx_m(1))] = -eight * h0;
482            a[(row, idx_mm(0))] = -three * h0_2;
483            a[(row, idx_mm(1))] = h0_2;
484            rhs[row] = twenty * (y[0] - y[1]);
485            row += 1;
486
487            a[(row, idx_m(0))] = sixteen * h0;
488            a[(row, idx_m(1))] = fourteen * h0;
489            a[(row, idx_mm(0))] = three * h0_2;
490            a[(row, idx_mm(1))] = -two * h0_2;
491            rhs[row] = -thirty * (y[0] - y[1]);
492            row += 1;
493        }
494
495        // Interior continuity: third and fourth derivative continuity at
496        // every interior knot i = 1 ..= n-2, linking segment (i-1) and
497        // segment i.
498        for i in 1..n - 1 {
499            let h_prev = h[i - 1];
500            let h_next = h[i];
501            let a_coef = F::one() / (h_prev * h_prev * h_prev);
502            let b_coef = F::one() / (h_next * h_next * h_next);
503            let c_coef = a_coef / h_prev;
504            let d_coef = b_coef / h_next;
505
506            // Third-derivative continuity.
507            a[(row, idx_m(i - 1))] += -eight * a_coef * h_prev;
508            a[(row, idx_m(i))] += -twelve * a_coef * h_prev + twelve * b_coef * h_next;
509            a[(row, idx_m(i + 1))] += eight * b_coef * h_next;
510            a[(row, idx_mm(i - 1))] += -a_coef * h_prev * h_prev;
511            a[(row, idx_mm(i))] +=
512                three * a_coef * h_prev * h_prev + three * b_coef * h_next * h_next;
513            a[(row, idx_mm(i + 1))] += -b_coef * h_next * h_next;
514            rhs[row] = twenty * a_coef * y[i - 1] - twenty * (a_coef + b_coef) * y[i]
515                + twenty * b_coef * y[i + 1];
516            row += 1;
517
518            // Fourth-derivative continuity.
519            a[(row, idx_m(i - 1))] += -fourteen * c_coef * h_prev;
520            a[(row, idx_m(i))] += -sixteen * c_coef * h_prev - sixteen * d_coef * h_next;
521            a[(row, idx_m(i + 1))] += -fourteen * d_coef * h_next;
522            a[(row, idx_mm(i - 1))] += -two * c_coef * h_prev * h_prev;
523            a[(row, idx_mm(i))] +=
524                three * c_coef * h_prev * h_prev - three * d_coef * h_next * h_next;
525            a[(row, idx_mm(i + 1))] += two * d_coef * h_next * h_next;
526            rhs[row] = thirty * c_coef * (y[i - 1] - y[i]) + thirty * d_coef * (y[i] - y[i + 1]);
527            row += 1;
528        }
529
530        // Right natural boundary condition: third and fourth derivatives of
531        // the last segment vanish at its right end (t = h_last).
532        {
533            let h_last = h[n - 2];
534            let h_last_2 = h_last * h_last;
535
536            a[(row, idx_m(n - 2))] = -eight * h_last;
537            a[(row, idx_m(n - 1))] = -twelve * h_last;
538            a[(row, idx_mm(n - 2))] = -h_last_2;
539            a[(row, idx_mm(n - 1))] = three * h_last_2;
540            rhs[row] = twenty * (y[n - 2] - y[n - 1]);
541            row += 1;
542
543            a[(row, idx_m(n - 2))] = -fourteen * h_last;
544            a[(row, idx_m(n - 1))] = -sixteen * h_last;
545            a[(row, idx_mm(n - 2))] = -two * h_last_2;
546            a[(row, idx_mm(n - 1))] = three * h_last_2;
547            rhs[row] = thirty * (y[n - 2] - y[n - 1]);
548            row += 1;
549        }
550
551        debug_assert_eq!(row, dim);
552
553        let solution = solve_with_stability_monitoring(&a.view(), &rhs.view()).map_err(|e| {
554            InterpolateError::NumericalInstability {
555                message: format!("failed to solve quintic spline derivative system: {e}"),
556            }
557        })?;
558
559        let mut segments = Vec::with_capacity(n - 1);
560        for i in 0..n - 1 {
561            segments.push(QuinticSegment::from_hermite_quintic(
562                y[i],
563                y[i + 1],
564                solution[idx_m(i)],
565                solution[idx_m(i + 1)],
566                solution[idx_mm(i)],
567                solution[idx_mm(i + 1)],
568                h[i],
569            ));
570        }
571
572        Ok(Self {
573            x: x.to_owned(),
574            segments,
575        })
576    }
577
578    /// Evaluate the spline at `x_new`, clamping to the nearest valid segment
579    /// (and thus extrapolating via that segment's polynomial) if `x_new`
580    /// falls outside `[x[0], x[n-1]]`.
581    fn evaluate(&self, x_new: F) -> F {
582        let n = self.x.len();
583        let idx = find_interval(&self.x.view(), x_new).min(n - 2);
584        let t = x_new - self.x[idx];
585        self.segments[idx].evaluate(t)
586    }
587}
588
589/// Check if array is sorted in ascending order
590#[allow(dead_code)]
591fn is_sorted<F: PartialOrd>(arr: &ArrayView1<F>) -> bool {
592    for window in arr.windows(2) {
593        if window[0] > window[1] {
594            return false;
595        }
596    }
597    true
598}
599
600/// Find interval containing the value using binary search
601#[allow(dead_code)]
602fn find_interval<F: PartialOrd>(arr: &ArrayView1<F>, value: F) -> usize {
603    // Convert to slice to use binary_search_by
604    let slice: &[F] = arr.as_slice().expect("Operation failed");
605    match slice.binary_search_by(|x| x.partial_cmp(&value).expect("Operation failed")) {
606        Ok(idx) => idx,
607        Err(idx) => {
608            if idx == 0 {
609                0
610            } else if idx >= arr.len() {
611                arr.len() - 1
612            } else {
613                idx - 1
614            }
615        }
616    }
617}
618
619/// Create a 2D interpolator (convenience function)
620///
621/// This function provides a simple interface similar to SciPy's interp2d.
622///
623/// # Examples
624///
625/// ```
626/// use scirs2_core::ndarray::{array, Array2};
627/// use scirs2_interpolate::interp2d::{interp2d, Interp2dKind};
628///
629/// let x = array![0.0, 1.0, 2.0];
630/// let y = array![0.0, 1.0];
631/// let z = Array2::from_shape_fn((2, 3), |(i, j)| {
632///     y[i] * x[j] // z = x * y
633/// });
634///
635/// let interp = interp2d(&x.view(), &y.view(), &z.view(), Interp2dKind::Linear)?;
636/// let result = interp.evaluate(1.5, 0.5)?;
637/// # Ok::<(), Box<dyn std::error::Error>>(())
638/// ```
639#[allow(dead_code)]
640pub fn interp2d<F>(
641    x: &ArrayView1<F>,
642    y: &ArrayView1<F>,
643    z: &ArrayView2<F>,
644    kind: Interp2dKind,
645) -> InterpolateResult<Interp2d<F>>
646where
647    F: Float + FromPrimitive + Debug + Clone + crate::traits::InterpolationFloat,
648{
649    Interp2d::new(x, y, z, kind)
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use approx::assert_abs_diff_eq;
656    use scirs2_core::ndarray::{array, Array2};
657
658    #[test]
659    fn test_linear_interpolation() -> InterpolateResult<()> {
660        // Create a simple 2x3 grid where z = x + y
661        let x = array![0.0, 1.0, 2.0];
662        let y = array![0.0, 1.0];
663        let z = Array2::from_shape_fn((2, 3), |(i, j)| y[i] + x[j]);
664
665        let interp = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Linear)?;
666
667        // Test exact grid points
668        assert_abs_diff_eq!(interp.evaluate(0.0, 0.0)?, 0.0, epsilon = 1e-10);
669        assert_abs_diff_eq!(interp.evaluate(1.0, 0.0)?, 1.0, epsilon = 1e-10);
670        assert_abs_diff_eq!(interp.evaluate(0.0, 1.0)?, 1.0, epsilon = 1e-10);
671        assert_abs_diff_eq!(interp.evaluate(2.0, 1.0)?, 3.0, epsilon = 1e-10);
672
673        // Test interpolated point
674        assert_abs_diff_eq!(interp.evaluate(0.5, 0.5)?, 1.0, epsilon = 1e-10);
675        assert_abs_diff_eq!(interp.evaluate(1.5, 0.5)?, 2.0, epsilon = 1e-10);
676
677        Ok(())
678    }
679
680    #[test]
681    fn test_cubic_interpolation() -> InterpolateResult<()> {
682        // Create a 4x4 grid for cubic interpolation
683        let x = array![0.0, 1.0, 2.0, 3.0];
684        let y = array![0.0, 1.0, 2.0, 3.0];
685        let z = Array2::from_shape_fn((4, 4), |(i, j)| {
686            let x_val = x[j];
687            let y_val = y[i];
688            x_val * x_val + y_val * y_val // z = x² + y²
689        });
690
691        let interp = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Cubic)?;
692
693        // Test exact grid points
694        assert_abs_diff_eq!(interp.evaluate(0.0, 0.0)?, 0.0, epsilon = 1e-10);
695        assert_abs_diff_eq!(interp.evaluate(1.0, 1.0)?, 2.0, epsilon = 1e-10);
696
697        // Test interpolated point (should be close to the function value)
698        let result = interp.evaluate(1.5, 1.5)?;
699        let expected = 1.5 * 1.5 + 1.5 * 1.5; // 4.5
700        assert!((result - expected).abs() < 0.5); // Reasonable tolerance for cubic
701
702        Ok(())
703    }
704
705    #[test]
706    fn test_grid_evaluation() -> InterpolateResult<()> {
707        let x = array![0.0, 1.0];
708        let y = array![0.0, 1.0];
709        let z = Array2::from_shape_fn((2, 2), |(i, j)| y[i] + x[j]);
710
711        let interp = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Linear)?;
712
713        let x_new = array![0.0, 0.5, 1.0];
714        let ynew = array![0.0, 0.5, 1.0];
715
716        let result = interp.evaluate_grid(&x_new.view(), &ynew.view())?;
717
718        assert_eq!(result.shape(), &[3, 3]);
719        assert_abs_diff_eq!(result[[0, 0]], 0.0, epsilon = 1e-10); // (0,0)
720        assert_abs_diff_eq!(result[[1, 1]], 1.0, epsilon = 1e-10); // (0.5,0.5)
721        assert_abs_diff_eq!(result[[2, 2]], 2.0, epsilon = 1e-10); // (1,1)
722
723        Ok(())
724    }
725
726    #[test]
727    fn test_validation() {
728        let x = array![0.0, 1.0];
729        let y = array![0.0, 1.0];
730        let z = Array2::zeros((3, 2)); // Wrong shape
731
732        let result = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Linear);
733        assert!(result.is_err());
734    }
735
736    #[test]
737    fn test_unsorted_coordinates() {
738        let x = array![1.0, 0.0]; // Not sorted
739        let y = array![0.0, 1.0];
740        let z = Array2::zeros((2, 2));
741
742        let result = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Linear);
743        assert!(result.is_err());
744    }
745
746    #[test]
747    fn test_quintic_spline_1d_exact_quadratic_reproduction() -> InterpolateResult<()> {
748        // A natural quintic spline (third and fourth derivatives zero at
749        // the two endpoints) exactly reproduces any polynomial of degree
750        // <= 2, exactly as a natural cubic spline exactly reproduces any
751        // polynomial of degree <= 1 -- both because such low-degree
752        // polynomials trivially satisfy the "natural" boundary condition
753        // everywhere. Non-uniform grid, non-constant data.
754        let x = array![0.0, 0.3, 0.9, 1.5, 2.2, 3.0, 3.7];
755        let poly = |v: f64| 3.0 - 2.0 * v + 0.5 * v * v;
756        let y = x.mapv(poly);
757
758        let spline = QuinticSpline1D::new(&x.view(), &y.view())?;
759
760        for &xq in &[0.05, 0.6, 1.1, 1.9, 2.6, 3.5] {
761            let got = spline.evaluate(xq);
762            let expected = poly(xq);
763            assert!(
764                (got - expected).abs() < 1e-9,
765                "quintic spline should exactly reproduce a quadratic: got {got}, \
766                 expected {expected} at x={xq}"
767            );
768        }
769
770        Ok(())
771    }
772
773    #[test]
774    fn test_quintic_spline_1d_converges_faster_than_cubic_order() -> InterpolateResult<()> {
775        // Genuine quintic-order accuracy on a smooth, non-polynomial
776        // function (sin) must converge dramatically faster than a cubic
777        // spline's well-known O(h^4) rate as the grid is refined: doubling
778        // the resolution should shrink the error by far more than cubic's
779        // ~16x (2^4) per halving. A silent fallback to cubic would only
780        // ever show ~16x here, never the >50x required below.
781        fn error_at(n: usize) -> InterpolateResult<f64> {
782            let x = Array1::linspace(0.0, 2.0 * std::f64::consts::PI, n);
783            let y = x.mapv(|v: f64| v.sin());
784            let spline = QuinticSpline1D::new(&x.view(), &y.view())?;
785            let h = x[1] - x[0];
786            let xq = std::f64::consts::PI + 0.31 * h; // off-node, near domain center
787            Ok((spline.evaluate(xq) - xq.sin()).abs())
788        }
789
790        let e11 = error_at(11)?;
791        let e21 = error_at(21)?;
792        let e41 = error_at(41)?;
793
794        assert!(e11 > 0.0 && e21 > 0.0 && e41 > 0.0);
795        assert!(
796            e11 / e21 > 50.0,
797            "expected quintic-order convergence (>50x per doubling), got {}x \
798             (e11={e11}, e21={e21})",
799            e11 / e21
800        );
801        assert!(
802            e21 / e41 > 50.0,
803            "expected quintic-order convergence (>50x per doubling), got {}x \
804             (e21={e21}, e41={e41})",
805            e21 / e41
806        );
807
808        Ok(())
809    }
810
811    #[test]
812    fn test_quintic_spline_1d_rejects_degenerate_input() {
813        let x = array![0.0, 1.0];
814        let y = array![0.0, 1.0];
815        // Fewer than 3 points: cannot build the natural quintic system.
816        assert!(QuinticSpline1D::new(&x.view(), &y.view()).is_err());
817
818        let x2 = array![0.0, 1.0, 1.0];
819        let y2 = array![0.0, 1.0, 2.0];
820        // Non-strictly-increasing x.
821        assert!(QuinticSpline1D::new(&x2.view(), &y2.view()).is_err());
822    }
823
824    #[test]
825    fn test_interp2d_quintic_reproduces_separable_quadratic_exactly() -> InterpolateResult<()> {
826        // z = x^2 + y^2 is additively separable into two degree-2 pieces,
827        // so the tensor-product quintic construction (a quintic spline
828        // along x for every row, then a quintic spline along y) must
829        // reproduce it to near machine precision -- a stronger, more
830        // reliable check than merely comparing against a cubic spline
831        // (whose own accuracy is data- and resolution-dependent).
832        let x = array![0.0, 0.4, 1.1, 1.8, 2.6, 3.5];
833        let y = array![0.0, 0.5, 1.3, 2.1, 2.9];
834        let z = Array2::from_shape_fn((y.len(), x.len()), |(i, j)| x[j] * x[j] + y[i] * y[i]);
835
836        let interp = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Quintic)?;
837
838        for &(xq, yq) in &[(0.2, 0.3), (1.5, 1.0), (3.2, 2.5), (0.05, 2.85)] {
839            let got = interp.evaluate(xq, yq)?;
840            let expected = xq * xq + yq * yq;
841            assert!(
842                (got - expected).abs() < 1e-8,
843                "quintic Interp2d should exactly reproduce x^2+y^2: got {got}, \
844                 expected {expected} at ({xq}, {yq})"
845            );
846        }
847
848        Ok(())
849    }
850
851    #[test]
852    fn test_interp2d_quintic_is_not_a_silent_cubic_fallback() -> InterpolateResult<()> {
853        // On identical, smooth (non-polynomial) data, Quintic must produce
854        // a genuinely different result from Cubic. Under the previous
855        // implementation, `Interp2dKind::Quintic` silently called
856        // `evaluate_cubic`, so the two would have been bit-for-bit
857        // identical; a real quintic implementation must differ by far more
858        // than any floating-point rounding noise (~1e-13 here).
859        let n = 9;
860        let x = Array1::linspace(0.0, 3.0, n);
861        let y = Array1::linspace(0.0, 2.0, n);
862        let z = Array2::from_shape_fn((n, n), |(i, j)| x[j].sin() + y[i].cos());
863
864        let quintic = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Quintic)?;
865        let cubic = Interp2d::new(&x.view(), &y.view(), &z.view(), Interp2dKind::Cubic)?;
866
867        let (xq, yq) = (1.35, 0.83);
868        let quintic_val = quintic.evaluate(xq, yq)?;
869        let cubic_val = cubic.evaluate(xq, yq)?;
870
871        assert!(
872            (quintic_val - cubic_val).abs() > 1e-8,
873            "Quintic ({quintic_val}) must not silently match Cubic ({cubic_val})"
874        );
875
876        Ok(())
877    }
878}