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/// Phase II monitoring from decomposed fields (no [`SpmChart`] struct needed).
402///
403/// This enables monitoring from a serialized/restored `SpmChartLayer`
404/// without reconstructing the full `SpmChart` struct.  The caller provides
405/// the raw FPCA components and control limits directly.
406///
407/// # Arguments
408/// * `fpca_mean` -- Mean function from FPCA (length m)
409/// * `fpca_rotation` -- Eigenfunctions / rotation matrix (m x ncomp)
410/// * `fpca_weights` -- Integration weights (length m)
411/// * `eigenvalues` -- Eigenvalues (length ncomp)
412/// * `t2_ucl` -- Upper control limit for T²
413/// * `spe_ucl` -- Upper control limit for SPE
414/// * `new_data` -- New functional observations (n_new x m)
415/// * `argvals` -- Grid points (length m)
416///
417/// # Errors
418///
419/// Returns [`FdarError::InvalidDimension`] if any dimension is inconsistent.
420/// Returns [`FdarError::InvalidParameter`] if any eigenvalue is non-positive.
421///
422/// # Example
423/// ```
424/// use fdars_core::matrix::FdMatrix;
425/// use fdars_core::spm::phase::{spm_phase1, spm_monitor, spm_monitor_from_fields, SpmConfig};
426///
427/// let data = FdMatrix::from_column_major(
428///     (0..200).map(|i| (i as f64 * 0.1).sin()).collect(), 20, 10
429/// ).unwrap();
430/// let argvals: Vec<f64> = (0..10).map(|i| i as f64 / 9.0).collect();
431/// let config = SpmConfig { ncomp: 2, ..SpmConfig::default() };
432/// let chart = spm_phase1(&data, &argvals, &config).unwrap();
433///
434/// let new_data = FdMatrix::from_column_major(
435///     (0..50).map(|i| (i as f64 * 0.1).sin()).collect(), 5, 10
436/// ).unwrap();
437///
438/// // Equivalent to spm_monitor(&chart, &new_data, &argvals)
439/// let result = spm_monitor_from_fields(
440///     &chart.fpca.mean,
441///     &chart.fpca.rotation,
442///     &chart.fpca.weights,
443///     &chart.eigenvalues,
444///     chart.t2_limit.ucl,
445///     chart.spe_limit.ucl,
446///     &new_data,
447///     &argvals,
448/// ).unwrap();
449/// assert_eq!(result.t2.len(), 5);
450/// ```
451#[must_use = "monitoring result should not be discarded"]
452pub fn spm_monitor_from_fields(
453    fpca_mean: &[f64],
454    fpca_rotation: &FdMatrix,
455    fpca_weights: &[f64],
456    eigenvalues: &[f64],
457    t2_ucl: f64,
458    spe_ucl: f64,
459    new_data: &FdMatrix,
460    argvals: &[f64],
461) -> Result<SpmMonitorResult, FdarError> {
462    let m = fpca_mean.len();
463    let ncomp = eigenvalues.len();
464
465    if new_data.ncols() != m {
466        return Err(FdarError::InvalidDimension {
467            parameter: "new_data",
468            expected: format!("{m} columns"),
469            actual: format!("{} columns", new_data.ncols()),
470        });
471    }
472    if fpca_rotation.nrows() != m {
473        return Err(FdarError::InvalidDimension {
474            parameter: "fpca_rotation",
475            expected: format!("{m} rows (matching fpca_mean)"),
476            actual: format!("{} rows", fpca_rotation.nrows()),
477        });
478    }
479    if fpca_rotation.ncols() != ncomp {
480        return Err(FdarError::InvalidDimension {
481            parameter: "fpca_rotation",
482            expected: format!("{ncomp} columns (matching eigenvalues)"),
483            actual: format!("{} columns", fpca_rotation.ncols()),
484        });
485    }
486    if fpca_weights.len() != m {
487        return Err(FdarError::InvalidDimension {
488            parameter: "fpca_weights",
489            expected: format!("{m} (matching fpca_mean)"),
490            actual: format!("{}", fpca_weights.len()),
491        });
492    }
493    if argvals.len() != m {
494        return Err(FdarError::InvalidDimension {
495            parameter: "argvals",
496            expected: format!("{m} (matching fpca_mean)"),
497            actual: format!("{}", argvals.len()),
498        });
499    }
500
501    let n_new = new_data.nrows();
502
503    // 1. Project new data onto FPC scores (center + weight + rotate)
504    let mut scores = FdMatrix::zeros(n_new, ncomp);
505    for i in 0..n_new {
506        for k in 0..ncomp {
507            let mut sum = 0.0;
508            for j in 0..m {
509                sum += (new_data[(i, j)] - fpca_mean[j]) * fpca_rotation[(j, k)] * fpca_weights[j];
510            }
511            scores[(i, k)] = sum;
512        }
513    }
514
515    // 2. Compute T² from scores / eigenvalues
516    let t2 = hotelling_t2(&scores, eigenvalues)?;
517
518    // 3. Reconstruct (centered) and compute SPE
519    //    centered = new_data - mean
520    let centered = center_data(new_data, fpca_mean);
521    //    centered_reconstruction = scores * rotation^T
522    let mut recon_centered = FdMatrix::zeros(n_new, m);
523    for i in 0..n_new {
524        for j in 0..m {
525            let mut val = 0.0;
526            for k in 0..ncomp {
527                val += scores[(i, k)] * fpca_rotation[(j, k)];
528            }
529            recon_centered[(i, j)] = val;
530        }
531    }
532    let spe = spe_univariate(&centered, &recon_centered, argvals)?;
533
534    // 4. Flag alarms
535    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > t2_ucl).collect();
536    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > spe_ucl).collect();
537
538    Ok(SpmMonitorResult {
539        t2,
540        spe,
541        t2_alarm,
542        spe_alarm,
543        scores,
544    })
545}
546
547/// Build a multivariate SPM chart from Phase I data.
548///
549/// # Arguments
550/// * `variables` - Slice of in-control functional matrices (each n x m_p)
551/// * `argvals_list` - Per-variable grid points
552/// * `config` - SPM configuration
553///
554/// # Errors
555///
556/// Returns errors from MFPCA, T-squared computation, or control limit estimation.
557#[must_use = "expensive computation whose result should not be discarded"]
558pub fn mf_spm_phase1(
559    variables: &[&FdMatrix],
560    argvals_list: &[&[f64]],
561    config: &SpmConfig,
562) -> Result<MfSpmChart, FdarError> {
563    if variables.is_empty() {
564        return Err(FdarError::InvalidDimension {
565            parameter: "variables",
566            expected: "at least 1 variable".to_string(),
567            actual: "0 variables".to_string(),
568        });
569    }
570    if variables.len() != argvals_list.len() {
571        return Err(FdarError::InvalidDimension {
572            parameter: "argvals_list",
573            expected: format!("{} (matching variables)", variables.len()),
574            actual: format!("{}", argvals_list.len()),
575        });
576    }
577
578    let n = variables[0].nrows();
579    if n < 4 {
580        return Err(FdarError::InvalidDimension {
581            parameter: "variables",
582            expected: "at least 4 observations".to_string(),
583            actual: format!("{n} observations"),
584        });
585    }
586
587    // Validate argvals lengths
588    for (p, (var, argvals)) in variables.iter().zip(argvals_list.iter()).enumerate() {
589        if var.ncols() != argvals.len() {
590            return Err(FdarError::InvalidDimension {
591                parameter: "argvals_list",
592                expected: format!("{} for variable {p}", var.ncols()),
593                actual: format!("{}", argvals.len()),
594            });
595        }
596    }
597
598    // Split
599    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
600
601    let tune_vars: Vec<FdMatrix> = variables
602        .iter()
603        .map(|v| crate::cv::subset_rows(v, &tune_idx))
604        .collect();
605    let cal_vars: Vec<FdMatrix> = variables
606        .iter()
607        .map(|v| crate::cv::subset_rows(v, &cal_idx))
608        .collect();
609
610    let tune_refs: Vec<&FdMatrix> = tune_vars.iter().collect();
611
612    // MFPCA on tuning set
613    let mfpca_config = MfpcaConfig {
614        ncomp: config.ncomp,
615        weighted: true,
616    };
617    let mfpca_result = mfpca(&tune_refs, &mfpca_config)?;
618    let actual_ncomp = mfpca_result.eigenvalues.len();
619
620    // Project calibration set
621    let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
622    let cal_scores = mfpca_result.project(&cal_refs)?;
623
624    // T-squared
625    let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
626
627    // SPE on calibration: need standardized centered and reconstructed
628    let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
629
630    // For SPE, we need standardized centered data and standardized reconstruction
631    let n_cal = cal_vars[0].nrows();
632    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(variables.len());
633    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(variables.len());
634
635    for (p, cal_var) in cal_vars.iter().enumerate() {
636        let m_p = cal_var.ncols();
637        let scale = if mfpca_result.scales[p] > 1e-15 {
638            mfpca_result.scales[p]
639        } else {
640            1.0
641        };
642
643        let mut std_mat = FdMatrix::zeros(n_cal, m_p);
644        let mut recon_mat = FdMatrix::zeros(n_cal, m_p);
645        for i in 0..n_cal {
646            for j in 0..m_p {
647                std_mat[(i, j)] = (cal_var[(i, j)] - mfpca_result.means[p][j]) / scale;
648                recon_mat[(i, j)] = (cal_recon[p][(i, j)] - mfpca_result.means[p][j]) / scale;
649            }
650        }
651        std_vars.push(std_mat);
652        std_recon.push(recon_mat);
653    }
654
655    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
656    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
657    let spe_phase1 = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
658
659    // Control limits
660    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
661    let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
662
663    Ok(MfSpmChart {
664        mfpca: mfpca_result,
665        t2_phase1,
666        spe_phase1,
667        t2_limit,
668        spe_limit,
669        config: config.clone(),
670    })
671}
672
673/// Monitor new multivariate functional data against an established chart.
674///
675/// # Arguments
676/// * `chart` - Phase I multivariate SPM chart
677/// * `new_variables` - Per-variable new data (each n_new x m_p)
678/// * `argvals_list` - Per-variable grid points
679///
680/// # Errors
681///
682/// Returns errors from projection or statistic computation.
683#[must_use = "monitoring result should not be discarded"]
684pub fn mf_spm_monitor(
685    chart: &MfSpmChart,
686    new_variables: &[&FdMatrix],
687    argvals_list: &[&[f64]],
688) -> Result<SpmMonitorResult, FdarError> {
689    let n_vars = chart.mfpca.means.len();
690    if new_variables.len() != n_vars {
691        return Err(FdarError::InvalidDimension {
692            parameter: "new_variables",
693            expected: format!("{n_vars} variables"),
694            actual: format!("{} variables", new_variables.len()),
695        });
696    }
697
698    let actual_ncomp = chart.mfpca.eigenvalues.len();
699
700    // Project
701    let scores = chart.mfpca.project(new_variables)?;
702
703    // T-squared
704    let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
705
706    // SPE
707    let recon = chart.mfpca.reconstruct(&scores, actual_ncomp)?;
708
709    let n_new = new_variables[0].nrows();
710    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(n_vars);
711    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(n_vars);
712
713    for (p, new_var) in new_variables.iter().enumerate() {
714        let m_p = new_var.ncols();
715        let scale = if chart.mfpca.scales[p] > 1e-15 {
716            chart.mfpca.scales[p]
717        } else {
718            1.0
719        };
720
721        let mut std_mat = FdMatrix::zeros(n_new, m_p);
722        let mut recon_mat = FdMatrix::zeros(n_new, m_p);
723        for i in 0..n_new {
724            for j in 0..m_p {
725                std_mat[(i, j)] = (new_var[(i, j)] - chart.mfpca.means[p][j]) / scale;
726                recon_mat[(i, j)] = (recon[p][(i, j)] - chart.mfpca.means[p][j]) / scale;
727            }
728        }
729        std_vars.push(std_mat);
730        std_recon.push(recon_mat);
731    }
732
733    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
734    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
735    let spe = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
736
737    // Alarms
738    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
739    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
740
741    Ok(SpmMonitorResult {
742        t2,
743        spe,
744        t2_alarm,
745        spe_alarm,
746        scores,
747    })
748}
749
750#[cfg(all(test, feature = "serde"))]
751mod tests {
752    use super::*;
753    use crate::simulation::{sim_fundata, EFunType, EValType};
754
755    #[test]
756    fn spm_chart_roundtrip_serde() {
757        let t: Vec<f64> = (0..50).map(|i| i as f64 / 49.0).collect();
758        let data = sim_fundata(
759            40,
760            &t,
761            5,
762            EFunType::Fourier,
763            EValType::Exponential,
764            Some(42),
765        );
766        let config = SpmConfig {
767            ncomp: 3,
768            alpha: 0.05,
769            ..Default::default()
770        };
771        let chart = spm_phase1(&data, &t, &config).unwrap();
772
773        let json = serde_json::to_string(&chart).unwrap();
774        let restored: SpmChart = serde_json::from_str(&json).unwrap();
775
776        // Compare with tolerance for JSON floating-point roundtrip
777        for (a, b) in chart.t2_phase1.iter().zip(&restored.t2_phase1) {
778            assert!((a - b).abs() < 1e-12, "t2_phase1 mismatch: {a} vs {b}");
779        }
780        assert_eq!(chart.t2_limit.ucl, restored.t2_limit.ucl);
781        assert_eq!(chart.spe_limit.ucl, restored.spe_limit.ucl);
782        assert_eq!(chart.config, restored.config);
783        assert_eq!(chart.eigenvalues.len(), restored.eigenvalues.len());
784
785        // Monitor with the restored chart — should produce nearly identical results
786        // (tiny floating-point rounding from JSON roundtrip is expected)
787        let new_data = sim_fundata(
788            10,
789            &t,
790            5,
791            EFunType::Fourier,
792            EValType::Exponential,
793            Some(99),
794        );
795        let r1 = spm_monitor(&chart, &new_data, &t).unwrap();
796        let r2 = spm_monitor(&restored, &new_data, &t).unwrap();
797        for (a, b) in r1.t2.iter().zip(&r2.t2) {
798            assert!((a - b).abs() < 1e-10, "t2 mismatch: {a} vs {b}");
799        }
800        assert_eq!(r1.t2_alarm, r2.t2_alarm);
801        assert_eq!(r1.spe_alarm, r2.spe_alarm);
802    }
803}