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 = "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 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 let t2 = hotelling_t2(&scores, eigenvalues)?;
517
518 let centered = center_data(new_data, fpca_mean);
521 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(¢ered, &recon_centered, argvals)?;
533
534 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#[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 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 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 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 let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
622 let cal_scores = mfpca_result.project(&cal_refs)?;
623
624 let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
626
627 let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
629
630 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 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#[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 let scores = chart.mfpca.project(new_variables)?;
702
703 let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
705
706 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 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 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 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}