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/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
243///
244/// Interior points use the 5-point central difference:
245///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
246///
247/// Near-boundary points use appropriate forward/backward formulas.
248pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
249    let n = y.len();
250    let mut g = vec![0.0; n];
251    if n < 2 {
252        return g;
253    }
254    if n == 2 {
255        g[0] = (y[1] - y[0]) / h;
256        g[1] = (y[1] - y[0]) / h;
257        return g;
258    }
259    if n == 3 {
260        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
261        g[1] = (y[2] - y[0]) / (2.0 * h);
262        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
263        return g;
264    }
265    if n == 4 {
266        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
267        g[1] = (y[2] - y[0]) / (2.0 * h);
268        g[2] = (y[3] - y[1]) / (2.0 * h);
269        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
270        return g;
271    }
272
273    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
274    // Left boundary: O(h³) forward formula
275    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);
276    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
277
278    // Interior: 5-point central difference O(h⁴)
279    for i in 2..n - 2 {
280        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
281    }
282
283    // Right boundary: O(h³) backward formula
284    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])
285        / (12.0 * h);
286    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
287        + 25.0 * y[n - 1])
288        / (12.0 * h);
289    g
290}
291
292/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
293///
294/// At interior points uses the three-point formula:
295///   `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))`
296/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
297///
298/// Boundary points use forward/backward 3-point formulas.
299pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
300    let n = y.len();
301    assert_eq!(n, t.len(), "y and t must have the same length");
302    let mut g = vec![0.0; n];
303    if n < 2 {
304        return g;
305    }
306    if n == 2 {
307        let h = t[1] - t[0];
308        if h.abs() < 1e-15 {
309            return g;
310        }
311        g[0] = (y[1] - y[0]) / h;
312        g[1] = g[0];
313        return g;
314    }
315
316    // Left boundary: 3-point forward Lagrange derivative
317    let h0 = t[1] - t[0];
318    let h1 = t[2] - t[0];
319    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
320        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
321            - y[2] * h0 / (h1 * (h1 - h0));
322    } else {
323        g[0] = (y[1] - y[0]) / h0.max(1e-15);
324    }
325
326    // Interior: 3-point Lagrange central formula
327    for i in 1..n - 1 {
328        let h_l = t[i] - t[i - 1];
329        let h_r = t[i + 1] - t[i];
330        let h_sum = h_l + h_r;
331        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
332            g[i] = 0.0;
333            continue;
334        }
335        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
336            + y[i] * (h_r - h_l) / (h_l * h_r)
337            + y[i + 1] * h_l / (h_r * h_sum);
338    }
339
340    // Right boundary: 3-point backward Lagrange derivative
341    let h_last = t[n - 1] - t[n - 2];
342    let h_prev = t[n - 1] - t[n - 3];
343    let h_mid = t[n - 2] - t[n - 3];
344    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
345        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
346            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
347    } else {
348        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
349    }
350
351    g
352}
353
354/// Numerical gradient that auto-detects uniform vs non-uniform grids.
355///
356/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
357/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
358/// [`gradient_nonuniform`].
359pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
360    let n = t.len();
361    if n < 2 {
362        return vec![0.0; y.len()];
363    }
364
365    let h0 = t[1] - t[0];
366    let is_uniform = t
367        .windows(2)
368        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
369
370    if is_uniform {
371        gradient_uniform(y, h0)
372    } else {
373        gradient_nonuniform(y, t)
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_simpsons_weights_uniform() {
383        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
384        let weights = simpsons_weights(&argvals);
385        let sum: f64 = weights.iter().sum();
386        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
387    }
388
389    #[test]
390    fn test_simpsons_weights_2d() {
391        let argvals_s = vec![0.0, 0.5, 1.0];
392        let argvals_t = vec![0.0, 0.5, 1.0];
393        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
394        let sum: f64 = weights.iter().sum();
395        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
396    }
397
398    #[test]
399    fn test_extract_curves() {
400        // Column-major data: 2 observations, 3 points
401        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
402        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
403        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
404        let curves = extract_curves(&mat);
405        assert_eq!(curves.len(), 2);
406        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
407        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
408    }
409
410    #[test]
411    fn test_l2_distance_identical() {
412        let curve = vec![1.0, 2.0, 3.0];
413        let weights = vec![0.25, 0.5, 0.25];
414        let dist = l2_distance(&curve, &curve, &weights);
415        assert!(dist.abs() < NUMERICAL_EPS);
416    }
417
418    #[test]
419    fn test_l2_distance_different() {
420        let curve1 = vec![0.0, 0.0, 0.0];
421        let curve2 = vec![1.0, 1.0, 1.0];
422        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
423        let dist = l2_distance(&curve1, &curve2, &weights);
424        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
425        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
426    }
427
428    #[test]
429    fn test_n1_weights() {
430        // Single point: fallback weight is 1.0 (degenerate case)
431        let w = simpsons_weights(&[0.5]);
432        assert_eq!(w.len(), 1);
433        assert!((w[0] - 1.0).abs() < 1e-12);
434    }
435
436    #[test]
437    fn test_n2_weights() {
438        let w = simpsons_weights(&[0.0, 1.0]);
439        assert_eq!(w.len(), 2);
440        // Trapezoidal: each weight should be 0.5
441        assert!((w[0] - 0.5).abs() < 1e-12);
442        assert!((w[1] - 0.5).abs() < 1e-12);
443    }
444
445    #[test]
446    fn test_mismatched_l2_distance() {
447        // Mismatched lengths should not panic but may give garbage
448        let a = vec![1.0, 2.0, 3.0];
449        let b = vec![1.0, 2.0, 3.0];
450        let w = vec![0.5, 0.5, 0.5];
451        let d = l2_distance(&a, &b, &w);
452        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
453    }
454
455    // ── trapz ──
456
457    #[test]
458    fn test_trapz_sine() {
459        // ∫₀^π sin(x) dx = 2
460        let m = 1000;
461        let x: Vec<f64> = (0..m)
462            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
463            .collect();
464        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
465        let result = trapz(&y, &x);
466        assert!(
467            (result - 2.0).abs() < 1e-4,
468            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
469        );
470    }
471
472    // ── cumulative_trapz ──
473
474    #[test]
475    fn test_cumulative_trapz_matches_final() {
476        let m = 100;
477        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
478        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
479        let cum = cumulative_trapz(&y, &x);
480        let total = trapz(&y, &x);
481        assert!(
482            (cum[m - 1] - total).abs() < 1e-12,
483            "Final cumulative value should match trapz"
484        );
485    }
486
487    // ── linear_interp ──
488
489    #[test]
490    fn test_linear_interp_boundary_clamp() {
491        let x = vec![0.0, 0.5, 1.0];
492        let y = vec![10.0, 20.0, 30.0];
493        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
494        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
495        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
496    }
497
498    // ── gradient_uniform ──
499
500    #[test]
501    fn test_gradient_uniform_linear() {
502        // f(x) = 3x → f'(x) = 3 everywhere
503        let m = 50;
504        let h = 1.0 / (m - 1) as f64;
505        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
506        let g = gradient_uniform(&y, h);
507        for i in 0..m {
508            assert!(
509                (g[i] - 3.0).abs() < 1e-10,
510                "gradient of 3x should be 3 at i={i}, got {}",
511                g[i]
512            );
513        }
514    }
515}