1use 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#[non_exhaustive]
30#[derive(Debug, Clone, PartialEq)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct SpmConfig {
33 pub ncomp: usize,
39 pub alpha: f64,
41 pub tuning_fraction: f64,
55 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#[derive(Debug, Clone, PartialEq)]
83#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
84#[non_exhaustive]
85pub struct SpmChart {
86 pub fpca: FpcaResult,
88 pub eigenvalues: Vec<f64>,
96 pub t2_phase1: Vec<f64>,
98 pub spe_phase1: Vec<f64>,
100 pub t2_limit: ControlLimit,
102 pub spe_limit: ControlLimit,
104 pub config: SpmConfig,
106 pub sample_size_adequate: bool,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
114#[non_exhaustive]
115pub struct MfSpmChart {
116 pub mfpca: MfpcaResult,
118 pub t2_phase1: Vec<f64>,
120 pub spe_phase1: Vec<f64>,
122 pub t2_limit: ControlLimit,
124 pub spe_limit: ControlLimit,
126 pub config: SpmConfig,
128}
129
130#[derive(Debug, Clone, PartialEq)]
132#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
133#[non_exhaustive]
134pub struct SpmMonitorResult {
135 pub t2: Vec<f64>,
137 pub spe: Vec<f64>,
139 pub t2_alarm: Vec<bool>,
141 pub spe_alarm: Vec<bool>,
143 pub scores: FdMatrix,
145}
146
147pub(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 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
171fn 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
185pub(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
206pub(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#[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 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 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 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 let cal_scores = fpca.project(&cal_data)?;
312
313 let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
315
316 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 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#[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 let scores = chart.fpca.project(new_data)?;
384
385 let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
387
388 let centered = center_data(new_data, &chart.fpca.mean);
390 let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
391 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
392
393 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#[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 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 let t2 = hotelling_t2(&scores, eigenvalues)?;
523
524 let centered = center_data(new_data, fpca_mean);
527 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(¢ered, &recon_centered, argvals)?;
539
540 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#[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 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 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 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 let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
628 let cal_scores = mfpca_result.project(&cal_refs)?;
629
630 let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
632
633 let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
635
636 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 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#[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 let scores = chart.mfpca.project(new_variables)?;
708
709 let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
711
712 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 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 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 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}