use serde::Serialize;
use wasm_bindgen::prelude::*;
use crate::wire::{
c_chart_dto, capability_dto, correlation_matrix_dto, count_pairs, count_rows, cusum_dto,
default_correlation_method, ewma_dto, failure_site, fit_best_dto, g_chart_dto,
gage_rr_anova_dto, gage_rr_xbar_r_dto,
hypothesis::{
adf_dto, anderson_darling_dto, chi_squared_gof_dto, chi_squared_independence_dto,
fisher_exact_dto, groups_test_dto, jarque_bera_dto, mann_kendall_dto, mann_whitney_dto,
one_sample_t_dto, one_way_anova_dto, p_adjust_dto, paired_t_dto, shapiro_wilk_dto,
two_sample_t_dto, wilcoxon_dto, AdfInputDto,
},
imr_dto, laney_p_dto, laney_u_dto, multi_pelt_dto, np_chart_dto, p_chart_dto, pelt_dto,
percentile_capability_dto,
point_process::{
check_truncation, laplace_dto, mil_hdbk_189_dto, power_law_dto, ObservationDto,
},
rate_pairs,
reliability::{
boxcox_capability_dto, ppm_to_sigma_value, sigma_to_ppm_value, weibull_mle_dto,
weibull_mrr_dto, weibull_reliability_dto, BoxcoxCapabilityInputDto,
WeibullReliabilityInputDto,
},
rules_from_json, run_rules_dto, sample_size_value, simple_regression_dto, t_chart_dto,
u_chart_dto, xbar_r_dto, xbar_s_dto, AttributeStandardDto, CapabilityInputDto,
CorrelationInputDto, CusumInputDto, EwmaInputDto, GageRRInputDto, LimitsInputDto,
MultiPeltInputDto, PeltInputDto, PercentileCapabilityInputDto, RegressionInputDto,
SeasonalityInputDto, SpectralResidualInputDto, WireError,
};
#[wasm_bindgen(unchecked_return_type = "Codes")]
pub fn codes() -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&crate::wire::code_catalog()).map_err(|e| {
js_err(WireError::new(
crate::wire::code::INTERNAL,
None,
e.to_string(),
))
})
}
fn js_err(error: impl Into<WireError>) -> JsValue {
let error = error.into();
let js = js_sys::Error::new(&error.message);
let index = error
.index
.map_or(JsValue::NULL, |i| JsValue::from_f64(i as f64));
let _ = js_sys::Reflect::set(
&js,
&JsValue::from_str("code"),
&JsValue::from_str(error.code),
);
let _ = js_sys::Reflect::set(&js, &JsValue::from_str("index"), &index);
let parameter = error
.parameter
.as_deref()
.map_or(JsValue::NULL, JsValue::from_str);
let _ = js_sys::Reflect::set(&js, &JsValue::from_str("parameter"), ¶meter);
for (key, value) in &error.details {
let value = match value {
crate::wire::Detail::Num(n) => JsValue::from_f64(*n),
crate::wire::Detail::Text(s) => JsValue::from_str(s),
crate::wire::Detail::List(v) => v
.iter()
.map(|s| JsValue::from_str(s))
.collect::<js_sys::Array>()
.into(),
crate::wire::Detail::Null => JsValue::NULL,
};
let _ = js_sys::Reflect::set(&js, &JsValue::from_str(key), &value);
}
js.into()
}
impl From<serde_wasm_bindgen::Error> for WireError {
fn from(e: serde_wasm_bindgen::Error) -> Self {
WireError::new(crate::wire::code::INTERNAL, None, e.to_string())
}
}
fn to_js<T: Serialize>(val: &T) -> Result<JsValue, JsValue> {
val.serialize(&serde_wasm_bindgen::Serializer::new().serialize_missing_as_null(true))
.map_err(js_err)
}
struct NonFinite {
parameter: String,
index: Option<usize>,
value: f64,
}
impl NonFinite {
fn message(&self) -> String {
let at = match self.index {
Some(i) => format!("{}[{i}]", self.parameter),
None => self.parameter.clone(),
};
let got = if self.value.is_nan() {
"NaN"
} else if self.value > 0.0 {
"Infinity"
} else {
"-Infinity"
};
format!("{at}: expected a finite number, got {got}")
}
}
fn find_non_finite(value: &JsValue, parameter: &str, allow_nan: bool) -> Option<NonFinite> {
let refused = |n: f64| !n.is_finite() && !(allow_nan && n.is_nan());
let found = |index: Option<usize>, value: f64| NonFinite {
parameter: parameter.to_string(),
index,
value,
};
if let Some(n) = value.as_f64() {
return refused(n).then(|| found(None, n));
}
if !value.is_object() {
return None;
}
if let Ok(Some(items)) = js_sys::try_iter(value) {
for (i, item) in items.enumerate() {
let item = item.ok()?;
match item.as_f64() {
Some(n) if refused(n) => return Some(found(Some(i), n)),
Some(_) => {}
None => {
let inner = find_non_finite(&item, &format!("{parameter}[{i}]"), allow_nan);
if inner.is_some() {
return inner;
}
}
}
}
return None;
}
let object: &js_sys::Object = wasm_bindgen::JsCast::unchecked_ref(value);
for entry in js_sys::Object::entries(object).iter() {
let pair: js_sys::Array = wasm_bindgen::JsCast::unchecked_into(entry);
let key = pair.get(0).as_string().unwrap_or_default();
let inner = find_non_finite(&pair.get(1), &format!("{parameter}.{key}"), allow_nan);
if inner.is_some() {
return inner;
}
}
None
}
fn from_js<T: serde::de::DeserializeOwned>(value: JsValue, param: &str) -> Result<T, JsValue> {
if value.as_string().is_some() {
return Err(js_err(
WireError::new(
crate::wire::code::MALFORMED_INPUT,
None,
format!(
"{param}: expected a native JS object/array, got a string โ \
pass the value directly, not JSON.stringify(...)"
),
)
.about(param.to_owned()),
));
}
if let Some(found) = find_non_finite(&value, param, false) {
return Err(js_err(
WireError::new(
crate::wire::code::VALUE_NOT_FINITE,
found.index,
found.message(),
)
.about(found.parameter),
));
}
let json: serde_json::Value = serde_wasm_bindgen::from_value(value).map_err(|e| {
js_err(WireError::new(
crate::wire::code::MALFORMED_INPUT,
None,
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, WireError> {
serde_path_to_error::deserialize(json).map_err(|e| {
let (parameter, index) = failure_site(param, e.path());
WireError::new(
crate::wire::code::MALFORMED_INPUT,
index,
format!("{param}: {}: {}", e.path(), e.inner()),
)
.about(parameter)
})
}
#[derive(Debug, Clone, PartialEq)]
enum Element {
Number(f64),
Other(String),
}
fn numbers_from(
param: &'static str,
elements: impl IntoIterator<Item = Element>,
) -> Result<Vec<f64>, WireError> {
let mut out = Vec::new();
for (i, element) in elements.into_iter().enumerate() {
match element {
Element::Number(x) if x.is_finite() => out.push(x),
Element::Number(x) => {
return Err(WireError::new(
crate::wire::code::VALUE_NOT_FINITE,
Some(i),
format!("{param}[{i}]: expected a finite number, got {x}"),
)
.about(param))
}
Element::Other(kind) => {
return Err(WireError::new(
crate::wire::code::MALFORMED_INPUT,
Some(i),
format!("{param}[{i}]: expected a number, got {kind}"),
)
.about(param))
}
}
}
Ok(out)
}
fn read_numbers(value: &JsValue, param: &'static str) -> Result<Vec<f64>, WireError> {
use wasm_bindgen::JsCast;
if let Some(typed) = value.dyn_ref::<js_sys::Float64Array>() {
return numbers_from(param, typed.to_vec().into_iter().map(Element::Number));
}
if !js_sys::Array::is_array(value) {
return Err(WireError::new(
crate::wire::code::MALFORMED_INPUT,
None,
format!("{param}: expected an array of numbers or a Float64Array"),
)
.about(param));
}
let array: &js_sys::Array = value.unchecked_ref();
numbers_from(
param,
array.iter().map(|item| match item.as_f64() {
Some(x) => Element::Number(x),
None if item.is_null() => Element::Other("null".to_string()),
None => Element::Other(item.js_typeof().as_string().unwrap_or_default()),
}),
)
}
fn at_row(e: WireError, param: &'static str, row: usize) -> WireError {
let path = format!("{param}[{row}]");
let message = e.message.replacen(param, &path, 1);
WireError { message, ..e }.about(path)
}
fn read_number_rows(value: &JsValue, param: &'static str) -> Result<Vec<Vec<f64>>, WireError> {
use wasm_bindgen::JsCast;
if !js_sys::Array::is_array(value) {
return Err(WireError::new(
crate::wire::code::MALFORMED_INPUT,
None,
format!("{param}: expected an array of number arrays"),
)
.about(param));
}
let array: &js_sys::Array = value.unchecked_ref();
array
.iter()
.enumerate()
.map(|(row, item)| read_numbers(&item, param).map_err(|e| at_row(e, param, row)))
.collect()
}
#[wasm_bindgen(unchecked_return_type = "XbarRChartDto")]
pub fn xbar_r_chart(
#[wasm_bindgen(unchecked_param_type = "number[][]")] data: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "RuleOptions | null")] options: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let subgroups = read_number_rows(&data, "data").map_err(js_err)?;
let rules = rules_option(options)?;
to_js(&xbar_r_dto(subgroups, rules).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "XbarSChartDto")]
pub fn xbar_s_chart(
#[wasm_bindgen(unchecked_param_type = "number[][]")] data: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "RuleOptions | null")] options: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let subgroups = read_number_rows(&data, "data").map_err(js_err)?;
let rules = rules_option(options)?;
to_js(&xbar_s_dto(subgroups, rules).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "ImrChartDto")]
pub fn imr_chart(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] values: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "RuleOptions | null")] options: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let values = read_numbers(&values, "values").map_err(js_err)?;
let rules = rules_option(options)?;
to_js(&imr_dto(values, rules).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "ChartPointDto[]")]
pub fn run_rules(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] values: JsValue,
#[wasm_bindgen(unchecked_param_type = "LimitsInputDto")] limits: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "RuleOptions | null")] options: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let values = read_numbers(&values, "values").map_err(js_err)?;
let limits: LimitsInputDto = from_js(limits, "limits")?;
let rules = rules_option(options)?;
to_js(&run_rules_dto(values, limits, rules).map_err(js_err)?)
}
#[wasm_bindgen(typescript_custom_section)]
const OPTIONS_TS: &'static str = r#"
/** A run test, by the name a point's `violations` reports it under. */
export type RunRule = "BeyondLimits" | "NineOneSide" | "SixTrend" | "FourteenAlternating" | "TwoOfThreeBeyond2Sigma" | "FourOfFiveBeyond1Sigma" | "FifteenWithin1Sigma" | "EightBeyond1Sigma" | "EightOneSide";
/** A named set of run tests: the 3-sigma test alone, Western Electric's four, or Nelson's eight. */
export type RuleSetName = "shewhart" | "western_electric" | "nelson";
/** Which run tests to apply: a named set, or the tests spelt out. Omitted or `null`: `"shewhart"`. `[]`: none. */
export interface RuleOptions {
rules?: RuleSetName | RunRule[] | null;
}
/** A known centre line from a Phase I study. */
export interface PChartOptions {
p_bar?: number | null;
}
/** A known centre line and dispersion factor from a Phase I study. */
export interface LaneyPChartOptions {
p_bar?: number | null;
phi?: number | null;
}
/** A known centre line from a Phase I study. */
export interface UChartOptions {
u_bar?: number | null;
}
/** A known centre line and dispersion factor from a Phase I study. */
export interface LaneyUChartOptions {
u_bar?: number | null;
phi?: number | null;
}
"#;
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()),
}
}
fn standard_option(options: Option<JsValue>) -> Result<AttributeStandardDto, JsValue> {
match options {
Some(o) if !o.is_undefined() && !o.is_null() => from_js(o, "options"),
_ => Ok(AttributeStandardDto::default()),
}
}
#[wasm_bindgen(unchecked_return_type = "PChartDto")]
pub fn p_chart(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] samples: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "PChartOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let json: serde_json::Value = from_js(samples, "samples")?;
let samples = count_pairs(&json, "samples").map_err(js_err)?;
let standard = standard_option(options)?;
to_js(&p_chart_dto(&samples, &standard).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "CapabilityDto")]
pub fn process_capability(
#[wasm_bindgen(unchecked_param_type = "CapabilityInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: CapabilityInputDto = from_js(input, "input")?;
to_js(&capability_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "AndersonDarlingDto")]
pub fn anderson_darling_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&anderson_darling_dto(&data).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "AdfDto")]
pub fn adf_test(
#[wasm_bindgen(unchecked_param_type = "AdfInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: AdfInputDto = from_js(input, "input")?;
to_js(&adf_dto(input).map_err(js_err)?)
}
#[derive(serde::Deserialize, Default, tsify::Tsify)]
#[tsify(missing_as_null)]
#[serde(deny_unknown_fields)]
struct CorrelationOptions {
#[serde(default)]
#[tsify(optional, type = "\"pearson\" | \"spearman\" | \"kendall\"")]
method: Option<String>,
}
#[wasm_bindgen(unchecked_return_type = "CorrelationMatrixDto")]
pub fn correlation_matrix(
#[wasm_bindgen(unchecked_param_type = "number[][]")] variables: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "CorrelationOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let variables = read_number_rows(&variables, "variables").map_err(js_err)?;
let options: CorrelationOptions = match options {
Some(v) if !v.is_null() && !v.is_undefined() => from_js(v, "options")?,
_ => CorrelationOptions::default(),
};
let input = CorrelationInputDto {
variables,
method: options.method.unwrap_or_else(default_correlation_method),
};
to_js(&correlation_matrix_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "RegressionDto")]
pub fn simple_regression(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] x: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] y: JsValue,
) -> Result<JsValue, JsValue> {
let x = read_numbers(&x, "x").map_err(js_err)?;
let y = read_numbers(&y, "y").map_err(js_err)?;
to_js(&simple_regression_dto(RegressionInputDto { x, y }).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "DistributionFitDto[]")]
pub fn fit_best(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&fit_best_dto(&data).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "LaneyPChartDto")]
pub fn laney_p_chart(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] samples: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "LaneyPChartOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let json: serde_json::Value = from_js(samples, "samples")?;
let samples = count_pairs(&json, "samples").map_err(js_err)?;
let standard = standard_option(options)?;
to_js(&laney_p_dto(&samples, &standard).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "FixedLimitChartDto")]
pub fn np_chart(
#[wasm_bindgen(unchecked_param_type = "number[]")] defectives: JsValue,
#[wasm_bindgen(unchecked_param_type = "number")] sample_size: JsValue,
) -> Result<JsValue, JsValue> {
let defectives: serde_json::Value = from_js(defectives, "defectives")?;
let defectives = count_rows(&defectives, "defectives").map_err(js_err)?;
let sample_size: serde_json::Value = from_js(sample_size, "sample_size")?;
let sample_size = sample_size_value(&sample_size, "sample_size").map_err(js_err)?;
to_js(&np_chart_dto(&defectives, sample_size).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "FixedLimitChartDto")]
pub fn c_chart(
#[wasm_bindgen(unchecked_param_type = "number[]")] defects: JsValue,
) -> Result<JsValue, JsValue> {
let defects: serde_json::Value = from_js(defects, "defects")?;
let defects = count_rows(&defects, "defects").map_err(js_err)?;
to_js(&c_chart_dto(&defects).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "UChartDto")]
pub fn u_chart(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] samples: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "UChartOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let json: serde_json::Value = from_js(samples, "samples")?;
let samples = rate_pairs(&json, "samples").map_err(js_err)?;
let standard = standard_option(options)?;
to_js(&u_chart_dto(&samples, &standard).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "LaneyUChartDto")]
pub fn laney_u_chart(
#[wasm_bindgen(unchecked_param_type = "[number, number][]")] samples: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "LaneyUChartOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let json: serde_json::Value = from_js(samples, "samples")?;
let samples = rate_pairs(&json, "samples").map_err(js_err)?;
let standard = standard_option(options)?;
to_js(&laney_u_dto(&samples, &standard).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "GChartDto")]
pub fn g_chart(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] gaps: JsValue,
) -> Result<JsValue, JsValue> {
let gaps = read_numbers(&gaps, "gaps").map_err(js_err)?;
to_js(&g_chart_dto(&gaps).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TChartDto")]
pub fn t_chart(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
to_js(&t_chart_dto(×).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn one_sample_t_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
mu0: f64,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&one_sample_t_dto(&data, mu0).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn two_sample_t_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] a: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] b: JsValue,
) -> Result<JsValue, JsValue> {
let a = read_numbers(&a, "a").map_err(js_err)?;
let b = read_numbers(&b, "b").map_err(js_err)?;
to_js(&two_sample_t_dto(&a, &b).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn paired_t_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] x: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] y: JsValue,
) -> Result<JsValue, JsValue> {
let x = read_numbers(&x, "x").map_err(js_err)?;
let y = read_numbers(&y, "y").map_err(js_err)?;
to_js(&paired_t_dto(&x, &y).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn mann_whitney_u_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] a: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] b: JsValue,
) -> Result<JsValue, JsValue> {
let a = read_numbers(&a, "a").map_err(js_err)?;
let b = read_numbers(&b, "b").map_err(js_err)?;
to_js(&mann_whitney_dto(&a, &b).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn wilcoxon_signed_rank_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] x: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] y: JsValue,
) -> Result<JsValue, JsValue> {
let x = read_numbers(&x, "x").map_err(js_err)?;
let y = read_numbers(&y, "y").map_err(js_err)?;
to_js(&wilcoxon_dto(&x, &y).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn chi_squared_goodness_of_fit(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] observed: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] expected: JsValue,
) -> Result<JsValue, JsValue> {
let observed = read_numbers(&observed, "observed").map_err(js_err)?;
let expected = read_numbers(&expected, "expected").map_err(js_err)?;
to_js(&chi_squared_gof_dto(&observed, &expected).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn jarque_bera_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&jarque_bera_dto(&data).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "ShapiroWilkDto")]
pub fn shapiro_wilk_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&shapiro_wilk_dto(&data).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "MannKendallDto")]
pub fn mann_kendall_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] data: JsValue,
) -> Result<JsValue, JsValue> {
let data = read_numbers(&data, "data").map_err(js_err)?;
to_js(&mann_kendall_dto(&data).map_err(js_err)?)
}
fn read_observation(value: JsValue) -> Result<ObservationDto, JsValue> {
let name = js_sys::Reflect::get(&value, &JsValue::from_str("truncation"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default();
check_truncation(&name).map_err(js_err)?;
from_js(value, "observation")
}
fn read_counter_options(
options: Option<JsValue>,
) -> Result<crate::wire::counter::CounterOptionsDto, JsValue> {
match options {
Some(o) if !o.is_undefined() && !o.is_null() => from_js(o, "options"),
_ => Ok(Default::default()),
}
}
#[wasm_bindgen(unchecked_return_type = "CounterIncrementDto[]")]
pub fn counter_increments(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] values: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "CounterOptionsDto | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
let values = read_numbers(&values, "values").map_err(js_err)?;
let options = read_counter_options(options)?;
to_js(&crate::wire::counter::increments_dto(×, &values, &options).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "CounterTotalDto[]")]
pub fn counter_totals(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] values: JsValue,
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] edges: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "CounterOptionsDto | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
let values = read_numbers(&values, "values").map_err(js_err)?;
let edges = read_numbers(&edges, "edges").map_err(js_err)?;
let options = read_counter_options(options)?;
to_js(&crate::wire::counter::totals_dto(×, &values, &edges, &options).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TrendTestDto")]
pub fn laplace_trend_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
#[wasm_bindgen(unchecked_param_type = "ObservationDto")] observation: JsValue,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
let o = read_observation(observation)?;
to_js(&laplace_dto(×, o).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TrendTestDto")]
pub fn mil_hdbk_189_test(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
#[wasm_bindgen(unchecked_param_type = "ObservationDto")] observation: JsValue,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
let o = read_observation(observation)?;
to_js(&mil_hdbk_189_dto(×, o).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "PowerLawFitDto")]
pub fn power_law_process_fit(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] times: JsValue,
#[wasm_bindgen(unchecked_param_type = "ObservationDto")] observation: JsValue,
) -> Result<JsValue, JsValue> {
let times = read_numbers(×, "times").map_err(js_err)?;
let o = read_observation(observation)?;
to_js(&power_law_dto(×, o).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "AnovaDto")]
pub fn one_way_anova(
#[wasm_bindgen(unchecked_param_type = "number[][]")] groups: JsValue,
) -> Result<JsValue, JsValue> {
let groups = read_number_rows(&groups, "groups").map_err(js_err)?;
to_js(&one_way_anova_dto(&groups).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn kruskal_wallis_test(
#[wasm_bindgen(unchecked_param_type = "number[][]")] groups: JsValue,
) -> Result<JsValue, JsValue> {
let groups = read_number_rows(&groups, "groups").map_err(js_err)?;
to_js(&groups_test_dto("kruskal_wallis_test", &groups).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn levene_test(
#[wasm_bindgen(unchecked_param_type = "number[][]")] groups: JsValue,
) -> Result<JsValue, JsValue> {
let groups = read_number_rows(&groups, "groups").map_err(js_err)?;
to_js(&groups_test_dto("levene_test", &groups).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn bartlett_test(
#[wasm_bindgen(unchecked_param_type = "number[][]")] groups: JsValue,
) -> Result<JsValue, JsValue> {
let groups = read_number_rows(&groups, "groups").map_err(js_err)?;
to_js(&groups_test_dto("bartlett_test", &groups).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn chi_squared_independence(
#[wasm_bindgen(unchecked_param_type = "number[][]")] table: JsValue,
) -> Result<JsValue, JsValue> {
let table = read_number_rows(&table, "table").map_err(js_err)?;
to_js(&chi_squared_independence_dto(&table).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "TestResultDto")]
pub fn fisher_exact_test(
#[wasm_bindgen(unchecked_param_type = "[[number, number], [number, number]]")] table: JsValue,
) -> Result<JsValue, JsValue> {
let table: serde_json::Value = from_js(table, "table")?;
to_js(&fisher_exact_dto(&table).map_err(js_err)?)
}
#[wasm_bindgen]
pub fn bonferroni_correction(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] p_values: JsValue,
) -> Result<Vec<f64>, JsValue> {
let p = read_numbers(&p_values, "p_values").map_err(js_err)?;
p_adjust_dto("bonferroni_correction", &p).map_err(js_err)
}
#[wasm_bindgen]
pub fn benjamini_hochberg(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] p_values: JsValue,
) -> Result<Vec<f64>, JsValue> {
let p = read_numbers(&p_values, "p_values").map_err(js_err)?;
p_adjust_dto("benjamini_hochberg", &p).map_err(js_err)
}
#[wasm_bindgen(unchecked_return_type = "PeltResultDto")]
pub fn detect_changepoints(
#[wasm_bindgen(unchecked_param_type = "PeltInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: PeltInputDto = from_js(input, "input")?;
to_js(&pelt_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "PeltResultDto")]
pub fn detect_changepoints_multi(
#[wasm_bindgen(unchecked_param_type = "MultiPeltInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: MultiPeltInputDto = from_js(input, "input")?;
to_js(&multi_pelt_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "GageRRResultDto")]
pub fn gage_rr_xbar_r(
#[wasm_bindgen(unchecked_param_type = "GageRRInputDto")] 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(unchecked_return_type = "GageRRAnovaResultDto")]
pub fn gage_rr_anova(
#[wasm_bindgen(unchecked_param_type = "GageRRInputDto")] 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(unchecked_return_type = "PercentileCapabilityDto")]
pub fn percentile_capability(
#[wasm_bindgen(unchecked_param_type = "PercentileCapabilityInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: PercentileCapabilityInputDto = from_js(input, "input")?;
to_js(&percentile_capability_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "CusumDto")]
pub fn cusum(
#[wasm_bindgen(unchecked_param_type = "CusumInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: CusumInputDto = from_js(input, "input")?;
to_js(&cusum_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "EwmaDto")]
pub fn ewma(
#[wasm_bindgen(unchecked_param_type = "EwmaInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: EwmaInputDto = from_js(input, "input")?;
to_js(&ewma_dto(input).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "BoxcoxCapabilityDto")]
pub fn boxcox_capability(
#[wasm_bindgen(unchecked_param_type = "BoxcoxCapabilityInputDto")] 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)
}
#[wasm_bindgen(unchecked_return_type = "SeasonalityDto")]
pub fn estimate_period(
#[wasm_bindgen(unchecked_param_type = "SeasonalityInputDto")] 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(unchecked_return_type = "SpectralResidualDto")]
pub fn spectral_residual(
#[wasm_bindgen(unchecked_param_type = "SpectralResidualInputDto")] 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> {
sigma_to_ppm_value(sigma).map_err(js_err)
}
#[wasm_bindgen]
pub fn ppm_to_sigma(ppm: f64) -> Result<f64, JsValue> {
ppm_to_sigma_value(ppm).map_err(js_err)
}
#[wasm_bindgen(unchecked_return_type = "WeibullMleDto")]
pub fn weibull_mle(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] failure_times: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "number[] | Float64Array | null")]
suspension_times: Option<JsValue>,
#[wasm_bindgen(unchecked_optional_param_type = "WeibullMleOptions | null")] options: Option<
JsValue,
>,
) -> Result<JsValue, JsValue> {
let times = read_numbers(&failure_times, "failure_times").map_err(js_err)?;
let suspensions = match suspension_times {
Some(s) if !s.is_undefined() && !s.is_null() => {
read_numbers(&s, "suspension_times").map_err(js_err)?
}
_ => Vec::new(),
};
let options = match options {
Some(o) if !o.is_undefined() && !o.is_null() => from_js::<WeibullMleOptions>(o, "options")?,
_ => WeibullMleOptions::default(),
};
to_js(
&weibull_mle_dto(
×,
&suspensions,
options.confidence,
options.method.as_deref(),
&options.fractions_failed,
)
.map_err(js_err)?,
)
}
#[derive(serde::Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct WeibullMleOptions {
#[serde(default)]
confidence: Option<f64>,
#[serde(default)]
method: Option<String>,
#[serde(default)]
fractions_failed: Vec<f64>,
}
#[wasm_bindgen(typescript_custom_section)]
const WEIBULL_OPTIONS_TS: &'static str = r#"
export interface WeibullMleOptions {
/** Confidence level of `intervals`, in (0, 1). Omitted or `null`: 0.95. */
confidence?: number | null;
/** `"wald"` (default) or `"likelihood_ratio"` -- the profile-likelihood interval. */
method?: "wald" | "likelihood_ratio" | null;
/** Fractions failed (each in (0, 1)) whose B-life and interval to report; 0.1 is B10. */
fractions_failed?: number[];
}
"#;
#[wasm_bindgen(unchecked_return_type = "WeibullMrrDto")]
pub fn weibull_mrr(
#[wasm_bindgen(unchecked_param_type = "number[] | Float64Array")] failure_times: JsValue,
#[wasm_bindgen(unchecked_optional_param_type = "number[] | Float64Array | null")]
suspension_times: Option<JsValue>,
) -> Result<JsValue, JsValue> {
let times = read_numbers(&failure_times, "failure_times").map_err(js_err)?;
let suspensions = match suspension_times {
Some(s) if !s.is_undefined() && !s.is_null() => {
read_numbers(&s, "suspension_times").map_err(js_err)?
}
_ => Vec::new(),
};
to_js(&weibull_mrr_dto(×, &suspensions).map_err(js_err)?)
}
#[wasm_bindgen(unchecked_return_type = "WeibullReliabilityDto")]
pub fn weibull_reliability(
#[wasm_bindgen(unchecked_param_type = "WeibullReliabilityInputDto")] input: JsValue,
) -> Result<JsValue, JsValue> {
let input: WeibullReliabilityInputDto = from_js(input, "input")?;
to_js(&weibull_reliability_dto(input).map_err(js_err)?)
}
#[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.message.contains("unknown field"), "{e}");
assert_eq!(e.code, crate::wire::code::MALFORMED_INPUT);
}
}
}
#[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::*;
use crate::wire::{each_within, Bound, Detail};
#[test]
fn a_t_test_reports_what_the_crate_computes() {
let data = [5.1, 4.9, 5.3, 5.0, 5.2];
let dto = one_sample_t_dto(&data, 5.0).unwrap();
let direct = crate::testing::one_sample_t_test(&data, 5.0).unwrap();
assert_eq!(
(dto.statistic, dto.df, dto.p_value),
(direct.statistic, direct.df, direct.p_value)
);
}
#[test]
fn hypothesis_refusals_name_the_argument_and_row() {
let e = paired_t_dto(&[1.0, 2.0, 3.0], &[1.0, 2.0]).unwrap_err();
assert_eq!(
(e.code, e.parameter.as_deref()),
("dimension_mismatch", Some("y"))
);
let e = one_way_anova_dto(&[vec![1.0, 2.0], vec![3.0]]).unwrap_err();
assert_eq!(
(e.code, e.index),
(crate::wire::code::INSUFFICIENT_DATA.name(), Some(1))
);
let e = chi_squared_independence_dto(&[vec![1.0, 2.0], vec![3.0]]).unwrap_err();
assert_eq!((e.code, e.index), ("dimension_mismatch", Some(1)));
let e = one_sample_t_dto(&[2.0, 2.0, 2.0], 1.0).unwrap_err();
assert_eq!(
(e.code, e.parameter.as_deref()),
(crate::wire::code::INVALID_INPUT.name(), Some("data"))
);
let e = chi_squared_gof_dto(&[3.0, 4.0], &[3.5, 0.0]).unwrap_err();
assert_eq!(
(e.code, e.index),
(crate::wire::code::PARAMETER_OUT_OF_RANGE.name(), Some(1))
);
}
#[test]
fn fisher_reads_a_two_by_two_table_of_whole_counts() {
let ok = fisher_exact_dto(&serde_json::json!([[3, 1], [1, 3]])).unwrap();
assert!(ok.p_value > 0.0 && ok.p_value <= 1.0);
let e = fisher_exact_dto(&serde_json::json!([[3, 1.5], [1, 3]])).unwrap_err();
assert_eq!(
(e.code, e.index),
(crate::wire::code::COUNT_NOT_WHOLE.name(), Some(1))
);
let e = fisher_exact_dto(&serde_json::json!([[3, 1, 2], [1, 3, 0]])).unwrap_err();
assert_eq!(e.code, "dimension_mismatch");
}
#[test]
fn null_in_a_number_array_is_refused_at_its_index() {
let e = numbers_from(
"data",
[
Element::Number(1.0),
Element::Other("null".into()),
Element::Number(3.0),
],
)
.unwrap_err();
assert_eq!(e.code, crate::wire::code::MALFORMED_INPUT);
assert_eq!(e.index, Some(1));
assert_eq!(e.parameter.as_deref(), Some("data"));
}
#[test]
fn a_non_finite_element_is_value_not_finite() {
let e = numbers_from("gaps", [2.0, f64::NAN].map(Element::Number)).unwrap_err();
assert_eq!(e.code, crate::wire::code::VALUE_NOT_FINITE);
assert_eq!(e.index, Some(1));
}
#[test]
fn rare_event_charts_need_three_values_and_say_so() {
let e = crate::wire::at_least(vec![1.0, 2.0], 3, "times").unwrap_err();
assert_eq!(e.code, crate::wire::code::INSUFFICIENT_DATA);
assert!(e.message.contains("at least 3"), "{}", e.message);
assert!(crate::spc::t_chart(&[1.0, 2.0]).is_none());
assert!(crate::spc::t_chart(&[1.0, 2.0, 3.0]).is_some());
assert!(crate::spc::g_chart(&[1.0, 2.0]).is_none());
}
#[test]
fn a_value_outside_the_domain_is_named_where_it_sits() {
let e = each_within(
&[1.0, 0.0, 2.0],
"times",
Bound::Open(0.0),
Bound::Unbounded,
)
.unwrap_err();
assert_eq!(e.code, crate::wire::code::PARAMETER_OUT_OF_RANGE);
assert_eq!(e.index, Some(1));
assert_eq!(e.parameter.as_deref(), Some("times"));
assert_eq!(
e.details,
vec![
("min", Detail::Null),
("exclusive_min", Detail::Num(0.0)),
("max", Detail::Null),
("got", Detail::Num(0.0)),
]
);
assert_eq!(e.message, "times[1]: must be > 0, got 0");
assert!(each_within(&[0.0, 2.0], "gaps", Bound::Closed(0.0), Bound::Unbounded).is_ok());
}
#[test]
fn rules_option_absent_means_the_shewhart_set() {
use crate::spc::{RuleSet, ViolationType};
for absent in [
None,
Some(serde_json::Value::Null),
Some(serde_json::json!({})),
Some(serde_json::json!({ "rules": null })),
] {
let set = rules_from_json(absent).unwrap();
assert_eq!(set, RuleSet::shewhart());
assert_eq!(
set.iter().collect::<Vec<_>>(),
[ViolationType::BeyondLimits]
);
}
}
#[test]
fn rules_option_names_a_set() {
use crate::spc::{RuleSet, ViolationType};
for (name, set) in [
("shewhart", RuleSet::shewhart()),
("western_electric", RuleSet::western_electric()),
("nelson", RuleSet::nelson()),
] {
assert_eq!(
rules_from_json(Some(serde_json::json!({ "rules": name }))).unwrap(),
set
);
}
let we = RuleSet::western_electric();
assert!(we.contains(ViolationType::EightOneSide));
assert!(!we.contains(ViolationType::NineOneSide));
assert_eq!(we.len(), 4);
assert_eq!(RuleSet::nelson().len(), 8);
assert!(!RuleSet::nelson().contains(ViolationType::EightOneSide));
}
#[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_eq!(
(err.code, err.parameter.as_deref()),
("unknown_option", Some("rules"))
);
assert!(err.message.contains("Rule1"), "{err}");
assert_eq!(
err.details[0],
("got", crate::wire::Detail::Text("Rule1".into()))
);
let rule_names: Vec<&str> = crate::spc::ViolationType::ALL
.iter()
.map(|v| v.name())
.collect();
assert_eq!(
err.details[1],
("expected", crate::wire::Detail::List(rule_names))
);
let err =
rules_from_json(Some(serde_json::json!({ "rules": "BeyondLimits" }))).unwrap_err();
assert_eq!(
(err.code, err.parameter.as_deref()),
("unknown_option", Some("rules"))
);
assert_eq!(
err.details[1],
(
"expected",
crate::wire::Detail::List(crate::spc::RuleSet::NAMES.to_vec())
)
);
let err = rules_from_json(Some(serde_json::json!({ "rules": 3 }))).unwrap_err();
assert_eq!(err.code, "malformed_input");
assert!(err.message.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").map_err(|e| e.message)?)
.map_err(|e| format!("{} [{}]", e.message, e.parameter.as_deref().unwrap_or("-")))
}
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.ends_with("[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,
sigma_within_df: None,
confidence: None,
};
let e = capability_dto(input).expect_err("non-finite sigma must be refused");
assert_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("sigma_within")),
"{bad} -> {e}"
);
}
}
#[test]
fn capability_intervals_on_the_wire() {
let sigma = sigma_within_from_chart();
let overall = dto(json!({ "data": flat(), "usl": 11.0, "lsl": 9.0 })).unwrap();
let ci = &overall.intervals;
assert_eq!(ci.confidence, 0.95);
let (pp, ppk) = (ci.pp.as_ref().unwrap(), ci.ppk.as_ref().unwrap());
assert!(pp.lower < overall.pp.unwrap() && overall.pp.unwrap() < pp.upper);
assert!(ppk.lower < overall.ppk.unwrap() && overall.ppk.unwrap() < ppk.upper);
assert!(ci.cp.is_none() && ci.cpk.is_none());
let n = flat().len() as f64;
let within = dto(json!({
"data": flat(), "usl": 11.0, "lsl": 9.0,
"sigma_within": sigma, "sigma_within_df": n - 5.0, "confidence": 0.90
}))
.unwrap();
let ci = &within.intervals;
assert_eq!(ci.confidence, 0.90);
let cpk = ci.cpk.as_ref().unwrap();
assert!(cpk.lower < within.cpk.unwrap() && within.cpk.unwrap() < cpk.upper);
let core = crate::capability::ProcessCapability::new(Some(11.0), Some(9.0))
.unwrap()
.compute(&flat(), sigma)
.unwrap()
.intervals(0.90, Some(n - 5.0))
.unwrap();
let core_cpk = core.cpk.unwrap();
assert_eq!((cpk.lower, cpk.upper), (core_cpk.lower, core_cpk.upper));
assert!(
within.intervals.ppk.as_ref().unwrap().upper
< overall.intervals.ppk.as_ref().unwrap().upper
);
}
#[test]
fn chart_sigma_and_its_df_compose_into_cp_cpk_intervals() {
let chart = xbar_r_dto(
SUBGROUPS.iter().map(|g| g.to_vec()).collect(),
crate::spc::RuleSet::default(),
)
.expect("valid");
let cap = dto(json!({
"data": flat(), "usl": 11.0, "lsl": 9.0,
"sigma_within": chart.sigma_hat, "sigma_within_df": chart.sigma_hat_df
}))
.unwrap();
let (cp, cpk) = (
cap.intervals.cp.as_ref().unwrap(),
cap.intervals.cpk.as_ref().unwrap(),
);
assert!(cp.lower < cap.cp.unwrap() && cap.cp.unwrap() < cp.upper);
assert!(cpk.lower < cap.cpk.unwrap() && cap.cpk.unwrap() < cpk.upper);
let pooled = (SUBGROUPS.len() * (SUBGROUPS[0].len() - 1)) as f64;
assert!(chart.sigma_hat_df > 0.0 && chart.sigma_hat_df < pooled);
}
#[test]
fn capability_interval_options_are_checked() {
for bad in [0.0, 1.0, -0.1, 1.5] {
let e = capability_dto(
from_json::<CapabilityInputDto>(
json!({ "data": flat(), "usl": 11.0, "confidence": bad }),
"input",
)
.unwrap(),
)
.expect_err("confidence outside (0, 1)");
assert_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("confidence")),
"{bad}"
);
}
let e = capability_dto(
from_json::<CapabilityInputDto>(
json!({ "data": flat(), "usl": 11.0, "sigma_within": 0.2, "sigma_within_df": 0.0 }),
"input",
)
.unwrap(),
)
.expect_err("df must be positive");
assert_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("sigma_within_df"))
);
let e = capability_dto(
from_json::<CapabilityInputDto>(
json!({ "data": flat(), "usl": 11.0, "sigma_within_df": 20.0 }),
"input",
)
.unwrap(),
)
.expect_err("df without the sigma it belongs to");
assert_eq!(
(e.code, e.parameter.as_deref()),
("invalid_option", Some("sigma_within_df"))
);
}
#[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("at least 2 values") && e.ends_with("[data]"),
"{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!(Some(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.message.contains("subgroups[7]"), "{e}");
assert_eq!(
(e.code, e.index, e.parameter.as_deref()),
(
crate::wire::code::DIMENSION_MISMATCH.name(),
Some(7),
Some("subgroups")
)
);
assert_eq!(
e.details,
vec![("expected", Detail::Num(5.0)), ("got", Detail::Num(4.0))]
);
}
}
#[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!(Some(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.message.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.message.contains("lcl <= cl <= ucl"), "{e}");
assert_eq!(e.code, "parameter_out_of_range");
}
#[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.message.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() {
use crate::wire::code;
let e = np_chart_dto(&[5, 101, 3], 100).expect_err("more defectives than inspected");
assert!(e.message.contains("defectives[1]"), "{e}");
assert_eq!(
(e.code, e.index),
(code::DEFECTIVES_EXCEED_SAMPLE.name(), Some(1))
);
let e = np_chart_dto(&[0, 0], 0).expect_err("nothing inspected");
assert!(e.message.contains("sample size"), "{e}");
assert_eq!(
(e.code, e.index),
(code::SAMPLE_SIZE_NOT_WHOLE.name(), None)
);
}
#[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, &Default::default()).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)], &Default::default())
.expect_err("zero units");
assert!(e.message.contains("samples[1]"), "{e}");
assert_eq!(
(e.code, e.index),
(crate::wire::code::UNITS_NOT_POSITIVE.name(), Some(1))
);
}
#[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, &Default::default()).expect("valid");
let expected = crate::spc::laney_u_chart(&samples, None).expect("valid");
assert_eq!((d.u_bar, d.phi), (expected.u_bar, expected.phi));
let e = laney_u_dto(&samples[..2], &Default::default()).expect_err("fewer than 3 samples");
assert_eq!(
(e.code, e.index),
(crate::wire::code::INSUFFICIENT_DATA.name(), None)
);
let e = laney_u_dto(&[(3, 1.0), (5, -1.0), (2, 0.8)], &Default::default())
.expect_err("negative units");
assert!(e.message.contains("samples[1]"), "{e}");
assert_eq!(
(e.code, e.index),
(crate::wire::code::UNITS_NOT_POSITIVE.name(), Some(1))
);
}
#[test]
fn p_chart_refuses_a_sample_with_no_proportion() {
use crate::wire::code;
let e = p_chart_dto(&[(3, 100), (12, 10), (4, 100)], &Default::default())
.expect_err("12 of 10");
assert!(e.message.contains("samples[1]"), "{e}");
assert_eq!(
(e.code, e.index),
(code::DEFECTIVES_EXCEED_SAMPLE.name(), Some(1))
);
let e = p_chart_dto(&[(3, 100), (0, 0)], &Default::default()).expect_err("0 of 0");
assert!(e.message.contains("samples[1]"), "{e}");
assert_eq!(
(e.code, e.index),
(code::SAMPLE_SIZE_NOT_WHOLE.name(), Some(1))
);
let ok = p_chart_dto(&[(3, 100), (5, 120), (2, 80)], &Default::default()).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),
crate::capability::DEFAULT_LAMBDA_RANGE,
)
.expect("same call");
let native_i = native.indices.expect("limits given");
assert!((dto.lambda - native.lambda).abs() < 1e-12);
assert_eq!(dto.lambda_at_bound, native.lambda_at_bound);
assert_eq!(dto.pp, native_i.pp);
assert_eq!(dto.ppk, native_i.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], "usl": 9.0 }), json!({ "data": [1.0, 2.0, 3.0, 4.0], "lambda_range": [2.0, -2.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 boxcox_without_limits_and_with_a_narrow_range_reports_the_bound() {
let n = 100;
let data: Vec<f64> = (1..=n)
.map(|i| {
let p = (i as f64 - 0.5) / n as f64;
let z = 10.0 + 2.0 * u_numflow::special::inverse_normal_cdf(p);
(4.0 * z + 1.0).powf(0.25)
})
.collect();
let narrow: super::BoxcoxCapabilityInputDto = super::from_json(
json!({ "data": data, "lambda_range": [-2.0, 2.0] }),
"input",
)
.expect("valid input");
let dto = super::boxcox_capability_dto(narrow).expect("no limits is allowed");
assert!(dto.lambda_at_bound);
assert_eq!(dto.lambda, 2.0);
assert!(
dto.pp.is_none() && dto.ppk.is_none(),
"no limits, no indices"
);
let default: super::BoxcoxCapabilityInputDto =
super::from_json(json!({ "data": data }), "input").expect("valid input");
let dto = super::boxcox_capability_dto(default).expect("default range");
assert!(!dto.lambda_at_bound, "lambda={}", dto.lambda);
}
#[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());
}
#[test]
fn a_bad_value_inside_an_input_object_is_refused_where_it_sits() {
let e = super::from_json::<super::CusumInputDto>(
json!({ "data": [1.0, null, 3.0], "target": 1.0, "sigma": 1.0 }),
"input",
)
.err()
.expect("null is not a number");
assert_eq!(
(e.code, e.parameter.as_deref(), e.index),
("malformed_input", Some("data"), Some(1)),
"{e}"
);
let e = super::from_json::<super::MultiPeltInputDto>(
json!({ "signals": [[1.0, 2.0], [3.0, "4"]] }),
"input",
)
.err()
.expect("a string is not a number");
assert_eq!(
(e.parameter.as_deref(), e.index),
(Some("signals[1]"), Some(1)),
"{e}"
);
let e = super::from_json::<super::CusumInputDto>(
json!({ "data": [1.0], "target": "ten", "sigma": 1.0 }),
"input",
)
.err()
.expect("target is a number");
assert_eq!((e.parameter.as_deref(), e.index), (Some("target"), None));
let e = super::from_json::<super::CusumInputDto>(json!({ "data": [1.0] }), "input")
.err()
.expect("target is required");
assert_eq!(e.parameter.as_deref(), Some("input"), "{e}");
}
#[test]
fn a_fractional_count_is_malformed_where_it_sits() {
for (body, parameter) in [
(
json!({ "data": vec![1.0; 20], "max_lags": 2.5 }),
"max_lags",
),
(
json!({ "data": vec![1.0; 20], "averaging_window": 2.5 }),
"averaging_window",
),
(
json!({ "data": [1.0], "min_segment_len": 2.5 }),
"min_segment_len",
),
] {
let e = match parameter {
"max_lags" => super::from_json::<super::AdfInputDto>(body, "input").map(|_| ()),
"averaging_window" => {
super::from_json::<super::SpectralResidualInputDto>(body, "input").map(|_| ())
}
_ => super::from_json::<super::PeltInputDto>(body, "input").map(|_| ()),
}
.expect_err("a fraction is not a count");
assert_eq!(
(e.code, e.parameter.as_deref(), e.index),
("malformed_input", Some(parameter), None),
"{e}"
);
assert!(e.message.contains("expected a whole number"), "{e}");
}
}
#[test]
fn a_negative_count_is_out_of_range_not_malformed() {
let ten = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 5.0, 8.0, 7.0, 9.0];
let adf = |max_lags: serde_json::Value| {
super::adf_dto(
super::from_json(json!({ "data": ten, "max_lags": max_lags }), "input")
.expect("a whole number reads"),
)
};
for got in [-1.0, 4.0] {
let e = adf(json!(got as i64)).expect_err("outside [0, 3]");
assert_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("max_lags"))
);
assert_eq!(
e.details,
vec![
("min", Detail::Num(0.0)),
("max", Detail::Num(3.0)),
("got", Detail::Num(got)),
]
);
}
assert!(adf(json!(2)).expect("in range").n_lags <= 2);
assert_eq!(
adf(json!(2)).expect("in range").n_lags,
adf(json!(2.0)).expect("in range").n_lags
);
let e = super::pelt_dto(
super::from_json(
json!({ "data": [1.0, 2.0], "min_segment_len": -1 }),
"input",
)
.expect("a whole number reads"),
)
.err()
.expect("below 2");
assert_eq!(
(e.code, e.parameter.as_deref(), &e.details[..]),
(
"parameter_out_of_range",
Some("min_segment_len"),
&[
("min", Detail::Num(2.0)),
("max", Detail::Null),
("got", Detail::Num(-1.0)),
][..]
)
);
}
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");
let e = super::cusum_dto(empty).expect_err("empty");
assert_eq!(
(e.code, e.parameter.as_deref()),
("empty_input", Some("data"))
);
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_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("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");
let e = super::ewma_dto(empty).expect_err("empty");
assert_eq!(
(e.code, e.parameter.as_deref()),
("empty_input", Some("data"))
);
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_eq!(
(e.code, e.parameter.as_deref()),
("parameter_out_of_range", Some("lambda")),
"{e}"
);
assert_eq!(
e.details,
vec![
("min", crate::wire::Detail::Null),
("exclusive_min", crate::wire::Detail::Num(0.0)),
("max", crate::wire::Detail::Num(1.0)),
("got", crate::wire::Detail::Num(lambda)),
]
);
assert_eq!(e.message, format!("lambda must be in (0, 1], got {lambda}"));
}
}
#[test]
fn laney_p_chart_refuses_a_sample_with_no_proportion() {
let e = laney_p_dto(&[(3, 100), (0, 0), (4, 100), (2, 100)], &Default::default())
.expect_err("0 of 0");
assert!(e.message.contains("samples[1]"), "{e}");
assert_eq!(e.index, Some(1));
let e = laney_p_dto(&[(3, 100), (5, 120)], &Default::default())
.expect_err("fewer than 3 samples");
assert_eq!(e.code, crate::wire::code::INSUFFICIENT_DATA);
}
}
#[cfg(test)]
mod code_catalog_tests {
#[test]
fn the_readme_documents_every_code_and_no_other() {
let readme = include_str!("../README.md");
let mut documented = std::collections::BTreeSet::new();
let mut in_table = false;
for line in readme.lines() {
if line.starts_with("| `code` |") {
in_table = true;
continue;
}
if !line.starts_with('|') {
in_table = false;
continue;
}
if in_table && !line.starts_with("|---") {
let first = line.split('|').nth(1).expect("a cell");
for code in first.split('ยท') {
documented.insert(code.trim().trim_matches('`').to_string());
}
}
}
let catalog = crate::wire::code_catalog();
let published: std::collections::BTreeSet<String> = catalog
.refusals
.iter()
.map(|r| r.code.clone())
.chain(catalog.violations.iter().map(|v| v.code.clone()))
.collect();
assert_eq!(documented, published);
}
}