use crate::error::FdarError;
use crate::function_on_scalar::{fosr, predict_fosr, FosrResult};
use crate::matrix::FdMatrix;
use crate::regression::{fdata_to_pc_1d, FpcaResult};
use super::control::{spe_control_limit, t2_control_limit, ControlLimit};
use super::phase::{center_data, centered_reconstruct, split_indices};
use super::stats::{hotelling_t2, spe_univariate};
#[derive(Debug, Clone, PartialEq)]
pub struct FrccConfig {
pub ncomp: usize,
pub fosr_lambda: f64,
pub alpha: f64,
pub tuning_fraction: f64,
pub seed: u64,
pub min_r_squared: f64,
}
impl Default for FrccConfig {
fn default() -> Self {
Self {
ncomp: 5,
fosr_lambda: 1e-4,
alpha: 0.05,
tuning_fraction: 0.5,
seed: 42,
min_r_squared: 0.1,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct FrccChart {
pub fosr: FosrResult,
pub residual_fpca: FpcaResult,
pub eigenvalues: Vec<f64>,
pub t2_limit: ControlLimit,
pub spe_limit: ControlLimit,
pub fosr_r_squared: f64,
pub config: FrccConfig,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct FrccMonitorResult {
pub t2: Vec<f64>,
pub spe: Vec<f64>,
pub t2_alarm: Vec<bool>,
pub spe_alarm: Vec<bool>,
pub residual_scores: FdMatrix,
}
fn compute_residuals(observed: &FdMatrix, predicted: &FdMatrix) -> FdMatrix {
let (n, m) = observed.shape();
let mut residuals = FdMatrix::zeros(n, m);
for i in 0..n {
for j in 0..m {
residuals[(i, j)] = observed[(i, j)] - predicted[(i, j)];
}
}
residuals
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn frcc_phase1(
y_curves: &FdMatrix,
predictors: &FdMatrix,
argvals: &[f64],
config: &FrccConfig,
) -> Result<FrccChart, FdarError> {
let (n, m) = y_curves.shape();
let p = predictors.ncols();
if n < 6 {
return Err(FdarError::InvalidDimension {
parameter: "y_curves",
expected: "at least 6 observations".to_string(),
actual: format!("{n} observations"),
});
}
if predictors.nrows() != n {
return Err(FdarError::InvalidDimension {
parameter: "predictors",
expected: format!("{n} rows"),
actual: format!("{} rows", predictors.nrows()),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if config.tuning_fraction <= 0.0 || config.tuning_fraction >= 1.0 {
return Err(FdarError::InvalidParameter {
parameter: "tuning_fraction",
message: format!(
"tuning_fraction must be in (0, 1), got {}",
config.tuning_fraction
),
});
}
let (tune_idx, cal_idx) = split_indices(n, config.tuning_fraction, config.seed);
let tune_y = crate::cv::subset_rows(y_curves, &tune_idx);
let tune_x = crate::cv::subset_rows(predictors, &tune_idx);
let cal_y = crate::cv::subset_rows(y_curves, &cal_idx);
let cal_x = crate::cv::subset_rows(predictors, &cal_idx);
let n_tune = tune_y.nrows();
let n_cal = cal_y.nrows();
if n_cal < 2 {
return Err(FdarError::InvalidDimension {
parameter: "y_curves",
expected: "calibration set with at least 2 observations".to_string(),
actual: format!("{n_cal} observations in calibration set"),
});
}
if n_tune < p + 2 {
return Err(FdarError::InvalidDimension {
parameter: "y_curves",
expected: format!("tuning set with at least {} observations", p + 2),
actual: format!("{n_tune} observations in tuning set"),
});
}
if config.fosr_lambda < 0.0 {
return Err(FdarError::InvalidParameter {
parameter: "fosr_lambda",
message: format!(
"fosr_lambda must be non-negative, got {}",
config.fosr_lambda
),
});
}
let fosr_lambda = config.fosr_lambda;
let fosr_result = fosr(&tune_y, &tune_x, fosr_lambda)?;
let tune_predicted = predict_fosr(&fosr_result, &tune_x);
let fosr_r_squared = {
let (n_t, m_t) = tune_y.shape();
let mut ss_res = 0.0;
let mut ss_tot = 0.0;
for j in 0..m_t {
let col_mean: f64 = (0..n_t).map(|i| tune_y[(i, j)]).sum::<f64>() / n_t as f64;
for i in 0..n_t {
ss_res += (tune_y[(i, j)] - tune_predicted[(i, j)]).powi(2);
ss_tot += (tune_y[(i, j)] - col_mean).powi(2);
}
}
if ss_tot > 0.0 {
1.0 - ss_res / ss_tot
} else {
0.0
}
};
if fosr_r_squared < config.min_r_squared {
return Err(FdarError::ComputationFailed {
operation: "frcc_phase1",
detail: format!(
"FOSR R² = {fosr_r_squared:.4}; below threshold {:.4}. \
Consider: (a) adding more predictors, (b) increasing the training \
set size, (c) reducing fosr_lambda for a less smooth fit, or \
(d) using standard `spm_phase1` instead. Low R² means the \
predictors explain little variance, so covariate adjustment \
provides minimal benefit and may introduce estimation noise.",
config.min_r_squared
),
});
}
let cal_predicted = predict_fosr(&fosr_result, &cal_x);
let cal_residuals = compute_residuals(&cal_y, &cal_predicted);
let ncomp = config.ncomp.min(n_cal - 1).min(m);
let residual_fpca = fdata_to_pc_1d(&cal_residuals, ncomp, argvals)?;
let actual_ncomp = residual_fpca.scores.ncols();
let eigenvalues: Vec<f64> = residual_fpca
.singular_values
.iter()
.take(actual_ncomp)
.map(|&sv| sv * sv / (n_cal as f64 - 1.0))
.collect();
let _t2_cal = hotelling_t2(&residual_fpca.scores, &eigenvalues)?;
let cal_resid_centered = center_data(&cal_residuals, &residual_fpca.mean);
let cal_resid_recon = centered_reconstruct(&residual_fpca, &residual_fpca.scores, actual_ncomp);
let spe_cal = spe_univariate(&cal_resid_centered, &cal_resid_recon, argvals)?;
let t2_limit = t2_control_limit(actual_ncomp, config.alpha)?;
let spe_limit = spe_control_limit(&spe_cal, config.alpha)?;
Ok(FrccChart {
fosr: fosr_result,
residual_fpca,
eigenvalues,
t2_limit,
spe_limit,
fosr_r_squared,
config: config.clone(),
})
}
#[must_use = "monitoring result should not be discarded"]
pub fn frcc_monitor(
chart: &FrccChart,
new_y: &FdMatrix,
new_predictors: &FdMatrix,
argvals: &[f64],
) -> Result<FrccMonitorResult, FdarError> {
let m = chart.residual_fpca.mean.len();
if new_y.ncols() != m {
return Err(FdarError::InvalidDimension {
parameter: "new_y",
expected: format!("{m} columns"),
actual: format!("{} columns", new_y.ncols()),
});
}
if new_y.nrows() != new_predictors.nrows() {
return Err(FdarError::InvalidDimension {
parameter: "new_predictors",
expected: format!("{} rows", new_y.nrows()),
actual: format!("{} rows", new_predictors.nrows()),
});
}
let expected_p = chart.fosr.beta.nrows();
if new_predictors.ncols() != expected_p {
return Err(FdarError::InvalidDimension {
parameter: "new_predictors",
expected: format!("{expected_p} columns (predictors)"),
actual: format!("{} columns", new_predictors.ncols()),
});
}
let ncomp = chart.eigenvalues.len();
let predicted = predict_fosr(&chart.fosr, new_predictors);
let residuals = compute_residuals(new_y, &predicted);
let residual_scores = chart.residual_fpca.project(&residuals)?;
let t2 = hotelling_t2(&residual_scores, &chart.eigenvalues)?;
let resid_centered = center_data(&residuals, &chart.residual_fpca.mean);
let resid_recon = centered_reconstruct(&chart.residual_fpca, &residual_scores, ncomp);
let spe = spe_univariate(&resid_centered, &resid_recon, argvals)?;
let t2_alarm: Vec<bool> = t2.iter().map(|&v| v > chart.t2_limit.ucl).collect();
let spe_alarm: Vec<bool> = spe.iter().map(|&v| v > chart.spe_limit.ucl).collect();
Ok(FrccMonitorResult {
t2,
spe,
t2_alarm,
spe_alarm,
residual_scores,
})
}