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/// Extract curves from column-major data matrix.
10///
11/// Converts a flat column-major matrix into a vector of curve vectors,
12/// where each curve contains all evaluation points for one observation.
13///
14/// # Arguments
15/// * `data` - Functional data matrix (n x m)
16///
17/// # Returns
18/// Vector of n curves, each containing m values
19pub fn extract_curves(data: &crate::matrix::FdMatrix) -> Vec<Vec<f64>> {
20    data.rows()
21}
22
23/// Compute L2 distance between two curves using integration weights.
24///
25/// # Arguments
26/// * `curve1` - First curve values
27/// * `curve2` - Second curve values
28/// * `weights` - Integration weights
29///
30/// # Returns
31/// L2 distance between the curves
32pub fn l2_distance(curve1: &[f64], curve2: &[f64], weights: &[f64]) -> f64 {
33    let mut dist_sq = 0.0;
34    for i in 0..curve1.len() {
35        let diff = curve1[i] - curve2[i];
36        dist_sq += diff * diff * weights[i];
37    }
38    dist_sq.sqrt()
39}
40
41/// Compute Simpson's 1/3 rule integration weights for a grid.
42///
43/// For odd n (even number of intervals): standard composite Simpson's 1/3 rule.
44/// For even n: Simpson's 1/3 for first n-1 points, trapezoidal for last interval.
45/// For non-uniform grids: generalized Simpson's weights per sub-interval pair.
46///
47/// # Arguments
48/// * `argvals` - Grid points (evaluation points)
49///
50/// # Returns
51/// Vector of integration weights
52pub fn simpsons_weights(argvals: &[f64]) -> Vec<f64> {
53    let n = argvals.len();
54    if n < 2 {
55        return vec![1.0; n];
56    }
57
58    let mut weights = vec![0.0; n];
59
60    if n == 2 {
61        // Trapezoidal rule
62        let h = argvals[1] - argvals[0];
63        weights[0] = h / 2.0;
64        weights[1] = h / 2.0;
65        return weights;
66    }
67
68    // Check if grid is uniform
69    let h0 = argvals[1] - argvals[0];
70    let is_uniform = argvals
71        .windows(2)
72        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs());
73
74    if is_uniform {
75        simpsons_weights_uniform(&mut weights, n, h0);
76    } else {
77        simpsons_weights_nonuniform(&mut weights, argvals, n);
78    }
79
80    weights
81}
82
83/// Uniform grid Simpson's 1/3 weights.
84fn simpsons_weights_uniform(weights: &mut [f64], n: usize, h0: f64) {
85    let n_intervals = n - 1;
86    if n_intervals % 2 == 0 {
87        // Even number of intervals (odd n): pure Simpson's
88        weights[0] = h0 / 3.0;
89        weights[n - 1] = h0 / 3.0;
90        for i in 1..n - 1 {
91            weights[i] = if i % 2 == 1 {
92                4.0 * h0 / 3.0
93            } else {
94                2.0 * h0 / 3.0
95            };
96        }
97    } else {
98        // Odd number of intervals (even n): Simpson's + trapezoidal for last
99        let n_simp = n - 1;
100        weights[0] = h0 / 3.0;
101        weights[n_simp - 1] = h0 / 3.0;
102        for i in 1..n_simp - 1 {
103            weights[i] = if i % 2 == 1 {
104                4.0 * h0 / 3.0
105            } else {
106                2.0 * h0 / 3.0
107            };
108        }
109        weights[n_simp - 1] += h0 / 2.0;
110        weights[n - 1] += h0 / 2.0;
111    }
112}
113
114/// Non-uniform grid generalized Simpson's weights.
115fn simpsons_weights_nonuniform(weights: &mut [f64], argvals: &[f64], n: usize) {
116    let n_intervals = n - 1;
117    let n_pairs = n_intervals / 2;
118
119    for k in 0..n_pairs {
120        let i0 = 2 * k;
121        let i1 = i0 + 1;
122        let i2 = i0 + 2;
123        let h1 = argvals[i1] - argvals[i0];
124        let h2 = argvals[i2] - argvals[i1];
125        let h_sum = h1 + h2;
126
127        weights[i0] += (2.0 * h1 - h2) * h_sum / (6.0 * h1);
128        weights[i1] += h_sum * h_sum * h_sum / (6.0 * h1 * h2);
129        weights[i2] += (2.0 * h2 - h1) * h_sum / (6.0 * h2);
130    }
131
132    if n_intervals % 2 == 1 {
133        let h_last = argvals[n - 1] - argvals[n - 2];
134        weights[n - 2] += h_last / 2.0;
135        weights[n - 1] += h_last / 2.0;
136    }
137}
138
139/// Compute 2D integration weights using tensor product of 1D weights.
140///
141/// Returns a flattened vector of weights for an m1 x m2 grid.
142///
143/// # Arguments
144/// * `argvals_s` - Grid points in s direction
145/// * `argvals_t` - Grid points in t direction
146///
147/// # Returns
148/// Flattened vector of integration weights (column-major: s-varies-fastest, matching FdMatrix surface layout)
149pub fn simpsons_weights_2d(argvals_s: &[f64], argvals_t: &[f64]) -> Vec<f64> {
150    let weights_s = simpsons_weights(argvals_s);
151    let weights_t = simpsons_weights(argvals_t);
152    let m1 = argvals_s.len();
153    let m2 = argvals_t.len();
154
155    let mut weights = vec![0.0; m1 * m2];
156    for i in 0..m1 {
157        for j in 0..m2 {
158            weights[i + j * m1] = weights_s[i] * weights_t[j];
159        }
160    }
161    weights
162}
163
164/// Linear interpolation at point `t` using binary search.
165///
166/// Clamps to boundary values outside the domain of `x`.
167pub fn linear_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
168    if t <= x[0] {
169        return y[0];
170    }
171    let last = x.len() - 1;
172    if t >= x[last] {
173        return y[last];
174    }
175
176    let idx = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap()) {
177        Ok(i) => return y[i],
178        Err(i) => i,
179    };
180
181    let t0 = x[idx - 1];
182    let t1 = x[idx];
183    let y0 = y[idx - 1];
184    let y1 = y[idx];
185    y0 + (y1 - y0) * (t - t0) / (t1 - t0)
186}
187
188/// Cumulative integration using Simpson's rule where possible.
189///
190/// For pairs of intervals uses Simpson's 1/3 rule for higher accuracy.
191/// Falls back to trapezoidal for the last interval if n is even.
192pub fn cumulative_trapz(y: &[f64], x: &[f64]) -> Vec<f64> {
193    let n = y.len();
194    let mut out = vec![0.0; n];
195    if n < 2 {
196        return out;
197    }
198
199    // Process pairs of intervals with Simpson's rule
200    let mut k = 1;
201    while k + 1 < n {
202        let h1 = x[k] - x[k - 1];
203        let h2 = x[k + 1] - x[k];
204        let h_sum = h1 + h2;
205
206        // Generalized Simpson's for this pair of intervals
207        let integral = h_sum / 6.0
208            * (y[k - 1] * (2.0 * h1 - h2) / h1
209                + y[k] * h_sum * h_sum / (h1 * h2)
210                + y[k + 1] * (2.0 * h2 - h1) / h2);
211
212        out[k] = out[k - 1] + {
213            // First sub-interval: use trapezoidal for the intermediate value
214            0.5 * (y[k] + y[k - 1]) * h1
215        };
216        out[k + 1] = out[k - 1] + integral;
217        k += 2;
218    }
219
220    // If there's a remaining interval, use trapezoidal
221    if k < n {
222        out[k] = out[k - 1] + 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
223    }
224
225    out
226}
227
228/// Trapezoidal integration of `y` over `x`.
229pub fn trapz(y: &[f64], x: &[f64]) -> f64 {
230    let mut sum = 0.0;
231    for k in 1..y.len() {
232        sum += 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
233    }
234    sum
235}
236
237/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
238///
239/// Interior points use the 5-point central difference:
240///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
241///
242/// Near-boundary points use appropriate forward/backward formulas.
243pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
244    let n = y.len();
245    let mut g = vec![0.0; n];
246    if n < 2 {
247        return g;
248    }
249    if n == 2 {
250        g[0] = (y[1] - y[0]) / h;
251        g[1] = (y[1] - y[0]) / h;
252        return g;
253    }
254    if n == 3 {
255        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
256        g[1] = (y[2] - y[0]) / (2.0 * h);
257        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
258        return g;
259    }
260    if n == 4 {
261        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
262        g[1] = (y[2] - y[0]) / (2.0 * h);
263        g[2] = (y[3] - y[1]) / (2.0 * h);
264        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
265        return g;
266    }
267
268    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
269    // Left boundary: O(h³) forward formula
270    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);
271    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
272
273    // Interior: 5-point central difference O(h⁴)
274    for i in 2..n - 2 {
275        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
276    }
277
278    // Right boundary: O(h³) backward formula
279    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])
280        / (12.0 * h);
281    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
282        + 25.0 * y[n - 1])
283        / (12.0 * h);
284    g
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_simpsons_weights_uniform() {
293        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
294        let weights = simpsons_weights(&argvals);
295        let sum: f64 = weights.iter().sum();
296        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
297    }
298
299    #[test]
300    fn test_simpsons_weights_2d() {
301        let argvals_s = vec![0.0, 0.5, 1.0];
302        let argvals_t = vec![0.0, 0.5, 1.0];
303        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
304        let sum: f64 = weights.iter().sum();
305        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
306    }
307
308    #[test]
309    fn test_extract_curves() {
310        // Column-major data: 2 observations, 3 points
311        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
312        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
313        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
314        let curves = extract_curves(&mat);
315        assert_eq!(curves.len(), 2);
316        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
317        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
318    }
319
320    #[test]
321    fn test_l2_distance_identical() {
322        let curve = vec![1.0, 2.0, 3.0];
323        let weights = vec![0.25, 0.5, 0.25];
324        let dist = l2_distance(&curve, &curve, &weights);
325        assert!(dist.abs() < NUMERICAL_EPS);
326    }
327
328    #[test]
329    fn test_l2_distance_different() {
330        let curve1 = vec![0.0, 0.0, 0.0];
331        let curve2 = vec![1.0, 1.0, 1.0];
332        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
333        let dist = l2_distance(&curve1, &curve2, &weights);
334        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
335        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
336    }
337
338    #[test]
339    fn test_n1_weights() {
340        // Single point: fallback weight is 1.0 (degenerate case)
341        let w = simpsons_weights(&[0.5]);
342        assert_eq!(w.len(), 1);
343        assert!((w[0] - 1.0).abs() < 1e-12);
344    }
345
346    #[test]
347    fn test_n2_weights() {
348        let w = simpsons_weights(&[0.0, 1.0]);
349        assert_eq!(w.len(), 2);
350        // Trapezoidal: each weight should be 0.5
351        assert!((w[0] - 0.5).abs() < 1e-12);
352        assert!((w[1] - 0.5).abs() < 1e-12);
353    }
354
355    #[test]
356    fn test_mismatched_l2_distance() {
357        // Mismatched lengths should not panic but may give garbage
358        let a = vec![1.0, 2.0, 3.0];
359        let b = vec![1.0, 2.0, 3.0];
360        let w = vec![0.5, 0.5, 0.5];
361        let d = l2_distance(&a, &b, &w);
362        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
363    }
364
365    // ── trapz ──
366
367    #[test]
368    fn test_trapz_sine() {
369        // ∫₀^π sin(x) dx = 2
370        let m = 1000;
371        let x: Vec<f64> = (0..m)
372            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
373            .collect();
374        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
375        let result = trapz(&y, &x);
376        assert!(
377            (result - 2.0).abs() < 1e-4,
378            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
379        );
380    }
381
382    // ── cumulative_trapz ──
383
384    #[test]
385    fn test_cumulative_trapz_matches_final() {
386        let m = 100;
387        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
388        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
389        let cum = cumulative_trapz(&y, &x);
390        let total = trapz(&y, &x);
391        assert!(
392            (cum[m - 1] - total).abs() < 1e-12,
393            "Final cumulative value should match trapz"
394        );
395    }
396
397    // ── linear_interp ──
398
399    #[test]
400    fn test_linear_interp_boundary_clamp() {
401        let x = vec![0.0, 0.5, 1.0];
402        let y = vec![10.0, 20.0, 30.0];
403        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
404        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
405        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
406    }
407
408    // ── gradient_uniform ──
409
410    #[test]
411    fn test_gradient_uniform_linear() {
412        // f(x) = 3x → f'(x) = 3 everywhere
413        let m = 50;
414        let h = 1.0 / (m - 1) as f64;
415        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
416        let g = gradient_uniform(&y, h);
417        for i in 0..m {
418            assert!(
419                (g[i] - 3.0).abs() < 1e-10,
420                "gradient of 3x should be 3 at i={i}, got {}",
421                g[i]
422            );
423        }
424    }
425}