use crate::alignment::{align_to_target, karcher_mean, srsf_inverse, srsf_transform};
use crate::elastic_fpca::{
build_augmented_srsfs, center_matrix, horiz_fpca, joint_fpca, shooting_vectors_from_psis,
warps_to_normalized_psi, JointFpcaResult,
};
use crate::matrix::FdMatrix;
use crate::warping::{exp_map_sphere, psi_to_gam};
use crate::FdarError;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PrincipalDirections {
pub pc_index: usize,
pub c_values: Vec<f64>,
pub amplitude_curves: FdMatrix,
pub phase_curves: FdMatrix,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct JfpcaModel {
pub karcher_mean: Vec<f64>,
pub mean_q: Vec<f64>,
pub mean_psi: Vec<f64>,
pub vert_component: FdMatrix,
pub horiz_component: FdMatrix,
pub balance_c: f64,
pub argvals: Vec<f64>,
pub eigenvalues: Vec<f64>,
pub ncomp: usize,
pub joint_result: JointFpcaResult,
pub lambda: f64,
pub training_gammas: FdMatrix,
pub training_aligned: FdMatrix,
pub mean_srsf: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct JfpcaTransform {
pub scores: FdMatrix,
pub aligned: FdMatrix,
pub warping: FdMatrix,
}
#[must_use = "expensive computation: fit returns a trained JfpcaModel; use it to project curves"]
pub fn jfpca_fit(
data: &FdMatrix,
argvals: &[f64],
ncomp: usize,
balance_c: Option<f64>,
lambda: f64,
max_iter: usize,
) -> Result<JfpcaModel, FdarError> {
let (n, m) = data.shape();
if n < 2 || m < 2 || ncomp < 1 || argvals.len() != m || max_iter < 1 {
return Err(FdarError::InvalidDimension {
parameter: "data/argvals/ncomp/max_iter",
expected: "n >= 2, m >= 2, ncomp >= 1, argvals.len() == m, max_iter >= 1".to_string(),
actual: format!(
"n={}, m={}, ncomp={}, argvals.len()={}, max_iter={}",
n,
m,
ncomp,
argvals.len(),
max_iter
),
});
}
let karcher = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
let joint_result = joint_fpca(&karcher, argvals, ncomp, balance_c)?;
let horiz = horiz_fpca(&karcher, argvals, ncomp)?;
let (n_k, m_k) = karcher.aligned_data.shape();
let m_aug = m_k + 1;
let qn = match &karcher.aligned_srsfs {
Some(srsfs) => srsfs.clone(),
None => srsf_transform(&karcher.aligned_data, argvals),
};
let q_aug = build_augmented_srsfs(&qn, &karcher.aligned_data, n_k, m_k);
let (_, mean_q) = center_matrix(&q_aug, n_k, m_aug);
let ncomp_actual = joint_result.eigenvalues.len();
Ok(JfpcaModel {
karcher_mean: karcher.mean.clone(),
mean_q,
mean_psi: horiz.mean_psi,
vert_component: joint_result.vert_component.clone(),
horiz_component: joint_result.horiz_component.clone(),
balance_c: joint_result.balance_c,
argvals: argvals.to_vec(),
eigenvalues: joint_result.eigenvalues.clone(),
ncomp: ncomp_actual,
training_gammas: karcher.gammas.clone(),
training_aligned: karcher.aligned_data.clone(),
mean_srsf: karcher.mean_srsf.clone(),
joint_result,
lambda,
})
}
impl JfpcaModel {
#[must_use = "expensive computation: transform returns out-of-sample scores; use the result"]
pub fn transform(&self, new_curves: &FdMatrix) -> Result<JfpcaTransform, FdarError> {
let (n_new, m_new) = new_curves.shape();
let m = self.argvals.len();
if m_new != m {
return Err(FdarError::InvalidDimension {
parameter: "new_curves columns",
expected: format!("== {} (trained argvals length)", m),
actual: format!("{}", m_new),
});
}
if n_new < 1 {
return Err(FdarError::InvalidDimension {
parameter: "new_curves rows",
expected: ">= 1".to_string(),
actual: format!("{}", n_new),
});
}
let aln = align_to_target(new_curves, &self.karcher_mean, &self.argvals, self.lambda);
let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let psis = warps_to_normalized_psi(&aln.gammas, &self.argvals);
let shooting = shooting_vectors_from_psis(&psis, &self.mean_psi, &time);
let qn_new = srsf_transform(&aln.aligned_data, &self.argvals);
let q_aug = build_augmented_srsfs(&qn_new, &aln.aligned_data, n_new, m);
let m_aug = m + 1;
let mut q_aug_centered = q_aug;
for i in 0..n_new {
for j in 0..m_aug {
q_aug_centered[(i, j)] -= self.mean_q[j];
}
}
let scores = self.project_joint(&q_aug_centered, &shooting, n_new);
Ok(JfpcaTransform {
scores,
aligned: aln.aligned_data,
warping: aln.gammas,
})
}
#[must_use = "expensive computation: score_training returns the round-trip scores; use the result"]
pub fn score_training(&self) -> Result<JfpcaTransform, FdarError> {
let m = self.argvals.len();
let (n_tr, m_tr) = self.training_aligned.shape();
if m_tr != m {
return Err(FdarError::InvalidDimension {
parameter: "training_aligned columns",
expected: format!("== {} (argvals length)", m),
actual: format!("{}", m_tr),
});
}
let (n_gam, m_gam) = self.training_gammas.shape();
if n_gam != n_tr || m_gam != m {
return Err(FdarError::InvalidDimension {
parameter: "training_gammas shape",
expected: format!("({}, {}) matching training_aligned/argvals", n_tr, m),
actual: format!("({}, {})", n_gam, m_gam),
});
}
let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let psis = warps_to_normalized_psi(&self.training_gammas, &self.argvals);
let shooting = shooting_vectors_from_psis(&psis, &self.mean_psi, &time);
let qn = srsf_transform(&self.training_aligned, &self.argvals);
let q_aug = build_augmented_srsfs(&qn, &self.training_aligned, n_tr, m);
let m_aug = m + 1;
let mut q_aug_centered = q_aug;
for i in 0..n_tr {
for j in 0..m_aug {
q_aug_centered[(i, j)] -= self.mean_q[j];
}
}
let scores = self.project_joint(&q_aug_centered, &shooting, n_tr);
Ok(JfpcaTransform {
scores,
aligned: self.training_aligned.clone(),
warping: self.training_gammas.clone(),
})
}
#[must_use = "expensive computation: principal_directions returns reconstructed curves; use the result"]
pub fn principal_directions(
&self,
pc_index: usize,
c_values: &[f64],
) -> Result<PrincipalDirections, FdarError> {
if pc_index >= self.ncomp {
return Err(FdarError::InvalidParameter {
parameter: "pc_index",
message: format!("pc_index={} must be < ncomp={}", pc_index, self.ncomp),
});
}
if c_values.is_empty() {
return Err(FdarError::InvalidParameter {
parameter: "c_values",
message: "must be non-empty".to_string(),
});
}
let m = self.argvals.len();
let n_c = c_values.len();
let sigma_j = self.eigenvalues[pc_index].max(0.0).sqrt();
let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let domain = self.argvals[m - 1] - self.argvals[0];
let mut amplitude_curves = FdMatrix::zeros(n_c, m);
let mut phase_curves = FdMatrix::zeros(n_c, m);
let f0 = self.karcher_mean[0];
for (ci, &c) in c_values.iter().enumerate() {
let q_perturbed: Vec<f64> = (0..m)
.map(|l| self.mean_srsf[l] + c * sigma_j * self.vert_component[(pc_index, l)])
.collect();
let amp = srsf_inverse(&q_perturbed, &self.argvals, f0);
for j in 0..m {
amplitude_curves[(ci, j)] = amp[j];
}
let v_perturbed: Vec<f64> = (0..m)
.map(|l| c * sigma_j * self.horiz_component[(pc_index, l)])
.collect();
let psi_p = exp_map_sphere(&self.mean_psi, &v_perturbed, &time);
let gam = psi_to_gam(&psi_p, &time);
for j in 0..m {
phase_curves[(ci, j)] = self.argvals[0] + gam[j] * domain;
}
}
Ok(PrincipalDirections {
pc_index,
c_values: c_values.to_vec(),
amplitude_curves,
phase_curves,
})
}
fn project_joint(&self, q_aug_centered: &FdMatrix, shooting: &FdMatrix, n: usize) -> FdMatrix {
let m = self.argvals.len();
let m_aug = m + 1;
let ncomp = self.ncomp;
let mut scores = FdMatrix::zeros(n, ncomp);
for k in 0..ncomp {
for i in 0..n {
let mut s = 0.0;
for j in 0..m_aug {
s += q_aug_centered[(i, j)] * self.vert_component[(k, j)];
}
for j in 0..m {
s += self.balance_c * shooting[(i, j)] * self.horiz_component[(k, j)];
}
scores[(i, k)] = s;
}
}
scores
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn spanning_fixture(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let fi = i as f64;
let a1 = 1.0 + 0.4 * fi;
let a2 = 0.6 - 0.08 * fi;
let a3 = 0.35 + 0.07 * fi;
let a4 = 0.2 - 0.03 * fi;
for j in 0..m {
let t = argvals[j];
data[(i, j)] = a1 * (2.0 * PI * t).sin()
+ a2 * (4.0 * PI * t).cos()
+ a3 * (6.0 * PI * t).sin()
+ a4 * (8.0 * PI * t).cos();
}
}
(data, argvals)
}
fn max_abs_diff(a: &FdMatrix, b: &FdMatrix) -> f64 {
let (na, ma) = a.shape();
let (nb, mb) = b.shape();
assert_eq!((na, ma), (nb, mb), "shape mismatch in max_abs_diff");
let mut max_d = 0.0_f64;
for i in 0..na {
for j in 0..ma {
max_d = max_d.max((a[(i, j)] - b[(i, j)]).abs());
}
}
max_d
}
#[test]
fn tracer() {
let n = 12;
let m = 15;
let ncomp = 4;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20)
.expect("jfpca_fit should succeed on spanning fixture");
let transform = model
.transform(&data)
.expect("transform should succeed on training curves");
let (s_rows, s_cols) = transform.scores.shape();
assert_eq!(s_rows, n, "scores should have n rows");
assert_eq!(s_cols, model.ncomp, "scores should have ncomp cols");
assert_eq!(transform.aligned.shape(), (n, m), "aligned shape mismatch");
assert_eq!(transform.warping.shape(), (n, m), "warping shape mismatch");
for i in 0..s_rows {
for j in 0..s_cols {
assert!(
transform.scores[(i, j)].is_finite(),
"score [{i},{j}] is not finite"
);
}
}
}
#[test]
fn test_fit_scores_match_joint_fpca() {
use crate::alignment::karcher_mean;
use crate::elastic_fpca::joint_fpca;
let n = 12;
let m = 15;
let ncomp = 4;
let (data, argvals) = spanning_fixture(n, m);
let model =
jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
let karcher_ref = karcher_mean(&data, &argvals, 20, 1e-4, 0.0);
let joint_ref = joint_fpca(&karcher_ref, &argvals, ncomp, None)
.expect("joint_fpca reference should succeed");
let diff = max_abs_diff(&model.joint_result.scores, &joint_ref.scores);
assert!(
diff < 1e-8,
"training scores differ from joint_fpca by {diff} (tolerance 1e-8)"
);
}
#[test]
fn test_model_fields_populated() {
let n = 12;
let m = 15;
let ncomp = 4;
let (data, argvals) = spanning_fixture(n, m);
let model =
jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
assert_eq!(model.mean_psi.len(), m, "mean_psi should have length m");
assert_eq!(model.mean_q.len(), m + 1, "mean_q should have length m+1");
assert_eq!(
model.vert_component.shape(),
(model.ncomp, m + 1),
"vert_component shape mismatch"
);
assert_eq!(
model.horiz_component.shape(),
(model.ncomp, m),
"horiz_component shape mismatch"
);
assert_eq!(
model.eigenvalues.len(),
model.ncomp,
"eigenvalues length should equal ncomp"
);
assert_eq!(model.argvals, argvals, "argvals mismatch");
assert_eq!(
model.ncomp,
model.joint_result.eigenvalues.len(),
"ncomp must equal joint_result.eigenvalues.len() (clamp check)"
);
assert!(model.ncomp < n, "ncomp must be clamped to n-1");
assert_eq!(
model.training_gammas.shape(),
(n, m),
"training_gammas shape mismatch"
);
assert_eq!(
model.training_aligned.shape(),
(n, m),
"training_aligned shape mismatch"
);
}
#[test]
fn test_roundtrip_training_curves() {
let n = 12;
let m = 15;
let ncomp = 4;
let (data, argvals) = spanning_fixture(n, m);
let model =
jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
let transform = model
.score_training()
.expect("score_training should succeed");
let diff = max_abs_diff(&transform.scores, &model.joint_result.scores);
assert!(
diff < 1e-8,
"round-trip (stored alignment) diff = {diff} exceeds 1e-8; \
check the dot-product formula in project_joint()"
);
}
#[test]
fn test_transform_grid_mismatch_error() {
let n = 12;
let m = 15;
let ncomp = 4;
let (data, argvals) = spanning_fixture(n, m);
let model =
jfpca_fit(&data, &argvals, ncomp, None, 0.0, 20).expect("jfpca_fit should succeed");
let wrong_curves = FdMatrix::zeros(n, m + 3);
let res = model.transform(&wrong_curves);
assert!(
matches!(res, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension on grid mismatch, got: {:?}",
res
);
}
#[test]
fn test_fit_rejects_degenerate() {
let m = 10;
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let data_ok = FdMatrix::zeros(3, m);
let bad_argvals: Vec<f64> = (0..(m + 1)).map(|i| i as f64 / m as f64).collect();
let res = jfpca_fit(&data_ok, &bad_argvals, 2, None, 0.0, 5);
assert!(
matches!(res, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension for argvals length mismatch"
);
let res2 = jfpca_fit(&data_ok, &argvals, 0, None, 0.0, 5);
assert!(
matches!(res2, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension for ncomp=0"
);
let data_tiny = FdMatrix::zeros(1, m);
let res3 = jfpca_fit(&data_tiny, &argvals, 2, None, 0.0, 5);
assert!(
matches!(res3, Err(FdarError::InvalidDimension { .. })),
"expected InvalidDimension for n<2"
);
}
#[test]
fn principal_directions_c0_mean() {
let n = 12;
let m = 15;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
let c_values = vec![-2.0, -1.0, 0.0, 1.0, 2.0];
let pd = model
.principal_directions(0, &c_values)
.expect("principal_directions should succeed");
for j in 0..m {
let amp = pd.amplitude_curves[(2, j)];
let km = model.karcher_mean[j];
assert!(
(amp - km).abs() < 1e-10,
"c=0 amplitude curve deviates from karcher_mean at j={j}: \
amplitude={amp}, karcher_mean={km}, diff={}",
(amp - km).abs()
);
}
}
#[test]
fn principal_directions_shapes() {
let n = 12;
let m = 15;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
let c_values = vec![-2.0, -1.0, 0.0, 1.0, 2.0];
let n_c = c_values.len();
let pd = model
.principal_directions(0, &c_values)
.expect("principal_directions should succeed");
assert_eq!(
pd.amplitude_curves.shape(),
(n_c, m),
"amplitude_curves shape should be ({n_c}, {m})"
);
assert_eq!(
pd.phase_curves.shape(),
(n_c, m),
"phase_curves shape should be ({n_c}, {m})"
);
}
#[test]
fn principal_directions_sigma_sqrt_scaling() {
let n = 12;
let m = 15;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
let pc_index = 0;
let c_values = vec![0.0, 1.0];
let pd = model
.principal_directions(pc_index, &c_values)
.expect("principal_directions should succeed");
let deviation_at_c1: f64 = (0..m)
.map(|j| (pd.amplitude_curves[(1, j)] - pd.amplitude_curves[(0, j)]).abs())
.fold(0.0f64, f64::max);
let sigma_j = model.eigenvalues[pc_index].sqrt(); let raw_eigenvalue = model.eigenvalues[pc_index];
assert!(
deviation_at_c1 > 1e-12,
"c=1 should produce a non-zero deviation from c=0; got {deviation_at_c1}"
);
let max_vert = (0..m)
.map(|l| model.vert_component[(pc_index, l)].abs())
.fold(0.0f64, f64::max);
let expected_sqrt_scale = sigma_j * max_vert;
let expected_raw_scale = raw_eigenvalue * max_vert;
if (sigma_j - raw_eigenvalue).abs() > 1e-6 {
let dist_to_sqrt = (deviation_at_c1 - expected_sqrt_scale).abs();
let dist_to_raw = (deviation_at_c1 - expected_raw_scale).abs();
assert!(
dist_to_sqrt < dist_to_raw,
"Deviation at c=1 ({deviation_at_c1}) is closer to raw-eigenvalue scale \
({expected_raw_scale}) than sqrt-eigenvalue scale ({expected_sqrt_scale}); \
check that sigma_j = eigenvalues[{pc_index}].sqrt() is used, not the raw eigenvalue"
);
}
}
#[test]
fn principal_directions_rejects_bad_pc() {
let n = 12;
let m = 15;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 20).expect("jfpca_fit should succeed");
let res = model.principal_directions(model.ncomp, &[0.0]);
assert!(
matches!(res, Err(FdarError::InvalidParameter { .. })),
"pc_index == ncomp should return Err(InvalidParameter), got: {:?}",
res
);
let res2 = model.principal_directions(0, &[]);
assert!(
matches!(res2, Err(FdarError::InvalidParameter { .. })),
"empty c_values should return Err(InvalidParameter), got: {:?}",
res2
);
}
}