Skip to main content

fdars_core/alignment/
shift.rs

1//! Least-squares shift (rigid horizontal) registration.
2//!
3//! Provides [`least_squares_shift_registration`] which aligns each curve in a
4//! functional data set to the cross-sectional sample mean by estimating a
5//! per-curve rigid horizontal shift δᵢ that minimises the Simpson-weighted L2
6//! distance ‖fᵢ(t − δᵢ) − mean(t)‖².
7//!
8//! The shift δᵢ is found by golden-section search over the closed interval
9//! `[−max_shift, +max_shift]`. The objective is assumed to be unimodal in δ
10//! for typical functional data within the default bracket — see
11//! [`least_squares_shift_registration`] for caveats.
12
13use crate::error::FdarError;
14use crate::helpers::{linear_interp, simpsons_weights};
15use crate::iter_maybe_parallel;
16use crate::matrix::FdMatrix;
17#[cfg(feature = "parallel")]
18use rayon::iter::ParallelIterator;
19
20// ---------------------------------------------------------------------------
21// Constants
22// ---------------------------------------------------------------------------
23
24/// Default fraction of the domain range to use as `max_shift`.
25///
26/// Recommended caller value: `DEFAULT_MAX_SHIFT_FRACTION * (argvals.last() - argvals.first())`.
27///
28/// Example: for `argvals` on `[0.0, 1.0]`, use `max_shift = 0.25`.
29pub const DEFAULT_MAX_SHIFT_FRACTION: f64 = 0.25;
30
31/// Convergence tolerance for the golden-section search (in domain units).
32const GS_TOL: f64 = 1e-6;
33
34/// Maximum iterations for the golden-section search.
35const GS_MAX_ITER: usize = 100;
36
37// ---------------------------------------------------------------------------
38// Result type
39// ---------------------------------------------------------------------------
40
41/// Result of least-squares shift (rigid horizontal) registration.
42///
43/// Each curve fᵢ is shifted by `shifts[i]` and re-evaluated at the original
44/// `argvals` grid via linear interpolation with boundary clamping.
45#[derive(Debug, Clone, PartialEq)]
46#[non_exhaustive]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct ShiftRegistrationResult {
49    /// Registered (shifted) functional data matrix (n × m).
50    ///
51    /// `registered_data[(i, j)]` equals `linear_interp(argvals, row_i, argvals[j] − shifts[i])`.
52    pub registered_data: FdMatrix,
53
54    /// Per-curve horizontal shifts δᵢ (length n).
55    ///
56    /// Positive δᵢ shifts the curve to the right (later in time);
57    /// negative δᵢ shifts the curve to the left.
58    pub shifts: Vec<f64>,
59}
60
61// ---------------------------------------------------------------------------
62// Private helpers
63// ---------------------------------------------------------------------------
64
65/// Golden-section search for the minimum of a unimodal function on `[lo, hi]`.
66///
67/// Returns the midpoint of the final bracket after convergence (width < `tol`)
68/// or after `max_iter` iterations.
69fn golden_section_search<F>(f: F, mut lo: f64, mut hi: f64, tol: f64, max_iter: usize) -> f64
70where
71    F: Fn(f64) -> f64,
72{
73    const PHI: f64 = 1.618_033_988_749_895;
74    let mut x1 = hi - (hi - lo) / PHI;
75    let mut x2 = lo + (hi - lo) / PHI;
76    let mut f1 = f(x1);
77    let mut f2 = f(x2);
78    for _ in 0..max_iter {
79        if (hi - lo) < tol {
80            break;
81        }
82        if f1 < f2 {
83            hi = x2;
84            x2 = x1;
85            f2 = f1;
86            x1 = hi - (hi - lo) / PHI;
87            f1 = f(x1);
88        } else {
89            lo = x1;
90            x1 = x2;
91            f1 = f2;
92            x2 = lo + (hi - lo) / PHI;
93            f2 = f(x2);
94        }
95    }
96    (lo + hi) / 2.0
97}
98
99/// Simpson-weighted L2 distance between the shifted curve fᵢ(t − δ) and `mean`.
100///
101/// For each grid point j, evaluates `linear_interp(argvals, row, argvals[j] − delta)`,
102/// which applies boundary clamping for out-of-domain arguments.
103fn l2_shift_objective(
104    row: &[f64],
105    argvals: &[f64],
106    mean: &[f64],
107    weights: &[f64],
108    delta: f64,
109) -> f64 {
110    argvals
111        .iter()
112        .zip(mean.iter())
113        .zip(weights.iter())
114        .map(|((&t, &m_j), &w)| {
115            let fi_shifted = linear_interp(argvals, row, t - delta);
116            let diff = fi_shifted - m_j;
117            diff * diff * w
118        })
119        .sum::<f64>()
120}
121
122// ---------------------------------------------------------------------------
123// Public API
124// ---------------------------------------------------------------------------
125
126/// Register a set of functional curves by a per-curve rigid horizontal shift.
127///
128/// For each curve fᵢ in `data`, finds the shift δᵢ ∈ `[−max_shift, +max_shift]`
129/// that minimises the Simpson-weighted L2 distance to the cross-sectional sample mean:
130///
131/// ```text
132/// δᵢ = argmin_δ ‖fᵢ(· − δ) − mean(·)‖²_L2
133/// ```
134///
135/// The minimisation is performed by golden-section search, which assumes the
136/// objective is **unimodal** in δ. This holds for typical functional data within
137/// the default bracket but may not hold for multi-modal or highly oscillatory
138/// curves shifted by more than half a period.
139///
140/// Shifted curves are re-evaluated at the original `argvals` grid via linear
141/// interpolation. Points shifted outside the domain are clamped to the boundary
142/// value (Boundary extrapolation policy, inherited from [`crate::helpers::linear_interp`]).
143///
144/// # Arguments
145///
146/// * `data` — Functional data matrix (n × m, column-major).
147/// * `argvals` — Evaluation points, length m. Must be sorted in ascending order.
148/// * `max_shift` — Half-width of the shift search interval (must be > 0).
149///   Recommended value: `0.25 * (argvals.last() - argvals.first())`, i.e. `0.25 * domain_range`.
150///
151/// # Returns
152///
153/// [`ShiftRegistrationResult`] containing the registered curves and per-curve shifts.
154///
155/// # Errors
156///
157/// * [`FdarError::InvalidDimension`] if `data` is empty (`n = 0` or `m = 0`).
158/// * [`FdarError::InvalidDimension`] if `argvals.len() != m`.
159/// * [`FdarError::InvalidParameter`] if `argvals.len() < 2`.
160/// * [`FdarError::InvalidParameter`] if `max_shift <= 0.0`.
161///
162/// # Examples
163///
164/// ```
165/// use fdars_core::matrix::FdMatrix;
166/// use fdars_core::alignment::least_squares_shift_registration;
167///
168/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
169/// let data = FdMatrix::from_column_major(
170///     (0..60).map(|i| ((i as f64 * 0.1).sin())).collect(),
171///     3, 20,
172/// ).unwrap();
173/// let max_shift = fdars_core::alignment::DEFAULT_MAX_SHIFT_FRACTION * (argvals[19] - argvals[0]);
174/// let result = least_squares_shift_registration(&data, &argvals, max_shift).unwrap();
175/// assert_eq!(result.registered_data.shape(), (3, 20));
176/// assert_eq!(result.shifts.len(), 3);
177/// ```
178pub fn least_squares_shift_registration(
179    data: &FdMatrix,
180    argvals: &[f64],
181    max_shift: f64,
182) -> Result<ShiftRegistrationResult, FdarError> {
183    let (n, m) = data.shape();
184
185    // V5 input validation — all checks before any computation
186    if n == 0 || m == 0 {
187        return Err(FdarError::InvalidDimension {
188            parameter: "data",
189            expected: "non-empty matrix".to_string(),
190            actual: format!("{}x{}", n, m),
191        });
192    }
193    if argvals.len() != m {
194        return Err(FdarError::InvalidDimension {
195            parameter: "argvals",
196            expected: m.to_string(),
197            actual: argvals.len().to_string(),
198        });
199    }
200    if argvals.len() < 2 {
201        return Err(FdarError::InvalidParameter {
202            parameter: "argvals",
203            message: "must have at least 2 evaluation points".to_string(),
204        });
205    }
206    if max_shift <= 0.0 {
207        return Err(FdarError::InvalidParameter {
208            parameter: "max_shift",
209            message: format!("must be positive, got {max_shift}"),
210        });
211    }
212
213    // Pre-compute shared mean and integration weights
214    let weights = simpsons_weights(argvals);
215    let mean = crate::fdata::mean_1d(data);
216
217    // Parallel per-curve shift estimation + re-evaluation
218    // Collect into Vec first (parallel order arbitrary), then assemble sequentially (#3099)
219    let results: Vec<(f64, Vec<f64>)> = iter_maybe_parallel!(0..n)
220        .map(|i| {
221            let row = data.row(i);
222            let delta = golden_section_search(
223                |d| l2_shift_objective(&row, argvals, &mean, &weights, d),
224                -max_shift,
225                max_shift,
226                GS_TOL,
227                GS_MAX_ITER,
228            );
229            let shifted: Vec<f64> = argvals
230                .iter()
231                .map(|&t| linear_interp(argvals, &row, t - delta))
232                .collect();
233            (delta, shifted)
234        })
235        .collect();
236
237    // Sequential assembly into FdMatrix (maintains row order regardless of parallel dispatch)
238    let mut registered_data = FdMatrix::zeros(n, m);
239    let mut shifts = Vec::with_capacity(n);
240
241    for (i, (delta, shifted_row)) in results.into_iter().enumerate() {
242        for j in 0..m {
243            registered_data[(i, j)] = shifted_row[j];
244        }
245        shifts.push(delta);
246    }
247
248    Ok(ShiftRegistrationResult {
249        registered_data,
250        shifts,
251    })
252}
253
254// ---------------------------------------------------------------------------
255// Tests
256// ---------------------------------------------------------------------------
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::matrix::FdMatrix;
262
263    /// Uniform grid on [0, 1] with `n` points.
264    fn uniform_grid(n: usize) -> Vec<f64> {
265        (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
266    }
267
268    /// Gaussian bump at position `mu` with standard deviation `sigma`.
269    fn gaussian_bump(argvals: &[f64], mu: f64, sigma: f64) -> Vec<f64> {
270        argvals
271            .iter()
272            .map(|&t| (-(t - mu).powi(2) / (2.0 * sigma * sigma)).exp())
273            .collect()
274    }
275
276    // FEAT-06-A: already-aligned curves → estimated shifts ≈ 0
277    #[test]
278    fn test_shift_already_aligned() {
279        let m = 51;
280        let argvals = uniform_grid(m);
281        // All curves are the same Gaussian bump centred at 0.5 — already aligned
282        let n = 4;
283        let mut data = FdMatrix::zeros(n, m);
284        let bump = gaussian_bump(&argvals, 0.5, 0.08);
285        for i in 0..n {
286            for j in 0..m {
287                data[(i, j)] = bump[j];
288            }
289        }
290        let max_shift = 0.25 * (argvals[m - 1] - argvals[0]);
291        let result = least_squares_shift_registration(&data, &argvals, max_shift).unwrap();
292        for (i, &delta) in result.shifts.iter().enumerate() {
293            assert!(
294                delta.abs() < 1e-3,
295                "curve {i}: expected shift ≈ 0, got {delta}"
296            );
297        }
298    }
299
300    // FEAT-06-B: injected offsets recovered within tolerance
301    #[test]
302    fn test_shift_recovers_injected_offset() {
303        // 3 curves: centred at 0.5, 0.4, 0.6.
304        // Mean of bumps ≈ Gaussian at 0.5 (average centre).
305        // Convention: registered(t) = original(t - δ).
306        // To bring peak at mu=0.4 to t=0.5 we need δ=+0.1: original(0.5-0.1)=original(0.4)=peak.
307        // To bring peak at mu=0.6 to t=0.5 we need δ=-0.1: original(0.5-(-0.1))=original(0.6)=peak.
308        let m = 101;
309        let argvals = uniform_grid(m);
310        let sigma = 0.05_f64;
311        let centres = [0.5_f64, 0.4, 0.6];
312        let n = centres.len();
313        let mut data = FdMatrix::zeros(n, m);
314        for (i, &mu) in centres.iter().enumerate() {
315            let row = gaussian_bump(&argvals, mu, sigma);
316            for j in 0..m {
317                data[(i, j)] = row[j];
318            }
319        }
320        // Expected shifts: δᵢ = mean_centre - mu_i = 0.5 - mu_i
321        let true_shifts = [0.0_f64, 0.1, -0.1];
322        let max_shift = 0.25 * (argvals[m - 1] - argvals[0]);
323        let result = least_squares_shift_registration(&data, &argvals, max_shift).unwrap();
324
325        // Allow generous tolerance: golden-section tol 1e-6 but some boundary effects.
326        for (i, (&recovered, &expected)) in result.shifts.iter().zip(true_shifts.iter()).enumerate()
327        {
328            assert!(
329                (recovered - expected).abs() < 0.05,
330                "curve {i}: expected shift ≈ {expected}, got {recovered}"
331            );
332        }
333    }
334
335    // FEAT-06-C: registered curves are the correct shifted re-evaluation
336    #[test]
337    fn test_shift_registration_curve_values() {
338        // Small deterministic case: 2 curves on a 5-point grid.
339        // After registration, registered_data[(i,j)] must equal
340        // linear_interp(argvals, row_i, argvals[j] - shifts[i]) exactly.
341        let m = 5;
342        let argvals = uniform_grid(m);
343        let n = 2;
344        // Curve 0: Gaussian bump at 0.3; Curve 1: Gaussian bump at 0.7
345        let mut data = FdMatrix::zeros(n, m);
346        for (i, &mu) in [0.3_f64, 0.7].iter().enumerate() {
347            let row = gaussian_bump(&argvals, mu, 0.15);
348            for j in 0..m {
349                data[(i, j)] = row[j];
350            }
351        }
352        let max_shift = 0.25;
353        let result = least_squares_shift_registration(&data, &argvals, max_shift).unwrap();
354
355        // Spot-check all (i, j) positions: registered value must equal the
356        // shifted linear-interpolation evaluation, proving the matrix is
357        // assembled from the correct re-evaluation calls.
358        for i in 0..n {
359            let row = data.row(i);
360            let delta = result.shifts[i];
361            for j in 0..m {
362                let expected = linear_interp(&argvals, &row, argvals[j] - delta);
363                let actual = result.registered_data[(i, j)];
364                assert!(
365                    (actual - expected).abs() < 1e-9,
366                    "registered_data[({i},{j})] = {actual}, expected {expected} (shift={delta})"
367                );
368            }
369        }
370    }
371
372    // FEAT-06-D: empty data returns Err(InvalidDimension)
373    #[test]
374    fn test_shift_registration_empty_data() {
375        let argvals = uniform_grid(5);
376        // n = 0
377        let data_n0 = FdMatrix::zeros(0, 5);
378        let result = least_squares_shift_registration(&data_n0, &argvals, 0.1);
379        assert!(
380            matches!(result, Err(FdarError::InvalidDimension { .. })),
381            "expected Err(InvalidDimension) for n=0, got {result:?}"
382        );
383
384        // m = 0
385        let data_m0 = FdMatrix::zeros(3, 0);
386        let result_m0 = least_squares_shift_registration(&data_m0, &[], 0.1);
387        assert!(
388            matches!(result_m0, Err(FdarError::InvalidDimension { .. })),
389            "expected Err(InvalidDimension) for m=0, got {result_m0:?}"
390        );
391    }
392
393    // FEAT-06-E: argvals length mismatch returns Err(InvalidDimension)
394    #[test]
395    fn test_shift_registration_argvals_mismatch() {
396        let m = 5;
397        let data = FdMatrix::zeros(2, m);
398        // Pass argvals with wrong length (m+1 instead of m)
399        let wrong_argvals = uniform_grid(m + 1);
400        let result = least_squares_shift_registration(&data, &wrong_argvals, 0.1);
401        assert!(
402            matches!(result, Err(FdarError::InvalidDimension { .. })),
403            "expected Err(InvalidDimension) for argvals length mismatch, got {result:?}"
404        );
405        // Also test argvals shorter than m
406        let short_argvals = uniform_grid(m - 1);
407        let result2 = least_squares_shift_registration(&data, &short_argvals, 0.1);
408        assert!(
409            matches!(result2, Err(FdarError::InvalidDimension { .. })),
410            "expected Err(InvalidDimension) for argvals too short, got {result2:?}"
411        );
412    }
413}