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