Skip to main content

fdars_core/alignment/
mod.rs

1//! Elastic alignment and SRSF (Square-Root Slope Function) transforms.
2//!
3//! This module provides phase-amplitude separation for functional data via
4//! the elastic framework. Key capabilities:
5//!
6//! - [`srsf_transform`] / [`srsf_inverse`] — SRSF representation and reconstruction
7//! - [`elastic_align_pair`] — Pairwise curve alignment via dynamic programming
8//! - [`elastic_distance`] — Elastic (Fisher-Rao) distance between curves
9//! - [`align_to_target`] — Align a set of curves to a common target
10//! - [`karcher_mean`] — Karcher (Fréchet) mean in the elastic metric
11//! - [`elastic_self_distance_matrix`] / [`elastic_cross_distance_matrix`] — Distance matrices
12//! - [`reparameterize_curve`] / [`compose_warps`] — Warping utilities
13
14mod constrained;
15mod karcher;
16mod nd;
17mod pairwise;
18mod quality;
19mod set;
20mod srsf;
21mod tsrvf;
22
23#[cfg(test)]
24mod tests;
25
26// Re-export all public items so that `crate::alignment::X` continues to work.
27pub use constrained::{
28    elastic_align_pair_constrained, elastic_align_pair_with_landmarks, ConstrainedAlignmentResult,
29};
30pub use karcher::karcher_mean;
31pub use nd::{
32    elastic_align_pair_nd, elastic_distance_nd, srsf_inverse_nd, srsf_transform_nd,
33    AlignmentResultNd,
34};
35pub use pairwise::{
36    amplitude_distance, amplitude_self_distance_matrix, elastic_align_pair,
37    elastic_cross_distance_matrix, elastic_distance, elastic_self_distance_matrix,
38    phase_distance_pair, phase_self_distance_matrix,
39};
40pub use quality::{
41    alignment_quality, pairwise_consistency, warp_complexity, warp_smoothness, AlignmentQuality,
42};
43pub use set::{align_to_target, elastic_decomposition, DecompositionResult};
44pub use srsf::{compose_warps, reparameterize_curve, srsf_inverse, srsf_transform};
45pub use tsrvf::{
46    tsrvf_from_alignment, tsrvf_from_alignment_with_method, tsrvf_inverse, tsrvf_transform,
47    tsrvf_transform_with_method, TransportMethod, TsrvfResult,
48};
49
50// Re-export pub(crate) items so other crate modules can use them.
51pub(crate) use karcher::sqrt_mean_inverse;
52
53use crate::helpers::linear_interp;
54use crate::matrix::FdMatrix;
55use crate::warping::normalize_warp;
56
57// ─── Types ──────────────────────────────────────────────────────────────────
58
59/// Result of aligning one curve to another.
60#[derive(Debug, Clone, PartialEq)]
61pub struct AlignmentResult {
62    /// Warping function γ mapping the domain to itself.
63    pub gamma: Vec<f64>,
64    /// The aligned (reparameterized) curve.
65    pub f_aligned: Vec<f64>,
66    /// Elastic distance after alignment.
67    pub distance: f64,
68}
69
70/// Result of aligning a set of curves to a common target.
71#[derive(Debug, Clone, PartialEq)]
72pub struct AlignmentSetResult {
73    /// Warping functions (n × m).
74    pub gammas: FdMatrix,
75    /// Aligned curves (n × m).
76    pub aligned_data: FdMatrix,
77    /// Elastic distances for each curve.
78    pub distances: Vec<f64>,
79}
80
81/// Result of the Karcher mean computation.
82#[derive(Debug, Clone, PartialEq)]
83pub struct KarcherMeanResult {
84    /// Karcher mean curve.
85    pub mean: Vec<f64>,
86    /// SRSF of the Karcher mean.
87    pub mean_srsf: Vec<f64>,
88    /// Final warping functions (n × m).
89    pub gammas: FdMatrix,
90    /// Curves aligned to the mean (n × m).
91    pub aligned_data: FdMatrix,
92    /// Number of iterations used.
93    pub n_iter: usize,
94    /// Whether the algorithm converged.
95    pub converged: bool,
96    /// Pre-computed SRSFs of aligned curves (n × m), if available.
97    /// When set, FPCA functions use these instead of recomputing from `aligned_data`.
98    pub aligned_srsfs: Option<FdMatrix>,
99}
100
101// ─── Dynamic Programming Alignment ──────────────────────────────────────────
102// Faithful port of fdasrvf's DP algorithm (dp_grid.cpp / dp_nbhd.cpp).
103
104/// Pre-computed coprime neighborhood for nbhd_dim=7 (fdasrvf default).
105/// All (dr, dc) with 1 ≤ dr, dc ≤ 7 and gcd(dr, dc) = 1.
106/// dr = row delta (q2 direction), dc = column delta (q1 direction).
107#[rustfmt::skip]
108const COPRIME_NBHD_7: [(usize, usize); 35] = [
109    (1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(1,7),
110    (2,1),      (2,3),      (2,5),      (2,7),
111    (3,1),(3,2),      (3,4),(3,5),      (3,7),
112    (4,1),      (4,3),      (4,5),      (4,7),
113    (5,1),(5,2),(5,3),(5,4),      (5,6),(5,7),
114    (6,1),                  (6,5),      (6,7),
115    (7,1),(7,2),(7,3),(7,4),(7,5),(7,6),
116];
117
118/// Compute the edge weight for a move from grid point (sr, sc) to (tr, tc).
119///
120/// Port of fdasrvf's `dp_edge_weight` for 1-D curves on a shared uniform grid.
121/// - Rows = q2 indices, columns = q1 indices (matching fdasrvf convention).
122/// - `slope = (argvals[tr] - argvals[sr]) / (argvals[tc] - argvals[sc])` = γ'
123/// - Walks through sub-intervals synchronized at both curves' breakpoints,
124///   accumulating `(q1[idx1] - √slope · q2[idx2])² · dt`.
125#[inline]
126pub(super) fn dp_edge_weight(
127    q1: &[f64],
128    q2: &[f64],
129    argvals: &[f64],
130    sc: usize,
131    tc: usize,
132    sr: usize,
133    tr: usize,
134) -> f64 {
135    let n1 = tc - sc;
136    let n2 = tr - sr;
137    if n1 == 0 || n2 == 0 {
138        return f64::INFINITY;
139    }
140
141    let slope = (argvals[tr] - argvals[sr]) / (argvals[tc] - argvals[sc]);
142    let rslope = slope.sqrt();
143
144    // Walk through sub-intervals synchronized at breakpoints of both curves
145    let mut weight = 0.0;
146    let mut i1 = 0usize; // sub-interval index in q1 direction
147    let mut i2 = 0usize; // sub-interval index in q2 direction
148
149    while i1 < n1 && i2 < n2 {
150        // Current sub-interval boundaries as fractions of the total span
151        let left1 = i1 as f64 / n1 as f64;
152        let right1 = (i1 + 1) as f64 / n1 as f64;
153        let left2 = i2 as f64 / n2 as f64;
154        let right2 = (i2 + 1) as f64 / n2 as f64;
155
156        let left = left1.max(left2);
157        let right = right1.min(right2);
158        let dt = right - left;
159
160        if dt > 0.0 {
161            let diff = q1[sc + i1] - rslope * q2[sr + i2];
162            weight += diff * diff * dt;
163        }
164
165        // Advance whichever sub-interval ends first
166        if right1 < right2 {
167            i1 += 1;
168        } else if right2 < right1 {
169            i2 += 1;
170        } else {
171            i1 += 1;
172            i2 += 1;
173        }
174    }
175
176    // Scale by the span in q1 direction
177    weight * (argvals[tc] - argvals[sc])
178}
179
180/// Compute the λ·(slope−1)²·dt penalty for a DP edge.
181#[inline]
182pub(super) fn dp_lambda_penalty(
183    argvals: &[f64],
184    sc: usize,
185    tc: usize,
186    sr: usize,
187    tr: usize,
188    lambda: f64,
189) -> f64 {
190    if lambda > 0.0 {
191        let dt = argvals[tc] - argvals[sc];
192        let slope = (argvals[tr] - argvals[sr]) / dt;
193        lambda * (slope - 1.0).powi(2) * dt
194    } else {
195        0.0
196    }
197}
198
199/// Traceback a parent-pointer array from bottom-right to top-left.
200///
201/// Returns the path as `(row, col)` pairs from `(0,0)` to `(nrows-1, ncols-1)`.
202fn dp_traceback(parent: &[u32], nrows: usize, ncols: usize) -> Vec<(usize, usize)> {
203    let mut path = Vec::with_capacity(nrows + ncols);
204    let mut cur = (nrows - 1) * ncols + (ncols - 1);
205    loop {
206        path.push((cur / ncols, cur % ncols));
207        if cur == 0 || parent[cur] == u32::MAX {
208            break;
209        }
210        cur = parent[cur] as usize;
211    }
212    path.reverse();
213    path
214}
215
216/// Try to relax cell `(tr, tc)` from each coprime neighbor, updating cost and parent.
217#[inline]
218fn dp_relax_cell<F>(
219    e: &mut [f64],
220    parent: &mut [u32],
221    ncols: usize,
222    tr: usize,
223    tc: usize,
224    edge_cost: &F,
225) where
226    F: Fn(usize, usize, usize, usize) -> f64,
227{
228    let idx = tr * ncols + tc;
229    for &(dr, dc) in &COPRIME_NBHD_7 {
230        if dr > tr || dc > tc {
231            continue;
232        }
233        let sr = tr - dr;
234        let sc = tc - dc;
235        let src_idx = sr * ncols + sc;
236        if e[src_idx] == f64::INFINITY {
237            continue;
238        }
239        let cost = e[src_idx] + edge_cost(sr, sc, tr, tc);
240        if cost < e[idx] {
241            e[idx] = cost;
242            parent[idx] = src_idx as u32;
243        }
244    }
245}
246
247/// Shared DP grid fill + traceback using the coprime neighborhood.
248///
249/// `edge_cost(sr, sc, tr, tc)` returns the combined edge weight + penalty for
250/// a move from local (sr, sc) to local (tr, tc). Returns the raw local-index
251/// path from (0,0) to (nrows-1, ncols-1).
252pub(super) fn dp_grid_solve<F>(nrows: usize, ncols: usize, edge_cost: F) -> Vec<(usize, usize)>
253where
254    F: Fn(usize, usize, usize, usize) -> f64,
255{
256    let mut e = vec![f64::INFINITY; nrows * ncols];
257    let mut parent = vec![u32::MAX; nrows * ncols];
258    e[0] = 0.0;
259
260    for tr in 0..nrows {
261        for tc in 0..ncols {
262            if tr == 0 && tc == 0 {
263                continue;
264            }
265            dp_relax_cell(&mut e, &mut parent, ncols, tr, tc, &edge_cost);
266        }
267    }
268
269    dp_traceback(&parent, nrows, ncols)
270}
271
272/// Convert a DP path (local row,col indices) to an interpolated+normalized gamma warp.
273pub(super) fn dp_path_to_gamma(path: &[(usize, usize)], argvals: &[f64]) -> Vec<f64> {
274    let path_tc: Vec<f64> = path.iter().map(|&(_, c)| argvals[c]).collect();
275    let path_tr: Vec<f64> = path.iter().map(|&(r, _)| argvals[r]).collect();
276    let mut gamma: Vec<f64> = argvals
277        .iter()
278        .map(|&t| linear_interp(&path_tc, &path_tr, t))
279        .collect();
280    normalize_warp(&mut gamma, argvals);
281    gamma
282}
283
284/// Core DP alignment between two SRSFs on a grid.
285///
286/// Finds the optimal warping γ minimizing ‖q₁ - (q₂∘γ)√γ'‖².
287/// Uses fdasrvf's coprime neighborhood (nbhd_dim=7 → 35 move directions).
288/// SRSFs are L2-normalized before alignment (matching fdasrvf's `optimum.reparam`).
289pub(crate) fn dp_alignment_core(q1: &[f64], q2: &[f64], argvals: &[f64], lambda: f64) -> Vec<f64> {
290    let m = argvals.len();
291    if m < 2 {
292        return argvals.to_vec();
293    }
294
295    let norm1 = q1.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
296    let norm2 = q2.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
297    let q1n: Vec<f64> = q1.iter().map(|&v| v / norm1).collect();
298    let q2n: Vec<f64> = q2.iter().map(|&v| v / norm2).collect();
299
300    let path = dp_grid_solve(m, m, |sr, sc, tr, tc| {
301        dp_edge_weight(&q1n, &q2n, argvals, sc, tc, sr, tr)
302            + dp_lambda_penalty(argvals, sc, tc, sr, tr, lambda)
303    });
304
305    dp_path_to_gamma(&path, argvals)
306}
307
308/// Greatest common divisor (Euclidean algorithm). Used only in tests.
309#[cfg(test)]
310pub(super) fn gcd(a: usize, b: usize) -> usize {
311    if b == 0 {
312        a
313    } else {
314        gcd(b, a % b)
315    }
316}
317
318/// Generate coprime neighborhood: all (i,j) with 1 ≤ i,j ≤ nbhd_dim, gcd(i,j) = 1.
319/// With nbhd_dim=7 this produces 35 pairs, matching fdasrvf's default.
320#[cfg(test)]
321pub(super) fn generate_coprime_nbhd(nbhd_dim: usize) -> Vec<(usize, usize)> {
322    let mut pairs = Vec::new();
323    for i in 1..=nbhd_dim {
324        for j in 1..=nbhd_dim {
325            if gcd(i, j) == 1 {
326                pairs.push((i, j));
327            }
328        }
329    }
330    pairs
331}