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)]
28pub struct SpmConfig {
29 pub ncomp: usize,
35 pub alpha: f64,
37 pub tuning_fraction: f64,
51 pub seed: u64,
53}
54
55impl Default for SpmConfig {
56 fn default() -> Self {
57 Self {
58 ncomp: 5,
59 alpha: 0.05,
60 tuning_fraction: 0.5,
61 seed: 42,
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
79#[non_exhaustive]
80pub struct SpmChart {
81 pub fpca: FpcaResult,
83 pub eigenvalues: Vec<f64>,
91 pub t2_phase1: Vec<f64>,
93 pub spe_phase1: Vec<f64>,
95 pub t2_limit: ControlLimit,
97 pub spe_limit: ControlLimit,
99 pub config: SpmConfig,
101 pub sample_size_adequate: bool,
104}
105
106#[derive(Debug, Clone, PartialEq)]
108#[non_exhaustive]
109pub struct MfSpmChart {
110 pub mfpca: MfpcaResult,
112 pub t2_phase1: Vec<f64>,
114 pub spe_phase1: Vec<f64>,
116 pub t2_limit: ControlLimit,
118 pub spe_limit: ControlLimit,
120 pub config: SpmConfig,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126#[non_exhaustive]
127pub struct SpmMonitorResult {
128 pub t2: Vec<f64>,
130 pub spe: Vec<f64>,
132 pub t2_alarm: Vec<bool>,
134 pub spe_alarm: Vec<bool>,
136 pub scores: FdMatrix,
138}
139
140pub(super) fn split_indices(n: usize, tuning_fraction: f64, seed: u64) -> (Vec<usize>, Vec<usize>) {
147 let n_tune = ((n as f64 * tuning_fraction).round() as usize)
148 .max(2)
149 .min(n - 1);
150
151 let mut indices: Vec<usize> = (0..n).collect();
153 let mut rng_state: u64 = seed;
154 for i in (1..n).rev() {
155 let j = pcg_next(&mut rng_state) as usize % (i + 1);
156 indices.swap(i, j);
157 }
158
159 let tune_indices: Vec<usize> = indices[..n_tune].to_vec();
160 let cal_indices: Vec<usize> = indices[n_tune..].to_vec();
161 (tune_indices, cal_indices)
162}
163
164fn pcg_next(state: &mut u64) -> u32 {
171 let old = *state;
172 *state = old.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
173 let xorshifted = (((old >> 18) ^ old) >> 27) as u32;
174 let rot = (old >> 59) as u32;
175 xorshifted.rotate_right(rot)
176}
177
178pub(super) fn centered_reconstruct(fpca: &FpcaResult, scores: &FdMatrix, ncomp: usize) -> FdMatrix {
182 let n = scores.nrows();
183 let m = fpca.mean.len();
184 let ncomp = ncomp.min(fpca.rotation.ncols()).min(scores.ncols());
185
186 let mut recon = FdMatrix::zeros(n, m);
187 for i in 0..n {
188 for j in 0..m {
189 let mut val = 0.0;
190 for k in 0..ncomp {
191 val += scores[(i, k)] * fpca.rotation[(j, k)];
192 }
193 recon[(i, j)] = val;
194 }
195 }
196 recon
197}
198
199pub(super) fn center_data(data: &FdMatrix, mean: &[f64]) -> FdMatrix {
201 let (n, m) = data.shape();
202 let mut centered = FdMatrix::zeros(n, m);
203 for i in 0..n {
204 for j in 0..m {
205 centered[(i, j)] = data[(i, j)] - mean[j];
206 }
207 }
208 centered
209}
210
211#[must_use = "expensive computation whose result should not be discarded"]
245pub fn spm_phase1(
246 data: &FdMatrix,
247 argvals: &[f64],
248 config: &SpmConfig,
249) -> Result<SpmChart, FdarError> {
250 let (n, m) = data.shape();
251 if n < 4 {
252 return Err(FdarError::InvalidDimension {
253 parameter: "data",
254 expected: "at least 4 observations for tuning/calibration split".to_string(),
255 actual: format!("{n} observations"),
256 });
257 }
258 let sample_size_adequate = n >= 10 * config.ncomp;
259 if argvals.len() != m {
260 return Err(FdarError::InvalidDimension {
261 parameter: "argvals",
262 expected: format!("{m}"),
263 actual: format!("{}", argvals.len()),
264 });
265 }
266
267 let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
269
270 let tune_data = crate::cv::subset_rows(data, &tune_idx);
271 let cal_data = crate::cv::subset_rows(data, &cal_idx);
272 let n_tune = tune_data.nrows();
273 if n_tune < 3 {
274 return Err(FdarError::InvalidDimension {
275 parameter: "data",
276 expected: "tuning set with at least 3 observations".to_string(),
277 actual: format!(
278 "{n_tune} observations in tuning set (increase data size or tuning_fraction)"
279 ),
280 });
281 }
282
283 let ncomp = config.ncomp.min(n_tune - 1).min(m);
288 let fpca = fdata_to_pc_1d(&tune_data, ncomp)?;
289 let actual_ncomp = fpca.scores.ncols();
290
291 let eigenvalues: Vec<f64> = fpca
296 .singular_values
297 .iter()
298 .take(actual_ncomp)
299 .map(|&sv| sv * sv / (n_tune as f64 - 1.0))
300 .collect();
301
302 let cal_scores = fpca.project(&cal_data)?;
304
305 let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
307
308 let cal_centered = center_data(&cal_data, &fpca.mean);
310 let cal_recon_centered = centered_reconstruct(&fpca, &cal_scores, actual_ncomp);
311 let spe_phase1 = spe_univariate(&cal_centered, &cal_recon_centered, argvals)?;
312
313 let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
315 let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
316
317 Ok(SpmChart {
318 fpca,
319 eigenvalues,
320 t2_phase1,
321 spe_phase1,
322 t2_limit,
323 spe_limit,
324 config: config.clone(),
325 sample_size_adequate,
326 })
327}
328
329#[must_use = "monitoring result should not be discarded"]
357pub fn spm_monitor(
358 chart: &SpmChart,
359 new_data: &FdMatrix,
360 argvals: &[f64],
361) -> Result<SpmMonitorResult, FdarError> {
362 let m = chart.fpca.mean.len();
363 if new_data.ncols() != m {
364 return Err(FdarError::InvalidDimension {
365 parameter: "new_data",
366 expected: format!("{m} columns"),
367 actual: format!("{} columns", new_data.ncols()),
368 });
369 }
370
371 let ncomp = chart.eigenvalues.len();
372
373 let scores = chart.fpca.project(new_data)?;
375
376 let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
378
379 let centered = center_data(new_data, &chart.fpca.mean);
381 let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
382 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
383
384 let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
386 let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
387
388 Ok(SpmMonitorResult {
389 t2,
390 spe,
391 t2_alarm,
392 spe_alarm,
393 scores,
394 })
395}
396
397#[must_use = "expensive computation whose result should not be discarded"]
408pub fn mf_spm_phase1(
409 variables: &[&FdMatrix],
410 argvals_list: &[&[f64]],
411 config: &SpmConfig,
412) -> Result<MfSpmChart, FdarError> {
413 if variables.is_empty() {
414 return Err(FdarError::InvalidDimension {
415 parameter: "variables",
416 expected: "at least 1 variable".to_string(),
417 actual: "0 variables".to_string(),
418 });
419 }
420 if variables.len() != argvals_list.len() {
421 return Err(FdarError::InvalidDimension {
422 parameter: "argvals_list",
423 expected: format!("{} (matching variables)", variables.len()),
424 actual: format!("{}", argvals_list.len()),
425 });
426 }
427
428 let n = variables[0].nrows();
429 if n < 4 {
430 return Err(FdarError::InvalidDimension {
431 parameter: "variables",
432 expected: "at least 4 observations".to_string(),
433 actual: format!("{n} observations"),
434 });
435 }
436
437 for (p, (var, argvals)) in variables.iter().zip(argvals_list.iter()).enumerate() {
439 if var.ncols() != argvals.len() {
440 return Err(FdarError::InvalidDimension {
441 parameter: "argvals_list",
442 expected: format!("{} for variable {p}", var.ncols()),
443 actual: format!("{}", argvals.len()),
444 });
445 }
446 }
447
448 let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
450
451 let tune_vars: Vec<FdMatrix> = variables
452 .iter()
453 .map(|v| crate::cv::subset_rows(v, &tune_idx))
454 .collect();
455 let cal_vars: Vec<FdMatrix> = variables
456 .iter()
457 .map(|v| crate::cv::subset_rows(v, &cal_idx))
458 .collect();
459
460 let tune_refs: Vec<&FdMatrix> = tune_vars.iter().collect();
461
462 let mfpca_config = MfpcaConfig {
464 ncomp: config.ncomp,
465 weighted: true,
466 };
467 let mfpca_result = mfpca(&tune_refs, &mfpca_config)?;
468 let actual_ncomp = mfpca_result.eigenvalues.len();
469
470 let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
472 let cal_scores = mfpca_result.project(&cal_refs)?;
473
474 let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
476
477 let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
479
480 let n_cal = cal_vars[0].nrows();
482 let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(variables.len());
483 let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(variables.len());
484
485 for (p, cal_var) in cal_vars.iter().enumerate() {
486 let m_p = cal_var.ncols();
487 let scale = if mfpca_result.scales[p] > 1e-15 {
488 mfpca_result.scales[p]
489 } else {
490 1.0
491 };
492
493 let mut std_mat = FdMatrix::zeros(n_cal, m_p);
494 let mut recon_mat = FdMatrix::zeros(n_cal, m_p);
495 for i in 0..n_cal {
496 for j in 0..m_p {
497 std_mat[(i, j)] = (cal_var[(i, j)] - mfpca_result.means[p][j]) / scale;
498 recon_mat[(i, j)] = (cal_recon[p][(i, j)] - mfpca_result.means[p][j]) / scale;
499 }
500 }
501 std_vars.push(std_mat);
502 std_recon.push(recon_mat);
503 }
504
505 let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
506 let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
507 let spe_phase1 = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
508
509 let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
511 let spe_limit = spe_control_limit(&spe_phase1, config.alpha)?;
512
513 Ok(MfSpmChart {
514 mfpca: mfpca_result,
515 t2_phase1,
516 spe_phase1,
517 t2_limit,
518 spe_limit,
519 config: config.clone(),
520 })
521}
522
523#[must_use = "monitoring result should not be discarded"]
534pub fn mf_spm_monitor(
535 chart: &MfSpmChart,
536 new_variables: &[&FdMatrix],
537 argvals_list: &[&[f64]],
538) -> Result<SpmMonitorResult, FdarError> {
539 let n_vars = chart.mfpca.means.len();
540 if new_variables.len() != n_vars {
541 return Err(FdarError::InvalidDimension {
542 parameter: "new_variables",
543 expected: format!("{n_vars} variables"),
544 actual: format!("{} variables", new_variables.len()),
545 });
546 }
547
548 let actual_ncomp = chart.mfpca.eigenvalues.len();
549
550 let scores = chart.mfpca.project(new_variables)?;
552
553 let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
555
556 let recon = chart.mfpca.reconstruct(&scores, actual_ncomp)?;
558
559 let n_new = new_variables[0].nrows();
560 let mut std_vars: Vec<FdMatrix> = Vec::with_capacity(n_vars);
561 let mut std_recon: Vec<FdMatrix> = Vec::with_capacity(n_vars);
562
563 for (p, new_var) in new_variables.iter().enumerate() {
564 let m_p = new_var.ncols();
565 let scale = if chart.mfpca.scales[p] > 1e-15 {
566 chart.mfpca.scales[p]
567 } else {
568 1.0
569 };
570
571 let mut std_mat = FdMatrix::zeros(n_new, m_p);
572 let mut recon_mat = FdMatrix::zeros(n_new, m_p);
573 for i in 0..n_new {
574 for j in 0..m_p {
575 std_mat[(i, j)] = (new_var[(i, j)] - chart.mfpca.means[p][j]) / scale;
576 recon_mat[(i, j)] = (recon[p][(i, j)] - chart.mfpca.means[p][j]) / scale;
577 }
578 }
579 std_vars.push(std_mat);
580 std_recon.push(recon_mat);
581 }
582
583 let std_refs: Vec<&FdMatrix> = std_vars.iter().collect();
584 let recon_refs: Vec<&FdMatrix> = std_recon.iter().collect();
585 let spe = spe_multivariate(&std_refs, &recon_refs, argvals_list)?;
586
587 let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
589 let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
590
591 Ok(SpmMonitorResult {
592 t2,
593 spe,
594 t2_alarm,
595 spe_alarm,
596 scores,
597 })
598}