Skip to main content

fdars_core/
fdata.rs

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