fdars_core/alignment/pairwise.rs
1//! Pairwise elastic alignment, distance computation, and distance matrices.
2
3use super::srsf::{reparameterize_curve, srsf_single, srsf_transform};
4use super::{band_radius, dp_alignment_core_banded, AlignmentResult};
5use crate::helpers::{l2_distance, simpsons_weights};
6use crate::iter_maybe_parallel;
7use crate::matrix::FdMatrix;
8#[cfg(feature = "parallel")]
9use rayon::iter::ParallelIterator;
10
11// ─── Public Alignment Functions ─────────────────────────────────────────────
12
13/// Align curve f2 to curve f1 using the elastic framework.
14///
15/// Computes the optimal warping γ such that f2∘γ is as close as possible
16/// to f1 in the elastic (Fisher-Rao) metric.
17///
18/// # Arguments
19/// * `f1` — Target curve (length m)
20/// * `f2` — Curve to align (length m)
21/// * `argvals` — Evaluation points (length m)
22/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
23///
24/// # Returns
25/// [`AlignmentResult`] with warping function, aligned curve, and elastic distance.
26///
27/// # Examples
28///
29/// ```
30/// use fdars_core::alignment::elastic_align_pair;
31///
32/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
33/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
34/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
35/// let result = elastic_align_pair(&f1, &f2, &argvals, 0.0);
36/// assert_eq!(result.f_aligned.len(), 20);
37/// assert!(result.distance >= 0.0);
38/// ```
39#[must_use = "expensive computation whose result should not be discarded"]
40pub fn elastic_align_pair(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> AlignmentResult {
41 let q1 = srsf_single(f1, argvals);
42 let q2 = srsf_single(f2, argvals);
43 elastic_align_pair_from_srsf(f2, &q1, &q2, argvals, None, lambda)
44}
45
46/// Align curve f2 to curve f1, confining the warp to a Sakoe–Chiba band.
47///
48/// Identical to [`elastic_align_pair`] but restricts the optimal warping to a
49/// diagonal corridor: `|γ(t) − t|` may not exceed `band_frac` of the domain.
50/// This bounds the dynamic-programming search to that corridor, giving a large
51/// speedup (≈ O(m·band) instead of O(m²)) when the true warp is near-diagonal —
52/// at the cost of disallowing warps larger than the band. `band_frac ≤ 0` or
53/// `≥ 1` falls back to the full unbanded search.
54///
55/// # Examples
56///
57/// ```
58/// use fdars_core::alignment::elastic_align_pair_banded;
59///
60/// let argvals: Vec<f64> = (0..40).map(|i| i as f64 / 39.0).collect();
61/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
62/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
63/// // Allow warps up to 15% of the domain.
64/// let result = elastic_align_pair_banded(&f1, &f2, &argvals, 0.0, 0.15);
65/// assert_eq!(result.f_aligned.len(), 40);
66/// ```
67#[must_use = "expensive computation whose result should not be discarded"]
68pub fn elastic_align_pair_banded(
69 f1: &[f64],
70 f2: &[f64],
71 argvals: &[f64],
72 lambda: f64,
73 band_frac: f64,
74) -> AlignmentResult {
75 let q1 = srsf_single(f1, argvals);
76 let q2 = srsf_single(f2, argvals);
77 let band = band_radius(band_frac, argvals.len());
78 elastic_align_pair_from_srsf(f2, &q1, &q2, argvals, band, lambda)
79}
80
81/// Compute the elastic distance between two curves.
82///
83/// This is shorthand for aligning the pair and returning only the distance.
84///
85/// # Arguments
86/// * `f1` — First curve (length m)
87/// * `f2` — Second curve (length m)
88/// * `argvals` — Evaluation points (length m)
89/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
90///
91/// # Examples
92///
93/// ```
94/// use fdars_core::alignment::elastic_distance;
95///
96/// let argvals: Vec<f64> = (0..20).map(|i| i as f64 / 19.0).collect();
97/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
98/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
99/// let d = elastic_distance(&f1, &f2, &argvals, 0.0);
100/// assert!(d >= 0.0);
101/// ```
102#[must_use = "expensive computation whose result should not be discarded"]
103pub fn elastic_distance(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
104 elastic_align_pair(f1, f2, argvals, lambda).distance
105}
106
107/// Elastic distance between two curves using a Sakoe–Chiba band.
108///
109/// Shorthand for [`elastic_align_pair_banded`] returning only the distance.
110/// See that function for the meaning of `band_frac`.
111#[must_use = "expensive computation whose result should not be discarded"]
112pub fn elastic_distance_banded(
113 f1: &[f64],
114 f2: &[f64],
115 argvals: &[f64],
116 lambda: f64,
117 band_frac: f64,
118) -> f64 {
119 elastic_align_pair_banded(f1, f2, argvals, lambda, band_frac).distance
120}
121
122// ─── Internal Helpers with Pre-computed SRSFs ────────────────────────────────
123
124/// Align curve f2 to curve f1 given their pre-computed SRSFs.
125///
126/// This avoids redundant SRSF computation when calling from distance matrix
127/// routines where the same curve's SRSF would otherwise be recomputed for
128/// every pair.
129fn elastic_align_pair_from_srsf(
130 f2: &[f64],
131 q1: &[f64],
132 q2: &[f64],
133 argvals: &[f64],
134 band: Option<usize>,
135 lambda: f64,
136) -> AlignmentResult {
137 // Find optimal warping via DP
138 let gamma = dp_alignment_core_banded(q1, q2, argvals, lambda, band);
139
140 // Apply warping to f2
141 let f_aligned = reparameterize_curve(f2, argvals, &gamma);
142
143 // Compute elastic distance: L2 distance between q1 and aligned q2 SRSF
144 let q_aligned = srsf_single(&f_aligned, argvals);
145
146 let weights = simpsons_weights(argvals);
147 let distance = l2_distance(q1, &q_aligned, &weights);
148
149 AlignmentResult {
150 gamma,
151 f_aligned,
152 distance,
153 }
154}
155
156/// Compute elastic distance given a raw curve f2, pre-computed SRSFs q1, q2, and
157/// pre-computed Simpson integration `weights`.
158///
159/// The raw f2 is needed to reparameterize before computing the aligned SRSF.
160/// `weights` depends only on `argvals`, so the distance-matrix callers compute
161/// it once rather than on every one of the O(n²) pairs.
162fn elastic_distance_from_srsf(
163 f2: &[f64],
164 q1: &[f64],
165 q2: &[f64],
166 argvals: &[f64],
167 weights: &[f64],
168 band: Option<usize>,
169 lambda: f64,
170) -> f64 {
171 let gamma = dp_alignment_core_banded(q1, q2, argvals, lambda, band);
172 let f_aligned = reparameterize_curve(f2, argvals, &gamma);
173 let q_aligned = srsf_single(&f_aligned, argvals);
174 l2_distance(q1, &q_aligned, weights)
175}
176
177// ─── Distance Matrices ──────────────────────────────────────────────────────
178
179/// Compute the symmetric elastic distance matrix for a set of curves.
180///
181/// Pre-computes SRSF transforms for all curves once (O(n)) instead of
182/// recomputing each curve's SRSF for every pair (O(n²)).
183///
184/// Uses upper-triangle computation with parallelism, following the
185/// `self_distance_matrix` pattern from `metric.rs`.
186///
187/// # Arguments
188/// * `data` — Functional data matrix (n × m)
189/// * `argvals` — Evaluation points (length m)
190/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
191///
192/// # Returns
193/// Symmetric n × n distance matrix.
194pub fn elastic_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
195 self_distance_matrix_impl(data, argvals, None, lambda)
196}
197
198/// Symmetric elastic distance matrix using a Sakoe–Chiba band (see
199/// [`elastic_align_pair_banded`] for `band_frac`).
200///
201/// Because the band bounds every pairwise alignment to a diagonal corridor,
202/// this is the fastest way to build an elastic distance matrix over many curves
203/// when warps are moderate — the O(n²) pairs each drop from O(m²) to O(m·band).
204#[must_use = "expensive computation whose result should not be discarded"]
205pub fn elastic_self_distance_matrix_banded(
206 data: &FdMatrix,
207 argvals: &[f64],
208 lambda: f64,
209 band_frac: f64,
210) -> FdMatrix {
211 let band = band_radius(band_frac, argvals.len());
212 self_distance_matrix_impl(data, argvals, band, lambda)
213}
214
215fn self_distance_matrix_impl(
216 data: &FdMatrix,
217 argvals: &[f64],
218 band: Option<usize>,
219 lambda: f64,
220) -> FdMatrix {
221 let n = data.nrows();
222
223 // Pre-compute all SRSF transforms and the integration weights once
224 let srsfs = srsf_transform(data, argvals);
225 let weights = simpsons_weights(argvals);
226
227 let upper_vals: Vec<f64> = iter_maybe_parallel!(0..n)
228 .flat_map(|i| {
229 let qi = srsfs.row(i);
230 ((i + 1)..n)
231 .map(|j| {
232 let fj = data.row(j);
233 let qj = srsfs.row(j);
234 elastic_distance_from_srsf(&fj, &qi, &qj, argvals, &weights, band, lambda)
235 })
236 .collect::<Vec<_>>()
237 })
238 .collect();
239
240 let mut dist = FdMatrix::zeros(n, n);
241 let mut idx = 0;
242 for i in 0..n {
243 for j in (i + 1)..n {
244 let d = upper_vals[idx];
245 dist[(i, j)] = d;
246 dist[(j, i)] = d;
247 idx += 1;
248 }
249 }
250 dist
251}
252
253/// Compute the elastic distance matrix between two sets of curves.
254///
255/// Pre-computes SRSF transforms for both datasets once instead of
256/// recomputing each curve's SRSF for every pair.
257///
258/// # Arguments
259/// * `data1` — First dataset (n1 × m)
260/// * `data2` — Second dataset (n2 × m)
261/// * `argvals` — Evaluation points (length m)
262/// * `lambda` — Penalty weight on warp deviation from identity (0.0 = no penalty)
263///
264/// # Returns
265/// n1 × n2 distance matrix.
266pub fn elastic_cross_distance_matrix(
267 data1: &FdMatrix,
268 data2: &FdMatrix,
269 argvals: &[f64],
270 lambda: f64,
271) -> FdMatrix {
272 cross_distance_matrix_impl(data1, data2, argvals, None, lambda)
273}
274
275/// Elastic distance matrix between two datasets using a Sakoe–Chiba band (see
276/// [`elastic_align_pair_banded`] for `band_frac`).
277#[must_use = "expensive computation whose result should not be discarded"]
278pub fn elastic_cross_distance_matrix_banded(
279 data1: &FdMatrix,
280 data2: &FdMatrix,
281 argvals: &[f64],
282 lambda: f64,
283 band_frac: f64,
284) -> FdMatrix {
285 let band = band_radius(band_frac, argvals.len());
286 cross_distance_matrix_impl(data1, data2, argvals, band, lambda)
287}
288
289fn cross_distance_matrix_impl(
290 data1: &FdMatrix,
291 data2: &FdMatrix,
292 argvals: &[f64],
293 band: Option<usize>,
294 lambda: f64,
295) -> FdMatrix {
296 let n1 = data1.nrows();
297 let n2 = data2.nrows();
298
299 // Pre-compute all SRSF transforms and the integration weights once
300 let srsfs1 = srsf_transform(data1, argvals);
301 let srsfs2 = srsf_transform(data2, argvals);
302 let weights = simpsons_weights(argvals);
303
304 let vals: Vec<f64> = iter_maybe_parallel!(0..n1)
305 .flat_map(|i| {
306 let qi = srsfs1.row(i);
307 (0..n2)
308 .map(|j| {
309 let fj = data2.row(j);
310 let qj = srsfs2.row(j);
311 elastic_distance_from_srsf(&fj, &qi, &qj, argvals, &weights, band, lambda)
312 })
313 .collect::<Vec<_>>()
314 })
315 .collect();
316
317 let mut dist = FdMatrix::zeros(n1, n2);
318 for i in 0..n1 {
319 for j in 0..n2 {
320 dist[(i, j)] = vals[i * n2 + j];
321 }
322 }
323 dist
324}
325
326/// Compute the amplitude distance between two curves (= elastic distance after alignment).
327pub fn amplitude_distance(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
328 elastic_distance(f1, f2, argvals, lambda)
329}
330
331/// Compute the phase distance between two curves (geodesic distance of optimal warp from identity).
332pub fn phase_distance_pair(f1: &[f64], f2: &[f64], argvals: &[f64], lambda: f64) -> f64 {
333 let alignment = elastic_align_pair(f1, f2, argvals, lambda);
334 crate::warping::phase_distance(&alignment.gamma, argvals)
335}
336
337/// Compute the symmetric phase distance matrix for a set of curves.
338pub fn phase_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
339 let n = data.nrows();
340
341 let upper_vals: Vec<f64> = iter_maybe_parallel!(0..n)
342 .flat_map(|i| {
343 let fi = data.row(i);
344 ((i + 1)..n)
345 .map(|j| {
346 let fj = data.row(j);
347 phase_distance_pair(&fi, &fj, argvals, lambda)
348 })
349 .collect::<Vec<_>>()
350 })
351 .collect();
352
353 let mut dist = FdMatrix::zeros(n, n);
354 let mut idx = 0;
355 for i in 0..n {
356 for j in (i + 1)..n {
357 let d = upper_vals[idx];
358 dist[(i, j)] = d;
359 dist[(j, i)] = d;
360 idx += 1;
361 }
362 }
363 dist
364}
365
366/// Compute the symmetric amplitude distance matrix (= elastic self distance matrix).
367pub fn amplitude_self_distance_matrix(data: &FdMatrix, argvals: &[f64], lambda: f64) -> FdMatrix {
368 elastic_self_distance_matrix(data, argvals, lambda)
369}
370
371// ─── Higher-Order Warp Penalties ─────────────────────────────────────────────
372
373/// Penalty type for alignment regularization.
374///
375/// Controls how the warping function is penalized during alignment.
376/// `FirstOrder` uses the standard DP penalty on slope deviation.
377/// `SecondOrder` and `Combined` first run standard DP alignment, then
378/// apply iterative Tikhonov smoothing to reduce warp curvature.
379#[derive(Debug, Clone, Copy, PartialEq, Default)]
380#[non_exhaustive]
381pub enum WarpPenaltyType {
382 /// Standard first-order penalty: lambda * (gamma' - 1)^2 * dt.
383 #[default]
384 FirstOrder,
385 /// Second-order (curvature) penalty: standard DP + iterative curvature smoothing.
386 SecondOrder,
387 /// Combined first- and second-order: DP alignment + curvature smoothing
388 /// weighted by `second_order_weight`.
389 Combined {
390 /// Relative weight of the curvature smoothing step (> 0).
391 second_order_weight: f64,
392 },
393}
394
395/// Number of Tikhonov smoothing iterations for second-order penalty.
396const TIKHONOV_ITERS: usize = 8;
397
398/// Apply Tikhonov curvature smoothing to a warping function.
399///
400/// Iteratively smooths toward the identity warp using Laplacian smoothing,
401/// which reduces high-frequency curvature while preserving monotonicity
402/// and boundary conditions. The smoothing weight `alpha` (clamped to [0,1])
403/// controls how much each iteration pulls interior points toward the
404/// midpoint of their neighbors.
405fn tikhonov_smooth_gamma(gamma: &[f64], argvals: &[f64], alpha: f64, n_iter: usize) -> Vec<f64> {
406 let m = gamma.len();
407 if m < 3 || alpha <= 0.0 {
408 return gamma.to_vec();
409 }
410
411 // Clamp effective weight to a stable range.
412 let w = alpha.min(0.5);
413
414 let mut gam = gamma.to_vec();
415
416 for _ in 0..n_iter {
417 let prev = gam.clone();
418
419 // Laplacian smoothing: move each interior point toward the
420 // midpoint of its neighbors, weighted by w.
421 for j in 1..m - 1 {
422 let mid = (prev[j - 1] + prev[j + 1]) / 2.0;
423 gam[j] = prev[j] + w * (mid - prev[j]);
424 }
425
426 // Enforce boundary conditions.
427 gam[0] = argvals[0];
428 gam[m - 1] = argvals[m - 1];
429
430 // Enforce monotonicity.
431 crate::warping::normalize_warp(&mut gam, argvals);
432 }
433
434 gam
435}
436
437/// Align two curves with a configurable penalty type.
438///
439/// For [`WarpPenaltyType::FirstOrder`], this delegates directly to
440/// [`elastic_align_pair`]. For [`WarpPenaltyType::SecondOrder`] and
441/// [`WarpPenaltyType::Combined`], runs the standard DP alignment first,
442/// then applies iterative Tikhonov smoothing to the warping function to
443/// reduce curvature (gamma'') while preserving alignment quality.
444///
445/// # Arguments
446/// * `f1` — Target curve (length m)
447/// * `f2` — Curve to align (length m)
448/// * `argvals` — Evaluation points (length m)
449/// * `lambda` — First-order penalty weight (passed to DP alignment)
450/// * `penalty_type` — Which penalty type to apply
451///
452/// # Returns
453/// [`AlignmentResult`] with warping function, aligned curve, and elastic distance.
454///
455/// # Examples
456///
457/// ```
458/// use fdars_core::alignment::{elastic_align_pair_penalized, WarpPenaltyType};
459///
460/// let argvals: Vec<f64> = (0..30).map(|i| i as f64 / 29.0).collect();
461/// let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
462/// let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
463///
464/// // Standard first-order
465/// let r1 = elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::FirstOrder);
466/// assert!(r1.distance >= 0.0);
467///
468/// // Second-order smoothing
469/// let r2 = elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::SecondOrder);
470/// assert!(r2.distance >= 0.0);
471/// ```
472#[must_use = "expensive computation whose result should not be discarded"]
473pub fn elastic_align_pair_penalized(
474 f1: &[f64],
475 f2: &[f64],
476 argvals: &[f64],
477 lambda: f64,
478 penalty_type: WarpPenaltyType,
479) -> AlignmentResult {
480 // Step 1: Run standard first-order DP alignment.
481 let initial = elastic_align_pair(f1, f2, argvals, lambda);
482
483 let smoothing_alpha = match penalty_type {
484 WarpPenaltyType::FirstOrder => return initial,
485 WarpPenaltyType::SecondOrder => lambda.max(0.01),
486 WarpPenaltyType::Combined {
487 second_order_weight,
488 } => second_order_weight.max(1e-6),
489 };
490
491 // Step 2: Apply Tikhonov curvature smoothing to the warping function.
492 let gamma_smooth =
493 tikhonov_smooth_gamma(&initial.gamma, argvals, smoothing_alpha, TIKHONOV_ITERS);
494
495 // Step 3: Recompute aligned curve and distance with smoothed gamma.
496 let f_aligned = reparameterize_curve(f2, argvals, &gamma_smooth);
497 let q1 = srsf_single(f1, argvals);
498 let q_aligned = srsf_single(&f_aligned, argvals);
499 let weights = simpsons_weights(argvals);
500 let distance = l2_distance(&q1, &q_aligned, &weights);
501
502 AlignmentResult {
503 gamma: gamma_smooth,
504 f_aligned,
505 distance,
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 fn uniform_grid(n: usize) -> Vec<f64> {
514 (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
515 }
516
517 #[test]
518 fn penalized_first_order_matches_standard() {
519 let argvals = uniform_grid(30);
520 let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
521 let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 6.0).sin()).collect();
522
523 let standard = elastic_align_pair(&f1, &f2, &argvals, 0.0);
524 let penalized =
525 elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::FirstOrder);
526
527 assert_eq!(standard.gamma, penalized.gamma);
528 assert_eq!(standard.f_aligned, penalized.f_aligned);
529 assert!((standard.distance - penalized.distance).abs() < 1e-12);
530 }
531
532 #[test]
533 fn second_order_produces_valid_warp() {
534 let argvals = uniform_grid(30);
535 let f1: Vec<f64> = argvals.iter().map(|&t| (t * 6.0).sin()).collect();
536 let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.15) * 6.0).sin()).collect();
537
538 let result =
539 elastic_align_pair_penalized(&f1, &f2, &argvals, 0.1, WarpPenaltyType::SecondOrder);
540
541 let m = argvals.len();
542 assert_eq!(result.gamma.len(), m);
543 assert_eq!(result.f_aligned.len(), m);
544 assert!(result.distance >= 0.0);
545
546 // Warp should be monotone non-decreasing.
547 for j in 1..m {
548 assert!(
549 result.gamma[j] >= result.gamma[j - 1] - 1e-12,
550 "gamma should be monotone at j={j}"
551 );
552 }
553
554 // Boundary conditions.
555 assert!((result.gamma[0] - argvals[0]).abs() < 1e-12);
556 assert!((result.gamma[m - 1] - argvals[m - 1]).abs() < 1e-12);
557 }
558
559 #[test]
560 fn combined_penalty_produces_valid_warp() {
561 let argvals = uniform_grid(25);
562 let f1: Vec<f64> = argvals.iter().map(|&t| (t * 4.0).sin()).collect();
563 let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.1) * 4.0).sin()).collect();
564
565 let result = elastic_align_pair_penalized(
566 &f1,
567 &f2,
568 &argvals,
569 0.05,
570 WarpPenaltyType::Combined {
571 second_order_weight: 0.1,
572 },
573 );
574
575 let m = argvals.len();
576 assert_eq!(result.gamma.len(), m);
577 assert!(result.distance >= 0.0);
578
579 // Monotonicity.
580 for j in 1..m {
581 assert!(
582 result.gamma[j] >= result.gamma[j - 1] - 1e-12,
583 "gamma should be monotone at j={j}"
584 );
585 }
586 }
587
588 #[test]
589 fn second_order_smoother_curvature() {
590 let argvals = uniform_grid(40);
591 let f1: Vec<f64> = argvals.iter().map(|&t| (t * 8.0).sin()).collect();
592 let f2: Vec<f64> = argvals.iter().map(|&t| ((t + 0.2) * 8.0).sin()).collect();
593
594 let first_order = elastic_align_pair(&f1, &f2, &argvals, 0.0);
595 let second_order =
596 elastic_align_pair_penalized(&f1, &f2, &argvals, 0.0, WarpPenaltyType::SecondOrder);
597
598 // Compute bending energy (sum of squared second derivative).
599 let bending = |g: &[f64]| -> f64 {
600 let m = g.len();
601 let mut energy = 0.0;
602 for j in 1..m - 1 {
603 let dt = argvals[j + 1] - argvals[j - 1];
604 if dt > 0.0 {
605 let d2 = (g[j + 1] - 2.0 * g[j] + g[j - 1]) / (dt / 2.0).powi(2);
606 energy += d2 * d2 * dt / 2.0;
607 }
608 }
609 energy
610 };
611
612 let be_first = bending(&first_order.gamma);
613 let be_second = bending(&second_order.gamma);
614
615 // Second-order penalty should reduce bending energy (or at least not
616 // increase it much if the first-order warp is already smooth).
617 assert!(
618 be_second <= be_first + 1e-6,
619 "second-order should reduce bending: first={be_first:.4}, second={be_second:.4}"
620 );
621 }
622
623 #[test]
624 fn warp_penalty_type_default_is_first_order() {
625 let penalty: WarpPenaltyType = WarpPenaltyType::default();
626 assert_eq!(penalty, WarpPenaltyType::FirstOrder);
627 }
628}