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}