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/// Interpolate functional data to a new grid using B-splines with explicit extrapolation control.
511///
512/// Like [`spline_interpolate`] but applies `policy` for any query point that falls outside the
513/// domain `[argvals[0], argvals[m-1]]` instead of always returning an error. In-range queries
514/// produce identical values to the [`spline_interpolate`] path.
515///
516/// # Arguments
517/// * `data`         — Functional data matrix (`n × m`)
518/// * `argvals`      — Original evaluation points (length `m`, must be sorted)
519/// * `query_points` — Points to evaluate at (may include out-of-range values)
520/// * `order`        — B-spline order (1 = linear, 2 = quadratic, 4 = cubic, …); must be in `[1, m)`
521/// * `policy`       — Extrapolation policy for out-of-range query points
522///
523/// # Returns
524/// Interpolated `FdMatrix` of shape `(n, query_points.len())`
525///
526/// # Errors
527/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()` or `query_points` is empty
528/// * `FdarError::InvalidParameter` — `order` is 0 or ≥ `m`; or `policy == Exception` and any
529///   query point is outside `[argvals[0], argvals[m-1]]`; or `policy == Periodic` and the domain
530///   length is zero
531/// * `FdarError::ComputationFailed` — SVD pseudoinverse could not be computed
532///
533/// # Policy Semantics
534/// * `Boundary` — clamp OOB query to the nearest boundary (`t_min` or `t_max`) before spline eval
535/// * `Exception` — return `Err(FdarError::InvalidParameter { parameter: "query_points", .. })` on first OOB
536/// * `Fill(v)` — set OOB output cells to constant `v`; in-range cells use spline interpolation
537/// * `Periodic` — wrap OOB query modulo the domain length (same recipe as in
538///   [`fdata_interpolate_with_policy`]); requires `argvals[0] < argvals[m-1]`
539pub fn spline_interpolate_with_policy(
540    data: &crate::matrix::FdMatrix,
541    argvals: &[f64],
542    query_points: &[f64],
543    order: usize,
544    policy: ExtrapolationPolicy,
545) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
546    let (n, m) = data.shape();
547
548    // --- Input validation (mirrors spline_interpolate) ---
549    if argvals.len() != m {
550        return Err(crate::FdarError::InvalidDimension {
551            parameter: "argvals",
552            expected: format!("{m}"),
553            actual: format!("{}", argvals.len()),
554        });
555    }
556    if query_points.is_empty() {
557        return Err(crate::FdarError::InvalidDimension {
558            parameter: "query_points",
559            expected: ">= 1".to_string(),
560            actual: "0".to_string(),
561        });
562    }
563    if order == 0 || order >= m {
564        return Err(crate::FdarError::InvalidParameter {
565            parameter: "order",
566            message: format!("must be in [1, {m}), got {order}"),
567        });
568    }
569
570    let t_min = argvals[0];
571    let t_max = argvals[m - 1];
572    let domain_len = t_max - t_min;
573
574    // Periodic requires a positive domain length.
575    if domain_len <= 0.0 && matches!(policy, ExtrapolationPolicy::Periodic) {
576        return Err(crate::FdarError::InvalidParameter {
577            parameter: "argvals",
578            message: "Periodic extrapolation requires a positive domain length \
579                      (argvals[0] < argvals[m-1])"
580                .to_string(),
581        });
582    }
583
584    let m_q = query_points.len();
585
586    // --- Map each query point to an effective in-range point (or mark as Fill) ---
587    // `effective[j]` holds the remapped query to pass to the spline; `fill_mask[j]` is true
588    // when the output should be the Fill constant instead.
589    let mut effective = Vec::with_capacity(m_q);
590    let mut fill_mask = vec![false; m_q];
591
592    for (j, &q) in query_points.iter().enumerate() {
593        let in_range = q >= t_min && q <= t_max;
594        if in_range {
595            effective.push(q);
596        } else {
597            match &policy {
598                ExtrapolationPolicy::Boundary => effective.push(q.clamp(t_min, t_max)),
599                ExtrapolationPolicy::Exception => {
600                    return Err(crate::FdarError::InvalidParameter {
601                        parameter: "query_points",
602                        message: format!("query {q} is outside domain [{t_min}, {t_max}]"),
603                    });
604                }
605                ExtrapolationPolicy::Fill(_) => {
606                    // Placeholder; we will overwrite this column from the fill value.
607                    fill_mask[j] = true;
608                    effective.push(t_min); // dummy in-range value — will be discarded
609                }
610                ExtrapolationPolicy::Periodic => {
611                    let wrapped = t_min + ((q - t_min) % domain_len + domain_len) % domain_len;
612                    effective.push(wrapped);
613                }
614            }
615        }
616    }
617
618    // --- Run the core spline logic on the effective (all in-range) query points ---
619    // Reuse the same SVD-based evaluation as spline_interpolate.
620    let nknots = m.saturating_sub(order).max(2);
621    let knots = crate::basis::bspline::construct_bspline_knots(t_min, t_max, nknots, order);
622    let basis_vals = crate::basis::bspline::bspline_basis(argvals, nknots, order);
623    let nbasis = basis_vals.len() / m;
624    let b_mat = nalgebra::DMatrix::from_column_slice(m, nbasis, &basis_vals);
625    let tol = NUMERICAL_EPS * b_mat.nrows().max(b_mat.ncols()) as f64;
626    let svd = nalgebra::SVD::new(b_mat.clone(), true, true);
627    let pinv = svd
628        .pseudo_inverse(tol)
629        .map_err(|e| crate::FdarError::ComputationFailed {
630            operation: "spline_interpolate_with_policy SVD pseudoinverse",
631            detail: e.to_string(),
632        })?;
633    let basis_query = crate::basis::bspline::bspline_basis_from_knots(&effective, &knots, order);
634
635    let mut out = crate::matrix::FdMatrix::zeros(n, m_q);
636    for i in 0..n {
637        let y_vec: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
638        let y_col = nalgebra::DVector::from_vec(y_vec);
639        let coefs = &pinv * y_col;
640
641        for j in 0..m_q {
642            if fill_mask[j] {
643                // Fill policy: overwrite with constant.
644                if let ExtrapolationPolicy::Fill(v) = policy {
645                    out[(i, j)] = v;
646                }
647            } else {
648                let mut val = 0.0_f64;
649                for k in 0..nbasis {
650                    val += coefs[k] * basis_query[j + k * m_q];
651                }
652                out[(i, j)] = val;
653            }
654        }
655    }
656
657    Ok(out)
658}
659
660/// Cubic Hermite interpolation at a single point.
661///
662/// Uses Fritsch-Carlson monotone slopes for C1 interpolation.
663fn cubic_hermite_interp(x: &[f64], y: &[f64], t: f64) -> f64 {
664    let n = x.len();
665    if n < 2 {
666        return if n == 1 { y[0] } else { 0.0 };
667    }
668
669    // Clamp to domain
670    if t <= x[0] {
671        return y[0];
672    }
673    if t >= x[n - 1] {
674        return y[n - 1];
675    }
676
677    // Find interval via binary search
678    let k = match x.binary_search_by(|v| v.partial_cmp(&t).unwrap_or(std::cmp::Ordering::Equal)) {
679        Ok(i) => return y[i],
680        Err(i) => {
681            if i == 0 {
682                0
683            } else {
684                i - 1
685            }
686        }
687    };
688
689    // Compute slopes (Fritsch-Carlson)
690    let slopes: Vec<f64> = x
691        .windows(2)
692        .zip(y.windows(2))
693        .map(|(xw, yw)| (yw[1] - yw[0]) / (xw[1] - xw[0]))
694        .collect();
695
696    // Tangents at each point
697    let mut tangents = vec![0.0; n];
698    tangents[0] = slopes[0];
699    tangents[n - 1] = slopes[n - 2];
700    for i in 1..n - 1 {
701        if slopes[i - 1].signum() != slopes[i].signum() {
702            tangents[i] = 0.0;
703        } else {
704            tangents[i] = (slopes[i - 1] + slopes[i]) / 2.0;
705        }
706    }
707
708    // Hermite basis
709    let h = x[k + 1] - x[k];
710    let s = (t - x[k]) / h;
711    let s2 = s * s;
712    let s3 = s2 * s;
713
714    let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
715    let h10 = s3 - 2.0 * s2 + s;
716    let h01 = -2.0 * s3 + 3.0 * s2;
717    let h11 = s3 - s2;
718
719    h00 * y[k] + h10 * h * tangents[k] + h01 * y[k + 1] + h11 * h * tangents[k + 1]
720}
721
722/// Numerical gradient with uniform spacing using 5-point stencil (O(h⁴)).
723///
724/// Interior points use the 5-point central difference:
725///   `g[i] = (-y[i+2] + 8*y[i+1] - 8*y[i-1] + y[i-2]) / (12*h)`
726///
727/// Near-boundary points use appropriate forward/backward formulas.
728pub fn gradient_uniform(y: &[f64], h: f64) -> Vec<f64> {
729    let n = y.len();
730    let mut g = vec![0.0; n];
731    if n < 2 {
732        return g;
733    }
734    if n == 2 {
735        g[0] = (y[1] - y[0]) / h;
736        g[1] = (y[1] - y[0]) / h;
737        return g;
738    }
739    if n == 3 {
740        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
741        g[1] = (y[2] - y[0]) / (2.0 * h);
742        g[2] = (y[0] - 4.0 * y[1] + 3.0 * y[2]) / (2.0 * h);
743        return g;
744    }
745    if n == 4 {
746        g[0] = (-3.0 * y[0] + 4.0 * y[1] - y[2]) / (2.0 * h);
747        g[1] = (y[2] - y[0]) / (2.0 * h);
748        g[2] = (y[3] - y[1]) / (2.0 * h);
749        g[3] = (y[1] - 4.0 * y[2] + 3.0 * y[3]) / (2.0 * h);
750        return g;
751    }
752
753    // n >= 5: use 5-point stencil for interior, 4-point formulas at boundaries
754    // Left boundary: O(h³) forward formula
755    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);
756    g[1] = (-3.0 * y[0] - 10.0 * y[1] + 18.0 * y[2] - 6.0 * y[3] + y[4]) / (12.0 * h);
757
758    // Interior: 5-point central difference O(h⁴)
759    for i in 2..n - 2 {
760        g[i] = (-y[i + 2] + 8.0 * y[i + 1] - 8.0 * y[i - 1] + y[i - 2]) / (12.0 * h);
761    }
762
763    // Right boundary: O(h³) backward formula
764    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])
765        / (12.0 * h);
766    g[n - 1] = (3.0 * y[n - 5] - 16.0 * y[n - 4] + 36.0 * y[n - 3] - 48.0 * y[n - 2]
767        + 25.0 * y[n - 1])
768        / (12.0 * h);
769    g
770}
771
772/// Numerical gradient for non-uniform grids using 3-point Lagrange derivative.
773///
774/// At interior points uses the three-point formula:
775///   `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))`
776/// where `h_l = t[i]-t[i-1]` and `h_r = t[i+1]-t[i]`.
777///
778/// Boundary points use forward/backward 3-point formulas.
779pub fn gradient_nonuniform(y: &[f64], t: &[f64]) -> Vec<f64> {
780    let n = y.len();
781    assert_eq!(n, t.len(), "y and t must have the same length");
782    let mut g = vec![0.0; n];
783    if n < 2 {
784        return g;
785    }
786    if n == 2 {
787        let h = t[1] - t[0];
788        if h.abs() < 1e-15 {
789            return g;
790        }
791        g[0] = (y[1] - y[0]) / h;
792        g[1] = g[0];
793        return g;
794    }
795
796    // Left boundary: 3-point forward Lagrange derivative
797    let h0 = t[1] - t[0];
798    let h1 = t[2] - t[0];
799    if h0.abs() > 1e-15 && h1.abs() > 1e-15 && (h1 - h0).abs() > 1e-15 {
800        g[0] = y[0] * (-h1 - h0) / (h0 * h1) + y[1] * h1 / (h0 * (h1 - h0))
801            - y[2] * h0 / (h1 * (h1 - h0));
802    } else {
803        g[0] = (y[1] - y[0]) / h0.max(1e-15);
804    }
805
806    // Interior: 3-point Lagrange central formula
807    for i in 1..n - 1 {
808        let h_l = t[i] - t[i - 1];
809        let h_r = t[i + 1] - t[i];
810        let h_sum = h_l + h_r;
811        if h_l.abs() < 1e-15 || h_r.abs() < 1e-15 || h_sum.abs() < 1e-15 {
812            g[i] = 0.0;
813            continue;
814        }
815        g[i] = -y[i - 1] * h_r / (h_l * h_sum)
816            + y[i] * (h_r - h_l) / (h_l * h_r)
817            + y[i + 1] * h_l / (h_r * h_sum);
818    }
819
820    // Right boundary: 3-point backward Lagrange derivative
821    let h_last = t[n - 1] - t[n - 2];
822    let h_prev = t[n - 1] - t[n - 3];
823    let h_mid = t[n - 2] - t[n - 3];
824    if h_last.abs() > 1e-15 && h_prev.abs() > 1e-15 && h_mid.abs() > 1e-15 {
825        g[n - 1] = y[n - 3] * h_last / (h_mid * h_prev) - y[n - 2] * h_prev / (h_mid * h_last)
826            + y[n - 1] * (h_prev + h_last) / (h_prev * h_last);
827    } else {
828        g[n - 1] = (y[n - 1] - y[n - 2]) / h_last.max(1e-15);
829    }
830
831    g
832}
833
834/// Numerical gradient that auto-detects uniform vs non-uniform grids.
835///
836/// If the grid `t` is uniformly spaced (max|Δt_i − Δt_0| < ε), dispatches to
837/// [`gradient_uniform`] for optimal accuracy. Otherwise falls back to
838/// [`gradient_nonuniform`].
839pub fn gradient(y: &[f64], t: &[f64]) -> Vec<f64> {
840    let n = t.len();
841    if n < 2 {
842        return vec![0.0; y.len()];
843    }
844
845    let h0 = t[1] - t[0];
846    let is_uniform = t
847        .windows(2)
848        .all(|w| ((w[1] - w[0]) - h0).abs() < 1e-12 * h0.abs().max(1.0));
849
850    if is_uniform {
851        gradient_uniform(y, h0)
852    } else {
853        gradient_nonuniform(y, t)
854    }
855}
856
857/// Extrapolation policy controlling behavior when a query point falls
858/// outside the domain of `argvals`.
859///
860/// Used with [`fdata_interpolate_with_policy`] to give callers explicit control
861/// over out-of-range query handling instead of the silent boundary clamp that
862/// [`fdata_interpolate`] applies.
863#[derive(Debug, Clone, PartialEq)]
864#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
865pub enum ExtrapolationPolicy {
866    /// Clamp the query point to the nearest boundary value.
867    ///
868    /// A query at `t < t_min` returns the interpolated value at `t_min`;
869    /// a query at `t > t_max` returns the interpolated value at `t_max`.
870    Boundary,
871    /// Return an error for any out-of-range query point.
872    ///
873    /// Returns `Err(FdarError::InvalidParameter { parameter: "new_argvals", .. })`
874    /// when a query point lies outside `[argvals[0], argvals[m-1]]`.
875    Exception,
876    /// Fill out-of-range cells with a constant value.
877    Fill(f64),
878    /// Wrap query points modulo the domain length (periodic extension).
879    ///
880    /// A query at `t_min - delta` returns the same value as `t_max - delta`.
881    /// Uses the guarded-modulo recipe `((t - t_min) % L + L) % L` to handle
882    /// negative remainders for `t < t_min`.
883    Periodic,
884}
885
886/// Interpolate functional data to a new grid with explicit extrapolation control.
887///
888/// Like [`fdata_interpolate`] but applies `policy` for any query point that falls
889/// outside the domain `[argvals[0], argvals[m-1]]` instead of silently clamping.
890/// In-range queries produce identical values to the [`fdata_interpolate`] path.
891///
892/// # Arguments
893/// * `data`       — Functional data matrix (`n × m`)
894/// * `argvals`    — Original evaluation points (length `m`, must be sorted)
895/// * `new_argvals`— New evaluation points (length `m_new`)
896/// * `method`     — Interpolation method for in-range (and `Boundary`/`Periodic`) points
897/// * `policy`     — Extrapolation policy for out-of-range query points
898///
899/// # Returns
900/// Interpolated matrix `(n × m_new)`
901///
902/// # Errors
903/// * `FdarError::InvalidDimension` — `argvals.len() != data.ncols()`
904/// * `FdarError::InvalidParameter` — a query point is out of range and `policy ==
905///   ExtrapolationPolicy::Exception`
906pub fn fdata_interpolate_with_policy(
907    data: &crate::matrix::FdMatrix,
908    argvals: &[f64],
909    new_argvals: &[f64],
910    method: InterpolationMethod,
911    policy: ExtrapolationPolicy,
912) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
913    let (n, m) = data.shape();
914    if argvals.len() != m {
915        return Err(crate::FdarError::InvalidDimension {
916            parameter: "argvals",
917            expected: format!("{m}"),
918            actual: format!("{}", argvals.len()),
919        });
920    }
921    let m_new = new_argvals.len();
922    if n == 0 || m < 2 || m_new == 0 {
923        return Ok(crate::matrix::FdMatrix::zeros(n.max(1), m_new.max(1)));
924    }
925    let t_min = argvals[0];
926    let t_max = argvals[m - 1];
927    let domain_len = t_max - t_min;
928
929    // CR-01: Guard degenerate domain before the loop — Periodic wraps via modulo domain_len
930    // and produces NaN when domain_len == 0 (IEEE 754: x % 0.0 = NaN).  Other policies do
931    // not divide by domain_len so only Periodic needs this guard.
932    if domain_len <= 0.0 && matches!(policy, ExtrapolationPolicy::Periodic) {
933        return Err(crate::FdarError::InvalidParameter {
934            parameter: "argvals",
935            message: "Periodic extrapolation requires a positive domain length \
936                      (argvals[0] < argvals[m-1])"
937                .to_string(),
938        });
939    }
940
941    let mut result = crate::matrix::FdMatrix::zeros(n, m_new);
942    for i in 0..n {
943        let y: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
944        for (j, &t) in new_argvals.iter().enumerate() {
945            let in_range = t >= t_min && t <= t_max;
946            result[(i, j)] = if in_range {
947                match method {
948                    InterpolationMethod::Linear => linear_interp(argvals, &y, t),
949                    InterpolationMethod::CubicHermite => cubic_hermite_interp(argvals, &y, t),
950                }
951            } else {
952                match &policy {
953                    ExtrapolationPolicy::Boundary => {
954                        let t_clamped = t.clamp(t_min, t_max);
955                        match method {
956                            InterpolationMethod::Linear => linear_interp(argvals, &y, t_clamped),
957                            InterpolationMethod::CubicHermite => {
958                                cubic_hermite_interp(argvals, &y, t_clamped)
959                            }
960                        }
961                    }
962                    ExtrapolationPolicy::Exception => {
963                        return Err(crate::FdarError::InvalidParameter {
964                            parameter: "new_argvals",
965                            message: format!("query {t} is outside domain [{t_min}, {t_max}]"),
966                        });
967                    }
968                    ExtrapolationPolicy::Fill(v) => *v,
969                    ExtrapolationPolicy::Periodic => {
970                        let wrapped = t_min + ((t - t_min) % domain_len + domain_len) % domain_len;
971                        match method {
972                            InterpolationMethod::Linear => linear_interp(argvals, &y, wrapped),
973                            InterpolationMethod::CubicHermite => {
974                                cubic_hermite_interp(argvals, &y, wrapped)
975                            }
976                        }
977                    }
978                }
979            };
980        }
981    }
982    Ok(result)
983}
984
985// ── FEAT-03: NaN imputation ────────────────────────────────────────────────
986
987/// Strategy for in-grid NaN imputation in a regular `FdMatrix`.
988///
989/// Used with [`impute_missing_values`] to specify how NaN entries are replaced
990/// in each curve.
991///
992/// Leading or trailing NaN values (no neighbor on one side) are filled with
993/// the nearest valid value (boundary extension) for the `Linear` strategy.
994#[derive(Debug, Clone, PartialEq)]
995#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
996pub enum ImputationMethod {
997    /// Linear interpolation between the nearest non-NaN neighbors.
998    ///
999    /// For gaps at the boundary (leading or trailing NaN), the nearest valid
1000    /// value is used as boundary extension.
1001    Linear,
1002    /// Replace each NaN with the curve's mean of its non-NaN values.
1003    Mean,
1004    /// Replace each NaN with a user-supplied constant value.
1005    Constant(f64),
1006}
1007
1008/// Impute NaN values in a regular functional data matrix.
1009///
1010/// Returns a new `FdMatrix` with NaN entries replaced according to `method`.
1011/// Non-NaN entries are copied through unchanged.
1012///
1013/// For `Linear`, gaps at the curve boundary (no neighbor on one side) are
1014/// filled with the nearest valid value (boundary extension).
1015///
1016/// # Arguments
1017/// * `data`    — Functional data matrix (`n × m`) with possible NaN entries
1018/// * `argvals` — Evaluation points (length `m`, must be sorted)
1019/// * `method`  — Imputation strategy
1020///
1021/// # Errors
1022/// * `FdarError::InvalidDimension` if `argvals.len() != data.ncols()`
1023/// * `FdarError::InvalidParameter` if any curve consists entirely of NaN values
1024pub fn impute_missing_values(
1025    data: &crate::matrix::FdMatrix,
1026    argvals: &[f64],
1027    method: ImputationMethod,
1028) -> Result<crate::matrix::FdMatrix, crate::FdarError> {
1029    let (n, m) = data.shape();
1030    if argvals.len() != m {
1031        return Err(crate::FdarError::InvalidDimension {
1032            parameter: "argvals",
1033            expected: format!("{m}"),
1034            actual: format!("{}", argvals.len()),
1035        });
1036    }
1037    // WR-01: A zero-column matrix has no evaluation points and is degenerate.
1038    // Without this guard the per-curve loop would report "curve 0 contains only NaN values",
1039    // which is factually incorrect (the curve has no values at all).
1040    if m == 0 {
1041        return Err(crate::FdarError::InvalidDimension {
1042            parameter: "data",
1043            expected: "m >= 1".to_string(),
1044            actual: "m=0".to_string(),
1045        });
1046    }
1047    let mut out_data = vec![0.0_f64; n * m]; // column-major output buffer
1048    for i in 0..n {
1049        let row: Vec<f64> = data.row(i);
1050        let valid_count = row.iter().filter(|v| !v.is_nan()).count();
1051        if valid_count == 0 {
1052            return Err(crate::FdarError::InvalidParameter {
1053                parameter: "data",
1054                message: format!("curve {i} contains only NaN values"),
1055            });
1056        }
1057        let imputed = impute_row(&row, argvals, &method);
1058        for j in 0..m {
1059            out_data[i + j * n] = imputed[j]; // column-major write
1060        }
1061    }
1062    crate::matrix::FdMatrix::from_column_major(out_data, n, m)
1063}
1064
1065/// Impute a single row (curve) of NaN values using the given strategy.
1066fn impute_row(row: &[f64], argvals: &[f64], method: &ImputationMethod) -> Vec<f64> {
1067    let mut result = row.to_vec();
1068    match method {
1069        ImputationMethod::Mean => {
1070            let sum: f64 = row.iter().filter(|v| !v.is_nan()).sum();
1071            let count = row.iter().filter(|v| !v.is_nan()).count();
1072            let mean = sum / count as f64;
1073            for v in &mut result {
1074                if v.is_nan() {
1075                    *v = mean;
1076                }
1077            }
1078        }
1079        ImputationMethod::Constant(c) => {
1080            for v in &mut result {
1081                if v.is_nan() {
1082                    *v = *c;
1083                }
1084            }
1085        }
1086        ImputationMethod::Linear => {
1087            let valid_idxs: Vec<usize> = (0..row.len()).filter(|&j| !row[j].is_nan()).collect();
1088            for j in 0..row.len() {
1089                if result[j].is_nan() {
1090                    let left = valid_idxs.iter().rev().find(|&&k| k < j).copied();
1091                    let right = valid_idxs.iter().find(|&&k| k > j).copied();
1092                    result[j] = match (left, right) {
1093                        (Some(l), Some(r)) => {
1094                            linear_interp(&[argvals[l], argvals[r]], &[row[l], row[r]], argvals[j])
1095                        }
1096                        (Some(l), None) => row[l], // boundary fill (trailing NaN)
1097                        (None, Some(r)) => row[r], // boundary fill (leading NaN)
1098                        (None, None) => unreachable!(), // all-NaN already rejected
1099                    };
1100                }
1101            }
1102        }
1103    }
1104    result
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    #[test]
1112    fn test_simpsons_weights_uniform() {
1113        let argvals = vec![0.0, 0.25, 0.5, 0.75, 1.0];
1114        let weights = simpsons_weights(&argvals);
1115        let sum: f64 = weights.iter().sum();
1116        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1117    }
1118
1119    #[test]
1120    fn test_simpsons_weights_2d() {
1121        let argvals_s = vec![0.0, 0.5, 1.0];
1122        let argvals_t = vec![0.0, 0.5, 1.0];
1123        let weights = simpsons_weights_2d(&argvals_s, &argvals_t);
1124        let sum: f64 = weights.iter().sum();
1125        assert!((sum - 1.0).abs() < NUMERICAL_EPS);
1126    }
1127
1128    #[test]
1129    fn test_extract_curves() {
1130        // Column-major data: 2 observations, 3 points
1131        // obs 0: [1, 2, 3], obs 1: [4, 5, 6]
1132        let data = vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0];
1133        let mat = crate::matrix::FdMatrix::from_column_major(data, 2, 3).unwrap();
1134        let curves = extract_curves(&mat);
1135        assert_eq!(curves.len(), 2);
1136        assert_eq!(curves[0], vec![1.0, 2.0, 3.0]);
1137        assert_eq!(curves[1], vec![4.0, 5.0, 6.0]);
1138    }
1139
1140    #[test]
1141    fn test_l2_distance_identical() {
1142        let curve = vec![1.0, 2.0, 3.0];
1143        let weights = vec![0.25, 0.5, 0.25];
1144        let dist = l2_distance(&curve, &curve, &weights);
1145        assert!(dist.abs() < NUMERICAL_EPS);
1146    }
1147
1148    #[test]
1149    fn test_l2_distance_different() {
1150        let curve1 = vec![0.0, 0.0, 0.0];
1151        let curve2 = vec![1.0, 1.0, 1.0];
1152        let weights = vec![0.25, 0.5, 0.25]; // sum = 1
1153        let dist = l2_distance(&curve1, &curve2, &weights);
1154        // dist^2 = 0.25*1 + 0.5*1 + 0.25*1 = 1.0, so dist = 1.0
1155        assert!((dist - 1.0).abs() < NUMERICAL_EPS);
1156    }
1157
1158    #[test]
1159    fn test_n1_weights() {
1160        // Single point: fallback weight is 1.0 (degenerate case)
1161        let w = simpsons_weights(&[0.5]);
1162        assert_eq!(w.len(), 1);
1163        assert!((w[0] - 1.0).abs() < 1e-12);
1164    }
1165
1166    #[test]
1167    fn test_n2_weights() {
1168        let w = simpsons_weights(&[0.0, 1.0]);
1169        assert_eq!(w.len(), 2);
1170        // Trapezoidal: each weight should be 0.5
1171        assert!((w[0] - 0.5).abs() < 1e-12);
1172        assert!((w[1] - 0.5).abs() < 1e-12);
1173    }
1174
1175    #[test]
1176    fn test_mismatched_l2_distance() {
1177        // Mismatched lengths should not panic but may give garbage
1178        let a = vec![1.0, 2.0, 3.0];
1179        let b = vec![1.0, 2.0, 3.0];
1180        let w = vec![0.5, 0.5, 0.5];
1181        let d = l2_distance(&a, &b, &w);
1182        assert!(d.abs() < 1e-12, "Same vectors should have zero distance");
1183    }
1184
1185    // ── trapz ──
1186
1187    #[test]
1188    fn test_trapz_sine() {
1189        // ∫₀^π sin(x) dx = 2
1190        let m = 1000;
1191        let x: Vec<f64> = (0..m)
1192            .map(|i| std::f64::consts::PI * i as f64 / (m - 1) as f64)
1193            .collect();
1194        let y: Vec<f64> = x.iter().map(|&xi| xi.sin()).collect();
1195        let result = trapz(&y, &x);
1196        assert!(
1197            (result - 2.0).abs() < 1e-4,
1198            "∫ sin(x) dx over [0,π] should be ~2, got {result}"
1199        );
1200    }
1201
1202    // ── cumulative_trapz ──
1203
1204    #[test]
1205    fn test_cumulative_trapz_matches_final() {
1206        let m = 100;
1207        let x: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1208        let y: Vec<f64> = x.iter().map(|&xi| 2.0 * xi).collect();
1209        let cum = cumulative_trapz(&y, &x);
1210        let total = trapz(&y, &x);
1211        assert!(
1212            (cum[m - 1] - total).abs() < 1e-12,
1213            "Final cumulative value should match trapz"
1214        );
1215    }
1216
1217    // ── linear_interp ──
1218
1219    #[test]
1220    fn test_linear_interp_boundary_clamp() {
1221        let x = vec![0.0, 0.5, 1.0];
1222        let y = vec![10.0, 20.0, 30.0];
1223        assert!((linear_interp(&x, &y, -1.0) - 10.0).abs() < 1e-12);
1224        assert!((linear_interp(&x, &y, 2.0) - 30.0).abs() < 1e-12);
1225        assert!((linear_interp(&x, &y, 0.25) - 15.0).abs() < 1e-12);
1226    }
1227
1228    // ── gradient_uniform ──
1229
1230    #[test]
1231    fn test_gradient_uniform_linear() {
1232        // f(x) = 3x → f'(x) = 3 everywhere
1233        let m = 50;
1234        let h = 1.0 / (m - 1) as f64;
1235        let y: Vec<f64> = (0..m).map(|i| 3.0 * i as f64 * h).collect();
1236        let g = gradient_uniform(&y, h);
1237        for i in 0..m {
1238            assert!(
1239                (g[i] - 3.0).abs() < 1e-10,
1240                "gradient of 3x should be 3 at i={i}, got {}",
1241                g[i]
1242            );
1243        }
1244    }
1245
1246    // ── fdata_interpolate ──
1247
1248    #[test]
1249    fn test_gaussian_kernel() {
1250        assert!((gaussian_kernel(0.0, 1.0) - 1.0).abs() < 1e-12);
1251        assert!(gaussian_kernel(3.0, 1.0) < 0.02); // far from center
1252        assert!((gaussian_kernel(1.0, 0.0)).abs() < 1e-12); // zero bandwidth
1253    }
1254
1255    #[test]
1256    fn test_bandwidth_candidates() {
1257        let n = 5;
1258        let mut dists = vec![0.0; n * n];
1259        for i in 0..n {
1260            for j in 0..n {
1261                dists[i * n + j] = (i as f64 - j as f64).abs();
1262            }
1263        }
1264        let cands = bandwidth_candidates_from_dists(&dists, n, 10);
1265        assert!(!cands.is_empty());
1266        assert!(cands.iter().all(|&h| h > 0.0));
1267        // Should be sorted
1268        for w in cands.windows(2) {
1269            assert!(w[1] >= w[0]);
1270        }
1271    }
1272
1273    #[test]
1274    fn test_quantile_sorted() {
1275        let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1276        assert!((quantile_sorted(&data, 0.0) - 1.0).abs() < 1e-12);
1277        assert!((quantile_sorted(&data, 1.0) - 5.0).abs() < 1e-12);
1278        assert!((quantile_sorted(&data, 0.5) - 3.0).abs() < 1e-12);
1279        assert!((quantile_sorted(&data, 0.25) - 2.0).abs() < 1e-12);
1280    }
1281
1282    #[test]
1283    fn test_r_squared_perfect() {
1284        let y = vec![1.0, 2.0, 3.0, 4.0];
1285        let resid = vec![0.0, 0.0, 0.0, 0.0];
1286        assert!((r_squared(&y, &resid) - 1.0).abs() < 1e-12);
1287    }
1288
1289    #[test]
1290    fn test_r_squared_mean_model() {
1291        let y = vec![1.0, 2.0, 3.0, 4.0];
1292        let mean = 2.5;
1293        let resid: Vec<f64> = y.iter().map(|&yi| yi - mean).collect();
1294        assert!(r_squared(&y, &resid).abs() < 1e-12); // R²=0 for mean model
1295    }
1296
1297    #[test]
1298    fn test_aic_bic() {
1299        let a = aic(100, 50.0, 5);
1300        let b = bic(100, 50.0, 5);
1301        assert!(a.is_finite());
1302        assert!(b.is_finite());
1303        assert!(b > a); // BIC penalizes more for n > ~8
1304    }
1305
1306    #[test]
1307    fn fdata_interpolate_linear_identity() {
1308        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1309        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1310        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1311        let result = fdata_interpolate(&data, &t, &t, InterpolationMethod::Linear);
1312        for j in 0..20 {
1313            assert!((result[(0, j)] - data[(0, j)]).abs() < 1e-12);
1314        }
1315    }
1316
1317    #[test]
1318    fn fdata_interpolate_cubic_hermite_smooth() {
1319        let t: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
1320        let vals: Vec<f64> = t.iter().map(|&x| x.sin()).collect();
1321        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1322
1323        let t_fine: Vec<f64> = (0..100).map(|i| i as f64 / 99.0).collect();
1324        let result = fdata_interpolate(&data, &t, &t_fine, InterpolationMethod::CubicHermite);
1325
1326        // Values should approximate sin(t) well
1327        for (j, &tj) in t_fine.iter().enumerate() {
1328            assert!(
1329                (result[(0, j)] - tj.sin()).abs() < 0.02,
1330                "at t={tj:.2}: got {:.4}, expected {:.4}",
1331                result[(0, j)],
1332                tj.sin()
1333            );
1334        }
1335    }
1336
1337    #[test]
1338    fn fdata_interpolate_multiple_curves() {
1339        let t: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
1340        let n = 5;
1341        let m = 30;
1342        // Build column-major data: n curves, each sin((i+1)*x)
1343        let mut col_major = vec![0.0; n * m];
1344        for i in 0..n {
1345            for j in 0..m {
1346                col_major[i + j * n] = ((i + 1) as f64 * t[j]).sin();
1347            }
1348        }
1349        let data = crate::matrix::FdMatrix::from_column_major(col_major, n, m).unwrap();
1350
1351        let t_new: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
1352        let result = fdata_interpolate(&data, &t, &t_new, InterpolationMethod::Linear);
1353        assert_eq!(result.shape(), (n, 50));
1354        // All values should be finite
1355        for i in 0..n {
1356            for j in 0..50 {
1357                assert!(result[(i, j)].is_finite());
1358            }
1359        }
1360    }
1361
1362    // ── spline_interpolate ──
1363
1364    #[test]
1365    fn spline_interpolate_reproduces_argvals() {
1366        use crate::test_helpers::uniform_grid;
1367        let t = uniform_grid(20);
1368        let vals: Vec<f64> = t.iter().map(|&x| x.powi(3)).collect();
1369        // column-major: 1 row, 20 columns
1370        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1371        let result = spline_interpolate(&data, &t, &t, 4).unwrap();
1372        for j in 0..20 {
1373            assert!(
1374                (result[(0, j)] - data[(0, j)]).abs() < 1e-10,
1375                "at j={j}: got {}, expected {}",
1376                result[(0, j)],
1377                data[(0, j)]
1378            );
1379        }
1380    }
1381
1382    #[test]
1383    fn spline_interpolate_cubic_offgrid() {
1384        // A cubic polynomial y = 2t^3 - t^2 + 0.5t - 0.1 lies exactly in the
1385        // order-4 B-spline space; an order-4 interpolant should reproduce it
1386        // within 1e-10 at off-grid midpoints.
1387        use crate::test_helpers::uniform_grid;
1388        let t = uniform_grid(20); // 20 evaluation points in [0, 1]
1389        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1390        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1391        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1392
1393        // Query at off-grid midpoints between consecutive t values
1394        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1395        let result = spline_interpolate(&data, &t, &q, 4).unwrap();
1396
1397        for (j, &qj) in q.iter().enumerate() {
1398            let expected = poly(qj);
1399            let got = result[(0, j)];
1400            assert!(
1401                (got - expected).abs() < 1e-10,
1402                "off-grid at q={qj:.4}: got {got}, expected {expected}"
1403            );
1404        }
1405    }
1406
1407    #[test]
1408    fn spline_interpolate_rejects_out_of_range() {
1409        use crate::test_helpers::uniform_grid;
1410        let t = uniform_grid(20);
1411        let vals: Vec<f64> = t.to_vec();
1412        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1413
1414        // Query point below argvals[0]
1415        let q_below = vec![-0.1_f64];
1416        let err = spline_interpolate(&data, &t, &q_below, 4).unwrap_err();
1417        assert!(
1418            matches!(
1419                err,
1420                crate::FdarError::InvalidParameter {
1421                    parameter: "query_points",
1422                    ..
1423                }
1424            ),
1425            "expected InvalidParameter for query below domain, got {err:?}"
1426        );
1427
1428        // Query point above argvals[m-1]
1429        let q_above = vec![1.1_f64];
1430        let err2 = spline_interpolate(&data, &t, &q_above, 4).unwrap_err();
1431        assert!(
1432            matches!(
1433                err2,
1434                crate::FdarError::InvalidParameter {
1435                    parameter: "query_points",
1436                    ..
1437                }
1438            ),
1439            "expected InvalidParameter for query above domain, got {err2:?}"
1440        );
1441    }
1442
1443    #[test]
1444    fn spline_interpolate_rejects_bad_order() {
1445        use crate::test_helpers::uniform_grid;
1446        let t = uniform_grid(20);
1447        let vals: Vec<f64> = t.to_vec();
1448        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1449        let q = vec![0.5_f64];
1450
1451        // order == 0
1452        let err = spline_interpolate(&data, &t, &q, 0).unwrap_err();
1453        assert!(
1454            matches!(
1455                err,
1456                crate::FdarError::InvalidParameter {
1457                    parameter: "order",
1458                    ..
1459                }
1460            ),
1461            "expected InvalidParameter for order=0, got {err:?}"
1462        );
1463
1464        // order >= m (m=20)
1465        let err2 = spline_interpolate(&data, &t, &q, 20).unwrap_err();
1466        assert!(
1467            matches!(
1468                err2,
1469                crate::FdarError::InvalidParameter {
1470                    parameter: "order",
1471                    ..
1472                }
1473            ),
1474            "expected InvalidParameter for order=20 (>=m=20), got {err2:?}"
1475        );
1476    }
1477
1478    #[test]
1479    fn spline_interpolate_rejects_dim_mismatch() {
1480        use crate::test_helpers::uniform_grid;
1481        let t = uniform_grid(20);
1482        let vals: Vec<f64> = t.to_vec();
1483        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1484
1485        // argvals.len() != data.ncols()
1486        let bad_argvals: Vec<f64> = (0..15).map(|i| i as f64 / 14.0).collect();
1487        let q = vec![0.5_f64];
1488        let err = spline_interpolate(&data, &bad_argvals, &q, 4).unwrap_err();
1489        assert!(
1490            matches!(
1491                err,
1492                crate::FdarError::InvalidDimension {
1493                    parameter: "argvals",
1494                    ..
1495                }
1496            ),
1497            "expected InvalidDimension for argvals mismatch, got {err:?}"
1498        );
1499
1500        // empty query_points
1501        let err2 = spline_interpolate(&data, &t, &[], 4).unwrap_err();
1502        assert!(
1503            matches!(
1504                err2,
1505                crate::FdarError::InvalidDimension {
1506                    parameter: "query_points",
1507                    ..
1508                }
1509            ),
1510            "expected InvalidDimension for empty query_points, got {err2:?}"
1511        );
1512    }
1513
1514    // ── spline_interpolate_with_policy tests ──────────────────────────────
1515
1516    #[test]
1517    fn test_spline_with_policy_in_range_matches_spline() {
1518        // In-range queries must match spline_interpolate exactly regardless of policy.
1519        use crate::test_helpers::uniform_grid;
1520        let t = uniform_grid(20);
1521        let poly = |x: f64| 2.0 * x.powi(3) - x.powi(2) + 0.5 * x - 0.1;
1522        let vals: Vec<f64> = t.iter().map(|&x| poly(x)).collect();
1523        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1524        // Off-grid midpoints (all in-range)
1525        let q: Vec<f64> = t.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect();
1526        let expected = spline_interpolate(&data, &t, &q, 4).unwrap();
1527        let actual =
1528            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1529                .unwrap();
1530        for j in 0..q.len() {
1531            assert!(
1532                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-10,
1533                "in-range mismatch at j={j}: policy={} vs plain={}",
1534                actual[(0, j)],
1535                expected[(0, j)]
1536            );
1537        }
1538    }
1539
1540    #[test]
1541    fn test_spline_with_policy_boundary() {
1542        // OOB queries clamped to nearest boundary value.
1543        use crate::test_helpers::uniform_grid;
1544        let t = uniform_grid(20); // [0, 1]
1545        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect(); // y = x^2
1546        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1547        // Query below (should clamp to t_min=0 → y=0) and above (clamp to t_max=1 → y≈1).
1548        let q = vec![-0.5_f64, 0.5, 1.5];
1549        let result =
1550            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Boundary)
1551                .unwrap();
1552        // Clamped to 0 → y = 0^2 = 0 (within spline tolerance)
1553        assert!(
1554            result[(0, 0)].abs() < 1e-9,
1555            "below boundary should clamp, got {}",
1556            result[(0, 0)]
1557        );
1558        // Clamped to 1 → y = 1^2 = 1 (within spline tolerance)
1559        assert!(
1560            (result[(0, 2)] - 1.0).abs() < 1e-9,
1561            "above boundary should clamp, got {}",
1562            result[(0, 2)]
1563        );
1564        // In-range 0.5 → y ≈ 0.25
1565        assert!(
1566            (result[(0, 1)] - 0.25).abs() < 1e-9,
1567            "in-range should be ~0.25, got {}",
1568            result[(0, 1)]
1569        );
1570    }
1571
1572    #[test]
1573    fn test_spline_with_policy_exception() {
1574        // Exception policy errors on OOB, matches spline_interpolate behavior.
1575        use crate::test_helpers::uniform_grid;
1576        let t = uniform_grid(20);
1577        let vals: Vec<f64> = t.to_vec();
1578        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1579        let q_oob = vec![1.5_f64];
1580        let err =
1581            spline_interpolate_with_policy(&data, &t, &q_oob, 4, ExtrapolationPolicy::Exception)
1582                .unwrap_err();
1583        assert!(
1584            matches!(
1585                err,
1586                crate::FdarError::InvalidParameter {
1587                    parameter: "query_points",
1588                    ..
1589                }
1590            ),
1591            "Exception policy should error on OOB, got {err:?}"
1592        );
1593        // In-range queries with Exception policy should succeed.
1594        let q_ok = vec![0.0_f64, 0.5, 1.0];
1595        let ok =
1596            spline_interpolate_with_policy(&data, &t, &q_ok, 4, ExtrapolationPolicy::Exception);
1597        assert!(
1598            ok.is_ok(),
1599            "Exception policy should succeed for in-range queries"
1600        );
1601    }
1602
1603    #[test]
1604    fn test_spline_with_policy_fill() {
1605        // Fill policy: OOB cells get constant fill value; in-range cells use spline.
1606        use crate::test_helpers::uniform_grid;
1607        let t = uniform_grid(20); // [0, 1]
1608        let vals: Vec<f64> = t.iter().map(|&x| x.powi(2)).collect();
1609        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1610        let fill_val = 42.0_f64;
1611        let q = vec![-0.5_f64, 0.5, 2.0];
1612        let result =
1613            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Fill(fill_val))
1614                .unwrap();
1615        assert!(
1616            (result[(0, 0)] - fill_val).abs() < 1e-10,
1617            "OOB below should be fill value, got {}",
1618            result[(0, 0)]
1619        );
1620        assert!(
1621            (result[(0, 2)] - fill_val).abs() < 1e-10,
1622            "OOB above should be fill value, got {}",
1623            result[(0, 2)]
1624        );
1625        // In-range: y ≈ 0.25
1626        assert!(
1627            (result[(0, 1)] - 0.25).abs() < 1e-9,
1628            "in-range should be ~0.25, got {}",
1629            result[(0, 1)]
1630        );
1631    }
1632
1633    #[test]
1634    fn test_spline_with_policy_periodic() {
1635        // Periodic policy: OOB queries wrap modulo domain length.
1636        // Use y = x (linear) on [0, 1]; a query at 1.3 should wrap to 0.3.
1637        use crate::test_helpers::uniform_grid;
1638        let t = uniform_grid(20); // [0, 1]
1639        let vals: Vec<f64> = t.to_vec(); // y = x
1640        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 20).unwrap();
1641        let q = vec![1.3_f64];
1642        let result =
1643            spline_interpolate_with_policy(&data, &t, &q, 4, ExtrapolationPolicy::Periodic)
1644                .unwrap();
1645        // Expected: wrap 1.3 → 0.3, spline of y=x at 0.3 ≈ 0.3
1646        assert!(
1647            (result[(0, 0)] - 0.3).abs() < 1e-9,
1648            "Periodic wrap of 1.3 should give ~0.3, got {}",
1649            result[(0, 0)]
1650        );
1651        // t = -0.2 → wrap to 0.8
1652        let q2 = vec![-0.2_f64];
1653        let result2 =
1654            spline_interpolate_with_policy(&data, &t, &q2, 4, ExtrapolationPolicy::Periodic)
1655                .unwrap();
1656        assert!(
1657            (result2[(0, 0)] - 0.8).abs() < 1e-9,
1658            "Periodic wrap of -0.2 should give ~0.8, got {}",
1659            result2[(0, 0)]
1660        );
1661    }
1662
1663    #[test]
1664    fn test_spline_with_policy_periodic_zero_length_domain_errors() {
1665        // Periodic + zero-length domain must error (same guard as fdata_interpolate_with_policy).
1666        let argvals = vec![3.0_f64, 3.0, 3.0];
1667        let vals = vec![1.0_f64, 1.0, 1.0];
1668        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1669        let q = vec![4.0_f64]; // OOB
1670        let err =
1671            spline_interpolate_with_policy(&data, &argvals, &q, 1, ExtrapolationPolicy::Periodic)
1672                .unwrap_err();
1673        assert!(
1674            matches!(
1675                err,
1676                crate::FdarError::InvalidParameter {
1677                    parameter: "argvals",
1678                    ..
1679                }
1680            ),
1681            "Periodic + zero-length domain should error, got {err:?}"
1682        );
1683    }
1684
1685    // ── ExtrapolationPolicy tests ──────────────────────────────────────────
1686
1687    /// Build a 1-curve FdMatrix: y = x on [0, 1] with `n_pts` points.
1688    fn make_linear_curve(n_pts: usize) -> (crate::matrix::FdMatrix, Vec<f64>) {
1689        use crate::test_helpers::uniform_grid;
1690        let t = uniform_grid(n_pts);
1691        let vals: Vec<f64> = t.to_vec();
1692        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, n_pts).unwrap();
1693        (mat, t)
1694    }
1695
1696    #[test]
1697    fn test_extrapolation_boundary() {
1698        let (data, t) = make_linear_curve(11); // y=x on [0,1]
1699                                               // Query at t=-0.2 (below) and t=1.3 (above)
1700        let q = vec![-0.2_f64, 0.5, 1.3];
1701        let result = fdata_interpolate_with_policy(
1702            &data,
1703            &t,
1704            &q,
1705            InterpolationMethod::Linear,
1706            ExtrapolationPolicy::Boundary,
1707        )
1708        .unwrap();
1709        // Clamped to t_min=0.0 → y=0.0
1710        assert!(
1711            (result[(0, 0)] - 0.0).abs() < 1e-10,
1712            "below boundary should clamp to 0"
1713        );
1714        // In-range: y=0.5
1715        assert!(
1716            (result[(0, 1)] - 0.5).abs() < 1e-10,
1717            "in-range should interpolate correctly"
1718        );
1719        // Clamped to t_max=1.0 → y=1.0
1720        assert!(
1721            (result[(0, 2)] - 1.0).abs() < 1e-10,
1722            "above boundary should clamp to 1"
1723        );
1724    }
1725
1726    #[test]
1727    fn test_extrapolation_exception() {
1728        let (data, t) = make_linear_curve(11);
1729        let q_bad = vec![1.5_f64]; // out of range
1730        let err = fdata_interpolate_with_policy(
1731            &data,
1732            &t,
1733            &q_bad,
1734            InterpolationMethod::Linear,
1735            ExtrapolationPolicy::Exception,
1736        )
1737        .unwrap_err();
1738        assert!(
1739            matches!(
1740                err,
1741                crate::FdarError::InvalidParameter {
1742                    parameter: "new_argvals",
1743                    ..
1744                }
1745            ),
1746            "expected InvalidParameter for OOB query, got {err:?}"
1747        );
1748
1749        // In-range should still work with Exception policy
1750        let q_ok = vec![0.0_f64, 0.5, 1.0];
1751        let result = fdata_interpolate_with_policy(
1752            &data,
1753            &t,
1754            &q_ok,
1755            InterpolationMethod::Linear,
1756            ExtrapolationPolicy::Exception,
1757        )
1758        .unwrap();
1759        assert!((result[(0, 1)] - 0.5).abs() < 1e-10);
1760    }
1761
1762    #[test]
1763    fn test_extrapolation_fill() {
1764        let (data, t) = make_linear_curve(11);
1765        let fill_val = 99.0_f64;
1766        let q = vec![-0.5_f64, 0.5, 2.0];
1767        let result = fdata_interpolate_with_policy(
1768            &data,
1769            &t,
1770            &q,
1771            InterpolationMethod::Linear,
1772            ExtrapolationPolicy::Fill(fill_val),
1773        )
1774        .unwrap();
1775        assert!(
1776            (result[(0, 0)] - fill_val).abs() < 1e-10,
1777            "below range should be fill value"
1778        );
1779        assert!(
1780            (result[(0, 1)] - 0.5).abs() < 1e-10,
1781            "in-range should interpolate"
1782        );
1783        assert!(
1784            (result[(0, 2)] - fill_val).abs() < 1e-10,
1785            "above range should be fill value"
1786        );
1787    }
1788
1789    #[test]
1790    fn test_extrapolation_periodic() {
1791        let (data, t) = make_linear_curve(11); // y=x on [0,1], domain_len=1
1792                                               // t = -0.1 should wrap to 0.9 (y=0.9)
1793        let q = vec![-0.1_f64, 0.5, 1.1];
1794        let result = fdata_interpolate_with_policy(
1795            &data,
1796            &t,
1797            &q,
1798            InterpolationMethod::Linear,
1799            ExtrapolationPolicy::Periodic,
1800        )
1801        .unwrap();
1802        // wrapped: ((−0.1 − 0) % 1 + 1) % 1 = (−0.1 + 1) % 1 = 0.9 % 1 = 0.9
1803        assert!(
1804            (result[(0, 0)] - 0.9).abs() < 1e-9,
1805            "t=-0.1 should wrap to 0.9, got {}",
1806            result[(0, 0)]
1807        );
1808        assert!(
1809            (result[(0, 1)] - 0.5).abs() < 1e-10,
1810            "in-range point unchanged"
1811        );
1812        // t=1.1 → ((1.1 − 0) % 1 + 1) % 1 = (0.1 + 1) % 1 = 0.1
1813        assert!(
1814            (result[(0, 2)] - 0.1).abs() < 1e-9,
1815            "t=1.1 should wrap to 0.1, got {}",
1816            result[(0, 2)]
1817        );
1818    }
1819
1820    #[test]
1821    fn test_extrapolation_in_range_equivalence() {
1822        // In-range queries must match fdata_interpolate exactly
1823        let (data, t) = make_linear_curve(21);
1824        let q: Vec<f64> = (0..=10).map(|i| i as f64 / 10.0).collect();
1825        let expected = fdata_interpolate(&data, &t, &q, InterpolationMethod::Linear);
1826        let actual = fdata_interpolate_with_policy(
1827            &data,
1828            &t,
1829            &q,
1830            InterpolationMethod::Linear,
1831            ExtrapolationPolicy::Boundary,
1832        )
1833        .unwrap();
1834        let (_, m_new) = actual.shape();
1835        for j in 0..m_new {
1836            assert!(
1837                (actual[(0, j)] - expected[(0, j)]).abs() < 1e-12,
1838                "in-range mismatch at j={j}: policy={} vs plain={}",
1839                actual[(0, j)],
1840                expected[(0, j)]
1841            );
1842        }
1843    }
1844
1845    #[test]
1846    fn test_extrapolation_policy_dim_guard() {
1847        let (data, _t) = make_linear_curve(11);
1848        let bad_argvals: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect(); // len=5, ncols=11
1849        let q = vec![0.5_f64];
1850        let err = fdata_interpolate_with_policy(
1851            &data,
1852            &bad_argvals,
1853            &q,
1854            InterpolationMethod::Linear,
1855            ExtrapolationPolicy::Boundary,
1856        )
1857        .unwrap_err();
1858        assert!(
1859            matches!(
1860                err,
1861                crate::FdarError::InvalidDimension {
1862                    parameter: "argvals",
1863                    ..
1864                }
1865            ),
1866            "expected InvalidDimension for argvals mismatch, got {err:?}"
1867        );
1868    }
1869
1870    // ── ImputationMethod / impute_missing_values tests ────────────────────
1871
1872    /// Build a 1-curve FdMatrix from given values and a uniform grid.
1873    fn make_curve_with_vals(vals: Vec<f64>) -> (crate::matrix::FdMatrix, Vec<f64>) {
1874        let m = vals.len();
1875        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
1876        let mat = crate::matrix::FdMatrix::from_column_major(vals, 1, m).unwrap();
1877        (mat, argvals)
1878    }
1879
1880    #[test]
1881    fn test_impute_linear() {
1882        // Curve: [0.0, NaN, 1.0] on argvals [0.0, 0.5, 1.0]
1883        // Linear between (0,0.0) and (1.0,1.0): at t=0.5 → 0.5
1884        let (data, argvals) = make_curve_with_vals(vec![0.0_f64, f64::NAN, 1.0]);
1885        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1886        // Hand-computed: linear_interp([0.0,1.0],[0.0,1.0],0.5) = 0.5
1887        assert!(
1888            (result[(0, 1)] - 0.5).abs() < 1e-10,
1889            "linear imputation should give 0.5, got {}",
1890            result[(0, 1)]
1891        );
1892        // Non-NaN entries unchanged
1893        assert!((result[(0, 0)] - 0.0).abs() < 1e-10);
1894        assert!((result[(0, 2)] - 1.0).abs() < 1e-10);
1895    }
1896
1897    #[test]
1898    fn test_impute_mean() {
1899        // Curve: [1.0, NaN, 3.0] → mean of non-NaN = 2.0
1900        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1901        let result = impute_missing_values(&data, &argvals, ImputationMethod::Mean).unwrap();
1902        assert!(
1903            (result[(0, 1)] - 2.0).abs() < 1e-10,
1904            "mean imputation should give 2.0, got {}",
1905            result[(0, 1)]
1906        );
1907    }
1908
1909    #[test]
1910    fn test_impute_constant() {
1911        // Curve: [1.0, NaN, 3.0] → constant 99.0
1912        let (data, argvals) = make_curve_with_vals(vec![1.0_f64, f64::NAN, 3.0]);
1913        let result =
1914            impute_missing_values(&data, &argvals, ImputationMethod::Constant(99.0)).unwrap();
1915        assert!(
1916            (result[(0, 1)] - 99.0).abs() < 1e-10,
1917            "constant imputation should give 99.0, got {}",
1918            result[(0, 1)]
1919        );
1920        // Non-NaN entries unchanged
1921        assert!((result[(0, 0)] - 1.0).abs() < 1e-10);
1922        assert!((result[(0, 2)] - 3.0).abs() < 1e-10);
1923    }
1924
1925    #[test]
1926    fn test_impute_all_nan() {
1927        // An all-NaN curve should return Err(InvalidParameter)
1928        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, f64::NAN, f64::NAN]);
1929        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
1930        assert!(
1931            matches!(
1932                err,
1933                crate::FdarError::InvalidParameter {
1934                    parameter: "data",
1935                    ..
1936                }
1937            ),
1938            "expected InvalidParameter for all-NaN curve, got {err:?}"
1939        );
1940    }
1941
1942    #[test]
1943    fn test_impute_boundary_nan() {
1944        // Curve: [NaN, 0.5, 1.0] → leading NaN → boundary fill with 0.5
1945        let (data, argvals) = make_curve_with_vals(vec![f64::NAN, 0.5_f64, 1.0]);
1946        let result = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap();
1947        assert!(
1948            (result[(0, 0)] - 0.5).abs() < 1e-10,
1949            "leading NaN should be filled with nearest valid (0.5), got {}",
1950            result[(0, 0)]
1951        );
1952
1953        // Curve: [0.0, 0.5, NaN] → trailing NaN → boundary fill with 0.5
1954        let (data2, argvals2) = make_curve_with_vals(vec![0.0_f64, 0.5, f64::NAN]);
1955        let result2 = impute_missing_values(&data2, &argvals2, ImputationMethod::Linear).unwrap();
1956        assert!(
1957            (result2[(0, 2)] - 0.5).abs() < 1e-10,
1958            "trailing NaN should be filled with nearest valid (0.5), got {}",
1959            result2[(0, 2)]
1960        );
1961    }
1962
1963    // ── CR-01: Periodic + zero-length domain must error (not produce NaN) ────
1964
1965    #[test]
1966    fn test_extrapolation_periodic_zero_length_domain_errors() {
1967        // Domain [5.0, 5.0] has length 0 — Periodic would compute x % 0.0 = NaN without the guard.
1968        let degenerate_argvals = vec![5.0_f64, 5.0, 5.0];
1969        let vals = vec![1.0_f64, 1.0, 1.0];
1970        let data = crate::matrix::FdMatrix::from_column_major(vals, 1, 3).unwrap();
1971        // Any OOB query with Periodic on a zero-length domain must return Err.
1972        let q = vec![6.0_f64]; // outside [5.0, 5.0]
1973        let err = fdata_interpolate_with_policy(
1974            &data,
1975            &degenerate_argvals,
1976            &q,
1977            InterpolationMethod::Linear,
1978            ExtrapolationPolicy::Periodic,
1979        )
1980        .unwrap_err();
1981        assert!(
1982            matches!(
1983                err,
1984                crate::FdarError::InvalidParameter {
1985                    parameter: "argvals",
1986                    ..
1987                }
1988            ),
1989            "expected InvalidParameter for zero-length domain + Periodic, got {err:?}"
1990        );
1991    }
1992
1993    // ── WR-01: m=0 guard in impute_missing_values ─────────────────────────
1994
1995    #[test]
1996    fn test_impute_zero_columns_errors() {
1997        // A matrix with m=0 columns is degenerate; should return InvalidDimension, not "all-NaN".
1998        let data = crate::matrix::FdMatrix::zeros(2, 0);
1999        let argvals: Vec<f64> = vec![];
2000        let err = impute_missing_values(&data, &argvals, ImputationMethod::Linear).unwrap_err();
2001        assert!(
2002            matches!(
2003                err,
2004                crate::FdarError::InvalidDimension {
2005                    parameter: "data",
2006                    ..
2007                }
2008            ),
2009            "expected InvalidDimension for m=0 matrix, got {err:?}"
2010        );
2011    }
2012
2013    #[test]
2014    fn test_impute_dim_mismatch() {
2015        // argvals length != ncols
2016        let (data, _argvals) = make_curve_with_vals(vec![1.0, 2.0, 3.0]);
2017        let bad_argvals = vec![0.0_f64, 1.0]; // len=2, ncols=3
2018        let err = impute_missing_values(&data, &bad_argvals, ImputationMethod::Linear).unwrap_err();
2019        assert!(
2020            matches!(
2021                err,
2022                crate::FdarError::InvalidDimension {
2023                    parameter: "argvals",
2024                    ..
2025                }
2026            ),
2027            "expected InvalidDimension for argvals mismatch, got {err:?}"
2028        );
2029    }
2030}