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