use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SlipVerdict {
ArcStart,
Continuous {
statistic: f64,
},
Slipped {
statistic: f64,
},
}
impl SlipVerdict {
#[must_use]
pub const fn breaks_arc(self) -> bool {
matches!(self, Self::ArcStart | Self::Slipped { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetectorError {
NonPositiveThreshold,
NonPositiveGap,
WindowTooShort {
order: usize,
window: usize,
},
}
impl fmt::Display for DetectorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::NonPositiveThreshold => write!(f, "the threshold is not finite and positive"),
Self::NonPositiveGap => write!(f, "the maximum gap is not finite and positive"),
Self::WindowTooShort { order, window } => write!(
f,
"a window of {window} cannot test a polynomial of order {order}"
),
}
}
}
impl std::error::Error for DetectorError {}
fn check_limits(threshold: f64, max_gap: f64) -> Result<(), DetectorError> {
if !(threshold.is_finite() && threshold > 0.0) {
return Err(DetectorError::NonPositiveThreshold);
}
if !(max_gap.is_finite() && max_gap > 0.0) {
return Err(DetectorError::NonPositiveGap);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct GeometryFreeDetector {
threshold: f64,
max_gap: f64,
previous: Option<(f64, f64)>,
}
impl GeometryFreeDetector {
pub fn new(threshold: f64, max_gap: f64) -> Result<Self, DetectorError> {
check_limits(threshold, max_gap)?;
Ok(Self {
threshold,
max_gap,
previous: None,
})
}
#[must_use]
pub const fn threshold(&self) -> f64 {
self.threshold
}
pub fn reset(&mut self) {
self.previous = None;
}
pub fn update(&mut self, time: f64, value: f64) -> SlipVerdict {
if !(time.is_finite() && value.is_finite()) {
self.previous = None;
return SlipVerdict::ArcStart;
}
let Some((t_prev, x_prev)) = self.previous else {
self.previous = Some((time, value));
return SlipVerdict::ArcStart;
};
self.previous = Some((time, value));
let gap = time - t_prev;
if gap <= 0.0 || gap > self.max_gap {
return SlipVerdict::ArcStart;
}
let statistic = (value - x_prev).abs();
if statistic > self.threshold {
SlipVerdict::Slipped { statistic }
} else {
SlipVerdict::Continuous { statistic }
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct PolynomialFitDetector {
order: usize,
window: usize,
threshold: f64,
max_gap: f64,
samples: Vec<(f64, f64)>,
}
impl PolynomialFitDetector {
pub fn new(
order: usize,
window: usize,
threshold: f64,
max_gap: f64,
) -> Result<Self, DetectorError> {
check_limits(threshold, max_gap)?;
if window < order + 2 {
return Err(DetectorError::WindowTooShort { order, window });
}
Ok(Self {
order,
window,
threshold,
max_gap,
samples: Vec::new(),
})
}
#[must_use]
pub const fn threshold(&self) -> f64 {
self.threshold
}
#[must_use]
pub fn buffered(&self) -> usize {
self.samples.len()
}
pub fn reset(&mut self) {
self.samples.clear();
}
pub fn update(&mut self, time: f64, value: f64) -> SlipVerdict {
if !(time.is_finite() && value.is_finite()) {
self.samples.clear();
return SlipVerdict::ArcStart;
}
if let Some(&(t_prev, _)) = self.samples.last() {
let gap = time - t_prev;
if gap <= 0.0 || gap > self.max_gap {
self.samples.clear();
}
}
if self.samples.len() < self.window {
self.samples.push((time, value));
return SlipVerdict::ArcStart;
}
let verdict = match predict(&self.samples, self.order, time) {
None => {
self.samples.clear();
self.samples.push((time, value));
return SlipVerdict::ArcStart;
}
Some(prediction) => {
let statistic = (value - prediction).abs();
if statistic > self.threshold {
SlipVerdict::Slipped { statistic }
} else {
SlipVerdict::Continuous { statistic }
}
}
};
if matches!(verdict, SlipVerdict::Slipped { .. }) {
self.samples.clear();
} else {
self.samples.remove(0);
}
self.samples.push((time, value));
verdict
}
}
fn predict(samples: &[(f64, f64)], order: usize, at: f64) -> Option<f64> {
let n = order + 1;
let (t_ref, _) = *samples.last()?;
let span = samples
.iter()
.map(|&(t, _)| (t - t_ref).abs())
.fold(0.0_f64, f64::max);
let scale = if span > 0.0 { span } else { 1.0 };
let mut normal = vec![0.0_f64; n * n];
let mut rhs = vec![0.0_f64; n];
for &(t, y) in samples {
let tau = (t - t_ref) / scale;
let mut powers = vec![1.0_f64; n];
for k in 1..n {
powers[k] = powers[k - 1] * tau;
}
for i in 0..n {
rhs[i] += powers[i] * y;
for j in 0..n {
normal[i * n + j] += powers[i] * powers[j];
}
}
}
let coefficients = solve(&mut normal, &mut rhs, n)?;
let tau = (at - t_ref) / scale;
Some(
coefficients
.iter()
.rev()
.fold(0.0, |acc, &c| acc.mul_add(tau, c)),
)
}
fn solve(a: &mut [f64], b: &mut [f64], n: usize) -> Option<Vec<f64>> {
for k in 0..n {
let pivot = (k..n).max_by(|&i, &j| a[i * n + k].abs().total_cmp(&a[j * n + k].abs()))?;
let head = a[pivot * n + k];
if !head.is_finite() || head == 0.0 {
return None;
}
if pivot != k {
for c in 0..n {
a.swap(k * n + c, pivot * n + c);
}
b.swap(k, pivot);
}
for i in k + 1..n {
let factor = a[i * n + k] / a[k * n + k];
if factor == 0.0 {
continue;
}
for c in k..n {
a[i * n + c] -= factor * a[k * n + c];
}
b[i] -= factor * b[k];
}
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let mut sum = b[i];
for j in i + 1..n {
sum -= a[i * n + j] * x[j];
}
let value = sum / a[i * n + i];
if !value.is_finite() {
return None;
}
x[i] = value;
}
Some(x)
}
#[cfg(test)]
mod tests {
#![allow(clippy::float_cmp, clippy::unwrap_used)]
use super::*;
fn clean_arc(epochs: usize) -> Vec<(f64, f64)> {
(0..epochs)
.map(|k| {
#[allow(clippy::cast_precision_loss)]
let t = k as f64 * 30.0;
(t, 0.10 + 2.0e-4 * t)
})
.collect()
}
#[test]
fn the_geometry_free_detector_finds_a_one_cycle_slip() {
let mut detector = GeometryFreeDetector::new(0.05, 60.0).unwrap();
let arc = clean_arc(10);
let slip_at = 5;
let lambda1 = 0.190_293_672_798_365_f64;
let mut worst_clean = 0.0_f64;
let mut fired = Vec::new();
for (k, &(t, x)) in arc.iter().enumerate() {
let value = if k >= slip_at { x + lambda1 } else { x };
match detector.update(t, value) {
SlipVerdict::ArcStart => assert_eq!(k, 0, "unexpected restart at {k}"),
SlipVerdict::Continuous { statistic } => worst_clean = worst_clean.max(statistic),
SlipVerdict::Slipped { statistic } => fired.push((k, statistic)),
}
}
assert_eq!(fired.len(), 1, "fired at {fired:?}");
assert_eq!(fired[0].0, slip_at);
assert!(
(fired[0].1 - (lambda1 + 6.0e-3)).abs() < 1e-9,
"statistic {}",
fired[0].1
);
assert!((worst_clean - 6.0e-3).abs() < 1e-9, "clean {worst_clean}");
}
#[test]
fn a_long_gap_restarts_rather_than_fires() {
let mut detector = GeometryFreeDetector::new(0.05, 60.0).unwrap();
assert_eq!(detector.update(0.0, 0.10), SlipVerdict::ArcStart);
assert!(matches!(
detector.update(30.0, 0.106),
SlipVerdict::Continuous { .. }
));
assert_eq!(detector.update(330.0, 0.166), SlipVerdict::ArcStart);
assert!(matches!(
detector.update(360.0, 0.172),
SlipVerdict::Continuous { .. }
));
}
#[test]
fn degenerate_samples_restart_the_arc() {
let mut gf = GeometryFreeDetector::new(0.05, 60.0).unwrap();
assert_eq!(gf.update(0.0, 0.10), SlipVerdict::ArcStart);
assert_eq!(gf.update(0.0, 0.10), SlipVerdict::ArcStart, "same epoch");
assert_eq!(gf.update(-30.0, 0.10), SlipVerdict::ArcStart, "backwards");
assert_eq!(gf.update(30.0, f64::NAN), SlipVerdict::ArcStart, "NaN");
assert_eq!(gf.update(60.0, 0.10), SlipVerdict::ArcStart, "after NaN");
let mut pf = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
for (t, x) in clean_arc(6) {
assert_eq!(pf.update(t, x), SlipVerdict::ArcStart);
}
assert_eq!(pf.buffered(), 6);
assert_eq!(pf.update(180.0, f64::INFINITY), SlipVerdict::ArcStart);
assert_eq!(pf.buffered(), 0, "the window survived a non-finite sample");
}
#[test]
fn the_polynomial_detector_predicts_a_quadratic_exactly() {
let mut detector = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
let quadratic = |t: f64| 0.10 + 1.0e-3 * t + 2.0e-6 * t * t;
let mut worst_clean = 0.0_f64;
let (mut fired, mut restarted) = (Vec::new(), Vec::new());
for k in 0..20 {
let t = f64::from(k) * 30.0;
let value = if k >= 12 {
quadratic(t) + 0.19
} else {
quadratic(t)
};
match detector.update(t, value) {
SlipVerdict::ArcStart => restarted.push(k),
SlipVerdict::Continuous { statistic } => worst_clean = worst_clean.max(statistic),
SlipVerdict::Slipped { statistic } => fired.push((k, statistic)),
}
}
assert_eq!(fired.len(), 1, "fired at {fired:?}");
assert_eq!(fired[0].0, 12);
assert!((fired[0].1 - 0.19).abs() < 1e-9, "statistic {}", fired[0].1);
assert_eq!(restarted, vec![0, 1, 2, 3, 4, 5, 13, 14, 15, 16, 17]);
assert!(worst_clean < 1e-9, "clean residual {worst_clean:.3e}");
}
#[test]
fn the_polynomial_fit_follows_curvature_the_first_difference_cannot() {
let curve = |t: f64| 0.10 + 2.0e-6 * t * t;
let mut gf = GeometryFreeDetector::new(0.05, 60.0).unwrap();
let mut pf = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
let (mut worst_difference, mut worst_residual) = (0.0_f64, 0.0_f64);
for k in 0..30 {
let t = f64::from(k) * 30.0;
if let SlipVerdict::Continuous { statistic } | SlipVerdict::Slipped { statistic } =
gf.update(t, curve(t))
{
worst_difference = worst_difference.max(statistic);
}
if let SlipVerdict::Continuous { statistic } | SlipVerdict::Slipped { statistic } =
pf.update(t, curve(t))
{
worst_residual = worst_residual.max(statistic);
}
}
assert!(
worst_difference > 0.05,
"the first difference stayed inside the threshold: {worst_difference:.3e}"
);
assert!(
worst_residual < 1e-9,
"the polynomial fit did not follow the curve: {worst_residual:.3e}"
);
}
#[test]
fn a_detector_refuses_limits_it_cannot_use() {
assert_eq!(
GeometryFreeDetector::new(0.0, 60.0),
Err(DetectorError::NonPositiveThreshold)
);
assert_eq!(
GeometryFreeDetector::new(f64::NAN, 60.0),
Err(DetectorError::NonPositiveThreshold)
);
assert_eq!(
GeometryFreeDetector::new(0.05, -1.0),
Err(DetectorError::NonPositiveGap)
);
assert_eq!(
GeometryFreeDetector::new(0.05, 0.0),
Err(DetectorError::NonPositiveGap)
);
assert_eq!(
PolynomialFitDetector::new(2, 3, 0.05, 60.0),
Err(DetectorError::WindowTooShort {
order: 2,
window: 3
})
);
assert!(PolynomialFitDetector::new(2, 4, 0.05, 60.0).is_ok());
let messages: Vec<String> = [
DetectorError::NonPositiveThreshold,
DetectorError::NonPositiveGap,
DetectorError::WindowTooShort {
order: 2,
window: 3,
},
]
.iter()
.map(ToString::to_string)
.collect();
let distinct: std::collections::HashSet<&String> = messages.iter().collect();
assert_eq!(distinct.len(), 3, "duplicate: {messages:?}");
}
#[test]
fn the_arc_breaking_verdicts_are_the_two_that_break_an_arc() {
assert!(SlipVerdict::ArcStart.breaks_arc());
assert!(SlipVerdict::Slipped { statistic: 1.0 }.breaks_arc());
assert!(!SlipVerdict::Continuous { statistic: 1.0 }.breaks_arc());
}
#[test]
fn a_detector_reports_the_threshold_it_was_built_with() {
assert_eq!(
GeometryFreeDetector::new(0.037, 60.0).unwrap().threshold(),
0.037
);
assert_eq!(
PolynomialFitDetector::new(2, 6, 0.052, 60.0)
.unwrap()
.threshold(),
0.052
);
}
#[test]
fn a_reset_detector_starts_a_new_arc() {
let mut gf = GeometryFreeDetector::new(0.05, 60.0).unwrap();
assert_eq!(gf.update(0.0, 0.10), SlipVerdict::ArcStart);
assert!(matches!(
gf.update(30.0, 0.106),
SlipVerdict::Continuous { .. }
));
gf.reset();
assert_eq!(gf.update(60.0, 0.112), SlipVerdict::ArcStart);
let mut pf = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
for (t, x) in clean_arc(6) {
assert_eq!(pf.update(t, x), SlipVerdict::ArcStart);
}
assert_eq!(pf.buffered(), 6);
pf.reset();
assert_eq!(pf.buffered(), 0);
assert_eq!(pf.update(180.0, 0.136), SlipVerdict::ArcStart);
}
#[test]
fn a_gap_of_exactly_the_maximum_stays_on_the_same_arc() {
let mut gf = GeometryFreeDetector::new(0.05, 60.0).unwrap();
assert_eq!(gf.update(0.0, 0.10), SlipVerdict::ArcStart);
assert!(matches!(
gf.update(60.0, 0.112),
SlipVerdict::Continuous { .. }
));
let mut pf = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
for (t, x) in clean_arc(6) {
assert_eq!(pf.update(t, x), SlipVerdict::ArcStart);
}
assert!(matches!(
pf.update(210.0, 0.142),
SlipVerdict::Continuous { .. }
));
assert_eq!(pf.buffered(), 6, "the window survived a gap of the maximum");
}
#[test]
fn a_gap_past_the_maximum_discards_the_polynomial_window() {
let mut pf = PolynomialFitDetector::new(2, 6, 0.05, 60.0).unwrap();
for (t, x) in clean_arc(6) {
assert_eq!(pf.update(t, x), SlipVerdict::ArcStart);
}
assert_eq!(pf.buffered(), 6);
assert_eq!(pf.update(450.0, 0.19), SlipVerdict::ArcStart);
assert_eq!(pf.buffered(), 1);
}
#[test]
fn a_statistic_of_exactly_the_threshold_is_not_a_slip() {
let (previous, value) = (0.10_f64, 0.15_f64);
let tie = (value - previous).abs();
let mut gf = GeometryFreeDetector::new(tie, 60.0).unwrap();
assert_eq!(gf.update(0.0, previous), SlipVerdict::ArcStart);
assert_eq!(
gf.update(30.0, value),
SlipVerdict::Continuous { statistic: tie }
);
let quadratic = |t: f64| 0.10 + 1.0e-3 * t + 2.0e-6 * t * t;
let arc: Vec<(f64, f64)> = (0..13)
.map(|k| {
let t = f64::from(k) * 30.0;
(
t,
if k >= 12 {
quadratic(t) + 0.19
} else {
quadratic(t)
},
)
})
.collect();
let mut wide = PolynomialFitDetector::new(2, 6, 1.0, 60.0).unwrap();
let mut residual = 0.0_f64;
for &(t, x) in &arc {
if let SlipVerdict::Continuous { statistic } | SlipVerdict::Slipped { statistic } =
wide.update(t, x)
{
residual = statistic;
}
}
assert!(residual > 0.0, "the step left no residual to stand on");
let mut pf = PolynomialFitDetector::new(2, 6, residual, 60.0).unwrap();
let mut verdict = SlipVerdict::ArcStart;
for &(t, x) in &arc {
verdict = pf.update(t, x);
}
assert_eq!(
verdict,
SlipVerdict::Continuous {
statistic: residual
}
);
}
#[test]
fn a_window_at_one_epoch_is_fitted_by_its_mean() {
let samples = [(90.0, 0.10), (90.0, 0.20), (90.0, 0.30)];
let prediction = predict(&samples, 0, 120.0).unwrap();
assert_eq!(prediction, (0.10 + 0.20 + 0.30) / 3.0);
}
#[test]
fn a_window_ending_at_the_time_origin_is_fitted_from_its_span() {
let quadratic = |t: f64| 0.10 + 1.0e-3 * t + 2.0e-6 * t * t;
let samples: Vec<(f64, f64)> = (0..6)
.map(|k| {
let t = f64::from(k) * 30.0 - 150.0;
(t, quadratic(t))
})
.collect();
assert_eq!(samples[5].0, 0.0, "the last epoch is the time origin");
let prediction = predict(&samples, 2, 30.0).unwrap();
assert!(
(prediction - quadratic(30.0)).abs() < 1.0e-15,
"residual {:.3e}",
(prediction - quadratic(30.0)).abs()
);
}
#[test]
fn the_solver_pivots_where_the_normal_equations_never_ask_it_to() {
for (label, mut a, mut b) in [
(
"a zero leading entry",
vec![0.0, 1.0, 0.0, -2.0, 0.0, -2.0, 0.0, 8.0, -2.0],
vec![2.0, -8.0, 10.0],
),
(
"a zero column under the pivot",
vec![1.0, -1.0, -4.0, -1.0, 0.0, 0.0, 0.0, 0.0, 4.0],
vec![-13.0, -1.0, 12.0],
),
] {
let x = solve(&mut a, &mut b, 3).unwrap_or_else(|| panic!("{label}: refused"));
assert_eq!(x, vec![1.0, 2.0, 3.0], "{label}");
}
}
#[test]
fn the_solver_refuses_a_system_it_cannot_reduce() {
let mut singular = vec![1.0, 2.0, 3.0, 2.0, 4.0, 6.0, 1.0, 1.0, 1.0];
let mut rhs = vec![14.0, 28.0, 6.0];
assert_eq!(solve(&mut singular, &mut rhs, 3), None);
let mut infinite = vec![f64::INFINITY, 3.0, 1.0, 8.0, 4.0, 2.0, 4.0, 1.0, 5.0];
let mut rhs = vec![11.0, 22.0, 21.0];
assert_eq!(solve(&mut infinite, &mut rhs, 3), None);
}
}