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}