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 rule integration weights for non-uniform grid.
42///
43/// Returns weights for trapezoidal rule integration.
44///
45/// # Arguments
46/// * `argvals` - Grid points (evaluation points)
47///
48/// # Returns
49/// Vector of integration weights
50pub fn simpsons_weights(argvals: &[f64]) -> Vec<f64> {
51    let n = argvals.len();
52    if n < 2 {
53        return vec![1.0; n];
54    }
55
56    let mut weights = vec![0.0; n];
57
58    if n == 2 {
59        // Trapezoidal rule
60        let h = argvals[1] - argvals[0];
61        weights[0] = h / 2.0;
62        weights[1] = h / 2.0;
63        return weights;
64    }
65
66    // For non-uniform spacing, use composite trapezoidal rule
67    for i in 0..n {
68        if i == 0 {
69            weights[i] = (argvals[1] - argvals[0]) / 2.0;
70        } else if i == n - 1 {
71            weights[i] = (argvals[n - 1] - argvals[n - 2]) / 2.0;
72        } else {
73            weights[i] = (argvals[i + 1] - argvals[i - 1]) / 2.0;
74        }
75    }
76
77    weights
78}
79
80/// Compute 2D integration weights using tensor product of 1D weights.
81///
82/// Returns a flattened vector of weights for an m1 x m2 grid.
83///
84/// # Arguments
85/// * `argvals_s` - Grid points in s direction
86/// * `argvals_t` - Grid points in t direction
87///
88/// # Returns
89/// Flattened vector of integration weights (row-major order)
90pub fn simpsons_weights_2d(argvals_s: &[f64], argvals_t: &[f64]) -> Vec<f64> {
91    let weights_s = simpsons_weights(argvals_s);
92    let weights_t = simpsons_weights(argvals_t);
93    let m1 = argvals_s.len();
94    let m2 = argvals_t.len();
95
96    let mut weights = vec![0.0; m1 * m2];
97    for i in 0..m1 {
98        for j in 0..m2 {
99            weights[i * m2 + j] = weights_s[i] * weights_t[j];
100        }
101    }
102    weights
103}
104
105/// Linear interpolation at point `t` using binary search.
106///
107/// Clamps to boundary values outside the domain of `x`.
108pub fn linear_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
109    if t <= x[0] {
110        return y[0];
111    }
112    let last = x.len() - 1;
113    if t >= x[last] {
114        return y[last];
115    }
116
117    let idx = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap()) {
118        Ok(i) => return y[i],
119        Err(i) => i,
120    };
121
122    let t0 = x[idx - 1];
123    let t1 = x[idx];
124    let y0 = y[idx - 1];
125    let y1 = y[idx];
126    y0 + (y1 - y0) * (t - t0) / (t1 - t0)
127}
128
129/// Cumulative trapezoidal integration.
130pub fn cumulative_trapz(y: &[f64], x: &[f64]) -> Vec<f64> {
131    let n = y.len();
132    let mut out = vec![0.0; n];
133    for k in 1..n {
134        out[k] = out[k - 1] + 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
135    }
136    out
137}
138
139/// Trapezoidal integration of `y` over `x`.
140pub fn trapz(y: &[f64], x: &[f64]) -> f64 {
141    let mut sum = 0.0;
142    for k in 1..y.len() {
143        sum += 0.5 * (y[k] + y[k - 1]) * (x[k] - x[k - 1]);
144    }
145    sum
146}
147
148/// Numerical gradient with uniform spacing (forward/central/backward differences).
149pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
150    let n = y.len();
151    let mut g = vec![0.0; n];
152    if n < 2 {
153        return g;
154    }
155    g[0] = (y[1] - y[0]) / h;
156    for i in 1..(n - 1) {
157        g[i] = (y[i + 1] - y[i - 1]) / (2.0 * h);
158    }
159    g[n - 1] = (y[n - 1] - y[n - 2]) / h;
160    g
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_simpsons_weights_uniform() {
169        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
170        let weights = simpsons_weights(&argvals);
171        let sum: f64 = weights.iter().sum();
172        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
173    }
174
175    #[test]
176    fn test_simpsons_weights_2d() {
177        let argvals_s = vec![0.0, 0.5, 1.0];
178        let argvals_t = vec![0.0, 0.5, 1.0];
179        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
180        let sum: f64 = weights.iter().sum();
181        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
182    }
183
184    #[test]
185    fn test_extract_curves() {
186        // Column-major data: 2 observations, 3 points
187        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
188        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
189        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
190        let curves = extract_curves(&mat);
191        assert_eq!(curves.len(), 2);
192        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
193        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
194    }
195
196    #[test]
197    fn test_l2_distance_identical() {
198        let curve = vec![1.0, 2.0, 3.0];
199        let weights = vec![0.25, 0.5, 0.25];
200        let dist = l2_distance(&curve, &curve, &weights);
201        assert!(dist.abs() < NUMERICAL_EPS);
202    }
203
204    #[test]
205    fn test_l2_distance_different() {
206        let curve1 = vec![0.0, 0.0, 0.0];
207        let curve2 = vec![1.0, 1.0, 1.0];
208        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
209        let dist = l2_distance(&curve1, &curve2, &weights);
210        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
211        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
212    }
213
214    #[test]
215    fn test_n1_weights() {
216        // Single point: fallback weight is 1.0 (degenerate case)
217        let w = simpsons_weights(&[0.5]);
218        assert_eq!(w.len(), 1);
219        assert!((w[0] - 1.0).abs() < 1e-12);
220    }
221
222    #[test]
223    fn test_n2_weights() {
224        let w = simpsons_weights(&[0.0, 1.0]);
225        assert_eq!(w.len(), 2);
226        // Trapezoidal: each weight should be 0.5
227        assert!((w[0] - 0.5).abs() < 1e-12);
228        assert!((w[1] - 0.5).abs() < 1e-12);
229    }
230
231    #[test]
232    fn test_mismatched_l2_distance() {
233        // Mismatched lengths should not panic but may give garbage
234        let a = vec![1.0, 2.0, 3.0];
235        let b = vec![1.0, 2.0, 3.0];
236        let w = vec![0.5, 0.5, 0.5];
237        let d = l2_distance(&a, &b, &w);
238        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
239    }
240
241    // ── trapz ──
242
243    #[test]
244    fn test_trapz_sine() {
245        // ∫₀^π sin(x) dx = 2
246        let m = 1000;
247        let x: Vec<f64> = (0..m)
248            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
249            .collect();
250        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
251        let result = trapz(&y, &x);
252        assert!(
253            (result - 2.0).abs() < 1e-4,
254            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
255        );
256    }
257
258    // ── cumulative_trapz ──
259
260    #[test]
261    fn test_cumulative_trapz_matches_final() {
262        let m = 100;
263        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
264        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
265        let cum = cumulative_trapz(&y, &x);
266        let total = trapz(&y, &x);
267        assert!(
268            (cum[m - 1] - total).abs() < 1e-12,
269            "Final cumulative value should match trapz"
270        );
271    }
272
273    // ── linear_interp ──
274
275    #[test]
276    fn test_linear_interp_boundary_clamp() {
277        let x = vec![0.0, 0.5, 1.0];
278        let y = vec![10.0, 20.0, 30.0];
279        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
280        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
281        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
282    }
283
284    // ── gradient_uniform ──
285
286    #[test]
287    fn test_gradient_uniform_linear() {
288        // f(x) = 3x → f'(x) = 3 everywhere
289        let m = 50;
290        let h = 1.0 / (m - 1) as f64;
291        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
292        let g = gradient_uniform(&y, h);
293        for i in 0..m {
294            assert!(
295                (g[i] - 3.0).abs() < 1e-10,
296                "gradient of 3x should be 3 at i={i}, got {}",
297                g[i]
298            );
299        }
300    }
301}