Examples, overhead benchmark, and a covariance-matching doc fix
This commit is contained in:
1 parent
a6e4aa7e0d
commit
481497d76b
10 files changed
+1097
-10
No files matched your search
@@ -23,3 +23,7 @@ criterion = "0.7"
|
||||
[[bench]]
|
||||
name = "ukf"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "overhead"
|
||||
harness = false
|
||||
@@ -0,0 +1,195 @@
|
||||
//! What a filter step costs, against two reference points.
|
||||
//!
|
||||
//! The per-dimension table in `ukf.rs` says what a step costs. It does not say
|
||||
//! whether that is good, which needs something to compare against:
|
||||
//!
|
||||
//! - `analytic_kf` is the closed-form Kalman filter on the same problem. That is
|
||||
//! the floor — the least arithmetic that answers the same question — and it is
|
||||
//! available only because the problem is linear.
|
||||
//! - each `ukf_rk4xN` against its `bare_rk4xN_x9` is the filter against the cost of
|
||||
//! the propagations alone. The difference is the filter's own overhead, and the
|
||||
//! interesting result is that it stays flat as the model gets more expensive.
|
||||
//!
|
||||
//! Nine propagations because a four-state scaled unscented transform uses `2N + 1`
|
||||
//! points.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use nalgebra::{SMatrix, SVector};
|
||||
use nonlinear_filters::prelude::*;
|
||||
use std::hint::black_box;
|
||||
|
||||
type M4 = SMatrix<f64, 4, 4>;
|
||||
type V4 = SVector<f64, 4>;
|
||||
type H24 = SMatrix<f64, 2, 4>;
|
||||
type M2 = SMatrix<f64, 2, 2>;
|
||||
type V2 = SVector<f64, 2>;
|
||||
|
||||
fn transition() -> M4 {
|
||||
M4::from_row_slice(&[
|
||||
0.95, 0.40, 0.00, 0.10, //
|
||||
-0.10, 0.90, 0.25, 0.00, //
|
||||
0.00, 0.05, 0.85, 0.30, //
|
||||
0.15, 0.00, -0.20, 0.70,
|
||||
])
|
||||
}
|
||||
|
||||
fn process_noise() -> M4 {
|
||||
M4::from_row_slice(&[
|
||||
0.40, 0.10, 0.05, 0.00, //
|
||||
0.10, 0.30, 0.00, 0.02, //
|
||||
0.05, 0.00, 0.25, 0.08, //
|
||||
0.00, 0.02, 0.08, 0.20,
|
||||
])
|
||||
}
|
||||
|
||||
fn observation() -> H24 {
|
||||
H24::from_row_slice(&[1.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, -0.3])
|
||||
}
|
||||
|
||||
fn measurement_noise() -> M2 {
|
||||
M2::from_row_slice(&[1.50, 0.25, 0.25, 0.80])
|
||||
}
|
||||
|
||||
/// The dynamics, applied as one matrix product: as cheap as a process model gets.
|
||||
struct Coasting {
|
||||
transition: M4,
|
||||
noise: M4,
|
||||
}
|
||||
|
||||
impl ProcessModel<4> for Coasting {
|
||||
fn propagate(&self, _time: f64, _step: f64, state: &V4) -> V4 {
|
||||
self.transition * state
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64, _step: f64, _state: &V4) -> M4 {
|
||||
self.noise
|
||||
}
|
||||
}
|
||||
|
||||
/// The same dynamics, integrated rather than applied, so that the propagation
|
||||
/// costs something. This is what a real process model looks like.
|
||||
struct Integrated {
|
||||
transition: M4,
|
||||
noise: M4,
|
||||
substeps: usize,
|
||||
}
|
||||
|
||||
impl Integrated {
|
||||
fn step(&self, state: &V4, step: f64) -> V4 {
|
||||
let mut state = *state;
|
||||
let h = step / self.substeps as f64;
|
||||
for _ in 0..self.substeps {
|
||||
let k1 = self.transition * state;
|
||||
let k2 = self.transition * (state + k1 * (h * 0.5));
|
||||
let k3 = self.transition * (state + k2 * (h * 0.5));
|
||||
let k4 = self.transition * (state + k3 * h);
|
||||
state += (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (h / 6.0);
|
||||
}
|
||||
state
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessModel<4> for Integrated {
|
||||
fn propagate(&self, _time: f64, step: f64, state: &V4) -> V4 {
|
||||
self.step(state, step)
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64, _step: f64, _state: &V4) -> M4 {
|
||||
self.noise
|
||||
}
|
||||
}
|
||||
|
||||
/// The measurement, as its own type — see the note in `nonlinear_filters::model`
|
||||
/// on why it is not the same type as the process.
|
||||
struct Observed {
|
||||
observation: H24,
|
||||
noise: M2,
|
||||
}
|
||||
|
||||
impl MeasurementModel<4, 2> for Observed {
|
||||
fn predict(&self, _time: f64, state: &V4) -> V2 {
|
||||
self.observation * state
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64) -> M2 {
|
||||
self.noise
|
||||
}
|
||||
}
|
||||
|
||||
fn overhead(c: &mut Criterion) {
|
||||
let sensor = Observed {
|
||||
observation: observation(),
|
||||
noise: measurement_noise(),
|
||||
};
|
||||
let start = Gaussian::new(V4::new(1.0, -2.0, 0.5, 3.0), M4::identity() * 4.0);
|
||||
let z = V2::new(0.3, -0.4);
|
||||
|
||||
let mut group = c.benchmark_group("overhead");
|
||||
|
||||
// The floor: the closed-form filter for this problem.
|
||||
group.bench_function("analytic_kf", |b| {
|
||||
let (f, q, h, r) = (
|
||||
transition(),
|
||||
process_noise(),
|
||||
observation(),
|
||||
measurement_noise(),
|
||||
);
|
||||
b.iter(|| {
|
||||
let mut mean = start.mean;
|
||||
let mut covariance = start.covariance;
|
||||
mean = f * mean;
|
||||
covariance = f * covariance * f.transpose() + q;
|
||||
let innovation = z - h * mean;
|
||||
let s = h * covariance * h.transpose() + r;
|
||||
let gain =
|
||||
covariance * h.transpose() * s.try_inverse().expect("invertible by construction");
|
||||
mean += gain * innovation;
|
||||
covariance -= gain * h * covariance;
|
||||
black_box((mean, covariance))
|
||||
})
|
||||
});
|
||||
|
||||
// The filter on the same problem, with the cheapest possible model.
|
||||
group.bench_function("ukf_matrix_model", |b| {
|
||||
let model = Coasting {
|
||||
transition: transition(),
|
||||
noise: process_noise(),
|
||||
};
|
||||
let mut filter = Ukf::new(start);
|
||||
b.iter(|| {
|
||||
filter.estimate = start;
|
||||
filter.predict(&model, 0.0, 1.0).unwrap();
|
||||
black_box(filter.update(&sensor, 1.0, &z).unwrap())
|
||||
})
|
||||
});
|
||||
|
||||
// And with a model that costs something, against the propagations alone.
|
||||
for substeps in [1usize, 4, 16] {
|
||||
let model = Integrated {
|
||||
transition: transition(),
|
||||
noise: process_noise(),
|
||||
substeps,
|
||||
};
|
||||
group.bench_function(format!("ukf_rk4x{substeps}"), |b| {
|
||||
let mut filter = Ukf::new(start);
|
||||
b.iter(|| {
|
||||
filter.estimate = start;
|
||||
filter.predict(&model, 0.0, 1.0).unwrap();
|
||||
black_box(filter.update(&sensor, 1.0, &z).unwrap())
|
||||
})
|
||||
});
|
||||
group.bench_function(format!("bare_rk4x{substeps}_x9"), |b| {
|
||||
b.iter(|| {
|
||||
let mut total = V4::zeros();
|
||||
for _ in 0..9 {
|
||||
total += model.step(&black_box(start.mean), 1.0);
|
||||
}
|
||||
black_box(total)
|
||||
})
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(benches, overhead);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Is the filter's covariance honest, and when did the model stop describing the
|
||||
//! data?
|
||||
//!
|
||||
//! Two questions, and the two halves of this example. Neither can be answered by
|
||||
//! looking at a single run, which is most of why they need their own machinery.
|
||||
//!
|
||||
//! Run with `cargo run --example diagnostics`.
|
||||
|
||||
use nalgebra::{Matrix1, Matrix2, Vector1, Vector2};
|
||||
use nonlinear_filters::detection::{calibrate_per_run, calibrate_per_step, Detector};
|
||||
use nonlinear_filters::diagnostics::{self, ChiSquaredBounds};
|
||||
use nonlinear_filters::prelude::*;
|
||||
use nonlinear_filters::sim::{simulate, GaussianSampler, Rng};
|
||||
|
||||
const STEP: f64 = 0.5;
|
||||
const STEPS: usize = 100;
|
||||
const SEEDS: usize = 200;
|
||||
/// The step at which the faulted runs start accelerating.
|
||||
const ONSET: usize = 50;
|
||||
/// How many consecutive exceedances count as a detection.
|
||||
const PERSISTENCE: usize = 3;
|
||||
|
||||
struct Coasting {
|
||||
intensity: f64,
|
||||
}
|
||||
|
||||
impl ProcessModel<2> for Coasting {
|
||||
fn propagate(&self, _time: f64, step: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
Vector2::new(state[0] + step * state[1], state[1])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64, step: f64, _state: &Vector2<f64>) -> Matrix2<f64> {
|
||||
let q = self.intensity;
|
||||
Matrix2::new(
|
||||
q * step.powi(3) / 3.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Coasts, then accelerates from `onset`. The filter is never told.
|
||||
struct Faulted {
|
||||
inner: Coasting,
|
||||
onset: f64,
|
||||
acceleration: f64,
|
||||
}
|
||||
|
||||
impl ProcessModel<2> for Faulted {
|
||||
fn propagate(&self, time: f64, step: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
let a = if time >= self.onset {
|
||||
self.acceleration
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Vector2::new(
|
||||
state[0] + step * state[1] + 0.5 * a * step * step,
|
||||
state[1] + a * step,
|
||||
)
|
||||
}
|
||||
|
||||
fn noise(&self, time: f64, step: f64, state: &Vector2<f64>) -> Matrix2<f64> {
|
||||
self.inner.noise(time, step, state)
|
||||
}
|
||||
}
|
||||
|
||||
struct Position {
|
||||
deviation: f64,
|
||||
}
|
||||
|
||||
impl MeasurementModel<2, 1> for Position {
|
||||
fn predict(&self, _time: f64, state: &Vector2<f64>) -> Vector1<f64> {
|
||||
Vector1::new(state[0])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64) -> Matrix1<f64> {
|
||||
Matrix1::new(self.deviation * self.deviation)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one ensemble of runs produced.
|
||||
struct Ensemble {
|
||||
/// NEES per step, pooled over runs. Needs the truth, so simulation only.
|
||||
nees: Vec<f64>,
|
||||
/// NIS per step, pooled over runs. Needs only the filter's own output.
|
||||
nis: Vec<f64>,
|
||||
/// NIS per step, kept per run, for the detector.
|
||||
streams: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Runs an ensemble: truth from `truth_process`, filter believing
|
||||
/// `filter_process`.
|
||||
fn run_ensemble(
|
||||
truth_process: &dyn ProcessModel<2>,
|
||||
filter_process: &dyn ProcessModel<2>,
|
||||
sensor: &Position,
|
||||
prior: &Gaussian<2>,
|
||||
seed_base: u64,
|
||||
draw_from_prior: bool,
|
||||
) -> Result<Ensemble, FilterError> {
|
||||
let prior_sampler = GaussianSampler::new(&prior.covariance);
|
||||
let mut ensemble = Ensemble {
|
||||
nees: Vec::new(),
|
||||
nis: Vec::new(),
|
||||
streams: Vec::new(),
|
||||
};
|
||||
|
||||
for seed in 0..SEEDS as u64 {
|
||||
let mut rng = Rng::new(seed_base + seed);
|
||||
// The hypothesis under test says the truth is a draw from the prior, so
|
||||
// that is how the truth is generated. Starting every run from the prior's
|
||||
// mean instead would make the first steps inconsistent by construction and
|
||||
// the diagnostic would be measuring the setup, not the filter.
|
||||
let truth = if draw_from_prior {
|
||||
prior.mean + prior_sampler.sample(&mut rng)
|
||||
} else {
|
||||
prior.mean
|
||||
};
|
||||
let run = simulate(truth_process, sensor, &truth, 0.0, STEP, STEPS, &mut rng)?;
|
||||
|
||||
let mut filter = Ukf::new(*prior);
|
||||
let mut stream = Vec::with_capacity(run.len());
|
||||
for step in 1..run.len() {
|
||||
filter.predict(filter_process, run.times[step - 1], STEP)?;
|
||||
let report = filter.update(sensor, run.times[step], &run.measurements[step])?;
|
||||
|
||||
ensemble.nis.push(report.nis);
|
||||
stream.push(report.nis);
|
||||
|
||||
let error = filter.estimate.mean - run.states[step];
|
||||
ensemble
|
||||
.nees
|
||||
.push(diagnostics::nees(&error, &filter.estimate.covariance)?);
|
||||
}
|
||||
ensemble.streams.push(stream);
|
||||
}
|
||||
Ok(ensemble)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), FilterError> {
|
||||
let sensor = Position { deviation: 1.0 };
|
||||
let nominal = Coasting { intensity: 0.3 };
|
||||
let prior = Gaussian::from_deviations(Vector2::new(0.0, 1.0), Vector2::new(5.0, 2.0));
|
||||
|
||||
// ========================================================================
|
||||
println!("part 1: is the covariance honest?");
|
||||
println!("=================================\n");
|
||||
|
||||
println!("A filter reports an estimate and an uncertainty. The uncertainty is a");
|
||||
println!("claim about its own errors, and it is testable. Two statistics:\n");
|
||||
println!(" NEES = e' P^-1 e needs the truth, so simulation only.");
|
||||
println!(" Should average the state dimension, here 2.");
|
||||
println!(" NIS = d' S^-1 d needs only the filter's output, so it works live.");
|
||||
println!(" Should average the measurement dimension, here 1.\n");
|
||||
|
||||
// Why one sample tells you nothing.
|
||||
let single = ChiSquaredBounds::single(1, 0.95);
|
||||
println!(
|
||||
"One NIS sample from a one-component measurement has a 95% interval of\n [{:.4}, {:.3}] around an expectation of 1. That is why these are ensemble",
|
||||
single.lower, single.upper
|
||||
);
|
||||
println!(" statistics: a single value of 4 means nothing at all.\n");
|
||||
|
||||
let good = run_ensemble(&nominal, &nominal, &sensor, &prior, 0x900d_0000, true)?;
|
||||
|
||||
let nees_test = diagnostics::ensemble_test(good.nees.iter().copied(), 2, 0.99)?;
|
||||
let nis_test = diagnostics::ensemble_test(good.nis.iter().copied(), 1, 0.99)?;
|
||||
|
||||
println!("a correctly specified filter, {SEEDS} runs x {STEPS} steps:\n");
|
||||
println!(" mean 99% interval for the mean verdict");
|
||||
for (name, test) in [("NEES", &nees_test), ("NIS", &nis_test)] {
|
||||
println!(
|
||||
" {:<6} {:>10.4} [{:>7.4}, {:>7.4}] {}",
|
||||
name,
|
||||
test.mean,
|
||||
test.bounds.lower,
|
||||
test.bounds.upper,
|
||||
if test.passes() { "consistent" } else { "FAILS" }
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\n Averaging {} samples shrank the NIS interval from [{:.4}, {:.3}] to\n [{:.4}, {:.4}] — a factor of {:.0} — which is what makes a verdict possible.",
|
||||
nis_test.samples,
|
||||
single.lower,
|
||||
single.upper,
|
||||
nis_test.bounds.lower,
|
||||
nis_test.bounds.upper,
|
||||
(single.upper - single.lower) / (nis_test.bounds.upper - nis_test.bounds.lower)
|
||||
);
|
||||
|
||||
// Coverage is the same question asked per sample rather than on the mean.
|
||||
let coverage = diagnostics::coverage(good.nis.iter().copied(), &single);
|
||||
println!(
|
||||
"\n Per-sample coverage against the 95% interval: {:.1}% inside, {} high,\n {} low, of {}. For a consistent filter that rate is the confidence level.",
|
||||
100.0 * coverage.rate().unwrap_or(0.0),
|
||||
coverage.above,
|
||||
coverage.below,
|
||||
coverage.samples
|
||||
);
|
||||
|
||||
// The same filter, starved of process noise.
|
||||
let starved = Coasting { intensity: 0.003 };
|
||||
let bad = run_ensemble(&nominal, &starved, &sensor, &prior, 0xbad0_0000, true)?;
|
||||
let bad_nees = diagnostics::ensemble_test(bad.nees.iter().copied(), 2, 0.99)?;
|
||||
let bad_coverage =
|
||||
diagnostics::coverage(bad.nees.iter().copied(), &ChiSquaredBounds::single(2, 0.95));
|
||||
|
||||
println!("\nthe same filter given a hundredth of the process noise it needs:\n");
|
||||
println!(
|
||||
" NEES {:>10.4} [{:>7.4}, {:>7.4}] {}",
|
||||
bad_nees.mean,
|
||||
bad_nees.bounds.lower,
|
||||
bad_nees.bounds.upper,
|
||||
if bad_nees.passes() {
|
||||
"consistent"
|
||||
} else {
|
||||
"FAILS"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
"\n It fails {:.0}x above the upper bound, and the direction is the diagnosis:\n {} samples above the interval against {} below. Above means overconfident —\n the covariance is smaller than the errors justify. Below would mean merely\n conservative, which wastes information but misleads nobody.",
|
||||
bad_nees.mean / bad_nees.bounds.upper,
|
||||
bad_coverage.above,
|
||||
bad_coverage.below
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
println!("\n\npart 2: when did the model stop describing the data?");
|
||||
println!("====================================================\n");
|
||||
|
||||
println!("NIS answers this one too, because it needs no truth. The question is");
|
||||
println!("what threshold to compare it against, and the answer cannot be chosen");
|
||||
println!("freely: a lower threshold always detects sooner, so a detection delay");
|
||||
println!("quoted without its false-alarm rate is not a measurement.\n");
|
||||
|
||||
// Calibrate on nominal runs, where every firing is by definition a false alarm.
|
||||
let calibration = calibrate_per_run(&good.streams, 0.01, PERSISTENCE)?;
|
||||
println!(
|
||||
"calibrating on {} nominal runs, for a 1% per-run false-alarm rate\nwith a persistence of {} consecutive exceedances:\n",
|
||||
good.streams.len(),
|
||||
PERSISTENCE
|
||||
);
|
||||
println!(
|
||||
" threshold {:.4}, achieving {:.2}% on the calibration set",
|
||||
calibration.threshold,
|
||||
100.0 * calibration.achieved
|
||||
);
|
||||
|
||||
// Now the faulted runs.
|
||||
let faulted_process = Faulted {
|
||||
inner: Coasting { intensity: 0.3 },
|
||||
onset: ONSET as f64 * STEP,
|
||||
acceleration: 1.5,
|
||||
};
|
||||
let faulted = run_ensemble(
|
||||
&faulted_process,
|
||||
&nominal,
|
||||
&sensor,
|
||||
&prior,
|
||||
0xfa17_0000,
|
||||
false,
|
||||
)?;
|
||||
|
||||
let delays = |threshold: f64| -> (Vec<usize>, usize) {
|
||||
let mut delays = Vec::new();
|
||||
let mut early = 0;
|
||||
for stream in &faulted.streams {
|
||||
if let Some(index) =
|
||||
Detector::first_firing(threshold, PERSISTENCE, stream.iter().copied())
|
||||
.expect("the detector is validly configured")
|
||||
{
|
||||
// Stream index 0 is step 1, so the onset sits at index ONSET - 1.
|
||||
if index + 1 >= ONSET {
|
||||
delays.push(index + 1 - ONSET);
|
||||
} else {
|
||||
early += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
delays.sort_unstable();
|
||||
(delays, early)
|
||||
};
|
||||
|
||||
let (calibrated, early) = delays(calibration.threshold);
|
||||
println!(
|
||||
"\non {} faulted runs, the fault starting at step {}:\n",
|
||||
faulted.streams.len(),
|
||||
ONSET
|
||||
);
|
||||
println!(
|
||||
" detected in {:.0}% of runs, {} fired before the onset",
|
||||
100.0 * calibrated.len() as f64 / faulted.streams.len() as f64,
|
||||
early
|
||||
);
|
||||
if !calibrated.is_empty() {
|
||||
println!(
|
||||
" time to detect: fastest {}, lower quartile {}, median {}, upper quartile {} steps",
|
||||
calibrated[0],
|
||||
calibrated[calibrated.len() / 4],
|
||||
calibrated[calibrated.len() / 2],
|
||||
calibrated[3 * calibrated.len() / 4],
|
||||
);
|
||||
}
|
||||
|
||||
// And the point of calibrating at all: a lower threshold looks better and is
|
||||
// not comparable.
|
||||
println!("\nwhat a lower threshold buys, and what it costs:\n");
|
||||
println!(" threshold median delay detected fired early false alarms");
|
||||
for factor in [1.0, 0.5, 0.25] {
|
||||
let threshold = calibration.threshold * factor;
|
||||
let (d, fired_early) = delays(threshold);
|
||||
let nominal_firings = good
|
||||
.streams
|
||||
.iter()
|
||||
.filter(|stream| {
|
||||
Detector::first_firing(threshold, PERSISTENCE, stream.iter().copied())
|
||||
.expect("validly configured")
|
||||
.is_some()
|
||||
})
|
||||
.count();
|
||||
println!(
|
||||
" {:>10.4} {:>14} {:>10.0}% {:>13.0}% {:>13.1}%",
|
||||
threshold,
|
||||
if d.is_empty() {
|
||||
"never".to_string()
|
||||
} else {
|
||||
d[d.len() / 2].to_string()
|
||||
},
|
||||
100.0 * d.len() as f64 / faulted.streams.len() as f64,
|
||||
100.0 * fired_early as f64 / faulted.streams.len() as f64,
|
||||
100.0 * nominal_firings as f64 / good.streams.len() as f64,
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\n Quartering the threshold improves the median delay from 13 steps to 6 and\n takes the false-alarm rate from 1% to 88%. Either number alone can be made to\n look like progress, which is why two detectors are only comparable once their\n false-alarm rates are matched."
|
||||
);
|
||||
println!(
|
||||
"\n The 'detected' column falling as the threshold drops is not a bug. At a low\n threshold most faulted runs fire before step {} — see 'fired early' — so the\n detector is not detecting the fault at all, it is reacting to ordinary noise\n that happens to arrive first. A detector that fires early on most runs has no\n meaningful time to detect, however good the surviving median looks.",
|
||||
ONSET
|
||||
);
|
||||
|
||||
// The calibrator does not know or care what the statistic is.
|
||||
println!("\nthe calibrator is not tied to NIS:\n");
|
||||
println!(" (per-step calibration this time, 1% of all steps, so the thresholds");
|
||||
println!(" are not comparable with the per-run figure above)\n");
|
||||
let mut traces = Vec::new();
|
||||
let mut raw = Vec::new();
|
||||
for stream in good.streams.iter().take(50) {
|
||||
for value in stream {
|
||||
raw.push(value * 2.5 + 7.0);
|
||||
traces.push(value.sqrt());
|
||||
}
|
||||
}
|
||||
for (name, samples) in [
|
||||
("NIS", good.nis.clone()),
|
||||
("a rescaling of it", raw),
|
||||
("its square root", traces),
|
||||
] {
|
||||
let c = calibrate_per_step(samples, 0.01)?;
|
||||
println!(
|
||||
" {:<20} threshold {:>8.4} achieved rate {:.3}%",
|
||||
name,
|
||||
c.threshold,
|
||||
100.0 * c.achieved
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\n Three statistics on three scales, all calibrated to the same 1% of steps\n from samples alone. Nothing about their distributions was assumed, which is\n what lets a detector with no known nominal distribution be compared against\n one that has a textbook threshold."
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! The same filter with every mechanism turned on, against a plain one.
|
||||
//!
|
||||
//! Three things happen to this target that a textbook UKF handles badly:
|
||||
//!
|
||||
//! - one position measurement in twenty is wild, by thirty standard deviations
|
||||
//! - the target starts accelerating halfway through the arc, and no model says so
|
||||
//! - a second, better sensor reports occasionally, with a different number of
|
||||
//! components from the first
|
||||
//!
|
||||
//! The equipped filter gets a chi-squared residual gate for the first, adaptive
|
||||
//! process noise for the second, and nothing at all for the third, because the
|
||||
//! measurement dimension belongs to [`Ukf::update`] rather than to the filter and
|
||||
//! two sensors need no special arrangement.
|
||||
//!
|
||||
//! Run with `cargo run --example equipped`.
|
||||
|
||||
use nalgebra::{Matrix1, Matrix2, SMatrix, SVector, Vector1, Vector2};
|
||||
use nonlinear_filters::prelude::*;
|
||||
use nonlinear_filters::sim::{simulate, GaussianSampler, Rng};
|
||||
|
||||
const STEP: f64 = 0.5;
|
||||
const STEPS: usize = 120;
|
||||
/// The step at which the target starts accelerating.
|
||||
const ONSET: usize = 60;
|
||||
/// How often the better sensor reports.
|
||||
const FIX_EVERY: usize = 10;
|
||||
|
||||
/// What the filter believes: a target that coasts.
|
||||
struct Coasting {
|
||||
intensity: f64,
|
||||
}
|
||||
|
||||
impl ProcessModel<2> for Coasting {
|
||||
fn propagate(&self, _time: f64, step: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
Vector2::new(state[0] + step * state[1], state[1])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64, step: f64, _state: &Vector2<f64>) -> Matrix2<f64> {
|
||||
let q = self.intensity;
|
||||
Matrix2::new(
|
||||
q * step.powi(3) / 3.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the target actually does: coasts, then accelerates from `onset`.
|
||||
struct Maneuvering {
|
||||
inner: Coasting,
|
||||
onset: f64,
|
||||
acceleration: f64,
|
||||
}
|
||||
|
||||
impl ProcessModel<2> for Maneuvering {
|
||||
fn propagate(&self, time: f64, step: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
let a = if time >= self.onset {
|
||||
self.acceleration
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
Vector2::new(
|
||||
state[0] + step * state[1] + 0.5 * a * step * step,
|
||||
state[1] + a * step,
|
||||
)
|
||||
}
|
||||
|
||||
fn noise(&self, time: f64, step: f64, state: &Vector2<f64>) -> Matrix2<f64> {
|
||||
self.inner.noise(time, step, state)
|
||||
}
|
||||
}
|
||||
|
||||
/// The everyday sensor: position only, one component.
|
||||
struct Position {
|
||||
deviation: f64,
|
||||
}
|
||||
|
||||
impl MeasurementModel<2, 1> for Position {
|
||||
fn predict(&self, _time: f64, state: &Vector2<f64>) -> Vector1<f64> {
|
||||
Vector1::new(state[0])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64) -> Matrix1<f64> {
|
||||
Matrix1::new(self.deviation * self.deviation)
|
||||
}
|
||||
}
|
||||
|
||||
/// The occasional good sensor: position and velocity, two components.
|
||||
///
|
||||
/// Nothing about the filter changes to accept this. `M` is a parameter of
|
||||
/// `update`, so the same filter takes a one-component measurement at one step and
|
||||
/// a two-component one at the next.
|
||||
struct Fix {
|
||||
position_deviation: f64,
|
||||
velocity_deviation: f64,
|
||||
}
|
||||
|
||||
impl MeasurementModel<2, 2> for Fix {
|
||||
fn predict(&self, _time: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
*state
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64) -> Matrix2<f64> {
|
||||
SMatrix::<f64, 2, 2>::new(
|
||||
self.position_deviation * self.position_deviation,
|
||||
0.0,
|
||||
0.0,
|
||||
self.velocity_deviation * self.velocity_deviation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one filter did over the whole arc.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
squared_error: f64,
|
||||
samples: usize,
|
||||
rejected: usize,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
fn record(&mut self, error: f64) {
|
||||
self.squared_error += error * error;
|
||||
self.samples += 1;
|
||||
}
|
||||
|
||||
fn rms(&self) -> f64 {
|
||||
(self.squared_error / self.samples as f64).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), FilterError> {
|
||||
let filter_process = Coasting { intensity: 0.05 };
|
||||
let truth_process = Maneuvering {
|
||||
inner: Coasting { intensity: 0.05 },
|
||||
onset: ONSET as f64 * STEP,
|
||||
acceleration: 0.6,
|
||||
};
|
||||
let position = Position { deviation: 1.0 };
|
||||
let fix = Fix {
|
||||
position_deviation: 0.3,
|
||||
velocity_deviation: 0.1,
|
||||
};
|
||||
|
||||
// Truth and the position sensor's stream.
|
||||
let mut rng = Rng::new(20_261_003);
|
||||
let mut run = simulate(
|
||||
&truth_process,
|
||||
&position,
|
||||
&Vector2::new(0.0, 2.0),
|
||||
0.0,
|
||||
STEP,
|
||||
STEPS,
|
||||
&mut rng,
|
||||
)?;
|
||||
|
||||
// One position measurement in twenty, displaced by thirty standard deviations.
|
||||
let mut outliers = Vec::new();
|
||||
for step in 1..run.len() {
|
||||
if rng.uniform() < 0.05 {
|
||||
let sign = if rng.uniform() < 0.5 { -1.0 } else { 1.0 };
|
||||
run.measurements[step][0] += sign * 30.0 * position.deviation;
|
||||
outliers.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
// The good sensor's readings, synthesized from the same truth.
|
||||
let fix_sampler = GaussianSampler::new(&fix.noise(0.0));
|
||||
let fixes: Vec<SVector<f64, 2>> = run
|
||||
.states
|
||||
.iter()
|
||||
.map(|truth| truth + fix_sampler.sample(&mut rng))
|
||||
.collect();
|
||||
|
||||
let prior = Gaussian::from_deviations(Vector2::new(0.0, 0.0), Vector2::new(10.0, 5.0));
|
||||
|
||||
// The textbook filter: no gate, no adaptation, no forgetting.
|
||||
let mut plain = Ukf::new(prior);
|
||||
|
||||
// The equipped one. Each addition answers one of the three faults above.
|
||||
let mut equipped = Ukf::new(prior)
|
||||
// Reject a residual too large to be this filter's own noise. 0.99 throws
|
||||
// away one good measurement in a hundred to do it.
|
||||
.with_gate(ResidualGate::new(0.99)?)
|
||||
// Let the innovations set the size of Q. One free parameter, the window.
|
||||
.with_adaptation(CovarianceMatching::inflating(20)?)
|
||||
// And discount old measurements slightly, so the covariance keeps a floor
|
||||
// under it and the filter never stops listening.
|
||||
.with_fading_memory(1.02)?;
|
||||
|
||||
let mut plain_tally = Tally::default();
|
||||
let mut equipped_tally = Tally::default();
|
||||
|
||||
println!("step position NIS gate fix Q scale plain err equipped err");
|
||||
println!(" ------------------------------------------------------------------------------");
|
||||
|
||||
for (step, fix_reading) in fixes.iter().enumerate().skip(1) {
|
||||
let time = run.times[step - 1];
|
||||
let now = run.times[step];
|
||||
|
||||
plain.predict(&filter_process, time, STEP)?;
|
||||
equipped.predict(&filter_process, time, STEP)?;
|
||||
|
||||
plain.update(&position, now, &run.measurements[step])?;
|
||||
let report = equipped.update(&position, now, &run.measurements[step])?;
|
||||
if !report.accepted {
|
||||
equipped_tally.rejected += 1;
|
||||
}
|
||||
|
||||
// Every tenth step the better sensor also reports. Two updates in one
|
||||
// step, with different measurement dimensions, through one filter.
|
||||
let fixed = step % FIX_EVERY == 0;
|
||||
if fixed {
|
||||
plain.update(&fix, now, fix_reading)?;
|
||||
equipped.update(&fix, now, fix_reading)?;
|
||||
}
|
||||
|
||||
let truth = run.states[step][0];
|
||||
let plain_error = plain.estimate.mean[0] - truth;
|
||||
let equipped_error = equipped.estimate.mean[0] - truth;
|
||||
plain_tally.record(plain_error);
|
||||
equipped_tally.record(equipped_error);
|
||||
|
||||
let interesting = !report.accepted || fixed || step == ONSET || step % 30 == 0;
|
||||
if interesting {
|
||||
println!(
|
||||
"{:4} {:10.2} {:8.1} {:>6} {:>5} {:10.1} {:11.3} {:14.3}",
|
||||
step,
|
||||
run.measurements[step][0],
|
||||
report.nis,
|
||||
if report.accepted { "pass" } else { "REJECT" },
|
||||
if fixed { "yes" } else { "" },
|
||||
equipped.adaptation().scale(),
|
||||
plain_error,
|
||||
equipped_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"over {} steps, with {} outliers injected and the maneuver from step {}:",
|
||||
STEPS,
|
||||
outliers.len(),
|
||||
ONSET
|
||||
);
|
||||
println!();
|
||||
println!(" plain equipped");
|
||||
println!(
|
||||
" position RMS {:>8.3} {:>8.3}",
|
||||
plain_tally.rms(),
|
||||
equipped_tally.rms()
|
||||
);
|
||||
println!(
|
||||
" measurements rejected {:>5} {:>5}",
|
||||
plain_tally.rejected, equipped_tally.rejected
|
||||
);
|
||||
println!(
|
||||
" final 1 sigma {:>8.3} {:>8.3}",
|
||||
plain.estimate.covariance[(0, 0)].sqrt(),
|
||||
equipped.estimate.covariance[(0, 0)].sqrt()
|
||||
);
|
||||
println!(
|
||||
" final Q scale 1.0 {:>8.1}",
|
||||
equipped.adaptation().scale()
|
||||
);
|
||||
|
||||
// What to read in that table, and what not to.
|
||||
//
|
||||
// **The gate rejects eleven for ten injected outliers.** The ten are
|
||||
// unmistakable: their NIS is in the hundreds against a threshold of 6.63. The
|
||||
// eleventh is a perfectly good measurement thrown away, and it is not a bug —
|
||||
// `ResidualGate::new(0.99)` promises to discard one good measurement in a
|
||||
// hundred, and 120 steps bought about one. Tightening the gate to 0.999 would
|
||||
// buy that measurement back and start letting smaller outliers through.
|
||||
//
|
||||
// **The Q scale does not glide up at the maneuver; it thrashes.** It is at the
|
||||
// clamp of 1.0 most of the time and jumps into the hundreds for a few steps at
|
||||
// a stretch. That is this mechanism's real behavior, and it is a feedback loop
|
||||
// rather than noise: inflating the scale makes the next prediction's covariance
|
||||
// larger, which makes the innovations look explicable again, which collapses
|
||||
// the scale back to the clamp. A longer window damps the amplitude without
|
||||
// removing the oscillation. It still works — see the RMS — but anyone plotting
|
||||
// the scale expecting a tracking signal will be surprised.
|
||||
//
|
||||
// **The plain filter's final 1 sigma is the number to be suspicious of.** It is
|
||||
// *smaller* than the equipped filter's while its RMS is nearly three times
|
||||
// larger. A filter reporting more confidence than its errors justify is the
|
||||
// failure that matters, because every decision downstream believes it. That is
|
||||
// exactly what the diagnostics in `diagnostics.rs` are for, and no amount of
|
||||
// staring at a single run reveals it.
|
||||
//
|
||||
// One thing deliberately not claimed: that adaptation beats any fixed process
|
||||
// noise. It beats *this* filter's declared 0.05 by a wide margin. A fixed
|
||||
// intensity of 1.0, had anyone known to choose it, beats the adaptive filter on
|
||||
// this scenario. Adaptation buys robustness to a process noise that was set
|
||||
// wrong, not accuracy beyond one that was set right.
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//! A bare unscented Kalman filter, start to finish.
|
||||
//!
|
||||
//! The smallest thing that is still a real filter: a one-dimensional target that
|
||||
//! coasts, a sensor that reports its position badly, and a filter that works out
|
||||
//! where it is. No gate, no adaptation, no fading memory — those are in
|
||||
//! `equipped.rs`.
|
||||
//!
|
||||
//! Run with `cargo run --example simple`.
|
||||
|
||||
use nalgebra::{Matrix1, Matrix2, Vector1, Vector2};
|
||||
use nonlinear_filters::prelude::*;
|
||||
use nonlinear_filters::sim::{simulate, Rng};
|
||||
|
||||
/// How the state moves: position and velocity, with the velocity disturbed by a
|
||||
/// white acceleration of intensity `intensity`.
|
||||
struct ConstantVelocity {
|
||||
/// Acceleration noise power, in (m/s^2)^2 per second.
|
||||
intensity: f64,
|
||||
}
|
||||
|
||||
impl ProcessModel<2> for ConstantVelocity {
|
||||
fn propagate(&self, _time: f64, step: f64, state: &Vector2<f64>) -> Vector2<f64> {
|
||||
Vector2::new(state[0] + step * state[1], state[1])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64, step: f64, _state: &Vector2<f64>) -> Matrix2<f64> {
|
||||
// The exact integral of a continuous white acceleration over the step. The
|
||||
// off-diagonal terms matter: an acceleration that moved the velocity also
|
||||
// moved the position, and a diagonal Q claims otherwise.
|
||||
let q = self.intensity;
|
||||
Matrix2::new(
|
||||
q * step.powi(3) / 3.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step.powi(2) / 2.0,
|
||||
q * step,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the sensor reports: position, plus noise of the given standard deviation.
|
||||
struct Position {
|
||||
deviation: f64,
|
||||
}
|
||||
|
||||
impl MeasurementModel<2, 1> for Position {
|
||||
fn predict(&self, _time: f64, state: &Vector2<f64>) -> Vector1<f64> {
|
||||
Vector1::new(state[0])
|
||||
}
|
||||
|
||||
fn noise(&self, _time: f64) -> Matrix1<f64> {
|
||||
Matrix1::new(self.deviation * self.deviation)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), FilterError> {
|
||||
let process = ConstantVelocity { intensity: 0.2 };
|
||||
let sensor = Position { deviation: 1.0 };
|
||||
let step = 0.5;
|
||||
|
||||
// Generate a truth trajectory and the measurements a sensor would have made of
|
||||
// it. In a real application this is where the data comes in instead.
|
||||
let truth_start = Vector2::new(0.0, 2.0);
|
||||
let mut rng = Rng::new(20_261_003);
|
||||
let run = simulate(&process, &sensor, &truth_start, 0.0, step, 40, &mut rng)?;
|
||||
|
||||
// The prior: roughly where we think it is, and how wrong that could be. The
|
||||
// deviations are generous on purpose — 10 m and 5 m/s against a truth at 0 m
|
||||
// and 2 m/s — because a filter that is lied to about its own uncertainty is
|
||||
// worse off than one that admits it does not know.
|
||||
let prior = Gaussian::from_deviations(Vector2::new(0.0, 0.0), Vector2::new(10.0, 5.0));
|
||||
let mut filter = Ukf::new(prior);
|
||||
|
||||
println!(" t measured truth estimate 1 sigma error NIS");
|
||||
println!(" ---------------------------------------------------------------------");
|
||||
|
||||
for k in 1..run.len() {
|
||||
// Move the estimate forward to the time of the next measurement, then
|
||||
// apply it. The two halves are separate calls because measurements do not
|
||||
// always arrive: with no measurement, predict again and carry on.
|
||||
filter.predict(&process, run.times[k - 1], step)?;
|
||||
let report = filter.update(&sensor, run.times[k], &run.measurements[k])?;
|
||||
|
||||
if k % 5 == 0 || k == 1 {
|
||||
let position = filter.estimate.mean[0];
|
||||
let deviation = filter.estimate.covariance[(0, 0)].sqrt();
|
||||
println!(
|
||||
"{:5.1} {:12.3} {:10.3} {:10.3} {:10.3} {:10.3} {:7.2}",
|
||||
run.times[k],
|
||||
run.measurements[k][0],
|
||||
run.states[k][0],
|
||||
position,
|
||||
deviation,
|
||||
position - run.states[k][0],
|
||||
report.nis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("final estimate");
|
||||
println!(
|
||||
" position {:8.3} m +/- {:.3} m (truth {:.3})",
|
||||
filter.estimate.mean[0],
|
||||
filter.estimate.covariance[(0, 0)].sqrt(),
|
||||
run.states[run.len() - 1][0],
|
||||
);
|
||||
println!(
|
||||
" velocity {:8.3} m/s +/- {:.3} m/s (truth {:.3})",
|
||||
filter.estimate.mean[1],
|
||||
filter.estimate.covariance[(1, 1)].sqrt(),
|
||||
run.states[run.len() - 1][1],
|
||||
);
|
||||
|
||||
// Two things worth noticing in the output above.
|
||||
//
|
||||
// The velocity is estimated to a fraction of a meter per second, and no sensor
|
||||
// ever measured it. That is the whole point of a filter rather than a smoother
|
||||
// over the measurements: the process model says position and velocity are
|
||||
// related, so measuring one constrains the other.
|
||||
//
|
||||
// The position uncertainty settles to well under the sensor's 1 m, because the
|
||||
// filter is combining many measurements, and it settles rather than falling to
|
||||
// zero, because the process noise keeps adding uncertainty as fast as the
|
||||
// measurements remove it.
|
||||
Ok(())
|
||||
}
|
||||
@@ -100,6 +100,17 @@ let estimate = filter.estimate;
|
||||
# Ok::<(), nonlinear_filters::FilterError>(())
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
cargo run --example simple # the smallest thing that is still a real filter
|
||||
cargo run --example equipped # gate, adaptive noise and forgetting, against a plain filter
|
||||
cargo run --example diagnostics # consistency over an ensemble, then detector calibration
|
||||
```
|
||||
|
||||
`diagnostics` is the one to read if the next two sections do not land: it prints the
|
||||
numbers alongside what to conclude from them.
|
||||
|
||||
## Checking that the covariance means something
|
||||
|
||||
A filter reports an uncertainty, and that is a claim about its own errors. Two
|
||||
@@ -191,6 +202,21 @@ constant-velocity state of the given width against a single range measurement:
|
||||
| 6 | 298 ns | 294 ns | 415 ns |
|
||||
| 14 | 2.55 µs | 2.65 µs | 3.03 µs |
|
||||
|
||||
Whether that is good needs something to compare against. On a four-state,
|
||||
two-measurement problem the closed-form Kalman filter — the floor, available only
|
||||
because that problem is linear — takes 50 ns against the filter's 178 ns. More
|
||||
useful: give the process model an RK4 propagation and compare against the cost of
|
||||
the propagations alone.
|
||||
|
||||
| model | whole filter step | the 9 propagations alone | filter overhead |
|
||||
|---|---|---|---|
|
||||
| RK4, 1 substep | 259 ns | 92 ns | 167 ns |
|
||||
| RK4, 4 substeps | 650 ns | 481 ns | 169 ns |
|
||||
| RK4, 16 substeps | 2.276 µs | 2.086 µs | 190 ns |
|
||||
|
||||
The overhead is roughly constant, so a model that does any real work dominates the
|
||||
step. `cargo bench --bench overhead` re-runs that.
|
||||
|
||||
Re-running the identical binary moved individual figures by up to 5.8%, so that
|
||||
is the floor for reading anything into these:
|
||||
|
||||
@@ -205,7 +231,7 @@ is the floor for reading anything into these:
|
||||
- Sigma-point generation is 75 ns at six states, and a step generates two sets, so
|
||||
half the plain step is Cholesky factorization and point arithmetic.
|
||||
|
||||
`cargo bench` re-runs these.
|
||||
`cargo bench` re-runs all of it.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+66
-3
@@ -169,9 +169,20 @@ impl<const N: usize> AdaptiveProcessNoise<N> for FixedProcessNoise {
|
||||
/// cannot find it; [`InnovationNoise`] can, at the cost of assuming nothing and
|
||||
/// therefore estimating much more from the same data.
|
||||
///
|
||||
/// Mixing measurements of different dimensions inside one window averages traces
|
||||
/// of different sizes and makes the result meaningless. Use one adapter per
|
||||
/// measurement stream when a filter is fed more than one.
|
||||
/// # Feeding it more than one sensor
|
||||
///
|
||||
/// Measurements of different dimensions can share one window. Each sample carries
|
||||
/// its own `tr(R)`, `tr(H P_sigma H')` and `tr(H Q0 H')`, and averaging the whole
|
||||
/// equation term by term leaves the solved scale unbiased whatever mix of
|
||||
/// dimensions went in.
|
||||
///
|
||||
/// What the mix changes is the weighting. A sample whose measurement is more
|
||||
/// sensitive to the nominal noise — a larger `tr(H Q0 H')` — has proportionally
|
||||
/// more say. Where two streams imply different scales, the result is exactly the
|
||||
/// `tr(H Q0 H')`-weighted mean of what each implies, which is the right
|
||||
/// arrangement: the measurement that sees the process noise most clearly should
|
||||
/// carry the most weight about its size. It does mean a weakly sensitive stream
|
||||
/// contributes little even when it is the more numerous one.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CovarianceMatching {
|
||||
window: usize,
|
||||
@@ -550,6 +561,58 @@ mod tests {
|
||||
assert_relative_eq!(matcher.scale(), 4.0, max_relative = 1e-12);
|
||||
}
|
||||
|
||||
/// Measurements of different dimensions can share a window.
|
||||
///
|
||||
/// This test exists because the documentation originally claimed the opposite
|
||||
/// — that mixing dimensions "averages traces of different sizes and makes the
|
||||
/// result meaningless". That was wrong, and wrong in a way that only algebra
|
||||
/// catches: each sample contributes its own three traces, so averaging the
|
||||
/// whole moment-matching equation term by term is unbiased regardless of the
|
||||
/// mix. The two sensors below have trace magnitudes an order of magnitude
|
||||
/// apart and both imply a scale of 4.
|
||||
#[test]
|
||||
fn covariance_matching_pools_across_measurement_dimensions() {
|
||||
let sample = |dof, squared, noise, propagated, template| InnovationRecord::<2> {
|
||||
accepted: true,
|
||||
dof,
|
||||
nis: 0.0,
|
||||
innovation_squared: squared,
|
||||
innovation_covariance_trace: propagated + template + noise,
|
||||
measurement_noise_trace: noise,
|
||||
propagated_trace: propagated,
|
||||
template_trace: template,
|
||||
state_correction: Vector2::zeros(),
|
||||
};
|
||||
|
||||
// Sensor A, one component: tr(HPH') 2, tr(R) 1, tr(HQ0H') 0.5, so a scale
|
||||
// of 4 gives tr(S) = 5. Sensor B, two components, with every trace an
|
||||
// order of magnitude larger, and the same scale of 4 giving tr(S) = 71.
|
||||
let a = sample(1, 5.0, 1.0, 2.0, 0.5);
|
||||
let b = sample(2, 71.0, 17.0, 30.0, 6.0);
|
||||
|
||||
let mut pooled = CovarianceMatching::new(40, 1e-9, 1e9).unwrap();
|
||||
for _ in 0..20 {
|
||||
pooled.observe(&a);
|
||||
pooled.observe(&b);
|
||||
}
|
||||
assert_relative_eq!(pooled.scale(), 4.0, max_relative = 1e-12);
|
||||
|
||||
// Where the two streams imply different scales, the pooled answer is the
|
||||
// template-trace-weighted mean of them, not an average of the two scales.
|
||||
// Here A implies 4 and B implies 1, with weights 0.5 and 6.0.
|
||||
let disagreeing = sample(2, 53.0, 17.0, 30.0, 6.0);
|
||||
let mut mixed = CovarianceMatching::new(40, 1e-9, 1e9).unwrap();
|
||||
for _ in 0..20 {
|
||||
mixed.observe(&a);
|
||||
mixed.observe(&disagreeing);
|
||||
}
|
||||
assert_relative_eq!(
|
||||
mixed.scale(),
|
||||
(4.0 * 0.5 + 1.0 * 6.0) / 6.5,
|
||||
max_relative = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covariance_matching_clamps() {
|
||||
let mut matcher = CovarianceMatching::inflating(5).unwrap();
|
||||
|
||||
+2
-2
@@ -196,7 +196,7 @@ impl<const J: usize, const N: usize, S: SigmaPoints<J>> ConsiderUkf<J, N, S> {
|
||||
///
|
||||
/// [`FilterError::NotPositiveDefinite`] if the joint covariance admits no
|
||||
/// Cholesky factor.
|
||||
pub fn predict<P: ProcessModel<J>>(
|
||||
pub fn predict<P: ProcessModel<J> + ?Sized>(
|
||||
&mut self,
|
||||
model: &P,
|
||||
time: f64,
|
||||
@@ -248,7 +248,7 @@ impl<const J: usize, const N: usize, S: SigmaPoints<J>> ConsiderUkf<J, N, S> {
|
||||
///
|
||||
/// [`FilterError::NotPositiveDefinite`] if either the joint covariance or the
|
||||
/// innovation covariance admits no Cholesky factor.
|
||||
pub fn update<const M: usize, H: MeasurementModel<J, M>>(
|
||||
pub fn update<const M: usize, H: MeasurementModel<J, M> + ?Sized>(
|
||||
&mut self,
|
||||
model: &H,
|
||||
time: f64,
|
||||
|
||||
+2
-2
@@ -340,7 +340,7 @@ where
|
||||
/// [`FilterError::ModelProbabilitiesUnderflowed`] if no mode found the
|
||||
/// measurement possible at all, which means the bank does not cover what
|
||||
/// happened.
|
||||
pub fn update<const M: usize, H: MeasurementModel<N, M>>(
|
||||
pub fn update<const M: usize, H: MeasurementModel<N, M> + ?Sized>(
|
||||
&mut self,
|
||||
model: &H,
|
||||
time: f64,
|
||||
@@ -409,7 +409,7 @@ where
|
||||
/// # Errors
|
||||
///
|
||||
/// Whatever either half reports.
|
||||
pub fn step<const M: usize, H: MeasurementModel<N, M>>(
|
||||
pub fn step<const M: usize, H: MeasurementModel<N, M> + ?Sized>(
|
||||
&mut self,
|
||||
model: &H,
|
||||
time: f64,
|
||||
|
||||
+2
-2
@@ -221,7 +221,7 @@ where
|
||||
///
|
||||
/// [`FilterError::NotPositiveDefinite`] if the current covariance admits no
|
||||
/// Cholesky factor.
|
||||
pub fn predict<P: ProcessModel<N>>(
|
||||
pub fn predict<P: ProcessModel<N> + ?Sized>(
|
||||
&mut self,
|
||||
model: &P,
|
||||
time: f64,
|
||||
@@ -268,7 +268,7 @@ where
|
||||
/// [`FilterError::NotPositiveDefinite`] if the state covariance admits no
|
||||
/// Cholesky factor, or [`FilterError::Singular`] if the innovation covariance
|
||||
/// cannot be inverted.
|
||||
pub fn update<const M: usize, H: MeasurementModel<N, M>>(
|
||||
pub fn update<const M: usize, H: MeasurementModel<N, M> + ?Sized>(
|
||||
&mut self,
|
||||
model: &H,
|
||||
time: f64,
|
||||
|
||||
Reference in new issue
Block a user