Skip to main content

fdars_core/
fdata.rs

1//! Functional data operations: mean, center, derivatives, norms, and geometric median.
2
3use crate::dim::Dim;
4use crate::error::FdarError;
5use crate::helpers::{simpsons_weights, simpsons_weights_2d, NUMERICAL_EPS};
6use crate::iter_maybe_parallel;
7use crate::matrix::FdMatrix;
8#[cfg(feature = "parallel")]
9use rayon::iter::ParallelIterator;
10
11/// Compute finite difference for a 1D function at a given index.
12///
13/// Uses forward difference at left boundary, backward difference at right boundary,
14/// and central difference for interior points.
15fn finite_diff_1d(
16    values: impl Fn(usize) -> f64,
17    idx: usize,
18    n_points: usize,
19    step_sizes: &[f64],
20) -> f64 {
21    if idx == 0 {
22        (values(1) - values(0)) / step_sizes[0]
23    } else if idx == n_points - 1 {
24        (values(n_points - 1) - values(n_points - 2)) / step_sizes[n_points - 1]
25    } else {
26        (values(idx + 1) - values(idx - 1)) / step_sizes[idx]
27    }
28}
29
30/// Compute 2D partial derivatives at a single grid point.
31///
32/// Returns (∂f/∂s, ∂f/∂t, ∂²f/∂s∂t) using finite differences.
33fn compute_2d_derivatives(
34    get_val: impl Fn(usize, usize) -> f64,
35    si: usize,
36    ti: usize,
37    m1: usize,
38    m2: usize,
39    hs: &[f64],
40    ht: &[f64],
41) -> (f64, f64, f64) {
42    // ∂f/∂s
43    let ds = finite_diff_1d(|s| get_val(s, ti), si, m1, hs);
44
45    // ∂f/∂t
46    let dt = finite_diff_1d(|t| get_val(si, t), ti, m2, ht);
47
48    // ∂²f/∂s∂t (mixed partial)
49    let denom = hs[si] * ht[ti];
50
51    // Get the appropriate indices for s and t differences
52    let (s_lo, s_hi) = if si == 0 {
53        (0, 1)
54    } else if si == m1 - 1 {
55        (m1 - 2, m1 - 1)
56    } else {
57        (si - 1, si + 1)
58    };
59
60    let (t_lo, t_hi) = if ti == 0 {
61        (0, 1)
62    } else if ti == m2 - 1 {
63        (m2 - 2, m2 - 1)
64    } else {
65        (ti - 1, ti + 1)
66    };
67
68    let dsdt = (get_val(s_hi, t_hi) - get_val(s_lo, t_hi) - get_val(s_hi, t_lo)
69        + get_val(s_lo, t_lo))
70        / denom;
71
72    (ds, dt, dsdt)
73}
74
75/// Perform Weiszfeld iteration to compute geometric median.
76///
77/// This is the core algorithm shared by 1D and 2D geometric median computations.
78fn weiszfeld_iteration(data: &FdMatrix, weights: &[f64], max_iter: usize, tol: f64) -> Vec<f64> {
79    let (n, m) = data.shape();
80
81    // Initialize with the mean
82    let mut median: Vec<f64> = (0..m)
83        .map(|j| {
84            let col = data.column(j);
85            col.iter().sum::<f64>() / n as f64
86        })
87        .collect();
88
89    for _ in 0..max_iter {
90        // Compute distances from current median to all curves
91        let distances: Vec<f64> = (0..n)
92            .map(|i| {
93                let mut dist_sq = 0.0;
94                for j in 0..m {
95                    let diff = data[(i, j)] - median[j];
96                    dist_sq += diff * diff * weights[j];
97                }
98                dist_sq.sqrt()
99            })
100            .collect();
101
102        // Compute weights (1/distance), handling zero distances
103        let inv_distances: Vec<f64> = distances
104            .iter()
105            .map(|d| {
106                if *d > NUMERICAL_EPS {
107                    1.0 / d
108                } else {
109                    1.0 / NUMERICAL_EPS
110                }
111            })
112            .collect();
113
114        let sum_inv_dist: f64 = inv_distances.iter().sum();
115
116        // Update median using Weiszfeld iteration
117        let new_median: Vec<f64> = (0..m)
118            .map(|j| {
119                let mut weighted_sum = 0.0;
120                for i in 0..n {
121                    weighted_sum += data[(i, j)] * inv_distances[i];
122                }
123                weighted_sum / sum_inv_dist
124            })
125            .collect();
126
127        // Check convergence
128        let diff: f64 = median
129            .iter()
130            .zip(new_median.iter())
131            .map(|(a, b)| (a - b).abs())
132            .sum::<f64>()
133            / m as f64;
134
135        median = new_median;
136
137        if diff < tol {
138            break;
139        }
140    }
141
142    median
143}
144
145/// Compute the mean function across all samples (1D).
146///
147/// # Arguments
148/// * `data` - Functional data matrix (n x m)
149///
150/// # Returns
151/// Mean function values at each evaluation point
152///
153/// # Examples
154///
155/// ```
156/// use fdars_core::matrix::FdMatrix;
157/// use fdars_core::fdata::mean_1d;
158///
159/// // 3 curves at 4 evaluation points
160/// let data = FdMatrix::from_column_major(
161///     vec![1.0, 2.0, 3.0,  4.0, 5.0, 6.0,  7.0, 8.0, 9.0,  10.0, 11.0, 12.0],
162///     3, 4,
163/// ).unwrap();
164/// let mean = mean_1d(&data);
165/// assert_eq!(mean.len(), 4);
166/// assert!((mean[0] - 2.0).abs() < 1e-10); // mean of [1, 2, 3]
167/// ```
168pub fn mean_1d(data: &FdMatrix) -> Vec<f64> {
169    let (n, m) = data.shape();
170    if n == 0 || m == 0 {
171        return Vec::new();
172    }
173
174    iter_maybe_parallel!(0..m)
175        .map(|j| {
176            let col = data.column(j);
177            col.iter().sum::<f64>() / n as f64
178        })
179        .collect()
180}
181
182/// Compute the mean function for 1D or 2D functional data via a unified [`Dim`] dispatch.
183///
184/// The 2D path never diverged from the 1D one (both compute the pointwise mean
185/// over the flattened column-major grid), so both [`Dim`] arms forward to
186/// [`mean_1d`]. The `dim` argument makes caller intent explicit and provides a
187/// single future seam should a real 2D specialization ever be needed.
188///
189/// # Arguments
190/// * `data` - Functional data matrix (n x m)
191/// * `dim` - Dimensionality selector ([`Dim::One`] or [`Dim::Two`])
192#[must_use = "expensive computation whose result should not be discarded"]
193pub fn mean(data: &FdMatrix, dim: Dim) -> Vec<f64> {
194    match dim {
195        Dim::One | Dim::Two => mean_1d(data),
196    }
197}
198
199/// Compute the mean function for 2D surfaces.
200///
201/// Data is stored as n x (m1*m2) matrix where each row is a flattened surface.
202#[deprecated(
203    since = "0.30.0",
204    note = "redundant with `mean(…, Dim::Two)`; body just forwards to `mean_1d`"
205)]
206pub fn mean_2d(data: &FdMatrix) -> Vec<f64> {
207    // Same computation as 1D - just compute pointwise mean
208    mean_1d(data)
209}
210
211/// Center functional data by subtracting the mean function.
212///
213/// # Arguments
214/// * `data` - Functional data matrix (n x m)
215///
216/// # Returns
217/// Centered data matrix
218///
219/// # Examples
220///
221/// ```
222/// use fdars_core::matrix::FdMatrix;
223/// use fdars_core::fdata::{center_1d, mean_1d};
224///
225/// let data = FdMatrix::from_column_major(
226///     vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0], 2, 3,
227/// ).unwrap();
228/// let centered = center_1d(&data);
229/// assert_eq!(centered.shape(), (2, 3));
230/// // Column means of centered data should be zero
231/// let means = mean_1d(&centered);
232/// assert!(means.iter().all(|m| m.abs() < 1e-10));
233/// ```
234pub fn center_1d(data: &FdMatrix) -> FdMatrix {
235    let (n, m) = data.shape();
236    if n == 0 || m == 0 {
237        return FdMatrix::zeros(0, 0);
238    }
239
240    // First compute the mean for each column (parallelized)
241    let means: Vec<f64> = iter_maybe_parallel!(0..m)
242        .map(|j| {
243            let col = data.column(j);
244            col.iter().sum::<f64>() / n as f64
245        })
246        .collect();
247
248    // Create centered data
249    let mut centered = FdMatrix::zeros(n, m);
250    for j in 0..m {
251        let col = centered.column_mut(j);
252        let src = data.column(j);
253        for i in 0..n {
254            col[i] = src[i] - means[j];
255        }
256    }
257
258    centered
259}
260
261/// Compute pointwise sample variance of functional data (Bessel-corrected, ddof = n-1).
262///
263/// For each evaluation point j, computes the sample variance across the n curves:
264/// `var[j] = sum_i (data[(i,j)] - mean[j])^2 / (n - 1)`
265///
266/// This is a plain pointwise statistic (no integration weights), matching
267/// `FDataGrid.var()` in scikit-fda.
268///
269/// # Arguments
270/// * `data` - Functional data matrix (n x m), requires n >= 2.
271///
272/// # Returns
273/// Length-m vector of pointwise sample variances.
274///
275/// # Errors
276/// Returns [`FdarError::InvalidDimension`] if `n < 2` (Bessel correction requires at least
277/// two observations).
278///
279/// # Examples
280///
281/// ```
282/// use fdars_core::matrix::FdMatrix;
283/// use fdars_core::fdata::functional_variance;
284///
285/// let data = FdMatrix::from_column_major(vec![1.0, 3.0, 4.0, 2.0], 2, 2).unwrap();
286/// let var = functional_variance(&data).unwrap();
287/// assert_eq!(var.len(), 2);
288/// assert!((var[0] - 2.0).abs() < 1e-10); // Bessel-corrected variance
289/// ```
290pub fn functional_variance(data: &FdMatrix) -> Result<Vec<f64>, FdarError> {
291    let (n, m) = data.shape();
292    if n < 2 {
293        return Err(FdarError::InvalidDimension {
294            parameter: "data",
295            expected: ">= 2 rows".to_string(),
296            actual: n.to_string(),
297        });
298    }
299    let means = mean_1d(data);
300    let var: Vec<f64> = (0..m)
301        .map(|j| {
302            let col = data.column(j);
303            let mu = means[j];
304            col.iter().map(|&x| (x - mu).powi(2)).sum::<f64>() / (n - 1) as f64
305        })
306        .collect();
307    Ok(var)
308}
309
310/// Compute pointwise sample standard deviation of functional data (ddof = n-1).
311///
312/// Delegates to [`functional_variance`] so that `functional_std(data)[j]^2 ==
313/// functional_variance(data)[j]` holds by construction.
314///
315/// # Arguments
316/// * `data` - Functional data matrix (n x m), requires n >= 2.
317///
318/// # Returns
319/// Length-m vector of pointwise sample standard deviations.
320///
321/// # Errors
322/// Returns [`FdarError::InvalidDimension`] if `n < 2`.
323///
324/// # Examples
325///
326/// ```
327/// use fdars_core::matrix::FdMatrix;
328/// use fdars_core::fdata::{functional_std, functional_variance};
329///
330/// let data = FdMatrix::from_column_major(vec![1.0, 3.0, 4.0, 2.0], 2, 2).unwrap();
331/// let std = functional_std(&data).unwrap();
332/// let var = functional_variance(&data).unwrap();
333/// // std^2 == var pointwise
334/// for j in 0..2 {
335///     assert!((std[j].powi(2) - var[j]).abs() < 1e-10);
336/// }
337/// ```
338pub fn functional_std(data: &FdMatrix) -> Result<Vec<f64>, FdarError> {
339    Ok(functional_variance(data)?
340        .iter()
341        .map(|v| v.sqrt())
342        .collect())
343}
344
345/// Compute the M×M sample covariance matrix of functional data (Bessel-corrected, ddof = n-1).
346///
347/// For each pair of evaluation points `(j1, j2)`, computes the sample covariance across
348/// the n curves:
349/// `cov[j1, j2] = sum_i (data[(i,j1)] - mean[j1]) * (data[(i,j2)] - mean[j2]) / (n - 1)`
350///
351/// The diagonal equals `functional_variance(data)` pointwise. The result is a symmetric
352/// M×M [`FdMatrix`] stored in column-major order.
353///
354/// This is an O(n·m²) operation — may be expensive for large m.
355///
356/// # Arguments
357/// * `data` - Functional data matrix (n x m), requires n >= 2.
358///
359/// # Returns
360/// M×M sample covariance [`FdMatrix`].
361///
362/// # Errors
363/// Returns [`FdarError::InvalidDimension`] if `n < 2`, or
364/// [`FdarError::InvalidParameter`] if `m * m` overflows `usize`.
365///
366/// # Examples
367///
368/// ```
369/// use fdars_core::matrix::FdMatrix;
370/// use fdars_core::fdata::{functional_covariance, functional_variance};
371///
372/// let data = FdMatrix::from_column_major(vec![1.0, 3.0, 4.0, 2.0], 2, 2).unwrap();
373/// let cov = functional_covariance(&data).unwrap();
374/// assert_eq!(cov.shape(), (2, 2));
375/// let var = functional_variance(&data).unwrap();
376/// // Diagonal matches variance
377/// assert!((cov[(0, 0)] - var[0]).abs() < 1e-10);
378/// assert!((cov[(1, 1)] - var[1]).abs() < 1e-10);
379/// ```
380pub fn functional_covariance(data: &FdMatrix) -> Result<FdMatrix, FdarError> {
381    let (n, m) = data.shape();
382    if n < 2 {
383        return Err(FdarError::InvalidDimension {
384            parameter: "data",
385            expected: ">= 2 rows".to_string(),
386            actual: n.to_string(),
387        });
388    }
389    // Guard against usize overflow in M×M allocation (threat T-10-02-04)
390    m.checked_mul(m)
391        .ok_or_else(|| FdarError::InvalidParameter {
392            parameter: "data",
393            message: format!(
394                "m={m} is too large: m*m would overflow usize (max {})",
395                usize::MAX
396            ),
397        })?;
398
399    let centered = center_1d(data);
400    let mut cov = FdMatrix::zeros(m, m);
401    let denom = (n - 1) as f64;
402    for j1 in 0..m {
403        let col1 = centered.column(j1);
404        for j2 in j1..m {
405            let col2 = centered.column(j2);
406            let val: f64 = col1
407                .iter()
408                .zip(col2.iter())
409                .map(|(&a, &b)| a * b)
410                .sum::<f64>()
411                / denom;
412            cov[(j1, j2)] = val;
413            cov[(j2, j1)] = val; // symmetric
414        }
415    }
416    Ok(cov)
417}
418
419/// Return the index of the deepest curve under the Fraiman-Muniz depth measure.
420///
421/// Computes self-depth scores (`fraiman_muniz_1d(data, data, true)`) and returns the
422/// index `i*` of the curve with the maximum depth — the functional analog of the
423/// depth-based median. The curve itself can be retrieved with `data.row(i*)` or
424/// `data[(i*, j)]`.
425///
426/// # Arguments
427/// * `data` - Functional data matrix (n x m), requires n >= 1.
428///
429/// # Returns
430/// Index of the deepest curve.
431///
432/// # Errors
433/// Returns [`FdarError::InvalidDimension`] if `n == 0`, or
434/// [`FdarError::ComputationFailed`] if the depth vector is empty (should not occur with n >= 1).
435///
436/// # Examples
437///
438/// ```
439/// use fdars_core::matrix::FdMatrix;
440/// use fdars_core::fdata::depth_based_median;
441///
442/// // 3 curves on 5 points; the middle curve (index 1) is most central
443/// let data = FdMatrix::from_column_major(
444///     vec![0.0, 0.5, 1.0, 0.0, 0.5, 1.0, 0.0, 0.5, 1.0, 0.0, 0.5, 1.0, 0.0, 0.5, 1.0],
445///     3, 5,
446/// ).unwrap();
447/// let idx = depth_based_median(&data).unwrap();
448/// assert_eq!(idx, 1);
449/// ```
450pub fn depth_based_median(data: &FdMatrix) -> Result<usize, FdarError> {
451    let (n, _) = data.shape();
452    if n == 0 {
453        return Err(FdarError::InvalidDimension {
454            parameter: "data",
455            expected: ">= 1 row".to_string(),
456            actual: "0".to_string(),
457        });
458    }
459    let depths = crate::depth::fraiman_muniz_1d(data, data, true);
460    depths
461        .iter()
462        .enumerate()
463        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
464        .map(|(i, _)| i)
465        .ok_or_else(|| FdarError::ComputationFailed {
466            operation: "depth_based_median",
467            detail: "depth vector is empty".to_string(),
468        })
469}
470
471/// Compute the depth-trimmed mean of functional data.
472///
473/// Excludes the `floor(alpha * n)` least-deep curves (by Fraiman-Muniz depth) and
474/// returns the pointwise mean of the remaining curves. With `alpha = 0`, all curves
475/// are retained and the result equals [`mean_1d`] exactly.
476///
477/// # Arguments
478/// * `data` - Functional data matrix (n x m), requires n >= 1.
479/// * `alpha` - Trimming fraction in `[0, 1)`. A value of `alpha = 0.2` removes the
480///   20% least-deep curves before averaging.
481///
482/// # Returns
483/// Length-m vector of trimmed pointwise mean values.
484///
485/// # Errors
486/// Returns [`FdarError::InvalidParameter`] if `alpha` is not in `[0, 1)`,
487/// or [`FdarError::InvalidDimension`] if `n == 0`.
488///
489/// # Examples
490///
491/// ```
492/// use fdars_core::matrix::FdMatrix;
493/// use fdars_core::fdata::{trim_mean, mean_1d};
494///
495/// let data = FdMatrix::from_column_major(
496///     vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 3, 2,
497/// ).unwrap();
498/// // alpha=0 => no trimming => equals mean
499/// let tm = trim_mean(&data, 0.0).unwrap();
500/// let mu = mean_1d(&data);
501/// for j in 0..2 {
502///     assert!((tm[j] - mu[j]).abs() < 1e-10);
503/// }
504/// ```
505pub fn trim_mean(data: &FdMatrix, alpha: f64) -> Result<Vec<f64>, FdarError> {
506    if !(0.0..1.0).contains(&alpha) {
507        return Err(FdarError::InvalidParameter {
508            parameter: "alpha",
509            message: format!("must be in [0, 1), got {alpha}"),
510        });
511    }
512    let (n, m) = data.shape();
513    if n == 0 {
514        return Err(FdarError::InvalidDimension {
515            parameter: "data",
516            expected: ">= 1 row".to_string(),
517            actual: "0".to_string(),
518        });
519    }
520
521    // Compute self-depth for all curves
522    let depths = crate::depth::fraiman_muniz_1d(data, data, true);
523
524    // Determine how many curves to drop (least-deep)
525    let k = (alpha * n as f64).floor() as usize;
526
527    // Sort indices by descending depth; retain the n-k deepest
528    let mut indices: Vec<usize> = (0..n).collect();
529    indices.sort_unstable_by(|&a, &b| {
530        depths[b]
531            .partial_cmp(&depths[a])
532            .unwrap_or(std::cmp::Ordering::Equal)
533    });
534    let retained = &indices[..n - k];
535
536    // Compute pointwise mean over retained curves
537    let n_ret = retained.len() as f64;
538    let mean: Vec<f64> = (0..m)
539        .map(|j| retained.iter().map(|&i| data[(i, j)]).sum::<f64>() / n_ret)
540        .collect();
541
542    Ok(mean)
543}
544
545/// Normalization method for functional data.
546#[derive(Debug, Clone, Copy, PartialEq)]
547#[non_exhaustive]
548pub enum NormalizationMethod {
549    /// Center columns (subtract per-time-point mean across curves).
550    Center,
551    /// Autoscale columns (center + divide by per-time-point std dev). UV scaling.
552    Autoscale,
553    /// Pareto scaling (center + divide by sqrt of per-time-point std dev).
554    Pareto,
555    /// Range scaling (center + divide by per-time-point range).
556    Range,
557    /// Per-curve centering (subtract each curve's own mean).
558    CurveCenter,
559    /// Per-curve standardization (subtract mean, divide by std dev per curve).
560    CurveStandardize,
561    /// Per-curve range normalization to [0, 1].
562    CurveRange,
563    /// Per-curve Lp normalization: divide each curve by its Lp norm.
564    ///
565    /// Common choices: `p = 1.0` (L1), `p = 2.0` (L2 / unit sphere),
566    /// `p = f64::INFINITY` (L-inf / max-norm). Requires `argvals` for
567    /// integration — use [`normalize_with_argvals`] instead of [`normalize`].
568    CurveLp(f64),
569}
570
571/// Normalize functional data using the specified method.
572///
573/// **Column-wise methods** (across curves at each time point):
574/// - `Center`: subtract column means (same as [`center_1d`])
575/// - `Autoscale`: center + divide by column std dev (unit variance per time point)
576/// - `Pareto`: center + divide by sqrt(column std dev)
577/// - `Range`: center + divide by column range (max - min)
578///
579/// **Row-wise methods** (per curve):
580/// - `CurveCenter`: subtract each curve's own mean
581/// - `CurveStandardize`: subtract mean, divide by std dev per curve
582/// - `CurveRange`: scale each curve to [0, 1]
583/// - `CurveLp(p)`: divide each curve by its Lp norm — requires `argvals`,
584///   use [`normalize_with_argvals`] instead
585///
586/// # Panics
587///
588/// Panics if `CurveLp` is used without argvals. Use [`normalize_with_argvals`]
589/// for Lp normalization.
590///
591/// # Examples
592///
593/// ```
594/// use fdars_core::matrix::FdMatrix;
595/// use fdars_core::fdata::{normalize, NormalizationMethod};
596///
597/// let data = FdMatrix::from_column_major(
598///     vec![1.0, 3.0, 2.0, 6.0, 3.0, 9.0], 2, 3,
599/// ).unwrap();
600///
601/// // Autoscale: zero mean, unit variance per time point
602/// let scaled = normalize(&data, NormalizationMethod::Autoscale);
603/// assert_eq!(scaled.shape(), (2, 3));
604/// ```
605pub fn normalize(data: &FdMatrix, method: NormalizationMethod) -> FdMatrix {
606    match method {
607        NormalizationMethod::CurveLp(_) => {
608            panic!("CurveLp requires argvals — use normalize_with_argvals()")
609        }
610        _ => {
611            let argvals: Vec<f64> = (0..data.ncols())
612                .map(|j| j as f64 / (data.ncols() - 1).max(1) as f64)
613                .collect();
614            normalize_with_argvals(data, &argvals, method)
615        }
616    }
617}
618
619/// Normalize functional data with an evaluation grid.
620///
621/// Same as [`normalize`] but accepts `argvals` for integration-based methods
622/// (`CurveLp`). For non-Lp methods, `argvals` is ignored.
623///
624/// # Examples
625///
626/// ```
627/// use fdars_core::matrix::FdMatrix;
628/// use fdars_core::fdata::{normalize_with_argvals, NormalizationMethod};
629///
630/// let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
631/// let t = vec![0.0, 1.0];
632///
633/// // L2 normalization: each curve has unit L2 norm
634/// let l2 = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
635/// assert_eq!(l2.shape(), (2, 2));
636/// ```
637pub fn normalize_with_argvals(
638    data: &FdMatrix,
639    argvals: &[f64],
640    method: NormalizationMethod,
641) -> FdMatrix {
642    let (n, m) = data.shape();
643    if n == 0 || m == 0 {
644        return FdMatrix::zeros(n, m);
645    }
646
647    match method {
648        NormalizationMethod::Center => center_1d(data),
649        NormalizationMethod::Autoscale => column_scale(data, n, m, ScaleKind::StdDev),
650        NormalizationMethod::Pareto => column_scale(data, n, m, ScaleKind::SqrtStdDev),
651        NormalizationMethod::Range => column_scale(data, n, m, ScaleKind::Range),
652        NormalizationMethod::CurveCenter => row_normalize(data, n, m, RowNorm::Center),
653        NormalizationMethod::CurveStandardize => row_normalize(data, n, m, RowNorm::Standardize),
654        NormalizationMethod::CurveRange => row_normalize(data, n, m, RowNorm::Range),
655        NormalizationMethod::CurveLp(p) => curve_lp_normalize(data, argvals, n, m, p),
656    }
657}
658
659#[derive(Clone, Copy)]
660enum ScaleKind {
661    StdDev,
662    SqrtStdDev,
663    Range,
664}
665
666fn column_scale(data: &FdMatrix, n: usize, m: usize, kind: ScaleKind) -> FdMatrix {
667    let mut result = FdMatrix::zeros(n, m);
668    for j in 0..m {
669        let col = data.column(j);
670        let mean = col.iter().sum::<f64>() / n as f64;
671        let scale = match kind {
672            ScaleKind::StdDev => {
673                let var =
674                    col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
675                var.sqrt()
676            }
677            ScaleKind::SqrtStdDev => {
678                let var =
679                    col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
680                var.sqrt().sqrt()
681            }
682            ScaleKind::Range => {
683                let min = col.iter().copied().fold(f64::INFINITY, f64::min);
684                let max = col.iter().copied().fold(f64::NEG_INFINITY, f64::max);
685                max - min
686            }
687        };
688        let out = result.column_mut(j);
689        let denom = if scale > 1e-15 { scale } else { 1.0 };
690        for i in 0..n {
691            out[i] = (col[i] - mean) / denom;
692        }
693    }
694    result
695}
696
697#[derive(Clone, Copy)]
698enum RowNorm {
699    Center,
700    Standardize,
701    Range,
702}
703
704fn row_normalize(data: &FdMatrix, n: usize, m: usize, kind: RowNorm) -> FdMatrix {
705    let mut result = FdMatrix::zeros(n, m);
706    for i in 0..n {
707        let row: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
708        let mean = row.iter().sum::<f64>() / m as f64;
709        match kind {
710            RowNorm::Center => {
711                for j in 0..m {
712                    result[(i, j)] = row[j] - mean;
713                }
714            }
715            RowNorm::Standardize => {
716                let std = (row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>()
717                    / (m - 1).max(1) as f64)
718                    .sqrt();
719                let denom = if std > 1e-15 { std } else { 1.0 };
720                for j in 0..m {
721                    result[(i, j)] = (row[j] - mean) / denom;
722                }
723            }
724            RowNorm::Range => {
725                let min = row.iter().copied().fold(f64::INFINITY, f64::min);
726                let max = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
727                let range = max - min;
728                let denom = if range > 1e-15 { range } else { 1.0 };
729                for j in 0..m {
730                    result[(i, j)] = (row[j] - min) / denom;
731                }
732            }
733        }
734    }
735    result
736}
737
738/// Per-curve Lp normalization: divide each curve by its Lp norm.
739fn curve_lp_normalize(data: &FdMatrix, argvals: &[f64], n: usize, m: usize, p: f64) -> FdMatrix {
740    let mut result = FdMatrix::zeros(n, m);
741    if p.is_infinite() {
742        // L-infinity: divide by max|f(t)|
743        for i in 0..n {
744            let max_abs = (0..m).map(|j| data[(i, j)].abs()).fold(0.0f64, f64::max);
745            let denom = if max_abs > 1e-15 { max_abs } else { 1.0 };
746            for j in 0..m {
747                result[(i, j)] = data[(i, j)] / denom;
748            }
749        }
750    } else {
751        let norms = norm_lp_1d(data, argvals, p);
752        for i in 0..n {
753            let denom = if norms[i] > 1e-15 { norms[i] } else { 1.0 };
754            for j in 0..m {
755                result[(i, j)] = data[(i, j)] / denom;
756            }
757        }
758    }
759    result
760}
761
762/// Compute Lp norm for each sample.
763///
764/// # Arguments
765/// * `data` - Functional data matrix (n x m)
766/// * `argvals` - Evaluation points for integration
767/// * `p` - Order of the norm (e.g., 2.0 for L2)
768///
769/// # Returns
770/// Vector of Lp norms for each sample
771pub fn norm_lp_1d(data: &FdMatrix, argvals: &[f64], p: f64) -> Vec<f64> {
772    let (n, m) = data.shape();
773    if n == 0 || m == 0 || argvals.len() != m {
774        return Vec::new();
775    }
776
777    let weights = simpsons_weights(argvals);
778
779    if (p - 2.0).abs() < 1e-14 {
780        iter_maybe_parallel!(0..n)
781            .map(|i| {
782                let mut integral = 0.0;
783                for j in 0..m {
784                    let v = data[(i, j)];
785                    integral += v * v * weights[j];
786                }
787                integral.sqrt()
788            })
789            .collect()
790    } else if (p - 1.0).abs() < 1e-14 {
791        iter_maybe_parallel!(0..n)
792            .map(|i| {
793                let mut integral = 0.0;
794                for j in 0..m {
795                    integral += data[(i, j)].abs() * weights[j];
796                }
797                integral
798            })
799            .collect()
800    } else {
801        iter_maybe_parallel!(0..n)
802            .map(|i| {
803                let mut integral = 0.0;
804                for j in 0..m {
805                    integral += data[(i, j)].abs().powf(p) * weights[j];
806                }
807                integral.powf(1.0 / p)
808            })
809            .collect()
810    }
811}
812
813/// Compute numerical derivative of functional data (parallelized over rows).
814///
815/// # Arguments
816/// * `data` - Functional data matrix (n x m)
817/// * `argvals` - Evaluation points
818/// * `nderiv` - Order of derivative
819///
820/// # Returns
821/// Derivative data matrix
822///
823/// # Examples
824///
825/// ```
826/// use fdars_core::matrix::FdMatrix;
827/// use fdars_core::fdata::deriv_1d;
828///
829/// // Linear function f(t) = t on [0, 1], derivative should be ~1
830/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
831/// let data = FdMatrix::from_column_major(argvals.clone(), 1, 20).unwrap();
832/// let deriv = deriv_1d(&data, &argvals, 1);
833/// assert_eq!(deriv.shape(), (1, 20));
834/// // Interior points should have derivative close to 1.0
835/// assert!((deriv[(0, 10)] - 1.0).abs() < 0.1);
836/// ```
837/// Compute one derivative step: forward/central/backward differences written column-wise.
838fn deriv_1d_step(
839    current: &FdMatrix,
840    n: usize,
841    m: usize,
842    h0: f64,
843    hn: f64,
844    h_central: &[f64],
845) -> FdMatrix {
846    let mut next = FdMatrix::zeros(n, m);
847    // Column 0: forward difference
848    let src_col0 = current.column(0);
849    let src_col1 = current.column(1);
850    let dst = next.column_mut(0);
851    for i in 0..n {
852        dst[i] = (src_col1[i] - src_col0[i]) / h0;
853    }
854    // Interior columns: central difference
855    for j in 1..(m - 1) {
856        let src_prev = current.column(j - 1);
857        let src_next = current.column(j + 1);
858        let dst = next.column_mut(j);
859        let h = h_central[j - 1];
860        for i in 0..n {
861            dst[i] = (src_next[i] - src_prev[i]) / h;
862        }
863    }
864    // Column m-1: backward difference
865    let src_colm2 = current.column(m - 2);
866    let src_colm1 = current.column(m - 1);
867    let dst = next.column_mut(m - 1);
868    for i in 0..n {
869        dst[i] = (src_colm1[i] - src_colm2[i]) / hn;
870    }
871    next
872}
873
874pub fn deriv_1d(data: &FdMatrix, argvals: &[f64], nderiv: usize) -> FdMatrix {
875    let (n, m) = data.shape();
876    if n == 0 || m < 2 || argvals.len() != m {
877        return FdMatrix::zeros(n, m);
878    }
879    if nderiv == 0 {
880        return data.clone();
881    }
882
883    let mut current = data.clone();
884
885    // Pre-compute step sizes for central differences
886    let h0 = argvals[1] - argvals[0];
887    let hn = argvals[m - 1] - argvals[m - 2];
888    let h_central: Vec<f64> = (1..(m - 1))
889        .map(|j| argvals[j + 1] - argvals[j - 1])
890        .collect();
891
892    for _ in 0..nderiv {
893        current = deriv_1d_step(&current, n, m, h0, hn, &h_central);
894    }
895
896    current
897}
898
899/// Result of 2D partial derivatives.
900#[derive(Debug, Clone, PartialEq)]
901#[non_exhaustive]
902pub struct Deriv2DResult {
903    /// Partial derivative with respect to s (∂f/∂s)
904    pub ds: FdMatrix,
905    /// Partial derivative with respect to t (∂f/∂t)
906    pub dt: FdMatrix,
907    /// Mixed partial derivative (∂²f/∂s∂t)
908    pub dsdt: FdMatrix,
909}
910
911/// Compute finite-difference step sizes for a grid.
912///
913/// Uses forward/backward difference at boundaries and central difference for interior.
914fn compute_step_sizes(argvals: &[f64]) -> Vec<f64> {
915    let m = argvals.len();
916    if m < 2 {
917        return vec![1.0; m];
918    }
919    (0..m)
920        .map(|j| {
921            if j == 0 {
922                argvals[1] - argvals[0]
923            } else if j == m - 1 {
924                argvals[m - 1] - argvals[m - 2]
925            } else {
926                argvals[j + 1] - argvals[j - 1]
927            }
928        })
929        .collect()
930}
931
932/// Collect per-curve row vectors into a column-major FdMatrix.
933fn reassemble_colmajor(rows: &[Vec<f64>], n: usize, ncol: usize) -> FdMatrix {
934    let mut mat = FdMatrix::zeros(n, ncol);
935    for i in 0..n {
936        for j in 0..ncol {
937            mat[(i, j)] = rows[i][j];
938        }
939    }
940    mat
941}
942
943/// Compute 2D partial derivatives for surface data.
944///
945/// For a surface f(s,t), computes:
946/// - ds: partial derivative with respect to s (∂f/∂s)
947/// - dt: partial derivative with respect to t (∂f/∂t)
948/// - dsdt: mixed partial derivative (∂²f/∂s∂t)
949///
950/// # Arguments
951/// * `data` - Functional data matrix, n surfaces, each stored as m1*m2 values
952/// * `argvals_s` - Grid points in s direction (length m1)
953/// * `argvals_t` - Grid points in t direction (length m2)
954/// * `m1` - Grid size in s direction
955/// * `m2` - Grid size in t direction
956pub fn deriv_2d(
957    data: &FdMatrix,
958    argvals_s: &[f64],
959    argvals_t: &[f64],
960    m1: usize,
961    m2: usize,
962) -> Option<Deriv2DResult> {
963    let n = data.nrows();
964    let ncol = m1 * m2;
965    if n == 0
966        || ncol == 0
967        || m1 < 2
968        || m2 < 2
969        || data.ncols() != ncol
970        || argvals_s.len() != m1
971        || argvals_t.len() != m2
972    {
973        return None;
974    }
975
976    let hs = compute_step_sizes(argvals_s);
977    let ht = compute_step_sizes(argvals_t);
978
979    // Compute all derivatives in parallel over surfaces
980    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>)> = iter_maybe_parallel!(0..n)
981        .map(|i| {
982            let mut ds = vec![0.0; ncol];
983            let mut dt = vec![0.0; ncol];
984            let mut dsdt = vec![0.0; ncol];
985
986            let get_val = |si: usize, ti: usize| -> f64 { data[(i, si + ti * m1)] };
987
988            for ti in 0..m2 {
989                for si in 0..m1 {
990                    let idx = si + ti * m1;
991                    let (ds_val, dt_val, dsdt_val) =
992                        compute_2d_derivatives(get_val, si, ti, m1, m2, &hs, &ht);
993                    ds[idx] = ds_val;
994                    dt[idx] = dt_val;
995                    dsdt[idx] = dsdt_val;
996                }
997            }
998
999            (ds, dt, dsdt)
1000        })
1001        .collect();
1002
1003    let (ds_vecs, (dt_vecs, dsdt_vecs)): (Vec<Vec<f64>>, (Vec<Vec<f64>>, Vec<Vec<f64>>)) =
1004        results.into_iter().map(|(a, b, c)| (a, (b, c))).unzip();
1005
1006    Some(Deriv2DResult {
1007        ds: reassemble_colmajor(&ds_vecs, n, ncol),
1008        dt: reassemble_colmajor(&dt_vecs, n, ncol),
1009        dsdt: reassemble_colmajor(&dsdt_vecs, n, ncol),
1010    })
1011}
1012
1013/// Compute the geometric median (L1 median) of functional data using Weiszfeld's algorithm.
1014///
1015/// The geometric median minimizes sum of L2 distances to all curves.
1016///
1017/// # Arguments
1018/// * `data` - Functional data matrix (n x m)
1019/// * `argvals` - Evaluation points for integration
1020/// * `max_iter` - Maximum iterations
1021/// * `tol` - Convergence tolerance
1022pub fn geometric_median_1d(
1023    data: &FdMatrix,
1024    argvals: &[f64],
1025    max_iter: usize,
1026    tol: f64,
1027) -> Vec<f64> {
1028    let (n, m) = data.shape();
1029    if n == 0 || m == 0 || argvals.len() != m {
1030        return Vec::new();
1031    }
1032
1033    let weights = simpsons_weights(argvals);
1034    weiszfeld_iteration(data, &weights, max_iter, tol)
1035}
1036
1037/// Compute the geometric median for 2D functional data.
1038///
1039/// Data is stored as n x (m1*m2) matrix where each row is a flattened surface.
1040///
1041/// # Arguments
1042/// * `data` - Functional data matrix (n x m) where m = m1*m2
1043/// * `argvals_s` - Grid points in s direction (length m1)
1044/// * `argvals_t` - Grid points in t direction (length m2)
1045/// * `max_iter` - Maximum iterations
1046/// * `tol` - Convergence tolerance
1047pub fn geometric_median_2d(
1048    data: &FdMatrix,
1049    argvals_s: &[f64],
1050    argvals_t: &[f64],
1051    max_iter: usize,
1052    tol: f64,
1053) -> Vec<f64> {
1054    let (n, m) = data.shape();
1055    let expected_cols = argvals_s.len() * argvals_t.len();
1056    if n == 0 || m == 0 || m != expected_cols {
1057        return Vec::new();
1058    }
1059
1060    let weights = simpsons_weights_2d(argvals_s, argvals_t);
1061    weiszfeld_iteration(data, &weights, max_iter, tol)
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use crate::test_helpers::uniform_grid;
1068    use std::f64::consts::PI;
1069
1070    // ============== Mean tests ==============
1071
1072    #[test]
1073    fn test_mean_1d() {
1074        // 2 samples, 3 points each
1075        // Sample 1: [1, 2, 3]
1076        // Sample 2: [3, 4, 5]
1077        // Mean should be [2, 3, 4]
1078        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0]; // column-major
1079        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
1080        let mean = mean_1d(&mat);
1081        assert_eq!(mean, vec![2.0, 3.0, 4.0]);
1082    }
1083
1084    #[test]
1085    fn test_mean_1d_single_sample() {
1086        let data = vec![1.0, 2.0, 3.0];
1087        let mat = FdMatrix::from_column_major(data, 1, 3).unwrap();
1088        let mean = mean_1d(&mat);
1089        assert_eq!(mean, vec![1.0, 2.0, 3.0]);
1090    }
1091
1092    #[test]
1093    fn test_mean_1d_invalid() {
1094        assert!(mean_1d(&FdMatrix::zeros(0, 0)).is_empty());
1095    }
1096
1097    #[allow(deprecated)]
1098    #[test]
1099    fn test_mean_2d_delegates() {
1100        let data = vec![1.0, 3.0, 2.0, 4.0];
1101        let mat = FdMatrix::from_column_major(data, 2, 2).unwrap();
1102        let mean1d = mean_1d(&mat);
1103        let mean2d = mean_2d(&mat);
1104        assert_eq!(mean1d, mean2d);
1105    }
1106
1107    // ============== Center tests ==============
1108
1109    #[test]
1110    fn test_center_1d() {
1111        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0]; // column-major
1112        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
1113        let centered = center_1d(&mat);
1114        // Mean is [2, 3, 4], so centered should be [-1, 1, -1, 1, -1, 1]
1115        assert_eq!(centered.as_slice(), &[-1.0, 1.0, -1.0, 1.0, -1.0, 1.0]);
1116    }
1117
1118    #[test]
1119    fn test_center_1d_mean_zero() {
1120        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0];
1121        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
1122        let centered = center_1d(&mat);
1123        let centered_mean = mean_1d(&centered);
1124        for m in centered_mean {
1125            assert!(m.abs() < 1e-10, "Centered data should have zero mean");
1126        }
1127    }
1128
1129    #[test]
1130    fn test_center_1d_invalid() {
1131        let centered = center_1d(&FdMatrix::zeros(0, 0));
1132        assert!(centered.is_empty());
1133    }
1134
1135    // ============== Norm tests ==============
1136
1137    #[test]
1138    fn test_norm_lp_1d_constant() {
1139        // Constant function 2 on [0, 1] has L2 norm = 2
1140        let argvals = uniform_grid(21);
1141        let data: Vec<f64> = vec![2.0; 21];
1142        let mat = FdMatrix::from_column_major(data, 1, 21).unwrap();
1143        let norms = norm_lp_1d(&mat, &argvals, 2.0);
1144        assert_eq!(norms.len(), 1);
1145        assert!(
1146            (norms[0] - 2.0).abs() < 0.1,
1147            "L2 norm of constant 2 should be 2"
1148        );
1149    }
1150
1151    #[test]
1152    fn test_norm_lp_1d_sine() {
1153        // L2 norm of sin(pi*x) on [0, 1] = sqrt(0.5)
1154        let argvals = uniform_grid(101);
1155        let data: Vec<f64> = argvals.iter().map(|&x| (PI * x).sin()).collect();
1156        let mat = FdMatrix::from_column_major(data, 1, 101).unwrap();
1157        let norms = norm_lp_1d(&mat, &argvals, 2.0);
1158        let expected = 0.5_f64.sqrt();
1159        assert!(
1160            (norms[0] - expected).abs() < 0.05,
1161            "Expected {}, got {}",
1162            expected,
1163            norms[0]
1164        );
1165    }
1166
1167    #[test]
1168    fn test_norm_lp_1d_invalid() {
1169        assert!(norm_lp_1d(&FdMatrix::zeros(0, 0), &[], 2.0).is_empty());
1170    }
1171
1172    // ============== Derivative tests ==============
1173
1174    #[test]
1175    fn test_deriv_1d_linear() {
1176        // Derivative of linear function x should be 1
1177        let argvals = uniform_grid(21);
1178        let data = argvals.clone();
1179        let mat = FdMatrix::from_column_major(data, 1, 21).unwrap();
1180        let deriv = deriv_1d(&mat, &argvals, 1);
1181        // Interior points should have derivative close to 1
1182        for j in 2..19 {
1183            assert!(
1184                (deriv[(0, j)] - 1.0).abs() < 0.1,
1185                "Derivative of x should be 1"
1186            );
1187        }
1188    }
1189
1190    #[test]
1191    fn test_deriv_1d_quadratic() {
1192        // Derivative of x^2 should be 2x
1193        let argvals = uniform_grid(51);
1194        let data: Vec<f64> = argvals.iter().map(|&x| x * x).collect();
1195        let mat = FdMatrix::from_column_major(data, 1, 51).unwrap();
1196        let deriv = deriv_1d(&mat, &argvals, 1);
1197        // Check interior points
1198        for j in 5..45 {
1199            let expected = 2.0 * argvals[j];
1200            assert!(
1201                (deriv[(0, j)] - expected).abs() < 0.1,
1202                "Derivative of x^2 should be 2x"
1203            );
1204        }
1205    }
1206
1207    #[test]
1208    fn test_deriv_1d_invalid() {
1209        let result = deriv_1d(&FdMatrix::zeros(0, 0), &[], 1);
1210        assert!(result.is_empty() || result.as_slice().iter().all(|&x| x == 0.0));
1211    }
1212
1213    // ============== Geometric median tests ==============
1214
1215    #[test]
1216    fn test_geometric_median_identical_curves() {
1217        // All curves identical -> median = that curve
1218        let argvals = uniform_grid(21);
1219        let n = 5;
1220        let m = 21;
1221        let mut data = vec![0.0; n * m];
1222        for i in 0..n {
1223            for j in 0..m {
1224                data[i + j * n] = (2.0 * PI * argvals[j]).sin();
1225            }
1226        }
1227        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
1228        let median = geometric_median_1d(&mat, &argvals, 100, 1e-6);
1229        for j in 0..m {
1230            let expected = (2.0 * PI * argvals[j]).sin();
1231            assert!(
1232                (median[j] - expected).abs() < 0.01,
1233                "Median should equal all curves"
1234            );
1235        }
1236    }
1237
1238    #[test]
1239    fn test_geometric_median_converges() {
1240        let argvals = uniform_grid(21);
1241        let n = 10;
1242        let m = 21;
1243        let mut data = vec![0.0; n * m];
1244        for i in 0..n {
1245            for j in 0..m {
1246                data[i + j * n] = (i as f64 / n as f64) * argvals[j];
1247            }
1248        }
1249        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
1250        let median = geometric_median_1d(&mat, &argvals, 100, 1e-6);
1251        assert_eq!(median.len(), m);
1252        assert!(median.iter().all(|&x| x.is_finite()));
1253    }
1254
1255    #[test]
1256    fn test_geometric_median_invalid() {
1257        assert!(geometric_median_1d(&FdMatrix::zeros(0, 0), &[], 100, 1e-6).is_empty());
1258    }
1259
1260    // ============== 2D derivative tests ==============
1261
1262    #[test]
1263    fn test_deriv_2d_linear_surface() {
1264        // f(s, t) = 2*s + 3*t
1265        // ∂f/∂s = 2, ∂f/∂t = 3, ∂²f/∂s∂t = 0
1266        let m1 = 11;
1267        let m2 = 11;
1268        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
1269        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
1270
1271        let n = 1; // single surface
1272        let ncol = m1 * m2;
1273        let mut data = vec![0.0; n * ncol];
1274
1275        for si in 0..m1 {
1276            for ti in 0..m2 {
1277                let s = argvals_s[si];
1278                let t = argvals_t[ti];
1279                let idx = si + ti * m1;
1280                data[idx] = 2.0 * s + 3.0 * t;
1281            }
1282        }
1283
1284        let mat = FdMatrix::from_column_major(data, n, ncol).unwrap();
1285        let result = deriv_2d(&mat, &argvals_s, &argvals_t, m1, m2).unwrap();
1286
1287        // Check interior points for ∂f/∂s ≈ 2
1288        for si in 2..(m1 - 2) {
1289            for ti in 2..(m2 - 2) {
1290                let idx = si + ti * m1;
1291                assert!(
1292                    (result.ds[(0, idx)] - 2.0).abs() < 0.2,
1293                    "∂f/∂s at ({}, {}) = {}, expected 2",
1294                    si,
1295                    ti,
1296                    result.ds[(0, idx)]
1297                );
1298            }
1299        }
1300
1301        // Check interior points for ∂f/∂t ≈ 3
1302        for si in 2..(m1 - 2) {
1303            for ti in 2..(m2 - 2) {
1304                let idx = si + ti * m1;
1305                assert!(
1306                    (result.dt[(0, idx)] - 3.0).abs() < 0.2,
1307                    "∂f/∂t at ({}, {}) = {}, expected 3",
1308                    si,
1309                    ti,
1310                    result.dt[(0, idx)]
1311                );
1312            }
1313        }
1314
1315        // Check interior points for mixed partial ≈ 0
1316        for si in 2..(m1 - 2) {
1317            for ti in 2..(m2 - 2) {
1318                let idx = si + ti * m1;
1319                assert!(
1320                    result.dsdt[(0, idx)].abs() < 0.5,
1321                    "∂²f/∂s∂t at ({}, {}) = {}, expected 0",
1322                    si,
1323                    ti,
1324                    result.dsdt[(0, idx)]
1325                );
1326            }
1327        }
1328    }
1329
1330    #[test]
1331    fn test_deriv_2d_quadratic_surface() {
1332        // f(s, t) = s*t
1333        // ∂f/∂s = t, ∂f/∂t = s, ∂²f/∂s∂t = 1
1334        let m1 = 21;
1335        let m2 = 21;
1336        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
1337        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
1338
1339        let n = 1;
1340        let ncol = m1 * m2;
1341        let mut data = vec![0.0; n * ncol];
1342
1343        for si in 0..m1 {
1344            for ti in 0..m2 {
1345                let s = argvals_s[si];
1346                let t = argvals_t[ti];
1347                let idx = si + ti * m1;
1348                data[idx] = s * t;
1349            }
1350        }
1351
1352        let mat = FdMatrix::from_column_major(data, n, ncol).unwrap();
1353        let result = deriv_2d(&mat, &argvals_s, &argvals_t, m1, m2).unwrap();
1354
1355        // Check interior points for ∂f/∂s ≈ t
1356        for si in 3..(m1 - 3) {
1357            for ti in 3..(m2 - 3) {
1358                let idx = si + ti * m1;
1359                let expected = argvals_t[ti];
1360                assert!(
1361                    (result.ds[(0, idx)] - expected).abs() < 0.1,
1362                    "∂f/∂s at ({}, {}) = {}, expected {}",
1363                    si,
1364                    ti,
1365                    result.ds[(0, idx)],
1366                    expected
1367                );
1368            }
1369        }
1370
1371        // Check interior points for ∂f/∂t ≈ s
1372        for si in 3..(m1 - 3) {
1373            for ti in 3..(m2 - 3) {
1374                let idx = si + ti * m1;
1375                let expected = argvals_s[si];
1376                assert!(
1377                    (result.dt[(0, idx)] - expected).abs() < 0.1,
1378                    "∂f/∂t at ({}, {}) = {}, expected {}",
1379                    si,
1380                    ti,
1381                    result.dt[(0, idx)],
1382                    expected
1383                );
1384            }
1385        }
1386
1387        // Check interior points for mixed partial ≈ 1
1388        for si in 3..(m1 - 3) {
1389            for ti in 3..(m2 - 3) {
1390                let idx = si + ti * m1;
1391                assert!(
1392                    (result.dsdt[(0, idx)] - 1.0).abs() < 0.3,
1393                    "∂²f/∂s∂t at ({}, {}) = {}, expected 1",
1394                    si,
1395                    ti,
1396                    result.dsdt[(0, idx)]
1397                );
1398            }
1399        }
1400    }
1401
1402    #[test]
1403    fn test_deriv_2d_invalid_input() {
1404        // Empty data
1405        let result = deriv_2d(&FdMatrix::zeros(0, 0), &[], &[], 0, 0);
1406        assert!(result.is_none());
1407
1408        // Mismatched dimensions
1409        let mat = FdMatrix::from_column_major(vec![1.0; 4], 1, 4).unwrap();
1410        let argvals = vec![0.0, 1.0];
1411        let result = deriv_2d(&mat, &argvals, &[0.0, 0.5, 1.0], 2, 2);
1412        assert!(result.is_none());
1413    }
1414
1415    // ============== 2D geometric median tests ==============
1416
1417    #[test]
1418    fn test_geometric_median_2d_basic() {
1419        // Three identical surfaces -> median = that surface
1420        let m1 = 5;
1421        let m2 = 5;
1422        let m = m1 * m2;
1423        let n = 3;
1424        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
1425        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
1426
1427        let mut data = vec![0.0; n * m];
1428
1429        // Create identical surfaces: f(s, t) = s + t
1430        for i in 0..n {
1431            for si in 0..m1 {
1432                for ti in 0..m2 {
1433                    let idx = si + ti * m1;
1434                    let s = argvals_s[si];
1435                    let t = argvals_t[ti];
1436                    data[i + idx * n] = s + t;
1437                }
1438            }
1439        }
1440
1441        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
1442        let median = geometric_median_2d(&mat, &argvals_s, &argvals_t, 100, 1e-6);
1443        assert_eq!(median.len(), m);
1444
1445        // Check that median equals the surface
1446        for si in 0..m1 {
1447            for ti in 0..m2 {
1448                let idx = si + ti * m1;
1449                let expected = argvals_s[si] + argvals_t[ti];
1450                assert!(
1451                    (median[idx] - expected).abs() < 0.01,
1452                    "Median at ({}, {}) = {}, expected {}",
1453                    si,
1454                    ti,
1455                    median[idx],
1456                    expected
1457                );
1458            }
1459        }
1460    }
1461
1462    // ============== Functional statistics tests (Task 1 — pointwise trio) ==============
1463
1464    #[test]
1465    fn functional_variance_equals_std_squared() {
1466        // 3 curves at 4 evaluation points
1467        let data = FdMatrix::from_column_major(
1468            vec![
1469                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1470            ],
1471            3,
1472            4,
1473        )
1474        .unwrap();
1475        let var = functional_variance(&data).unwrap();
1476        let std = functional_std(&data).unwrap();
1477        for j in 0..4 {
1478            assert!(
1479                (std[j].powi(2) - var[j]).abs() < 1e-10,
1480                "at j={j}: std^2={} != var={}",
1481                std[j].powi(2),
1482                var[j]
1483            );
1484        }
1485    }
1486
1487    #[test]
1488    fn functional_covariance_diagonal_matches_variance() {
1489        let data = FdMatrix::from_column_major(
1490            vec![
1491                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1492            ],
1493            3,
1494            4,
1495        )
1496        .unwrap();
1497        let var = functional_variance(&data).unwrap();
1498        let cov = functional_covariance(&data).unwrap();
1499        for j in 0..4 {
1500            assert!(
1501                (cov[(j, j)] - var[j]).abs() < 1e-10,
1502                "at j={j}: cov[j,j]={} != var={}",
1503                cov[(j, j)],
1504                var[j]
1505            );
1506        }
1507    }
1508
1509    #[test]
1510    fn functional_variance_hand_computed() {
1511        // 2 curves, 2 eval points:
1512        // curve 0: [1.0, 4.0], curve 1: [3.0, 2.0]
1513        // col-major: [1.0, 3.0, 4.0, 2.0]
1514        // means: [2.0, 3.0]
1515        // var[0] = ((1-2)^2 + (3-2)^2) / (2-1) = (1+1)/1 = 2.0
1516        // var[1] = ((4-3)^2 + (2-3)^2) / (2-1) = (1+1)/1 = 2.0
1517        let data = FdMatrix::from_column_major(vec![1.0, 3.0, 4.0, 2.0], 2, 2).unwrap();
1518        let var = functional_variance(&data).unwrap();
1519        assert!((var[0] - 2.0).abs() < 1e-10, "var[0]={}", var[0]);
1520        assert!((var[1] - 2.0).abs() < 1e-10, "var[1]={}", var[1]);
1521    }
1522
1523    // ============== Depth-based statistics tests (Task 2) ==============
1524
1525    #[test]
1526    fn depth_based_median_argmax() {
1527        // 5 curves at 10 points; curve 2 (index 2) is constant at 0.5, which is the
1528        // most central value — it should have the highest FM depth.
1529        // Curves 0,1,3,4 are at the extremes.
1530        let n = 5;
1531        let m = 10;
1532        let mut data_vec = vec![0.0f64; n * m];
1533        // curve 0: constant at 0.0
1534        // curve 1: constant at 0.25
1535        // curve 2: constant at 0.5 (most central)
1536        // curve 3: constant at 0.75
1537        // curve 4: constant at 1.0
1538        for j in 0..m {
1539            data_vec[j * n] = 0.0;
1540            data_vec[1 + j * n] = 0.25;
1541            data_vec[2 + j * n] = 0.5;
1542            data_vec[3 + j * n] = 0.75;
1543            data_vec[4 + j * n] = 1.0;
1544        }
1545        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1546        let idx = depth_based_median(&data).unwrap();
1547        assert_eq!(idx, 2, "most central curve should be at index 2, got {idx}");
1548    }
1549
1550    #[test]
1551    fn trim_mean_alpha_zero_equals_mean() {
1552        let n = 5;
1553        let m = 4;
1554        let mut data_vec = vec![0.0f64; n * m];
1555        for i in 0..n {
1556            for j in 0..m {
1557                data_vec[i + j * n] = (i + 1) as f64 * (j + 1) as f64;
1558            }
1559        }
1560        let data = FdMatrix::from_column_major(data_vec, n, m).unwrap();
1561        let tm = trim_mean(&data, 0.0).unwrap();
1562        let mu = mean_1d(&data);
1563        for j in 0..m {
1564            assert!(
1565                (tm[j] - mu[j]).abs() < 1e-10,
1566                "at j={j}: trim_mean(alpha=0)={} != mean={}",
1567                tm[j],
1568                mu[j]
1569            );
1570        }
1571    }
1572
1573    #[test]
1574    fn trim_mean_rejects_bad_alpha() {
1575        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
1576        // alpha = 1.0 is invalid (must be < 1.0)
1577        let result = trim_mean(&data, 1.0);
1578        assert!(
1579            matches!(
1580                result,
1581                Err(FdarError::InvalidParameter {
1582                    parameter: "alpha",
1583                    ..
1584                })
1585            ),
1586            "expected InvalidParameter for alpha=1.0, got {result:?}"
1587        );
1588        // alpha = -0.1 is invalid (must be >= 0.0)
1589        let result2 = trim_mean(&data, -0.1);
1590        assert!(
1591            matches!(
1592                result2,
1593                Err(FdarError::InvalidParameter {
1594                    parameter: "alpha",
1595                    ..
1596                })
1597            ),
1598            "expected InvalidParameter for alpha=-0.1, got {result2:?}"
1599        );
1600    }
1601
1602    // ============== Consolidated input-validation test (Task 3) ==============
1603
1604    #[test]
1605    fn functional_stats_input_validation() {
1606        use crate::error::FdarError;
1607
1608        // n=1 matrix (2 points) — fails n>=2 requirement for variance/std/covariance
1609        let one_row = FdMatrix::from_column_major(vec![1.0, 2.0], 1, 2).unwrap();
1610        assert!(
1611            matches!(
1612                functional_variance(&one_row),
1613                Err(FdarError::InvalidDimension {
1614                    parameter: "data",
1615                    ..
1616                })
1617            ),
1618            "functional_variance should reject n=1"
1619        );
1620        assert!(
1621            matches!(
1622                functional_std(&one_row),
1623                Err(FdarError::InvalidDimension {
1624                    parameter: "data",
1625                    ..
1626                })
1627            ),
1628            "functional_std should reject n=1"
1629        );
1630        assert!(
1631            matches!(
1632                functional_covariance(&one_row),
1633                Err(FdarError::InvalidDimension {
1634                    parameter: "data",
1635                    ..
1636                })
1637            ),
1638            "functional_covariance should reject n=1"
1639        );
1640
1641        // n=0 matrix — fails n>=1 requirement for depth_based_median / trim_mean
1642        let zero_rows = FdMatrix::zeros(0, 3);
1643        assert!(
1644            matches!(
1645                depth_based_median(&zero_rows),
1646                Err(FdarError::InvalidDimension {
1647                    parameter: "data",
1648                    ..
1649                })
1650            ),
1651            "depth_based_median should reject n=0"
1652        );
1653        assert!(
1654            matches!(
1655                trim_mean(&zero_rows, 0.0),
1656                Err(FdarError::InvalidDimension {
1657                    parameter: "data",
1658                    ..
1659                })
1660            ),
1661            "trim_mean should reject n=0"
1662        );
1663    }
1664
1665    #[test]
1666    fn test_nan_mean_no_panic() {
1667        let mut data_vec = vec![1.0; 6];
1668        data_vec[2] = f64::NAN;
1669        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1670        let m = mean_1d(&data);
1671        assert_eq!(m.len(), 3);
1672    }
1673
1674    #[test]
1675    fn test_nan_center_no_panic() {
1676        let mut data_vec = vec![1.0; 6];
1677        data_vec[2] = f64::NAN;
1678        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1679        let c = center_1d(&data);
1680        assert_eq!(c.nrows(), 2);
1681    }
1682
1683    #[test]
1684    fn test_nan_norm_no_panic() {
1685        let mut data_vec = vec![1.0; 6];
1686        data_vec[2] = f64::NAN;
1687        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1688        let argvals = vec![0.0, 0.5, 1.0];
1689        let norms = norm_lp_1d(&data, &argvals, 2.0);
1690        assert_eq!(norms.len(), 2);
1691    }
1692
1693    #[test]
1694    fn test_n1_norm() {
1695        let data = FdMatrix::from_column_major(vec![0.0, 1.0, 0.0], 1, 3).unwrap();
1696        let argvals = vec![0.0, 0.5, 1.0];
1697        let norms = norm_lp_1d(&data, &argvals, 2.0);
1698        assert_eq!(norms.len(), 1);
1699        assert!(norms[0] > 0.0);
1700    }
1701
1702    #[test]
1703    fn test_n2_center() {
1704        let data = FdMatrix::from_column_major(vec![1.0, 3.0, 2.0, 4.0], 2, 2).unwrap();
1705        let centered = center_1d(&data);
1706        // Mean at each point: [2.0, 3.0]
1707        // centered[0,0] = 1.0 - 2.0 = -1.0
1708        assert!((centered[(0, 0)] - (-1.0)).abs() < 1e-12);
1709        assert!((centered[(1, 0)] - 1.0).abs() < 1e-12);
1710    }
1711
1712    #[test]
1713    fn test_deriv_nderiv0() {
1714        // nderiv=0 returns the original data (0th derivative = identity)
1715        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 2, 3).unwrap();
1716        let argvals = vec![0.0, 0.5, 1.0];
1717        let result = deriv_1d(&data, &argvals, 0);
1718        assert_eq!(result.shape(), data.shape());
1719        for i in 0..2 {
1720            for j in 0..3 {
1721                assert!((result[(i, j)] - data[(i, j)]).abs() < 1e-12);
1722            }
1723        }
1724    }
1725
1726    // ============== Normalize tests ==============
1727
1728    #[test]
1729    fn test_normalize_autoscale() {
1730        // 3 curves, 4 time points
1731        let data = FdMatrix::from_column_major(
1732            vec![
1733                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1734            ],
1735            3,
1736            4,
1737        )
1738        .unwrap();
1739        let scaled = normalize(&data, NormalizationMethod::Autoscale);
1740        // Each column should have mean ≈ 0 and std ≈ 1
1741        for j in 0..4 {
1742            let col = scaled.column(j);
1743            let mean = col.iter().sum::<f64>() / 3.0;
1744            assert!(
1745                mean.abs() < 1e-10,
1746                "column {j} mean should be 0, got {mean}"
1747            );
1748            let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / 2.0;
1749            assert!(
1750                (var - 1.0).abs() < 1e-10,
1751                "column {j} variance should be 1, got {var}"
1752            );
1753        }
1754    }
1755
1756    #[test]
1757    fn test_normalize_pareto() {
1758        let data =
1759            FdMatrix::from_column_major(vec![1.0, 5.0, 3.0, 10.0, 20.0, 30.0], 2, 3).unwrap();
1760        let scaled = normalize(&data, NormalizationMethod::Pareto);
1761        // Columns should be centered and scaled by sqrt(std)
1762        for j in 0..3 {
1763            let col = scaled.column(j);
1764            let mean = col.iter().sum::<f64>() / 2.0;
1765            assert!(mean.abs() < 1e-10, "column {j} mean should be 0");
1766        }
1767    }
1768
1769    #[test]
1770    fn test_normalize_range() {
1771        let data = FdMatrix::from_column_major(vec![0.0, 10.0, 2.0, 8.0], 2, 2).unwrap();
1772        let scaled = normalize(&data, NormalizationMethod::Range);
1773        // Column 0: values [0, 10], range 10, centered [-5, 5], scaled [-0.5, 0.5]
1774        assert!((scaled[(0, 0)] - (-0.5)).abs() < 1e-10);
1775        assert!((scaled[(1, 0)] - 0.5).abs() < 1e-10);
1776    }
1777
1778    #[test]
1779    fn test_normalize_curve_center() {
1780        let data = FdMatrix::from_column_major(vec![1.0, 4.0, 3.0, 6.0, 5.0, 8.0], 2, 3).unwrap();
1781        let result = normalize(&data, NormalizationMethod::CurveCenter);
1782        // Row 0: [1, 3, 5], mean=3, centered=[-2, 0, 2]
1783        assert!((result[(0, 0)] - (-2.0)).abs() < 1e-10);
1784        assert!((result[(0, 1)] - 0.0).abs() < 1e-10);
1785        assert!((result[(0, 2)] - 2.0).abs() < 1e-10);
1786    }
1787
1788    #[test]
1789    fn test_normalize_curve_standardize() {
1790        let data = FdMatrix::from_column_major(vec![1.0, 4.0, 3.0, 6.0, 5.0, 8.0], 2, 3).unwrap();
1791        let result = normalize(&data, NormalizationMethod::CurveStandardize);
1792        // Each row should have mean ≈ 0 and std ≈ 1
1793        for i in 0..2 {
1794            let row: Vec<f64> = (0..3).map(|j| result[(i, j)]).collect();
1795            let mean = row.iter().sum::<f64>() / 3.0;
1796            assert!(mean.abs() < 1e-10, "row {i} mean should be 0");
1797            let var = row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / 2.0;
1798            assert!((var - 1.0).abs() < 1e-10, "row {i} variance should be 1");
1799        }
1800    }
1801
1802    #[test]
1803    fn test_normalize_curve_range() {
1804        let data =
1805            FdMatrix::from_column_major(vec![2.0, 10.0, 4.0, 20.0, 6.0, 30.0], 2, 3).unwrap();
1806        let result = normalize(&data, NormalizationMethod::CurveRange);
1807        // Row 0: [2, 4, 6] -> [0.0, 0.5, 1.0]
1808        assert!((result[(0, 0)] - 0.0).abs() < 1e-10);
1809        assert!((result[(0, 1)] - 0.5).abs() < 1e-10);
1810        assert!((result[(0, 2)] - 1.0).abs() < 1e-10);
1811    }
1812
1813    #[test]
1814    fn test_normalize_center_matches_center_1d() {
1815        let data = FdMatrix::from_column_major(vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0], 2, 3).unwrap();
1816        let a = center_1d(&data);
1817        let b = normalize(&data, NormalizationMethod::Center);
1818        assert_eq!(a.as_slice(), b.as_slice());
1819    }
1820
1821    #[test]
1822    fn test_normalize_curve_lp_l2() {
1823        // 2 curves on 3 points, uniform grid [0, 1]
1824        let data = FdMatrix::from_column_major(vec![3.0, 0.0, 0.0, 4.0, 0.0, 0.0], 2, 3).unwrap();
1825        let t = vec![0.0, 0.5, 1.0];
1826        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
1827        // Curve 0: [3, 0, 0], L2 norm = sqrt(∫ 9 dt) on [0,1] with trapezoidal ≈ sqrt(9*0.5) ~ 2.12
1828        // After normalization, L2 norm should be ≈ 1
1829        let norms = norm_lp_1d(&result, &t, 2.0);
1830        assert!(
1831            (norms[0] - 1.0).abs() < 0.1,
1832            "L2 norm after normalization should be ≈ 1, got {}",
1833            norms[0]
1834        );
1835    }
1836
1837    #[test]
1838    fn test_normalize_curve_lp_l1() {
1839        let data = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 8.0], 2, 2).unwrap();
1840        let t = vec![0.0, 1.0];
1841        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(1.0));
1842        // After L1 normalization, L1 norm of each curve should be ≈ 1
1843        let norms = norm_lp_1d(&result, &t, 1.0);
1844        for (i, &norm) in norms.iter().enumerate() {
1845            assert!(
1846                (norm - 1.0).abs() < 0.1,
1847                "curve {i} L1 norm after normalization should be ≈ 1, got {norm}"
1848            );
1849        }
1850    }
1851
1852    #[test]
1853    fn test_normalize_curve_lp_linf() {
1854        let data =
1855            FdMatrix::from_column_major(vec![2.0, -5.0, 4.0, -10.0, 6.0, 15.0], 2, 3).unwrap();
1856        let t = vec![0.0, 0.5, 1.0];
1857        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(f64::INFINITY));
1858        // L-inf norm = max |f(t)|; after normalization, max abs value should be ≈ 1
1859        for i in 0..2 {
1860            let max_abs: f64 = (0..3).map(|j| result[(i, j)].abs()).fold(0.0, f64::max);
1861            assert!(
1862                (max_abs - 1.0).abs() < 1e-10,
1863                "curve {i} max abs after L-inf normalization should be 1, got {max_abs}"
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    fn test_normalize_curve_lp_zero_curve() {
1870        // Zero curve should stay zero (not divide by zero)
1871        let data = FdMatrix::from_column_major(vec![0.0, 1.0, 0.0, 2.0], 2, 2).unwrap();
1872        let t = vec![0.0, 1.0];
1873        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
1874        // Curve 0 is all zeros — should remain zero
1875        assert!((result[(0, 0)]).abs() < 1e-15);
1876        assert!((result[(0, 1)]).abs() < 1e-15);
1877        // Curve 1 should be normalized
1878        assert!(result[(1, 0)].abs() > 0.0);
1879    }
1880}