use crate::error::FdarError;
use crate::helpers::{cumulative_trapz, linear_interp, trapz};
use crate::matrix::FdMatrix;
use crate::regression::{fdata_to_pc_1d, FpcaResult};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct LqdFpcaResult {
pub fpca: FpcaResult,
pub fve: Vec<f64>,
}
pub fn normalize_density(vals: &[f64], argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
if vals.len() != argvals.len() {
return Err(FdarError::InvalidDimension {
parameter: "vals",
expected: format!("{}", argvals.len()),
actual: format!("{}", vals.len()),
});
}
if argvals.len() < 2 {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must have at least 2 elements".to_string(),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
if vals.iter().any(|&v| v < 0.0) {
return Err(FdarError::InvalidParameter {
parameter: "vals",
message: "density values must be non-negative".to_string(),
});
}
let integral = trapz(vals, argvals);
if integral < 1e-15 {
return Err(FdarError::InvalidParameter {
parameter: "vals",
message: "density integrates to zero or is all-zero".to_string(),
});
}
Ok(vals.iter().map(|&v| v / integral).collect())
}
pub fn lqd_transform(
density: &[f64],
argvals: &[f64],
n_quantile_pts: Option<usize>,
) -> Result<Vec<f64>, FdarError> {
if density.len() != argvals.len() {
return Err(FdarError::InvalidDimension {
parameter: "density",
expected: format!("{}", argvals.len()),
actual: format!("{}", density.len()),
});
}
if argvals.len() < 2 {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must have at least 2 elements".to_string(),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
if density.iter().any(|&v| v <= 0.0) {
return Err(FdarError::InvalidParameter {
parameter: "density",
message: "density values must be strictly positive for the LQD transform (zero/negative density produces ±∞)".to_string(),
});
}
let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
if n_q < 2 {
return Err(FdarError::InvalidParameter {
parameter: "n_quantile_pts",
message: "n_quantile_pts must be at least 2".to_string(),
});
}
let integral = trapz(density, argvals);
let dens_norm: Vec<f64> = density.iter().map(|&d| d / integral).collect();
let cdf = cumulative_trapz(&dens_norm, argvals);
let lqd_raw: Vec<f64> = dens_norm.iter().map(|&d| -d.ln()).collect();
let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
let psi: Vec<f64> = t_grid
.iter()
.map(|&t| linear_interp(&cdf, &lqd_raw, t))
.collect();
if psi.iter().any(|v| !v.is_finite()) {
return Err(FdarError::ComputationFailed {
operation: "lqd_transform",
detail: "non-finite ψ values produced; possible cause: a density value \
underflowed to 0 after normalization (input density too small \
relative to its maximum on this grid)"
.to_string(),
});
}
Ok(psi)
}
pub fn inverse_lqd(
psi: &[f64],
t_grid: &[f64],
target_argvals: &[f64],
) -> Result<Vec<f64>, FdarError> {
if psi.len() != t_grid.len() {
return Err(FdarError::InvalidDimension {
parameter: "psi",
expected: format!("{}", t_grid.len()),
actual: format!("{}", psi.len()),
});
}
if t_grid.len() < 2 {
return Err(FdarError::InvalidParameter {
parameter: "t_grid",
message: "t_grid must have at least 2 elements".to_string(),
});
}
if target_argvals.len() < 2 {
return Err(FdarError::InvalidParameter {
parameter: "target_argvals",
message: "target_argvals must have at least 2 elements".to_string(),
});
}
if t_grid.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "t_grid",
message: "t_grid must be strictly increasing".to_string(),
});
}
if target_argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "target_argvals",
message: "target_argvals must be strictly increasing".to_string(),
});
}
if psi.iter().any(|v| !v.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "psi",
message: "psi must contain only finite values".to_string(),
});
}
let exp_psi: Vec<f64> = psi.iter().map(|&p| p.exp()).collect();
let q_raw_cumtrapz = cumulative_trapz(&exp_psi, t_grid);
let lb = target_argvals[0];
let q_raw: Vec<f64> = q_raw_cumtrapz.iter().map(|&v| lb + v).collect();
let q_range = q_raw[q_raw.len() - 1] - q_raw[0]; let d_range = target_argvals[target_argvals.len() - 1] - lb;
if q_range < 1e-15 {
return Err(FdarError::ComputationFailed {
operation: "inverse_lqd",
detail: "quantile function range is zero; degenerate ψ (all-constant)".to_string(),
});
}
let scale = d_range / q_range;
let q_scaled: Vec<f64> = q_raw.iter().map(|&v| (v - q_raw[0]) * scale + lb).collect();
let dens_raw: Vec<f64> = psi.iter().map(|&p| (-p).exp()).collect();
let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
let dens: Vec<f64> = target_argvals
.iter()
.map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
.collect();
let integral = trapz(&dens, target_argvals);
if integral < 1e-15 {
return Err(FdarError::ComputationFailed {
operation: "inverse_lqd",
detail: "reconstructed density integrates to zero; check ψ admissibility".to_string(),
});
}
Ok(dens.iter().map(|&d| d / integral).collect())
}
pub fn wasserstein_barycenter(
density_matrix: &FdMatrix,
argvals: &[f64],
weights: Option<&[f64]>,
) -> Result<Vec<f64>, FdarError> {
let (n, m) = density_matrix.shape();
if n == 0 {
return Err(FdarError::InvalidDimension {
parameter: "density_matrix",
expected: "at least 1 row".to_string(),
actual: "0 rows".to_string(),
});
}
if m == 0 {
return Err(FdarError::InvalidDimension {
parameter: "density_matrix",
expected: "at least 1 column".to_string(),
actual: "0 columns".to_string(),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements (matching density_matrix columns)"),
actual: format!("{} elements", argvals.len()),
});
}
if argvals.windows(2).any(|w| w[1] <= w[0]) {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be strictly increasing".to_string(),
});
}
let w_vec: Vec<f64> = if let Some(w) = weights {
if w.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "weights",
expected: format!("{n}"),
actual: format!("{}", w.len()),
});
}
if w.iter().any(|&wi| wi < 0.0 || !wi.is_finite()) {
return Err(FdarError::InvalidParameter {
parameter: "weights",
message: "weights must be non-negative and finite".to_string(),
});
}
let s: f64 = w.iter().sum();
if s < 1e-15 {
return Err(FdarError::InvalidParameter {
parameter: "weights",
message: "weights sum to zero".to_string(),
});
}
w.iter().map(|&wi| wi / s).collect()
} else {
vec![1.0 / n as f64; n]
};
let n_q = m.max(101);
let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
let mut q_bar = vec![0.0_f64; n_q];
for i in 0..n {
let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
if row.iter().any(|&v| v < 0.0) {
return Err(FdarError::InvalidParameter {
parameter: "density_matrix",
message: format!(
"row {i} contains negative values; densities must be non-negative"
),
});
}
let integral = trapz(&row, argvals);
if integral < 1e-15 {
return Err(FdarError::InvalidParameter {
parameter: "density_matrix",
message: format!("row {i} integrates to zero (all-zero density)"),
});
}
let norm_row: Vec<f64> = row.iter().map(|&v| v / integral).collect();
let cdf_i = cumulative_trapz(&norm_row, argvals);
let wi = w_vec[i];
for j in 0..n_q {
q_bar[j] += wi * linear_interp(&cdf_i, argvals, t_grid[j]);
}
}
let lb = argvals[0];
let ub = argvals[m - 1];
let q_range = q_bar[n_q - 1] - q_bar[0];
if q_range < 1e-15 {
return Err(FdarError::ComputationFailed {
operation: "wasserstein_barycenter",
detail: "quantile average has zero range; degenerate input densities".to_string(),
});
}
let d_range = ub - lb;
let q_scaled: Vec<f64> = q_bar
.iter()
.map(|&v| (v - q_bar[0]) * d_range / q_range + lb)
.collect();
let dens_raw = quantile_density_from_q(&q_scaled, &t_grid);
let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
let dens: Vec<f64> = argvals
.iter()
.map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
.collect();
let integral = trapz(&dens, argvals);
if integral < 1e-15 {
return Err(FdarError::ComputationFailed {
operation: "wasserstein_barycenter",
detail: "barycenter density integrates to zero".to_string(),
});
}
Ok(dens.iter().map(|&d| d / integral).collect())
}
#[must_use = "expensive SVD computation — store or use the returned LqdFpcaResult"]
pub fn lqd_fpca(
density_matrix: &FdMatrix,
argvals: &[f64],
ncomp: usize,
n_quantile_pts: Option<usize>,
) -> Result<LqdFpcaResult, FdarError> {
let (n_dens, m) = density_matrix.shape();
if n_dens == 0 {
return Err(FdarError::InvalidDimension {
parameter: "density_matrix",
expected: "at least 1 row".to_string(),
actual: "0 rows".to_string(),
});
}
if m == 0 || argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements"),
actual: format!("{} elements", argvals.len()),
});
}
if ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: "ncomp must be at least 1".to_string(),
});
}
let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
let mut lqd_data = FdMatrix::zeros(n_dens, n_q);
for i in 0..n_dens {
let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
let psi = lqd_transform(&row, argvals, Some(n_q))?;
for (j, &val) in psi.iter().enumerate() {
lqd_data[(i, j)] = val;
}
}
let fpca = fdata_to_pc_1d(&lqd_data, ncomp, &t_grid)?;
let sv_sq: Vec<f64> = fpca.singular_values.iter().map(|&s| s * s).collect();
let total: f64 = sv_sq.iter().sum();
let mut cumsum = 0.0_f64;
let fve: Vec<f64> = sv_sq
.iter()
.map(|&s| {
cumsum += s;
if total > 0.0 {
cumsum / total
} else {
0.0
}
})
.collect();
Ok(LqdFpcaResult { fpca, fve })
}
pub(crate) fn dedup_adjacent(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
let mut xd = Vec::with_capacity(x.len());
let mut yd = Vec::with_capacity(y.len());
for (i, (&xi, &yi)) in x.iter().zip(y.iter()).enumerate() {
if i == 0 || xi > xd[xd.len() - 1] {
xd.push(xi);
yd.push(yi);
}
}
(xd, yd)
}
pub(crate) fn quantile_density_from_q(q: &[f64], t: &[f64]) -> Vec<f64> {
let n = q.len();
let mut qd = vec![0.0_f64; n];
if n < 2 {
return qd;
}
qd[0] = (q[1] - q[0]) / (t[1] - t[0]);
for i in 1..n - 1 {
qd[i] = (q[i + 1] - q[i - 1]) / (t[i + 1] - t[i - 1]);
}
qd[n - 1] = (q[n - 1] - q[n - 2]) / (t[n - 1] - t[n - 2]);
let eps = 1e-6_f64;
qd.iter().map(|&dq| 1.0 / dq.max(eps)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::helpers::trapz;
fn truncated_gaussian(argvals: &[f64], mu: f64) -> Vec<f64> {
let raw: Vec<f64> = argvals
.iter()
.map(|&x| (-(x - mu).powi(2) / 2.0).exp())
.collect();
let integral = trapz(&raw, argvals);
raw.iter().map(|&d| d / integral).collect()
}
#[test]
fn normalize_density_integral_to_one() {
let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
let vals: Vec<f64> = argvals.iter().map(|&x| 2.0 * x + 0.5).collect(); let normed = normalize_density(&vals, &argvals).unwrap();
let integral = trapz(&normed, &argvals);
assert!(
(integral - 1.0).abs() < 1e-10,
"integral = {integral}, expected 1.0"
);
assert!(normed.iter().all(|&v| v >= 0.0), "negative values");
}
#[test]
fn lqd_uniform_is_zero() {
let argvals: Vec<f64> = (0..201).map(|i| i as f64 / 200.0).collect();
let uniform = vec![1.0_f64; 201];
let psi = lqd_transform(&uniform, &argvals, Some(101)).unwrap();
let max_abs = psi.iter().map(|&v| v.abs()).fold(0.0_f64, f64::max);
assert!(
max_abs < 1e-5,
"lqd of uniform should be ≈0 everywhere, got max |ψ| = {max_abs}"
);
}
#[test]
fn lqd_transform_finite() {
let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
let dens = truncated_gaussian(&argvals, 0.0);
let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
assert_eq!(psi.len(), 101);
assert!(
psi.iter().all(|v| v.is_finite()),
"ψ contains non-finite values"
);
}
#[test]
fn round_trip_lqd_density_within_tolerance() {
let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
let dens = truncated_gaussian(&argvals, 0.0);
let n_q = 201usize;
let psi = lqd_transform(&dens, &argvals, Some(n_q)).unwrap();
let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
let dens2 = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
let max_err = dens
.iter()
.zip(dens2.iter())
.map(|(&a, &b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(
max_err < 1.5e-2,
"round-trip L∞ error = {max_err} (tolerance 1.5e-2)"
);
let integral = trapz(&dens2, &argvals);
assert!(
(integral - 1.0).abs() < 1e-6,
"reconstructed integral = {integral}"
);
assert!(
dens2.iter().all(|&v| v >= -1e-9),
"negative density values found"
);
}
#[test]
fn inverse_lqd_normalized_nonneg() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let dens = truncated_gaussian(&argvals, 0.5);
let t_grid: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
let rec = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
let integral = trapz(&rec, &argvals);
assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
assert!(rec.iter().all(|&v| v >= -1e-9), "negative density values");
}
#[test]
fn error_negative_density() {
let argvals = vec![0.0, 0.5, 1.0];
let vals = vec![1.0, -0.1, 1.0]; assert!(
matches!(
normalize_density(&vals, &argvals),
Err(FdarError::InvalidParameter { .. })
),
"expected InvalidParameter for negative density"
);
assert!(
matches!(
lqd_transform(&vals, &argvals, None),
Err(FdarError::InvalidParameter { .. })
),
"expected InvalidParameter for negative density in lqd_transform"
);
}
#[test]
fn error_length_mismatch() {
let argvals = vec![0.0, 0.5, 1.0];
let vals = vec![1.0, 1.0]; assert!(
matches!(
normalize_density(&vals, &argvals),
Err(FdarError::InvalidDimension { .. })
),
"expected InvalidDimension for length mismatch"
);
assert!(
matches!(
lqd_transform(&vals, &argvals, None),
Err(FdarError::InvalidDimension { .. })
),
"expected InvalidDimension for length mismatch in lqd_transform"
);
}
#[test]
fn error_non_monotone_grid() {
let argvals = vec![0.0, 1.0, 0.5]; let vals = vec![1.0, 1.0, 1.0];
assert!(
matches!(
normalize_density(&vals, &argvals),
Err(FdarError::InvalidParameter { .. })
),
"expected InvalidParameter for non-monotone argvals"
);
}
#[test]
fn error_all_zero_density() {
let argvals = vec![0.0, 0.5, 1.0];
let vals = vec![0.0, 0.0, 0.0];
assert!(
matches!(
normalize_density(&vals, &argvals),
Err(FdarError::InvalidParameter { .. })
),
"expected InvalidParameter for all-zero density"
);
}
#[test]
fn error_inverse_lqd_length_mismatch() {
let psi = vec![0.0, 0.0, 0.0];
let t_grid = vec![0.0, 0.5]; let target = vec![0.0, 0.5, 1.0];
assert!(
matches!(
inverse_lqd(&psi, &t_grid, &target),
Err(FdarError::InvalidDimension { .. })
),
"expected InvalidDimension"
);
}
#[test]
fn error_inverse_lqd_non_monotone_t_grid() {
let psi = vec![0.0, 0.0];
let t_grid = vec![1.0, 0.0]; let target = vec![0.0, 1.0];
assert!(
matches!(
inverse_lqd(&psi, &t_grid, &target),
Err(FdarError::InvalidParameter { .. })
),
"expected InvalidParameter for non-monotone t_grid"
);
}
#[test]
fn barycenter_singleton_reduction() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let dens = truncated_gaussian(&argvals, 0.0);
let mut data = FdMatrix::zeros(1, 101);
for (j, &v) in dens.iter().enumerate() {
data[(0, j)] = v;
}
let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
let max_err = dens
.iter()
.zip(bary.iter())
.map(|(&a, &b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(max_err < 1e-2, "singleton barycenter L∞ error = {max_err}");
}
#[test]
fn barycenter_two_density_midpoint() {
let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
let d1 = truncated_gaussian(&argvals, -1.0);
let d2 = truncated_gaussian(&argvals, 1.0);
let mut data = FdMatrix::zeros(2, 201);
for (j, &v) in d1.iter().enumerate() {
data[(0, j)] = v;
}
for (j, &v) in d2.iter().enumerate() {
data[(1, j)] = v;
}
let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
let bary_integral = trapz(&bary, &argvals);
assert!(
(bary_integral - 1.0).abs() < 1e-6,
"barycenter integral = {bary_integral}"
);
assert!(bary.iter().all(|&v| v >= -1e-9), "negative barycenter");
}
#[test]
fn error_empty_barycenter() {
let data = FdMatrix::zeros(0, 101);
let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
assert!(
matches!(
wasserstein_barycenter(&data, &argvals, None),
Err(FdarError::InvalidDimension { .. })
),
"expected InvalidDimension for empty matrix"
);
}
#[test]
fn lqd_fpca_fve_monotone_and_bounded() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let mut data = FdMatrix::zeros(20, 101);
for i in 0..20usize {
let mu = -2.0 + i as f64 * 0.2;
let dens = truncated_gaussian(&argvals, mu);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let result = lqd_fpca(&data, &argvals, 5, Some(101)).unwrap();
for k in 1..result.fve.len() {
assert!(
result.fve[k] >= result.fve[k - 1] - 1e-12,
"FVE not monotone at k={k}: {} < {}",
result.fve[k],
result.fve[k - 1]
);
}
assert!(
result.fve.iter().all(|&v| (0.0..=1.0 + 1e-9).contains(&v)),
"FVE out of [0, 1] range"
);
}
#[test]
fn lqd_fpca_leading_pc_captures_shift() {
let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
let mut data = FdMatrix::zeros(20, 201);
for i in 0..20usize {
let mu = -2.0 + i as f64 * 4.0 / 19.0;
let dens = truncated_gaussian(&argvals, mu);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let result = lqd_fpca(&data, &argvals, 3, Some(101)).unwrap();
assert!(
result.fve[0] > 0.80,
"leading PC should explain >80% of variance for a shift family, got FVE[0] = {}",
result.fve[0]
);
}
#[test]
fn barycenter_weighted_extreme() {
let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
let d1 = truncated_gaussian(&argvals, -1.0);
let d2 = truncated_gaussian(&argvals, 1.0);
let mut data = FdMatrix::zeros(2, 201);
for (j, (&a, &b)) in d1.iter().zip(d2.iter()).enumerate() {
data[(0, j)] = a;
data[(1, j)] = b;
}
let bary = wasserstein_barycenter(&data, &argvals, Some(&[1.0, 0.0])).unwrap();
let d1n = normalize_density(&d1, &argvals).unwrap();
let d2n = normalize_density(&d2, &argvals).unwrap();
let l1 = |a: &[f64], b: &[f64]| -> f64 {
a.iter().zip(b).map(|(&x, &y)| (x - y).abs()).sum::<f64>()
};
let err_d1 = l1(&bary, &d1n);
let err_d2 = l1(&bary, &d2n);
assert!(
err_d1 < 0.4 * err_d2,
"all-weight-on-d1 barycenter should track d1 (L1 to d1 = {err_d1}, to d2 = {err_d2})"
);
}
#[test]
fn barycenter_normalized_nonneg() {
let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
let mut data = FdMatrix::zeros(3, 201);
for i in 0..3usize {
let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 1.5);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
let integral = trapz(&bary, &argvals);
assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
assert!(
bary.iter().all(|&v| v >= -1e-9),
"negative barycenter value"
);
}
#[test]
fn error_barycenter_bad_weights() {
let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
let mut data = FdMatrix::zeros(2, 201);
for i in 0..2usize {
let dens = truncated_gaussian(&argvals, -1.0 + 2.0 * i as f64);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let err = wasserstein_barycenter(&data, &argvals, Some(&[-0.5, 1.5]));
assert!(
matches!(err, Err(FdarError::InvalidParameter { .. })),
"negative weight should return InvalidParameter, got {err:?}"
);
}
#[test]
fn lqd_fpca_full_rank_fve_reaches_one() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let mut data = FdMatrix::zeros(5, 101);
for i in 0..5usize {
let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 0.75);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let result = lqd_fpca(&data, &argvals, 4, Some(101)).unwrap();
let last = *result.fve.last().unwrap();
assert!(
(last - 1.0).abs() < 1e-6,
"full-rank cumulative FVE should reach 1, got {last}"
);
}
#[test]
fn error_lqd_fpca_empty() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let data = FdMatrix::zeros(0, 101);
let err = lqd_fpca(&data, &argvals, 2, Some(101));
assert!(err.is_err(), "empty density matrix should return an error");
}
#[test]
fn error_lqd_fpca_zero_ncomp() {
let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
let mut data = FdMatrix::zeros(5, 101);
for i in 0..5usize {
let dens = truncated_gaussian(&argvals, -1.0 + i as f64 * 0.5);
for (j, &v) in dens.iter().enumerate() {
data[(i, j)] = v;
}
}
let err = lqd_fpca(&data, &argvals, 0, Some(101));
assert!(
matches!(
err,
Err(FdarError::InvalidParameter {
parameter: "ncomp",
..
})
),
"ncomp=0 should return InvalidParameter, got {err:?}"
);
}
}