fdars_core/alignment/srsf.rs
1//! SRSF (Square-Root Slope Function) transforms and warping utilities.
2
3use crate::error::FdarError;
4use crate::fdata::{deriv, DerivDomain, DerivResult};
5use crate::helpers::{cumulative_trapz, linear_interp};
6use crate::matrix::FdMatrix;
7
8// ─── SRSF Transform and Inverse ─────────────────────────────────────────────
9
10/// Compute the Square-Root Slope Function (SRSF) transform.
11///
12/// For each curve f, the SRSF is: `q(t) = sign(f'(t)) * sqrt(|f'(t)|)`
13///
14/// # Arguments
15/// * `data` — Functional data matrix (n × m)
16/// * `argvals` — Evaluation points (length m)
17///
18/// # Returns
19/// FdMatrix of SRSFs with the same shape as input.
20///
21/// # Examples
22///
23/// ```
24/// use fdars_core::matrix::FdMatrix;
25/// use fdars_core::alignment::srsf_transform;
26///
27/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
28/// let data = FdMatrix::from_column_major(
29/// argvals.iter().map(|&t| (t * 6.0).sin()).collect(),
30/// 1, 20,
31/// ).unwrap();
32/// let srsf = srsf_transform(&data, &argvals);
33/// assert_eq!(srsf.shape(), (1, 20));
34/// ```
35#[must_use = "expensive computation whose result should not be discarded"]
36pub fn srsf_transform(data: &FdMatrix, argvals: &[f64]) -> FdMatrix {
37 let (n, m) = data.shape();
38 if n == 0 || m == 0 || argvals.len() != m {
39 return FdMatrix::zeros(n, m);
40 }
41
42 let DerivResult::OneD(deriv_mat) = deriv(data, DerivDomain::OneD { argvals, nderiv: 1 }) else {
43 unreachable!("1D domain yields a 1D result");
44 };
45
46 let mut result = FdMatrix::zeros(n, m);
47 for i in 0..n {
48 for j in 0..m {
49 let d = deriv_mat[(i, j)];
50 result[(i, j)] = d.signum() * d.abs().sqrt();
51 }
52 }
53 result
54}
55
56/// Reconstruct a curve from its SRSF representation.
57///
58/// Given SRSF q and initial value f0, reconstructs: `f(t) = f0 + ∫₀ᵗ q(s)|q(s)| ds`
59///
60/// # Arguments
61/// * `q` — SRSF values (length m)
62/// * `argvals` — Evaluation points (length m)
63/// * `f0` — Initial value f(argvals\[0\])
64///
65/// # Returns
66/// Reconstructed curve values.
67pub fn srsf_inverse(q: &[f64], argvals: &[f64], f0: f64) -> Vec<f64> {
68 let m = q.len();
69 if m == 0 {
70 return Vec::new();
71 }
72
73 // Integrand: q(s) * |q(s)|
74 let integrand: Vec<f64> = q.iter().map(|&qi| qi * qi.abs()).collect();
75 let integral = cumulative_trapz(&integrand, argvals);
76
77 integral.iter().map(|&v| f0 + v).collect()
78}
79
80// ─── Reparameterization ─────────────────────────────────────────────────────
81
82/// Reparameterize a curve by a warping function.
83///
84/// Computes `f(γ(t))` via linear interpolation.
85///
86/// # Arguments
87/// * `f` — Curve values (length m)
88/// * `argvals` — Evaluation points (length m)
89/// * `gamma` — Warping function values (length m)
90pub fn reparameterize_curve(f: &[f64], argvals: &[f64], gamma: &[f64]) -> Vec<f64> {
91 gamma
92 .iter()
93 .map(|&g| linear_interp(argvals, f, g))
94 .collect()
95}
96
97/// Compose two warping functions: `(γ₁ ∘ γ₂)(t) = γ₁(γ₂(t))`.
98///
99/// # Arguments
100/// * `gamma1` — Outer warping function (length m)
101/// * `gamma2` — Inner warping function (length m)
102/// * `argvals` — Evaluation points (length m)
103pub fn compose_warps(gamma1: &[f64], gamma2: &[f64], argvals: &[f64]) -> Vec<f64> {
104 gamma2
105 .iter()
106 .map(|&g| linear_interp(argvals, gamma1, g))
107 .collect()
108}
109
110/// Compute a single SRSF from a slice (single-row convenience).
111pub(crate) fn srsf_single(f: &[f64], argvals: &[f64]) -> Vec<f64> {
112 let m = f.len();
113 let mat = FdMatrix::from_slice(f, 1, m).expect("dimension invariant: data.len() == n * m");
114 let q_mat = srsf_transform(&mat, argvals);
115 q_mat.row(0)
116}
117
118/// Compute the inverse of a warping function.
119///
120/// Given γ: \[a,b\] → \[a,b\], computes γ⁻¹ such that γ⁻¹(γ(t)) ≈ t.
121/// The inverse is computed by mapping to \[0,1\], calling the sphere-based
122/// inverse from the warping module, and mapping back to the original domain.
123///
124/// # Errors
125/// Returns `FdarError::InvalidDimension` if lengths do not match or m < 2.
126pub fn invert_warp(gamma: &[f64], argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
127 let m = gamma.len();
128 if m != argvals.len() {
129 return Err(FdarError::InvalidDimension {
130 parameter: "gamma",
131 expected: format!("length {}", argvals.len()),
132 actual: format!("length {m}"),
133 });
134 }
135 if m < 2 {
136 return Err(FdarError::InvalidDimension {
137 parameter: "gamma",
138 expected: "length >= 2".to_string(),
139 actual: format!("length {m}"),
140 });
141 }
142 let t0 = argvals[0];
143 let domain = argvals[m - 1] - t0;
144 if domain <= 0.0 {
145 return Err(FdarError::InvalidParameter {
146 parameter: "argvals",
147 message: format!("domain must be positive, got {domain}"),
148 });
149 }
150 // Normalize to [0,1]
151 let gam_01: Vec<f64> = gamma.iter().map(|&g| (g - t0) / domain).collect();
152 let time_01: Vec<f64> = argvals.iter().map(|&t| (t - t0) / domain).collect();
153 // Invert using warping module
154 let inv_01 = crate::warping::invert_gamma(&gam_01, &time_01);
155 // Map back to original domain
156 let mut result: Vec<f64> = inv_01.iter().map(|&g| t0 + g * domain).collect();
157 crate::warping::normalize_warp(&mut result, argvals);
158 Ok(result)
159}
160
161/// Verify roundtrip accuracy: max |γ(γ⁻¹(t)) - t| over the domain.
162///
163/// Returns the maximum absolute deviation. Values near 0 indicate
164/// a high-quality inverse. Typical values for smooth warps: < 1e-10.
165pub fn warp_inverse_error(gamma: &[f64], gamma_inv: &[f64], argvals: &[f64]) -> f64 {
166 // compose gamma with gamma_inv, compare to identity
167 let roundtrip = compose_warps(gamma, gamma_inv, argvals);
168 roundtrip
169 .iter()
170 .zip(argvals.iter())
171 .map(|(&r, &t)| (r - t).abs())
172 .fold(0.0_f64, f64::max)
173}