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/// Gaussian kernel: K(d, h) = exp(-d² / (2h²)).
243///
244/// This is the un-normalized version used by Nadaraya-Watson regression
245/// and kernel classification. For density estimation with normalization,
246/// see the smoothing module.
247pub fn gaussian_kernel(d: f64, h: f64) -> f64 {
248    if h < 1e-15 {
249        return 0.0;
250    }
251    (-d * d / (2.0 * h * h)).exp()
252}
253
254/// Extract bandwidth candidates from a flat n×n distance matrix.
255///
256/// Collects the upper-triangle nonzero distances, sorts them, and returns
257/// `n_quantiles` evenly-spaced quantile values. Used for LOO-CV bandwidth
258/// grid search in kernel regression and classification.
259pub fn bandwidth_candidates_from_dists(dists: &[f64], n: usize, n_quantiles: usize) -> Vec<f64> {
260    let mut nonzero: Vec<f64> = (0..n)
261        .flat_map(|i| ((i + 1)..n).map(move |j| dists[i * n + j]))
262        .filter(|&d| d > 0.0)
263        .collect();
264    sort_nan_safe(&mut nonzero);
265
266    if nonzero.is_empty() {
267        return Vec::new();
268    }
269
270    (1..=n_quantiles)
271        .map(|q| {
272            let p = q as f64 / (n_quantiles + 1) as f64;
273            let idx = ((nonzero.len() as f64 * p) as usize).min(nonzero.len() - 1);
274            nonzero[idx]
275        })
276        .filter(|&h| h > 1e-15)
277        .collect()
278}
279
280/// Compute a quantile from a sorted slice.
281///
282/// `p` should be in [0, 1]. Uses linear interpolation between adjacent values.
283pub fn quantile_sorted(sorted: &[f64], p: f64) -> f64 {
284    if sorted.is_empty() {
285        return f64::NAN;
286    }
287    if sorted.len() == 1 || p <= 0.0 {
288        return sorted[0];
289    }
290    if p >= 1.0 {
291        return sorted[sorted.len() - 1];
292    }
293    let pos = p * (sorted.len() - 1) as f64;
294    let lo = pos.floor() as usize;
295    let hi = (lo + 1).min(sorted.len() - 1);
296    let frac = pos - lo as f64;
297    sorted[lo] * (1.0 - frac) + sorted[hi] * frac
298}
299
300/// Compute R² (coefficient of determination).
301pub fn r_squared(y_true: &[f64], residuals: &[f64]) -> f64 {
302    let n = y_true.len();
303    if n == 0 {
304        return f64::NAN;
305    }
306    let mean = y_true.iter().sum::<f64>() / n as f64;
307    let ss_tot: f64 = y_true.iter().map(|&y| (y - mean).powi(2)).sum();
308    let ss_res: f64 = residuals.iter().map(|r| r * r).sum();
309    if ss_tot > 1e-15 {
310        1.0 - ss_res / ss_tot
311    } else {
312        0.0
313    }
314}
315
316/// Compute adjusted R².
317pub fn r_squared_adj(y_true: &[f64], residuals: &[f64], p: usize) -> f64 {
318    let n = y_true.len();
319    let r2 = r_squared(y_true, residuals);
320    if n <= p + 1 {
321        return r2;
322    }
323    1.0 - (1.0 - r2) * (n - 1) as f64 / (n - p - 1) as f64
324}
325
326/// Compute AIC from residual sum of squares.
327///
328/// AIC = n * ln(RSS/n) + 2p
329pub fn aic(n: usize, rss: f64, p: usize) -> f64 {
330    let nf = n as f64;
331    nf * (rss / nf).ln() + 2.0 * p as f64
332}
333
334/// Compute BIC from residual sum of squares.
335///
336/// BIC = n * ln(RSS/n) + ln(n) * p
337pub fn bic(n: usize, rss: f64, p: usize) -> f64 {
338    let nf = n as f64;
339    nf * (rss / nf).ln() + nf.ln() * p as f64
340}
341
342/// Interpolation method for resampling functional data.
343#[derive(Debug, Clone, Copy, PartialEq)]
344#[non_exhaustive]
345pub enum InterpolationMethod {
346    /// Linear interpolation between adjacent points.
347    Linear,
348    /// Cubic Hermite interpolation (monotone, C1 continuous).
349    CubicHermite,
350}
351
352/// Interpolate functional data to a new grid.
353///
354/// Resamples each curve from `data` evaluated at `argvals` to the new
355/// evaluation points `new_argvals`.
356///
357/// # Arguments
358/// * `data` - Functional data matrix (n x m)
359/// * `argvals` - Original evaluation points (length m, must be sorted)
360/// * `new_argvals` - New evaluation points (length m_new, must be sorted, within original domain)
361/// * `method` - Interpolation method
362///
363/// # Returns
364/// Interpolated matrix (n x m_new)
365#[must_use]
366pub fn fdata_interpolate(
367    data: &crate::matrix::FdMatrix,
368    argvals: &[f64],
369    new_argvals: &[f64],
370    method: InterpolationMethod,
371) -> crate::matrix::FdMatrix {
372    let (n, m) = data.shape();
373    let m_new = new_argvals.len();
374    if n == 0 || m < 2 || m_new == 0 {
375        return crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1));
376    }
377
378    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
379
380    for i in 0..n {
381        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
382        for (j, &t) in new_argvals.iter().enumerate() {
383            result[(i, j)] = match method {
384                InterpolationMethod::Linear => linear_interp(argvals, &y, t),
385                InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
386            };
387        }
388    }
389
390    result
391}
392
393/// Cubic Hermite interpolation at a single point.
394///
395/// Uses Fritsch-Carlson monotone slopes for C1 interpolation.
396fn cubic_hermite_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
397    let n = x.len();
398    if n < 2 {
399        return if n == 1 { y[0] } else { 0.0 };
400    }
401
402    // Clamp to domain
403    if t <= x[0] {
404        return y[0];
405    }
406    if t >= x[n - 1] {
407        return y[n - 1];
408    }
409
410    // Find interval via binary search
411    let k = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
412        Ok(i) => return y[i],
413        Err(i) => {
414            if i == 0 {
415                0
416            } else {
417                i - 1
418            }
419        }
420    };
421
422    // Compute slopes (Fritsch-Carlson)
423    let slopes: Vec<f64> = x
424        .windows(2)
425        .zip(y.windows(2))
426        .map(|(xw, yw)| (yw[1] - yw[0]) / (xw[1] - xw[0]))
427        .collect();
428
429    // Tangents at each point
430    let mut tangents = vec![0.0; n];
431    tangents[0] = slopes[0];
432    tangents[n - 1] = slopes[n - 2];
433    for i in 1..n - 1 {
434        if slopes[i - 1].signum() != slopes[i].signum() {
435            tangents[i] = 0.0;
436        } else {
437            tangents[i] = (slopes[i - 1] + slopes[i]) / 2.0;
438        }
439    }
440
441    // Hermite basis
442    let h = x[k + 1] - x[k];
443    let s = (t - x[k]) / h;
444    let s2 = s * s;
445    let s3 = s2 * s;
446
447    let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
448    let h10 = s3 - 2.0 * s2 + s;
449    let h01 = -2.0 * s3 + 3.0 * s2;
450    let h11 = s3 - s2;
451
452    h00 * y[k] + h10 * h * tangents[k] + h01 * y[k + 1] + h11 * h * tangents[k + 1]
453}
454
455/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
456///
457/// Interior points use the 5-point central difference:
458///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
459///
460/// Near-boundary points use appropriate forward/backward formulas.
461pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
462    let n = y.len();
463    let mut g = vec![0.0; n];
464    if n < 2 {
465        return g;
466    }
467    if n == 2 {
468        g[0] = (y[1] - y[0]) / h;
469        g[1] = (y[1] - y[0]) / h;
470        return g;
471    }
472    if n == 3 {
473        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
474        g[1] = (y[2] - y[0]) / (2.0 * h);
475        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
476        return g;
477    }
478    if n == 4 {
479        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
480        g[1] = (y[2] - y[0]) / (2.0 * h);
481        g[2] = (y[3] - y[1]) / (2.0 * h);
482        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
483        return g;
484    }
485
486    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
487    // Left boundary: O(h³) forward formula
488    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);
489    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
490
491    // Interior: 5-point central difference O(h⁴)
492    for i in 2..n - 2 {
493        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
494    }
495
496    // Right boundary: O(h³) backward formula
497    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])
498        / (12.0 * h);
499    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
500        + 25.0 * y[n - 1])
501        / (12.0 * h);
502    g
503}
504
505/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
506///
507/// At interior points uses the three-point formula:
508///   `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))`
509/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
510///
511/// Boundary points use forward/backward 3-point formulas.
512pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
513    let n = y.len();
514    assert_eq!(n, t.len(), "y and t must have the same length");
515    let mut g = vec![0.0; n];
516    if n < 2 {
517        return g;
518    }
519    if n == 2 {
520        let h = t[1] - t[0];
521        if h.abs() < 1e-15 {
522            return g;
523        }
524        g[0] = (y[1] - y[0]) / h;
525        g[1] = g[0];
526        return g;
527    }
528
529    // Left boundary: 3-point forward Lagrange derivative
530    let h0 = t[1] - t[0];
531    let h1 = t[2] - t[0];
532    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
533        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
534            - y[2] * h0 / (h1 * (h1 - h0));
535    } else {
536        g[0] = (y[1] - y[0]) / h0.max(1e-15);
537    }
538
539    // Interior: 3-point Lagrange central formula
540    for i in 1..n - 1 {
541        let h_l = t[i] - t[i - 1];
542        let h_r = t[i + 1] - t[i];
543        let h_sum = h_l + h_r;
544        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
545            g[i] = 0.0;
546            continue;
547        }
548        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
549            + y[i] * (h_r - h_l) / (h_l * h_r)
550            + y[i + 1] * h_l / (h_r * h_sum);
551    }
552
553    // Right boundary: 3-point backward Lagrange derivative
554    let h_last = t[n - 1] - t[n - 2];
555    let h_prev = t[n - 1] - t[n - 3];
556    let h_mid = t[n - 2] - t[n - 3];
557    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
558        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
559            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
560    } else {
561        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
562    }
563
564    g
565}
566
567/// Numerical gradient that auto-detects uniform vs non-uniform grids.
568///
569/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
570/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
571/// [`gradient_nonuniform`].
572pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
573    let n = t.len();
574    if n < 2 {
575        return vec![0.0; y.len()];
576    }
577
578    let h0 = t[1] - t[0];
579    let is_uniform = t
580        .windows(2)
581        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
582
583    if is_uniform {
584        gradient_uniform(y, h0)
585    } else {
586        gradient_nonuniform(y, t)
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_simpsons_weights_uniform() {
596        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
597        let weights = simpsons_weights(&argvals);
598        let sum: f64 = weights.iter().sum();
599        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
600    }
601
602    #[test]
603    fn test_simpsons_weights_2d() {
604        let argvals_s = vec![0.0, 0.5, 1.0];
605        let argvals_t = vec![0.0, 0.5, 1.0];
606        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
607        let sum: f64 = weights.iter().sum();
608        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
609    }
610
611    #[test]
612    fn test_extract_curves() {
613        // Column-major data: 2 observations, 3 points
614        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
615        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
616        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
617        let curves = extract_curves(&mat);
618        assert_eq!(curves.len(), 2);
619        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
620        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
621    }
622
623    #[test]
624    fn test_l2_distance_identical() {
625        let curve = vec![1.0, 2.0, 3.0];
626        let weights = vec![0.25, 0.5, 0.25];
627        let dist = l2_distance(&curve, &curve, &weights);
628        assert!(dist.abs() < NUMERICAL_EPS);
629    }
630
631    #[test]
632    fn test_l2_distance_different() {
633        let curve1 = vec![0.0, 0.0, 0.0];
634        let curve2 = vec![1.0, 1.0, 1.0];
635        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
636        let dist = l2_distance(&curve1, &curve2, &weights);
637        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
638        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
639    }
640
641    #[test]
642    fn test_n1_weights() {
643        // Single point: fallback weight is 1.0 (degenerate case)
644        let w = simpsons_weights(&[0.5]);
645        assert_eq!(w.len(), 1);
646        assert!((w[0] - 1.0).abs() < 1e-12);
647    }
648
649    #[test]
650    fn test_n2_weights() {
651        let w = simpsons_weights(&[0.0, 1.0]);
652        assert_eq!(w.len(), 2);
653        // Trapezoidal: each weight should be 0.5
654        assert!((w[0] - 0.5).abs() < 1e-12);
655        assert!((w[1] - 0.5).abs() < 1e-12);
656    }
657
658    #[test]
659    fn test_mismatched_l2_distance() {
660        // Mismatched lengths should not panic but may give garbage
661        let a = vec![1.0, 2.0, 3.0];
662        let b = vec![1.0, 2.0, 3.0];
663        let w = vec![0.5, 0.5, 0.5];
664        let d = l2_distance(&a, &b, &w);
665        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
666    }
667
668    // ── trapz ──
669
670    #[test]
671    fn test_trapz_sine() {
672        // ∫₀^π sin(x) dx = 2
673        let m = 1000;
674        let x: Vec<f64> = (0..m)
675            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
676            .collect();
677        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
678        let result = trapz(&y, &x);
679        assert!(
680            (result - 2.0).abs() < 1e-4,
681            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
682        );
683    }
684
685    // ── cumulative_trapz ──
686
687    #[test]
688    fn test_cumulative_trapz_matches_final() {
689        let m = 100;
690        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
691        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
692        let cum = cumulative_trapz(&y, &x);
693        let total = trapz(&y, &x);
694        assert!(
695            (cum[m - 1] - total).abs() < 1e-12,
696            "Final cumulative value should match trapz"
697        );
698    }
699
700    // ── linear_interp ──
701
702    #[test]
703    fn test_linear_interp_boundary_clamp() {
704        let x = vec![0.0, 0.5, 1.0];
705        let y = vec![10.0, 20.0, 30.0];
706        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
707        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
708        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
709    }
710
711    // ── gradient_uniform ──
712
713    #[test]
714    fn test_gradient_uniform_linear() {
715        // f(x) = 3x → f'(x) = 3 everywhere
716        let m = 50;
717        let h = 1.0 / (m - 1) as f64;
718        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
719        let g = gradient_uniform(&y, h);
720        for i in 0..m {
721            assert!(
722                (g[i] - 3.0).abs() < 1e-10,
723                "gradient of 3x should be 3 at i={i}, got {}",
724                g[i]
725            );
726        }
727    }
728
729    // ── fdata_interpolate ──
730
731    #[test]
732    fn test_gaussian_kernel() {
733        assert!((gaussian_kernel(0.0, 1.0) - 1.0).abs() < 1e-12);
734        assert!(gaussian_kernel(3.0, 1.0) < 0.02); // far from center
735        assert!((gaussian_kernel(1.0, 0.0)).abs() < 1e-12); // zero bandwidth
736    }
737
738    #[test]
739    fn test_bandwidth_candidates() {
740        let n = 5;
741        let mut dists = vec![0.0; n * n];
742        for i in 0..n {
743            for j in 0..n {
744                dists[i * n + j] = (i as f64 - j as f64).abs();
745            }
746        }
747        let cands = bandwidth_candidates_from_dists(&dists, n, 10);
748        assert!(!cands.is_empty());
749        assert!(cands.iter().all(|&h| h > 0.0));
750        // Should be sorted
751        for w in cands.windows(2) {
752            assert!(w[1] >= w[0]);
753        }
754    }
755
756    #[test]
757    fn test_quantile_sorted() {
758        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
759        assert!((quantile_sorted(&data, 0.0) - 1.0).abs() < 1e-12);
760        assert!((quantile_sorted(&data, 1.0) - 5.0).abs() < 1e-12);
761        assert!((quantile_sorted(&data, 0.5) - 3.0).abs() < 1e-12);
762        assert!((quantile_sorted(&data, 0.25) - 2.0).abs() < 1e-12);
763    }
764
765    #[test]
766    fn test_r_squared_perfect() {
767        let y = vec![1.0, 2.0, 3.0, 4.0];
768        let resid = vec![0.0, 0.0, 0.0, 0.0];
769        assert!((r_squared(&y, &resid) - 1.0).abs() < 1e-12);
770    }
771
772    #[test]
773    fn test_r_squared_mean_model() {
774        let y = vec![1.0, 2.0, 3.0, 4.0];
775        let mean = 2.5;
776        let resid: Vec<f64> = y.iter().map(|&yi| yi - mean).collect();
777        assert!(r_squared(&y, &resid).abs() < 1e-12); // R²=0 for mean model
778    }
779
780    #[test]
781    fn test_aic_bic() {
782        let a = aic(100, 50.0, 5);
783        let b = bic(100, 50.0, 5);
784        assert!(a.is_finite());
785        assert!(b.is_finite());
786        assert!(b > a); // BIC penalizes more for n > ~8
787    }
788
789    #[test]
790    fn fdata_interpolate_linear_identity() {
791        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
792        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
793        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
794        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
795        for j in 0..20 {
796            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
797        }
798    }
799
800    #[test]
801    fn fdata_interpolate_cubic_hermite_smooth() {
802        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
803        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
804        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
805
806        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
807        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
808
809        // Values should approximate sin(t) well
810        for (j, &tj) in t_fine.iter().enumerate() {
811            assert!(
812                (result[(0, j)] - tj.sin()).abs() < 0.02,
813                "at t={tj:.2}: got {:.4}, expected {:.4}",
814                result[(0, j)],
815                tj.sin()
816            );
817        }
818    }
819
820    #[test]
821    fn fdata_interpolate_multiple_curves() {
822        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
823        let n = 5;
824        let m = 30;
825        // Build column-major data: n curves, each sin((i+1)*x)
826        let mut col_major = vec![0.0; n * m];
827        for i in 0..n {
828            for j in 0..m {
829                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
830            }
831        }
832        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
833
834        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
835        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
836        assert_eq!(result.shape(), (n, 50));
837        // All values should be finite
838        for i in 0..n {
839            for j in 0..50 {
840                assert!(result[(i, j)].is_finite());
841            }
842        }
843    }
844}