1use 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#[non_exhaustive]
19#[derive(Debug, Clone, PartialEq)]
20pub struct PartialMatchConfig {
21 pub lambda: f64,
23 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#[derive(Debug, Clone, PartialEq)]
39#[non_exhaustive]
40pub struct PartialMatchResult {
41 pub start_index: usize,
43 pub end_index: usize,
45 pub gamma: Vec<f64>,
47 pub distance: f64,
49 pub domain_fraction: f64,
51}
52
53#[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 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 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 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 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 let gamma = dp_align_partial(&q_template, &q_sub, argvals_template, config.lambda);
156
157 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
200fn 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 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
229fn 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 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}