Skip to main content

fdars_core/
elastic_changepoint.rs

1//! Elastic changepoint detection for functional data streams.
2//!
3//! Implements the methods from Tucker & Yarger (2023) for detecting changepoints
4//! in the amplitude, phase, or joint structure of functional data.
5//!
6//! Key capabilities:
7//! - [`elastic_amp_changepoint`] — Amplitude changepoint via CUSUM on aligned curves
8//! - [`elastic_ph_changepoint`] — Phase changepoint via CUSUM on shooting vectors
9//! - [`elastic_fpca_changepoint`] — FPCA-based changepoint via Hotelling CUSUM
10
11use crate::alignment::{karcher_mean, KarcherMeanResult};
12use crate::elastic_fpca::{
13    horiz_fpca, joint_fpca, shooting_vectors_from_psis, sphere_karcher_mean, vert_fpca,
14    warps_to_normalized_psi,
15};
16use crate::helpers::simpsons_weights;
17use crate::matrix::FdMatrix;
18use nalgebra::DMatrix;
19use rand::rngs::StdRng;
20use rand::seq::SliceRandom;
21use rand::SeedableRng;
22
23// ─── Types ──────────────────────────────────────────────────────────────────
24
25/// Result of changepoint detection.
26#[derive(Debug, Clone, PartialEq)]
27#[non_exhaustive]
28pub struct ChangepointResult {
29    /// Estimated changepoint location (index in 1..n-1).
30    pub changepoint: usize,
31    /// Test statistic value.
32    pub test_statistic: f64,
33    /// Monte Carlo p-value.
34    pub p_value: f64,
35    /// CUSUM values for k=1..n-1.
36    pub cusum_values: Vec<f64>,
37}
38
39/// Type of changepoint to detect.
40#[derive(Debug, Clone, Copy, PartialEq)]
41#[non_exhaustive]
42pub enum ChangepointType {
43    /// Amplitude changepoint (on aligned curves).
44    Amplitude,
45    /// Phase changepoint (on warping functions).
46    Phase,
47    /// FPCA-based changepoint.
48    Fpca(FpcaChangepointMethod),
49}
50
51/// FPCA method for changepoint detection.
52#[derive(Debug, Clone, Copy, PartialEq)]
53#[non_exhaustive]
54pub enum FpcaChangepointMethod {
55    Vertical,
56    Horizontal,
57    Joint,
58}
59
60// ─── Amplitude Changepoint ──────────────────────────────────────────────────
61
62/// Detect a changepoint in the amplitude of functional data.
63///
64/// 1. Karcher mean alignment
65/// 2. CUSUM statistic on aligned curves
66/// 3. k* = argmax(S_n), T_n = max(S_n)
67/// 4. Monte Carlo p-value via permutation testing
68///
69/// # Arguments
70/// * `data` — Functional data (n × m), ordered by time/index
71/// * `argvals` — Evaluation points (length m)
72/// * `lambda` — Alignment penalty
73/// * `max_iter` — Karcher mean iterations
74/// * `n_mc` — Number of Monte Carlo simulations for p-value
75/// * `seed` — Random seed for reproducibility
76#[must_use = "expensive computation whose result should not be discarded"]
77pub fn elastic_amp_changepoint(
78    data: &FdMatrix,
79    argvals: &[f64],
80    lambda: f64,
81    max_iter: usize,
82    n_mc: usize,
83    seed: u64,
84) -> Result<ChangepointResult, crate::FdarError> {
85    let (n, m) = data.shape();
86    if n < 4 || m < 2 || argvals.len() != m {
87        return Err(crate::FdarError::InvalidDimension {
88            parameter: "data",
89            expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
90            actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
91        });
92    }
93
94    // Alignment
95    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
96
97    // CUSUM on aligned curves
98    let weights = simpsons_weights(argvals);
99    let cusum_values = functional_cusum(&km.aligned_data, &weights);
100
101    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
102
103    // Monte Carlo p-value via permutation
104    let p_value = mc_pvalue_permutation(test_statistic, &km.aligned_data, &weights, n_mc, seed);
105
106    Ok(ChangepointResult {
107        changepoint,
108        test_statistic,
109        p_value,
110        cusum_values,
111    })
112}
113
114// ─── Phase Changepoint ──────────────────────────────────────────────────────
115
116/// Detect a changepoint in the phase (warping) of functional data.
117///
118/// Same as amplitude changepoint but on shooting vectors from the warping functions.
119#[must_use = "expensive computation whose result should not be discarded"]
120pub fn elastic_ph_changepoint(
121    data: &FdMatrix,
122    argvals: &[f64],
123    lambda: f64,
124    max_iter: usize,
125    n_mc: usize,
126    seed: u64,
127) -> Result<ChangepointResult, crate::FdarError> {
128    let (n, m) = data.shape();
129    if n < 4 || m < 2 || argvals.len() != m {
130        return Err(crate::FdarError::InvalidDimension {
131            parameter: "data",
132            expected: "n >= 4, m >= 2, argvals.len() == m".to_string(),
133            actual: format!("n={}, m={}, argvals.len()={}", n, m, argvals.len()),
134        });
135    }
136
137    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
138
139    // Compute shooting vectors from warps (live on uniform [0,1] grid, not argvals)
140    let shooting = compute_shooting_vectors(&km, argvals).ok_or_else(|| {
141        crate::FdarError::ComputationFailed {
142            operation: "compute_shooting_vectors",
143            detail: "failed to compute shooting vectors from warps; alignment may have produced degenerate warping functions".to_string(),
144        }
145    })?;
146
147    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
148    let weights = simpsons_weights(&time);
149    let cusum_values = functional_cusum(&shooting, &weights);
150    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
151
152    // Monte Carlo p-value via permutation
153    let p_value = mc_pvalue_permutation(test_statistic, &shooting, &weights, n_mc, seed);
154
155    Ok(ChangepointResult {
156        changepoint,
157        test_statistic,
158        p_value,
159        cusum_values,
160    })
161}
162
163// ─── FPCA Changepoint ───────────────────────────────────────────────────────
164
165/// Detect a changepoint using FPCA scores with Hotelling CUSUM.
166///
167/// Uses PC scores from vert/horiz/joint FPCA for a Hotelling-type test.
168///
169/// # Arguments
170/// * `data` — Functional data (n × m)
171/// * `argvals` — Evaluation points (length m)
172/// * `pca_method` — Which FPCA variant to use
173/// * `ncomp` — Number of principal components
174/// * `lambda` — Alignment penalty
175/// * `max_iter` — Karcher mean iterations
176/// * `n_mc` — Monte Carlo simulations
177/// * `seed` — Random seed
178#[must_use = "expensive computation whose result should not be discarded"]
179pub fn elastic_fpca_changepoint(
180    data: &FdMatrix,
181    argvals: &[f64],
182    pca_method: FpcaChangepointMethod,
183    ncomp: usize,
184    lambda: f64,
185    max_iter: usize,
186    n_mc: usize,
187    seed: u64,
188) -> Result<ChangepointResult, crate::FdarError> {
189    let (n, m) = data.shape();
190    if n < 4 || m < 2 || argvals.len() != m || ncomp < 1 {
191        return Err(crate::FdarError::InvalidDimension {
192            parameter: "data",
193            expected: "n >= 4, m >= 2, argvals.len() == m, ncomp >= 1".to_string(),
194            actual: format!(
195                "n={}, m={}, argvals.len()={}, ncomp={}",
196                n,
197                m,
198                argvals.len(),
199                ncomp
200            ),
201        });
202    }
203
204    let km = karcher_mean(data, argvals, max_iter, 1e-4, lambda);
205
206    // Get PC scores
207    let scores = match pca_method {
208        FpcaChangepointMethod::Vertical => {
209            let fpca = vert_fpca(&km, argvals, ncomp)?;
210            fpca.scores
211        }
212        FpcaChangepointMethod::Horizontal => {
213            let fpca = horiz_fpca(&km, argvals, ncomp)?;
214            fpca.scores
215        }
216        FpcaChangepointMethod::Joint => {
217            let fpca = joint_fpca(&km, argvals, ncomp, None)?;
218            fpca.scores
219        }
220    };
221
222    // Hotelling CUSUM on scores
223    let cusum_values = hotelling_cusum(&scores);
224    let (changepoint, test_statistic) = find_max_cusum(&cusum_values);
225
226    // Monte Carlo p-value via permutation
227    let p_value = mc_pvalue_permutation_hotelling(test_statistic, &scores, n_mc, seed);
228
229    Ok(ChangepointResult {
230        changepoint,
231        test_statistic,
232        p_value,
233        cusum_values,
234    })
235}
236
237// ─── Internal Helpers ───────────────────────────────────────────────────────
238
239/// Compute shooting vectors from warping functions.
240fn compute_shooting_vectors(km: &KarcherMeanResult, argvals: &[f64]) -> Option<FdMatrix> {
241    let (n, m) = km.gammas.shape();
242    if n < 2 || m < 2 {
243        return None;
244    }
245    let time: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
246    let psis = warps_to_normalized_psi(&km.gammas, argvals);
247    let mu_psi = sphere_karcher_mean(&psis, &time, 30);
248    Some(shooting_vectors_from_psis(&psis, &mu_psi, &time))
249}
250
251/// Functional CUSUM: S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
252///
253/// Uses Simpson's quadrature weights for spatial integration.
254fn functional_cusum(data: &FdMatrix, weights: &[f64]) -> Vec<f64> {
255    let (n, m) = data.shape();
256    let mut cusum_values = Vec::with_capacity(n - 1);
257
258    // Running sum
259    let mut cumsum = vec![0.0; m];
260    let mut total = vec![0.0; m];
261    for i in 0..n {
262        for j in 0..m {
263            total[j] += data[(i, j)];
264        }
265    }
266
267    for k in 1..n {
268        for j in 0..m {
269            cumsum[j] += data[(k - 1, j)];
270        }
271
272        // S_n(k) = (1/n²) Σ_j w_j * (cumsum_j - (k/n)*total_j)²
273        let ratio = k as f64 / n as f64;
274        let mut s = 0.0;
275        for j in 0..m {
276            let diff = cumsum[j] - ratio * total[j];
277            s += weights[j] * diff * diff;
278        }
279        s /= (n * n) as f64;
280        cusum_values.push(s);
281    }
282
283    cusum_values
284}
285
286/// Compute sample covariance matrix with regularization, returning its inverse.
287fn regularized_cov_inverse(scores: &FdMatrix, mean: &[f64], n: usize, p: usize) -> DMatrix<f64> {
288    let mut cov = DMatrix::zeros(p, p);
289    for i in 0..n {
290        for j1 in 0..p {
291            for j2 in 0..p {
292                cov[(j1, j2)] += (scores[(i, j1)] - mean[j1]) * (scores[(i, j2)] - mean[j2]);
293            }
294        }
295    }
296    cov /= (n - 1) as f64;
297    for j in 0..p {
298        cov[(j, j)] += 1e-6;
299    }
300    if let Some(chol) = cov.cholesky() {
301        chol.inverse()
302    } else {
303        DMatrix::identity(p, p)
304    }
305}
306
307/// Hotelling CUSUM on multivariate scores: S_n(k) = (1/n) Δ_k' Σ^{-1} Δ_k
308fn hotelling_cusum(scores: &FdMatrix) -> Vec<f64> {
309    let (n, p) = scores.shape();
310
311    let mut mean = vec![0.0; p];
312    for k in 0..p {
313        for i in 0..n {
314            mean[k] += scores[(i, k)];
315        }
316        mean[k] /= n as f64;
317    }
318
319    let cov_inv = regularized_cov_inverse(scores, &mean, n, p);
320
321    let mut cumsum = vec![0.0; p];
322    let mut total = vec![0.0; p];
323    for i in 0..n {
324        for k in 0..p {
325            total[k] += scores[(i, k)];
326        }
327    }
328
329    let mut cusum_values = Vec::with_capacity(n - 1);
330    for k in 1..n {
331        for j in 0..p {
332            cumsum[j] += scores[(k - 1, j)];
333        }
334        let ratio = k as f64 / n as f64;
335        let mut delta = nalgebra::DVector::zeros(p);
336        for j in 0..p {
337            delta[j] = (cumsum[j] - ratio * total[j]) / n as f64;
338        }
339        let hotelling = (&delta.transpose() * &cov_inv * &delta)[(0, 0)];
340        cusum_values.push(hotelling);
341    }
342
343    cusum_values
344}
345
346/// Find index and value of maximum CUSUM.
347fn find_max_cusum(cusum_values: &[f64]) -> (usize, f64) {
348    let mut max_val = f64::NEG_INFINITY;
349    let mut max_idx = 0;
350    for (i, &v) in cusum_values.iter().enumerate() {
351        if v > max_val {
352            max_val = v;
353            max_idx = i + 1; // 1-indexed changepoint
354        }
355    }
356    (max_idx, max_val)
357}
358
359/// Monte Carlo p-value via permutation testing for functional CUSUM.
360///
361/// Shuffles the row order of `data`, recomputes `functional_cusum`, and compares
362/// max(cusum) to the observed test statistic. Scale-invariant by construction.
363fn mc_pvalue_permutation(
364    test_stat: f64,
365    data: &FdMatrix,
366    weights: &[f64],
367    n_mc: usize,
368    seed: u64,
369) -> f64 {
370    let (n, m) = data.shape();
371    let mut rng = StdRng::seed_from_u64(seed);
372    let mut indices: Vec<usize> = (0..n).collect();
373    let mut count_exceed = 0usize;
374
375    let mut shuffled = FdMatrix::zeros(n, m);
376    for _ in 0..n_mc {
377        indices.shuffle(&mut rng);
378        for (new_i, &orig_i) in indices.iter().enumerate() {
379            for j in 0..m {
380                shuffled[(new_i, j)] = data[(orig_i, j)];
381            }
382        }
383        let cusum = functional_cusum(&shuffled, weights);
384        let (_, max_val) = find_max_cusum(&cusum);
385        if max_val >= test_stat {
386            count_exceed += 1;
387        }
388    }
389
390    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
391}
392
393/// Monte Carlo p-value via permutation testing for Hotelling CUSUM.
394///
395/// Shuffles row order of scores, recomputes `hotelling_cusum`, and compares.
396fn mc_pvalue_permutation_hotelling(
397    test_stat: f64,
398    scores: &FdMatrix,
399    n_mc: usize,
400    seed: u64,
401) -> f64 {
402    let (n, p) = scores.shape();
403    let mut rng = StdRng::seed_from_u64(seed);
404    let mut indices: Vec<usize> = (0..n).collect();
405    let mut count_exceed = 0usize;
406
407    let mut shuffled = FdMatrix::zeros(n, p);
408    for _ in 0..n_mc {
409        indices.shuffle(&mut rng);
410        for (new_i, &orig_i) in indices.iter().enumerate() {
411            for j in 0..p {
412                shuffled[(new_i, j)] = scores[(orig_i, j)];
413            }
414        }
415        let cusum = hotelling_cusum(&shuffled);
416        let (_, max_val) = find_max_cusum(&cusum);
417        if max_val >= test_stat {
418            count_exceed += 1;
419        }
420    }
421
422    (count_exceed as f64 + 1.0) / (n_mc as f64 + 1.0)
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use std::f64::consts::PI;
429
430    fn generate_changepoint_data(n: usize, m: usize, cp: usize) -> (FdMatrix, Vec<f64>) {
431        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
432        let mut data = FdMatrix::zeros(n, m);
433
434        for i in 0..n {
435            let amp = if i < cp { 1.0 } else { 2.0 };
436            for j in 0..m {
437                data[(i, j)] = amp * (2.0 * PI * t[j]).sin();
438            }
439        }
440        (data, t)
441    }
442
443    #[test]
444    fn test_amp_changepoint_detects_shift() {
445        let n = 30;
446        let m = 51;
447        let cp = 15;
448        let (data, t) = generate_changepoint_data(n, m, cp);
449
450        let result = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42);
451        assert!(result.is_ok(), "amp changepoint should succeed");
452
453        let res = result.unwrap();
454        assert!(
455            res.cusum_values.len() == n - 1,
456            "Should have n-1 CUSUM values"
457        );
458        assert!(
459            (res.changepoint as i64 - cp as i64).abs() <= 5,
460            "Detected changepoint {} should be near true cp {}",
461            res.changepoint,
462            cp
463        );
464        assert!(
465            res.p_value < 0.05,
466            "Clear amplitude shift should be significant, got p={}",
467            res.p_value
468        );
469    }
470
471    #[test]
472    fn test_ph_changepoint_basic() {
473        let n = 30;
474        let m = 51;
475        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
476        let mut data = FdMatrix::zeros(n, m);
477
478        for i in 0..n {
479            let shift = if i < 15 { 0.0 } else { 0.15 };
480            for j in 0..m {
481                data[(i, j)] = (2.0 * PI * (t[j] + shift)).sin();
482            }
483        }
484
485        let result = elastic_ph_changepoint(&data, &t, 0.0, 5, 100, 42);
486        assert!(result.is_ok());
487    }
488
489    #[test]
490    fn test_fpca_changepoint_basic() {
491        let n = 30;
492        let m = 51;
493        let cp = 15;
494        let (data, t) = generate_changepoint_data(n, m, cp);
495
496        let result = elastic_fpca_changepoint(
497            &data,
498            &t,
499            FpcaChangepointMethod::Vertical,
500            3,
501            0.0,
502            5,
503            100,
504            42,
505        );
506        assert!(result.is_ok(), "fpca changepoint should succeed");
507    }
508
509    #[test]
510    fn test_changepoint_no_change() {
511        // All curves identical → weak test statistic, high p-value
512        let n = 20;
513        let m = 51;
514        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
515        let mut data = FdMatrix::zeros(n, m);
516        for i in 0..n {
517            for j in 0..m {
518                data[(i, j)] = (2.0 * PI * t[j]).sin();
519            }
520        }
521
522        let res = elastic_amp_changepoint(&data, &t, 0.0, 5, 200, 42)
523            .expect("should succeed for identical curves");
524        assert!(
525            res.p_value > 0.1,
526            "No change should not be significant, got p={}",
527            res.p_value
528        );
529    }
530
531    #[test]
532    fn test_pvalue_permutation_discriminates() {
533        let m = 51;
534        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
535
536        // Strong signal: amplitude doubles at midpoint
537        let (data_signal, _) = generate_changepoint_data(30, m, 15);
538        let res_signal =
539            elastic_amp_changepoint(&data_signal, &t, 0.0, 5, 199, 99).expect("should succeed");
540        assert!(
541            res_signal.p_value < 0.05,
542            "Strong signal should give small p, got {}",
543            res_signal.p_value
544        );
545
546        // No signal: all curves identical
547        let mut data_null = FdMatrix::zeros(30, m);
548        for i in 0..30 {
549            for j in 0..m {
550                data_null[(i, j)] = (2.0 * PI * t[j]).sin();
551            }
552        }
553        let res_null =
554            elastic_amp_changepoint(&data_null, &t, 0.0, 5, 199, 99).expect("should succeed");
555        assert!(
556            res_null.p_value > 0.1,
557            "No signal should give large p, got {}",
558            res_null.p_value
559        );
560    }
561
562    #[test]
563    fn test_invalid_input() {
564        let data = FdMatrix::zeros(2, 5);
565        let t: Vec<f64> = (0..5).map(|i| i as f64 / 4.0).collect();
566        // n=2 < 4 → should return Err
567        assert!(elastic_amp_changepoint(&data, &t, 0.0, 5, 100, 42).is_err());
568    }
569
570    #[test]
571    fn test_fpca_changepoint_horizontal() {
572        let n = 30;
573        let m = 51;
574        let cp = 15;
575        let (data, t) = generate_changepoint_data(n, m, cp);
576
577        let result = elastic_fpca_changepoint(
578            &data,
579            &t,
580            FpcaChangepointMethod::Horizontal,
581            3,
582            0.0,
583            5,
584            100,
585            42,
586        );
587        assert!(
588            result.is_ok(),
589            "fpca changepoint (horizontal) should succeed"
590        );
591
592        let res = result.unwrap();
593        assert_eq!(res.cusum_values.len(), n - 1);
594    }
595
596    #[test]
597    fn test_fpca_changepoint_joint() {
598        let n = 30;
599        let m = 51;
600        let cp = 15;
601        let (data, t) = generate_changepoint_data(n, m, cp);
602
603        let result =
604            elastic_fpca_changepoint(&data, &t, FpcaChangepointMethod::Joint, 3, 0.0, 5, 100, 42);
605        assert!(result.is_ok(), "fpca changepoint (joint) should succeed");
606
607        let res = result.unwrap();
608        assert_eq!(res.cusum_values.len(), n - 1);
609    }
610
611    #[test]
612    fn test_changepoint_seed_determinism() {
613        let n = 30;
614        let m = 51;
615        let cp = 15;
616        let (data, t) = generate_changepoint_data(n, m, cp);
617
618        let res1 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
619        let res2 = elastic_amp_changepoint(&data, &t, 0.0, 5, 199, 42).expect("should succeed");
620
621        assert_eq!(res1.changepoint, res2.changepoint);
622        assert!((res1.p_value - res2.p_value).abs() < 1e-10);
623    }
624
625    #[test]
626    fn test_pvalue_in_unit_interval() {
627        let n = 30;
628        let m = 51;
629        let cp = 15;
630        let (data, t) = generate_changepoint_data(n, m, cp);
631
632        // Amplitude
633        let res_amp =
634            elastic_amp_changepoint(&data, &t, 0.0, 5, 100, 42).expect("amp should succeed");
635        assert!(
636            (0.0..=1.0).contains(&res_amp.p_value),
637            "amp p-value {} should be in [0, 1]",
638            res_amp.p_value
639        );
640
641        // Phase
642        let res_ph =
643            elastic_ph_changepoint(&data, &t, 0.0, 5, 100, 42).expect("phase should succeed");
644        assert!(
645            (0.0..=1.0).contains(&res_ph.p_value),
646            "phase p-value {} should be in [0, 1]",
647            res_ph.p_value
648        );
649
650        // FPCA (vertical)
651        let res_fpca = elastic_fpca_changepoint(
652            &data,
653            &t,
654            FpcaChangepointMethod::Vertical,
655            3,
656            0.0,
657            5,
658            100,
659            42,
660        )
661        .expect("fpca should succeed");
662        assert!(
663            (0.0..=1.0).contains(&res_fpca.p_value),
664            "fpca p-value {} should be in [0, 1]",
665            res_fpca.p_value
666        );
667    }
668
669    #[test]
670    fn test_fpca_changepoint_discriminates_signal() {
671        let m = 51;
672        let t: Vec<f64> = (0..m).map(|j| j as f64 / (m - 1) as f64).collect();
673
674        // Strong signal: amplitude doubles at midpoint
675        let (data_signal, _) = generate_changepoint_data(30, m, 15);
676        let res_signal = elastic_fpca_changepoint(
677            &data_signal,
678            &t,
679            FpcaChangepointMethod::Vertical,
680            3,
681            0.0,
682            5,
683            199,
684            99,
685        )
686        .expect("fpca signal should succeed");
687        assert!(
688            res_signal.p_value < 0.1,
689            "Strong FPCA signal should give small p, got {}",
690            res_signal.p_value
691        );
692
693        // No signal: all curves identical
694        let mut data_null = FdMatrix::zeros(30, m);
695        for i in 0..30 {
696            for j in 0..m {
697                data_null[(i, j)] = (2.0 * PI * t[j]).sin();
698            }
699        }
700        let res_null = elastic_fpca_changepoint(
701            &data_null,
702            &t,
703            FpcaChangepointMethod::Vertical,
704            3,
705            0.0,
706            5,
707            199,
708            99,
709        )
710        .expect("fpca null should succeed");
711        assert!(
712            res_null.p_value > 0.1,
713            "No FPCA signal should give large p, got {}",
714            res_null.p_value
715        );
716    }
717
718    #[test]
719    fn test_fpca_changepoint_seed_determinism() {
720        let n = 30;
721        let m = 51;
722        let cp = 15;
723        let (data, t) = generate_changepoint_data(n, m, cp);
724
725        let res1 = elastic_fpca_changepoint(
726            &data,
727            &t,
728            FpcaChangepointMethod::Vertical,
729            3,
730            0.0,
731            5,
732            100,
733            42,
734        )
735        .expect("should succeed");
736        let res2 = elastic_fpca_changepoint(
737            &data,
738            &t,
739            FpcaChangepointMethod::Vertical,
740            3,
741            0.0,
742            5,
743            100,
744            42,
745        )
746        .expect("should succeed");
747
748        assert_eq!(res1.changepoint, res2.changepoint);
749        assert!(
750            (res1.p_value - res2.p_value).abs() < 1e-10,
751            "FPCA p-values should be deterministic with same seed"
752        );
753    }
754}