#[polydat::polydat_node(category = Math, output_names(stable_value, stable))]
fn is_stable(
samples: &[f64],
#[poly_default(0.05f64)] margin: polydat::derive_support::Const<f64>,
#[poly_default(8u64)] min_samples: polydat::derive_support::Const<u64>,
) -> (f64, u64) {
let n = samples.len();
if n == 0 {
return (0.0, 0);
}
let median = median_of(samples);
if n < (*min_samples) as usize {
return (median, 0);
}
let mean = samples.iter().sum::<f64>() / n as f64;
let var = samples
.iter()
.map(|x| {
let d = x - mean;
d * d
})
.sum::<f64>()
/ n as f64;
let threshold = (*margin) * median.abs().max(1.0);
let stable = if var.sqrt() <= threshold { 1 } else { 0 };
(median, stable)
}
fn median_of(samples: &[f64]) -> f64 {
let mut s = samples.to_vec();
let n = s.len();
let mid = n / 2;
let (lower, upper, _) = s.select_nth_unstable_by(mid, f64::total_cmp);
let upper = *upper;
if n % 2 == 1 {
upper
} else {
let below = lower.iter().copied().fold(f64::NEG_INFINITY, f64::max);
0.5 * (below + upper)
}
}
#[cfg(test)]
mod tests {
use super::*;
use polydat::ast::{PolydatNode, SliceArc, Value};
fn window(v: &[f64]) -> Value {
Value::VecF64(SliceArc::from_vec(v.to_vec()))
}
fn eval(node: &IsStable, samples: &[f64]) -> (f64, u64) {
let mut out = [Value::None, Value::None];
node.eval(&[window(samples)], &mut out);
(out[0].as_f64(), out[1].as_u64())
}
#[test]
fn settles_on_a_steady_window_and_reports_the_level() {
let node = IsStable::new(0.05, 4);
let (_, stable) = eval(&node, &[0.0, 1.0, 3.0, 4.5, 5.0, 5.0, 5.0, 5.0]);
assert_eq!(stable, 0, "a window with the ramp in it is not steady");
let (level, stable) = eval(&node, &[5.0; 8]);
assert_eq!(stable, 1, "a steady window is stable");
assert!(
(level - 5.0).abs() < 1e-9,
"the level is the steady value, got {level}"
);
}
#[test]
fn a_trailing_outlier_does_not_move_the_level() {
let node = IsStable::new(0.05, 4);
let (level, stable) = eval(&node, &[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.0]);
assert!(
(level - 5.0).abs() < 1e-9,
"the median absorbs one outlier, got {level}"
);
assert_eq!(stable, 0, "one outlier breaks strict steady state");
}
#[test]
fn reports_unstable_until_min_samples() {
let node = IsStable::new(0.05, 4);
assert_eq!(eval(&node, &[5.0]).1, 0);
assert_eq!(eval(&node, &[5.0; 3]).1, 0);
assert_eq!(eval(&node, &[5.0; 4]).1, 1);
}
#[test]
fn a_near_zero_level_still_settles() {
let node = IsStable::new(0.05, 4);
let (level, stable) = eval(&node, &[0.0; 8]);
assert_eq!(stable, 1);
assert!(level.abs() < 1e-9);
}
#[test]
fn empty_and_even_windows() {
let node = IsStable::new(0.05, 1);
assert_eq!(eval(&node, &[]), (0.0, 0));
assert!((eval(&node, &[1.0, 4.0, 2.0, 3.0]).0 - 2.5).abs() < 1e-12);
}
#[test]
fn both_outputs_read_one_window_in_any_order() {
let mut k = polydat::dsl::compile::compile_polydat_kernel(
"extern samples: vec_f64\n(stable_value, stable) := is_stable(samples, 0.05, 4)",
)
.expect("compile");
k.set_input("samples", window(&[5.0; 8]))
.expect("write the window");
assert_eq!(k.pull("stable").as_u64(), 1);
assert!((k.pull("stable_value").as_f64() - 5.0).abs() < 1e-9);
assert_eq!(k.pull("stable").as_u64(), 1);
}
}