Skip to main content

fdars_core/
pda.rs

1//! Linear differential operators and principal differential analysis.
2//!
3//! This module provides two complementary tools for working with linear ordinary
4//! differential equations (ODEs) in functional data analysis:
5//!
6//! - [`Lfd`]: A linear differential operator that can be *applied* to a set of
7//!   observed curves, forming `Lx = D^m x + β_{m-1}(t)·D^{m-1}x + … + β₀(t)·x`.
8//! - [`principal_differential_analysis`]: Estimates the coefficient functions of a
9//!   linear ODE from a collection of observed solution curves.
10//!
11//! # Relationship to the R `fda` package
12//!
13//! The [`Lfd`] struct corresponds to the `Lfd` object in the R `fda` package
14//! (see <https://rdrr.io/cran/fda/man/Lfd.html>).  The PDA estimator corresponds
15//! to `pda.fd` and recovers the weight functions β₀(t), …, β_{m-1}(t) of the
16//! order-*m* ODE by independent least squares at each grid point
17//! (see <https://arxiv.org/abs/2406.18484>).
18//!
19//! # Examples
20//!
21//! ```
22//! use fdars_core::pda::{Lfd, PdaResult, principal_differential_analysis};
23//! use fdars_core::matrix::FdMatrix;
24//! use std::f64::consts::PI;
25//!
26//! // Build a harmonic-oscillator dataset: x_i(t) = cos(2π t)
27//! let omega = 2.0 * PI;
28//! let n_pts = 101;
29//! let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
30//! let n_curves = 5;
31//! let mut data = FdMatrix::zeros(n_curves, n_pts);
32//! for i in 0..n_curves {
33//!     let a = (i + 1) as f64;
34//!     for (j, &t) in argvals.iter().enumerate() {
35//!         data[(i, j)] = a * (omega * t).cos();
36//!     }
37//! }
38//!
39//! // Apply the identity-like Lfd (m=1, β₀ = 0) to verify shape invariance.
40//! let lfd = Lfd { coefs: vec![vec![0.0]] };
41//! let lx = lfd.apply(&data, &argvals).unwrap();
42//! assert_eq!(lx.shape(), data.shape());
43//! ```
44
45use crate::error::FdarError;
46use crate::matrix::FdMatrix;
47use nalgebra::DMatrix;
48
49// ─── Lfd ────────────────────────────────────────────────────────────────────
50
51/// A linear differential operator of the form
52/// `Lx(t) = D^m x(t) + β_{m-1}(t)·D^{m-1}x(t) + … + β₀(t)·x(t)`.
53///
54/// The operator holds `m` weight functions `β₀, …, β_{m-1}` (where `m` is the
55/// *order* of `L`).  Each weight function is sampled on the same evaluation
56/// grid that is later supplied to [`Lfd::apply`].
57///
58/// # Constant-coefficient operators
59///
60/// A length-1 inner `Vec<f64>` in `coefs` is treated as a constant and broadcast
61/// to all grid points.  For example, `coefs = vec![vec![-9.87]]` represents the
62/// operator `Lx = Dx - 9.87·x` (order 1, constant negative spring constant).
63#[derive(Debug, Clone, PartialEq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65pub struct Lfd {
66    /// Weight functions β₀(t), …, β_{m-1}(t), each sampled on the evaluation grid.
67    ///
68    /// `coefs.len()` equals the operator order *m*.
69    /// `coefs[k]` must have length either 1 (constant, broadcast) or `n_pts`
70    /// (grid-sampled), where `n_pts = argvals.len()` when [`apply`](Lfd::apply) is
71    /// called.
72    pub coefs: Vec<Vec<f64>>,
73}
74
75impl Lfd {
76    /// Apply the operator to each curve in `data`.
77    ///
78    /// For a curve `xᵢ`, computes the scalar sequence
79    /// `Lxᵢ(t_j) = D^m xᵢ(t_j) + Σ_{k=0}^{m-1} βₖ(t_j) · D^k xᵢ(t_j)`.
80    ///
81    /// Derivatives are estimated via the iterated finite-difference scheme in
82    /// [`crate::helpers::gradient`] (5-point stencil on uniform grids, 3-point
83    /// Lagrange on non-uniform grids).
84    ///
85    /// # Arguments
86    ///
87    /// * `data`    — Functional data matrix `(n × n_pts)`; each row is one curve.
88    /// * `argvals` — Evaluation points (length `n_pts`, must be sorted ascending).
89    ///
90    /// # Errors
91    ///
92    /// * [`FdarError::InvalidParameter`] if `coefs` is empty (`m = 0`); use
93    ///   `coefs = vec![vec![0.0]]` for a pure first-derivative operator.
94    /// * [`FdarError::InvalidDimension`] if `n_pts < 2` (single-point grid cannot produce derivatives).
95    /// * [`FdarError::InvalidDimension`] if `argvals.len() != data.ncols()`.
96    /// * [`FdarError::InvalidDimension`] if any `coefs[k]` has a length other than
97    ///   `1` or `n_pts`.
98    ///
99    /// # Returns
100    ///
101    /// An `FdMatrix` of the same shape as `data`, containing the operator output.
102    pub fn apply(&self, data: &FdMatrix, argvals: &[f64]) -> Result<FdMatrix, FdarError> {
103        let (n, n_pts) = data.shape();
104        let m = self.coefs.len(); // operator order
105
106        // Guard: empty coefs is not a valid operator (WR-02).
107        if m == 0 {
108            return Err(FdarError::InvalidParameter {
109                parameter: "coefs",
110                message: "Lfd requires at least one weight function (coefs.len() >= 1); \
111                          use coefs = vec![vec![0.0]] for the pure-derivative operator"
112                    .to_string(),
113            });
114        }
115
116        // Guard: single-point grid produces only zero derivatives (IN-03).
117        if n_pts < 2 {
118            return Err(FdarError::InvalidDimension {
119                parameter: "argvals",
120                expected: ">= 2 (required for finite-difference derivatives)".to_string(),
121                actual: n_pts.to_string(),
122            });
123        }
124
125        // Validate argvals length matches data columns.
126        if argvals.len() != n_pts {
127            return Err(FdarError::InvalidDimension {
128                parameter: "argvals",
129                expected: n_pts.to_string(),
130                actual: argvals.len().to_string(),
131            });
132        }
133
134        // Validate coefs[k] lengths: each must be 1 (constant) or n_pts (grid-sampled).
135        for (k, coef) in self.coefs.iter().enumerate() {
136            if coef.len() != 1 && coef.len() != n_pts {
137                return Err(FdarError::InvalidDimension {
138                    parameter: "coefs[k]",
139                    expected: format!("1 or {n_pts}"),
140                    actual: format!("coefs[{k}].len() = {}", coef.len()),
141                });
142            }
143        }
144
145        let mut out = FdMatrix::zeros(n, n_pts);
146
147        for i in 0..n {
148            // Extract curve i as a Vec<f64>.
149            let mut derivs: Vec<Vec<f64>> = Vec::with_capacity(m + 1);
150            let curve: Vec<f64> = (0..n_pts).map(|j| data[(i, j)]).collect();
151            derivs.push(curve);
152
153            // Compute D¹x, D²x, …, D^m x by iterating gradient.
154            for _ in 0..m {
155                let prev = derivs.last().unwrap();
156                let d = crate::helpers::gradient(prev, argvals);
157                derivs.push(d);
158            }
159
160            // Lx_i(t_j) = D^m x_i(t_j) + Σ_{k=0}^{m-1} β_k(t_j) · D^k x_i(t_j)
161            for j in 0..n_pts {
162                let mut lx_j = derivs[m][j];
163                for k in 0..m {
164                    // Broadcast length-1 coefficients (Pitfall 3).
165                    let beta_k = if self.coefs[k].len() == 1 {
166                        self.coefs[k][0]
167                    } else {
168                        self.coefs[k][j]
169                    };
170                    lx_j += beta_k * derivs[k][j];
171                }
172                // Write into column-major output: element (i, j) at i + j*n.
173                out[(i, j)] = lx_j;
174            }
175        }
176
177        Ok(out)
178    }
179}
180
181// ─── PdaResult ───────────────────────────────────────────────────────────────
182
183/// Result of [`principal_differential_analysis`].
184///
185/// Holds the recovered pointwise coefficient functions β₀(t), …, β_{m-1}(t)
186/// of the order-`m` linear ODE:
187///
188/// `D^m x(t) = -β₀(t)·x(t) - β₁(t)·Dx(t) - … - β_{m-1}(t)·D^{m-1}x(t)`.
189///
190/// # Fields
191///
192/// * `coefficients` — Length-`order` outer Vec; `coefficients[k]` is β_k(t) sampled
193///   at `argvals` (length `n_pts`).
194/// * `order`        — ODE order *m*.
195/// * `residuals`    — Optional residual matrix (currently always `None`; can be
196///   computed from [`Lfd::apply`] with the recovered coefficients).
197#[derive(Debug, Clone, PartialEq)]
198#[non_exhaustive]
199#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
200pub struct PdaResult {
201    /// Recovered coefficient functions.
202    ///
203    /// `coefficients[k]` is β_k(t) sampled at the evaluation grid supplied to
204    /// [`principal_differential_analysis`].  Length of outer Vec is `order`;
205    /// each inner Vec has length `n_pts`.
206    pub coefficients: Vec<Vec<f64>>,
207
208    /// Order of the estimated ODE.
209    pub order: usize,
210
211    /// Optional residuals matrix (not computed by default; kept for extensibility).
212    pub residuals: Option<FdMatrix>,
213}
214
215// ─── principal_differential_analysis ────────────────────────────────────────
216
217/// Estimate the coefficient functions of a linear ODE from observed solution curves.
218///
219/// Given `n` curves on a common grid of `n_pts` points, PDA estimates the weight
220/// functions β₀(t), …, β_{m-1}(t) such that
221///
222/// `D^m x_i(t) ≈ -β₀(t)·x_i(t) - β₁(t)·Dx_i(t) - … - β_{m-1}(t)·D^{m-1}x_i(t)`.
223///
224/// At each grid point `t_j` an independent ordinary least-squares problem is solved:
225///
226/// ```text
227/// X_j = [x_i(t_j), Dx_i(t_j), …, D^{m-1}x_i(t_j)]  (n × m design matrix)
228/// y_j = -[D^m x_i(t_j)]                               (n-vector)
229/// β(t_j) = pinv(X_j) y_j   via SVD pseudoinverse
230/// ```
231///
232/// # Arguments
233///
234/// * `data`    — Functional data matrix `(n × n_pts)`; each row is one solution curve.
235/// * `argvals` — Evaluation grid (length `n_pts`, must be sorted ascending).
236/// * `order`   — ODE order *m* (`≥ 1`).
237///
238/// # Errors
239///
240/// * [`FdarError::InvalidDimension`] if `argvals.len() != data.ncols()`.
241/// * [`FdarError::InvalidDimension`] if `n_pts < 2` (single-point grid cannot produce derivatives).
242/// * [`FdarError::InvalidParameter`] if `order == 0`.
243/// * [`FdarError::InvalidDimension`] if `n_curves < order + 1` (underdetermined system).
244///
245/// # Returns
246///
247/// A [`PdaResult`] containing the recovered coefficient functions.
248///
249/// # Notes on singular designs
250///
251/// When the pointwise design matrix `X_j` is rank-deficient (e.g. due to nearly
252/// identical curves or ill-posed boundary grid points), the SVD pseudoinverse
253/// threshold `1e-10 · max_singular_value` is applied, yielding zero coefficients
254/// for the degenerate directions rather than `NaN`/panic.
255pub fn principal_differential_analysis(
256    data: &FdMatrix,
257    argvals: &[f64],
258    order: usize,
259) -> Result<PdaResult, FdarError> {
260    let (n, n_pts) = data.shape();
261
262    // Validate order.
263    if order == 0 {
264        return Err(FdarError::InvalidParameter {
265            parameter: "order",
266            message: "must be >= 1".to_string(),
267        });
268    }
269
270    // Validate argvals length.
271    if argvals.len() != n_pts {
272        return Err(FdarError::InvalidDimension {
273            parameter: "argvals",
274            expected: n_pts.to_string(),
275            actual: argvals.len().to_string(),
276        });
277    }
278
279    // Guard: single-point grid produces only zero derivatives (IN-03).
280    if n_pts < 2 {
281        return Err(FdarError::InvalidDimension {
282            parameter: "argvals",
283            expected: ">= 2 (required for finite-difference derivatives)".to_string(),
284            actual: n_pts.to_string(),
285        });
286    }
287
288    // Guard: Pitfall 4 — too few curves for a well-posed pointwise regression.
289    if n < order + 1 {
290        return Err(FdarError::InvalidDimension {
291            parameter: "data (n_curves)",
292            expected: format!(">= order + 1 = {}", order + 1),
293            actual: n.to_string(),
294        });
295    }
296
297    // Compute derivative FdMatrices D⁰x, D¹x, …, D^{order}x.
298    // derivs[k] is the k-th derivative of all curves, same shape as data.
299    let mut derivs: Vec<FdMatrix> = Vec::with_capacity(order + 1);
300    derivs.push(data.clone()); // D⁰x = x
301
302    for _ in 0..order {
303        let prev = derivs.last().unwrap();
304        let mut next = FdMatrix::zeros(n, n_pts);
305        for i in 0..n {
306            let row: Vec<f64> = (0..n_pts).map(|j| prev[(i, j)]).collect();
307            let grad = crate::helpers::gradient(&row, argvals);
308            for j in 0..n_pts {
309                next[(i, j)] = grad[j];
310            }
311        }
312        derivs.push(next);
313    }
314
315    // Initialize coefficient storage: coefficients[k] = β_k(t), length n_pts.
316    let mut coefficients: Vec<Vec<f64>> = vec![vec![0.0; n_pts]; order];
317
318    // At each grid point t_j, solve the pointwise least-squares system.
319    for j in 0..n_pts {
320        // Build n × order design matrix X_j: column k = D^k x at t_j, for k=0..order-1.
321        let mut x_j = DMatrix::<f64>::zeros(n, order);
322        for i in 0..n {
323            for k in 0..order {
324                x_j[(i, k)] = derivs[k][(i, j)];
325            }
326        }
327
328        // Target y_j = -(D^order x) at t_j.
329        let y_j: Vec<f64> = (0..n).map(|i| -derivs[order][(i, j)]).collect();
330        let y_vec = nalgebra::DVector::from_vec(y_j);
331
332        // Solve via SVD pseudoinverse: β = pinv(X_j) · y_j.
333        let svd = nalgebra::SVD::new(x_j, true, true);
334        let max_sv = svd.singular_values.iter().copied().fold(0.0_f64, f64::max);
335        let threshold = 1e-10 * max_sv;
336
337        // Compute the pseudoinverse action: pinv(X) y = V · diag(1/σ) · U^T · y.
338        if let (Some(u), Some(v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
339            // u_t_y = U^T · y  (shape: order × 1, using only relevant rows)
340            let u_t_y: Vec<f64> = (0..order.min(svd.singular_values.len()))
341                .map(|s| (0..n).map(|i| u[(i, s)] * y_vec[i]).sum::<f64>())
342                .collect();
343
344            // Apply 1/σ filter and accumulate via V^T rows.
345            let mut beta_j = vec![0.0_f64; order];
346            for k in 0..order {
347                let mut val = 0.0_f64;
348                for s in 0..order.min(svd.singular_values.len()) {
349                    if svd.singular_values[s] > threshold {
350                        // v_t[(s, k)] is the (s, k) entry of V^T.
351                        val += v_t[(s, k)] * u_t_y[s] / svd.singular_values[s];
352                    }
353                }
354                beta_j[k] = val;
355            }
356
357            for k in 0..order {
358                coefficients[k][j] = beta_j[k];
359            }
360        }
361        // If SVD factorization failed entirely (u or v_t missing), coefficients
362        // remain zero — a conservative fallback that avoids NaN/panic.
363    }
364
365    Ok(PdaResult {
366        coefficients,
367        order,
368        residuals: None,
369    })
370}
371
372// ─── Tests ───────────────────────────────────────────────────────────────────
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use std::f64::consts::PI;
378
379    // ── Lfd tests ────────────────────────────────────────────────────────────
380
381    /// WR-02: empty coefs returns Err(InvalidParameter).
382    #[test]
383    fn lfd_empty_coefs_returns_err() {
384        let n_pts = 10;
385        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
386        let data = FdMatrix::zeros(2, n_pts);
387        let lfd = Lfd { coefs: vec![] };
388        let result = lfd.apply(&data, &argvals);
389        assert!(
390            matches!(result, Err(FdarError::InvalidParameter { .. })),
391            "expected InvalidParameter for empty coefs, got: {:?}",
392            result
393        );
394    }
395
396    /// A constant operator (m=1, β₀ = c) applied to a constant curve:
397    /// D¹(const) = 0, so Lx(t) = 0 + c · const = c · const.
398    #[test]
399    fn lfd_constant_operator_on_constant_curve() {
400        let n_pts = 11;
401        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
402        let curve_val = 3.0_f64;
403        let c = 2.0_f64;
404
405        // One curve, all values = curve_val.
406        let mut data = FdMatrix::zeros(1, n_pts);
407        for j in 0..n_pts {
408            data[(0, j)] = curve_val;
409        }
410
411        let lfd = Lfd {
412            coefs: vec![vec![c]], // constant β₀ = c, broadcast
413        };
414        let lx = lfd.apply(&data, &argvals).unwrap();
415
416        assert_eq!(lx.shape(), (1, n_pts));
417        // D¹ const ≈ 0, so Lx ≈ c * curve_val everywhere.
418        let expected = c * curve_val;
419        for j in 0..n_pts {
420            assert!(
421                (lx[(0, j)] - expected).abs() < 1e-6,
422                "lx[{j}] = {} but expected {} (diff = {})",
423                lx[(0, j)],
424                expected,
425                (lx[(0, j)] - expected).abs()
426            );
427        }
428    }
429
430    /// Mismatched argvals length returns Err(InvalidDimension).
431    #[test]
432    fn lfd_mismatched_argvals_returns_err() {
433        let n_pts = 10;
434        let _argvals: Vec<f64> = (0..n_pts).map(|i| i as f64).collect();
435        let data = FdMatrix::zeros(2, n_pts);
436
437        // Supply argvals of wrong length.
438        let lfd = Lfd {
439            coefs: vec![vec![1.0]],
440        };
441        let wrong_argvals: Vec<f64> = (0..n_pts + 3).map(|i| i as f64).collect();
442        let result = lfd.apply(&data, &wrong_argvals);
443        assert!(
444            matches!(result, Err(FdarError::InvalidDimension { .. })),
445            "expected InvalidDimension, got: {:?}",
446            result
447        );
448    }
449
450    /// A coefs[k] of a length other than 1 or n_pts returns InvalidDimension.
451    #[test]
452    fn lfd_bad_coefs_length_returns_err() {
453        let n_pts = 10;
454        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64).collect();
455        let data = FdMatrix::zeros(2, n_pts);
456
457        // coefs[0].len() = 5, which is neither 1 nor n_pts=10.
458        let lfd = Lfd {
459            coefs: vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]],
460        };
461        let result = lfd.apply(&data, &argvals);
462        assert!(
463            matches!(result, Err(FdarError::InvalidDimension { .. })),
464            "expected InvalidDimension, got: {:?}",
465            result
466        );
467    }
468
469    /// apply returns FdMatrix of same shape as input data.
470    #[test]
471    fn lfd_apply_shape_preserved() {
472        let n_pts = 20;
473        let n_curves = 4;
474        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
475        let mut data = FdMatrix::zeros(n_curves, n_pts);
476        for i in 0..n_curves {
477            for j in 0..n_pts {
478                data[(i, j)] = argvals[j].powi(2) + i as f64;
479            }
480        }
481
482        // m=2 operator with constant coefficients.
483        let lfd = Lfd {
484            coefs: vec![vec![1.0], vec![0.5]],
485        };
486        let lx = lfd.apply(&data, &argvals).unwrap();
487        assert_eq!(lx.shape(), (n_curves, n_pts));
488    }
489
490    // ── PDA tests ────────────────────────────────────────────────────────────
491
492    /// Harmonic oscillator: x''(t) = -ω²x(t), ω = 2π.
493    /// PDA with order=2 should recover β₀ ≈ ω² and β₁ ≈ 0.
494    #[test]
495    fn pda_recovers_harmonic_oscillator() {
496        let omega = 2.0 * PI;
497        let n_pts = 101;
498        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
499        let n_curves = 20;
500
501        // x_i(t) = A_i · cos(ω t) + B_i · sin(ω t), with varied A_i, B_i.
502        let mut data = FdMatrix::zeros(n_curves, n_pts);
503        for i in 0..n_curves {
504            let a = (i + 1) as f64;
505            let b = (i + 2) as f64;
506            for (j, &t) in argvals.iter().enumerate() {
507                data[(i, j)] = a * (omega * t).cos() + b * (omega * t).sin();
508            }
509        }
510
511        let result = principal_differential_analysis(&data, &argvals, 2).unwrap();
512        assert_eq!(result.coefficients.len(), 2);
513        assert_eq!(result.coefficients[0].len(), n_pts);
514
515        let omega_sq = omega * omega; // ≈ 39.478
516        let tolerance = 1.0;
517
518        for (j, &beta0_j) in result.coefficients[0].iter().enumerate() {
519            assert!(
520                (beta0_j - omega_sq).abs() < tolerance,
521                "β₀[{j}] = {beta0_j}, expected ≈ {omega_sq}, diff = {}",
522                (beta0_j - omega_sq).abs()
523            );
524        }
525        for (j, &beta1_j) in result.coefficients[1].iter().enumerate() {
526            assert!(
527                beta1_j.abs() < tolerance,
528                "β₁[{j}] = {beta1_j}, expected ≈ 0"
529            );
530        }
531    }
532
533    /// n < order+1 returns Err(InvalidDimension).
534    #[test]
535    fn pda_too_few_curves_returns_err() {
536        // order=2 requires n >= 3; supply only 2 curves.
537        let n_pts = 51;
538        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
539        let data = FdMatrix::zeros(2, n_pts); // 2 curves, order=2 needs ≥3
540
541        let result = principal_differential_analysis(&data, &argvals, 2);
542        assert!(
543            matches!(result, Err(FdarError::InvalidDimension { .. })),
544            "expected InvalidDimension, got: {:?}",
545            result
546        );
547    }
548
549    /// Mismatched argvals length returns FdarError.
550    #[test]
551    fn pda_mismatched_argvals_returns_err() {
552        let n_pts = 51;
553        let _argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
554        let data = FdMatrix::zeros(5, n_pts);
555
556        // Supply argvals of wrong length.
557        let wrong_argvals: Vec<f64> = (0..n_pts + 5).map(|i| i as f64).collect();
558        let result = principal_differential_analysis(&data, &wrong_argvals, 2);
559        assert!(
560            matches!(result, Err(FdarError::InvalidDimension { .. })),
561            "expected InvalidDimension, got: {:?}",
562            result
563        );
564    }
565
566    /// IN-03: Lfd::apply with n_pts == 1 returns Err(InvalidDimension).
567    #[test]
568    fn lfd_single_point_grid_returns_err() {
569        let argvals = vec![0.5_f64];
570        let mut data = FdMatrix::zeros(2, 1);
571        data[(0, 0)] = 1.0;
572        data[(1, 0)] = 2.0;
573        let lfd = Lfd {
574            coefs: vec![vec![1.0]],
575        };
576        let result = lfd.apply(&data, &argvals);
577        assert!(
578            matches!(result, Err(FdarError::InvalidDimension { .. })),
579            "expected InvalidDimension for n_pts=1, got: {:?}",
580            result
581        );
582    }
583
584    /// IN-03: principal_differential_analysis with n_pts == 1 returns Err(InvalidDimension).
585    #[test]
586    fn pda_single_point_grid_returns_err() {
587        let argvals = vec![0.5_f64];
588        let mut data = FdMatrix::zeros(5, 1);
589        for i in 0..5 {
590            data[(i, 0)] = i as f64;
591        }
592        let result = principal_differential_analysis(&data, &argvals, 2);
593        assert!(
594            matches!(result, Err(FdarError::InvalidDimension { .. })),
595            "expected InvalidDimension for n_pts=1, got: {:?}",
596            result
597        );
598    }
599
600    /// order=0 returns Err(InvalidParameter).
601    #[test]
602    fn pda_zero_order_returns_err() {
603        let n_pts = 51;
604        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
605        let data = FdMatrix::zeros(5, n_pts);
606
607        let result = principal_differential_analysis(&data, &argvals, 0);
608        assert!(
609            matches!(result, Err(FdarError::InvalidParameter { .. })),
610            "expected InvalidParameter, got: {:?}",
611            result
612        );
613    }
614
615    /// PdaResult struct invariants: coefficients.len()==order, each inner len==n_pts.
616    #[test]
617    fn pda_result_shape_invariants() {
618        let omega = 2.0 * PI;
619        let n_pts = 51;
620        let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
621        let n_curves = 10;
622        let order = 2;
623
624        let mut data = FdMatrix::zeros(n_curves, n_pts);
625        for i in 0..n_curves {
626            let a = (i + 1) as f64;
627            for (j, &t) in argvals.iter().enumerate() {
628                data[(i, j)] = a * (omega * t).cos();
629            }
630        }
631
632        let result = principal_differential_analysis(&data, &argvals, order).unwrap();
633        assert_eq!(result.order, order);
634        assert_eq!(result.coefficients.len(), order);
635        for k in 0..order {
636            assert_eq!(
637                result.coefficients[k].len(),
638                n_pts,
639                "coefficients[{k}] should have length n_pts={n_pts}"
640            );
641        }
642        // residuals default is None.
643        assert!(result.residuals.is_none());
644    }
645}