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
10use crate::error::FdarError;
11use crate::matrix::FdMatrix;
12use crate::regression::{fdata_to_pc_1d, FpcaResult};
13
14use super::control::{spe_control_limit, t2_control_limit, ControlLimit};
15use super::mfpca::{mfpca, MfpcaConfig, MfpcaResult};
16use super::stats::{hotelling_t2, spe_multivariate, spe_univariate};
17
18/// Configuration for SPM chart construction.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SpmConfig {
21    /// Number of principal components (default 5).
22    pub ncomp: usize,
23    /// Significance level for control limits (default 0.05).
24    pub alpha: f64,
25    /// Fraction of data used for tuning/FPCA (default 0.5).
26    pub tuning_fraction: f64,
27    /// Random seed for data splitting (default 42).
28    pub seed: u64,
29}
30
31impl Default for SpmConfig {
32    fn default() -> Self {
33        Self {
34            ncomp: 5,
35            alpha: 0.05,
36            tuning_fraction: 0.5,
37            seed: 42,
38        }
39    }
40}
41
42/// Univariate SPM chart from Phase I.
43#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub struct SpmChart {
46    /// FPCA result from the tuning set.
47    pub fpca: FpcaResult,
48    /// Eigenvalues: sv^2 / (n_tune - 1).
49    pub eigenvalues: Vec<f64>,
50    /// T-squared values for the calibration set.
51    pub t2_phase1: Vec<f64>,
52    /// SPE values for the calibration set.
53    pub spe_phase1: Vec<f64>,
54    /// T-squared control limit.
55    pub t2_limit: ControlLimit,
56    /// SPE control limit.
57    pub spe_limit: ControlLimit,
58    /// Configuration used to build the chart.
59    pub config: SpmConfig,
60}
61
62/// Multivariate SPM chart from Phase I.
63#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct MfSpmChart {
66    /// MFPCA result from the tuning set.
67    pub mfpca: MfpcaResult,
68    /// T-squared values for the calibration set.
69    pub t2_phase1: Vec<f64>,
70    /// SPE values for the calibration set.
71    pub spe_phase1: Vec<f64>,
72    /// T-squared control limit.
73    pub t2_limit: ControlLimit,
74    /// SPE control limit.
75    pub spe_limit: ControlLimit,
76    /// Configuration used to build the chart.
77    pub config: SpmConfig,
78}
79
80/// Result of Phase II monitoring.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct SpmMonitorResult {
84    /// T-squared values for new observations.
85    pub t2: Vec<f64>,
86    /// SPE values for new observations.
87    pub spe: Vec<f64>,
88    /// T-squared alarm flags.
89    pub t2_alarm: Vec<bool>,
90    /// SPE alarm flags.
91    pub spe_alarm: Vec<bool>,
92    /// Score matrix for new observations.
93    pub scores: FdMatrix,
94}
95
96/// Split indices into tuning and calibration sets.
97fn split_indices(n: usize, tuning_fraction: f64, seed: u64) -> (Vec<usize>, Vec<usize>) {
98    let n_tune = ((n as f64 * tuning_fraction).round() as usize)
99        .max(2)
100        .min(n - 1);
101
102    // Generate a deterministic permutation
103    let mut indices: Vec<usize> = (0..n).collect();
104    // Simple LCG-based shuffle
105    let mut rng_state: u64 = seed;
106    for i in (1..n).rev() {
107        rng_state = rng_state
108            .wrapping_mul(6_364_136_223_846_793_005)
109            .wrapping_add(1);
110        let j = (rng_state >> 33) as usize % (i + 1);
111        indices.swap(i, j);
112    }
113
114    let tune_indices: Vec<usize> = indices[..n_tune].to_vec();
115    let cal_indices: Vec<usize> = indices[n_tune..].to_vec();
116    (tune_indices, cal_indices)
117}
118
119/// Compute centered reconstruction (without adding back mean) for SPE.
120///
121/// Returns the centered reconstruction: scores * rotation^T (no mean added).
122fn centered_reconstruct(fpca: &FpcaResult, scores: &FdMatrix, ncomp: usize) -> FdMatrix {
123    let n = scores.nrows();
124    let m = fpca.mean.len();
125    let ncomp = ncomp.min(fpca.rotation.ncols()).min(scores.ncols());
126
127    let mut recon = FdMatrix::zeros(n, m);
128    for i in 0..n {
129        for j in 0..m {
130            let mut val = 0.0;
131            for k in 0..ncomp {
132                val += scores[(i, k)] * fpca.rotation[(j, k)];
133            }
134            recon[(i, j)] = val;
135        }
136    }
137    recon
138}
139
140/// Compute centered data for new observations (data - mean).
141fn center_data(data: &FdMatrix, mean: &[f64]) -> FdMatrix {
142    let (n, m) = data.shape();
143    let mut centered = FdMatrix::zeros(n, m);
144    for i in 0..n {
145        for j in 0..m {
146            centered[(i, j)] = data[(i, j)] - mean[j];
147        }
148    }
149    centered
150}
151
152/// Build a univariate SPM chart from Phase I data.
153///
154/// # Arguments
155/// * `data` - In-control functional data (n x m)
156/// * `argvals` - Grid points (length m)
157/// * `config` - SPM configuration
158///
159/// # Errors
160///
161/// Returns errors from FPCA, T-squared computation, or control limit estimation.
162#[must_use = "expensive computation whose result should not be discarded"]
163pub fn spm_phase1(
164    data: &FdMatrix,
165    argvals: &[f64],
166    config: &SpmConfig,
167) -> Result<SpmChart, FdarError> {
168    let (n, m) = data.shape();
169    if n < 4 {
170        return Err(FdarError::InvalidDimension {
171            parameter: "data",
172            expected: "at least 4 observations for tuning/calibration split".to_string(),
173            actual: format!("{n} observations"),
174        });
175    }
176    if argvals.len() != m {
177        return Err(FdarError::InvalidDimension {
178            parameter: "argvals",
179            expected: format!("{m}"),
180            actual: format!("{}", argvals.len()),
181        });
182    }
183
184    // Split into tuning and calibration
185    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
186
187    let tune_data = crate::cv::subset_rows(data, &tune_idx);
188    let cal_data = crate::cv::subset_rows(data, &cal_idx);
189    let n_tune = tune_data.nrows();
190
191    // FPCA on tuning set
192    let ncomp = config.ncomp.min(n_tune - 1).min(m);
193    let fpca = fdata_to_pc_1d(&tune_data, ncomp)?;
194    let actual_ncomp = fpca.scores.ncols();
195
196    // Eigenvalues
197    let eigenvalues: Vec<f64> = fpca
198        .singular_values
199        .iter()
200        .take(actual_ncomp)
201        .map(|&sv| sv * sv / (n_tune as f64 - 1.0))
202        .collect();
203
204    // Project calibration set
205    let cal_scores = fpca.project(&cal_data)?;
206
207    // T-squared on calibration
208    let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
209
210    // SPE on calibration: need centered and reconstructed
211    let cal_centered = center_data(&cal_data, &fpca.mean);
212    let cal_recon_centered = centered_reconstruct(&fpca, &cal_scores, actual_ncomp);
213    let spe_phase1 = spe_univariate(&cal_centered, &cal_recon_centered, argvals)?;
214
215    // Control limits
216    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
217    let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
218
219    Ok(SpmChart {
220        fpca,
221        eigenvalues,
222        t2_phase1,
223        spe_phase1,
224        t2_limit,
225        spe_limit,
226        config: config.clone(),
227    })
228}
229
230/// Monitor new univariate functional data against an established SPM chart.
231///
232/// # Arguments
233/// * `chart` - Phase I SPM chart
234/// * `new_data` - New functional observations (n_new x m)
235/// * `argvals` - Grid points (length m)
236///
237/// # Errors
238///
239/// Returns errors from projection or statistic computation.
240#[must_use = "monitoring result should not be discarded"]
241pub fn spm_monitor(
242    chart: &SpmChart,
243    new_data: &FdMatrix,
244    argvals: &[f64],
245) -> Result<SpmMonitorResult, FdarError> {
246    let m = chart.fpca.mean.len();
247    if new_data.ncols() != m {
248        return Err(FdarError::InvalidDimension {
249            parameter: "new_data",
250            expected: format!("{m} columns"),
251            actual: format!("{} columns", new_data.ncols()),
252        });
253    }
254
255    let ncomp = chart.eigenvalues.len();
256
257    // Project new data
258    let scores = chart.fpca.project(new_data)?;
259
260    // T-squared
261    let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
262
263    // SPE
264    let centered = center_data(new_data, &chart.fpca.mean);
265    let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
266    let spe = spe_univariate(&centered, &recon_centered, argvals)?;
267
268    // Alarms
269    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
270    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
271
272    Ok(SpmMonitorResult {
273        t2,
274        spe,
275        t2_alarm,
276        spe_alarm,
277        scores,
278    })
279}
280
281/// Build a multivariate SPM chart from Phase I data.
282///
283/// # Arguments
284/// * `variables` - Slice of in-control functional matrices (each n x m_p)
285/// * `argvals_list` - Per-variable grid points
286/// * `config` - SPM configuration
287///
288/// # Errors
289///
290/// Returns errors from MFPCA, T-squared computation, or control limit estimation.
291#[must_use = "expensive computation whose result should not be discarded"]
292pub fn mf_spm_phase1(
293    variables: &[&FdMatrix],
294    argvals_list: &[&[f64]],
295    config: &SpmConfig,
296) -> Result<MfSpmChart, FdarError> {
297    if variables.is_empty() {
298        return Err(FdarError::InvalidDimension {
299            parameter: "variables",
300            expected: "at least 1 variable".to_string(),
301            actual: "0 variables".to_string(),
302        });
303    }
304    if variables.len() != argvals_list.len() {
305        return Err(FdarError::InvalidDimension {
306            parameter: "argvals_list",
307            expected: format!("{} (matching variables)", variables.len()),
308            actual: format!("{}", argvals_list.len()),
309        });
310    }
311
312    let n = variables[0].nrows();
313    if n < 4 {
314        return Err(FdarError::InvalidDimension {
315            parameter: "variables",
316            expected: "at least 4 observations".to_string(),
317            actual: format!("{n} observations"),
318        });
319    }
320
321    // Validate argvals lengths
322    for (p, (var, argvals)) in variables.iter().zip(argvals_list.iter()).enumerate() {
323        if var.ncols() != argvals.len() {
324            return Err(FdarError::InvalidDimension {
325                parameter: "argvals_list",
326                expected: format!("{} for variable {p}", var.ncols()),
327                actual: format!("{}", argvals.len()),
328            });
329        }
330    }
331
332    // Split
333    let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
334
335    let tune_vars: Vec<FdMatrix> = variables
336        .iter()
337        .map(|v| crate::cv::subset_rows(v, &tune_idx))
338        .collect();
339    let cal_vars: Vec<FdMatrix> = variables
340        .iter()
341        .map(|v| crate::cv::subset_rows(v, &cal_idx))
342        .collect();
343
344    let tune_refs: Vec<&FdMatrix> = tune_vars.iter().collect();
345
346    // MFPCA on tuning set
347    let mfpca_config = MfpcaConfig {
348        ncomp: config.ncomp,
349        weighted: true,
350    };
351    let mfpca_result = mfpca(&tune_refs, &mfpca_config)?;
352    let actual_ncomp = mfpca_result.eigenvalues.len();
353
354    // Project calibration set
355    let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
356    let cal_scores = mfpca_result.project(&cal_refs)?;
357
358    // T-squared
359    let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
360
361    // SPE on calibration: need standardized centered and reconstructed
362    let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
363
364    // For SPE, we need standardized centered data and standardized reconstruction
365    let n_cal = cal_vars[0].nrows();
366    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(variables.len());
367    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(variables.len());
368
369    for (p, cal_var) in cal_vars.iter().enumerate() {
370        let m_p = cal_var.ncols();
371        let scale = if mfpca_result.scales[p] > 1e-15 {
372            mfpca_result.scales[p]
373        } else {
374            1.0
375        };
376
377        let mut std_mat = FdMatrix::zeros(n_cal, m_p);
378        let mut recon_mat = FdMatrix::zeros(n_cal, m_p);
379        for i in 0..n_cal {
380            for j in 0..m_p {
381                std_mat[(i, j)] = (cal_var[(i, j)] - mfpca_result.means[p][j]) / scale;
382                recon_mat[(i, j)] = (cal_recon[p][(i, j)] - mfpca_result.means[p][j]) / scale;
383            }
384        }
385        std_vars.push(std_mat);
386        std_recon.push(recon_mat);
387    }
388
389    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
390    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
391    let spe_phase1 = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
392
393    // Control limits
394    let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
395    let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
396
397    Ok(MfSpmChart {
398        mfpca: mfpca_result,
399        t2_phase1,
400        spe_phase1,
401        t2_limit,
402        spe_limit,
403        config: config.clone(),
404    })
405}
406
407/// Monitor new multivariate functional data against an established chart.
408///
409/// # Arguments
410/// * `chart` - Phase I multivariate SPM chart
411/// * `new_variables` - Per-variable new data (each n_new x m_p)
412/// * `argvals_list` - Per-variable grid points
413///
414/// # Errors
415///
416/// Returns errors from projection or statistic computation.
417#[must_use = "monitoring result should not be discarded"]
418pub fn mf_spm_monitor(
419    chart: &MfSpmChart,
420    new_variables: &[&FdMatrix],
421    argvals_list: &[&[f64]],
422) -> Result<SpmMonitorResult, FdarError> {
423    let n_vars = chart.mfpca.means.len();
424    if new_variables.len() != n_vars {
425        return Err(FdarError::InvalidDimension {
426            parameter: "new_variables",
427            expected: format!("{n_vars} variables"),
428            actual: format!("{} variables", new_variables.len()),
429        });
430    }
431
432    let actual_ncomp = chart.mfpca.eigenvalues.len();
433
434    // Project
435    let scores = chart.mfpca.project(new_variables)?;
436
437    // T-squared
438    let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
439
440    // SPE
441    let recon = chart.mfpca.reconstruct(&scores, actual_ncomp)?;
442
443    let n_new = new_variables[0].nrows();
444    let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(n_vars);
445    let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(n_vars);
446
447    for (p, new_var) in new_variables.iter().enumerate() {
448        let m_p = new_var.ncols();
449        let scale = if chart.mfpca.scales[p] > 1e-15 {
450            chart.mfpca.scales[p]
451        } else {
452            1.0
453        };
454
455        let mut std_mat = FdMatrix::zeros(n_new, m_p);
456        let mut recon_mat = FdMatrix::zeros(n_new, m_p);
457        for i in 0..n_new {
458            for j in 0..m_p {
459                std_mat[(i, j)] = (new_var[(i, j)] - chart.mfpca.means[p][j]) / scale;
460                recon_mat[(i, j)] = (recon[p][(i, j)] - chart.mfpca.means[p][j]) / scale;
461            }
462        }
463        std_vars.push(std_mat);
464        std_recon.push(recon_mat);
465    }
466
467    let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
468    let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
469    let spe = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
470
471    // Alarms
472    let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
473    let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
474
475    Ok(SpmMonitorResult {
476        t2,
477        spe,
478        t2_alarm,
479        spe_alarm,
480        scores,
481    })
482}