Skip to main content

fdars_core/spm/
phase.rs

1//! Phase I/II framework for statistical process monitoring.
2//!
3//! Phase I: Builds a monitoring chart from historical in-control data by
4//! splitting into tuning (for FPCA) and calibration (for control limits) sets.
5//!
6//! Phase II: Monitors new observations against the established chart.
7//!
8//! Both univariate and multivariate variants are provided.
9//!
10//! # References
11//!
12//! - Horváth, L. & Kokoszka, P. (2012). *Inference for Functional Data
13//!   with Applications*, Chapter 13, pp. 323--352. Springer.
14//! - Flores, M., Naya, S., Fernández-Casal, R. & Zaragoza, S. (2022).
15//!   Constructing a control chart using functional data, §2, Algorithm 1.
16//!   *Mathematics*, 8(1), 58.
17
18use crate::error::FdarError;
19use crate::matrix::FdMatrix;
20use crate::regression::{fdata_to_pc_1d, FpcaResult};
21
22use super::control::{spe_control_limit, t2_control_limit, ControlLimit};
23use super::mfpca::{mfpca, MfpcaConfig, MfpcaResult};
24use super::stats::{hotelling_t2, spe_multivariate, spe_univariate};
25
26/// Configuration for SPM chart construction.
27#[derive(Debug, Clone, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct SpmConfig {
30    /// Number of principal components to retain (default 5).
31    ///
32    /// Typical range: 3--10. Use [`select_ncomp()`](super::ncomp::select_ncomp)
33    /// for data-driven selection. More components capture finer structure but
34    /// increase dimensionality of the monitoring statistic.
35    pub ncomp: usize,
36    /// Significance level for control limits (default 0.05).
37    pub alpha: f64,
38    /// Fraction of data used for tuning/FPCA (default 0.5).
39    ///
40    /// The remainder forms the calibration set for control limits. Default 0.5
41    /// balances FPCA estimation quality against control limit precision. With
42    /// small datasets (n < 50), consider 0.6--0.7 to ensure adequate FPCA
43    /// estimation.
44    ///
45    /// The tuning/calibration split induces a bias-variance trade-off: a larger
46    /// tuning fraction yields better FPCA eigenfunction estimates but less
47    /// precise control limits. The optimal split depends on the eigenvalue
48    /// decay rate — fast decay (smooth processes) favors allocating more data
49    /// to calibration, while slow decay (rough processes) favors more tuning
50    /// data for stable FPCA estimation.
51    pub tuning_fraction: f64,
52    /// Random seed for data splitting (default 42).
53    pub seed: u64,
54}
55
56impl Default for SpmConfig {
57    fn default() -> Self {
58        Self {
59            ncomp: 5,
60            alpha: 0.05,
61            tuning_fraction: 0.5,
62            seed: 42,
63        }
64    }
65}
66
67/// Univariate SPM chart from Phase I.
68///
69/// The chart assumes approximate multivariate normality in the score space.
70/// For non-Gaussian functional data, the chi-squared control limits are
71/// approximate. Use `t2_limit_robust()` with bootstrap method for
72/// distribution-free limits.
73///
74/// For finite calibration samples, the exact Hotelling T^2 distribution is
75/// `(n_cal * ncomp / (n_cal - ncomp)) * F(ncomp, n_cal - ncomp)`. The
76/// chi-squared limit `chi2(ncomp)` is asymptotically exact as `n_cal -> inf`,
77/// but can be anti-conservative for small calibration sets (n_cal < 10 *
78/// ncomp). When the calibration set is small, prefer bootstrap-based limits.
79#[derive(Debug, Clone, PartialEq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[non_exhaustive]
82pub struct SpmChart {
83    /// FPCA result from the tuning set.
84    pub fpca: FpcaResult,
85    /// Eigenvalues lambda_l = s_l^2 / (n_tune - 1), where s_l are singular
86    /// values from the SVD of the centered data matrix X = U Sigma V^T. This
87    /// gives the sample covariance eigenvalues since Cov = X^T X / (n-1) has
88    /// eigenvalues s_l^2 / (n-1).
89    ///
90    /// The actual number of components may be fewer than `config.ncomp` if
91    /// limited by sample size or grid resolution.
92    pub eigenvalues: Vec<f64>,
93    /// T-squared values for the calibration set.
94    pub t2_phase1: Vec<f64>,
95    /// SPE values for the calibration set.
96    pub spe_phase1: Vec<f64>,
97    /// T-squared control limit.
98    pub t2_limit: ControlLimit,
99    /// SPE control limit.
100    pub spe_limit: ControlLimit,
101    /// Configuration used to build the chart.
102    pub config: SpmConfig,
103    /// Whether the sample size meets the recommended minimum (10 × ncomp).
104    /// False indicates results may be unstable.
105    pub sample_size_adequate: bool,
106}
107
108/// Multivariate SPM chart from Phase I.
109#[derive(Debug, Clone, PartialEq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111#[non_exhaustive]
112pub struct MfSpmChart {
113    /// MFPCA result from the tuning set.
114    pub mfpca: MfpcaResult,
115    /// T-squared values for the calibration set.
116    pub t2_phase1: Vec<f64>,
117    /// SPE values for the calibration set.
118    pub spe_phase1: Vec<f64>,
119    /// T-squared control limit.
120    pub t2_limit: ControlLimit,
121    /// SPE control limit.
122    pub spe_limit: ControlLimit,
123    /// Configuration used to build the chart.
124    pub config: SpmConfig,
125}
126
127/// Result of Phase II monitoring.
128#[derive(Debug, Clone, PartialEq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130#[non_exhaustive]
131pub struct SpmMonitorResult {
132    /// T-squared values for new observations.
133    pub t2: Vec<f64>,
134    /// SPE values for new observations.
135    pub spe: Vec<f64>,
136    /// T-squared alarm flags.
137    pub t2_alarm: Vec<bool>,
138    /// SPE alarm flags.
139    pub spe_alarm: Vec<bool>,
140    /// Score matrix for new observations.
141    pub scores: FdMatrix,
142}
143
144/// Split indices into tuning and calibration sets.
145///
146/// Uses a deterministic Fisher-Yates shuffle with PCG-XSH-RR output function
147/// (O'Neill, 2014, §4.1, p. 14) for high-quality uniform sampling. PCG-XSH-RR
148/// has period 2^64 and passes the full TestU01 BigCrush battery. The same seed
149/// always produces the same split, ensuring reproducibility.
150pub(super) fn split_indices(n: usize, tuning_fraction: f64, seed: u64) -> (Vec<usize>, Vec<usize>) {
151    let n_tune = ((n as f64 * tuning_fraction).round() as usize)
152        .max(2)
153        .min(n - 1);
154
155    // Generate a deterministic permutation using PCG-XSH-RR
156    let mut indices: Vec<usize> = (0..n).collect();
157    let mut rng_state: u64 = seed;
158    for i in (1..n).rev() {
159        let j = pcg_next(&mut rng_state) as usize % (i + 1);
160        indices.swap(i, j);
161    }
162
163    let tune_indices: Vec<usize> = indices[..n_tune].to_vec();
164    let cal_indices: Vec<usize> = indices[n_tune..].to_vec();
165    (tune_indices, cal_indices)
166}
167
168/// PCG-XSH-RR 64→32 output function (O'Neill, 2014).
169///
170/// PCG-XSH-RR (O'Neill, 2014) produces uniformly distributed 32-bit outputs
171/// from a 64-bit LCG state. The XSH-RR output function (xor-shift, random
172/// rotation) provides excellent statistical quality (passes TestU01 BigCrush).
173/// Used here for Fisher-Yates shuffle in `split_indices`.
174fn pcg_next(state: &mut u64) -> u32 {
175    let old = *state;
176    *state = old.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
177    let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
178    let rot = (old >> 59) as u32;
179    xorshifted.rotate_right(rot)
180}
181
182/// Compute centered reconstruction (without adding back mean) for SPE.
183///
184/// Returns the centered reconstruction: scores * rotation^T (no mean added).
185pub(super) fn centered_reconstruct(fpca: &FpcaResult, scores: &FdMatrix, ncomp: usize) -> FdMatrix {
186    let n = scores.nrows();
187    let m = fpca.mean.len();
188    let ncomp = ncomp.min(fpca.rotation.ncols()).min(scores.ncols());
189
190    let mut recon = FdMatrix::zeros(n, m);
191    for i in 0..n {
192        for j in 0..m {
193            let mut val = 0.0;
194            for k in 0..ncomp {
195                val += scores[(i, k)] * fpca.rotation[(j, k)];
196            }
197            recon[(i, j)] = val;
198        }
199    }
200    recon
201}
202
203/// Compute centered data for new observations (data - mean).
204pub(super) fn center_data(data: &FdMatrix, mean: &[f64]) -> FdMatrix {
205    let (n, m) = data.shape();
206    let mut centered = FdMatrix::zeros(n, m);
207    for i in 0..n {
208        for j in 0..m {
209            centered[(i, j)] = data[(i, j)] - mean[j];
210        }
211    }
212    centered
213}
214
215/// Build a univariate SPM chart from Phase I data.
216///
217/// # Arguments
218/// * `data` - In-control functional data (n x m)
219/// * `argvals` - Grid points (length m)
220/// * `config` - SPM configuration
221///
222/// # Errors
223///
224/// Returns errors from FPCA, T-squared computation, or control limit estimation.
225///
226/// # Assumptions
227///
228/// - The in-control data should be approximately normally distributed in the
229///   score space. Departures from normality may inflate false alarm rates.
230/// - Recommended minimum sample size: at least 10 × ncomp observations to
231///   ensure stable FPCA estimation (Horváth & Kokoszka, 2012).
232/// - The tuning/calibration split means the effective sample for each step
233///   is smaller than `n`. Ensure each subset has at least 2 × ncomp rows.
234///
235/// # Example
236/// ```
237/// use fdars_core::matrix::FdMatrix;
238/// use fdars_core::spm::phase::{spm_phase1, SpmConfig};
239/// let data = FdMatrix::from_column_major(
240///     (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
241/// ).unwrap();
242/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
243/// let config = SpmConfig { ncomp: 2, ..SpmConfig::default() };
244/// let chart = spm_phase1(&data, &argvals, &config).unwrap();
245/// assert!(chart.eigenvalues.len() <= 2);
246/// assert!(chart.t2_limit.ucl > 0.0);
247/// ```
248#[must_use = "expensive computation whose result should not be discarded"]
249pub fn spm_phase1(
250    data: &FdMatrix,
251    argvals: &[f64],
252    config: &SpmConfig,
253) -> Result<SpmChart, FdarError> {
254    let (n, m) = data.shape();
255    if n < 4 {
256        return Err(FdarError::InvalidDimension {
257            parameter: "data",
258            expected: "at least 4 observations for tuning/calibration split".to_string(),
259            actual: format!("{n} observations"),
260        });
261    }
262    let sample_size_adequate = n >= 10 * config.ncomp;
263    if argvals.len() != m {
264        return Err(FdarError::InvalidDimension {
265            parameter: "argvals",
266            expected: format!("{m}"),
267            actual: format!("{}", argvals.len()),
268        });
269    }
270
271    // Split into tuning and calibration
272    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
273
274    let tune_data = crate::cv::subset_rows(data, &tune_idx);
275    let cal_data = crate::cv::subset_rows(data, &cal_idx);
276    let n_tune = tune_data.nrows();
277    if n_tune < 3 {
278        return Err(FdarError::InvalidDimension {
279            parameter: "data",
280            expected: "tuning set with at least 3 observations".to_string(),
281            actual: format!(
282                "{n_tune} observations in tuning set (increase data size or tuning_fraction)"
283            ),
284        });
285    }
286
287    // FPCA on tuning set.
288    // Clamp ncomp to at most (n_tune - 1) and m to avoid rank-deficient SVD.
289    // The actual number of retained components may therefore be fewer than
290    // config.ncomp; this is reflected in the chart's eigenvalues length.
291    let ncomp = config.ncomp.min(n_tune - 1).min(m);
292    let fpca = fdata_to_pc_1d(&tune_data, ncomp, argvals)?;
293    let actual_ncomp = fpca.scores.ncols();
294
295    // Eigenvalues are computed as λ_l = s_l² / (n-1) where s_l is the l-th
296    // singular value from SVD of the centered data. This gives the sample
297    // variance explained by each PC, consistent with the covariance-based PCA
298    // formulation (cov = X'X / (n-1), whose eigenvalues are s_l² / (n-1)).
299    let eigenvalues: Vec<f64> = fpca
300        .singular_values
301        .iter()
302        .take(actual_ncomp)
303        .map(|&sv| sv * sv / (n_tune as f64 - 1.0))
304        .collect();
305
306    // Project calibration set
307    let cal_scores = fpca.project(&cal_data)?;
308
309    // T-squared on calibration
310    let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
311
312    // SPE on calibration: need centered and reconstructed
313    let cal_centered = center_data(&cal_data, &fpca.mean);
314    let cal_recon_centered = centered_reconstruct(&fpca, &cal_scores, actual_ncomp);
315    let spe_phase1 = spe_univariate(&cal_centered, &cal_recon_centered, argvals)?;
316
317    // Control limits
318    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
319    let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
320
321    Ok(SpmChart {
322        fpca,
323        eigenvalues,
324        t2_phase1,
325        spe_phase1,
326        t2_limit,
327        spe_limit,
328        config: config.clone(),
329        sample_size_adequate,
330    })
331}
332
333/// Monitor new univariate functional data against an established SPM chart.
334///
335/// # Arguments
336/// * `chart` - Phase I SPM chart
337/// * `new_data` - New functional observations (n_new x m)
338/// * `argvals` - Grid points (length m)
339///
340/// # Errors
341///
342/// Returns errors from projection or statistic computation.
343///
344/// # Example
345/// ```
346/// use fdars_core::matrix::FdMatrix;
347/// use fdars_core::spm::phase::{spm_phase1, spm_monitor, SpmConfig};
348/// let data = FdMatrix::from_column_major(
349///     (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
350/// ).unwrap();
351/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
352/// let config = SpmConfig { ncomp: 2, ..SpmConfig::default() };
353/// let chart = spm_phase1(&data, &argvals, &config).unwrap();
354/// let new_data = FdMatrix::from_column_major(
355///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(), 5, 10
356/// ).unwrap();
357/// let result = spm_monitor(&chart, &new_data, &argvals).unwrap();
358/// assert_eq!(result.t2.len(), 5);
359/// ```
360#[must_use = "monitoring result should not be discarded"]
361pub fn spm_monitor(
362    chart: &SpmChart,
363    new_data: &FdMatrix,
364    argvals: &[f64],
365) -> Result<SpmMonitorResult, FdarError> {
366    let m = chart.fpca.mean.len();
367    if new_data.ncols() != m {
368        return Err(FdarError::InvalidDimension {
369            parameter: "new_data",
370            expected: format!("{m} columns"),
371            actual: format!("{} columns", new_data.ncols()),
372        });
373    }
374
375    let ncomp = chart.eigenvalues.len();
376
377    // Project new data
378    let scores = chart.fpca.project(new_data)?;
379
380    // T-squared
381    let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
382
383    // SPE
384    let centered = center_data(new_data, &chart.fpca.mean);
385    let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
386    let spe = spe_univariate(&centered, &recon_centered, argvals)?;
387
388    // Alarms
389    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
390    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
391
392    Ok(SpmMonitorResult {
393        t2,
394        spe,
395        t2_alarm,
396        spe_alarm,
397        scores,
398    })
399}
400
401/// Build a multivariate SPM chart from Phase I data.
402///
403/// # Arguments
404/// * `variables` - Slice of in-control functional matrices (each n x m_p)
405/// * `argvals_list` - Per-variable grid points
406/// * `config` - SPM configuration
407///
408/// # Errors
409///
410/// Returns errors from MFPCA, T-squared computation, or control limit estimation.
411#[must_use = "expensive computation whose result should not be discarded"]
412pub fn mf_spm_phase1(
413    variables: &[&FdMatrix],
414    argvals_list: &[&[f64]],
415    config: &SpmConfig,
416) -> Result<MfSpmChart, FdarError> {
417    if variables.is_empty() {
418        return Err(FdarError::InvalidDimension {
419            parameter: "variables",
420            expected: "at least 1 variable".to_string(),
421            actual: "0 variables".to_string(),
422        });
423    }
424    if variables.len() != argvals_list.len() {
425        return Err(FdarError::InvalidDimension {
426            parameter: "argvals_list",
427            expected: format!("{} (matching variables)", variables.len()),
428            actual: format!("{}", argvals_list.len()),
429        });
430    }
431
432    let n = variables[0].nrows();
433    if n < 4 {
434        return Err(FdarError::InvalidDimension {
435            parameter: "variables",
436            expected: "at least 4 observations".to_string(),
437            actual: format!("{n} observations"),
438        });
439    }
440
441    // Validate argvals lengths
442    for (p, (var, argvals)) in variables.iter().zip(argvals_list.iter()).enumerate() {
443        if var.ncols() != argvals.len() {
444            return Err(FdarError::InvalidDimension {
445                parameter: "argvals_list",
446                expected: format!("{} for variable {p}", var.ncols()),
447                actual: format!("{}", argvals.len()),
448            });
449        }
450    }
451
452    // Split
453    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
454
455    let tune_vars: Vec<FdMatrix> = variables
456        .iter()
457        .map(|v| crate::cv::subset_rows(v, &tune_idx))
458        .collect();
459    let cal_vars: Vec<FdMatrix> = variables
460        .iter()
461        .map(|v| crate::cv::subset_rows(v, &cal_idx))
462        .collect();
463
464    let tune_refs: Vec<&FdMatrix> = tune_vars.iter().collect();
465
466    // MFPCA on tuning set
467    let mfpca_config = MfpcaConfig {
468        ncomp: config.ncomp,
469        weighted: true,
470    };
471    let mfpca_result = mfpca(&tune_refs, &mfpca_config)?;
472    let actual_ncomp = mfpca_result.eigenvalues.len();
473
474    // Project calibration set
475    let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
476    let cal_scores = mfpca_result.project(&cal_refs)?;
477
478    // T-squared
479    let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
480
481    // SPE on calibration: need standardized centered and reconstructed
482    let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
483
484    // For SPE, we need standardized centered data and standardized reconstruction
485    let n_cal = cal_vars[0].nrows();
486    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(variables.len());
487    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(variables.len());
488
489    for (p, cal_var) in cal_vars.iter().enumerate() {
490        let m_p = cal_var.ncols();
491        let scale = if mfpca_result.scales[p] > 1e-15 {
492            mfpca_result.scales[p]
493        } else {
494            1.0
495        };
496
497        let mut std_mat = FdMatrix::zeros(n_cal, m_p);
498        let mut recon_mat = FdMatrix::zeros(n_cal, m_p);
499        for i in 0..n_cal {
500            for j in 0..m_p {
501                std_mat[(i, j)] = (cal_var[(i, j)] - mfpca_result.means[p][j]) / scale;
502                recon_mat[(i, j)] = (cal_recon[p][(i, j)] - mfpca_result.means[p][j]) / scale;
503            }
504        }
505        std_vars.push(std_mat);
506        std_recon.push(recon_mat);
507    }
508
509    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
510    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
511    let spe_phase1 = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
512
513    // Control limits
514    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
515    let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
516
517    Ok(MfSpmChart {
518        mfpca: mfpca_result,
519        t2_phase1,
520        spe_phase1,
521        t2_limit,
522        spe_limit,
523        config: config.clone(),
524    })
525}
526
527/// Monitor new multivariate functional data against an established chart.
528///
529/// # Arguments
530/// * `chart` - Phase I multivariate SPM chart
531/// * `new_variables` - Per-variable new data (each n_new x m_p)
532/// * `argvals_list` - Per-variable grid points
533///
534/// # Errors
535///
536/// Returns errors from projection or statistic computation.
537#[must_use = "monitoring result should not be discarded"]
538pub fn mf_spm_monitor(
539    chart: &MfSpmChart,
540    new_variables: &[&FdMatrix],
541    argvals_list: &[&[f64]],
542) -> Result<SpmMonitorResult, FdarError> {
543    let n_vars = chart.mfpca.means.len();
544    if new_variables.len() != n_vars {
545        return Err(FdarError::InvalidDimension {
546            parameter: "new_variables",
547            expected: format!("{n_vars} variables"),
548            actual: format!("{} variables", new_variables.len()),
549        });
550    }
551
552    let actual_ncomp = chart.mfpca.eigenvalues.len();
553
554    // Project
555    let scores = chart.mfpca.project(new_variables)?;
556
557    // T-squared
558    let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
559
560    // SPE
561    let recon = chart.mfpca.reconstruct(&scores, actual_ncomp)?;
562
563    let n_new = new_variables[0].nrows();
564    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(n_vars);
565    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(n_vars);
566
567    for (p, new_var) in new_variables.iter().enumerate() {
568        let m_p = new_var.ncols();
569        let scale = if chart.mfpca.scales[p] > 1e-15 {
570            chart.mfpca.scales[p]
571        } else {
572            1.0
573        };
574
575        let mut std_mat = FdMatrix::zeros(n_new, m_p);
576        let mut recon_mat = FdMatrix::zeros(n_new, m_p);
577        for i in 0..n_new {
578            for j in 0..m_p {
579                std_mat[(i, j)] = (new_var[(i, j)] - chart.mfpca.means[p][j]) / scale;
580                recon_mat[(i, j)] = (recon[p][(i, j)] - chart.mfpca.means[p][j]) / scale;
581            }
582        }
583        std_vars.push(std_mat);
584        std_recon.push(recon_mat);
585    }
586
587    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
588    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
589    let spe = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
590
591    // Alarms
592    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
593    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
594
595    Ok(SpmMonitorResult {
596        t2,
597        spe,
598        t2_alarm,
599        spe_alarm,
600        scores,
601    })
602}
603
604#[cfg(all(test, feature = "serde"))]
605mod tests {
606    use super::*;
607    use crate::simulation::{sim_fundata, EFunType, EValType};
608
609    #[test]
610    fn spm_chart_roundtrip_serde() {
611        let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
612        let data = sim_fundata(
613            40,
614            &t,
615            5,
616            EFunType::Fourier,
617            EValType::Exponential,
618            Some(42),
619        );
620        let config = SpmConfig {
621            ncomp: 3,
622            alpha: 0.05,
623            ..Default::default()
624        };
625        let chart = spm_phase1(&data, &t, &config).unwrap();
626
627        let json = serde_json::to_string(&chart).unwrap();
628        let restored: SpmChart = serde_json::from_str(&json).unwrap();
629
630        // Compare with tolerance for JSON floating-point roundtrip
631        for (a, b) in chart.t2_phase1.iter().zip(&restored.t2_phase1) {
632            assert!((a - b).abs() < 1e-12, "t2_phase1 mismatch: {a} vs {b}");
633        }
634        assert_eq!(chart.t2_limit.ucl, restored.t2_limit.ucl);
635        assert_eq!(chart.spe_limit.ucl, restored.spe_limit.ucl);
636        assert_eq!(chart.config, restored.config);
637        assert_eq!(chart.eigenvalues.len(), restored.eigenvalues.len());
638
639        // Monitor with the restored chart — should produce nearly identical results
640        // (tiny floating-point rounding from JSON roundtrip is expected)
641        let new_data = sim_fundata(
642            10,
643            &t,
644            5,
645            EFunType::Fourier,
646            EValType::Exponential,
647            Some(99),
648        );
649        let r1 = spm_monitor(&chart, &new_data, &t).unwrap();
650        let r2 = spm_monitor(&restored, &new_data, &t).unwrap();
651        for (a, b) in r1.t2.iter().zip(&r2.t2) {
652            assert!((a - b).abs() < 1e-10, "t2 mismatch: {a} vs {b}");
653        }
654        assert_eq!(r1.t2_alarm, r2.t2_alarm);
655        assert_eq!(r1.spe_alarm, r2.spe_alarm);
656    }
657}