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