1use 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#[derive(Debug, Clone, PartialEq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub struct SpmConfig {
30 pub ncomp: usize,
36 pub alpha: f64,
38 pub tuning_fraction: f64,
52 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#[derive(Debug, Clone, PartialEq)]
80#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
81#[non_exhaustive]
82pub struct SpmChart {
83 pub fpca: FpcaResult,
85 pub eigenvalues: Vec<f64>,
93 pub t2_phase1: Vec<f64>,
95 pub spe_phase1: Vec<f64>,
97 pub t2_limit: ControlLimit,
99 pub spe_limit: ControlLimit,
101 pub config: SpmConfig,
103 pub sample_size_adequate: bool,
106}
107
108#[derive(Debug, Clone, PartialEq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
111#[non_exhaustive]
112pub struct MfSpmChart {
113 pub mfpca: MfpcaResult,
115 pub t2_phase1: Vec<f64>,
117 pub spe_phase1: Vec<f64>,
119 pub t2_limit: ControlLimit,
121 pub spe_limit: ControlLimit,
123 pub config: SpmConfig,
125}
126
127#[derive(Debug, Clone, PartialEq)]
129#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
130#[non_exhaustive]
131pub struct SpmMonitorResult {
132 pub t2: Vec<f64>,
134 pub spe: Vec<f64>,
136 pub t2_alarm: Vec<bool>,
138 pub spe_alarm: Vec<bool>,
140 pub scores: FdMatrix,
142}
143
144pub(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 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
168fn 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
182pub(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
203pub(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#[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 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 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 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 let cal_scores = fpca.project(&cal_data)?;
308
309 let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
311
312 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 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#[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 let scores = chart.fpca.project(new_data)?;
379
380 let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
382
383 let centered = center_data(new_data, &chart.fpca.mean);
385 let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
386 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
387
388 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#[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 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 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 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 let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
476 let cal_scores = mfpca_result.project(&cal_refs)?;
477
478 let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
480
481 let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
483
484 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 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#[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 let scores = chart.mfpca.project(new_variables)?;
556
557 let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
559
560 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 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 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 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}