use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use crate::wire::{
add_rows, attribute_point_dtos, capability_dto, default_cost, default_min_seg, default_penalty,
gage_rr_anova_dto, gage_rr_xbar_r_dto, imr_dto, laney_p_dto, laney_point_dtos, p_chart_dto,
pelt_dto, percentile_capability_dto, rules_from_json, run_rules_dto, xbar_r_dto, xbar_s_dto,
AttributeChartPointDto, CapabilityInputDto, GageRRInputDto, LimitsInputDto, PeltInputDto,
PeltPenaltyDto, PeltResultDto, PercentileCapabilityInputDto, SeasonalityInputDto,
SpectralResidualInputDto,
};
#[derive(Serialize)]
struct AdNormalityDto {
statistic: f64,
statistic_modified: f64,
p_value: f64,
}
#[derive(Serialize, Debug)]
struct FixedLimitChartDto {
cl: f64,
ucl: f64,
lcl: f64,
points: Vec<AttributeChartPointDto>,
in_control: bool,
}
#[derive(Serialize, Debug)]
struct UChartDto {
u_bar: f64,
points: Vec<AttributeChartPointDto>,
in_control: bool,
}
#[derive(Serialize, Debug)]
struct LaneyUChartDto {
u_bar: f64,
phi: f64,
points: Vec<AttributeChartPointDto>,
}
#[derive(Serialize)]
struct GChartDto {
g_bar: f64,
points: Vec<GChartPointDto>,
}
#[derive(Serialize)]
struct GChartPointDto {
index: usize,
value: f64,
ucl: f64,
cl: f64,
lcl: f64,
out_of_control: bool,
}
#[derive(Serialize)]
struct TChartDto {
t_bar: f64,
points: Vec<TChartPointDto>,
}
#[derive(Serialize)]
struct TChartPointDto {
index: usize,
value: f64,
ucl: f64,
cl: f64,
lcl: f64,
out_of_control: bool,
}
fn js_err(msg: impl std::fmt::Display) -> JsValue {
JsValue::from_str(&msg.to_string())
}
fn to_js<T: Serialize>(val: &T) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(val).map_err(js_err)
}
fn from_js<T: serde::de::DeserializeOwned>(value: JsValue, param: &str) -> Result<T, JsValue> {
if value.as_string().is_some() {
return Err(js_err(format!(
"{param}: expected a native JS object/array, got a string — \
pass the value directly, not JSON.stringify(...)"
)));
}
let json: serde_json::Value =
serde_wasm_bindgen::from_value(value).map_err(|e| js_err(format!("{param}: {e}")))?;
from_json(json, param).map_err(js_err)
}
fn from_json<T: serde::de::DeserializeOwned>(
json: serde_json::Value,
param: &str,
) -> Result<T, String> {
serde_json::from_value(json).map_err(|e| format!("{param}: {e}"))
}
#[wasm_bindgen]
pub fn xbar_r_chart(data: JsValue, options: Option<JsValue>) -> Result<JsValue, JsValue> {
let subgroups: Vec<Vec<f64>> = from_js(data, "data")?;
let rules = rules_option(options)?;
to_js(&xbar_r_dto(subgroups, rules).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn xbar_s_chart(data: JsValue, options: Option<JsValue>) -> Result<JsValue, JsValue> {
let subgroups: Vec<Vec<f64>> = from_js(data, "data")?;
let rules = rules_option(options)?;
to_js(&xbar_s_dto(subgroups, rules).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn imr_chart(values: JsValue, options: Option<JsValue>) -> Result<JsValue, JsValue> {
let values: Vec<f64> = from_js(values, "values")?;
let rules = rules_option(options)?;
to_js(&imr_dto(values, rules).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn run_rules(
values: JsValue,
limits: JsValue,
options: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let values: Vec<f64> = from_js(values, "values")?;
let limits: LimitsInputDto = from_js(limits, "limits")?;
let rules = rules_option(options)?;
to_js(&run_rules_dto(values, limits, rules).map_err(js_err)?)
}
fn rules_option(options: Option<JsValue>) -> Result<crate::spc::RuleSet, JsValue> {
match options {
Some(o) if !o.is_undefined() && !o.is_null() => {
rules_from_json(Some(from_js(o, "options")?)).map_err(js_err)
}
_ => Ok(crate::spc::RuleSet::default()),
}
}
#[wasm_bindgen]
pub fn p_chart(samples: JsValue) -> Result<JsValue, JsValue> {
let raw: Vec<[u64; 2]> = from_js(samples, "samples (expected [[defectives, size], ...])")?;
to_js(&p_chart_dto(&raw).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn process_capability(input: JsValue) -> Result<JsValue, JsValue> {
let input: CapabilityInputDto = from_js(input, "input")?;
to_js(&capability_dto(input).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn anderson_darling_normality(data: &[f64]) -> Result<JsValue, JsValue> {
let result = crate::testing::anderson_darling_normality(data).ok_or_else(|| {
js_err("insufficient or invalid data (need >= 3 finite non-constant values)")
})?;
let dto = AdNormalityDto {
statistic: result.statistic,
statistic_modified: result.statistic_modified,
p_value: result.p_value,
};
to_js(&dto)
}
#[wasm_bindgen]
pub fn laney_p_chart(samples: JsValue) -> Result<JsValue, JsValue> {
let raw: Vec<[u64; 2]> = from_js(samples, "samples (expected [[defectives, size], ...])")?;
to_js(&laney_p_dto(&raw).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn np_chart(defectives: JsValue, sample_size: JsValue) -> Result<JsValue, JsValue> {
let defectives: Vec<u64> = from_js(defectives, "defectives")?;
let sample_size: u64 = from_js(sample_size, "sample_size")?;
to_js(&np_chart_dto(&defectives, sample_size).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn c_chart(defects: JsValue) -> Result<JsValue, JsValue> {
let defects: Vec<u64> = from_js(defects, "defects")?;
to_js(&c_chart_dto(&defects).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn u_chart(samples: JsValue) -> Result<JsValue, JsValue> {
let raw: Vec<(u64, f64)> = from_js(samples, "samples (expected [[defects, units], ...])")?;
to_js(&u_chart_dto(&raw).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn laney_u_chart(samples: JsValue) -> Result<JsValue, JsValue> {
let raw: Vec<(u64, f64)> = from_js(samples, "samples (expected [[defects, units], ...])")?;
to_js(&laney_u_dto(&raw).map_err(js_err)?)
}
fn check_rate_samples(raw: &[(u64, f64)]) -> Result<(), String> {
match raw.iter().position(|&(_, u)| !(u.is_finite() && u > 0.0)) {
Some(i) => Err(format!(
"samples[{i}]: units must be a positive number, got {}",
raw[i].1
)),
None => Ok(()),
}
}
fn np_chart_dto(defectives: &[u64], sample_size: u64) -> Result<FixedLimitChartDto, String> {
use crate::spc::NPChart;
let mut chart = NPChart::new(sample_size).map_err(|e| e.to_string())?;
add_rows(defectives, "defectives", |&d| chart.add_sample(d))?;
let (ucl, cl, lcl) = chart
.control_limits()
.ok_or("defectives must not be empty")?;
Ok(FixedLimitChartDto {
cl,
ucl,
lcl,
points: attribute_point_dtos(chart.points()),
in_control: chart.is_in_control(),
})
}
fn c_chart_dto(defects: &[u64]) -> Result<FixedLimitChartDto, String> {
use crate::spc::CChart;
let mut chart = CChart::new();
for &c in defects {
chart.add_sample(c);
}
let (ucl, cl, lcl) = chart.control_limits().ok_or("defects must not be empty")?;
Ok(FixedLimitChartDto {
cl,
ucl,
lcl,
points: attribute_point_dtos(chart.points()),
in_control: chart.is_in_control(),
})
}
fn u_chart_dto(raw: &[(u64, f64)]) -> Result<UChartDto, String> {
use crate::spc::UChart;
let mut chart = UChart::new();
add_rows(raw, "samples", |&(d, u)| chart.add_sample(d, u))?;
let u_bar = chart.u_bar().ok_or("samples must not be empty")?;
Ok(UChartDto {
u_bar,
points: attribute_point_dtos(chart.points()),
in_control: chart.is_in_control(),
})
}
fn laney_u_dto(raw: &[(u64, f64)]) -> Result<LaneyUChartDto, String> {
check_rate_samples(raw)?;
let chart = crate::spc::laney_u_chart(raw).ok_or("at least 3 samples are needed")?;
Ok(LaneyUChartDto {
u_bar: chart.u_bar,
phi: chart.phi,
points: laney_point_dtos(&chart.points),
})
}
#[wasm_bindgen]
pub fn g_chart(gaps: &[f64]) -> Result<JsValue, JsValue> {
let chart = crate::spc::g_chart(gaps)
.ok_or_else(|| js_err("insufficient data (need >= 2 finite positive values)"))?;
let points = chart
.points
.iter()
.map(|p| GChartPointDto {
index: p.index,
value: p.value,
ucl: p.ucl,
cl: p.cl,
lcl: p.lcl,
out_of_control: p.out_of_control,
})
.collect();
let dto = GChartDto {
g_bar: chart.g_bar,
points,
};
to_js(&dto)
}
#[wasm_bindgen]
pub fn t_chart(times: &[f64]) -> Result<JsValue, JsValue> {
let chart = crate::spc::t_chart(times)
.ok_or_else(|| js_err("insufficient data (need >= 2 finite positive values)"))?;
let points = chart
.points
.iter()
.map(|p| TChartPointDto {
index: p.index,
value: p.value,
ucl: p.ucl,
cl: p.cl,
lcl: p.lcl,
out_of_control: p.out_of_control,
})
.collect();
let dto = TChartDto {
t_bar: chart.t_bar,
points,
};
to_js(&dto)
}
#[wasm_bindgen]
pub fn detect_changepoints(input: JsValue) -> Result<JsValue, JsValue> {
let input: PeltInputDto = from_js(input, "input")?;
to_js(&pelt_dto(input).map_err(js_err)?)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MultiPeltInputDto {
signals: Vec<Vec<f64>>,
#[serde(default = "default_cost")]
cost: String,
#[serde(default = "default_penalty")]
penalty: PeltPenaltyDto,
#[serde(default = "default_min_seg")]
min_segment_len: usize,
}
#[wasm_bindgen]
pub fn detect_changepoints_multi(input: JsValue) -> Result<JsValue, JsValue> {
let input: MultiPeltInputDto = from_js(input, "input")?;
if input.signals.is_empty() {
return Err(js_err("signals must not be empty"));
}
let cost = match input.cost.as_str() {
"l2" => crate::detection::CostFunction::L2,
"normal" => crate::detection::CostFunction::Normal,
other => return Err(js_err(format!("unknown cost function: {other}"))),
};
let penalty = match input.penalty {
PeltPenaltyDto::Named(ref s) if s == "bic" => crate::detection::Penalty::Bic,
PeltPenaltyDto::Named(ref s) => return Err(js_err(format!("unknown penalty: {s}"))),
PeltPenaltyDto::Value(v) => crate::detection::Penalty::Custom(v),
};
let pelt = crate::detection::Pelt::with_min_segment_len(cost, penalty, input.min_segment_len)
.ok_or_else(|| js_err("invalid parameters"))?;
let refs: Vec<&[f64]> = input.signals.iter().map(|s| s.as_slice()).collect();
let result = pelt
.detect_multi(&refs)
.ok_or_else(|| js_err("all signals must have the same length"))?;
let dto = PeltResultDto {
n_segments: result.changepoints.len() + 1,
changepoints: result.changepoints,
};
to_js(&dto)
}
#[wasm_bindgen]
pub fn gage_rr_xbar_r(input: JsValue) -> Result<JsValue, JsValue> {
let input: GageRRInputDto = from_js(input, "input")?;
to_js(&gage_rr_xbar_r_dto(input).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn gage_rr_anova(input: JsValue) -> Result<JsValue, JsValue> {
let input: GageRRInputDto = from_js(input, "input")?;
to_js(&gage_rr_anova_dto(input).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn percentile_capability(input: JsValue) -> Result<JsValue, JsValue> {
let input: PercentileCapabilityInputDto = from_js(input, "input")?;
to_js(&percentile_capability_dto(input).map_err(js_err)?)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CusumInputDto {
data: Vec<f64>,
target: f64,
sigma: f64,
#[serde(default = "default_cusum_k")]
k: f64,
#[serde(default = "default_cusum_h")]
h: f64,
}
fn default_cusum_k() -> f64 {
0.5
}
fn default_cusum_h() -> f64 {
5.0
}
#[derive(Serialize, Debug)]
struct CusumPointDto {
index: usize,
s_upper: f64,
s_lower: f64,
signal: bool,
}
#[derive(Serialize, Debug)]
struct CusumDto {
h: f64,
points: Vec<CusumPointDto>,
signal_indices: Vec<usize>,
in_control: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EwmaInputDto {
data: Vec<f64>,
target: f64,
sigma: f64,
#[serde(default = "default_ewma_lambda")]
lambda: f64,
#[serde(default = "default_ewma_l_factor")]
l_factor: f64,
}
fn default_ewma_lambda() -> f64 {
0.2
}
fn default_ewma_l_factor() -> f64 {
3.0
}
#[derive(Serialize, Debug)]
struct EwmaPointDto {
index: usize,
ewma: f64,
ucl: f64,
lcl: f64,
signal: bool,
}
#[derive(Serialize, Debug)]
struct EwmaDto {
points: Vec<EwmaPointDto>,
signal_indices: Vec<usize>,
in_control: bool,
}
#[wasm_bindgen]
pub fn cusum(input: JsValue) -> Result<JsValue, JsValue> {
let input: CusumInputDto = from_js(input, "input")?;
let dto = cusum_dto(input).map_err(js_err)?;
to_js(&dto)
}
fn cusum_dto(input: CusumInputDto) -> Result<CusumDto, String> {
if input.data.is_empty() {
return Err("data must not be empty".to_owned());
}
let chart = crate::detection::Cusum::with_params(input.target, input.sigma, input.k, input.h)
.ok_or_else(|| {
format!(
"invalid parameters (target must be finite, sigma > 0, k >= 0, h > 0); \
got target={}, sigma={}, k={}, h={}",
input.target, input.sigma, input.k, input.h
)
})?;
let results = chart.analyze(&input.data);
let signal_indices: Vec<usize> = results
.iter()
.filter(|r| r.signal)
.map(|r| r.index)
.collect();
Ok(CusumDto {
h: input.h,
in_control: signal_indices.is_empty(),
signal_indices,
points: results
.into_iter()
.map(|r| CusumPointDto {
index: r.index,
s_upper: r.s_upper,
s_lower: r.s_lower,
signal: r.signal,
})
.collect(),
})
}
#[wasm_bindgen]
pub fn ewma(input: JsValue) -> Result<JsValue, JsValue> {
let input: EwmaInputDto = from_js(input, "input")?;
let dto = ewma_dto(input).map_err(js_err)?;
to_js(&dto)
}
fn ewma_dto(input: EwmaInputDto) -> Result<EwmaDto, String> {
if input.data.is_empty() {
return Err("data must not be empty".to_owned());
}
let chart = crate::detection::Ewma::with_params(
input.target,
input.sigma,
input.lambda,
input.l_factor,
)
.ok_or_else(|| {
format!(
"invalid parameters (target must be finite, sigma > 0, 0 < lambda <= 1, \
l_factor > 0); got target={}, sigma={}, lambda={}, l_factor={}",
input.target, input.sigma, input.lambda, input.l_factor
)
})?;
let results = chart.analyze(&input.data);
let signal_indices: Vec<usize> = results
.iter()
.filter(|r| r.signal)
.map(|r| r.index)
.collect();
Ok(EwmaDto {
in_control: signal_indices.is_empty(),
signal_indices,
points: results
.into_iter()
.map(|r| EwmaPointDto {
index: r.index,
ewma: r.ewma,
ucl: r.ucl,
lcl: r.lcl,
signal: r.signal,
})
.collect(),
})
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BoxcoxCapabilityInputDto {
data: Vec<f64>,
#[serde(default)]
usl: Option<f64>,
#[serde(default)]
lsl: Option<f64>,
}
#[derive(Serialize, Debug)]
struct BoxcoxCapabilityDto {
lambda: f64,
cp: Option<f64>,
cpk: Option<f64>,
cpu: Option<f64>,
cpl: Option<f64>,
pp: Option<f64>,
ppk: Option<f64>,
ppu: Option<f64>,
ppl: Option<f64>,
cpm: Option<f64>,
}
#[wasm_bindgen]
pub fn boxcox_capability(input: JsValue) -> Result<JsValue, JsValue> {
let input: BoxcoxCapabilityInputDto = from_js(input, "input")?;
let dto = boxcox_capability_dto(input).map_err(js_err)?;
to_js(&dto)
}
fn boxcox_capability_dto(input: BoxcoxCapabilityInputDto) -> Result<BoxcoxCapabilityDto, String> {
let result = crate::capability::boxcox_capability(&input.data, input.usl, input.lsl)
.map_err(|e| e.to_string())?;
let i = result.indices;
Ok(BoxcoxCapabilityDto {
lambda: result.lambda,
cp: i.cp,
cpk: i.cpk,
cpu: i.cpu,
cpl: i.cpl,
pp: i.pp,
ppk: i.ppk,
ppu: i.ppu,
ppl: i.ppl,
cpm: i.cpm,
})
}
#[wasm_bindgen]
pub fn estimate_period(input: JsValue) -> Result<JsValue, JsValue> {
let input: SeasonalityInputDto = from_js(input, "input")?;
to_js(&crate::wire::seasonality_dto(input).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn spectral_residual(input: JsValue) -> Result<JsValue, JsValue> {
let input: SpectralResidualInputDto = from_js(input, "input")?;
to_js(&crate::wire::spectral_residual_dto(input).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn sigma_to_ppm(sigma: f64) -> Result<f64, JsValue> {
if !sigma.is_finite() {
return Err(js_err(format!("sigma must be finite, got {sigma}")));
}
Ok(crate::capability::sigma_to_ppm(sigma))
}
#[wasm_bindgen]
pub fn ppm_to_sigma(ppm: f64) -> Result<f64, JsValue> {
crate::capability::ppm_to_sigma(ppm).ok_or_else(|| {
js_err(format!(
"ppm must be finite and strictly inside (0, 1000000), got {ppm}"
))
})
}
#[cfg(test)]
mod dto_strictness_tests {
use serde_json::json;
fn assert_rejects_unknown<T: serde::de::DeserializeOwned>(v: serde_json::Value) {
match super::from_json::<T>(v, "input") {
Ok(_) => panic!("unknown key must be rejected"),
Err(e) => assert!(e.contains("unknown field"), "{e}"),
}
}
#[test]
fn capability_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::CapabilityInputDto>(
json!({ "data": [1.0, 2.0], "usl": 3.0, "sigmaWithin": 0.5 }),
);
}
#[test]
fn pelt_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::PeltInputDto>(
json!({ "data": [1.0, 2.0], "minSegmentLen": 3 }),
);
}
#[test]
fn multi_pelt_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::MultiPeltInputDto>(
json!({ "signals": [[1.0, 2.0]], "penalty_value": 1.0 }),
);
}
#[test]
fn gage_rr_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::GageRRInputDto>(
json!({ "measurements": [[[1.0]]], "tol": 0.5 }),
);
}
#[test]
fn cusum_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::CusumInputDto>(
json!({ "data": [1.0, 2.0], "target": 1.0, "sigma": 1.0, "H": 5.0 }),
);
}
#[test]
fn ewma_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::EwmaInputDto>(
json!({ "data": [1.0, 2.0], "target": 1.0, "sigma": 1.0, "lFactor": 3.0 }),
);
}
#[test]
fn boxcox_capability_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::BoxcoxCapabilityInputDto>(
json!({ "data": [1.0, 2.0, 3.0, 4.0], "USL": 9.0 }),
);
}
#[test]
fn percentile_capability_input_rejects_unknown_keys() {
assert_rejects_unknown::<super::PercentileCapabilityInputDto>(
json!({ "data": [1.0, 2.0], "target": 1.5 }),
);
}
}
#[cfg(test)]
mod binding_contract_tests {
use super::*;
#[test]
fn rules_option_absent_means_the_default_set() {
use crate::spc::RuleSet;
for absent in [
None,
Some(serde_json::Value::Null),
Some(serde_json::json!({})),
Some(serde_json::json!({ "rules": null })),
] {
assert_eq!(rules_from_json(absent).unwrap(), RuleSet::default());
}
}
#[test]
fn rules_option_selects_exactly_what_it_names() {
use crate::spc::{RuleSet, ViolationType};
let set = rules_from_json(Some(serde_json::json!({
"rules": ["BeyondLimits", "SixTrend"]
})))
.unwrap();
assert_eq!(
set,
RuleSet::from_iter([ViolationType::BeyondLimits, ViolationType::SixTrend])
);
assert!(!set.contains(ViolationType::NineOneSide));
}
#[test]
fn rules_option_empty_array_applies_no_test() {
use crate::spc::RuleSet;
assert_eq!(
rules_from_json(Some(serde_json::json!({ "rules": [] }))).unwrap(),
RuleSet::none()
);
}
#[test]
fn rules_option_rejects_a_name_that_is_not_a_rule() {
let err = rules_from_json(Some(serde_json::json!({ "rules": ["Rule1"] }))).unwrap_err();
assert!(err.contains("Rule1"), "{err}");
let err =
rules_from_json(Some(serde_json::json!({ "rules": "BeyondLimits" }))).unwrap_err();
assert!(err.contains("array"), "{err}");
}
use super::{capability_dto, from_json, CapabilityInputDto};
use serde_json::json;
const SUBGROUPS: [[f64; 5]; 6] = [
[9.9, 10.1, 10.0, 9.8, 10.2],
[10.3, 9.7, 10.0, 10.1, 9.9],
[9.8, 10.2, 10.1, 9.9, 10.0],
[10.5, 9.5, 10.0, 10.2, 9.8],
[9.6, 10.4, 10.0, 9.9, 10.1],
[10.1, 9.9, 10.0, 10.3, 9.7],
];
fn flat() -> Vec<f64> {
SUBGROUPS.iter().flatten().copied().collect()
}
fn dto(v: serde_json::Value) -> Result<crate::wire::CapabilityDto, String> {
capability_dto(from_json::<CapabilityInputDto>(v, "input")?)
}
fn sigma_within_from_chart() -> f64 {
use crate::spc::{ControlChart, XBarRChart};
let mut chart = XBarRChart::new(5).expect("5 is within range");
for g in SUBGROUPS {
chart.add_sample(&g).unwrap();
}
chart.sigma_hat().expect("limits available for 6 subgroups")
}
#[test]
fn omitting_sigma_within_yields_nulls_not_long_term_numbers_under_short_term_names() {
let d = dto(json!({ "data": flat(), "usl": 11.0, "lsl": 9.0 })).expect("valid input");
assert_eq!(d.sigma_source, "overall");
assert!(d.std_dev_within.is_none());
assert!(d.cp.is_none());
assert!(d.cpk.is_none());
assert!(d.cpu.is_none());
assert!(d.cpl.is_none());
assert!(d.pp.is_some());
assert!(d.ppk.is_some());
}
#[test]
fn supplying_sigma_within_separates_the_two_index_families() {
let sigma_within = sigma_within_from_chart();
let d = dto(json!({
"data": flat(), "usl": 11.0, "lsl": 9.0, "sigma_within": sigma_within,
}))
.expect("valid input");
assert_eq!(d.sigma_source, "within");
assert_eq!(d.std_dev_within, Some(sigma_within));
let (cp, pp) = (d.cp.expect("cp"), d.pp.expect("pp"));
let (cpk, ppk) = (d.cpk.expect("cpk"), d.ppk.expect("ppk"));
assert!(
(cp - pp).abs() > 1e-9,
"cp and pp must come from different sigmas: cp={cp} pp={pp}"
);
assert!(
(cpk - ppk).abs() > 1e-9,
"cpk and ppk must come from different sigmas: cpk={cpk} ppk={ppk}"
);
let sigma_overall = d.std_dev_overall;
assert!(
(cp / pp - sigma_overall / sigma_within).abs() < 1e-12,
"cp/pp must equal sigma_overall/sigma_within: \
cp={cp} pp={pp} sigma_within={sigma_within} sigma_overall={sigma_overall}"
);
assert!(sigma_within > sigma_overall);
}
#[test]
fn one_sided_specification_drops_only_the_indices_it_cannot_support() {
let upper = dto(json!({ "data": flat(), "usl": 11.0 })).expect("usl-only is legal");
assert!(upper.ppu.is_some(), "an upper limit supports Ppu");
assert!(upper.ppl.is_none(), "no lower limit, no Ppl");
assert!(upper.pp.is_none(), "Pp needs both limits");
assert!(upper.cpm.is_none(), "Cpm needs both limits");
let lower = dto(json!({ "data": flat(), "lsl": 9.0 })).expect("lsl-only is legal");
assert!(lower.ppl.is_some());
assert!(lower.ppu.is_none());
assert!(lower.pp.is_none());
}
#[test]
fn a_specification_with_no_limit_at_all_is_rejected() {
let e = dto(json!({ "data": flat() })).expect_err("no limit is not a specification");
assert!(e.contains("specification"), "{e}");
}
#[test]
fn cpm_needs_a_declared_target_and_no_sigma() {
let within = json!({ "data": flat(), "usl": 11.0, "lsl": 9.0, "sigma_within": 0.2 });
let overall = json!({ "data": flat(), "usl": 11.0, "lsl": 9.0 });
assert!(dto(within.clone()).expect("valid").cpm.is_none());
assert!(dto(overall.clone()).expect("valid").cpm.is_none());
let (mut within, mut overall) = (within, overall);
within["target"] = json!(10.4);
overall["target"] = json!(10.4);
let a = dto(within).expect("valid").cpm.expect("cpm with a target");
let b = dto(overall)
.expect("valid")
.cpm
.expect("cpm without sigma_within");
assert_eq!(a, b);
}
#[test]
fn sigma_within_must_be_a_usable_standard_deviation() {
for bad in [0.0, -1.0] {
let v = json!({ "data": flat(), "usl": 11.0, "lsl": 9.0, "sigma_within": bad });
let e = dto(v).expect_err("a non-positive sigma is not a standard deviation");
assert!(e.contains("sigma_within"), "{bad} -> {e}");
}
for bad in [f64::NAN, f64::INFINITY] {
let input = CapabilityInputDto {
data: flat(),
usl: Some(11.0),
lsl: Some(9.0),
sigma_within: Some(bad),
target: None,
};
let e = capability_dto(input).expect_err("non-finite sigma must be refused");
assert!(e.contains("sigma_within"), "{bad} -> {e}");
}
}
#[test]
fn too_little_data_is_an_error_not_a_nan() {
let e = dto(json!({ "data": [1.0], "usl": 11.0, "lsl": 9.0 }))
.expect_err("one point has no dispersion");
assert!(e.contains("insufficient"), "{e}");
}
fn shifted_subgroups() -> Vec<Vec<f64>> {
(0..20)
.map(|g| {
let shift = if g >= 10 { 0.6 } else { 0.0 };
(0..5)
.map(|i| 10.0 + shift + 0.1 * (((g * 3 + i * 7) % 5) as f64 - 2.0))
.collect()
})
.collect()
}
#[test]
fn xbar_s_chart_reports_the_crate_chart() {
use crate::spc::{ControlChart, RuleSet, XBarSChart};
let subgroups = shifted_subgroups();
let d = xbar_s_dto(subgroups.clone(), RuleSet::default()).expect("valid input");
let mut chart = XBarSChart::new(5).expect("5 is in range");
for g in &subgroups {
chart.add_sample(g).unwrap();
}
let x = chart.control_limits().expect("limits");
let s = chart.s_limits().expect("limits");
assert_eq!((d.xbar_ucl, d.xbar_cl, d.xbar_lcl), (x.ucl, x.cl, x.lcl));
assert_eq!((d.s_ucl, d.s_cl, d.s_lcl), (s.ucl, s.cl, s.lcl));
assert_eq!(d.sigma_hat, chart.sigma_hat());
assert_eq!(d.xbar_points.len(), 20);
assert_eq!(d.s_points.len(), 20);
assert_eq!(d.in_control, chart.is_in_control());
}
#[test]
fn a_ragged_subgroup_is_rejected_by_its_row_not_skipped() {
use crate::spc::RuleSet;
let mut subgroups = shifted_subgroups();
subgroups[7].pop();
for result in [
xbar_s_dto(subgroups.clone(), RuleSet::default()).map(|_| ()),
xbar_r_dto(subgroups, RuleSet::default()).map(|_| ()),
] {
let e = result.expect_err("ragged subgroup");
assert!(e.contains("subgroups[7]"), "{e}");
}
}
#[test]
fn imr_chart_reports_the_crate_chart() {
use crate::spc::{ControlChart, IndividualMRChart, RuleSet};
let values: Vec<f64> = shifted_subgroups().iter().map(|g| g[0]).collect();
let d = imr_dto(values.clone(), RuleSet::default()).expect("valid input");
let mut chart = IndividualMRChart::new();
for &x in &values {
chart.add_sample(&[x]).unwrap();
}
let i = chart.control_limits().expect("limits");
let mr = chart.mr_limits().expect("limits");
assert_eq!((d.i_ucl, d.i_cl, d.i_lcl), (i.ucl, i.cl, i.lcl));
assert_eq!((d.mr_ucl, d.mr_cl, d.mr_lcl), (mr.ucl, mr.cl, mr.lcl));
assert_eq!(d.sigma_hat, chart.sigma_hat());
assert_eq!(d.i_points.len(), values.len());
assert_eq!(d.mr_points.len(), values.len() - 1);
assert_eq!(d.mr_points[0].index, 1);
}
#[test]
fn imr_chart_needs_two_values() {
use crate::spc::RuleSet;
let e = imr_dto(vec![1.0], RuleSet::default()).expect_err("one value has no range");
assert!(e.contains("two"), "{e}");
}
#[test]
fn run_rules_finds_what_the_chart_found() {
use crate::spc::RuleSet;
let chart = xbar_r_dto(shifted_subgroups(), RuleSet::default()).expect("valid");
let flagged = chart
.xbar_points
.iter()
.filter(|p| !p.violations.is_empty())
.count();
assert!(
flagged > 0,
"the fixture must give the rules something to find"
);
let values = chart.xbar_points.iter().map(|p| p.value).collect();
let limits = LimitsInputDto {
ucl: chart.xbar_ucl,
cl: chart.xbar_cl,
lcl: chart.xbar_lcl,
};
let standalone = run_rules_dto(values, limits, RuleSet::default()).expect("valid");
assert_eq!(standalone.len(), chart.xbar_points.len());
for (a, b) in standalone.iter().zip(&chart.xbar_points) {
assert_eq!(a.index, b.index);
assert_eq!(a.violations, b.violations, "point {}", a.index);
}
}
#[test]
fn run_rules_applies_only_the_rules_it_is_given() {
use crate::spc::{RuleSet, ViolationType};
let mut values = vec![0.0; 10];
values[4] = 5.0;
let limits = || LimitsInputDto {
ucl: 3.0,
cl: 0.0,
lcl: -3.0,
};
let all = run_rules_dto(values.clone(), limits(), RuleSet::default()).expect("valid");
assert!(all[4].violations.contains(&"BeyondLimits".to_owned()));
let none = run_rules_dto(values.clone(), limits(), RuleSet::none()).expect("valid");
assert!(none.iter().all(|p| p.violations.is_empty()));
let only_trend = RuleSet::none().with(ViolationType::SixTrend);
let trend = run_rules_dto(values, limits(), only_trend).expect("valid");
assert!(trend[4].violations.is_empty());
}
#[test]
fn run_rules_reports_every_value_in_order() {
use crate::spc::RuleSet;
let limits = LimitsInputDto {
ucl: 5.0,
cl: 2.0,
lcl: -1.0,
};
let out = run_rules_dto(vec![1.0, 2.0, 3.0], limits, RuleSet::default()).expect("valid");
let indices: Vec<usize> = out.iter().map(|p| p.index).collect();
assert_eq!(indices, vec![0, 1, 2]);
assert_eq!(out[2].value, 3.0);
}
#[test]
fn run_rules_rejects_limits_out_of_order() {
use crate::spc::RuleSet;
let limits = LimitsInputDto {
ucl: 1.0,
cl: 2.0,
lcl: 0.0,
};
let e = run_rules_dto(vec![1.0], limits, RuleSet::default()).expect_err("cl above ucl");
assert!(e.contains("lcl <= cl <= ucl"), "{e}");
}
#[test]
fn limits_input_rejects_unknown_keys() {
let e = from_json::<LimitsInputDto>(
json!({ "ucl": 1.0, "cl": 0.0, "lcl": -1.0, "usl": 2.0 }),
"limits",
)
.err()
.expect("unknown key");
assert!(e.contains("unknown field"), "{e}");
}
#[test]
fn np_chart_reports_the_crate_chart() {
use crate::spc::NPChart;
let defectives = [5, 8, 3, 6, 4, 7, 2, 9, 5, 6];
let d = np_chart_dto(&defectives, 100).expect("valid");
let mut chart = NPChart::new(100).expect("valid size");
for &x in &defectives {
chart.add_sample(x).unwrap();
}
let (ucl, cl, lcl) = chart.control_limits().expect("limits");
assert_eq!((d.ucl, d.cl, d.lcl), (ucl, cl, lcl));
assert_eq!(d.points.len(), defectives.len());
assert_eq!(d.in_control, chart.is_in_control());
}
#[test]
fn np_chart_refuses_rows_it_cannot_chart() {
let e = np_chart_dto(&[5, 101, 3], 100).expect_err("more defectives than inspected");
assert!(e.contains("defectives[1]"), "{e}");
let e = np_chart_dto(&[0, 0], 0).expect_err("nothing inspected");
assert!(e.contains("sample size"), "{e}");
}
#[test]
fn c_chart_reports_the_crate_chart() {
use crate::spc::CChart;
let defects = [3, 5, 2, 4, 6, 3, 1, 4];
let d = c_chart_dto(&defects).expect("valid");
let mut chart = CChart::new();
for &x in &defects {
chart.add_sample(x);
}
let (ucl, cl, lcl) = chart.control_limits().expect("limits");
assert_eq!((d.ucl, d.cl, d.lcl), (ucl, cl, lcl));
assert!(c_chart_dto(&[]).is_err());
}
#[test]
fn u_chart_reports_the_crate_chart_and_refuses_empty_units() {
use crate::spc::UChart;
let samples = [(3, 1.0), (5, 1.5), (2, 0.8), (4, 1.2)];
let d = u_chart_dto(&samples).expect("valid");
let mut chart = UChart::new();
for &(x, u) in &samples {
chart.add_sample(x, u).unwrap();
}
assert_eq!(Some(d.u_bar), chart.u_bar());
assert_eq!(d.points.len(), samples.len());
let e = u_chart_dto(&[(3, 1.0), (5, 0.0), (2, 0.8)]).expect_err("zero units");
assert!(e.contains("samples[1]"), "{e}");
}
#[test]
fn laney_u_chart_reports_the_crate_chart() {
let samples = [(3, 1.0), (5, 1.5), (2, 0.8), (4, 1.2), (6, 1.1)];
let d = laney_u_dto(&samples).expect("valid");
let expected = crate::spc::laney_u_chart(&samples).expect("valid");
assert_eq!((d.u_bar, d.phi), (expected.u_bar, expected.phi));
assert!(laney_u_dto(&samples[..2]).is_err(), "fewer than 3 samples");
let e = laney_u_dto(&[(3, 1.0), (5, -1.0), (2, 0.8)]).expect_err("negative units");
assert!(e.contains("samples[1]"), "{e}");
}
#[test]
fn p_chart_refuses_a_sample_with_no_proportion() {
let e = p_chart_dto(&[[3, 100], [12, 10], [4, 100]]).expect_err("12 of 10");
assert!(e.contains("samples[1]"), "{e}");
let e = p_chart_dto(&[[3, 100], [0, 0]]).expect_err("0 of 0");
assert!(e.contains("samples[1]"), "{e}");
let ok = p_chart_dto(&[[3, 100], [5, 120], [2, 80]]).expect("valid");
assert_eq!(ok.points.len(), 3);
}
#[test]
fn boxcox_reports_lambda_and_long_term_indices_matching_the_crate() {
let data: Vec<f64> = (1..=20).map(|i| (i as f64 * 0.3_f64).exp()).collect();
let input: super::BoxcoxCapabilityInputDto =
super::from_json(json!({ "data": data, "usl": 100.0, "lsl": 1.0 }), "input")
.expect("valid input");
let dto = super::boxcox_capability_dto(input).expect("skewed data is analysable");
let native =
crate::capability::boxcox_capability(&data, Some(100.0), Some(1.0)).expect("same call");
assert!((dto.lambda - native.lambda).abs() < 1e-12);
assert_eq!(dto.pp, native.indices.pp);
assert_eq!(dto.ppk, native.indices.ppk);
assert!(dto.cp.is_none() && dto.cpk.is_none());
assert!(
dto.ppk.is_some(),
"long-term indices are the available ones"
);
}
#[test]
fn boxcox_refuses_the_inputs_the_transform_cannot_take() {
let cases = [
json!({ "data": [1.0, 2.0, 0.0, 4.0], "usl": 9.0 }), json!({ "data": [1.0, 2.0, 3.0, 4.0] }), json!({ "data": [1.0, 2.0, 3.0], "usl": 9.0 }), ];
for case in cases {
let parsed: super::BoxcoxCapabilityInputDto =
super::from_json(case.clone(), "input").expect("parses");
assert!(
super::boxcox_capability_dto(parsed).is_err(),
"should be refused: {case}"
);
}
}
#[test]
fn sigma_and_ppm_round_trip_on_the_shifted_convention() {
for sigma in [3.0_f64, 4.5, 6.0] {
let ppm = crate::capability::sigma_to_ppm(sigma);
let back = crate::capability::ppm_to_sigma(ppm).expect("in range");
assert!((back - sigma).abs() < 1e-3, "{sigma} -> {ppm} -> {back}");
}
assert!((crate::capability::sigma_to_ppm(6.0) - 3.4).abs() < 1.0);
assert!((crate::capability::sigma_to_ppm(3.0) - 66_807.0).abs() < 500.0);
}
#[test]
fn ppm_to_sigma_rejects_both_ends_of_the_range() {
assert!(crate::capability::ppm_to_sigma(0.0).is_none());
assert!(crate::capability::ppm_to_sigma(1_000_000.0).is_none());
assert!(crate::capability::ppm_to_sigma(-1.0).is_none());
}
fn cusum_in(data: &[f64], target: f64, sigma: f64) -> super::CusumInputDto {
super::from_json(
json!({ "data": data, "target": target, "sigma": sigma }),
"input",
)
.expect("valid cusum input")
}
fn ewma_in(data: &[f64], target: f64, sigma: f64) -> super::EwmaInputDto {
super::from_json(
json!({ "data": data, "target": target, "sigma": sigma }),
"input",
)
.expect("valid ewma input")
}
#[test]
fn cusum_defaults_match_the_crate_and_stay_in_control_on_stable_data() {
let data = [10.1, 9.8, 10.2, 9.9, 10.0, 10.1, 9.7, 10.3];
let dto = super::cusum_dto(cusum_in(&data, 10.0, 1.0)).expect("in-control run");
assert_eq!(dto.h, 5.0);
assert_eq!(dto.points.len(), data.len());
assert!(dto.in_control);
assert!(dto.signal_indices.is_empty());
let native = crate::detection::Cusum::new(10.0, 1.0)
.expect("valid chart")
.analyze(&data);
for (p, n) in dto.points.iter().zip(native.iter()) {
assert_eq!(p.index, n.index);
assert!((p.s_upper - n.s_upper).abs() < 1e-12);
assert!((p.s_lower - n.s_lower).abs() < 1e-12);
assert_eq!(p.signal, n.signal);
}
}
#[test]
fn cusum_signals_a_sustained_upward_shift_and_reports_where() {
let mut data = vec![10.0; 10];
data.extend(vec![12.0; 10]);
let dto = super::cusum_dto(cusum_in(&data, 10.0, 1.0)).expect("shifted run");
assert!(!dto.in_control);
assert!(!dto.signal_indices.is_empty());
assert!(
dto.signal_indices.iter().all(|&i| i >= 10),
"{:?}",
dto.signal_indices
);
let from_points: Vec<usize> = dto
.points
.iter()
.filter(|p| p.signal)
.map(|p| p.index)
.collect();
assert_eq!(from_points, dto.signal_indices);
}
#[test]
fn cusum_refuses_empty_data_and_invalid_parameters() {
let empty: super::CusumInputDto =
super::from_json(json!({ "data": [], "target": 1.0, "sigma": 1.0 }), "input")
.expect("parses");
assert!(super::cusum_dto(empty)
.expect_err("empty")
.contains("empty"));
let bad: super::CusumInputDto = super::from_json(
json!({ "data": [1.0], "target": 1.0, "sigma": 0.0 }),
"input",
)
.expect("parses");
let e = super::cusum_dto(bad).expect_err("sigma must be > 0");
assert!(e.contains("sigma"), "{e}");
}
#[test]
fn ewma_limits_widen_with_the_index_and_match_the_crate() {
let data = [10.1, 9.8, 10.2, 9.9, 10.0, 10.1];
let dto = super::ewma_dto(ewma_in(&data, 10.0, 1.0)).expect("in-control run");
assert_eq!(dto.points.len(), data.len());
assert!(dto.in_control);
let widths: Vec<f64> = dto.points.iter().map(|p| p.ucl - p.lcl).collect();
for w in widths.windows(2) {
assert!(w[1] > w[0], "limits must widen: {widths:?}");
}
let native = crate::detection::Ewma::new(10.0, 1.0)
.expect("valid chart")
.analyze(&data);
for (p, n) in dto.points.iter().zip(native.iter()) {
assert_eq!(p.index, n.index);
assert!((p.ewma - n.ewma).abs() < 1e-12);
assert!((p.ucl - n.ucl).abs() < 1e-12);
assert!((p.lcl - n.lcl).abs() < 1e-12);
assert_eq!(p.signal, n.signal);
}
}
#[test]
fn ewma_refuses_empty_data_and_a_lambda_outside_zero_to_one() {
let empty: super::EwmaInputDto =
super::from_json(json!({ "data": [], "target": 1.0, "sigma": 1.0 }), "input")
.expect("parses");
assert!(super::ewma_dto(empty).expect_err("empty").contains("empty"));
for lambda in [0.0, 1.5] {
let bad: super::EwmaInputDto = super::from_json(
json!({ "data": [1.0, 2.0], "target": 1.0, "sigma": 1.0, "lambda": lambda }),
"input",
)
.expect("parses");
let e = super::ewma_dto(bad).expect_err("lambda out of range");
assert!(e.contains("lambda"), "{e}");
}
}
#[test]
fn laney_p_chart_refuses_a_sample_with_no_proportion() {
let e = laney_p_dto(&[[3, 100], [0, 0], [4, 100], [2, 100]]).expect_err("0 of 0");
assert!(e.contains("samples[1]"), "{e}");
assert!(
laney_p_dto(&[[3, 100], [5, 120]]).is_err(),
"fewer than 3 samples"
);
}
}