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/// Fit an order-k B-spline interpolant per curve and evaluate at arbitrary query points.
394///
395/// For each curve in `data` (sampled at `argvals`), fits a B-spline of the given `order`
396/// using least-squares via SVD pseudoinverse, then evaluates at `query_points` using the
397/// same knot vector. Returns a new `FdMatrix` with shape `(n, query_points.len())`.
398///
399/// Uses the fit-then-evaluate pattern from the existing B-spline basis system
400/// (`basis::bspline`), without P-spline smoothing — this is interpolation, not smoothing.
401///
402/// # Arguments
403/// * `data`         — Functional data matrix (`n × m`)
404/// * `argvals`      — Original evaluation points (`length m`, must be sorted)
405/// * `query_points` — Points to evaluate at (must lie within `[argvals[0], argvals[m-1]]`)
406/// * `order`        — B-spline order (1 = linear, 2 = quadratic, 4 = cubic, …); must be in `[1, m)`
407///
408/// # Returns
409/// Interpolated `FdMatrix` of shape `(n, query_points.len())`
410///
411/// # Errors
412/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()` or `query_points` is empty
413/// * `FdarError::InvalidParameter` — `order` is 0 or ≥ `m`, or any query point is outside
414///   `[argvals[0], argvals[m-1]]`
415/// * `FdarError::ComputationFailed` — SVD pseudoinverse could not be computed
416pub fn spline_interpolate(
417    data: &crate::matrix::FdMatrix,
418    argvals: &[f64],
419    query_points: &[f64],
420    order: usize,
421) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
422    let (n, m) = data.shape();
423
424    // --- Input validation ---
425    if argvals.len() != m {
426        return Err(crate::FdarError::InvalidDimension {
427            parameter: "argvals",
428            expected: format!("{m}"),
429            actual: format!("{}", argvals.len()),
430        });
431    }
432    if query_points.is_empty() {
433        return Err(crate::FdarError::InvalidDimension {
434            parameter: "query_points",
435            expected: ">= 1".to_string(),
436            actual: "0".to_string(),
437        });
438    }
439    if order == 0 || order >= m {
440        return Err(crate::FdarError::InvalidParameter {
441            parameter: "order",
442            message: format!("must be in [1, {m}), got {order}"),
443        });
444    }
445    let t_min = argvals[0];
446    let t_max = argvals[m - 1];
447    for &q in query_points {
448        if q < t_min || q > t_max {
449            return Err(crate::FdarError::InvalidParameter {
450                parameter: "query_points",
451                message: format!(
452                    "all query points must lie in [{t_min}, {t_max}]; found {q} which is outside the interpolation domain"
453                ),
454            });
455        }
456    }
457
458    // --- Build knot vector and basis matrix on argvals ---
459    // nknots chosen so nbasis = nknots + order ≈ m (interpolating system)
460    let nknots = m.saturating_sub(order).max(2);
461    let knots = crate::basis::bspline::construct_bspline_knots(t_min, t_max, nknots, order);
462
463    // basis_vals: column-major, length m * nbasis; layout: basis[ti + k*m] = B_k(argvals[ti])
464    let basis_vals = crate::basis::bspline::bspline_basis(argvals, nknots, order);
465    let nbasis = basis_vals.len() / m;
466
467    // Form B (m × nbasis) — same layout as pspline.rs:86-87
468    let b_mat = nalgebra::DMatrix::from_column_slice(m, nbasis, &basis_vals);
469
470    // Compute pseudoinverse of B once via SVD and reuse across all n curves.
471    // Mirrors the pattern in basis/helpers.rs:svd_pseudoinverse (which is pub(super)).
472    let tol = NUMERICAL_EPS * b_mat.nrows().max(b_mat.ncols()) as f64;
473    let svd = nalgebra::SVD::new(b_mat.clone(), true, true);
474    let pinv = svd
475        .pseudo_inverse(tol)
476        .map_err(|e| crate::FdarError::ComputationFailed {
477            operation: "spline_interpolate SVD pseudoinverse",
478            detail: e.to_string(),
479        })?;
480    // pinv: nbasis × m
481
482    // --- Build query basis on same knots ---
483    // basis_query: column-major, length m_q * nbasis; layout: basis_query[j + k*m_q] = B_k(query[j])
484    let m_q = query_points.len();
485    let basis_query = crate::basis::bspline::bspline_basis_from_knots(query_points, &knots, order);
486
487    // --- Evaluate per curve ---
488    let mut out = crate::matrix::FdMatrix::zeros(n, m_q);
489    for i in 0..n {
490        // Gather curve i as a column vector (length m)
491        let y_vec: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
492        let y_col = nalgebra::DVector::from_vec(y_vec);
493
494        // Solve: coefs = pinv * y  (shape: nbasis × 1)
495        let coefs = &pinv * y_col;
496
497        // Evaluate: out[i, j] = sum_k coefs[k] * basis_query[j + k*m_q]
498        for j in 0..m_q {
499            let mut val = 0.0;
500            for k in 0..nbasis {
501                val += coefs[k] * basis_query[j + k * m_q];
502            }
503            out[(i, j)] = val;
504        }
505    }
506
507    Ok(out)
508}
509
510/// Cubic Hermite interpolation at a single point.
511///
512/// Uses Fritsch-Carlson monotone slopes for C1 interpolation.
513fn cubic_hermite_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
514    let n = x.len();
515    if n < 2 {
516        return if n == 1 { y[0] } else { 0.0 };
517    }
518
519    // Clamp to domain
520    if t <= x[0] {
521        return y[0];
522    }
523    if t >= x[n - 1] {
524        return y[n - 1];
525    }
526
527    // Find interval via binary search
528    let k = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
529        Ok(i) => return y[i],
530        Err(i) => {
531            if i == 0 {
532                0
533            } else {
534                i - 1
535            }
536        }
537    };
538
539    // Compute slopes (Fritsch-Carlson)
540    let slopes: Vec<f64> = x
541        .windows(2)
542        .zip(y.windows(2))
543        .map(|(xw, yw)| (yw[1] - yw[0]) / (xw[1] - xw[0]))
544        .collect();
545
546    // Tangents at each point
547    let mut tangents = vec![0.0; n];
548    tangents[0] = slopes[0];
549    tangents[n - 1] = slopes[n - 2];
550    for i in 1..n - 1 {
551        if slopes[i - 1].signum() != slopes[i].signum() {
552            tangents[i] = 0.0;
553        } else {
554            tangents[i] = (slopes[i - 1] + slopes[i]) / 2.0;
555        }
556    }
557
558    // Hermite basis
559    let h = x[k + 1] - x[k];
560    let s = (t - x[k]) / h;
561    let s2 = s * s;
562    let s3 = s2 * s;
563
564    let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
565    let h10 = s3 - 2.0 * s2 + s;
566    let h01 = -2.0 * s3 + 3.0 * s2;
567    let h11 = s3 - s2;
568
569    h00 * y[k] + h10 * h * tangents[k] + h01 * y[k + 1] + h11 * h * tangents[k + 1]
570}
571
572/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
573///
574/// Interior points use the 5-point central difference:
575///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
576///
577/// Near-boundary points use appropriate forward/backward formulas.
578pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
579    let n = y.len();
580    let mut g = vec![0.0; n];
581    if n < 2 {
582        return g;
583    }
584    if n == 2 {
585        g[0] = (y[1] - y[0]) / h;
586        g[1] = (y[1] - y[0]) / h;
587        return g;
588    }
589    if n == 3 {
590        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
591        g[1] = (y[2] - y[0]) / (2.0 * h);
592        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
593        return g;
594    }
595    if n == 4 {
596        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
597        g[1] = (y[2] - y[0]) / (2.0 * h);
598        g[2] = (y[3] - y[1]) / (2.0 * h);
599        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
600        return g;
601    }
602
603    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
604    // Left boundary: O(h³) forward formula
605    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);
606    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
607
608    // Interior: 5-point central difference O(h⁴)
609    for i in 2..n - 2 {
610        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
611    }
612
613    // Right boundary: O(h³) backward formula
614    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])
615        / (12.0 * h);
616    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
617        + 25.0 * y[n - 1])
618        / (12.0 * h);
619    g
620}
621
622/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
623///
624/// At interior points uses the three-point formula:
625///   `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))`
626/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
627///
628/// Boundary points use forward/backward 3-point formulas.
629pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
630    let n = y.len();
631    assert_eq!(n, t.len(), "y and t must have the same length");
632    let mut g = vec![0.0; n];
633    if n < 2 {
634        return g;
635    }
636    if n == 2 {
637        let h = t[1] - t[0];
638        if h.abs() < 1e-15 {
639            return g;
640        }
641        g[0] = (y[1] - y[0]) / h;
642        g[1] = g[0];
643        return g;
644    }
645
646    // Left boundary: 3-point forward Lagrange derivative
647    let h0 = t[1] - t[0];
648    let h1 = t[2] - t[0];
649    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
650        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
651            - y[2] * h0 / (h1 * (h1 - h0));
652    } else {
653        g[0] = (y[1] - y[0]) / h0.max(1e-15);
654    }
655
656    // Interior: 3-point Lagrange central formula
657    for i in 1..n - 1 {
658        let h_l = t[i] - t[i - 1];
659        let h_r = t[i + 1] - t[i];
660        let h_sum = h_l + h_r;
661        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
662            g[i] = 0.0;
663            continue;
664        }
665        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
666            + y[i] * (h_r - h_l) / (h_l * h_r)
667            + y[i + 1] * h_l / (h_r * h_sum);
668    }
669
670    // Right boundary: 3-point backward Lagrange derivative
671    let h_last = t[n - 1] - t[n - 2];
672    let h_prev = t[n - 1] - t[n - 3];
673    let h_mid = t[n - 2] - t[n - 3];
674    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
675        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
676            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
677    } else {
678        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
679    }
680
681    g
682}
683
684/// Numerical gradient that auto-detects uniform vs non-uniform grids.
685///
686/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
687/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
688/// [`gradient_nonuniform`].
689pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
690    let n = t.len();
691    if n < 2 {
692        return vec![0.0; y.len()];
693    }
694
695    let h0 = t[1] - t[0];
696    let is_uniform = t
697        .windows(2)
698        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
699
700    if is_uniform {
701        gradient_uniform(y, h0)
702    } else {
703        gradient_nonuniform(y, t)
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    #[test]
712    fn test_simpsons_weights_uniform() {
713        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
714        let weights = simpsons_weights(&argvals);
715        let sum: f64 = weights.iter().sum();
716        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
717    }
718
719    #[test]
720    fn test_simpsons_weights_2d() {
721        let argvals_s = vec![0.0, 0.5, 1.0];
722        let argvals_t = vec![0.0, 0.5, 1.0];
723        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
724        let sum: f64 = weights.iter().sum();
725        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
726    }
727
728    #[test]
729    fn test_extract_curves() {
730        // Column-major data: 2 observations, 3 points
731        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
732        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
733        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
734        let curves = extract_curves(&mat);
735        assert_eq!(curves.len(), 2);
736        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
737        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
738    }
739
740    #[test]
741    fn test_l2_distance_identical() {
742        let curve = vec![1.0, 2.0, 3.0];
743        let weights = vec![0.25, 0.5, 0.25];
744        let dist = l2_distance(&curve, &curve, &weights);
745        assert!(dist.abs() < NUMERICAL_EPS);
746    }
747
748    #[test]
749    fn test_l2_distance_different() {
750        let curve1 = vec![0.0, 0.0, 0.0];
751        let curve2 = vec![1.0, 1.0, 1.0];
752        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
753        let dist = l2_distance(&curve1, &curve2, &weights);
754        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
755        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
756    }
757
758    #[test]
759    fn test_n1_weights() {
760        // Single point: fallback weight is 1.0 (degenerate case)
761        let w = simpsons_weights(&[0.5]);
762        assert_eq!(w.len(), 1);
763        assert!((w[0] - 1.0).abs() < 1e-12);
764    }
765
766    #[test]
767    fn test_n2_weights() {
768        let w = simpsons_weights(&[0.0, 1.0]);
769        assert_eq!(w.len(), 2);
770        // Trapezoidal: each weight should be 0.5
771        assert!((w[0] - 0.5).abs() < 1e-12);
772        assert!((w[1] - 0.5).abs() < 1e-12);
773    }
774
775    #[test]
776    fn test_mismatched_l2_distance() {
777        // Mismatched lengths should not panic but may give garbage
778        let a = vec![1.0, 2.0, 3.0];
779        let b = vec![1.0, 2.0, 3.0];
780        let w = vec![0.5, 0.5, 0.5];
781        let d = l2_distance(&a, &b, &w);
782        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
783    }
784
785    // ── trapz ──
786
787    #[test]
788    fn test_trapz_sine() {
789        // ∫₀^π sin(x) dx = 2
790        let m = 1000;
791        let x: Vec<f64> = (0..m)
792            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
793            .collect();
794        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
795        let result = trapz(&y, &x);
796        assert!(
797            (result - 2.0).abs() < 1e-4,
798            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
799        );
800    }
801
802    // ── cumulative_trapz ──
803
804    #[test]
805    fn test_cumulative_trapz_matches_final() {
806        let m = 100;
807        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
808        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
809        let cum = cumulative_trapz(&y, &x);
810        let total = trapz(&y, &x);
811        assert!(
812            (cum[m - 1] - total).abs() < 1e-12,
813            "Final cumulative value should match trapz"
814        );
815    }
816
817    // ── linear_interp ──
818
819    #[test]
820    fn test_linear_interp_boundary_clamp() {
821        let x = vec![0.0, 0.5, 1.0];
822        let y = vec![10.0, 20.0, 30.0];
823        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
824        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
825        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
826    }
827
828    // ── gradient_uniform ──
829
830    #[test]
831    fn test_gradient_uniform_linear() {
832        // f(x) = 3x → f'(x) = 3 everywhere
833        let m = 50;
834        let h = 1.0 / (m - 1) as f64;
835        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
836        let g = gradient_uniform(&y, h);
837        for i in 0..m {
838            assert!(
839                (g[i] - 3.0).abs() < 1e-10,
840                "gradient of 3x should be 3 at i={i}, got {}",
841                g[i]
842            );
843        }
844    }
845
846    // ── fdata_interpolate ──
847
848    #[test]
849    fn test_gaussian_kernel() {
850        assert!((gaussian_kernel(0.0, 1.0) - 1.0).abs() < 1e-12);
851        assert!(gaussian_kernel(3.0, 1.0) < 0.02); // far from center
852        assert!((gaussian_kernel(1.0, 0.0)).abs() < 1e-12); // zero bandwidth
853    }
854
855    #[test]
856    fn test_bandwidth_candidates() {
857        let n = 5;
858        let mut dists = vec![0.0; n * n];
859        for i in 0..n {
860            for j in 0..n {
861                dists[i * n + j] = (i as f64 - j as f64).abs();
862            }
863        }
864        let cands = bandwidth_candidates_from_dists(&dists, n, 10);
865        assert!(!cands.is_empty());
866        assert!(cands.iter().all(|&h| h > 0.0));
867        // Should be sorted
868        for w in cands.windows(2) {
869            assert!(w[1] >= w[0]);
870        }
871    }
872
873    #[test]
874    fn test_quantile_sorted() {
875        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
876        assert!((quantile_sorted(&data, 0.0) - 1.0).abs() < 1e-12);
877        assert!((quantile_sorted(&data, 1.0) - 5.0).abs() < 1e-12);
878        assert!((quantile_sorted(&data, 0.5) - 3.0).abs() < 1e-12);
879        assert!((quantile_sorted(&data, 0.25) - 2.0).abs() < 1e-12);
880    }
881
882    #[test]
883    fn test_r_squared_perfect() {
884        let y = vec![1.0, 2.0, 3.0, 4.0];
885        let resid = vec![0.0, 0.0, 0.0, 0.0];
886        assert!((r_squared(&y, &resid) - 1.0).abs() < 1e-12);
887    }
888
889    #[test]
890    fn test_r_squared_mean_model() {
891        let y = vec![1.0, 2.0, 3.0, 4.0];
892        let mean = 2.5;
893        let resid: Vec<f64> = y.iter().map(|&yi| yi - mean).collect();
894        assert!(r_squared(&y, &resid).abs() < 1e-12); // R²=0 for mean model
895    }
896
897    #[test]
898    fn test_aic_bic() {
899        let a = aic(100, 50.0, 5);
900        let b = bic(100, 50.0, 5);
901        assert!(a.is_finite());
902        assert!(b.is_finite());
903        assert!(b > a); // BIC penalizes more for n > ~8
904    }
905
906    #[test]
907    fn fdata_interpolate_linear_identity() {
908        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
909        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
910        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
911        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
912        for j in 0..20 {
913            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
914        }
915    }
916
917    #[test]
918    fn fdata_interpolate_cubic_hermite_smooth() {
919        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
920        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
921        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
922
923        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
924        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
925
926        // Values should approximate sin(t) well
927        for (j, &tj) in t_fine.iter().enumerate() {
928            assert!(
929                (result[(0, j)] - tj.sin()).abs() < 0.02,
930                "at t={tj:.2}: got {:.4}, expected {:.4}",
931                result[(0, j)],
932                tj.sin()
933            );
934        }
935    }
936
937    #[test]
938    fn fdata_interpolate_multiple_curves() {
939        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
940        let n = 5;
941        let m = 30;
942        // Build column-major data: n curves, each sin((i+1)*x)
943        let mut col_major = vec![0.0; n * m];
944        for i in 0..n {
945            for j in 0..m {
946                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
947            }
948        }
949        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
950
951        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
952        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
953        assert_eq!(result.shape(), (n, 50));
954        // All values should be finite
955        for i in 0..n {
956            for j in 0..50 {
957                assert!(result[(i, j)].is_finite());
958            }
959        }
960    }
961
962    // ── spline_interpolate ──
963
964    #[test]
965    fn spline_interpolate_reproduces_argvals() {
966        use crate::test_helpers::uniform_grid;
967        let t = uniform_grid(20);
968        let vals: Vec<f64> = t.iter().map(|&x| x.powi(3)).collect();
969        // column-major: 1 row, 20 columns
970        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
971        let result = spline_interpolate(&data, &t, &t, 4).unwrap();
972        for j in 0..20 {
973            assert!(
974                (result[(0, j)] - data[(0, j)]).abs() < 1e-10,
975                "at j={j}: got {}, expected {}",
976                result[(0, j)],
977                data[(0, j)]
978            );
979        }
980    }
981
982    #[test]
983    fn spline_interpolate_cubic_offgrid() {
984        // A cubic polynomial y = 2t^3 - t^2 + 0.5t - 0.1 lies exactly in the
985        // order-4 B-spline space; an order-4 interpolant should reproduce it
986        // within 1e-10 at off-grid midpoints.
987        use crate::test_helpers::uniform_grid;
988        let t = uniform_grid(20); // 20 evaluation points in [0, 1]
989        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
990        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
991        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
992
993        // Query at off-grid midpoints between consecutive t values
994        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
995        let result = spline_interpolate(&data, &t, &q, 4).unwrap();
996
997        for (j, &qj) in q.iter().enumerate() {
998            let expected = poly(qj);
999            let got = result[(0, j)];
1000            assert!(
1001                (got - expected).abs() < 1e-10,
1002                "off-grid at q={qj:.4}: got {got}, expected {expected}"
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn spline_interpolate_rejects_out_of_range() {
1009        use crate::test_helpers::uniform_grid;
1010        let t = uniform_grid(20);
1011        let vals: Vec<f64> = t.to_vec();
1012        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1013
1014        // Query point below argvals[0]
1015        let q_below = vec![-0.1_f64];
1016        let err = spline_interpolate(&data, &t, &q_below, 4).unwrap_err();
1017        assert!(
1018            matches!(
1019                err,
1020                crate::FdarError::InvalidParameter {
1021                    parameter: "query_points",
1022                    ..
1023                }
1024            ),
1025            "expected InvalidParameter for query below domain, got {err:?}"
1026        );
1027
1028        // Query point above argvals[m-1]
1029        let q_above = vec![1.1_f64];
1030        let err2 = spline_interpolate(&data, &t, &q_above, 4).unwrap_err();
1031        assert!(
1032            matches!(
1033                err2,
1034                crate::FdarError::InvalidParameter {
1035                    parameter: "query_points",
1036                    ..
1037                }
1038            ),
1039            "expected InvalidParameter for query above domain, got {err2:?}"
1040        );
1041    }
1042
1043    #[test]
1044    fn spline_interpolate_rejects_bad_order() {
1045        use crate::test_helpers::uniform_grid;
1046        let t = uniform_grid(20);
1047        let vals: Vec<f64> = t.to_vec();
1048        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1049        let q = vec![0.5_f64];
1050
1051        // order == 0
1052        let err = spline_interpolate(&data, &t, &q, 0).unwrap_err();
1053        assert!(
1054            matches!(
1055                err,
1056                crate::FdarError::InvalidParameter {
1057                    parameter: "order",
1058                    ..
1059                }
1060            ),
1061            "expected InvalidParameter for order=0, got {err:?}"
1062        );
1063
1064        // order >= m (m=20)
1065        let err2 = spline_interpolate(&data, &t, &q, 20).unwrap_err();
1066        assert!(
1067            matches!(
1068                err2,
1069                crate::FdarError::InvalidParameter {
1070                    parameter: "order",
1071                    ..
1072                }
1073            ),
1074            "expected InvalidParameter for order=20 (>=m=20), got {err2:?}"
1075        );
1076    }
1077
1078    #[test]
1079    fn spline_interpolate_rejects_dim_mismatch() {
1080        use crate::test_helpers::uniform_grid;
1081        let t = uniform_grid(20);
1082        let vals: Vec<f64> = t.to_vec();
1083        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1084
1085        // argvals.len() != data.ncols()
1086        let bad_argvals: Vec<f64> = (0..15).map(|i| i as f64 / 14.0).collect();
1087        let q = vec![0.5_f64];
1088        let err = spline_interpolate(&data, &bad_argvals, &q, 4).unwrap_err();
1089        assert!(
1090            matches!(
1091                err,
1092                crate::FdarError::InvalidDimension {
1093                    parameter: "argvals",
1094                    ..
1095                }
1096            ),
1097            "expected InvalidDimension for argvals mismatch, got {err:?}"
1098        );
1099
1100        // empty query_points
1101        let err2 = spline_interpolate(&data, &t, &[], 4).unwrap_err();
1102        assert!(
1103            matches!(
1104                err2,
1105                crate::FdarError::InvalidDimension {
1106                    parameter: "query_points",
1107                    ..
1108                }
1109            ),
1110            "expected InvalidDimension for empty query_points, got {err2:?}"
1111        );
1112    }
1113}