Skip to main content

fdars_core/
fdata.rs

1//! Functional data operations: mean, center, derivatives, norms, and geometric median.
2
3use crate::helpers::{simpsons_weights, simpsons_weights_2d, NUMERICAL_EPS};
4use crate::iter_maybe_parallel;
5use crate::matrix::FdMatrix;
6#[cfg(feature = "parallel")]
7use rayon::iter::ParallelIterator;
8
9/// Compute finite difference for a 1D function at a given index.
10///
11/// Uses forward difference at left boundary, backward difference at right boundary,
12/// and central difference for interior points.
13fn finite_diff_1d(
14    values: impl Fn(usize) -> f64,
15    idx: usize,
16    n_points: usize,
17    step_sizes: &[f64],
18) -> f64 {
19    if idx == 0 {
20        (values(1) - values(0)) / step_sizes[0]
21    } else if idx == n_points - 1 {
22        (values(n_points - 1) - values(n_points - 2)) / step_sizes[n_points - 1]
23    } else {
24        (values(idx + 1) - values(idx - 1)) / step_sizes[idx]
25    }
26}
27
28/// Compute 2D partial derivatives at a single grid point.
29///
30/// Returns (∂f/∂s, ∂f/∂t, ∂²f/∂s∂t) using finite differences.
31fn compute_2d_derivatives(
32    get_val: impl Fn(usize, usize) -> f64,
33    si: usize,
34    ti: usize,
35    m1: usize,
36    m2: usize,
37    hs: &[f64],
38    ht: &[f64],
39) -> (f64, f64, f64) {
40    // ∂f/∂s
41    let ds = finite_diff_1d(|s| get_val(s, ti), si, m1, hs);
42
43    // ∂f/∂t
44    let dt = finite_diff_1d(|t| get_val(si, t), ti, m2, ht);
45
46    // ∂²f/∂s∂t (mixed partial)
47    let denom = hs[si] * ht[ti];
48
49    // Get the appropriate indices for s and t differences
50    let (s_lo, s_hi) = if si == 0 {
51        (0, 1)
52    } else if si == m1 - 1 {
53        (m1 - 2, m1 - 1)
54    } else {
55        (si - 1, si + 1)
56    };
57
58    let (t_lo, t_hi) = if ti == 0 {
59        (0, 1)
60    } else if ti == m2 - 1 {
61        (m2 - 2, m2 - 1)
62    } else {
63        (ti - 1, ti + 1)
64    };
65
66    let dsdt = (get_val(s_hi, t_hi) - get_val(s_lo, t_hi) - get_val(s_hi, t_lo)
67        + get_val(s_lo, t_lo))
68        / denom;
69
70    (ds, dt, dsdt)
71}
72
73/// Perform Weiszfeld iteration to compute geometric median.
74///
75/// This is the core algorithm shared by 1D and 2D geometric median computations.
76fn weiszfeld_iteration(data: &FdMatrix, weights: &[f64], max_iter: usize, tol: f64) -> Vec<f64> {
77    let (n, m) = data.shape();
78
79    // Initialize with the mean
80    let mut median: Vec<f64> = (0..m)
81        .map(|j| {
82            let col = data.column(j);
83            col.iter().sum::<f64>() / n as f64
84        })
85        .collect();
86
87    for _ in 0..max_iter {
88        // Compute distances from current median to all curves
89        let distances: Vec<f64> = (0..n)
90            .map(|i| {
91                let mut dist_sq = 0.0;
92                for j in 0..m {
93                    let diff = data[(i, j)] - median[j];
94                    dist_sq += diff * diff * weights[j];
95                }
96                dist_sq.sqrt()
97            })
98            .collect();
99
100        // Compute weights (1/distance), handling zero distances
101        let inv_distances: Vec<f64> = distances
102            .iter()
103            .map(|d| {
104                if *d > NUMERICAL_EPS {
105                    1.0 / d
106                } else {
107                    1.0 / NUMERICAL_EPS
108                }
109            })
110            .collect();
111
112        let sum_inv_dist: f64 = inv_distances.iter().sum();
113
114        // Update median using Weiszfeld iteration
115        let new_median: Vec<f64> = (0..m)
116            .map(|j| {
117                let mut weighted_sum = 0.0;
118                for i in 0..n {
119                    weighted_sum += data[(i, j)] * inv_distances[i];
120                }
121                weighted_sum / sum_inv_dist
122            })
123            .collect();
124
125        // Check convergence
126        let diff: f64 = median
127            .iter()
128            .zip(new_median.iter())
129            .map(|(a, b)| (a - b).abs())
130            .sum::<f64>()
131            / m as f64;
132
133        median = new_median;
134
135        if diff < tol {
136            break;
137        }
138    }
139
140    median
141}
142
143/// Compute the mean function across all samples (1D).
144///
145/// # Arguments
146/// * `data` - Functional data matrix (n x m)
147///
148/// # Returns
149/// Mean function values at each evaluation point
150///
151/// # Examples
152///
153/// ```
154/// use fdars_core::matrix::FdMatrix;
155/// use fdars_core::fdata::mean_1d;
156///
157/// // 3 curves at 4 evaluation points
158/// let data = FdMatrix::from_column_major(
159///     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],
160///     3, 4,
161/// ).unwrap();
162/// let mean = mean_1d(&data);
163/// assert_eq!(mean.len(), 4);
164/// assert!((mean[0] - 2.0).abs() < 1e-10); // mean of [1, 2, 3]
165/// ```
166pub fn mean_1d(data: &FdMatrix) -> Vec<f64> {
167    let (n, m) = data.shape();
168    if n == 0 || m == 0 {
169        return Vec::new();
170    }
171
172    iter_maybe_parallel!(0..m)
173        .map(|j| {
174            let col = data.column(j);
175            col.iter().sum::<f64>() / n as f64
176        })
177        .collect()
178}
179
180/// Compute the mean function for 2D surfaces.
181///
182/// Data is stored as n x (m1*m2) matrix where each row is a flattened surface.
183pub fn mean_2d(data: &FdMatrix) -> Vec<f64> {
184    // Same computation as 1D - just compute pointwise mean
185    mean_1d(data)
186}
187
188/// Center functional data by subtracting the mean function.
189///
190/// # Arguments
191/// * `data` - Functional data matrix (n x m)
192///
193/// # Returns
194/// Centered data matrix
195///
196/// # Examples
197///
198/// ```
199/// use fdars_core::matrix::FdMatrix;
200/// use fdars_core::fdata::{center_1d, mean_1d};
201///
202/// let data = FdMatrix::from_column_major(
203///     vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0], 2, 3,
204/// ).unwrap();
205/// let centered = center_1d(&data);
206/// assert_eq!(centered.shape(), (2, 3));
207/// // Column means of centered data should be zero
208/// let means = mean_1d(&centered);
209/// assert!(means.iter().all(|m| m.abs() < 1e-10));
210/// ```
211pub fn center_1d(data: &FdMatrix) -> FdMatrix {
212    let (n, m) = data.shape();
213    if n == 0 || m == 0 {
214        return FdMatrix::zeros(0, 0);
215    }
216
217    // First compute the mean for each column (parallelized)
218    let means: Vec<f64> = iter_maybe_parallel!(0..m)
219        .map(|j| {
220            let col = data.column(j);
221            col.iter().sum::<f64>() / n as f64
222        })
223        .collect();
224
225    // Create centered data
226    let mut centered = FdMatrix::zeros(n, m);
227    for j in 0..m {
228        let col = centered.column_mut(j);
229        let src = data.column(j);
230        for i in 0..n {
231            col[i] = src[i] - means[j];
232        }
233    }
234
235    centered
236}
237
238/// Normalization method for functional data.
239#[derive(Debug, Clone, Copy, PartialEq)]
240#[non_exhaustive]
241pub enum NormalizationMethod {
242    /// Center columns (subtract per-time-point mean across curves).
243    Center,
244    /// Autoscale columns (center + divide by per-time-point std dev). UV scaling.
245    Autoscale,
246    /// Pareto scaling (center + divide by sqrt of per-time-point std dev).
247    Pareto,
248    /// Range scaling (center + divide by per-time-point range).
249    Range,
250    /// Per-curve centering (subtract each curve's own mean).
251    CurveCenter,
252    /// Per-curve standardization (subtract mean, divide by std dev per curve).
253    CurveStandardize,
254    /// Per-curve range normalization to [0, 1].
255    CurveRange,
256    /// Per-curve Lp normalization: divide each curve by its Lp norm.
257    ///
258    /// Common choices: `p = 1.0` (L1), `p = 2.0` (L2 / unit sphere),
259    /// `p = f64::INFINITY` (L-inf / max-norm). Requires `argvals` for
260    /// integration — use [`normalize_with_argvals`] instead of [`normalize`].
261    CurveLp(f64),
262}
263
264/// Normalize functional data using the specified method.
265///
266/// **Column-wise methods** (across curves at each time point):
267/// - `Center`: subtract column means (same as [`center_1d`])
268/// - `Autoscale`: center + divide by column std dev (unit variance per time point)
269/// - `Pareto`: center + divide by sqrt(column std dev)
270/// - `Range`: center + divide by column range (max - min)
271///
272/// **Row-wise methods** (per curve):
273/// - `CurveCenter`: subtract each curve's own mean
274/// - `CurveStandardize`: subtract mean, divide by std dev per curve
275/// - `CurveRange`: scale each curve to [0, 1]
276/// - `CurveLp(p)`: divide each curve by its Lp norm — requires `argvals`,
277///   use [`normalize_with_argvals`] instead
278///
279/// # Panics
280///
281/// Panics if `CurveLp` is used without argvals. Use [`normalize_with_argvals`]
282/// for Lp normalization.
283///
284/// # Examples
285///
286/// ```
287/// use fdars_core::matrix::FdMatrix;
288/// use fdars_core::fdata::{normalize, NormalizationMethod};
289///
290/// let data = FdMatrix::from_column_major(
291///     vec![1.0, 3.0, 2.0, 6.0, 3.0, 9.0], 2, 3,
292/// ).unwrap();
293///
294/// // Autoscale: zero mean, unit variance per time point
295/// let scaled = normalize(&data, NormalizationMethod::Autoscale);
296/// assert_eq!(scaled.shape(), (2, 3));
297/// ```
298pub fn normalize(data: &FdMatrix, method: NormalizationMethod) -> FdMatrix {
299    match method {
300        NormalizationMethod::CurveLp(_) => {
301            panic!("CurveLp requires argvals — use normalize_with_argvals()")
302        }
303        _ => {
304            let argvals: Vec<f64> = (0..data.ncols())
305                .map(|j| j as f64 / (data.ncols() - 1).max(1) as f64)
306                .collect();
307            normalize_with_argvals(data, &argvals, method)
308        }
309    }
310}
311
312/// Normalize functional data with an evaluation grid.
313///
314/// Same as [`normalize`] but accepts `argvals` for integration-based methods
315/// (`CurveLp`). For non-Lp methods, `argvals` is ignored.
316///
317/// # Examples
318///
319/// ```
320/// use fdars_core::matrix::FdMatrix;
321/// use fdars_core::fdata::{normalize_with_argvals, NormalizationMethod};
322///
323/// let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0], 2, 2).unwrap();
324/// let t = vec![0.0, 1.0];
325///
326/// // L2 normalization: each curve has unit L2 norm
327/// let l2 = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
328/// assert_eq!(l2.shape(), (2, 2));
329/// ```
330pub fn normalize_with_argvals(
331    data: &FdMatrix,
332    argvals: &[f64],
333    method: NormalizationMethod,
334) -> FdMatrix {
335    let (n, m) = data.shape();
336    if n == 0 || m == 0 {
337        return FdMatrix::zeros(n, m);
338    }
339
340    match method {
341        NormalizationMethod::Center => center_1d(data),
342        NormalizationMethod::Autoscale => column_scale(data, n, m, ScaleKind::StdDev),
343        NormalizationMethod::Pareto => column_scale(data, n, m, ScaleKind::SqrtStdDev),
344        NormalizationMethod::Range => column_scale(data, n, m, ScaleKind::Range),
345        NormalizationMethod::CurveCenter => row_normalize(data, n, m, RowNorm::Center),
346        NormalizationMethod::CurveStandardize => row_normalize(data, n, m, RowNorm::Standardize),
347        NormalizationMethod::CurveRange => row_normalize(data, n, m, RowNorm::Range),
348        NormalizationMethod::CurveLp(p) => curve_lp_normalize(data, argvals, n, m, p),
349    }
350}
351
352#[derive(Clone, Copy)]
353enum ScaleKind {
354    StdDev,
355    SqrtStdDev,
356    Range,
357}
358
359fn column_scale(data: &FdMatrix, n: usize, m: usize, kind: ScaleKind) -> FdMatrix {
360    let mut result = FdMatrix::zeros(n, m);
361    for j in 0..m {
362        let col = data.column(j);
363        let mean = col.iter().sum::<f64>() / n as f64;
364        let scale = match kind {
365            ScaleKind::StdDev => {
366                let var =
367                    col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
368                var.sqrt()
369            }
370            ScaleKind::SqrtStdDev => {
371                let var =
372                    col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / (n - 1).max(1) as f64;
373                var.sqrt().sqrt()
374            }
375            ScaleKind::Range => {
376                let min = col.iter().copied().fold(f64::INFINITY, f64::min);
377                let max = col.iter().copied().fold(f64::NEG_INFINITY, f64::max);
378                max - min
379            }
380        };
381        let out = result.column_mut(j);
382        let denom = if scale > 1e-15 { scale } else { 1.0 };
383        for i in 0..n {
384            out[i] = (col[i] - mean) / denom;
385        }
386    }
387    result
388}
389
390#[derive(Clone, Copy)]
391enum RowNorm {
392    Center,
393    Standardize,
394    Range,
395}
396
397fn row_normalize(data: &FdMatrix, n: usize, m: usize, kind: RowNorm) -> FdMatrix {
398    let mut result = FdMatrix::zeros(n, m);
399    for i in 0..n {
400        let row: Vec<f64> = (0..m).map(|j| data[(i, j)]).collect();
401        let mean = row.iter().sum::<f64>() / m as f64;
402        match kind {
403            RowNorm::Center => {
404                for j in 0..m {
405                    result[(i, j)] = row[j] - mean;
406                }
407            }
408            RowNorm::Standardize => {
409                let std = (row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>()
410                    / (m - 1).max(1) as f64)
411                    .sqrt();
412                let denom = if std > 1e-15 { std } else { 1.0 };
413                for j in 0..m {
414                    result[(i, j)] = (row[j] - mean) / denom;
415                }
416            }
417            RowNorm::Range => {
418                let min = row.iter().copied().fold(f64::INFINITY, f64::min);
419                let max = row.iter().copied().fold(f64::NEG_INFINITY, f64::max);
420                let range = max - min;
421                let denom = if range > 1e-15 { range } else { 1.0 };
422                for j in 0..m {
423                    result[(i, j)] = (row[j] - min) / denom;
424                }
425            }
426        }
427    }
428    result
429}
430
431/// Per-curve Lp normalization: divide each curve by its Lp norm.
432fn curve_lp_normalize(data: &FdMatrix, argvals: &[f64], n: usize, m: usize, p: f64) -> FdMatrix {
433    let mut result = FdMatrix::zeros(n, m);
434    if p.is_infinite() {
435        // L-infinity: divide by max|f(t)|
436        for i in 0..n {
437            let max_abs = (0..m).map(|j| data[(i, j)].abs()).fold(0.0f64, f64::max);
438            let denom = if max_abs > 1e-15 { max_abs } else { 1.0 };
439            for j in 0..m {
440                result[(i, j)] = data[(i, j)] / denom;
441            }
442        }
443    } else {
444        let norms = norm_lp_1d(data, argvals, p);
445        for i in 0..n {
446            let denom = if norms[i] > 1e-15 { norms[i] } else { 1.0 };
447            for j in 0..m {
448                result[(i, j)] = data[(i, j)] / denom;
449            }
450        }
451    }
452    result
453}
454
455/// Compute Lp norm for each sample.
456///
457/// # Arguments
458/// * `data` - Functional data matrix (n x m)
459/// * `argvals` - Evaluation points for integration
460/// * `p` - Order of the norm (e.g., 2.0 for L2)
461///
462/// # Returns
463/// Vector of Lp norms for each sample
464pub fn norm_lp_1d(data: &FdMatrix, argvals: &[f64], p: f64) -> Vec<f64> {
465    let (n, m) = data.shape();
466    if n == 0 || m == 0 || argvals.len() != m {
467        return Vec::new();
468    }
469
470    let weights = simpsons_weights(argvals);
471
472    if (p - 2.0).abs() < 1e-14 {
473        iter_maybe_parallel!(0..n)
474            .map(|i| {
475                let mut integral = 0.0;
476                for j in 0..m {
477                    let v = data[(i, j)];
478                    integral += v * v * weights[j];
479                }
480                integral.sqrt()
481            })
482            .collect()
483    } else if (p - 1.0).abs() < 1e-14 {
484        iter_maybe_parallel!(0..n)
485            .map(|i| {
486                let mut integral = 0.0;
487                for j in 0..m {
488                    integral += data[(i, j)].abs() * weights[j];
489                }
490                integral
491            })
492            .collect()
493    } else {
494        iter_maybe_parallel!(0..n)
495            .map(|i| {
496                let mut integral = 0.0;
497                for j in 0..m {
498                    integral += data[(i, j)].abs().powf(p) * weights[j];
499                }
500                integral.powf(1.0 / p)
501            })
502            .collect()
503    }
504}
505
506/// Compute numerical derivative of functional data (parallelized over rows).
507///
508/// # Arguments
509/// * `data` - Functional data matrix (n x m)
510/// * `argvals` - Evaluation points
511/// * `nderiv` - Order of derivative
512///
513/// # Returns
514/// Derivative data matrix
515///
516/// # Examples
517///
518/// ```
519/// use fdars_core::matrix::FdMatrix;
520/// use fdars_core::fdata::deriv_1d;
521///
522/// // Linear function f(t) = t on [0, 1], derivative should be ~1
523/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
524/// let data = FdMatrix::from_column_major(argvals.clone(), 1, 20).unwrap();
525/// let deriv = deriv_1d(&data, &argvals, 1);
526/// assert_eq!(deriv.shape(), (1, 20));
527/// // Interior points should have derivative close to 1.0
528/// assert!((deriv[(0, 10)] - 1.0).abs() < 0.1);
529/// ```
530/// Compute one derivative step: forward/central/backward differences written column-wise.
531fn deriv_1d_step(
532    current: &FdMatrix,
533    n: usize,
534    m: usize,
535    h0: f64,
536    hn: f64,
537    h_central: &[f64],
538) -> FdMatrix {
539    let mut next = FdMatrix::zeros(n, m);
540    // Column 0: forward difference
541    let src_col0 = current.column(0);
542    let src_col1 = current.column(1);
543    let dst = next.column_mut(0);
544    for i in 0..n {
545        dst[i] = (src_col1[i] - src_col0[i]) / h0;
546    }
547    // Interior columns: central difference
548    for j in 1..(m - 1) {
549        let src_prev = current.column(j - 1);
550        let src_next = current.column(j + 1);
551        let dst = next.column_mut(j);
552        let h = h_central[j - 1];
553        for i in 0..n {
554            dst[i] = (src_next[i] - src_prev[i]) / h;
555        }
556    }
557    // Column m-1: backward difference
558    let src_colm2 = current.column(m - 2);
559    let src_colm1 = current.column(m - 1);
560    let dst = next.column_mut(m - 1);
561    for i in 0..n {
562        dst[i] = (src_colm1[i] - src_colm2[i]) / hn;
563    }
564    next
565}
566
567pub fn deriv_1d(data: &FdMatrix, argvals: &[f64], nderiv: usize) -> FdMatrix {
568    let (n, m) = data.shape();
569    if n == 0 || m < 2 || argvals.len() != m {
570        return FdMatrix::zeros(n, m);
571    }
572    if nderiv == 0 {
573        return data.clone();
574    }
575
576    let mut current = data.clone();
577
578    // Pre-compute step sizes for central differences
579    let h0 = argvals[1] - argvals[0];
580    let hn = argvals[m - 1] - argvals[m - 2];
581    let h_central: Vec<f64> = (1..(m - 1))
582        .map(|j| argvals[j + 1] - argvals[j - 1])
583        .collect();
584
585    for _ in 0..nderiv {
586        current = deriv_1d_step(&current, n, m, h0, hn, &h_central);
587    }
588
589    current
590}
591
592/// Result of 2D partial derivatives.
593#[derive(Debug, Clone, PartialEq)]
594#[non_exhaustive]
595pub struct Deriv2DResult {
596    /// Partial derivative with respect to s (∂f/∂s)
597    pub ds: FdMatrix,
598    /// Partial derivative with respect to t (∂f/∂t)
599    pub dt: FdMatrix,
600    /// Mixed partial derivative (∂²f/∂s∂t)
601    pub dsdt: FdMatrix,
602}
603
604/// Compute finite-difference step sizes for a grid.
605///
606/// Uses forward/backward difference at boundaries and central difference for interior.
607fn compute_step_sizes(argvals: &[f64]) -> Vec<f64> {
608    let m = argvals.len();
609    if m < 2 {
610        return vec![1.0; m];
611    }
612    (0..m)
613        .map(|j| {
614            if j == 0 {
615                argvals[1] - argvals[0]
616            } else if j == m - 1 {
617                argvals[m - 1] - argvals[m - 2]
618            } else {
619                argvals[j + 1] - argvals[j - 1]
620            }
621        })
622        .collect()
623}
624
625/// Collect per-curve row vectors into a column-major FdMatrix.
626fn reassemble_colmajor(rows: &[Vec<f64>], n: usize, ncol: usize) -> FdMatrix {
627    let mut mat = FdMatrix::zeros(n, ncol);
628    for i in 0..n {
629        for j in 0..ncol {
630            mat[(i, j)] = rows[i][j];
631        }
632    }
633    mat
634}
635
636/// Compute 2D partial derivatives for surface data.
637///
638/// For a surface f(s,t), computes:
639/// - ds: partial derivative with respect to s (∂f/∂s)
640/// - dt: partial derivative with respect to t (∂f/∂t)
641/// - dsdt: mixed partial derivative (∂²f/∂s∂t)
642///
643/// # Arguments
644/// * `data` - Functional data matrix, n surfaces, each stored as m1*m2 values
645/// * `argvals_s` - Grid points in s direction (length m1)
646/// * `argvals_t` - Grid points in t direction (length m2)
647/// * `m1` - Grid size in s direction
648/// * `m2` - Grid size in t direction
649pub fn deriv_2d(
650    data: &FdMatrix,
651    argvals_s: &[f64],
652    argvals_t: &[f64],
653    m1: usize,
654    m2: usize,
655) -> Option<Deriv2DResult> {
656    let n = data.nrows();
657    let ncol = m1 * m2;
658    if n == 0
659        || ncol == 0
660        || m1 < 2
661        || m2 < 2
662        || data.ncols() != ncol
663        || argvals_s.len() != m1
664        || argvals_t.len() != m2
665    {
666        return None;
667    }
668
669    let hs = compute_step_sizes(argvals_s);
670    let ht = compute_step_sizes(argvals_t);
671
672    // Compute all derivatives in parallel over surfaces
673    let results: Vec<(Vec<f64>, Vec<f64>, Vec<f64>)> = iter_maybe_parallel!(0..n)
674        .map(|i| {
675            let mut ds = vec![0.0; ncol];
676            let mut dt = vec![0.0; ncol];
677            let mut dsdt = vec![0.0; ncol];
678
679            let get_val = |si: usize, ti: usize| -> f64 { data[(i, si + ti * m1)] };
680
681            for ti in 0..m2 {
682                for si in 0..m1 {
683                    let idx = si + ti * m1;
684                    let (ds_val, dt_val, dsdt_val) =
685                        compute_2d_derivatives(get_val, si, ti, m1, m2, &hs, &ht);
686                    ds[idx] = ds_val;
687                    dt[idx] = dt_val;
688                    dsdt[idx] = dsdt_val;
689                }
690            }
691
692            (ds, dt, dsdt)
693        })
694        .collect();
695
696    let (ds_vecs, (dt_vecs, dsdt_vecs)): (Vec<Vec<f64>>, (Vec<Vec<f64>>, Vec<Vec<f64>>)) =
697        results.into_iter().map(|(a, b, c)| (a, (b, c))).unzip();
698
699    Some(Deriv2DResult {
700        ds: reassemble_colmajor(&ds_vecs, n, ncol),
701        dt: reassemble_colmajor(&dt_vecs, n, ncol),
702        dsdt: reassemble_colmajor(&dsdt_vecs, n, ncol),
703    })
704}
705
706/// Compute the geometric median (L1 median) of functional data using Weiszfeld's algorithm.
707///
708/// The geometric median minimizes sum of L2 distances to all curves.
709///
710/// # Arguments
711/// * `data` - Functional data matrix (n x m)
712/// * `argvals` - Evaluation points for integration
713/// * `max_iter` - Maximum iterations
714/// * `tol` - Convergence tolerance
715pub fn geometric_median_1d(
716    data: &FdMatrix,
717    argvals: &[f64],
718    max_iter: usize,
719    tol: f64,
720) -> Vec<f64> {
721    let (n, m) = data.shape();
722    if n == 0 || m == 0 || argvals.len() != m {
723        return Vec::new();
724    }
725
726    let weights = simpsons_weights(argvals);
727    weiszfeld_iteration(data, &weights, max_iter, tol)
728}
729
730/// Compute the geometric median for 2D functional data.
731///
732/// Data is stored as n x (m1*m2) matrix where each row is a flattened surface.
733///
734/// # Arguments
735/// * `data` - Functional data matrix (n x m) where m = m1*m2
736/// * `argvals_s` - Grid points in s direction (length m1)
737/// * `argvals_t` - Grid points in t direction (length m2)
738/// * `max_iter` - Maximum iterations
739/// * `tol` - Convergence tolerance
740pub fn geometric_median_2d(
741    data: &FdMatrix,
742    argvals_s: &[f64],
743    argvals_t: &[f64],
744    max_iter: usize,
745    tol: f64,
746) -> Vec<f64> {
747    let (n, m) = data.shape();
748    let expected_cols = argvals_s.len() * argvals_t.len();
749    if n == 0 || m == 0 || m != expected_cols {
750        return Vec::new();
751    }
752
753    let weights = simpsons_weights_2d(argvals_s, argvals_t);
754    weiszfeld_iteration(data, &weights, max_iter, tol)
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use crate::test_helpers::uniform_grid;
761    use std::f64::consts::PI;
762
763    // ============== Mean tests ==============
764
765    #[test]
766    fn test_mean_1d() {
767        // 2 samples, 3 points each
768        // Sample 1: [1, 2, 3]
769        // Sample 2: [3, 4, 5]
770        // Mean should be [2, 3, 4]
771        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0]; // column-major
772        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
773        let mean = mean_1d(&mat);
774        assert_eq!(mean, vec![2.0, 3.0, 4.0]);
775    }
776
777    #[test]
778    fn test_mean_1d_single_sample() {
779        let data = vec![1.0, 2.0, 3.0];
780        let mat = FdMatrix::from_column_major(data, 1, 3).unwrap();
781        let mean = mean_1d(&mat);
782        assert_eq!(mean, vec![1.0, 2.0, 3.0]);
783    }
784
785    #[test]
786    fn test_mean_1d_invalid() {
787        assert!(mean_1d(&FdMatrix::zeros(0, 0)).is_empty());
788    }
789
790    #[test]
791    fn test_mean_2d_delegates() {
792        let data = vec![1.0, 3.0, 2.0, 4.0];
793        let mat = FdMatrix::from_column_major(data, 2, 2).unwrap();
794        let mean1d = mean_1d(&mat);
795        let mean2d = mean_2d(&mat);
796        assert_eq!(mean1d, mean2d);
797    }
798
799    // ============== Center tests ==============
800
801    #[test]
802    fn test_center_1d() {
803        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0]; // column-major
804        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
805        let centered = center_1d(&mat);
806        // Mean is [2, 3, 4], so centered should be [-1, 1, -1, 1, -1, 1]
807        assert_eq!(centered.as_slice(), &[-1.0, 1.0, -1.0, 1.0, -1.0, 1.0]);
808    }
809
810    #[test]
811    fn test_center_1d_mean_zero() {
812        let data = vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0];
813        let mat = FdMatrix::from_column_major(data, 2, 3).unwrap();
814        let centered = center_1d(&mat);
815        let centered_mean = mean_1d(&centered);
816        for m in centered_mean {
817            assert!(m.abs() < 1e-10, "Centered data should have zero mean");
818        }
819    }
820
821    #[test]
822    fn test_center_1d_invalid() {
823        let centered = center_1d(&FdMatrix::zeros(0, 0));
824        assert!(centered.is_empty());
825    }
826
827    // ============== Norm tests ==============
828
829    #[test]
830    fn test_norm_lp_1d_constant() {
831        // Constant function 2 on [0, 1] has L2 norm = 2
832        let argvals = uniform_grid(21);
833        let data: Vec<f64> = vec![2.0; 21];
834        let mat = FdMatrix::from_column_major(data, 1, 21).unwrap();
835        let norms = norm_lp_1d(&mat, &argvals, 2.0);
836        assert_eq!(norms.len(), 1);
837        assert!(
838            (norms[0] - 2.0).abs() < 0.1,
839            "L2 norm of constant 2 should be 2"
840        );
841    }
842
843    #[test]
844    fn test_norm_lp_1d_sine() {
845        // L2 norm of sin(pi*x) on [0, 1] = sqrt(0.5)
846        let argvals = uniform_grid(101);
847        let data: Vec<f64> = argvals.iter().map(|&x| (PI * x).sin()).collect();
848        let mat = FdMatrix::from_column_major(data, 1, 101).unwrap();
849        let norms = norm_lp_1d(&mat, &argvals, 2.0);
850        let expected = 0.5_f64.sqrt();
851        assert!(
852            (norms[0] - expected).abs() < 0.05,
853            "Expected {}, got {}",
854            expected,
855            norms[0]
856        );
857    }
858
859    #[test]
860    fn test_norm_lp_1d_invalid() {
861        assert!(norm_lp_1d(&FdMatrix::zeros(0, 0), &[], 2.0).is_empty());
862    }
863
864    // ============== Derivative tests ==============
865
866    #[test]
867    fn test_deriv_1d_linear() {
868        // Derivative of linear function x should be 1
869        let argvals = uniform_grid(21);
870        let data = argvals.clone();
871        let mat = FdMatrix::from_column_major(data, 1, 21).unwrap();
872        let deriv = deriv_1d(&mat, &argvals, 1);
873        // Interior points should have derivative close to 1
874        for j in 2..19 {
875            assert!(
876                (deriv[(0, j)] - 1.0).abs() < 0.1,
877                "Derivative of x should be 1"
878            );
879        }
880    }
881
882    #[test]
883    fn test_deriv_1d_quadratic() {
884        // Derivative of x^2 should be 2x
885        let argvals = uniform_grid(51);
886        let data: Vec<f64> = argvals.iter().map(|&x| x * x).collect();
887        let mat = FdMatrix::from_column_major(data, 1, 51).unwrap();
888        let deriv = deriv_1d(&mat, &argvals, 1);
889        // Check interior points
890        for j in 5..45 {
891            let expected = 2.0 * argvals[j];
892            assert!(
893                (deriv[(0, j)] - expected).abs() < 0.1,
894                "Derivative of x^2 should be 2x"
895            );
896        }
897    }
898
899    #[test]
900    fn test_deriv_1d_invalid() {
901        let result = deriv_1d(&FdMatrix::zeros(0, 0), &[], 1);
902        assert!(result.is_empty() || result.as_slice().iter().all(|&x| x == 0.0));
903    }
904
905    // ============== Geometric median tests ==============
906
907    #[test]
908    fn test_geometric_median_identical_curves() {
909        // All curves identical -> median = that curve
910        let argvals = uniform_grid(21);
911        let n = 5;
912        let m = 21;
913        let mut data = vec![0.0; n * m];
914        for i in 0..n {
915            for j in 0..m {
916                data[i + j * n] = (2.0 * PI * argvals[j]).sin();
917            }
918        }
919        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
920        let median = geometric_median_1d(&mat, &argvals, 100, 1e-6);
921        for j in 0..m {
922            let expected = (2.0 * PI * argvals[j]).sin();
923            assert!(
924                (median[j] - expected).abs() < 0.01,
925                "Median should equal all curves"
926            );
927        }
928    }
929
930    #[test]
931    fn test_geometric_median_converges() {
932        let argvals = uniform_grid(21);
933        let n = 10;
934        let m = 21;
935        let mut data = vec![0.0; n * m];
936        for i in 0..n {
937            for j in 0..m {
938                data[i + j * n] = (i as f64 / n as f64) * argvals[j];
939            }
940        }
941        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
942        let median = geometric_median_1d(&mat, &argvals, 100, 1e-6);
943        assert_eq!(median.len(), m);
944        assert!(median.iter().all(|&x| x.is_finite()));
945    }
946
947    #[test]
948    fn test_geometric_median_invalid() {
949        assert!(geometric_median_1d(&FdMatrix::zeros(0, 0), &[], 100, 1e-6).is_empty());
950    }
951
952    // ============== 2D derivative tests ==============
953
954    #[test]
955    fn test_deriv_2d_linear_surface() {
956        // f(s, t) = 2*s + 3*t
957        // ∂f/∂s = 2, ∂f/∂t = 3, ∂²f/∂s∂t = 0
958        let m1 = 11;
959        let m2 = 11;
960        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
961        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
962
963        let n = 1; // single surface
964        let ncol = m1 * m2;
965        let mut data = vec![0.0; n * ncol];
966
967        for si in 0..m1 {
968            for ti in 0..m2 {
969                let s = argvals_s[si];
970                let t = argvals_t[ti];
971                let idx = si + ti * m1;
972                data[idx] = 2.0 * s + 3.0 * t;
973            }
974        }
975
976        let mat = FdMatrix::from_column_major(data, n, ncol).unwrap();
977        let result = deriv_2d(&mat, &argvals_s, &argvals_t, m1, m2).unwrap();
978
979        // Check interior points for ∂f/∂s ≈ 2
980        for si in 2..(m1 - 2) {
981            for ti in 2..(m2 - 2) {
982                let idx = si + ti * m1;
983                assert!(
984                    (result.ds[(0, idx)] - 2.0).abs() < 0.2,
985                    "∂f/∂s at ({}, {}) = {}, expected 2",
986                    si,
987                    ti,
988                    result.ds[(0, idx)]
989                );
990            }
991        }
992
993        // Check interior points for ∂f/∂t ≈ 3
994        for si in 2..(m1 - 2) {
995            for ti in 2..(m2 - 2) {
996                let idx = si + ti * m1;
997                assert!(
998                    (result.dt[(0, idx)] - 3.0).abs() < 0.2,
999                    "∂f/∂t at ({}, {}) = {}, expected 3",
1000                    si,
1001                    ti,
1002                    result.dt[(0, idx)]
1003                );
1004            }
1005        }
1006
1007        // Check interior points for mixed partial ≈ 0
1008        for si in 2..(m1 - 2) {
1009            for ti in 2..(m2 - 2) {
1010                let idx = si + ti * m1;
1011                assert!(
1012                    result.dsdt[(0, idx)].abs() < 0.5,
1013                    "∂²f/∂s∂t at ({}, {}) = {}, expected 0",
1014                    si,
1015                    ti,
1016                    result.dsdt[(0, idx)]
1017                );
1018            }
1019        }
1020    }
1021
1022    #[test]
1023    fn test_deriv_2d_quadratic_surface() {
1024        // f(s, t) = s*t
1025        // ∂f/∂s = t, ∂f/∂t = s, ∂²f/∂s∂t = 1
1026        let m1 = 21;
1027        let m2 = 21;
1028        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
1029        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
1030
1031        let n = 1;
1032        let ncol = m1 * m2;
1033        let mut data = vec![0.0; n * ncol];
1034
1035        for si in 0..m1 {
1036            for ti in 0..m2 {
1037                let s = argvals_s[si];
1038                let t = argvals_t[ti];
1039                let idx = si + ti * m1;
1040                data[idx] = s * t;
1041            }
1042        }
1043
1044        let mat = FdMatrix::from_column_major(data, n, ncol).unwrap();
1045        let result = deriv_2d(&mat, &argvals_s, &argvals_t, m1, m2).unwrap();
1046
1047        // Check interior points for ∂f/∂s ≈ t
1048        for si in 3..(m1 - 3) {
1049            for ti in 3..(m2 - 3) {
1050                let idx = si + ti * m1;
1051                let expected = argvals_t[ti];
1052                assert!(
1053                    (result.ds[(0, idx)] - expected).abs() < 0.1,
1054                    "∂f/∂s at ({}, {}) = {}, expected {}",
1055                    si,
1056                    ti,
1057                    result.ds[(0, idx)],
1058                    expected
1059                );
1060            }
1061        }
1062
1063        // Check interior points for ∂f/∂t ≈ s
1064        for si in 3..(m1 - 3) {
1065            for ti in 3..(m2 - 3) {
1066                let idx = si + ti * m1;
1067                let expected = argvals_s[si];
1068                assert!(
1069                    (result.dt[(0, idx)] - expected).abs() < 0.1,
1070                    "∂f/∂t at ({}, {}) = {}, expected {}",
1071                    si,
1072                    ti,
1073                    result.dt[(0, idx)],
1074                    expected
1075                );
1076            }
1077        }
1078
1079        // Check interior points for mixed partial ≈ 1
1080        for si in 3..(m1 - 3) {
1081            for ti in 3..(m2 - 3) {
1082                let idx = si + ti * m1;
1083                assert!(
1084                    (result.dsdt[(0, idx)] - 1.0).abs() < 0.3,
1085                    "∂²f/∂s∂t at ({}, {}) = {}, expected 1",
1086                    si,
1087                    ti,
1088                    result.dsdt[(0, idx)]
1089                );
1090            }
1091        }
1092    }
1093
1094    #[test]
1095    fn test_deriv_2d_invalid_input() {
1096        // Empty data
1097        let result = deriv_2d(&FdMatrix::zeros(0, 0), &[], &[], 0, 0);
1098        assert!(result.is_none());
1099
1100        // Mismatched dimensions
1101        let mat = FdMatrix::from_column_major(vec![1.0; 4], 1, 4).unwrap();
1102        let argvals = vec![0.0, 1.0];
1103        let result = deriv_2d(&mat, &argvals, &[0.0, 0.5, 1.0], 2, 2);
1104        assert!(result.is_none());
1105    }
1106
1107    // ============== 2D geometric median tests ==============
1108
1109    #[test]
1110    fn test_geometric_median_2d_basic() {
1111        // Three identical surfaces -> median = that surface
1112        let m1 = 5;
1113        let m2 = 5;
1114        let m = m1 * m2;
1115        let n = 3;
1116        let argvals_s: Vec<f64> = (0..m1).map(|i| i as f64 / (m1 - 1) as f64).collect();
1117        let argvals_t: Vec<f64> = (0..m2).map(|i| i as f64 / (m2 - 1) as f64).collect();
1118
1119        let mut data = vec![0.0; n * m];
1120
1121        // Create identical surfaces: f(s, t) = s + t
1122        for i in 0..n {
1123            for si in 0..m1 {
1124                for ti in 0..m2 {
1125                    let idx = si + ti * m1;
1126                    let s = argvals_s[si];
1127                    let t = argvals_t[ti];
1128                    data[i + idx * n] = s + t;
1129                }
1130            }
1131        }
1132
1133        let mat = FdMatrix::from_column_major(data, n, m).unwrap();
1134        let median = geometric_median_2d(&mat, &argvals_s, &argvals_t, 100, 1e-6);
1135        assert_eq!(median.len(), m);
1136
1137        // Check that median equals the surface
1138        for si in 0..m1 {
1139            for ti in 0..m2 {
1140                let idx = si + ti * m1;
1141                let expected = argvals_s[si] + argvals_t[ti];
1142                assert!(
1143                    (median[idx] - expected).abs() < 0.01,
1144                    "Median at ({}, {}) = {}, expected {}",
1145                    si,
1146                    ti,
1147                    median[idx],
1148                    expected
1149                );
1150            }
1151        }
1152    }
1153
1154    #[test]
1155    fn test_nan_mean_no_panic() {
1156        let mut data_vec = vec![1.0; 6];
1157        data_vec[2] = f64::NAN;
1158        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1159        let m = mean_1d(&data);
1160        assert_eq!(m.len(), 3);
1161    }
1162
1163    #[test]
1164    fn test_nan_center_no_panic() {
1165        let mut data_vec = vec![1.0; 6];
1166        data_vec[2] = f64::NAN;
1167        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1168        let c = center_1d(&data);
1169        assert_eq!(c.nrows(), 2);
1170    }
1171
1172    #[test]
1173    fn test_nan_norm_no_panic() {
1174        let mut data_vec = vec![1.0; 6];
1175        data_vec[2] = f64::NAN;
1176        let data = FdMatrix::from_column_major(data_vec, 2, 3).unwrap();
1177        let argvals = vec![0.0, 0.5, 1.0];
1178        let norms = norm_lp_1d(&data, &argvals, 2.0);
1179        assert_eq!(norms.len(), 2);
1180    }
1181
1182    #[test]
1183    fn test_n1_norm() {
1184        let data = FdMatrix::from_column_major(vec![0.0, 1.0, 0.0], 1, 3).unwrap();
1185        let argvals = vec![0.0, 0.5, 1.0];
1186        let norms = norm_lp_1d(&data, &argvals, 2.0);
1187        assert_eq!(norms.len(), 1);
1188        assert!(norms[0] > 0.0);
1189    }
1190
1191    #[test]
1192    fn test_n2_center() {
1193        let data = FdMatrix::from_column_major(vec![1.0, 3.0, 2.0, 4.0], 2, 2).unwrap();
1194        let centered = center_1d(&data);
1195        // Mean at each point: [2.0, 3.0]
1196        // centered[0,0] = 1.0 - 2.0 = -1.0
1197        assert!((centered[(0, 0)] - (-1.0)).abs() < 1e-12);
1198        assert!((centered[(1, 0)] - 1.0).abs() < 1e-12);
1199    }
1200
1201    #[test]
1202    fn test_deriv_nderiv0() {
1203        // nderiv=0 returns the original data (0th derivative = identity)
1204        let data = FdMatrix::from_column_major(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], 2, 3).unwrap();
1205        let argvals = vec![0.0, 0.5, 1.0];
1206        let result = deriv_1d(&data, &argvals, 0);
1207        assert_eq!(result.shape(), data.shape());
1208        for i in 0..2 {
1209            for j in 0..3 {
1210                assert!((result[(i, j)] - data[(i, j)]).abs() < 1e-12);
1211            }
1212        }
1213    }
1214
1215    // ============== Normalize tests ==============
1216
1217    #[test]
1218    fn test_normalize_autoscale() {
1219        // 3 curves, 4 time points
1220        let data = FdMatrix::from_column_major(
1221            vec![
1222                1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
1223            ],
1224            3,
1225            4,
1226        )
1227        .unwrap();
1228        let scaled = normalize(&data, NormalizationMethod::Autoscale);
1229        // Each column should have mean ≈ 0 and std ≈ 1
1230        for j in 0..4 {
1231            let col = scaled.column(j);
1232            let mean = col.iter().sum::<f64>() / 3.0;
1233            assert!(
1234                mean.abs() < 1e-10,
1235                "column {j} mean should be 0, got {mean}"
1236            );
1237            let var = col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / 2.0;
1238            assert!(
1239                (var - 1.0).abs() < 1e-10,
1240                "column {j} variance should be 1, got {var}"
1241            );
1242        }
1243    }
1244
1245    #[test]
1246    fn test_normalize_pareto() {
1247        let data =
1248            FdMatrix::from_column_major(vec![1.0, 5.0, 3.0, 10.0, 20.0, 30.0], 2, 3).unwrap();
1249        let scaled = normalize(&data, NormalizationMethod::Pareto);
1250        // Columns should be centered and scaled by sqrt(std)
1251        for j in 0..3 {
1252            let col = scaled.column(j);
1253            let mean = col.iter().sum::<f64>() / 2.0;
1254            assert!(mean.abs() < 1e-10, "column {j} mean should be 0");
1255        }
1256    }
1257
1258    #[test]
1259    fn test_normalize_range() {
1260        let data = FdMatrix::from_column_major(vec![0.0, 10.0, 2.0, 8.0], 2, 2).unwrap();
1261        let scaled = normalize(&data, NormalizationMethod::Range);
1262        // Column 0: values [0, 10], range 10, centered [-5, 5], scaled [-0.5, 0.5]
1263        assert!((scaled[(0, 0)] - (-0.5)).abs() < 1e-10);
1264        assert!((scaled[(1, 0)] - 0.5).abs() < 1e-10);
1265    }
1266
1267    #[test]
1268    fn test_normalize_curve_center() {
1269        let data = FdMatrix::from_column_major(vec![1.0, 4.0, 3.0, 6.0, 5.0, 8.0], 2, 3).unwrap();
1270        let result = normalize(&data, NormalizationMethod::CurveCenter);
1271        // Row 0: [1, 3, 5], mean=3, centered=[-2, 0, 2]
1272        assert!((result[(0, 0)] - (-2.0)).abs() < 1e-10);
1273        assert!((result[(0, 1)] - 0.0).abs() < 1e-10);
1274        assert!((result[(0, 2)] - 2.0).abs() < 1e-10);
1275    }
1276
1277    #[test]
1278    fn test_normalize_curve_standardize() {
1279        let data = FdMatrix::from_column_major(vec![1.0, 4.0, 3.0, 6.0, 5.0, 8.0], 2, 3).unwrap();
1280        let result = normalize(&data, NormalizationMethod::CurveStandardize);
1281        // Each row should have mean ≈ 0 and std ≈ 1
1282        for i in 0..2 {
1283            let row: Vec<f64> = (0..3).map(|j| result[(i, j)]).collect();
1284            let mean = row.iter().sum::<f64>() / 3.0;
1285            assert!(mean.abs() < 1e-10, "row {i} mean should be 0");
1286            let var = row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / 2.0;
1287            assert!((var - 1.0).abs() < 1e-10, "row {i} variance should be 1");
1288        }
1289    }
1290
1291    #[test]
1292    fn test_normalize_curve_range() {
1293        let data =
1294            FdMatrix::from_column_major(vec![2.0, 10.0, 4.0, 20.0, 6.0, 30.0], 2, 3).unwrap();
1295        let result = normalize(&data, NormalizationMethod::CurveRange);
1296        // Row 0: [2, 4, 6] -> [0.0, 0.5, 1.0]
1297        assert!((result[(0, 0)] - 0.0).abs() < 1e-10);
1298        assert!((result[(0, 1)] - 0.5).abs() < 1e-10);
1299        assert!((result[(0, 2)] - 1.0).abs() < 1e-10);
1300    }
1301
1302    #[test]
1303    fn test_normalize_center_matches_center_1d() {
1304        let data = FdMatrix::from_column_major(vec![1.0, 3.0, 2.0, 4.0, 3.0, 5.0], 2, 3).unwrap();
1305        let a = center_1d(&data);
1306        let b = normalize(&data, NormalizationMethod::Center);
1307        assert_eq!(a.as_slice(), b.as_slice());
1308    }
1309
1310    #[test]
1311    fn test_normalize_curve_lp_l2() {
1312        // 2 curves on 3 points, uniform grid [0, 1]
1313        let data = FdMatrix::from_column_major(vec![3.0, 0.0, 0.0, 4.0, 0.0, 0.0], 2, 3).unwrap();
1314        let t = vec![0.0, 0.5, 1.0];
1315        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
1316        // Curve 0: [3, 0, 0], L2 norm = sqrt(∫ 9 dt) on [0,1] with trapezoidal ≈ sqrt(9*0.5) ~ 2.12
1317        // After normalization, L2 norm should be ≈ 1
1318        let norms = norm_lp_1d(&result, &t, 2.0);
1319        assert!(
1320            (norms[0] - 1.0).abs() < 0.1,
1321            "L2 norm after normalization should be ≈ 1, got {}",
1322            norms[0]
1323        );
1324    }
1325
1326    #[test]
1327    fn test_normalize_curve_lp_l1() {
1328        let data = FdMatrix::from_column_major(vec![2.0, 4.0, 6.0, 8.0], 2, 2).unwrap();
1329        let t = vec![0.0, 1.0];
1330        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(1.0));
1331        // After L1 normalization, L1 norm of each curve should be ≈ 1
1332        let norms = norm_lp_1d(&result, &t, 1.0);
1333        for (i, &norm) in norms.iter().enumerate() {
1334            assert!(
1335                (norm - 1.0).abs() < 0.1,
1336                "curve {i} L1 norm after normalization should be ≈ 1, got {norm}"
1337            );
1338        }
1339    }
1340
1341    #[test]
1342    fn test_normalize_curve_lp_linf() {
1343        let data =
1344            FdMatrix::from_column_major(vec![2.0, -5.0, 4.0, -10.0, 6.0, 15.0], 2, 3).unwrap();
1345        let t = vec![0.0, 0.5, 1.0];
1346        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(f64::INFINITY));
1347        // L-inf norm = max |f(t)|; after normalization, max abs value should be ≈ 1
1348        for i in 0..2 {
1349            let max_abs: f64 = (0..3).map(|j| result[(i, j)].abs()).fold(0.0, f64::max);
1350            assert!(
1351                (max_abs - 1.0).abs() < 1e-10,
1352                "curve {i} max abs after L-inf normalization should be 1, got {max_abs}"
1353            );
1354        }
1355    }
1356
1357    #[test]
1358    fn test_normalize_curve_lp_zero_curve() {
1359        // Zero curve should stay zero (not divide by zero)
1360        let data = FdMatrix::from_column_major(vec![0.0, 1.0, 0.0, 2.0], 2, 2).unwrap();
1361        let t = vec![0.0, 1.0];
1362        let result = normalize_with_argvals(&data, &t, NormalizationMethod::CurveLp(2.0));
1363        // Curve 0 is all zeros — should remain zero
1364        assert!((result[(0, 0)]).abs() < 1e-15);
1365        assert!((result[(0, 1)]).abs() < 1e-15);
1366        // Curve 1 should be normalized
1367        assert!(result[(1, 0)].abs() > 0.0);
1368    }
1369}