use crate::error::FdarError;
use crate::helpers::simpsons_weights;
use crate::matrix::FdMatrix;
use super::phase::SpmChart;
use super::stats::hotelling_t2;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum DomainCompletion {
PartialProjection,
ConditionalExpectation,
ZeroPad,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PartialDomainConfig {
pub ncomp: usize,
pub alpha: f64,
pub completion: DomainCompletion,
pub regularization_eps: f64,
}
impl Default for PartialDomainConfig {
fn default() -> Self {
Self {
ncomp: 5,
alpha: 0.05,
completion: DomainCompletion::ConditionalExpectation,
regularization_eps: 1e-10,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct PartialMonitorResult {
pub scores: Vec<f64>,
pub t2: f64,
pub t2_alarm: bool,
pub domain_fraction: f64,
pub completed_curve: Option<Vec<f64>>,
}
#[must_use = "monitoring result should not be discarded"]
pub fn spm_monitor_partial(
chart: &SpmChart,
partial_values: &[f64],
argvals: &[f64],
n_observed: usize,
config: &PartialDomainConfig,
) -> Result<PartialMonitorResult, FdarError> {
let m = chart.fpca.mean.len();
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m}"),
actual: format!("{}", argvals.len()),
});
}
if partial_values.len() < n_observed {
return Err(FdarError::InvalidDimension {
parameter: "partial_values",
expected: format!("at least {n_observed} values"),
actual: format!("{} values", partial_values.len()),
});
}
if n_observed == 0 {
return Err(FdarError::InvalidParameter {
parameter: "n_observed",
message: "n_observed must be at least 1".to_string(),
});
}
if m > 1 && argvals[0] >= argvals[m - 1] {
return Err(FdarError::InvalidParameter {
parameter: "argvals",
message: "argvals must be sorted (first element must be less than last)".to_string(),
});
}
let ncomp = config.ncomp.min(chart.eigenvalues.len());
let domain_fraction = if m <= 1 {
1.0
} else {
let range_fraction =
(argvals[n_observed.min(m) - 1] - argvals[0]) / (argvals[m - 1] - argvals[0]);
if range_fraction > 0.0 {
range_fraction
} else {
n_observed as f64 / m as f64
}
};
if !(0.0..=1.0).contains(&domain_fraction) {
return Err(FdarError::InvalidParameter {
parameter: "domain_fraction",
message: format!("computed domain_fraction must be in [0, 1], got {domain_fraction}"),
});
}
let (scores, completed_curve) = match &config.completion {
DomainCompletion::ConditionalExpectation => conditional_expectation(
chart,
partial_values,
argvals,
n_observed,
ncomp,
config.regularization_eps,
)?,
DomainCompletion::PartialProjection => {
let scores = partial_projection(chart, partial_values, argvals, n_observed, ncomp)?;
(scores, None)
}
DomainCompletion::ZeroPad => zero_pad(chart, partial_values, argvals, n_observed, ncomp)?,
};
let eigenvalues = &chart.eigenvalues[..ncomp];
let score_mat = FdMatrix::from_column_major(scores.clone(), 1, ncomp)?;
let t2_vec = hotelling_t2(&score_mat, eigenvalues)?;
let t2 = t2_vec[0];
let t2_alarm = t2 > chart.t2_limit.ucl;
Ok(PartialMonitorResult {
scores,
t2,
t2_alarm,
domain_fraction,
completed_curve,
})
}
#[must_use = "monitoring results should not be discarded"]
pub fn spm_monitor_partial_batch(
chart: &SpmChart,
partial_data: &[(&[f64], usize)],
argvals: &[f64],
config: &PartialDomainConfig,
) -> Result<Vec<PartialMonitorResult>, FdarError> {
partial_data
.iter()
.map(|(values, n_obs)| spm_monitor_partial(chart, values, argvals, *n_obs, config))
.collect()
}
fn conditional_expectation(
chart: &SpmChart,
partial_values: &[f64],
_argvals: &[f64],
n_observed: usize,
ncomp: usize,
reg_eps: f64,
) -> Result<(Vec<f64>, Option<Vec<f64>>), FdarError> {
let m = chart.fpca.mean.len();
let n_obs = n_observed.min(m);
let y_obs: Vec<f64> = (0..n_obs)
.map(|j| partial_values[j] - chart.fpca.mean[j])
.collect();
let sigma2 = if !chart.spe_phase1.is_empty() {
let mut sorted_spe = chart.spe_phase1.clone();
sorted_spe.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mid = sorted_spe.len() / 2;
let median_spe = if sorted_spe.len() % 2 == 0 {
(sorted_spe[mid - 1] + sorted_spe[mid]) / 2.0
} else {
sorted_spe[mid]
};
(median_spe / m as f64).max(1e-10)
} else {
1e-6
};
let phi_t_y: Vec<f64> = (0..ncomp)
.map(|l| {
(0..n_obs)
.map(|j| chart.fpca.rotation[(j, l)] * y_obs[j])
.sum::<f64>()
})
.collect();
let mut m_matrix = vec![0.0_f64; ncomp * ncomp];
for l in 0..ncomp {
m_matrix[l * ncomp + l] += 1.0 / chart.eigenvalues[l];
for k in 0..ncomp {
let phi_t_phi: f64 = (0..n_obs)
.map(|j| chart.fpca.rotation[(j, l)] * chart.fpca.rotation[(j, k)])
.sum();
m_matrix[l * ncomp + k] += phi_t_phi / sigma2;
}
}
let mut diag_min = f64::INFINITY;
let mut diag_max = 0.0_f64;
for l in 0..ncomp {
let d = m_matrix[l * ncomp + l];
if d > 0.0 {
diag_min = diag_min.min(d);
diag_max = diag_max.max(d);
}
}
if diag_max > 0.0 && diag_max / diag_min > 1e12 {
let reg = diag_max * reg_eps;
for l in 0..ncomp {
m_matrix[l * ncomp + l] += reg;
}
}
let rhs: Vec<f64> = phi_t_y.iter().map(|&v| v / sigma2).collect();
let scores = match solve_spd(&m_matrix, &rhs, ncomp) {
Ok(s) => s,
Err(_) => {
let mut m_reg = m_matrix.clone();
let mut reg_strength = diag_max * 1e-8;
let mut result = None;
for _ in 0..5 {
for l in 0..ncomp {
m_reg[l * ncomp + l] = m_matrix[l * ncomp + l] + reg_strength;
}
if let Ok(s) = solve_spd(&m_reg, &rhs, ncomp) {
result = Some(s);
break;
}
reg_strength *= 10.0;
}
result.ok_or(FdarError::ComputationFailed {
operation: "conditional_expectation",
detail: "Cholesky failed even with strong regularization".to_string(),
})?
}
};
let mut completed = chart.fpca.mean.clone();
for j in 0..m {
for l in 0..ncomp {
completed[j] += chart.fpca.rotation[(j, l)] * scores[l];
}
}
Ok((scores, Some(completed)))
}
fn partial_projection(
chart: &SpmChart,
partial_values: &[f64],
argvals: &[f64],
n_observed: usize,
ncomp: usize,
) -> Result<Vec<f64>, FdarError> {
let m = chart.fpca.mean.len();
let n_obs = n_observed.min(m);
let y_obs: Vec<f64> = (0..n_obs)
.map(|j| partial_values[j] - chart.fpca.mean[j])
.collect();
let partialargvals = &argvals[..n_obs];
let weights = simpsons_weights(partialargvals);
let full_weights = simpsons_weights(argvals);
let full_total: f64 = full_weights.iter().sum();
let partial_total: f64 = weights.iter().sum();
let scale = if partial_total > 0.0 {
full_total / partial_total
} else {
1.0
};
let scores: Vec<f64> = (0..ncomp)
.map(|l| {
let ip: f64 = (0..n_obs)
.map(|j| y_obs[j] * chart.fpca.rotation[(j, l)] * weights[j])
.sum();
ip * scale
})
.collect();
Ok(scores)
}
fn zero_pad(
chart: &SpmChart,
partial_values: &[f64],
_argvals: &[f64],
n_observed: usize,
ncomp: usize,
) -> Result<(Vec<f64>, Option<Vec<f64>>), FdarError> {
let m = chart.fpca.mean.len();
let n_obs = n_observed.min(m);
let mut padded = chart.fpca.mean.clone();
padded[..n_obs].copy_from_slice(&partial_values[..n_obs]);
let mut centered = vec![0.0; m];
for j in 0..m {
centered[j] = padded[j] - chart.fpca.mean[j];
}
let scores: Vec<f64> = (0..ncomp)
.map(|l| {
(0..m)
.map(|j| centered[j] * chart.fpca.rotation[(j, l)] * chart.fpca.weights[j])
.sum()
})
.collect();
Ok((scores, Some(padded)))
}
fn solve_spd(a: &[f64], b: &[f64], n: usize) -> Result<Vec<f64>, FdarError> {
let mut l = vec![0.0_f64; n * n];
for j in 0..n {
let mut sum = 0.0;
for k in 0..j {
sum += l[j * n + k] * l[j * n + k];
}
let diag = a[j * n + j] - sum;
if diag <= 0.0 || diag.is_nan() {
return Err(FdarError::ComputationFailed {
operation: "cholesky",
detail: "matrix is not positive definite".to_string(),
});
}
l[j * n + j] = diag.sqrt();
for i in (j + 1)..n {
let mut sum = 0.0;
for k in 0..j {
sum += l[i * n + k] * l[j * n + k];
}
l[i * n + j] = (a[i * n + j] - sum) / l[j * n + j];
}
}
let mut y = vec![0.0_f64; n];
for i in 0..n {
let mut sum = 0.0;
for k in 0..i {
sum += l[i * n + k] * y[k];
}
y[i] = (b[i] - sum) / l[i * n + i];
}
let mut x = vec![0.0_f64; n];
for i in (0..n).rev() {
let mut sum = 0.0;
for k in (i + 1)..n {
sum += l[k * n + i] * x[k];
}
x[i] = (y[i] - sum) / l[i * n + i];
}
Ok(x)
}