Skip to main content

fdars_core/
helpers.rs

1//! Helper functions for numerical integration and common operations.
2
3/// Small epsilon for numerical comparisons (e.g., avoiding division by zero).
4pub const NUMERICAL_EPS: f64 = 1e-10;
5
6/// Default convergence tolerance for iterative algorithms.
7pub const DEFAULT_CONVERGENCE_TOL: f64 = 1e-6;
8
9/// Sort a slice using total ordering that treats NaN as equal.
10pub fn sort_nan_safe(slice: &mut [f64]) {
11    slice.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
12}
13
14/// Extract curves from column-major data matrix.
15///
16/// Converts a flat column-major matrix into a vector of curve vectors,
17/// where each curve contains all evaluation points for one observation.
18///
19/// # Arguments
20/// * `data` - Functional data matrix (n x m)
21///
22/// # Returns
23/// Vector of n curves, each containing m values
24pub fn extract_curves(data: &crate::matrix::FdMatrix) -> Vec<Vec<f64>> {
25    data.rows()
26}
27
28/// Compute L2 distance between two curves using integration weights.
29///
30/// # Arguments
31/// * `curve1` - First curve values
32/// * `curve2` - Second curve values
33/// * `weights` - Integration weights
34///
35/// # Returns
36/// L2 distance between the curves
37pub fn l2_distance(curve1: &[f64], curve2: &[f64], weights: &[f64]) -> f64 {
38    let mut dist_sq = 0.0;
39    for i in 0..curve1.len() {
40        let diff = curve1[i] - curve2[i];
41        dist_sq += diff * diff * weights[i];
42    }
43    dist_sq.sqrt()
44}
45
46/// Compute Simpson's 1/3 rule integration weights for a grid.
47///
48/// For odd n (even number of intervals): standard composite Simpson's 1/3 rule.
49/// For even n: Simpson's 1/3 for first n-1 points, trapezoidal for last interval.
50/// For non-uniform grids: generalized Simpson's weights per sub-interval pair.
51///
52/// # Arguments
53/// * `argvals` - Grid points (evaluation points)
54///
55/// # Returns
56/// Vector of integration weights
57pub fn simpsons_weights(argvals: &[f64]) -> Vec<f64> {
58    let n = argvals.len();
59    if n < 2 {
60        return vec![1.0; n];
61    }
62
63    let mut weights = vec![0.0; n];
64
65    if n == 2 {
66        // Trapezoidal rule
67        let h = argvals[1] - argvals[0];
68        weights[0] = h / 2.0;
69        weights[1] = h / 2.0;
70        return weights;
71    }
72
73    // Check if grid is uniform
74    let h0 = argvals[1] - argvals[0];
75    let is_uniform = argvals
76        .windows(2)
77        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs());
78
79    if is_uniform {
80        simpsons_weights_uniform(&mut weights, n, h0);
81    } else {
82        simpsons_weights_nonuniform(&mut weights, argvals, n);
83    }
84
85    weights
86}
87
88/// Uniform grid Simpson's 1/3 weights.
89fn simpsons_weights_uniform(weights: &mut [f64], n: usize, h0: f64) {
90    let n_intervals = n - 1;
91    if n_intervals % 2 == 0 {
92        // Even number of intervals (odd n): pure Simpson's
93        weights[0] = h0 / 3.0;
94        weights[n - 1] = h0 / 3.0;
95        for i in 1..n - 1 {
96            weights[i] = if i % 2 == 1 {
97                4.0 * h0 / 3.0
98            } else {
99                2.0 * h0 / 3.0
100            };
101        }
102    } else {
103        // Odd number of intervals (even n): Simpson's + trapezoidal for last
104        let n_simp = n - 1;
105        weights[0] = h0 / 3.0;
106        weights[n_simp - 1] = h0 / 3.0;
107        for i in 1..n_simp - 1 {
108            weights[i] = if i % 2 == 1 {
109                4.0 * h0 / 3.0
110            } else {
111                2.0 * h0 / 3.0
112            };
113        }
114        weights[n_simp - 1] += h0 / 2.0;
115        weights[n - 1] += h0 / 2.0;
116    }
117}
118
119/// Non-uniform grid generalized Simpson's weights.
120fn simpsons_weights_nonuniform(weights: &mut [f64], argvals: &[f64], n: usize) {
121    let n_intervals = n - 1;
122    let n_pairs = n_intervals / 2;
123
124    for k in 0..n_pairs {
125        let i0 = 2 * k;
126        let i1 = i0 + 1;
127        let i2 = i0 + 2;
128        let h1 = argvals[i1] - argvals[i0];
129        let h2 = argvals[i2] - argvals[i1];
130        let h_sum = h1 + h2;
131
132        weights[i0] += (2.0 * h1 - h2) * h_sum / (6.0 * h1);
133        weights[i1] += h_sum * h_sum * h_sum / (6.0 * h1 * h2);
134        weights[i2] += (2.0 * h2 - h1) * h_sum / (6.0 * h2);
135    }
136
137    if n_intervals % 2 == 1 {
138        let h_last = argvals[n - 1] - argvals[n - 2];
139        weights[n - 2] += h_last / 2.0;
140        weights[n - 1] += h_last / 2.0;
141    }
142}
143
144/// Compute 2D integration weights using tensor product of 1D weights.
145///
146/// Returns a flattened vector of weights for an m1 x m2 grid.
147///
148/// # Arguments
149/// * `argvals_s` - Grid points in s direction
150/// * `argvals_t` - Grid points in t direction
151///
152/// # Returns
153/// Flattened vector of integration weights (column-major: s-varies-fastest, matching FdMatrix surface layout)
154pub fn simpsons_weights_2d(argvals_s: &[f64], argvals_t: &[f64]) -> Vec<f64> {
155    let weights_s = simpsons_weights(argvals_s);
156    let weights_t = simpsons_weights(argvals_t);
157    let m1 = argvals_s.len();
158    let m2 = argvals_t.len();
159
160    let mut weights = vec![0.0; m1 * m2];
161    for i in 0..m1 {
162        for j in 0..m2 {
163            weights[i + j * m1] = weights_s[i] * weights_t[j];
164        }
165    }
166    weights
167}
168
169/// Linear interpolation at point `t` using binary search.
170///
171/// Clamps to boundary values outside the domain of `x`.
172pub fn linear_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
173    if t <= x[0] {
174        return y[0];
175    }
176    let last = x.len() - 1;
177    if t >= x[last] {
178        return y[last];
179    }
180
181    let idx = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
182        Ok(i) => return y[i],
183        Err(i) => i,
184    };
185
186    let t0 = x[idx - 1];
187    let t1 = x[idx];
188    let y0 = y[idx - 1];
189    let y1 = y[idx];
190    y0 + (y1 - y0) * (t - t0) / (t1 - t0)
191}
192
193/// Cumulative integration using Simpson's rule where possible.
194///
195/// For pairs of intervals uses Simpson's 1/3 rule for higher accuracy.
196/// Falls back to trapezoidal for the last interval if n is even.
197pub fn cumulative_trapz(y: &[f64], x: &[f64]) -> Vec<f64> {
198    let n = y.len();
199    let mut out = vec![0.0; n];
200    if n < 2 {
201        return out;
202    }
203
204    // Process pairs of intervals with Simpson's rule
205    let mut k = 1;
206    while k + 1 < n {
207        let h1 = x[k] - x[k - 1];
208        let h2 = x[k + 1] - x[k];
209        let h_sum = h1 + h2;
210
211        // Generalized Simpson's for this pair of intervals
212        let integral = h_sum / 6.0
213            * (y[k - 1] * (2.0 * h1 - h2) / h1
214                + y[k] * h_sum * h_sum / (h1 * h2)
215                + y[k + 1] * (2.0 * h2 - h1) / h2);
216
217        out[k] = out[k - 1] + {
218            // First sub-interval: use trapezoidal for the intermediate value
219            0.5 * (y[k] + y[k - 1]) * h1
220        };
221        out[k + 1] = out[k - 1] + integral;
222        k += 2;
223    }
224
225    // If there's a remaining interval, use trapezoidal
226    if k < n {
227        out[k] = out[k - 1] + 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
228    }
229
230    out
231}
232
233/// Trapezoidal integration of `y` over `x`.
234pub fn trapz(y: &[f64], x: &[f64]) -> f64 {
235    let mut sum = 0.0;
236    for k in 1..y.len() {
237        sum += 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
238    }
239    sum
240}
241
242/// Interpolation method for resampling functional data.
243#[derive(Debug, Clone, Copy, PartialEq)]
244#[non_exhaustive]
245pub enum InterpolationMethod {
246    /// Linear interpolation between adjacent points.
247    Linear,
248    /// Cubic Hermite interpolation (monotone, C1 continuous).
249    CubicHermite,
250}
251
252/// Interpolate functional data to a new grid.
253///
254/// Resamples each curve from `data` evaluated at `argvals` to the new
255/// evaluation points `new_argvals`.
256///
257/// # Arguments
258/// * `data` - Functional data matrix (n x m)
259/// * `argvals` - Original evaluation points (length m, must be sorted)
260/// * `new_argvals` - New evaluation points (length m_new, must be sorted, within original domain)
261/// * `method` - Interpolation method
262///
263/// # Returns
264/// Interpolated matrix (n x m_new)
265#[must_use]
266pub fn fdata_interpolate(
267    data: &crate::matrix::FdMatrix,
268    argvals: &[f64],
269    new_argvals: &[f64],
270    method: InterpolationMethod,
271) -> crate::matrix::FdMatrix {
272    let (n, m) = data.shape();
273    let m_new = new_argvals.len();
274    if n == 0 || m < 2 || m_new == 0 {
275        return crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1));
276    }
277
278    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
279
280    for i in 0..n {
281        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
282        for (j, &t) in new_argvals.iter().enumerate() {
283            result[(i, j)] = match method {
284                InterpolationMethod::Linear => linear_interp(argvals, &y, t),
285                InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
286            };
287        }
288    }
289
290    result
291}
292
293/// Cubic Hermite interpolation at a single point.
294///
295/// Uses Fritsch-Carlson monotone slopes for C1 interpolation.
296fn cubic_hermite_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
297    let n = x.len();
298    if n < 2 {
299        return if n == 1 { y[0] } else { 0.0 };
300    }
301
302    // Clamp to domain
303    if t <= x[0] {
304        return y[0];
305    }
306    if t >= x[n - 1] {
307        return y[n - 1];
308    }
309
310    // Find interval via binary search
311    let k = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
312        Ok(i) => return y[i],
313        Err(i) => {
314            if i == 0 {
315                0
316            } else {
317                i - 1
318            }
319        }
320    };
321
322    // Compute slopes (Fritsch-Carlson)
323    let slopes: Vec<f64> = x
324        .windows(2)
325        .zip(y.windows(2))
326        .map(|(xw, yw)| (yw[1] - yw[0]) / (xw[1] - xw[0]))
327        .collect();
328
329    // Tangents at each point
330    let mut tangents = vec![0.0; n];
331    tangents[0] = slopes[0];
332    tangents[n - 1] = slopes[n - 2];
333    for i in 1..n - 1 {
334        if slopes[i - 1].signum() != slopes[i].signum() {
335            tangents[i] = 0.0;
336        } else {
337            tangents[i] = (slopes[i - 1] + slopes[i]) / 2.0;
338        }
339    }
340
341    // Hermite basis
342    let h = x[k + 1] - x[k];
343    let s = (t - x[k]) / h;
344    let s2 = s * s;
345    let s3 = s2 * s;
346
347    let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
348    let h10 = s3 - 2.0 * s2 + s;
349    let h01 = -2.0 * s3 + 3.0 * s2;
350    let h11 = s3 - s2;
351
352    h00 * y[k] + h10 * h * tangents[k] + h01 * y[k + 1] + h11 * h * tangents[k + 1]
353}
354
355/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
356///
357/// Interior points use the 5-point central difference:
358///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
359///
360/// Near-boundary points use appropriate forward/backward formulas.
361pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
362    let n = y.len();
363    let mut g = vec![0.0; n];
364    if n < 2 {
365        return g;
366    }
367    if n == 2 {
368        g[0] = (y[1] - y[0]) / h;
369        g[1] = (y[1] - y[0]) / h;
370        return g;
371    }
372    if n == 3 {
373        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
374        g[1] = (y[2] - y[0]) / (2.0 * h);
375        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
376        return g;
377    }
378    if n == 4 {
379        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
380        g[1] = (y[2] - y[0]) / (2.0 * h);
381        g[2] = (y[3] - y[1]) / (2.0 * h);
382        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
383        return g;
384    }
385
386    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
387    // Left boundary: O(h³) forward formula
388    g[0] = (-25.0 * y[0] + 48.0 * y[1] - 36.0 * y[2] + 16.0 * y[3] - 3.0 * y[4]) / (12.0 * h);
389    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
390
391    // Interior: 5-point central difference O(h⁴)
392    for i in 2..n - 2 {
393        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
394    }
395
396    // Right boundary: O(h³) backward formula
397    g[n - 2] = (-y[n - 5] + 6.0 * y[n - 4] - 18.0 * y[n - 3] + 10.0 * y[n - 2] + 3.0 * y[n - 1])
398        / (12.0 * h);
399    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
400        + 25.0 * y[n - 1])
401        / (12.0 * h);
402    g
403}
404
405/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
406///
407/// At interior points uses the three-point formula:
408///   `g[i] = y[i-1]*h_r/(-h_l*(h_l+h_r)) + y[i]*(h_r-h_l)/(h_l*h_r) + y[i+1]*h_l/(h_r*(h_l+h_r))`
409/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
410///
411/// Boundary points use forward/backward 3-point formulas.
412pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
413    let n = y.len();
414    assert_eq!(n, t.len(), "y and t must have the same length");
415    let mut g = vec![0.0; n];
416    if n < 2 {
417        return g;
418    }
419    if n == 2 {
420        let h = t[1] - t[0];
421        if h.abs() < 1e-15 {
422            return g;
423        }
424        g[0] = (y[1] - y[0]) / h;
425        g[1] = g[0];
426        return g;
427    }
428
429    // Left boundary: 3-point forward Lagrange derivative
430    let h0 = t[1] - t[0];
431    let h1 = t[2] - t[0];
432    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
433        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
434            - y[2] * h0 / (h1 * (h1 - h0));
435    } else {
436        g[0] = (y[1] - y[0]) / h0.max(1e-15);
437    }
438
439    // Interior: 3-point Lagrange central formula
440    for i in 1..n - 1 {
441        let h_l = t[i] - t[i - 1];
442        let h_r = t[i + 1] - t[i];
443        let h_sum = h_l + h_r;
444        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
445            g[i] = 0.0;
446            continue;
447        }
448        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
449            + y[i] * (h_r - h_l) / (h_l * h_r)
450            + y[i + 1] * h_l / (h_r * h_sum);
451    }
452
453    // Right boundary: 3-point backward Lagrange derivative
454    let h_last = t[n - 1] - t[n - 2];
455    let h_prev = t[n - 1] - t[n - 3];
456    let h_mid = t[n - 2] - t[n - 3];
457    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
458        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
459            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
460    } else {
461        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
462    }
463
464    g
465}
466
467/// Numerical gradient that auto-detects uniform vs non-uniform grids.
468///
469/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
470/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
471/// [`gradient_nonuniform`].
472pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
473    let n = t.len();
474    if n < 2 {
475        return vec![0.0; y.len()];
476    }
477
478    let h0 = t[1] - t[0];
479    let is_uniform = t
480        .windows(2)
481        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
482
483    if is_uniform {
484        gradient_uniform(y, h0)
485    } else {
486        gradient_nonuniform(y, t)
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn test_simpsons_weights_uniform() {
496        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
497        let weights = simpsons_weights(&argvals);
498        let sum: f64 = weights.iter().sum();
499        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
500    }
501
502    #[test]
503    fn test_simpsons_weights_2d() {
504        let argvals_s = vec![0.0, 0.5, 1.0];
505        let argvals_t = vec![0.0, 0.5, 1.0];
506        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
507        let sum: f64 = weights.iter().sum();
508        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
509    }
510
511    #[test]
512    fn test_extract_curves() {
513        // Column-major data: 2 observations, 3 points
514        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
515        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
516        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
517        let curves = extract_curves(&mat);
518        assert_eq!(curves.len(), 2);
519        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
520        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
521    }
522
523    #[test]
524    fn test_l2_distance_identical() {
525        let curve = vec![1.0, 2.0, 3.0];
526        let weights = vec![0.25, 0.5, 0.25];
527        let dist = l2_distance(&curve, &curve, &weights);
528        assert!(dist.abs() < NUMERICAL_EPS);
529    }
530
531    #[test]
532    fn test_l2_distance_different() {
533        let curve1 = vec![0.0, 0.0, 0.0];
534        let curve2 = vec![1.0, 1.0, 1.0];
535        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
536        let dist = l2_distance(&curve1, &curve2, &weights);
537        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
538        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
539    }
540
541    #[test]
542    fn test_n1_weights() {
543        // Single point: fallback weight is 1.0 (degenerate case)
544        let w = simpsons_weights(&[0.5]);
545        assert_eq!(w.len(), 1);
546        assert!((w[0] - 1.0).abs() < 1e-12);
547    }
548
549    #[test]
550    fn test_n2_weights() {
551        let w = simpsons_weights(&[0.0, 1.0]);
552        assert_eq!(w.len(), 2);
553        // Trapezoidal: each weight should be 0.5
554        assert!((w[0] - 0.5).abs() < 1e-12);
555        assert!((w[1] - 0.5).abs() < 1e-12);
556    }
557
558    #[test]
559    fn test_mismatched_l2_distance() {
560        // Mismatched lengths should not panic but may give garbage
561        let a = vec![1.0, 2.0, 3.0];
562        let b = vec![1.0, 2.0, 3.0];
563        let w = vec![0.5, 0.5, 0.5];
564        let d = l2_distance(&a, &b, &w);
565        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
566    }
567
568    // ── trapz ──
569
570    #[test]
571    fn test_trapz_sine() {
572        // ∫₀^π sin(x) dx = 2
573        let m = 1000;
574        let x: Vec<f64> = (0..m)
575            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
576            .collect();
577        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
578        let result = trapz(&y, &x);
579        assert!(
580            (result - 2.0).abs() < 1e-4,
581            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
582        );
583    }
584
585    // ── cumulative_trapz ──
586
587    #[test]
588    fn test_cumulative_trapz_matches_final() {
589        let m = 100;
590        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
591        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
592        let cum = cumulative_trapz(&y, &x);
593        let total = trapz(&y, &x);
594        assert!(
595            (cum[m - 1] - total).abs() < 1e-12,
596            "Final cumulative value should match trapz"
597        );
598    }
599
600    // ── linear_interp ──
601
602    #[test]
603    fn test_linear_interp_boundary_clamp() {
604        let x = vec![0.0, 0.5, 1.0];
605        let y = vec![10.0, 20.0, 30.0];
606        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
607        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
608        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
609    }
610
611    // ── gradient_uniform ──
612
613    #[test]
614    fn test_gradient_uniform_linear() {
615        // f(x) = 3x → f'(x) = 3 everywhere
616        let m = 50;
617        let h = 1.0 / (m - 1) as f64;
618        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
619        let g = gradient_uniform(&y, h);
620        for i in 0..m {
621            assert!(
622                (g[i] - 3.0).abs() < 1e-10,
623                "gradient of 3x should be 3 at i={i}, got {}",
624                g[i]
625            );
626        }
627    }
628
629    // ── fdata_interpolate ──
630
631    #[test]
632    fn fdata_interpolate_linear_identity() {
633        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
634        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
635        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
636        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
637        for j in 0..20 {
638            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
639        }
640    }
641
642    #[test]
643    fn fdata_interpolate_cubic_hermite_smooth() {
644        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
645        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
646        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
647
648        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
649        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
650
651        // Values should approximate sin(t) well
652        for (j, &tj) in t_fine.iter().enumerate() {
653            assert!(
654                (result[(0, j)] - tj.sin()).abs() < 0.02,
655                "at t={tj:.2}: got {:.4}, expected {:.4}",
656                result[(0, j)],
657                tj.sin()
658            );
659        }
660    }
661
662    #[test]
663    fn fdata_interpolate_multiple_curves() {
664        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
665        let n = 5;
666        let m = 30;
667        // Build column-major data: n curves, each sin((i+1)*x)
668        let mut col_major = vec![0.0; n * m];
669        for i in 0..n {
670            for j in 0..m {
671                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
672            }
673        }
674        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
675
676        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
677        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
678        assert_eq!(result.shape(), (n, 50));
679        // All values should be finite
680        for i in 0..n {
681            for j in 0..50 {
682                assert!(result[(i, j)].is_finite());
683            }
684        }
685    }
686}