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