1use 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#[derive(Debug, Clone, PartialEq)]
20pub struct SpmConfig {
21 pub ncomp: usize,
23 pub alpha: f64,
25 pub tuning_fraction: f64,
27 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#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub struct SpmChart {
46 pub fpca: FpcaResult,
48 pub eigenvalues: Vec<f64>,
50 pub t2_phase1: Vec<f64>,
52 pub spe_phase1: Vec<f64>,
54 pub t2_limit: ControlLimit,
56 pub spe_limit: ControlLimit,
58 pub config: SpmConfig,
60}
61
62#[derive(Debug, Clone, PartialEq)]
64#[non_exhaustive]
65pub struct MfSpmChart {
66 pub mfpca: MfpcaResult,
68 pub t2_phase1: Vec<f64>,
70 pub spe_phase1: Vec<f64>,
72 pub t2_limit: ControlLimit,
74 pub spe_limit: ControlLimit,
76 pub config: SpmConfig,
78}
79
80#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct SpmMonitorResult {
84 pub t2: Vec<f64>,
86 pub spe: Vec<f64>,
88 pub t2_alarm: Vec<bool>,
90 pub spe_alarm: Vec<bool>,
92 pub scores: FdMatrix,
94}
95
96fn 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 let mut indices: Vec<usize> = (0..n).collect();
104 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
119fn 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
140fn 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#[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 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 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 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 let cal_scores = fpca.project(&cal_data)?;
206
207 let t2_phase1 = hotelling_t2(&cal_scores, &eigenvalues)?;
209
210 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 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#[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 let scores = chart.fpca.project(new_data)?;
259
260 let t2 = hotelling_t2(&scores, &chart.eigenvalues)?;
262
263 let centered = center_data(new_data, &chart.fpca.mean);
265 let recon_centered = centered_reconstruct(&chart.fpca, &scores, ncomp);
266 let spe = spe_univariate(¢ered, &recon_centered, argvals)?;
267
268 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#[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 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 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 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 let cal_refs: Vec<&FdMatrix> = cal_vars.iter().collect();
356 let cal_scores = mfpca_result.project(&cal_refs)?;
357
358 let t2_phase1 = hotelling_t2(&cal_scores, &mfpca_result.eigenvalues)?;
360
361 let cal_recon = mfpca_result.reconstruct(&cal_scores, actual_ncomp)?;
363
364 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 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#[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 let scores = chart.mfpca.project(new_variables)?;
436
437 let t2 = hotelling_t2(&scores, &chart.mfpca.eigenvalues)?;
439
440 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 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}