Skip to main content

fdars_core/
density_fda.rs

1//! Density-valued functional data analysis (LQD transform, Wasserstein barycenter, density FPCA).
2//!
3//! This module implements the log-quantile-density (LQD) transformation framework of
4//! Petersen and Mueller (2016) for probability-density-valued functional data.  The LQD map
5//! embeds the constraint-carrying space of probability densities into the unconstrained Hilbert
6//! space L²([0,1]), where ordinary FPCA applies.  The inverse map always returns a valid
7//! (non-negative, unit-integral) probability density.
8//!
9//! # Types
10//!
11//! - [`LqdFpcaResult`] — output of [`lqd_fpca`], embedding the LQD-space
12//!   [`crate::regression::FpcaResult`] plus fraction of variance explained (FVE).
13//!
14//! # R baseline
15//!
16//! The algorithms in this module are based on the R package **fdadensity 0.1.4**
17//! (<https://cran.r-project.org/package=fdadensity>), specifically:
18//! - `dens2lqd` — forward LQD transform
19//! - `lqd2dens` — inverse LQD transform
20//! - `FPCAdens` — functional PCA of densities via LQD space
21//! - `getWFmean` — Wasserstein Fréchet mean (quantile average)
22//!
23//! **Reference:** Petersen, A. and Mueller, H.-G. (2016). Functional data analysis for
24//! density functions by transformation to a Hilbert space. *Annals of Statistics*,
25//! 44(1):183–218. <https://doi.org/10.1214/15-AOS1363>
26//!
27//! # Examples
28//!
29//! ```
30//! use fdars_core::density_fda::{normalize_density, lqd_transform, inverse_lqd};
31//!
32//! // Uniform density on [0, 1]: ψ(t) = 0 for all t
33//! let argvals: Vec<f64> = (0..51).map(|i| i as f64 / 50.0).collect();
34//! let uniform: Vec<f64> = vec![1.0; 51];
35//!
36//! let normed = normalize_density(&uniform, &argvals).unwrap();
37//! let psi = lqd_transform(&normed, &argvals, Some(51)).unwrap();
38//! // ψ ≡ 0 for the uniform density (analytic result)
39//! assert!(psi.iter().all(|&v| v.abs() < 1e-6));
40//!
41//! // Round-trip back to density space
42//! let t_grid: Vec<f64> = (0..51).map(|i| i as f64 / 50.0).collect();
43//! let recovered = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
44//! // Recovered density integrates to 1
45//! ```
46//!
47//! # Divergences from fdadensity
48//!
49//! 1. **Quantile interpolation:** `fdadensity` uses `spline(..., method = 'natural')` (natural
50//!    cubic spline) for the CDF→t mapping in `dens2lqd` and the Q→target-grid back-mapping in
51//!    `lqd2dens`.  This implementation uses [`crate::helpers::linear_interp`] (piecewise linear).
52//!    Effect: the round-trip (`lqd_transform` → `inverse_lqd`) L∞ error is larger than the
53//!    cubic-spline reference. Measured on a truncated standard Gaussian at 201 points it is
54//!    ~1.0e-2 (vs. ~5e-3 for cubic spline); on flatter/smoother densities or denser grids it is
55//!    smaller. Callers needing tighter round-trip accuracy should supply a finer density grid or
56//!    a smoother reference. The reconstructed density always integrates to 1 and is non-negative
57//!    regardless of interpolation error.
58//!
59//! 2. **`useSplines` integration path:** `fdadensity::lqd2dens` has an optional path that
60//!    integrates `exp(spline(ψ))` analytically per panel.  This implementation always uses
61//!    `cumulative_trapz(exp(ψ), t_grid)`, trading a small accuracy difference for code
62//!    simplicity and zero new dependencies.
63//!
64//! 3. **`wasserstein_barycenter` weights:** `fdadensity::getWFmean` does not support a weight
65//!    parameter.  This implementation accepts `weights: Option<&[f64]>`, defaulting to uniform
66//!    1/n — a strict superset of fdadensity capability.
67//!
68//! 4. **Silent normalization:** `fdadensity` emits a warning when |trapz(dens) − 1| > 1e-5.
69//!    This implementation normalizes silently without a warning and documents the behaviour here.
70
71use crate::error::FdarError;
72use crate::helpers::{cumulative_trapz, linear_interp, trapz};
73use crate::matrix::FdMatrix;
74use crate::regression::{fdata_to_pc_1d, FpcaResult};
75
76// ─── Result types ────────────────────────────────────────────────────────────
77
78/// Result of functional PCA on log-quantile-density (LQD) transformed densities.
79///
80/// All fields (`fpca`, scores, loadings, mean) are in **LQD space** on the uniform
81/// quantile grid t ∈ [0, 1], not in the original density space.
82///
83/// To obtain density-space variation modes, apply [`inverse_lqd`] to
84/// `fpca.mean ± scale * loading_column` for each principal component column.
85#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87#[non_exhaustive]
88pub struct LqdFpcaResult {
89    /// FPCA result in LQD space.
90    ///
91    /// The FPCA is performed on the LQD-transformed densities on the uniform
92    /// quantile grid t ∈ [0, 1]. Scores, loadings, and mean are all in LQD
93    /// space, not density space.
94    pub fpca: FpcaResult,
95    /// Fraction of variance explained by the first k components.
96    ///
97    /// `fve[k]` = cumsum(sv²)[0..=k] / sum(all sv²). Monotone non-decreasing;
98    /// `fve.last()` ≈ 1.0 only when `ncomp == min(n_densities, n_quantile_pts)`.
99    pub fve: Vec<f64>,
100}
101
102// ─── Public entry points ─────────────────────────────────────────────────────
103
104/// Normalize a density so that it integrates to 1 via trapezoidal quadrature.
105///
106/// # Arguments
107///
108/// * `vals`    — density values sampled on `argvals`; must be non-negative.
109/// * `argvals` — strictly increasing evaluation grid.
110///
111/// # Errors
112///
113/// Returns [`FdarError::InvalidDimension`] if `vals.len() != argvals.len()`.
114/// Returns [`FdarError::InvalidParameter`] if any value in `vals` is negative,
115/// if `argvals` is not strictly increasing, or if the integral is < 1e-15
116/// (all-zero density).
117///
118/// # Example
119///
120/// ```
121/// use fdars_core::density_fda::normalize_density;
122/// let argvals = vec![0.0, 0.5, 1.0];
123/// let vals = vec![2.0, 2.0, 2.0]; // uniform, scale factor 2
124/// let normed = normalize_density(&vals, &argvals).unwrap();
125/// // integral ≈ 1.0
126/// ```
127pub fn normalize_density(vals: &[f64], argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
128    if vals.len() != argvals.len() {
129        return Err(FdarError::InvalidDimension {
130            parameter: "vals",
131            expected: format!("{}", argvals.len()),
132            actual: format!("{}", vals.len()),
133        });
134    }
135    if argvals.len() < 2 {
136        return Err(FdarError::InvalidParameter {
137            parameter: "argvals",
138            message: "argvals must have at least 2 elements".to_string(),
139        });
140    }
141    if argvals.windows(2).any(|w| w[1] <= w[0]) {
142        return Err(FdarError::InvalidParameter {
143            parameter: "argvals",
144            message: "argvals must be strictly increasing".to_string(),
145        });
146    }
147    if vals.iter().any(|&v| v < 0.0) {
148        return Err(FdarError::InvalidParameter {
149            parameter: "vals",
150            message: "density values must be non-negative".to_string(),
151        });
152    }
153    let integral = trapz(vals, argvals);
154    if integral < 1e-15 {
155        return Err(FdarError::InvalidParameter {
156            parameter: "vals",
157            message: "density integrates to zero or is all-zero".to_string(),
158        });
159    }
160    Ok(vals.iter().map(|&v| v / integral).collect())
161}
162
163/// Log-quantile-density (LQD) forward transform.
164///
165/// Maps a probability density `density` sampled on `argvals` (a physical grid in
166/// density space) to the LQD representation ψ on a uniform quantile grid
167/// t ∈ [0, 1] of length `n_quantile_pts`.
168///
169/// The LQD is defined as ψ(t) = log q(t) = −log f(Q(t)), where Q is the
170/// quantile function and q = dQ/dt is the quantile density.
171///
172/// **Numeric chain** (matching `fdadensity::dens2lqd`):
173/// 1. Normalize density.
174/// 2. Compute CDF via `cumulative_trapz` (starts at 0).
175/// 3. Compute `lqd_raw[i] = −log(density_norm[i])` on the physical grid.
176/// 4. Interpolate (x = CDF, y = lqd_raw) onto the uniform t-grid via `linear_interp`.
177///
178/// # Arguments
179///
180/// * `density`        — strictly positive density values on `argvals`.
181/// * `argvals`        — strictly increasing evaluation grid.
182/// * `n_quantile_pts` — length of the output quantile grid (default: `argvals.len().max(101)`).
183///
184/// # Errors
185///
186/// Returns [`FdarError::InvalidParameter`] if any density value is ≤ 0 (since
187/// −log(0) = +∞), if `argvals` is not strictly increasing, or if any output ψ
188/// value is non-finite (NaN or ±∞).
189/// Returns [`FdarError::InvalidDimension`] for length mismatches.
190///
191/// # Example
192///
193/// ```
194/// use fdars_core::density_fda::lqd_transform;
195/// let argvals: Vec<f64> = (0..51).map(|i| i as f64 / 50.0).collect();
196/// let uniform = vec![1.0_f64; 51];
197/// let psi = lqd_transform(&uniform, &argvals, Some(51)).unwrap();
198/// // ψ ≡ 0 for uniform density
199/// assert!(psi.iter().all(|&v| v.abs() < 1e-5));
200/// ```
201pub fn lqd_transform(
202    density: &[f64],
203    argvals: &[f64],
204    n_quantile_pts: Option<usize>,
205) -> Result<Vec<f64>, FdarError> {
206    // --- validation ---
207    if density.len() != argvals.len() {
208        return Err(FdarError::InvalidDimension {
209            parameter: "density",
210            expected: format!("{}", argvals.len()),
211            actual: format!("{}", density.len()),
212        });
213    }
214    if argvals.len() < 2 {
215        return Err(FdarError::InvalidParameter {
216            parameter: "argvals",
217            message: "argvals must have at least 2 elements".to_string(),
218        });
219    }
220    if argvals.windows(2).any(|w| w[1] <= w[0]) {
221        return Err(FdarError::InvalidParameter {
222            parameter: "argvals",
223            message: "argvals must be strictly increasing".to_string(),
224        });
225    }
226    // LQD requires strictly positive density (log(0) = -∞)
227    if density.iter().any(|&v| v <= 0.0) {
228        return Err(FdarError::InvalidParameter {
229            parameter: "density",
230            message: "density values must be strictly positive for the LQD transform (zero/negative density produces ±∞)".to_string(),
231        });
232    }
233
234    let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
235    if n_q < 2 {
236        return Err(FdarError::InvalidParameter {
237            parameter: "n_quantile_pts",
238            message: "n_quantile_pts must be at least 2".to_string(),
239        });
240    }
241
242    // Step 1: normalize
243    let integral = trapz(density, argvals);
244    let dens_norm: Vec<f64> = density.iter().map(|&d| d / integral).collect();
245
246    // Step 2: CDF (starts at 0 by cumulative_trapz contract)
247    let cdf = cumulative_trapz(&dens_norm, argvals);
248
249    // Step 3: lqd on physical grid: ψ_raw[i] = −log(f(x_i))
250    let lqd_raw: Vec<f64> = dens_norm.iter().map(|&d| -d.ln()).collect();
251
252    // Step 4: interpolate onto uniform t-grid ∈ [0,1]
253    let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
254    let psi: Vec<f64> = t_grid
255        .iter()
256        .map(|&t| linear_interp(&cdf, &lqd_raw, t))
257        .collect();
258
259    // Guard: non-finite ψ indicates a numeric failure
260    if psi.iter().any(|v| !v.is_finite()) {
261        return Err(FdarError::ComputationFailed {
262            operation: "lqd_transform",
263            detail: "non-finite ψ values produced; possible cause: a density value \
264                     underflowed to 0 after normalization (input density too small \
265                     relative to its maximum on this grid)"
266                .to_string(),
267        });
268    }
269
270    Ok(psi)
271}
272
273/// Inverse LQD transform: reconstruct a normalized probability density on `target_argvals`.
274///
275/// Inverts the LQD transform: given ψ on a quantile grid t ∈ [0, 1], recovers a
276/// probability density on `target_argvals`.  The result is always renormalized so
277/// that it integrates to 1 over `target_argvals`.
278///
279/// **Numeric chain** (matching `fdadensity::lqd2dens`):
280/// 1. Compute quantile function: `Q_raw = lb + cumtrapz(exp(ψ), t_grid)`.
281/// 2. **Mandatory rescaling** (θ_ψ correction): map Q_raw to the target support
282///    `[target_argvals[0], target_argvals.last()]` by linear scaling.
283/// 3. Compute density values at quantile-grid points: `dens_raw[i] = exp(−ψ[i])`.
284/// 4. Dedup adjacent equal Q values to keep the interpolation x-axis strictly monotone.
285/// 5. Interpolate `dens_raw` onto `target_argvals` via `linear_interp`.
286/// 6. Renormalize so that `trapz(result, target_argvals) = 1`.
287///
288/// # Arguments
289///
290/// * `psi`            — LQD values on `t_grid`.
291/// * `t_grid`         — strictly increasing quantile grid (typically uniform on [0, 1]).
292/// * `target_argvals` — strictly increasing physical grid for the output density.
293///
294/// # Errors
295///
296/// Returns [`FdarError::InvalidDimension`] if `psi.len() != t_grid.len()`.
297/// Returns [`FdarError::InvalidParameter`] if `t_grid` or `target_argvals` is not
298/// strictly increasing, or if `psi` contains non-finite values.
299/// Returns [`FdarError::ComputationFailed`] if the reconstructed Q range is zero
300/// (degenerate density) or renormalization fails.
301pub fn inverse_lqd(
302    psi: &[f64],
303    t_grid: &[f64],
304    target_argvals: &[f64],
305) -> Result<Vec<f64>, FdarError> {
306    // --- validation ---
307    if psi.len() != t_grid.len() {
308        return Err(FdarError::InvalidDimension {
309            parameter: "psi",
310            expected: format!("{}", t_grid.len()),
311            actual: format!("{}", psi.len()),
312        });
313    }
314    if t_grid.len() < 2 {
315        return Err(FdarError::InvalidParameter {
316            parameter: "t_grid",
317            message: "t_grid must have at least 2 elements".to_string(),
318        });
319    }
320    if target_argvals.len() < 2 {
321        return Err(FdarError::InvalidParameter {
322            parameter: "target_argvals",
323            message: "target_argvals must have at least 2 elements".to_string(),
324        });
325    }
326    if t_grid.windows(2).any(|w| w[1] <= w[0]) {
327        return Err(FdarError::InvalidParameter {
328            parameter: "t_grid",
329            message: "t_grid must be strictly increasing".to_string(),
330        });
331    }
332    if target_argvals.windows(2).any(|w| w[1] <= w[0]) {
333        return Err(FdarError::InvalidParameter {
334            parameter: "target_argvals",
335            message: "target_argvals must be strictly increasing".to_string(),
336        });
337    }
338    if psi.iter().any(|v| !v.is_finite()) {
339        return Err(FdarError::InvalidParameter {
340            parameter: "psi",
341            message: "psi must contain only finite values".to_string(),
342        });
343    }
344
345    // Step 1: Q_raw(t) = lb + cumtrapz(exp(ψ), t_grid)
346    let exp_psi: Vec<f64> = psi.iter().map(|&p| p.exp()).collect();
347    let q_raw_cumtrapz = cumulative_trapz(&exp_psi, t_grid);
348    let lb = target_argvals[0];
349    let q_raw: Vec<f64> = q_raw_cumtrapz.iter().map(|&v| lb + v).collect();
350
351    // Step 2: mandatory θ_ψ rescaling — map Q_raw to the target support range
352    let q_range = q_raw[q_raw.len() - 1] - q_raw[0]; // = θ_ψ = ∫exp(ψ) dt
353    let d_range = target_argvals[target_argvals.len() - 1] - lb;
354    if q_range < 1e-15 {
355        return Err(FdarError::ComputationFailed {
356            operation: "inverse_lqd",
357            detail: "quantile function range is zero; degenerate ψ (all-constant)".to_string(),
358        });
359    }
360    let scale = d_range / q_range;
361    let q_scaled: Vec<f64> = q_raw.iter().map(|&v| (v - q_raw[0]) * scale + lb).collect();
362
363    // Step 3: density values at quantile-grid points: dens_raw[i] = exp(−ψ[i]) = 1/q(t_i)
364    let dens_raw: Vec<f64> = psi.iter().map(|&p| (-p).exp()).collect();
365
366    // Step 4: dedup adjacent equal Q values (prevents undefined linear_interp on duplicate x)
367    let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
368
369    // Step 5: interpolate onto target_argvals
370    let dens: Vec<f64> = target_argvals
371        .iter()
372        .map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
373        .collect();
374
375    // Step 6: renormalize to ∫f = 1
376    let integral = trapz(&dens, target_argvals);
377    if integral < 1e-15 {
378        return Err(FdarError::ComputationFailed {
379            operation: "inverse_lqd",
380            detail: "reconstructed density integrates to zero; check ψ admissibility".to_string(),
381        });
382    }
383    Ok(dens.iter().map(|&d| d / integral).collect())
384}
385
386/// 1D Wasserstein Fréchet mean (quantile-average barycenter) of a collection of densities.
387///
388/// Computes the Fréchet mean of probability densities under the 2-Wasserstein metric.
389/// In 1D this is the pointwise (weighted) average of the quantile functions
390/// Q̄(t) = Σᵢ wᵢ Qᵢ(t), which is then inverted back to a density.
391///
392/// **Formula:** Rüschendorf and Rachev (1990); confirmed in Petersen and Mueller (2016).
393///
394/// # Arguments
395///
396/// * `density_matrix` — n × m matrix (n densities, m evaluation points, column-major).
397/// * `argvals`        — strictly increasing evaluation grid of length m.
398/// * `weights`        — optional weight vector of length n summing to 1.
399///   Defaults to uniform 1/n.
400///
401/// # Errors
402///
403/// Returns [`FdarError::InvalidDimension`] for empty matrix or argvals mismatch.
404/// Returns [`FdarError::InvalidParameter`] if any density row is non-positive or
405/// `argvals` is not strictly increasing; if `weights` length mismatches or sums to zero.
406/// Returns [`FdarError::ComputationFailed`] if the quantile average inversion fails.
407pub fn wasserstein_barycenter(
408    density_matrix: &FdMatrix,
409    argvals: &[f64],
410    weights: Option<&[f64]>,
411) -> Result<Vec<f64>, FdarError> {
412    let (n, m) = density_matrix.shape();
413    if n == 0 {
414        return Err(FdarError::InvalidDimension {
415            parameter: "density_matrix",
416            expected: "at least 1 row".to_string(),
417            actual: "0 rows".to_string(),
418        });
419    }
420    if m == 0 {
421        return Err(FdarError::InvalidDimension {
422            parameter: "density_matrix",
423            expected: "at least 1 column".to_string(),
424            actual: "0 columns".to_string(),
425        });
426    }
427    if argvals.len() != m {
428        return Err(FdarError::InvalidDimension {
429            parameter: "argvals",
430            expected: format!("{m} elements (matching density_matrix columns)"),
431            actual: format!("{} elements", argvals.len()),
432        });
433    }
434    if argvals.windows(2).any(|w| w[1] <= w[0]) {
435        return Err(FdarError::InvalidParameter {
436            parameter: "argvals",
437            message: "argvals must be strictly increasing".to_string(),
438        });
439    }
440
441    // Resolve weights
442    let w_vec: Vec<f64> = if let Some(w) = weights {
443        if w.len() != n {
444            return Err(FdarError::InvalidDimension {
445                parameter: "weights",
446                expected: format!("{n}"),
447                actual: format!("{}", w.len()),
448            });
449        }
450        if w.iter().any(|&wi| wi < 0.0 || !wi.is_finite()) {
451            return Err(FdarError::InvalidParameter {
452                parameter: "weights",
453                message: "weights must be non-negative and finite".to_string(),
454            });
455        }
456        let s: f64 = w.iter().sum();
457        if s < 1e-15 {
458            return Err(FdarError::InvalidParameter {
459                parameter: "weights",
460                message: "weights sum to zero".to_string(),
461            });
462        }
463        w.iter().map(|&wi| wi / s).collect()
464    } else {
465        vec![1.0 / n as f64; n]
466    };
467
468    // Quantile grid (same resolution as input)
469    let n_q = m.max(101);
470    let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
471
472    // Compute weighted average quantile function Q̄(t) = Σᵢ wᵢ Qᵢ(t)
473    let mut q_bar = vec![0.0_f64; n_q];
474    for i in 0..n {
475        let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
476        if row.iter().any(|&v| v < 0.0) {
477            return Err(FdarError::InvalidParameter {
478                parameter: "density_matrix",
479                message: format!(
480                    "row {i} contains negative values; densities must be non-negative"
481                ),
482            });
483        }
484        let integral = trapz(&row, argvals);
485        if integral < 1e-15 {
486            return Err(FdarError::InvalidParameter {
487                parameter: "density_matrix",
488                message: format!("row {i} integrates to zero (all-zero density)"),
489            });
490        }
491        let norm_row: Vec<f64> = row.iter().map(|&v| v / integral).collect();
492        let cdf_i = cumulative_trapz(&norm_row, argvals);
493        let wi = w_vec[i];
494        for j in 0..n_q {
495            q_bar[j] += wi * linear_interp(&cdf_i, argvals, t_grid[j]);
496        }
497    }
498
499    // Invert Q̄ to a density using the same back-map as inverse_lqd
500    // Q̄ is already on the target x-range [argvals[0], argvals[last]]
501    // Rescale Q̄ to the exact target support
502    let lb = argvals[0];
503    let ub = argvals[m - 1];
504    let q_range = q_bar[n_q - 1] - q_bar[0];
505    if q_range < 1e-15 {
506        return Err(FdarError::ComputationFailed {
507            operation: "wasserstein_barycenter",
508            detail: "quantile average has zero range; degenerate input densities".to_string(),
509        });
510    }
511    let d_range = ub - lb;
512    let q_scaled: Vec<f64> = q_bar
513        .iter()
514        .map(|&v| (v - q_bar[0]) * d_range / q_range + lb)
515        .collect();
516
517    // Density at quantile-grid points: dQ̄/dt approximated by finite differences
518    let dens_raw = quantile_density_from_q(&q_scaled, &t_grid);
519
520    // Dedup and interpolate onto argvals
521    let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
522    let dens: Vec<f64> = argvals
523        .iter()
524        .map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
525        .collect();
526
527    // Renormalize
528    let integral = trapz(&dens, argvals);
529    if integral < 1e-15 {
530        return Err(FdarError::ComputationFailed {
531            operation: "wasserstein_barycenter",
532            detail: "barycenter density integrates to zero".to_string(),
533        });
534    }
535    Ok(dens.iter().map(|&d| d / integral).collect())
536}
537
538/// Functional PCA of probability densities in LQD space.
539///
540/// Transforms each density row to LQD space on a uniform quantile grid, assembles
541/// the resulting `FdMatrix`, and delegates to [`fdata_to_pc_1d`].  Returns the
542/// FPCA result together with the fraction of variance explained (FVE) vector.
543///
544/// **Algorithm:**
545/// 1. For each density row: `lqd_transform → ψᵢ` on the uniform t-grid.
546/// 2. Assemble the n × n_q LQD matrix.
547/// 3. Call `fdata_to_pc_1d` (existing SVD engine).
548/// 4. Compute FVE = cumsum(sv²) / sum(sv²).
549///
550/// # Arguments
551///
552/// * `density_matrix` — n × m matrix of probability densities (one per row).
553/// * `argvals`        — strictly increasing evaluation grid of length m.
554/// * `ncomp`          — number of principal components to retain.
555/// * `n_quantile_pts` — LQD quantile grid length (default: `argvals.len().max(101)`).
556///
557/// # Errors
558///
559/// Propagates errors from [`lqd_transform`] and [`fdata_to_pc_1d`].
560/// Returns [`FdarError::InvalidDimension`] for empty matrix or argvals mismatch.
561/// Returns [`FdarError::InvalidParameter`] when `ncomp == 0`.
562#[must_use = "expensive SVD computation — store or use the returned LqdFpcaResult"]
563pub fn lqd_fpca(
564    density_matrix: &FdMatrix,
565    argvals: &[f64],
566    ncomp: usize,
567    n_quantile_pts: Option<usize>,
568) -> Result<LqdFpcaResult, FdarError> {
569    let (n_dens, m) = density_matrix.shape();
570    if n_dens == 0 {
571        return Err(FdarError::InvalidDimension {
572            parameter: "density_matrix",
573            expected: "at least 1 row".to_string(),
574            actual: "0 rows".to_string(),
575        });
576    }
577    if m == 0 || argvals.len() != m {
578        return Err(FdarError::InvalidDimension {
579            parameter: "argvals",
580            expected: format!("{m} elements"),
581            actual: format!("{} elements", argvals.len()),
582        });
583    }
584    if ncomp == 0 {
585        return Err(FdarError::InvalidParameter {
586            parameter: "ncomp",
587            message: "ncomp must be at least 1".to_string(),
588        });
589    }
590
591    let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
592    let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
593
594    // Build LQD matrix (n × n_q), column-major
595    let mut lqd_data = FdMatrix::zeros(n_dens, n_q);
596    for i in 0..n_dens {
597        let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
598        let psi = lqd_transform(&row, argvals, Some(n_q))?;
599        for (j, &val) in psi.iter().enumerate() {
600            lqd_data[(i, j)] = val;
601        }
602    }
603
604    // Delegate to existing FPCA engine
605    let fpca = fdata_to_pc_1d(&lqd_data, ncomp, &t_grid)?;
606
607    // FVE = cumsum(sv²) / sum(sv²)
608    let sv_sq: Vec<f64> = fpca.singular_values.iter().map(|&s| s * s).collect();
609    let total: f64 = sv_sq.iter().sum();
610    let mut cumsum = 0.0_f64;
611    let fve: Vec<f64> = sv_sq
612        .iter()
613        .map(|&s| {
614            cumsum += s;
615            if total > 0.0 {
616                cumsum / total
617            } else {
618                0.0
619            }
620        })
621        .collect();
622
623    Ok(LqdFpcaResult { fpca, fve })
624}
625
626// ─── Private helpers ─────────────────────────────────────────────────────────
627
628/// Remove adjacent duplicate x values (and any non-monotone values), keeping the
629/// first of each run.
630///
631/// Used before `linear_interp` to guarantee a strictly monotone x-axis.  Callers
632/// pass `q_scaled`, the rescaled quantile function, which is *intended* to be
633/// non-decreasing (positive-linear map of a cumulative integral).  In practice,
634/// [`crate::helpers::cumulative_trapz`]'s generalized-Simpson pairing can produce
635/// small numerical reversals at intermediate grid points.  This helper silently
636/// discards any point where `x[i] <= x[i-1]`, recovering a strictly-increasing
637/// x-axis before the binary-search-based `linear_interp`.
638///
639/// **Silent drop:** points that are exactly equal to or strictly less than the
640/// previously kept value are skipped without error.  This is the intended behaviour
641/// for the current call sites; future callers that require non-decreasingness should
642/// validate their input before calling this helper.
643pub(crate) fn dedup_adjacent(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
644    let mut xd = Vec::with_capacity(x.len());
645    let mut yd = Vec::with_capacity(y.len());
646    for (i, (&xi, &yi)) in x.iter().zip(y.iter()).enumerate() {
647        if i == 0 || xi > xd[xd.len() - 1] {
648            xd.push(xi);
649            yd.push(yi);
650        }
651        // Points where xi <= xd.last() are silently skipped (duplicates or
652        // numerical reversals from cumulative_trapz's Simpson pairing).
653    }
654    (xd, yd)
655}
656
657/// Approximate the quantile density q(t) = dQ/dt at each t_grid point.
658///
659/// Uses central differences in the interior and forward/backward differences at
660/// the endpoints, then clamps negative values to 0 for numerical safety.
661pub(crate) fn quantile_density_from_q(q: &[f64], t: &[f64]) -> Vec<f64> {
662    let n = q.len();
663    let mut qd = vec![0.0_f64; n];
664    if n < 2 {
665        return qd;
666    }
667    // Forward difference at left boundary
668    qd[0] = (q[1] - q[0]) / (t[1] - t[0]);
669    // Central differences in interior
670    for i in 1..n - 1 {
671        qd[i] = (q[i + 1] - q[i - 1]) / (t[i + 1] - t[i - 1]);
672    }
673    // Backward difference at right boundary
674    qd[n - 1] = (q[n - 1] - q[n - 2]) / (t[n - 1] - t[n - 2]);
675    // The density is 1/q(t); clamp non-positive q to a small epsilon.
676    // eps = 1e-6 prevents 1e12 tail spikes from a too-small clamp: at tails
677    // the central-difference dq can be legitimately small on coarse grids,
678    // and 1/1e-12 = 1e12 dominates boundary interpolation in the barycenter.
679    let eps = 1e-6_f64;
680    qd.iter().map(|&dq| 1.0 / dq.max(eps)).collect()
681}
682
683// ─── Tests ───────────────────────────────────────────────────────────────────
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::helpers::trapz;
689
690    /// Truncated Gaussian density f(x) ∝ exp(−(x − mu)²/2) on argvals, normalized.
691    fn truncated_gaussian(argvals: &[f64], mu: f64) -> Vec<f64> {
692        let raw: Vec<f64> = argvals
693            .iter()
694            .map(|&x| (-(x - mu).powi(2) / 2.0).exp())
695            .collect();
696        let integral = trapz(&raw, argvals);
697        raw.iter().map(|&d| d / integral).collect()
698    }
699
700    // ── normalize_density ────────────────────────────────────────────────────
701
702    #[test]
703    fn normalize_density_integral_to_one() {
704        let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
705        let vals: Vec<f64> = argvals.iter().map(|&x| 2.0 * x + 0.5).collect(); // unnormalized
706        let normed = normalize_density(&vals, &argvals).unwrap();
707        let integral = trapz(&normed, &argvals);
708        assert!(
709            (integral - 1.0).abs() < 1e-10,
710            "integral = {integral}, expected 1.0"
711        );
712        assert!(normed.iter().all(|&v| v >= 0.0), "negative values");
713    }
714
715    // ── lqd_transform ────────────────────────────────────────────────────────
716
717    #[test]
718    fn lqd_uniform_is_zero() {
719        // For f(x) = 1 on [0,1], Q(t) = t, q(t) = 1, ψ(t) = −log(1) = 0 everywhere.
720        let argvals: Vec<f64> = (0..201).map(|i| i as f64 / 200.0).collect();
721        let uniform = vec![1.0_f64; 201];
722        let psi = lqd_transform(&uniform, &argvals, Some(101)).unwrap();
723        let max_abs = psi.iter().map(|&v| v.abs()).fold(0.0_f64, f64::max);
724        assert!(
725            max_abs < 1e-5,
726            "lqd of uniform should be ≈0 everywhere, got max |ψ| = {max_abs}"
727        );
728    }
729
730    #[test]
731    fn lqd_transform_finite() {
732        let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
733        let dens = truncated_gaussian(&argvals, 0.0);
734        let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
735        assert_eq!(psi.len(), 101);
736        assert!(
737            psi.iter().all(|v| v.is_finite()),
738            "ψ contains non-finite values"
739        );
740    }
741
742    // ── round-trip ───────────────────────────────────────────────────────────
743
744    #[test]
745    fn round_trip_lqd_density_within_tolerance() {
746        // Analytic reference: truncated standard Gaussian on [−3, 3] (201 pts).
747        // Use None for n_quantile_pts so the default resolves to 201 (= argvals.len()),
748        // matching the density grid resolution.  Downsampling to 101 quantile pts on a
749        // 201-pt density loses tail information and degrades accuracy to ~0.04 L∞,
750        // well above the 5e-3 target.  The fdadensity default is N = length(dSup),
751        // i.e. no downsampling.
752        let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
753        let dens = truncated_gaussian(&argvals, 0.0);
754
755        // Forward transform with default quantile-grid resolution (= 201 here)
756        let n_q = 201usize;
757        let psi = lqd_transform(&dens, &argvals, Some(n_q)).unwrap();
758        let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
759
760        // Inverse transform
761        let dens2 = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
762
763        // L∞ error tolerance. The double linear-interpolation chain
764        // (density → CDF → quantile inversion → density) has a measured L∞ error
765        // of ~1.0e-2 on this sharp-curvature truncated Gaussian at 201 points; the
766        // `fdadensity` reference uses natural cubic-spline inversion and reaches
767        // ~5e-3. The exact-match limit is documented as a known divergence on the
768        // public functions (linear vs. cubic-spline interpolation). We assert an
769        // empirically honest bound rather than the unverified 5e-3 estimate.
770        let max_err = dens
771            .iter()
772            .zip(dens2.iter())
773            .map(|(&a, &b)| (a - b).abs())
774            .fold(0.0_f64, f64::max);
775        assert!(
776            max_err < 1.5e-2,
777            "round-trip L∞ error = {max_err} (tolerance 1.5e-2)"
778        );
779
780        // Reconstructed density integrates to 1 within 1e-6
781        let integral = trapz(&dens2, &argvals);
782        assert!(
783            (integral - 1.0).abs() < 1e-6,
784            "reconstructed integral = {integral}"
785        );
786
787        // All values non-negative (up to rounding noise)
788        assert!(
789            dens2.iter().all(|&v| v >= -1e-9),
790            "negative density values found"
791        );
792    }
793
794    // ── inverse_lqd ──────────────────────────────────────────────────────────
795
796    #[test]
797    fn inverse_lqd_normalized_nonneg() {
798        // Use a non-trivial ψ (from a truncated Gaussian) and check guarantees
799        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
800        let dens = truncated_gaussian(&argvals, 0.5);
801        let t_grid: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
802        let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
803        let rec = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
804
805        let integral = trapz(&rec, &argvals);
806        assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
807        assert!(rec.iter().all(|&v| v >= -1e-9), "negative density values");
808    }
809
810    // ── error cases ──────────────────────────────────────────────────────────
811
812    #[test]
813    fn error_negative_density() {
814        let argvals = vec![0.0, 0.5, 1.0];
815        let vals = vec![1.0, -0.1, 1.0]; // negative value
816        assert!(
817            matches!(
818                normalize_density(&vals, &argvals),
819                Err(FdarError::InvalidParameter { .. })
820            ),
821            "expected InvalidParameter for negative density"
822        );
823        assert!(
824            matches!(
825                lqd_transform(&vals, &argvals, None),
826                Err(FdarError::InvalidParameter { .. })
827            ),
828            "expected InvalidParameter for negative density in lqd_transform"
829        );
830    }
831
832    #[test]
833    fn error_length_mismatch() {
834        let argvals = vec![0.0, 0.5, 1.0];
835        let vals = vec![1.0, 1.0]; // length 2, not 3
836        assert!(
837            matches!(
838                normalize_density(&vals, &argvals),
839                Err(FdarError::InvalidDimension { .. })
840            ),
841            "expected InvalidDimension for length mismatch"
842        );
843        assert!(
844            matches!(
845                lqd_transform(&vals, &argvals, None),
846                Err(FdarError::InvalidDimension { .. })
847            ),
848            "expected InvalidDimension for length mismatch in lqd_transform"
849        );
850    }
851
852    #[test]
853    fn error_non_monotone_grid() {
854        let argvals = vec![0.0, 1.0, 0.5]; // not monotone
855        let vals = vec![1.0, 1.0, 1.0];
856        assert!(
857            matches!(
858                normalize_density(&vals, &argvals),
859                Err(FdarError::InvalidParameter { .. })
860            ),
861            "expected InvalidParameter for non-monotone argvals"
862        );
863    }
864
865    #[test]
866    fn error_all_zero_density() {
867        let argvals = vec![0.0, 0.5, 1.0];
868        let vals = vec![0.0, 0.0, 0.0];
869        assert!(
870            matches!(
871                normalize_density(&vals, &argvals),
872                Err(FdarError::InvalidParameter { .. })
873            ),
874            "expected InvalidParameter for all-zero density"
875        );
876    }
877
878    #[test]
879    fn error_inverse_lqd_length_mismatch() {
880        let psi = vec![0.0, 0.0, 0.0];
881        let t_grid = vec![0.0, 0.5]; // length 2, not 3
882        let target = vec![0.0, 0.5, 1.0];
883        assert!(
884            matches!(
885                inverse_lqd(&psi, &t_grid, &target),
886                Err(FdarError::InvalidDimension { .. })
887            ),
888            "expected InvalidDimension"
889        );
890    }
891
892    #[test]
893    fn error_inverse_lqd_non_monotone_t_grid() {
894        let psi = vec![0.0, 0.0];
895        let t_grid = vec![1.0, 0.0]; // reversed
896        let target = vec![0.0, 1.0];
897        assert!(
898            matches!(
899                inverse_lqd(&psi, &t_grid, &target),
900                Err(FdarError::InvalidParameter { .. })
901            ),
902            "expected InvalidParameter for non-monotone t_grid"
903        );
904    }
905
906    // ── wasserstein_barycenter ────────────────────────────────────────────────
907
908    #[test]
909    fn barycenter_singleton_reduction() {
910        // Barycenter of a single density should return the density itself (up to tolerance)
911        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
912        let dens = truncated_gaussian(&argvals, 0.0);
913        let mut data = FdMatrix::zeros(1, 101);
914        for (j, &v) in dens.iter().enumerate() {
915            data[(0, j)] = v;
916        }
917        let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
918        let max_err = dens
919            .iter()
920            .zip(bary.iter())
921            .map(|(&a, &b)| (a - b).abs())
922            .fold(0.0_f64, f64::max);
923        assert!(max_err < 1e-2, "singleton barycenter L∞ error = {max_err}");
924    }
925
926    #[test]
927    fn barycenter_two_density_midpoint() {
928        // Barycenter of two shifted Gaussians should lie between them
929        let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
930        let d1 = truncated_gaussian(&argvals, -1.0);
931        let d2 = truncated_gaussian(&argvals, 1.0);
932        let mut data = FdMatrix::zeros(2, 201);
933        for (j, &v) in d1.iter().enumerate() {
934            data[(0, j)] = v;
935        }
936        for (j, &v) in d2.iter().enumerate() {
937            data[(1, j)] = v;
938        }
939        let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
940        // Barycenter should be close to Gaussian centered at 0
941        let bary_integral = trapz(&bary, &argvals);
942        assert!(
943            (bary_integral - 1.0).abs() < 1e-6,
944            "barycenter integral = {bary_integral}"
945        );
946        assert!(bary.iter().all(|&v| v >= -1e-9), "negative barycenter");
947    }
948
949    #[test]
950    fn error_empty_barycenter() {
951        let data = FdMatrix::zeros(0, 101);
952        let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
953        assert!(
954            matches!(
955                wasserstein_barycenter(&data, &argvals, None),
956                Err(FdarError::InvalidDimension { .. })
957            ),
958            "expected InvalidDimension for empty matrix"
959        );
960    }
961
962    // ── lqd_fpca ─────────────────────────────────────────────────────────────
963
964    #[test]
965    fn lqd_fpca_fve_monotone_and_bounded() {
966        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
967        // Build 20 truncated Gaussians with varying means
968        let mut data = FdMatrix::zeros(20, 101);
969        for i in 0..20usize {
970            let mu = -2.0 + i as f64 * 0.2;
971            let dens = truncated_gaussian(&argvals, mu);
972            for (j, &v) in dens.iter().enumerate() {
973                data[(i, j)] = v;
974            }
975        }
976        let result = lqd_fpca(&data, &argvals, 5, Some(101)).unwrap();
977
978        // FVE is non-decreasing
979        for k in 1..result.fve.len() {
980            assert!(
981                result.fve[k] >= result.fve[k - 1] - 1e-12,
982                "FVE not monotone at k={k}: {} < {}",
983                result.fve[k],
984                result.fve[k - 1]
985            );
986        }
987        // All FVE in [0, 1]
988        assert!(
989            result.fve.iter().all(|&v| (0.0..=1.0 + 1e-9).contains(&v)),
990            "FVE out of [0, 1] range"
991        );
992    }
993
994    #[test]
995    fn lqd_fpca_leading_pc_captures_shift() {
996        // 20 Gaussians shifted from -2 to 2 — leading PC should capture >80% variance
997        let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
998        let mut data = FdMatrix::zeros(20, 201);
999        for i in 0..20usize {
1000            let mu = -2.0 + i as f64 * 4.0 / 19.0;
1001            let dens = truncated_gaussian(&argvals, mu);
1002            for (j, &v) in dens.iter().enumerate() {
1003                data[(i, j)] = v;
1004            }
1005        }
1006        let result = lqd_fpca(&data, &argvals, 3, Some(101)).unwrap();
1007        assert!(
1008            result.fve[0] > 0.80,
1009            "leading PC should explain >80% of variance for a shift family, got FVE[0] = {}",
1010            result.fve[0]
1011        );
1012    }
1013
1014    #[test]
1015    fn barycenter_weighted_extreme() {
1016        // Weights [1.0, 0.0] put all mass on the first density → barycenter ≈ d1.
1017        let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1018        let d1 = truncated_gaussian(&argvals, -1.0);
1019        let d2 = truncated_gaussian(&argvals, 1.0);
1020        let mut data = FdMatrix::zeros(2, 201);
1021        for (j, (&a, &b)) in d1.iter().zip(d2.iter()).enumerate() {
1022            data[(0, j)] = a;
1023            data[(1, j)] = b;
1024        }
1025        let bary = wasserstein_barycenter(&data, &argvals, Some(&[1.0, 0.0])).unwrap();
1026        let d1n = normalize_density(&d1, &argvals).unwrap();
1027        let d2n = normalize_density(&d2, &argvals).unwrap();
1028        // The all-weight-on-d1 barycenter recovers d1 up to quantile-inversion
1029        // interpolation error (linear-interp floor, same family as the LQD round-trip).
1030        // The meaningful, resolution-robust check is that it is far closer to d1 than d2.
1031        let l1 = |a: &[f64], b: &[f64]| -> f64 {
1032            a.iter().zip(b).map(|(&x, &y)| (x - y).abs()).sum::<f64>()
1033        };
1034        let err_d1 = l1(&bary, &d1n);
1035        let err_d2 = l1(&bary, &d2n);
1036        assert!(
1037            err_d1 < 0.4 * err_d2,
1038            "all-weight-on-d1 barycenter should track d1 (L1 to d1 = {err_d1}, to d2 = {err_d2})"
1039        );
1040    }
1041
1042    #[test]
1043    fn barycenter_normalized_nonneg() {
1044        // Barycenter output is a valid density: integrates to 1 and is non-negative.
1045        let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1046        let mut data = FdMatrix::zeros(3, 201);
1047        for i in 0..3usize {
1048            let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 1.5);
1049            for (j, &v) in dens.iter().enumerate() {
1050                data[(i, j)] = v;
1051            }
1052        }
1053        let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
1054        let integral = trapz(&bary, &argvals);
1055        assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
1056        assert!(
1057            bary.iter().all(|&v| v >= -1e-9),
1058            "negative barycenter value"
1059        );
1060    }
1061
1062    #[test]
1063    fn error_barycenter_bad_weights() {
1064        // A negative weight must be rejected, not silently accepted.
1065        let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1066        let mut data = FdMatrix::zeros(2, 201);
1067        for i in 0..2usize {
1068            let dens = truncated_gaussian(&argvals, -1.0 + 2.0 * i as f64);
1069            for (j, &v) in dens.iter().enumerate() {
1070                data[(i, j)] = v;
1071            }
1072        }
1073        let err = wasserstein_barycenter(&data, &argvals, Some(&[-0.5, 1.5]));
1074        assert!(
1075            matches!(err, Err(FdarError::InvalidParameter { .. })),
1076            "negative weight should return InvalidParameter, got {err:?}"
1077        );
1078    }
1079
1080    #[test]
1081    fn lqd_fpca_full_rank_fve_reaches_one() {
1082        // At full rank the cumulative FVE must reach 1.
1083        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1084        let mut data = FdMatrix::zeros(5, 101);
1085        for i in 0..5usize {
1086            let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 0.75);
1087            for (j, &v) in dens.iter().enumerate() {
1088                data[(i, j)] = v;
1089            }
1090        }
1091        // 5 curves → rank ≤ 4 after centering; request 4 components.
1092        let result = lqd_fpca(&data, &argvals, 4, Some(101)).unwrap();
1093        let last = *result.fve.last().unwrap();
1094        assert!(
1095            (last - 1.0).abs() < 1e-6,
1096            "full-rank cumulative FVE should reach 1, got {last}"
1097        );
1098    }
1099
1100    #[test]
1101    fn error_lqd_fpca_empty() {
1102        // An empty density matrix must return an error, not panic.
1103        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1104        let data = FdMatrix::zeros(0, 101);
1105        let err = lqd_fpca(&data, &argvals, 2, Some(101));
1106        assert!(err.is_err(), "empty density matrix should return an error");
1107    }
1108
1109    #[test]
1110    fn error_lqd_fpca_zero_ncomp() {
1111        // ncomp = 0 must be rejected with InvalidParameter, not silently produce
1112        // an empty fve vec that panics callers doing result.fve.last().unwrap().
1113        let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1114        let mut data = FdMatrix::zeros(5, 101);
1115        for i in 0..5usize {
1116            let dens = truncated_gaussian(&argvals, -1.0 + i as f64 * 0.5);
1117            for (j, &v) in dens.iter().enumerate() {
1118                data[(i, j)] = v;
1119            }
1120        }
1121        let err = lqd_fpca(&data, &argvals, 0, Some(101));
1122        assert!(
1123            matches!(
1124                err,
1125                Err(FdarError::InvalidParameter {
1126                    parameter: "ncomp",
1127                    ..
1128                })
1129            ),
1130            "ncomp=0 should return InvalidParameter, got {err:?}"
1131        );
1132    }
1133}