Skip to main content

fdars_core/alignment/
partial_match.rs

1//! Elastic partial matching: find the best-aligned subcurve of a longer curve.
2//!
3//! Standard elastic alignment requires both curves to span the full domain.
4//! Partial matching relaxes this: given a template curve and a longer curve,
5//! it finds the contiguous subdomain of the longer curve that best matches
6//! the template in the elastic metric.
7
8use super::srsf::srsf_single;
9use super::{dp_edge_weight, dp_lambda_penalty, dp_path_to_gamma};
10use crate::error::FdarError;
11use crate::helpers::{l2_distance, simpsons_weights};
12
13// ─── Types ──────────────────────────────────────────────────────────────────
14
15/// Configuration for elastic partial matching.
16///
17/// Construct via `PartialMatchConfig::default()`, then assign the fields you need (e.g. `let mut c = PartialMatchConfig::default(); c.field = …;`). This struct is `#[non_exhaustive]`, so external crates cannot build it with a struct literal — not even functional-update `..Default::default()` form.
18#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq)]
20pub struct PartialMatchConfig {
21    /// Roughness penalty for elastic alignment (0.0 = no penalty).
22    pub lambda: f64,
23    /// Minimum fraction of the target curve that the match must span.
24    /// Must be in (0, 1]. Default 0.5.
25    pub min_span: f64,
26}
27
28impl Default for PartialMatchConfig {
29    fn default() -> Self {
30        Self {
31            lambda: 0.0,
32            min_span: 0.5,
33        }
34    }
35}
36
37/// Result of elastic partial matching.
38#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub struct PartialMatchResult {
41    /// Start index in the target curve where the best match begins.
42    pub start_index: usize,
43    /// End index (inclusive) in the target curve where the best match ends.
44    pub end_index: usize,
45    /// Warping function mapping template domain to the matched subdomain.
46    pub gamma: Vec<f64>,
47    /// Elastic distance of the best partial match.
48    pub distance: f64,
49    /// Fraction of the target domain spanned by the match.
50    pub domain_fraction: f64,
51}
52
53// ─── Public API ─────────────────────────────────────────────────────────────
54
55/// Find the best elastic partial match of `template` within `target`.
56///
57/// Slides a variable-length window over the target curve and performs
58/// elastic alignment of the template to each window, returning the
59/// window position and warp with minimum elastic distance.
60///
61/// # Arguments
62/// * `template` — Short template curve (length m_t)
63/// * `target` — Longer target curve (length m_f)
64/// * `argvals_template` — Evaluation points for the template (length m_t)
65/// * `argvals_target` — Evaluation points for the target (length m_f)
66/// * `config` — Partial matching configuration
67///
68/// # Errors
69/// Returns [`FdarError::InvalidDimension`] if lengths are inconsistent.
70/// Returns [`FdarError::InvalidParameter`] if `min_span` is not in (0, 1].
71#[must_use = "expensive computation whose result should not be discarded"]
72pub fn elastic_partial_match(
73    template: &[f64],
74    target: &[f64],
75    argvals_template: &[f64],
76    argvals_target: &[f64],
77    config: &PartialMatchConfig,
78) -> Result<PartialMatchResult, FdarError> {
79    let m_t = template.len();
80    let m_f = target.len();
81
82    if m_t != argvals_template.len() {
83        return Err(FdarError::InvalidDimension {
84            parameter: "argvals_template",
85            expected: format!("{m_t}"),
86            actual: format!("{}", argvals_template.len()),
87        });
88    }
89    if m_f != argvals_target.len() {
90        return Err(FdarError::InvalidDimension {
91            parameter: "argvals_target",
92            expected: format!("{m_f}"),
93            actual: format!("{}", argvals_target.len()),
94        });
95    }
96    if m_t < 2 || m_f < 2 {
97        return Err(FdarError::InvalidDimension {
98            parameter: "template/target",
99            expected: "length >= 2".to_string(),
100            actual: format!("template={m_t}, target={m_f}"),
101        });
102    }
103    if config.min_span <= 0.0 || config.min_span > 1.0 {
104        return Err(FdarError::InvalidParameter {
105            parameter: "min_span",
106            message: format!("must be in (0, 1], got {}", config.min_span),
107        });
108    }
109
110    let q_template = srsf_single(template, argvals_template);
111
112    // Minimum window size (in grid points) based on min_span
113    let min_window = ((m_f as f64 * config.min_span).ceil() as usize).max(2);
114
115    let mut best_start = 0;
116    let mut best_end = m_f - 1;
117    let mut best_dist = f64::INFINITY;
118    let mut best_gamma = argvals_template.to_vec();
119
120    // Iterate over window sizes from min_window to m_f
121    // Use a coarse grid of window sizes for efficiency
122    let n_sizes = 5.min(m_f - min_window + 1);
123    let sizes: Vec<usize> = if n_sizes <= 1 {
124        vec![m_f]
125    } else {
126        (0..n_sizes)
127            .map(|i| min_window + i * (m_f - min_window) / (n_sizes - 1))
128            .collect()
129    };
130
131    for &win_size in &sizes {
132        let step = (win_size / 10).max(1);
133        let mut start = 0;
134        while start + win_size <= m_f {
135            let end = start + win_size - 1;
136
137            // Extract sub-argvals and sub-curve
138            let sub_argvals: Vec<f64> = (0..m_t)
139                .map(|i| {
140                    argvals_target[start]
141                        + (argvals_target[end] - argvals_target[start]) * i as f64
142                            / (m_t - 1) as f64
143                })
144                .collect();
145
146            // Interpolate target onto sub_argvals
147            let sub_target: Vec<f64> = sub_argvals
148                .iter()
149                .map(|&t| interp_target(target, argvals_target, t))
150                .collect();
151
152            let q_sub = srsf_single(&sub_target, argvals_template);
153
154            // DP alignment on the shared template grid
155            let gamma = dp_align_partial(&q_template, &q_sub, argvals_template, config.lambda);
156
157            // Compute distance
158            let sub_aligned: Vec<f64> = argvals_template
159                .iter()
160                .map(|&t| {
161                    interp_target(
162                        &sub_target,
163                        argvals_template,
164                        interp_target(&gamma, argvals_template, t),
165                    )
166                })
167                .collect();
168            let q_aligned = srsf_single(&sub_aligned, argvals_template);
169            let weights = simpsons_weights(argvals_template);
170            let dist = l2_distance(&q_template, &q_aligned, &weights);
171
172            if dist < best_dist {
173                best_dist = dist;
174                best_start = start;
175                best_end = end;
176                best_gamma = gamma;
177            }
178
179            start += step;
180        }
181    }
182
183    let total_domain = argvals_target[m_f - 1] - argvals_target[0];
184    let match_domain = argvals_target[best_end] - argvals_target[best_start];
185    let domain_fraction = if total_domain > 0.0 {
186        match_domain / total_domain
187    } else {
188        1.0
189    };
190
191    Ok(PartialMatchResult {
192        start_index: best_start,
193        end_index: best_end,
194        gamma: best_gamma,
195        distance: best_dist,
196        domain_fraction,
197    })
198}
199
200// ─── Helpers ────────────────────────────────────────────────────────────────
201
202/// Linear interpolation of a curve at point `t`.
203fn interp_target(values: &[f64], grid: &[f64], t: f64) -> f64 {
204    let n = grid.len();
205    if n == 0 {
206        return 0.0;
207    }
208    if t <= grid[0] {
209        return values[0];
210    }
211    if t >= grid[n - 1] {
212        return values[n - 1];
213    }
214    // Binary search for the interval
215    let mut lo = 0;
216    let mut hi = n - 1;
217    while hi - lo > 1 {
218        let mid = (lo + hi) / 2;
219        if grid[mid] <= t {
220            lo = mid;
221        } else {
222            hi = mid;
223        }
224    }
225    let frac = (t - grid[lo]) / (grid[hi] - grid[lo]);
226    values[lo] * (1.0 - frac) + values[hi] * frac
227}
228
229/// DP alignment for partial matching (same grid for both SRSFs).
230fn dp_align_partial(q1: &[f64], q2: &[f64], argvals: &[f64], lambda: f64) -> Vec<f64> {
231    let m = argvals.len();
232    if m < 2 {
233        return argvals.to_vec();
234    }
235
236    let norm1 = q1.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
237    let norm2 = q2.iter().map(|&v| v * v).sum::<f64>().sqrt().max(1e-10);
238    let q1n: Vec<f64> = q1.iter().map(|&v| v / norm1).collect();
239    let q2n: Vec<f64> = q2.iter().map(|&v| v / norm2).collect();
240
241    let path = super::dp_grid_solve(m, m, |sr, sc, tr, tc| {
242        dp_edge_weight(&q1n, &q2n, argvals, sc, tc, sr, tr)
243            + dp_lambda_penalty(argvals, sc, tc, sr, tr, lambda)
244    });
245
246    dp_path_to_gamma(&path, argvals)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::test_helpers::uniform_grid;
253
254    #[test]
255    fn partial_match_identity() {
256        let m = 30;
257        let t = uniform_grid(m);
258        let f: Vec<f64> = t.iter().map(|&x| (x * 6.0).sin()).collect();
259
260        let config = PartialMatchConfig {
261            min_span: 0.5,
262            ..Default::default()
263        };
264        let result = elastic_partial_match(&f, &f, &t, &t, &config).unwrap();
265
266        assert!(
267            result.distance < 0.5,
268            "matching a curve to itself should give small distance, got {}",
269            result.distance
270        );
271    }
272
273    #[test]
274    fn partial_match_subcurve() {
275        let m = 40;
276        let t = uniform_grid(m);
277        let target: Vec<f64> = t.iter().map(|&x| (x * 6.0).sin()).collect();
278
279        // Template is roughly the middle portion
280        let m_t = 20;
281        let t_template = uniform_grid(m_t);
282        let template: Vec<f64> = t_template
283            .iter()
284            .map(|&x| ((x * 0.5 + 0.25) * 6.0).sin())
285            .collect();
286
287        let config = PartialMatchConfig {
288            min_span: 0.3,
289            ..Default::default()
290        };
291        let result = elastic_partial_match(&template, &target, &t_template, &t, &config).unwrap();
292
293        assert!(result.start_index < result.end_index);
294        assert!(result.domain_fraction >= 0.3);
295        assert!(result.gamma.len() == m_t);
296    }
297
298    #[test]
299    fn partial_match_rejects_bad_min_span() {
300        let t = uniform_grid(10);
301        let f: Vec<f64> = t.iter().map(|&x| x * x).collect();
302        let config = PartialMatchConfig {
303            min_span: 0.0,
304            ..Default::default()
305        };
306        assert!(elastic_partial_match(&f, &f, &t, &t, &config).is_err());
307    }
308
309    #[test]
310    fn partial_match_config_default() {
311        let config = PartialMatchConfig::default();
312        assert!((config.lambda - 0.0).abs() < f64::EPSILON);
313        assert!((config.min_span - 0.5).abs() < f64::EPSILON);
314    }
315}