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