use crate::error::FdarError;
use crate::helpers::{linear_interp, simpsons_weights};
use crate::irreg_fdata::{cov_irreg, mean_irreg, IrregFdata, KernelType};
use crate::iter_maybe_parallel;
use crate::linalg::cholesky_solve;
use crate::matrix::FdMatrix;
use nalgebra::DMatrix;
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaceFpcaConfig {
pub ncomp: usize,
pub bandwidth: f64,
pub sigma2: f64,
pub work_grid: Vec<f64>,
pub alpha: f64,
}
impl Default for PaceFpcaConfig {
fn default() -> Self {
let m = 51_usize;
Self {
ncomp: 3,
bandwidth: 0.1,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PaceFpcaResult {
pub mean: Vec<f64>,
pub eigenvalues: Vec<f64>,
pub eigenfunctions: FdMatrix,
pub scores: FdMatrix,
pub fitted: FdMatrix,
pub fitted_lower: FdMatrix,
pub fitted_upper: FdMatrix,
pub argvals: Vec<f64>,
pub sigma2: f64,
pub ncomp: usize,
}
fn standard_normal_quantile(p: f64) -> f64 {
const A: [f64; 4] = [2.515_517, 0.802_853, 0.010_328, 0.0];
const B: [f64; 4] = [1.0, 1.432_788, 0.189_269, 0.001_308];
debug_assert!(p > 0.0 && p < 1.0, "p must be in (0, 1)");
let p = p.clamp(1e-15, 1.0 - 1e-15);
let (sign, q) = if p < 0.5 { (-1.0, p) } else { (1.0, 1.0 - p) };
let t = (-2.0 * q.ln()).sqrt();
let num = A[0] + t * (A[1] + t * (A[2] + t * A[3]));
let den = B[0] + t * (B[1] + t * (B[2] + t * B[3]));
sign * (t - num / den)
}
fn eigendecompose_cov(
cov: &FdMatrix,
work_grid: &[f64],
ncomp_requested: usize,
) -> (Vec<f64>, FdMatrix) {
let m = work_grid.len();
let w = simpsons_weights(work_grid);
let sqrt_w: Vec<f64> = w.iter().map(|&wi| wi.sqrt()).collect();
let mut c_scaled = vec![0.0_f64; m * m];
for col in 0..m {
for row in 0..m {
c_scaled[row + col * m] = sqrt_w[row] * cov[(row, col)] * sqrt_w[col];
}
}
let c_dmat = DMatrix::from_column_slice(m, m, &c_scaled);
let eigen = c_dmat.symmetric_eigen();
let n_eval = eigen.eigenvalues.len();
let mut pairs: Vec<(f64, usize)> = (0..n_eval).map(|k| (eigen.eigenvalues[k], k)).collect();
pairs.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let pairs: Vec<(f64, usize)> = pairs
.into_iter()
.filter(|&(lam, _)| lam > 0.0)
.take(ncomp_requested)
.collect();
let actual_ncomp = pairs.len();
let mut eigenvalues = Vec::with_capacity(actual_ncomp);
let mut eigenfunctions = FdMatrix::zeros(m, actual_ncomp);
for (k, &(lam, col_idx)) in pairs.iter().enumerate() {
eigenvalues.push(lam);
for j in 0..m {
let raw = eigen.eigenvectors[(j, col_idx)];
eigenfunctions[(j, k)] = if sqrt_w[j] > 1e-15 {
raw / sqrt_w[j]
} else {
raw
};
}
}
for k in 0..actual_ncomp {
let j_max = (0..m)
.max_by(|&a, &b| {
eigenfunctions[(a, k)]
.abs()
.partial_cmp(&eigenfunctions[(b, k)].abs())
.unwrap_or(std::cmp::Ordering::Equal)
})
.unwrap_or(0);
if eigenfunctions[(j_max, k)] < 0.0 {
for j in 0..m {
eigenfunctions[(j, k)] = -eigenfunctions[(j, k)];
}
}
}
(eigenvalues, eigenfunctions)
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn pace_fpca(data: &IrregFdata, config: &PaceFpcaConfig) -> Result<PaceFpcaResult, FdarError> {
let n = data.n_obs();
if n == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 observation".to_string(),
actual: "0 observations".to_string(),
});
}
for i in 0..n {
let n_pts = data.n_points(i);
if n_pts < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: format!("curve {i} must have at least 2 observed points for PACE"),
actual: format!("curve {i} has {n_pts} observed point(s)"),
});
}
}
let m = config.work_grid.len();
if m < 2 {
return Err(FdarError::InvalidDimension {
parameter: "work_grid",
expected: "at least 2 grid points".to_string(),
actual: format!("{m} grid points"),
});
}
if config.ncomp == 0 {
return Err(FdarError::InvalidParameter {
parameter: "ncomp",
message: "ncomp must be at least 1".to_string(),
});
}
if !config.bandwidth.is_finite() || config.bandwidth <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "bandwidth",
message: format!(
"must be finite and strictly positive, got {}",
config.bandwidth
),
});
}
if !config.sigma2.is_finite() || config.sigma2 <= 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "sigma2",
message: format!(
"must be finite and strictly positive (required so Sigma_yi is positive-definite), got {}",
config.sigma2
),
});
}
if config.alpha <= 0.0 || config.alpha >= 1.0 {
return Err(FdarError::InvalidParameter {
parameter: "alpha",
message: format!("must be in the open interval (0, 1), got {}", config.alpha),
});
}
for (idx, &t) in config.work_grid.iter().enumerate() {
if !t.is_finite() {
return Err(FdarError::InvalidParameter {
parameter: "work_grid",
message: format!("grid point at index {idx} is not finite ({t})"),
});
}
}
for w in config.work_grid.windows(2) {
if w[0] >= w[1] {
return Err(FdarError::InvalidParameter {
parameter: "work_grid",
message: "work_grid must be strictly increasing (sorted with no duplicates)"
.to_string(),
});
}
}
let mean = mean_irreg(
data,
&config.work_grid,
config.bandwidth,
KernelType::Gaussian,
);
let nan_count = mean.iter().filter(|v| !v.is_finite()).count();
if nan_count > 0 {
return Err(FdarError::ComputationFailed {
operation: "pace_fpca mean smoothing",
detail: format!(
"mean_irreg returned non-finite values for {nan_count} of {} work-grid points; \
bandwidth {:.4e} is likely too narrow for the data range — try increasing it",
mean.len(),
config.bandwidth
),
});
}
let cov = cov_irreg(data, &config.work_grid, &config.work_grid, config.bandwidth);
let (eigenvalues, eigenfunctions) = eigendecompose_cov(&cov, &config.work_grid, config.ncomp);
let actual_ncomp = eigenvalues.len();
if actual_ncomp == 0 {
return Err(FdarError::ComputationFailed {
operation: "pace_fpca eigendecomposition",
detail: format!(
"no positive eigenvalues found in the smoothed covariance surface \
(requested {}, got 0 positive); try a larger bandwidth or more data",
config.ncomp
),
});
}
let ef_cols: Vec<Vec<f64>> = (0..actual_ncomp)
.map(|k| (0..m).map(|j| eigenfunctions[(j, k)]).collect::<Vec<f64>>())
.collect();
let z = standard_normal_quantile(1.0 - config.alpha / 2.0);
let sigma2 = config.sigma2;
type CurveResult = (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>);
let curve_results: Vec<Result<CurveResult, FdarError>> = iter_maybe_parallel!(0..n)
.map(|i| {
let (obs_t, obs_y) = data.get_obs(i);
let n_i = obs_t.len();
let mu_i: Vec<f64> = obs_t
.iter()
.map(|&t| linear_interp(&config.work_grid, &mean, t))
.collect();
let resid: Vec<f64> = obs_y
.iter()
.zip(mu_i.iter())
.map(|(&y, &m)| y - m)
.collect();
let mut phi_i = vec![0.0_f64; n_i * actual_ncomp];
for k in 0..actual_ncomp {
for j in 0..n_i {
phi_i[j * actual_ncomp + k] =
linear_interp(&config.work_grid, &ef_cols[k], obs_t[j]);
}
}
let mut sigma_yi = vec![0.0_f64; n_i * n_i];
for row in 0..n_i {
for col in 0..n_i {
let mut s = 0.0_f64;
for k in 0..actual_ncomp {
s += phi_i[row * actual_ncomp + k]
* eigenvalues[k]
* phi_i[col * actual_ncomp + k];
}
sigma_yi[row * n_i + col] = s;
}
sigma_yi[row * n_i + row] += sigma2;
}
let sigma_yi_resolved = match cholesky_solve(&sigma_yi, &resid, n_i) {
Ok(_) => sigma_yi.clone(), Err(_) => {
let mut r = sigma_yi.clone();
for row in 0..n_i {
r[row * n_i + row] += 1e-8;
}
r
}
};
let v = cholesky_solve(&sigma_yi_resolved, &resid, n_i).map_err(|_| {
FdarError::ComputationFailed {
operation: "pace_fpca BLUP",
detail: format!(
"Cholesky solve for Sigma_yi of curve {i} failed \
even after adding a 1e-8 ridge; sigma2 may be too small \
or curve has nearly collinear eigenfunction values"
),
}
})?;
let scores_row: Vec<f64> = (0..actual_ncomp)
.map(|k| {
let dot: f64 = (0..n_i).map(|j| phi_i[j * actual_ncomp + k] * v[j]).sum();
eigenvalues[k] * dot
})
.collect();
let fitted_row: Vec<f64> = (0..m)
.map(|j| {
let mut val = mean[j];
for k in 0..actual_ncomp {
val += scores_row[k] * eigenfunctions[(j, k)];
}
val
})
.collect();
let mut sigma_inv_phi_lam = vec![0.0_f64; n_i * actual_ncomp];
for k in 0..actual_ncomp {
let phi_col_k: Vec<f64> = (0..n_i).map(|j| phi_i[j * actual_ncomp + k]).collect();
let sol = cholesky_solve(&sigma_yi_resolved, &phi_col_k, n_i).map_err(|_| {
FdarError::ComputationFailed {
operation: "pace_fpca band solve",
detail: format!(
"Cholesky solve for Sigma_yi[:,{k}] of curve {i} failed after ridge"
),
}
})?;
for j in 0..n_i {
sigma_inv_phi_lam[j * actual_ncomp + k] = eigenvalues[k] * sol[j];
}
}
let mut a_mat = vec![0.0_f64; actual_ncomp * actual_ncomp];
for k in 0..actual_ncomp {
for l in 0..actual_ncomp {
let mut s = 0.0_f64;
for j in 0..n_i {
s += phi_i[j * actual_ncomp + k] * sigma_inv_phi_lam[j * actual_ncomp + l];
}
a_mat[k * actual_ncomp + l] = eigenvalues[k] * s;
}
}
let (lower_row, upper_row): (Vec<f64>, Vec<f64>) = (0..m)
.map(|j| {
let phi_at_j: Vec<f64> =
(0..actual_ncomp).map(|k| eigenfunctions[(j, k)]).collect();
let mut var_j = 0.0_f64;
for k in 0..actual_ncomp {
for l in 0..actual_ncomp {
let omega_kl = if k == l {
eigenvalues[k] - a_mat[k * actual_ncomp + l]
} else {
-a_mat[k * actual_ncomp + l]
};
var_j += omega_kl * phi_at_j[k] * phi_at_j[l];
}
}
let std_j = var_j.max(0.0).sqrt();
(fitted_row[j] - z * std_j, fitted_row[j] + z * std_j)
})
.unzip();
Ok((scores_row, fitted_row, lower_row, upper_row))
})
.collect();
let mut scores = FdMatrix::zeros(n, actual_ncomp);
let mut fitted = FdMatrix::zeros(n, m);
let mut fitted_lower = FdMatrix::zeros(n, m);
let mut fitted_upper = FdMatrix::zeros(n, m);
for (i, res) in curve_results.into_iter().enumerate() {
let (scores_row, fitted_row, lower_row, upper_row) = res?;
for k in 0..actual_ncomp {
scores[(i, k)] = scores_row[k];
}
for j in 0..m {
fitted[(i, j)] = fitted_row[j];
fitted_lower[(i, j)] = lower_row[j];
fitted_upper[(i, j)] = upper_row[j];
}
}
Ok(PaceFpcaResult {
mean,
eigenvalues,
eigenfunctions,
scores,
fitted,
fitted_lower,
fitted_upper,
argvals: config.work_grid.clone(),
sigma2: config.sigma2,
ncomp: actual_ncomp,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn small_irreg_data() -> IrregFdata {
let argvals_list = vec![
vec![0.1, 0.4, 0.7],
vec![0.0, 0.3, 0.6, 0.9],
vec![0.2, 0.5, 0.8],
vec![0.0, 0.25, 0.5, 0.75, 1.0],
vec![0.1, 0.5, 0.9],
vec![0.0, 0.4, 0.8],
];
let values_list: Vec<Vec<f64>> = argvals_list
.iter()
.enumerate()
.map(|(i, ts)| {
ts.iter()
.map(|&t: &f64| (i as f64 + 1.0) * t.sin())
.collect()
})
.collect();
IrregFdata::from_lists(&argvals_list, &values_list)
}
#[test]
fn test_pace_shape_smoke() {
let data = small_irreg_data();
let n = data.n_obs();
let m = 21_usize;
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.2,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
};
let result = pace_fpca(&data, &config).expect("smoke test should succeed");
let actual_ncomp = result.ncomp;
assert!(actual_ncomp >= 1, "at least 1 positive eigenvalue expected");
assert_eq!(result.mean.len(), m, "mean.len() == m");
assert_eq!(
result.eigenvalues.len(),
actual_ncomp,
"eigenvalues.len() == ncomp"
);
for &lam in &result.eigenvalues {
assert!(
lam > 0.0,
"all returned eigenvalues must be positive, got {lam}"
);
}
assert_eq!(
result.eigenfunctions.nrows(),
m,
"eigenfunctions.nrows() == m"
);
assert_eq!(
result.eigenfunctions.ncols(),
actual_ncomp,
"eigenfunctions.ncols() == ncomp"
);
assert_eq!(result.scores.nrows(), n, "scores.nrows() == n");
assert_eq!(
result.scores.ncols(),
actual_ncomp,
"scores.ncols() == ncomp"
);
assert_eq!(result.fitted.nrows(), n, "fitted.nrows() == n");
assert_eq!(result.fitted.ncols(), m, "fitted.ncols() == m");
assert_eq!(result.fitted_lower.nrows(), n, "fitted_lower.nrows() == n");
assert_eq!(result.fitted_lower.ncols(), m, "fitted_lower.ncols() == m");
assert_eq!(result.fitted_upper.nrows(), n, "fitted_upper.nrows() == n");
assert_eq!(result.fitted_upper.ncols(), m, "fitted_upper.ncols() == m");
assert_eq!(result.argvals, config.work_grid, "argvals echoes work_grid");
assert_eq!(result.sigma2, config.sigma2, "sigma2 echoed");
}
#[test]
fn test_crate_root_reexport() {
let _: fn(&IrregFdata, &PaceFpcaConfig) -> Result<PaceFpcaResult, FdarError> = pace_fpca;
let _config = PaceFpcaConfig::default();
assert_eq!(_config.ncomp, 3);
assert_eq!(_config.alpha, 0.05);
}
fn lcg_normal_samples(seed: u64, count: usize) -> Vec<f64> {
let mut state = seed;
let mut out = Vec::with_capacity(count);
let mut safety_valve = 0_usize;
while out.len() < count {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let u1 = ((state >> 11) as f64 + 0.5) / (1u64 << 53) as f64;
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let u2 = ((state >> 11) as f64 + 0.5) / (1u64 << 53) as f64;
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f64::consts::PI * u2;
out.push(r * theta.cos());
if out.len() < count {
out.push(r * theta.sin());
}
safety_valve += 1;
if safety_valve > 10 * count + 100 {
break;
}
}
out.truncate(count);
out
}
fn synthetic_sparse_dataset(
n: usize,
seed: u64,
) -> (IrregFdata, Vec<Vec<f64>>, Vec<f64>, Vec<f64>) {
use std::f64::consts::PI;
let sigma2_true = 0.01_f64;
let lambda = [1.0_f64, 0.5];
let all_normals = lcg_normal_samples(seed, 4 * n + 60);
let true_scores: Vec<(f64, f64)> = (0..n)
.map(|i| {
(
all_normals[i] * lambda[0].sqrt(),
all_normals[n + i] * lambda[1].sqrt(),
)
})
.collect();
let noise_start = 2 * n;
let mut state2 = seed.wrapping_add(999_999_007);
let mut argvals_list: Vec<Vec<f64>> = Vec::with_capacity(n);
let mut values_list: Vec<Vec<f64>> = Vec::with_capacity(n);
let mut noise_idx = noise_start;
for i in 0..n {
state2 = state2
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let n_pts = 3 + (state2 >> 61) as usize; let n_pts = n_pts.min(8);
let mut ts: Vec<f64> = (0..n_pts)
.map(|j| (j as f64 + 0.5) / n_pts as f64)
.collect();
for t in ts.iter_mut() {
state2 = state2
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
let jitter =
((state2 >> 11) as f64 / (1u64 << 53) as f64 - 0.5) * 0.4 / n_pts as f64;
*t = (*t + jitter).clamp(0.0, 1.0);
}
ts.sort_by(|a, b| a.partial_cmp(b).unwrap());
let (xi0, xi1) = true_scores[i];
let ys: Vec<f64> = ts
.iter()
.enumerate()
.map(|(j, &t)| {
let phi1 = (2.0_f64).sqrt() * (PI * t).sin();
let phi2 = (2.0_f64).sqrt() * (PI * t).cos();
let x_true = xi0 * phi1 + xi1 * phi2;
let eps =
all_normals.get(noise_idx + j).copied().unwrap_or(0.0) * sigma2_true.sqrt();
x_true + eps
})
.collect();
noise_idx += n_pts;
argvals_list.push(ts);
values_list.push(ys);
}
let ifd = IrregFdata::from_lists(&argvals_list, &values_list);
let true_score_vecs: Vec<Vec<f64>> = true_scores.iter().map(|&(a, b)| vec![a, b]).collect();
(ifd, true_score_vecs, lambda.to_vec(), vec![sigma2_true])
}
fn pearson_corr(x: &[f64], y: &[f64]) -> f64 {
let n = x.len() as f64;
let mx = x.iter().sum::<f64>() / n;
let my = y.iter().sum::<f64>() / n;
let num: f64 = x
.iter()
.zip(y.iter())
.map(|(&a, &b)| (a - mx) * (b - my))
.sum();
let dx: f64 = x.iter().map(|&a| (a - mx).powi(2)).sum::<f64>().sqrt();
let dy: f64 = y.iter().map(|&b| (b - my).powi(2)).sum::<f64>().sqrt();
if dx < 1e-12 || dy < 1e-12 {
0.0
} else {
num / (dx * dy)
}
}
#[test]
fn test_pace_synthetic_recovery() {
let n = 20_usize;
let (ifd, _true_scores, true_lambda, _) = synthetic_sparse_dataset(n, 42);
let m = 51_usize;
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.15,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
};
let result = pace_fpca(&ifd, &config).expect("synthetic recovery should succeed");
assert!(
result.ncomp >= 2,
"expected at least 2 positive eigenvalues, got {}",
result.ncomp
);
let lam0 = result.eigenvalues[0];
let lam1 = result.eigenvalues[1];
assert!(
(lam0 - true_lambda[0]).abs() < 0.45,
"λ̂₁ = {lam0:.4} should be within 0.45 of true λ₁ = {}",
true_lambda[0]
);
assert!(
(lam1 - true_lambda[1]).abs() < 0.3,
"λ̂₂ = {lam1:.4} should be within 0.3 of true λ₂ = {}",
true_lambda[1]
);
use std::f64::consts::PI;
let phi1_true: Vec<f64> = config
.work_grid
.iter()
.map(|&t| (2.0_f64).sqrt() * (PI * t).sin())
.collect();
let phi2_true: Vec<f64> = config
.work_grid
.iter()
.map(|&t| (2.0_f64).sqrt() * (PI * t).cos())
.collect();
let phi1_hat: Vec<f64> = (0..m).map(|j| result.eigenfunctions[(j, 0)]).collect();
let phi2_hat: Vec<f64> = (0..m).map(|j| result.eigenfunctions[(j, 1)]).collect();
let corr1 = pearson_corr(&phi1_hat, &phi1_true).abs();
let corr2 = pearson_corr(&phi2_hat, &phi2_true).abs();
assert!(
corr1 > 0.95,
"eigenfunction 1 correlation = {corr1:.4}, expected > 0.95"
);
assert!(
corr2 > 0.95,
"eigenfunction 2 correlation = {corr2:.4}, expected > 0.95"
);
}
#[test]
fn test_blup_scores_known() {
let n = 20_usize;
let (ifd, true_scores, _, _) = synthetic_sparse_dataset(n, 42);
let m = 51_usize;
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.15,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
};
let result = pace_fpca(&ifd, &config).expect("blup scores test should succeed");
let true_xi0: Vec<f64> = true_scores.iter().map(|ts| ts[0]).collect();
let hat_xi0: Vec<f64> = (0..n).map(|i| result.scores[(i, 0)]).collect();
let all_zero = hat_xi0.iter().all(|&v| v == 0.0);
assert!(!all_zero, "BLUP scores must not all be zero");
let corr0 = pearson_corr(&hat_xi0, &true_xi0).abs();
assert!(
corr0 > 0.8,
"score correlation for component 0 = {corr0:.4}, expected > 0.8"
);
}
#[test]
fn test_fitted_within_bands() {
let data = small_irreg_data();
let m = 21_usize;
let config = PaceFpcaConfig {
ncomp: 2,
bandwidth: 0.2,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
};
let result = pace_fpca(&data, &config).expect("band coverage test should succeed");
let n = data.n_obs();
for i in 0..n {
for j in 0..m {
let f = result.fitted[(i, j)];
let lo = result.fitted_lower[(i, j)];
let hi = result.fitted_upper[(i, j)];
assert!(
f >= lo - 1e-10,
"fitted[{i},{j}]={f} < fitted_lower[{i},{j}]={lo}"
);
assert!(
f <= hi + 1e-10,
"fitted[{i},{j}]={f} > fitted_upper[{i},{j}]={hi}"
);
assert!(
!(lo == 0.0 && hi == 0.0 && f != 0.0),
"degenerate band at [{i},{j}]: fitted={f}, lower=upper=0"
);
}
}
}
#[test]
fn test_determinism() {
let data = small_irreg_data();
let config = PaceFpcaConfig::default();
let r1 = pace_fpca(&data, &config).expect("first call");
let r2 = pace_fpca(&data, &config).expect("second call");
assert_eq!(r1, r2, "pace_fpca must be deterministic");
}
fn valid_config() -> PaceFpcaConfig {
let m = 11_usize;
PaceFpcaConfig {
ncomp: 1,
bandwidth: 0.2,
sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
}
}
#[test]
fn test_empty_data() {
let empty = IrregFdata::from_lists(&[], &[]);
let config = valid_config();
let err = pace_fpca(&empty, &config).expect_err("empty data must return Err");
assert!(
matches!(
err,
FdarError::InvalidDimension {
parameter: "data",
..
}
),
"expected InvalidDimension for data, got {err:?}"
);
}
#[test]
fn test_too_few_points() {
let argvals_list = vec![
vec![0.0, 0.5, 1.0], vec![], ];
let values_list = vec![vec![0.0, 0.5, 1.0], vec![]];
let data = IrregFdata::from_lists(&argvals_list, &values_list);
let config = valid_config();
let err = pace_fpca(&data, &config).expect_err("zero-point curve must return Err");
assert!(
matches!(
err,
FdarError::InvalidDimension {
parameter: "data",
..
}
),
"expected InvalidDimension for zero-point curve, got {err:?}"
);
}
#[test]
fn test_one_point_curve_rejected() {
let argvals_list = vec![
vec![0.0, 0.5, 1.0], vec![0.5], ];
let values_list = vec![vec![0.0, 0.5, 1.0], vec![0.5]];
let data = IrregFdata::from_lists(&argvals_list, &values_list);
let config = valid_config();
let err = pace_fpca(&data, &config).expect_err("single-point curve must return Err");
assert!(
matches!(
err,
FdarError::InvalidDimension {
parameter: "data",
..
}
),
"expected InvalidDimension for single-point curve, got {err:?}"
);
}
#[test]
fn test_narrow_bandwidth_returns_err_not_nan() {
let data = small_irreg_data();
let m = 21_usize;
let config = PaceFpcaConfig {
ncomp: 1,
bandwidth: 0.001, sigma2: 0.01,
work_grid: (0..m).map(|i| i as f64 / (m - 1) as f64).collect(),
alpha: 0.05,
};
let result = pace_fpca(&data, &config);
assert!(
result.is_err(),
"narrow bandwidth must return Err, not Ok with NaN; got {:?}",
result.map(|r| r.mean.iter().any(|v| !v.is_finite()))
);
if let Err(FdarError::ComputationFailed { operation, .. }) = result {
assert!(
operation.contains("mean smoothing"),
"expected ComputationFailed from mean smoothing, got operation={operation:?}"
);
}
}
#[test]
fn test_zero_ncomp() {
let data = small_irreg_data();
let mut config = valid_config();
config.ncomp = 0;
let err = pace_fpca(&data, &config).expect_err("ncomp=0 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "ncomp",
..
}
),
"expected InvalidParameter for ncomp=0, got {err:?}"
);
}
#[test]
fn test_invalid_bandwidth() {
let data = small_irreg_data();
let mut config = valid_config();
config.bandwidth = 0.0;
let err = pace_fpca(&data, &config).expect_err("bandwidth=0.0 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "bandwidth",
..
}
),
"expected InvalidParameter for bandwidth=0.0, got {err:?}"
);
config.bandwidth = -0.1;
let err = pace_fpca(&data, &config).expect_err("negative bandwidth must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "bandwidth",
..
}
),
"expected InvalidParameter for negative bandwidth, got {err:?}"
);
config.bandwidth = f64::NAN;
let err = pace_fpca(&data, &config).expect_err("NaN bandwidth must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "bandwidth",
..
}
),
"expected InvalidParameter for NaN bandwidth, got {err:?}"
);
}
#[test]
fn test_invalid_sigma2() {
let data = small_irreg_data();
let mut config = valid_config();
config.sigma2 = 0.0;
let err = pace_fpca(&data, &config).expect_err("sigma2=0.0 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "sigma2",
..
}
),
"expected InvalidParameter for sigma2=0.0, got {err:?}"
);
config.sigma2 = -0.01;
let err = pace_fpca(&data, &config).expect_err("negative sigma2 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "sigma2",
..
}
),
"expected InvalidParameter for negative sigma2, got {err:?}"
);
}
#[test]
fn test_invalid_alpha() {
let data = small_irreg_data();
let mut config = valid_config();
config.alpha = 0.0;
let err = pace_fpca(&data, &config).expect_err("alpha=0.0 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "alpha",
..
}
),
"expected InvalidParameter for alpha=0.0, got {err:?}"
);
config.alpha = 1.0;
let err = pace_fpca(&data, &config).expect_err("alpha=1.0 must return Err");
assert!(
matches!(
err,
FdarError::InvalidParameter {
parameter: "alpha",
..
}
),
"expected InvalidParameter for alpha=1.0, got {err:?}"
);
}
#[test]
fn test_short_work_grid() {
let data = small_irreg_data();
let mut config = valid_config();
config.work_grid = vec![0.5];
let err = pace_fpca(&data, &config).expect_err("1-point work_grid must return Err");
assert!(
matches!(
err,
FdarError::InvalidDimension {
parameter: "work_grid",
..
}
),
"expected InvalidDimension for 1-point work_grid, got {err:?}"
);
config.work_grid = vec![];
let err = pace_fpca(&data, &config).expect_err("empty work_grid must return Err");
assert!(
matches!(
err,
FdarError::InvalidDimension {
parameter: "work_grid",
..
}
),
"expected InvalidDimension for empty work_grid, got {err:?}"
);
}
}