use crate::error::FdarError;
use crate::matrix::FdMatrix;
use nalgebra::DMatrix;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Lfd {
pub coefs: Vec<Vec<f64>>,
}
impl Lfd {
pub fn apply(&self, data: &FdMatrix, argvals: &[f64]) -> Result<FdMatrix, FdarError> {
let (n, n_pts) = data.shape();
let m = self.coefs.len();
if m == 0 {
return Err(FdarError::InvalidParameter {
parameter: "coefs",
message: "Lfd requires at least one weight function (coefs.len() >= 1); \
use coefs = vec![vec![0.0]] for the pure-derivative operator"
.to_string(),
});
}
if n_pts < 2 {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: ">= 2 (required for finite-difference derivatives)".to_string(),
actual: n_pts.to_string(),
});
}
if argvals.len() != n_pts {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: n_pts.to_string(),
actual: argvals.len().to_string(),
});
}
for (k, coef) in self.coefs.iter().enumerate() {
if coef.len() != 1 && coef.len() != n_pts {
return Err(FdarError::InvalidDimension {
parameter: "coefs[k]",
expected: format!("1 or {n_pts}"),
actual: format!("coefs[{k}].len() = {}", coef.len()),
});
}
}
let mut out = FdMatrix::zeros(n, n_pts);
for i in 0..n {
let mut derivs: Vec<Vec<f64>> = Vec::with_capacity(m + 1);
let curve: Vec<f64> = (0..n_pts).map(|j| data[(i, j)]).collect();
derivs.push(curve);
for _ in 0..m {
let prev = derivs.last().unwrap();
let d = crate::helpers::gradient(prev, argvals);
derivs.push(d);
}
for j in 0..n_pts {
let mut lx_j = derivs[m][j];
for k in 0..m {
let beta_k = if self.coefs[k].len() == 1 {
self.coefs[k][0]
} else {
self.coefs[k][j]
};
lx_j += beta_k * derivs[k][j];
}
out[(i, j)] = lx_j;
}
}
Ok(out)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PdaResult {
pub coefficients: Vec<Vec<f64>>,
pub order: usize,
pub residuals: Option<FdMatrix>,
}
pub fn principal_differential_analysis(
data: &FdMatrix,
argvals: &[f64],
order: usize,
) -> Result<PdaResult, FdarError> {
let (n, n_pts) = data.shape();
if order == 0 {
return Err(FdarError::InvalidParameter {
parameter: "order",
message: "must be >= 1".to_string(),
});
}
if argvals.len() != n_pts {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: n_pts.to_string(),
actual: argvals.len().to_string(),
});
}
if n_pts < 2 {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: ">= 2 (required for finite-difference derivatives)".to_string(),
actual: n_pts.to_string(),
});
}
if n < order + 1 {
return Err(FdarError::InvalidDimension {
parameter: "data (n_curves)",
expected: format!(">= order + 1 = {}", order + 1),
actual: n.to_string(),
});
}
let mut derivs: Vec<FdMatrix> = Vec::with_capacity(order + 1);
derivs.push(data.clone());
for _ in 0..order {
let prev = derivs.last().unwrap();
let mut next = FdMatrix::zeros(n, n_pts);
for i in 0..n {
let row: Vec<f64> = (0..n_pts).map(|j| prev[(i, j)]).collect();
let grad = crate::helpers::gradient(&row, argvals);
for j in 0..n_pts {
next[(i, j)] = grad[j];
}
}
derivs.push(next);
}
let mut coefficients: Vec<Vec<f64>> = vec![vec![0.0; n_pts]; order];
for j in 0..n_pts {
let mut x_j = DMatrix::<f64>::zeros(n, order);
for i in 0..n {
for k in 0..order {
x_j[(i, k)] = derivs[k][(i, j)];
}
}
let y_j: Vec<f64> = (0..n).map(|i| -derivs[order][(i, j)]).collect();
let y_vec = nalgebra::DVector::from_vec(y_j);
let svd = nalgebra::SVD::new(x_j, true, true);
let max_sv = svd.singular_values.iter().copied().fold(0.0_f64, f64::max);
let threshold = 1e-10 * max_sv;
if let (Some(u), Some(v_t)) = (svd.u.as_ref(), svd.v_t.as_ref()) {
let u_t_y: Vec<f64> = (0..order.min(svd.singular_values.len()))
.map(|s| (0..n).map(|i| u[(i, s)] * y_vec[i]).sum::<f64>())
.collect();
let mut beta_j = vec![0.0_f64; order];
for k in 0..order {
let mut val = 0.0_f64;
for s in 0..order.min(svd.singular_values.len()) {
if svd.singular_values[s] > threshold {
val += v_t[(s, k)] * u_t_y[s] / svd.singular_values[s];
}
}
beta_j[k] = val;
}
for k in 0..order {
coefficients[k][j] = beta_j[k];
}
}
}
Ok(PdaResult {
coefficients,
order,
residuals: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn lfd_empty_coefs_returns_err() {
let n_pts = 10;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let data = FdMatrix::zeros(2, n_pts);
let lfd = Lfd { coefs: vec![] };
let result = lfd.apply(&data, &argvals);
assert!(
matches!(result, Err(FdarError::InvalidParameter { .. })),
"expected InvalidParameter for empty coefs, got: {:?}",
result
);
}
#[test]
fn lfd_constant_operator_on_constant_curve() {
let n_pts = 11;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let curve_val = 3.0_f64;
let c = 2.0_f64;
let mut data = FdMatrix::zeros(1, n_pts);
for j in 0..n_pts {
data[(0, j)] = curve_val;
}
let lfd = Lfd {
coefs: vec![vec![c]], };
let lx = lfd.apply(&data, &argvals).unwrap();
assert_eq!(lx.shape(), (1, n_pts));
let expected = c * curve_val;
for j in 0..n_pts {
assert!(
(lx[(0, j)] - expected).abs() < 1e-6,
"lx[{j}] = {} but expected {} (diff = {})",
lx[(0, j)],
expected,
(lx[(0, j)] - expected).abs()
);
}
}
#[test]
fn lfd_mismatched_argvals_returns_err() {
let n_pts = 10;
let _argvals: Vec<f64> = (0..n_pts).map(|i| i as f64).collect();
let data = FdMatrix::zeros(2, n_pts);
let lfd = Lfd {
coefs: vec![vec![1.0]],
};
let wrong_argvals: Vec<f64> = (0..n_pts + 3).map(|i| i as f64).collect();
let result = lfd.apply(&data, &wrong_argvals);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension, got: {:?}",
result
);
}
#[test]
fn lfd_bad_coefs_length_returns_err() {
let n_pts = 10;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64).collect();
let data = FdMatrix::zeros(2, n_pts);
let lfd = Lfd {
coefs: vec![vec![1.0, 2.0, 3.0, 4.0, 5.0]],
};
let result = lfd.apply(&data, &argvals);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension, got: {:?}",
result
);
}
#[test]
fn lfd_apply_shape_preserved() {
let n_pts = 20;
let n_curves = 4;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let mut data = FdMatrix::zeros(n_curves, n_pts);
for i in 0..n_curves {
for j in 0..n_pts {
data[(i, j)] = argvals[j].powi(2) + i as f64;
}
}
let lfd = Lfd {
coefs: vec![vec![1.0], vec![0.5]],
};
let lx = lfd.apply(&data, &argvals).unwrap();
assert_eq!(lx.shape(), (n_curves, n_pts));
}
#[test]
fn pda_recovers_harmonic_oscillator() {
let omega = 2.0 * PI;
let n_pts = 101;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let n_curves = 20;
let mut data = FdMatrix::zeros(n_curves, n_pts);
for i in 0..n_curves {
let a = (i + 1) as f64;
let b = (i + 2) as f64;
for (j, &t) in argvals.iter().enumerate() {
data[(i, j)] = a * (omega * t).cos() + b * (omega * t).sin();
}
}
let result = principal_differential_analysis(&data, &argvals, 2).unwrap();
assert_eq!(result.coefficients.len(), 2);
assert_eq!(result.coefficients[0].len(), n_pts);
let omega_sq = omega * omega; let tolerance = 1.0;
for (j, &beta0_j) in result.coefficients[0].iter().enumerate() {
assert!(
(beta0_j - omega_sq).abs() < tolerance,
"β₀[{j}] = {beta0_j}, expected ≈ {omega_sq}, diff = {}",
(beta0_j - omega_sq).abs()
);
}
for (j, &beta1_j) in result.coefficients[1].iter().enumerate() {
assert!(
beta1_j.abs() < tolerance,
"β₁[{j}] = {beta1_j}, expected ≈ 0"
);
}
}
#[test]
fn pda_too_few_curves_returns_err() {
let n_pts = 51;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let data = FdMatrix::zeros(2, n_pts);
let result = principal_differential_analysis(&data, &argvals, 2);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension, got: {:?}",
result
);
}
#[test]
fn pda_mismatched_argvals_returns_err() {
let n_pts = 51;
let _argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let data = FdMatrix::zeros(5, n_pts);
let wrong_argvals: Vec<f64> = (0..n_pts + 5).map(|i| i as f64).collect();
let result = principal_differential_analysis(&data, &wrong_argvals, 2);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension, got: {:?}",
result
);
}
#[test]
fn lfd_single_point_grid_returns_err() {
let argvals = vec![0.5_f64];
let mut data = FdMatrix::zeros(2, 1);
data[(0, 0)] = 1.0;
data[(1, 0)] = 2.0;
let lfd = Lfd {
coefs: vec![vec![1.0]],
};
let result = lfd.apply(&data, &argvals);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension for n_pts=1, got: {:?}",
result
);
}
#[test]
fn pda_single_point_grid_returns_err() {
let argvals = vec![0.5_f64];
let mut data = FdMatrix::zeros(5, 1);
for i in 0..5 {
data[(i, 0)] = i as f64;
}
let result = principal_differential_analysis(&data, &argvals, 2);
assert!(
matches!(result, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension for n_pts=1, got: {:?}",
result
);
}
#[test]
fn pda_zero_order_returns_err() {
let n_pts = 51;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let data = FdMatrix::zeros(5, n_pts);
let result = principal_differential_analysis(&data, &argvals, 0);
assert!(
matches!(result, Err(FdarError::InvalidParameter { .. })),
"expected InvalidParameter, got: {:?}",
result
);
}
#[test]
fn pda_result_shape_invariants() {
let omega = 2.0 * PI;
let n_pts = 51;
let argvals: Vec<f64> = (0..n_pts).map(|i| i as f64 / (n_pts - 1) as f64).collect();
let n_curves = 10;
let order = 2;
let mut data = FdMatrix::zeros(n_curves, n_pts);
for i in 0..n_curves {
let a = (i + 1) as f64;
for (j, &t) in argvals.iter().enumerate() {
data[(i, j)] = a * (omega * t).cos();
}
}
let result = principal_differential_analysis(&data, &argvals, order).unwrap();
assert_eq!(result.order, order);
assert_eq!(result.coefficients.len(), order);
for k in 0..order {
assert_eq!(
result.coefficients[k].len(),
n_pts,
"coefficients[{k}] should have length n_pts={n_pts}"
);
}
assert!(result.residuals.is_none());
}
}