Skip to main content

fdars_core/
warping.rs

1//! Warping function utilities and Hilbert sphere geometry.
2//!
3//! This module provides operations on warping (reparameterization) functions,
4//! including their Hilbert sphere representation via `ψ(t) = √γ'(t)`.
5//!
6//! Key capabilities:
7//! - [`gam_to_psi`] / [`psi_to_gam`] — Convert between warping functions and sphere
8//! - [`exp_map_sphere`] / [`inv_exp_map_sphere`] — Riemannian exponential / log maps
9//! - [`normalize_warp`] / [`invert_gamma`] — Warp normalization and inversion
10//! - [`phase_distance`] — Geodesic distance from a warp to the identity
11
12use crate::helpers::{cumulative_trapz, gradient_uniform, linear_interp, trapz};
13use crate::smoothing::nadaraya_watson;
14
15/// Ensure γ is a valid warping: monotone non-decreasing, with correct boundary values.
16pub fn normalize_warp(gamma: &mut [f64], argvals: &[f64]) {
17    let n = gamma.len();
18    if n == 0 {
19        return;
20    }
21
22    // Fix boundaries
23    gamma[0] = argvals[0];
24    gamma[n - 1] = argvals[n - 1];
25
26    // Enforce monotonicity
27    for i in 1..n {
28        if gamma[i] < gamma[i - 1] {
29            gamma[i] = gamma[i - 1];
30        }
31    }
32}
33
34/// Convert warping function to Hilbert sphere representation: ψ = √γ'.
35pub fn gam_to_psi(gam: &[f64], h: f64) -> Vec<f64> {
36    gradient_uniform(gam, h)
37        .iter()
38        .map(|&g| g.max(0.0).sqrt())
39        .collect()
40}
41
42/// Convert warping function to smoothed Hilbert sphere representation.
43///
44/// Like [`gam_to_psi`], but smooths γ before differentiating to remove
45/// DP grid kinks. Matches Python fdasrsf's `SqrtMean(smooth=True)` which
46/// uses spline smoothing (s=1e-4) before computing ψ = √γ'.
47///
48/// We use Nadaraya-Watson kernel smoothing on γ with bandwidth proportional
49/// to the grid spacing, then differentiate the smoothed result. This prevents
50/// derivative spikes from propagating into TSRVF tangent vectors and FPCA.
51pub fn gam_to_psi_smooth(gam: &[f64], h: f64) -> Vec<f64> {
52    let m = gam.len();
53    if m < 3 {
54        return gam_to_psi(gam, h);
55    }
56
57    let time: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
58
59    // Smooth gamma with Nadaraya-Watson (bandwidth = 2 grid spacings).
60    // This removes DP kinks while preserving the overall warp shape.
61    let bandwidth = 2.0 * h;
62    // nadaraya_watson cannot fail here: time/gam are non-empty (m >= 3) and bandwidth > 0.
63    let gam_smooth = nadaraya_watson(&time, gam, &time, bandwidth, "gaussian")
64        .expect("smoothing valid warp data should not fail");
65
66    gradient_uniform(&gam_smooth, h)
67        .iter()
68        .map(|&g| g.max(0.0).sqrt())
69        .collect()
70}
71
72/// Convert ψ back to warping function: γ = cumtrapz(ψ²), normalized to \[0,1\].
73pub fn psi_to_gam(psi: &[f64], time: &[f64]) -> Vec<f64> {
74    let psi_sq: Vec<f64> = psi.iter().map(|&p| p * p).collect();
75    let gam = cumulative_trapz(&psi_sq, time);
76    let min_val = gam.iter().copied().fold(f64::INFINITY, f64::min);
77    let max_val = gam.iter().copied().fold(f64::NEG_INFINITY, f64::max);
78    let range = (max_val - min_val).max(1e-10);
79    gam.iter().map(|&v| (v - min_val) / range).collect()
80}
81
82/// L2 inner product: ∫ψ₁·ψ₂ dt via trapezoidal rule.
83pub fn inner_product_l2(psi1: &[f64], psi2: &[f64], time: &[f64]) -> f64 {
84    let prod: Vec<f64> = psi1.iter().zip(psi2.iter()).map(|(&a, &b)| a * b).collect();
85    trapz(&prod, time)
86}
87
88/// L2 norm: √(∫ψ² dt).
89pub fn l2_norm_l2(psi: &[f64], time: &[f64]) -> f64 {
90    inner_product_l2(psi, psi, time).max(0.0).sqrt()
91}
92
93/// Inverse exponential (log) map on the Hilbert sphere.
94/// Returns tangent vector at `mu` pointing toward `psi`.
95pub fn inv_exp_map_sphere(mu: &[f64], psi: &[f64], time: &[f64]) -> Vec<f64> {
96    let ip = inner_product_l2(mu, psi, time).clamp(-1.0, 1.0);
97    let theta = ip.acos();
98    if theta < 1e-10 {
99        vec![0.0; mu.len()]
100    } else {
101        let coeff = theta / theta.sin();
102        let cos_theta = theta.cos();
103        mu.iter()
104            .zip(psi.iter())
105            .map(|(&m, &p)| coeff * (p - cos_theta * m))
106            .collect()
107    }
108}
109
110/// Exponential map on the Hilbert sphere.
111/// Moves from `psi` along tangent vector `v`.
112pub fn exp_map_sphere(psi: &[f64], v: &[f64], time: &[f64]) -> Vec<f64> {
113    let v_norm = l2_norm_l2(v, time);
114    if v_norm < 1e-10 {
115        psi.to_vec()
116    } else {
117        let cos_n = v_norm.cos();
118        let sin_n = v_norm.sin();
119        psi.iter()
120            .zip(v.iter())
121            .map(|(&p, &vi)| cos_n * p + sin_n * vi / v_norm)
122            .collect()
123    }
124}
125
126/// Invert a warping function: find γ⁻¹ such that γ⁻¹(γ(t)) = t.
127/// `gam` and `time` are both on \[0,1\].
128pub fn invert_gamma(gam: &[f64], time: &[f64]) -> Vec<f64> {
129    let n = time.len();
130    let mut gam_inv: Vec<f64> = time.iter().map(|&t| linear_interp(gam, time, t)).collect();
131    gam_inv[0] = time[0];
132    gam_inv[n - 1] = time[n - 1];
133    gam_inv
134}
135
136/// Geodesic distance from a warping function to the identity on the Hilbert sphere.
137///
138/// Computes `arccos(⟨ψ/‖ψ‖, 1/‖1‖⟩_L2)` where `ψ = √γ'`.
139///
140/// # Arguments
141/// * `gamma` — Warping function values (length m)
142/// * `argvals` — Evaluation points (length m)
143///
144/// # Returns
145/// Geodesic distance (≥ 0). Returns 0 for the identity warp.
146pub fn phase_distance(gamma: &[f64], argvals: &[f64]) -> f64 {
147    let m = gamma.len();
148    if m < 2 {
149        return 0.0;
150    }
151
152    let t0 = argvals[0];
153    let t1 = argvals[m - 1];
154    let domain = t1 - t0;
155
156    // Work on [0,1] internally
157    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
158    let binsize = 1.0 / (m - 1) as f64;
159
160    // Convert gamma to [0,1] and compute psi
161    let gam_01: Vec<f64> = (0..m).map(|j| (gamma[j] - t0) / domain).collect();
162    let psi = gam_to_psi(&gam_01, binsize);
163
164    // Normalize psi to unit sphere
165    let psi_norm = l2_norm_l2(&psi, &time);
166    if psi_norm < 1e-10 {
167        return 0.0;
168    }
169    let psi_unit: Vec<f64> = psi.iter().map(|&p| p / psi_norm).collect();
170
171    // Identity warp psi = constant 1, normalized
172    let id_raw = vec![1.0; m];
173    let id_norm = l2_norm_l2(&id_raw, &time);
174    let id_unit: Vec<f64> = id_raw.iter().map(|&v| v / id_norm).collect();
175
176    // Geodesic distance = arccos(inner product)
177    let ip = inner_product_l2(&psi_unit, &id_unit, &time).clamp(-1.0, 1.0);
178    ip.acos()
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::test_helpers::uniform_grid;
185
186    #[test]
187    fn test_gam_psi_round_trip() {
188        let m = 101;
189        let time = uniform_grid(m);
190        let h = 1.0 / (m - 1) as f64;
191
192        // Start with identity warp
193        let gam = time.clone();
194        let psi = gam_to_psi(&gam, h);
195        let gam_recovered = psi_to_gam(&psi, &time);
196
197        for j in 0..m {
198            assert!(
199                (gam_recovered[j] - time[j]).abs() < 0.02,
200                "Round trip failed at j={j}: got {}, expected {}",
201                gam_recovered[j],
202                time[j]
203            );
204        }
205    }
206
207    #[test]
208    fn test_normalize_warp_properties() {
209        let t = uniform_grid(20);
210        let mut gamma = vec![0.1; 20];
211        normalize_warp(&mut gamma, &t);
212
213        assert_eq!(gamma[0], t[0]);
214        assert_eq!(gamma[19], t[19]);
215        for i in 1..20 {
216            assert!(gamma[i] >= gamma[i - 1]);
217        }
218    }
219
220    #[test]
221    fn test_invert_gamma_identity() {
222        let m = 50;
223        let time = uniform_grid(m);
224        let inv = invert_gamma(&time, &time);
225        for j in 0..m {
226            assert!(
227                (inv[j] - time[j]).abs() < 1e-12,
228                "Inverting identity should give identity at j={j}"
229            );
230        }
231    }
232
233    #[test]
234    fn test_sphere_round_trip() {
235        let m = 21;
236        let time = uniform_grid(m);
237
238        // Construct two unit vectors on the sphere
239        let raw1 = vec![1.0; m];
240        let norm1 = l2_norm_l2(&raw1, &time);
241        let psi1: Vec<f64> = raw1.iter().map(|&v| v / norm1).collect();
242
243        let raw2: Vec<f64> = time
244            .iter()
245            .map(|&t| 1.0 + 0.3 * (2.0 * std::f64::consts::PI * t).sin())
246            .collect();
247        let norm2 = l2_norm_l2(&raw2, &time);
248        let psi2: Vec<f64> = raw2.iter().map(|&v| v / norm2).collect();
249
250        let v = inv_exp_map_sphere(&psi1, &psi2, &time);
251        let recovered = exp_map_sphere(&psi1, &v, &time);
252
253        let diff: Vec<f64> = psi2
254            .iter()
255            .zip(recovered.iter())
256            .map(|(&a, &b)| (a - b).powi(2))
257            .collect();
258        let l2_err = trapz(&diff, &time).max(0.0).sqrt();
259        assert!(
260            l2_err < 1e-12,
261            "Sphere round-trip error = {l2_err:.2e}, expected < 1e-12"
262        );
263    }
264
265    #[test]
266    fn test_phase_distance_identity_zero() {
267        let m = 101;
268        let t = uniform_grid(m);
269        let d = phase_distance(&t, &t);
270        assert!(
271            d < 1e-6,
272            "Phase distance of identity warp should be ~0, got {d}"
273        );
274    }
275
276    #[test]
277    fn test_phase_distance_nonidentity_positive() {
278        let m = 101;
279        let t = uniform_grid(m);
280        let gamma: Vec<f64> = t.iter().map(|&ti| ti * ti).collect(); // quadratic warp
281        let d = phase_distance(&gamma, &t);
282        assert!(
283            d > 0.01,
284            "Phase distance of non-identity warp should be > 0, got {d}"
285        );
286    }
287}