1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
// SPDX-License-Identifier: AGPL-3.0-only
//! Sequential-importance-resampling (SIR) particle filter.
//!
//! The nonlinear, non-Gaussian estimator behind **map-aided, GPS-denied navigation**:
//! terrain-referenced navigation against a digital elevation model, or gravity-map
//! matching against a gravity-anomaly field. A cloud of weighted state hypotheses
//! (particles) is propagated through the platform dynamics, reweighted by how well each
//! explains the latest map measurement, and resampled when it degenerates.
//!
//! This is the generic estimator engine: `predict` (propagate + process noise),
//! `update` (reweight by a likelihood), systematic `resample`, the effective-sample-size
//! degeneracy monitor, and the weighted-mean estimate. The reference maps and their
//! measurement models are now built on top: the gravity-anomaly field in
//! [`crate::gravimeter`], the IGRF-14 magnetic field in [`crate::igrf`], and an SRTM `.hgt`
//! digital-elevation grid with the terrain-referenced and combined gravity+magnetic+terrain
//! navigators in [`crate::altpnt::terrain`] — all reusing the bilinear grid sampler pattern
//! from [`crate::ionex`].
use rand::RngCore;
use rand_distr::{Distribution, Normal};
/// Normalise weights to sum 1. If every weight is zero (total particle collapse), reset
/// to a uniform distribution rather than divide by zero.
pub fn normalize(weights: &mut [f64]) {
let sum: f64 = weights.iter().sum();
if sum > 0.0 {
for w in weights.iter_mut() {
*w /= sum;
}
} else {
let u = 1.0 / weights.len() as f64;
for w in weights.iter_mut() {
*w = u;
}
}
}
/// Effective sample size `1 / Σwᵢ²` (weights assumed normalised): `N` for a uniform cloud,
/// `1` when one particle holds all the weight. The standard resample-when-it-drops monitor.
pub fn effective_sample_size(weights: &[f64]) -> f64 {
let s: f64 = weights.iter().map(|w| w * w).sum();
if s > 0.0 {
1.0 / s
} else {
0.0
}
}
/// Systematic resampling: return `weights.len()` particle indices to keep, drawing one
/// stratified sample per `1/N` interval offset by `u0 ∈ [0,1)`. Deterministic given `u0`,
/// and lower-variance than multinomial resampling.
pub fn systematic_resample(weights: &[f64], u0: f64) -> Vec<usize> {
let n = weights.len();
let mut cumsum = Vec::with_capacity(n);
let mut acc = 0.0;
for &w in weights {
acc += w;
cumsum.push(acc);
}
let mut idx = vec![0usize; n];
let (mut i, mut j) = (0usize, 0usize);
while i < n {
let pos = (u0 + i as f64) / n as f64;
if pos < cumsum[j] {
idx[i] = j;
i += 1;
} else if j + 1 < n {
j += 1;
} else {
idx[i] = n - 1; // floating-point guard at the top of the cumulative sum
i += 1;
}
}
idx
}
/// Weighted mean of the particle states (the minimum-mean-square-error estimate).
pub fn weighted_mean(particles: &[Vec<f64>], weights: &[f64]) -> Vec<f64> {
let dim = particles[0].len();
let mut m = vec![0.0; dim];
for (p, &w) in particles.iter().zip(weights) {
for (mk, &pk) in m.iter_mut().zip(p) {
*mk += w * pk;
}
}
m
}
/// A SIR particle filter over an `n`-dimensional state.
#[derive(Clone, Debug)]
pub struct ParticleFilter {
/// The particle states.
pub particles: Vec<Vec<f64>>,
/// The (normalised) particle weights.
pub weights: Vec<f64>,
}
impl ParticleFilter {
/// A filter seeded with the given particles and uniform weights.
pub fn new(particles: Vec<Vec<f64>>) -> Self {
let n = particles.len().max(1);
Self {
weights: vec![1.0 / n as f64; particles.len()],
particles,
}
}
/// Propagate every particle through the dynamics `f`, adding per-component Gaussian
/// process noise with standard deviations `noise_sd` (drawn from `rng`).
pub fn predict<F>(&mut self, f: F, noise_sd: &[f64], rng: &mut dyn RngCore)
where
F: Fn(&[f64]) -> Vec<f64>,
{
for p in self.particles.iter_mut() {
let mut next = f(p);
for (k, &sd) in noise_sd.iter().enumerate() {
if sd > 0.0 && k < next.len() {
// The `sd > 0.0` guard ensures positivity but not finiteness;
// `Normal::new` (rand_distr 0.4) rejects only a non-finite std_dev, so
// coerce an `inf` element to the smallest positive normal.
let sigma = if sd.is_finite() {
sd
} else {
f64::MIN_POSITIVE
};
next[k] += Normal::new(0.0, sigma)
.expect("sigma is finite and strictly positive, which Normal::new always accepts")
.sample(rng);
}
}
*p = next;
}
}
/// Reweight by a per-particle measurement likelihood, then renormalise.
pub fn update<L>(&mut self, likelihood: L)
where
L: Fn(&[f64]) -> f64,
{
for (w, p) in self.weights.iter_mut().zip(&self.particles) {
*w *= likelihood(p);
}
normalize(&mut self.weights);
}
/// Effective sample size of the current weights.
pub fn effective_sample_size(&self) -> f64 {
effective_sample_size(&self.weights)
}
/// Systematic-resample the cloud (offset `u0 ∈ [0,1)`) and reset to uniform weights.
pub fn resample(&mut self, u0: f64) {
let idx = systematic_resample(&self.weights, u0);
self.particles = idx.iter().map(|&i| self.particles[i].clone()).collect();
let u = 1.0 / self.weights.len() as f64;
for w in self.weights.iter_mut() {
*w = u;
}
}
/// The weighted-mean state estimate.
pub fn estimate(&self) -> Vec<f64> {
weighted_mean(&self.particles, &self.weights)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
#[test]
fn effective_sample_size_spans_one_to_n() {
assert!((effective_sample_size(&[0.25; 4]) - 4.0).abs() < 1e-12);
assert!((effective_sample_size(&[1.0, 0.0, 0.0, 0.0]) - 1.0).abs() < 1e-12);
assert!((effective_sample_size(&[0.5, 0.5, 0.0, 0.0]) - 2.0).abs() < 1e-12);
}
#[test]
fn systematic_resample_picks_in_proportion_to_weight() {
// Weights [0.1, 0.6, 0.3], u0 = 0.5 ⇒ indices [1, 1, 2] (the 0.6 particle twice,
// the 0.3 particle once, the 0.1 particle dropped).
assert_eq!(systematic_resample(&[0.1, 0.6, 0.3], 0.5), vec![1, 1, 2]);
// A collapsed weight vector resamples entirely to the surviving particle.
assert_eq!(systematic_resample(&[0.0, 1.0, 0.0], 0.5), vec![1, 1, 1]);
}
#[test]
fn weighted_mean_is_the_convex_combination() {
let particles = vec![vec![0.0], vec![2.0], vec![4.0]];
let m = weighted_mean(&particles, &[0.5, 0.25, 0.25]);
assert!((m[0] - 1.5).abs() < 1e-12); // 0.5·0 + 0.25·2 + 0.25·4
}
#[test]
fn update_concentrates_weight_near_the_measurement() {
// A fine, deterministic grid of 1-D particles over [−10, 10]; a Gaussian
// likelihood centred on z = 5 (σ = 1) must pull the estimate to ≈ 5.
let n = 1001;
let particles: Vec<Vec<f64>> = (0..n)
.map(|i| vec![-10.0 + 20.0 * i as f64 / (n - 1) as f64])
.collect();
let mut pf = ParticleFilter::new(particles);
let z = 5.0;
pf.update(|p| (-0.5 * (p[0] - z) * (p[0] - z)).exp());
assert!(
(pf.estimate()[0] - z).abs() < 1e-3,
"estimate = {}",
pf.estimate()[0]
);
// The cloud has degenerated (ESS far below N), so it should be resampled.
assert!(pf.effective_sample_size() < n as f64 / 2.0);
}
#[test]
fn resample_then_uniform_weights_and_concentrated_particles() {
let particles = vec![vec![0.0], vec![5.0], vec![10.0]];
let mut pf = ParticleFilter::new(particles);
pf.weights = vec![0.0, 1.0, 0.0]; // all weight on the middle particle
pf.resample(0.5);
assert!(pf.particles.iter().all(|p| (p[0] - 5.0).abs() < 1e-12));
assert!(pf.weights.iter().all(|&w| (w - 1.0 / 3.0).abs() < 1e-12));
}
#[test]
fn predict_is_deterministic_for_a_fixed_seed_and_moves_particles() {
// Constant-velocity-ish drift f(x) = x + 1, with process noise.
let run = || {
let mut pf = ParticleFilter::new(vec![vec![0.0], vec![10.0]]);
let mut rng = ChaCha8Rng::seed_from_u64(7);
pf.predict(|p| vec![p[0] + 1.0], &[0.5], &mut rng);
pf.particles.clone()
};
let a = run();
let b = run();
assert_eq!(a, b, "same seed must reproduce the propagation");
// Each particle moved ~ +1 from its start (within a few sigma of the noise).
assert!((a[0][0] - 1.0).abs() < 3.0 && (a[1][0] - 11.0).abs() < 3.0);
}
}