Skip to content

Commit 2ded247

Browse files
docs(roadmap): add specs for items 67, 70 and 74 (#364)
These are the generic pieces that robotics-toolbox-cpp's localization family needs: - 67 PointSetAlignment (Kabsch/Umeyama): weighted rigid or similarity fit with the reflection fix, a 2-D closed form and 3-D degeneracy rejection. Used for point-to-point ICP and calibration. - 70 ParticleFilter: bounded SIR with log-domain weights, effective sample size, and systematic resampling from a caller-supplied uniform. Motion and likelihood are passed as callables. - 74 ResidualKalmanUpdate (new item): stateless Joseph-form update from a caller-formed residual, returning the NIS, with a χ² gate. ExtendedKalmanFilter can't serve wrapped bearings, per-landmark measurement models or Mahalanobis gating, because it forms z − h(x) itself and does not expose S. All test vectors were checked in double precision. Claude-Session: https://claude.ai/code/session_01Mmp77ieEy16GxJL8KsLsVx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63c9b91 commit 2ded247

11 files changed

Lines changed: 565 additions & 7 deletions

File tree

‎ROADMAP.md‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,18 @@ Difficulty legend:
2828
|----|-------------------------------------------------------|-------------------------|------------|
2929
| 66 | SE(2) transform, twists, exp/log, pose error | `math` | ★★☆☆☆ |
3030
| 67 | Kabsch / Umeyama point-set alignment | `solvers` | ★★☆☆☆ |
31+
| 74 | Residual-form Kalman update with χ² gating | `filters/active` | ★★☆☆☆ |
3132
| 68 | Lie-group Jacobians and ⊕/⊖ for SO(3), SE(3), SE(2) | `math` | ★★★☆☆ |
3233
| 69 | Control allocation (redistributed pseudo-inverse, QP) | `controllers` | ★★★☆☆ |
3334
| 70 | Particle filter (bounded, systematic resampling) | `filters/active` | ★★★☆☆ |
3435
| 71 | Error-state (multiplicative) EKF on Lie groups | `filters/active` | ★★★★☆ |
3536
| 72 | Minimum-derivative (min-snap) piecewise polynomials | `motion_profiles` (new) | ★★★★☆ |
3637
| 73 | Online jerk-limited trajectory from arbitrary state | `motion_profiles` (new) | ★★★★★ |
3738

38-
Items 66–73 are generic building blocks of robotics-toolbox-cpp's roadmap (referenced there as
39-
NT66–NT73; NT53–NT65 are deployed). They serve its mobile, aerial, legged, marine and localization
40-
families and also have non-robotics consumers. Item 66 has a spec under [`roadmap/`](roadmap/README.md);
41-
items 67–73 have none yet. All are *(float-first)*.
39+
Items 66–74 are generic building blocks of robotics-toolbox-cpp's roadmap (referenced there as
40+
NT66–NT74; NT53–NT65 are deployed). They serve its mobile, aerial, legged, marine and localization
41+
families and also have non-robotics consumers. Items 66, 67, 70 and 74 have specs under
42+
[`roadmap/`](roadmap/README.md); items 68, 69 and 71–73 have none yet. All are *(float-first)*.
4243

4344
Items 48–52 are the **evaluation & metrics primitives** — reusable quantities the per-family
4445
unit-test reference [`TESTING.md`](TESTING.md) depends on but which
@@ -64,6 +65,20 @@ the library does not yet expose. Detailed below under
6465
and fiducial registration.
6566
- **Algorithm / paper:** W. Kabsch, *Acta Cryst.* A32, 1976; S. Umeyama, *IEEE T-PAMI*, 13(4), 1991.
6667
- **Reuses:** `SingularValueDecomposition`.
68+
- **Spec:** [roadmap/solvers/PointSetAlignment](roadmap/solvers/PointSetAlignment/implementation.md).
69+
70+
### 74. Residual-form Kalman update with χ² gating
71+
- **What:** Stateless Kalman measurement update from a caller-formed residual `z ⊖ h(x)`, Jacobian and
72+
noise: Joseph-form correction, normalised innovation squared, and a χ² gate that rejects outliers
73+
without touching the state.
74+
- **Embedded value:** Covers what `ExtendedKalmanFilter` cannot: wrapped angle residuals (bearings,
75+
headings), a measurement model chosen per observation (landmarks), and gating and data association
76+
by Mahalanobis distance.
77+
- **Algorithm / paper:** P. Maybeck, *Stochastic Models, Estimation, and Control*, Vol. 1 (1979), Ch. 5;
78+
Y. Bar-Shalom, X.-R. Li, T. Kirubarajan, *Estimation with Applications to Tracking and Navigation*
79+
(2001), Ch. 5.
80+
- **Reuses:** `CholeskyDecomposition`, `ConsistencyMetrics`, `MatrixOperations`; item 54 for angle residuals.
81+
- **Spec:** [roadmap/filters/active/ResidualKalmanUpdate](roadmap/filters/active/ResidualKalmanUpdate/implementation.md).
6782

6883
---
6984

@@ -93,7 +108,8 @@ the library does not yet expose. Detailed below under
93108
navigation, fault detection — on fixed memory.
94109
- **Algorithm / paper:** N. Gordon, D. Salmond, A. Smith, *IEE Proc. F*, 140(2), 1993; A. Doucet,
95110
A. Johansen, "A tutorial on particle filtering and smoothing" (2009).
96-
- **Reuses:** A caller-injected deterministic PRNG; `math::Statistics`.
111+
- **Reuses:** A caller-injected uniform number for resampling; caller-supplied motion and likelihood callables.
112+
- **Spec:** [roadmap/filters/active/ParticleFilter](roadmap/filters/active/ParticleFilter/implementation.md).
97113

98114
---
99115

‎roadmap/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ with `EXPECT_NEAR` and `math::Tolerance<float>()` (or an explicit tolerance).
8080
`ExponentialMovingAverage` (1) · `MovingAverage` (2) · `MedianFilter` (6) · `CicFilter` (14) · `BiquadCascade` (15) · `NotchCombFilter` (16) · `SavitzkyGolayFilter` (22) · `IirFilterDesign` (45)
8181

8282
### `filters/active`
83-
`AlphaBetaFilter` (8) · `ComplementaryFilter` (9) · `AhrsMadgwickMahony` (33) · `SquareRootKalmanFilter` (39)
83+
`AlphaBetaFilter` (8) · `ComplementaryFilter` (9) · `AhrsMadgwickMahony` (33) · `SquareRootKalmanFilter` (39) · `ParticleFilter` (70) · `ResidualKalmanUpdate` (74)
8484

8585
### `controllers`
8686
`SaturationRateLimiter` (3) · `BangBangHysteresis` (4) · `Feedforward2Dof` (7) · `GainScheduledController` (10) · `LeadLagCompensator` (17) · `LuenbergerObserver` (19) · `IntegralStateFeedbackLqi` (20)
@@ -107,4 +107,4 @@ with `EXPECT_NEAR` and `math::Tolerance<float>()` (or an explicit tolerance).
107107
`ControllabilityObservability` (26) · `TransferFunctionStateSpace` (32)
108108

109109
### `solvers`
110-
`RungeKuttaIntegrators` (24) · `QrDecomposition` (27) · `LuDecomposition` (28) · `LyapunovSylvester` (31) · `JacobiEigenSolver` (42) · `SingularValueDecomposition` (43)
110+
`RungeKuttaIntegrators` (24) · `QrDecomposition` (27) · `LuDecomposition` (28) · `LyapunovSylvester` (31) · `JacobiEigenSolver` (42) · `SingularValueDecomposition` (43) · `PointSetAlignment` (67)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Particle Filter — Overview
2+
3+
## What it is
4+
A state estimator that represents its belief as a fixed set of samples ("particles") with weights,
5+
instead of a single mean and covariance. Each particle is moved by the motion model, re-weighted by how
6+
well it explains the measurement, and the set is periodically resampled to concentrate on the likely
7+
states.
8+
9+
## Why it matters (embedded)
10+
Kalman filters assume a single Gaussian belief. Robot localization in a symmetric building, terrain-aided
11+
navigation and fault detection have several competing hypotheses or strongly non-linear sensors, and a
12+
particle filter handles those directly. With a compile-time particle count it runs in fixed memory and
13+
fixed time per step, and with the random numbers supplied by the caller it is fully reproducible.
14+
15+
## How it works (intuition)
16+
Every particle is a guess of the state. The prediction step moves each guess as the system would move,
17+
with the caller adding random disturbances. The update step scores each guess against the measurement,
18+
and the score becomes its weight. After a few updates most weight sits on a few particles, which wastes
19+
the rest; the effective sample size detects this. Resampling then draws a new, equally weighted set in
20+
which good particles are duplicated and poor ones disappear. The systematic scheme uses one random
21+
number and a comb of evenly spaced pointers, which keeps the extra randomness small.
22+
23+
## Key parameters
24+
- **number of particles** — accuracy versus time and memory (compile-time).
25+
- **resampling threshold** — fraction of the particle count below which the effective sample size triggers resampling.
26+
- **motion model and likelihood** — supplied by the caller.
27+
28+
## Reference
29+
N. J. Gordon, D. J. Salmond, A. F. M. Smith, "Novel approach to nonlinear/non-Gaussian Bayesian state
30+
estimation," *IEE Proc. F*, 140(2), 1993; A. Doucet, A. M. Johansen, "A tutorial on particle filtering and
31+
smoothing: fifteen years later," in *Handbook of Nonlinear Filtering* (2009).
32+
33+
## See also
34+
`ExtendedKalmanFilter`, `UnscentedKalmanFilter` (Gaussian alternatives); robotics-toolbox-cpp's Monte Carlo
35+
localization (E4).
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Particle Filter (Bounded SIR, Systematic Resampling) — Implementation Pseudocode
2+
3+
> Roadmap ref: #70 (Tier 3) · Target: `numerical/filters/active` · Namespace `filters` · Type: `float` (templated on `T`, instantiated for `float` only)
4+
5+
Sequential importance resampling with a compile-time particle count. The caller supplies the motion
6+
model (including its noise draws) and the measurement log-likelihood as callables. The filter keeps
7+
normalised weights, measures degeneracy with the effective sample size, and resamples systematically
8+
from a caller-provided uniform number. With no allocation and no internal random generator, a seeded
9+
run is bit-for-bit reproducible.
10+
11+
## Data structures
12+
13+
```cpp
14+
template<typename T, std::size_t StateSize, std::size_t NumParticles> # static_assert(std::is_floating_point_v<T>); NumParticles ≥ 2
15+
class ParticleFilter:
16+
using StateVector = math::Vector<T, StateSize>
17+
std::array<StateVector, NumParticles> buffers[2] # current and resampling target
18+
std::size_t active{ 0 }
19+
std::array<T, NumParticles> weights # normalised, Σ = 1
20+
std::array<T, NumParticles> scratch # log-domain work area
21+
```
22+
23+
## Interface
24+
25+
```cpp
26+
template<class Draw> void Initialize(Draw&& draw) # particles[i] = draw(i); weights = 1/N
27+
template<class Propagate> void Predict(Propagate&& propagate) # propagate(particle&, i) in place; hot path
28+
template<class LogLikelihood> bool Update(LogLikelihood&& logLikelihood) # w ∝ w·exp(ℓ(x)); false if every ℓ = −∞
29+
T EffectiveSampleSize() const # 1 / Σ wᵢ²
30+
void Resample(T u) # systematic, u ∈ [0, 1); weights → 1/N
31+
bool ResampleIfNeeded(T essFraction, T u) # resample when ESS < essFraction·N
32+
StateVector Mean() const # Σ wᵢ xᵢ, component-wise
33+
const StateVector& MaximumWeight() const # highest-weight particle
34+
std::span<const StateVector> Particles() const; std::span<StateVector> Particles()
35+
std::span<const T> Weights() const
36+
```
37+
38+
Callables are template parameters, not `infra::Function`, so captured state of any size is allowed and
39+
the calls inline.
40+
41+
## Algorithm (pseudocode)
42+
43+
```text
44+
function Update(ℓ): # OPTIMIZE_FOR_SPEED; log domain against under/overflow
45+
for i: scratch[i] = log(weights[i]) + ℓ(xᵢ) # log 0 = −∞ keeps dead particles dead
46+
m = max_i scratch[i]
47+
if m == −∞: return false # every particle impossible: weights unchanged, caller decides
48+
for i: weights[i] = exp(scratch[i] − m)
49+
normalise weights to Σ = 1
50+
return true
51+
52+
function EffectiveSampleSize(): return 1 / Σ weights[i]²
53+
54+
function Resample(u): # systematic: one uniform, N evenly spaced pointers
55+
c = weights[0]; j = 0
56+
for k in [0, N):
57+
target = (u + k) / N
58+
while target > c and j < N − 1: j += 1; c += weights[j]
59+
buffers[1 − active][k] = buffers[active][j]
60+
active = 1 − active; weights = 1/N
61+
62+
function ResampleIfNeeded(f, u):
63+
if EffectiveSampleSize() < f·N: Resample(u); return true
64+
return false
65+
```
66+
67+
## Complexity & memory
68+
69+
- `Predict` / `Update`: `O(N)` callable invocations; `Update` adds `N` `exp` and `N` `log`. `Resample`: `O(N)`.
70+
- Memory: `2·N·StateSize + 2·N` scalars plus an index. A 3-state, 500-particle filter uses 16 KB in
71+
`float`. No heap.
72+
73+
## Numerical / embedded notes
74+
75+
- **Log domain.** Likelihoods of long scans underflow `float` (`exp(−200) = 0`). Subtracting the maximum
76+
log-weight keeps the best particle at weight 1 before normalisation, so the update never divides by 0.
77+
- **Systematic resampling** has the lowest variance of the common schemes and costs one random number
78+
per resampling, injected as `u` so the caller owns the generator (seeded, deterministic tests).
79+
- **Resample only when needed.** Resampling every step adds sample impoverishment. `ResampleIfNeeded(0.5, u)`
80+
is the usual ESS rule (Doucet & Johansen, 2009).
81+
- `Mean` is component-wise. For angles (headings in localization) the caller computes the circular mean
82+
from `Weights()`; `MaximumWeight` gives the MAP-style point estimate.
83+
- Particles are mutable through `Particles()`, so the caller can inject random particles for
84+
kidnapped-robot recovery (augmented MCL).
85+
- Float-only: `static_assert(std::is_floating_point_v<T>)`.
86+
87+
## Deployment
88+
89+
- Header: `numerical/filters/active/ParticleFilter.hpp` — `#pragma once`, `OPTIMIZE_FOR_SPEED` on
90+
`Predict`/`Update`/`Resample`, and `extern template class ParticleFilter<float, 3, 64>;` under
91+
`#ifdef NUMERICAL_TOOLBOX_COVERAGE_BUILD` (the template members are instantiated by the tests).
92+
- Coverage: `numerical/filters/active/ParticleFilter.cpp` → `template class ParticleFilter<float, 3, 64>;`
93+
- Test: `numerical/filters/active/test/TestParticleFilter.cpp`
94+
- Doc: `doc/filters/active/ParticleFilter.md` (per `doc/TEMPLATE.md`) + row in `doc/filters/README.md`
95+
- CMake: `.hpp` → `target_sources(numerical.filters.active PRIVATE ...)`; `.cpp` →
96+
`numerical_add_coverage_sources(numerical.filters.active ...)`; test → `numerical.filters.active_test`.
97+
- Generic pattern: see `roadmap/DEPLOYMENT.md`.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Particle Filter — Unit Test Plan (Pseudocode)
2+
3+
> GoogleTest · `TEST_F` (`float`) · `StrictMock` only · no heap.
4+
5+
## Fixture
6+
7+
```cpp
8+
class TestParticleFilter : public ::testing::Test:
9+
ParticleFilter<float, 1, 41> grid # Initialize(i ⇒ −2 + 4i/40): an evenly spaced prior on [−2, 2]
10+
ParticleFilter<float, 1, 4> four # Initialize(i ⇒ i), then Update(x ⇒ log(0.1·(x + 1))): weights (0.1, 0.2, 0.3, 0.4)
11+
Gaussian(z, σ) = [z, σ](x) ⇒ −½((z − x)/σ)²
12+
# each case below is a TEST_F(TestParticleFilter, <name>)
13+
```
14+
15+
## Test cases (Arrange / Act / Assert)
16+
17+
```cpp
18+
initialize_sets_uniform_weights:
19+
Assert: every grid weight ≈ 1/41; EffectiveSampleSize() ≈ 41
20+
update_normalises_in_the_log_domain:
21+
Act: grid.Update(Gaussian(0.5, 0.5))
22+
Assert: Mean() ≈ 0.498389; EffectiveSampleSize() ≈ 17.690938; MaximumWeight() ≈ 0.5; Σ weights ≈ 1
23+
Act: the same on a fresh grid with ℓ − 1000
24+
Assert: identical weights (no underflow)
25+
sequential_updates_multiply_likelihoods:
26+
Act: grid.Update(Gaussian(0.5, 0.5)); grid.Update(Gaussian(0.7, 0.5))
27+
Assert: Mean() ≈ 0.599970; EffectiveSampleSize() ≈ 12.532656
28+
systematic_resampling_is_deterministic:
29+
Assert: four.EffectiveSampleSize() ≈ 3.333333
30+
after four.Resample(0.5): particles (1, 2, 3, 3), weights all 0.25
31+
on a fresh `four`, Resample(0): particles (0, 1, 2, 3)
32+
resample_if_needed_respects_the_threshold:
33+
Assert: four.ResampleIfNeeded(0.5, 0.5) == false (ESS 3.33 ≥ 2), particles unchanged
34+
four.ResampleIfNeeded(0.9, 0.5) == true (ESS 3.33 < 3.6)
35+
predict_moves_every_particle_and_keeps_weights:
36+
Act: four.Predict([](x, i) { x[0] += 1; })
37+
Assert: Mean() ≈ 3.0 (2.0 + 1); weights still (0.1, 0.2, 0.3, 0.4)
38+
impossible_measurement_leaves_weights_unchanged:
39+
Assert: four.Update(x ⇒ −∞) == false; weights still (0.1, 0.2, 0.3, 0.4)
40+
```
41+
42+
## Reference vectors
43+
44+
- Grid prior on `[−2, 2]` (41 points), `z = 0.5, σ = 0.5`: posterior mean `0.498389` (the discretised
45+
Gaussian's mean is slightly pulled to 0 by the truncated prior), ESS `17.690938`.
46+
- Second measurement `z = 0.7`: mean `0.599970` (≈ (0.5 + 0.7)/2), ESS `12.532656`.
47+
- Systematic pointers for `u = 0.5`, `N = 4`: `0.125, 0.375, 0.625, 0.875` against the cumulative weights
48+
`0.1, 0.3, 0.6, 1.0` ⇒ indices `1, 2, 3, 3`.
49+
50+
## Edge cases
51+
52+
- `u` close to 1 (`0.9999`) still selects valid indices (the `j < N − 1` guard).
53+
- A particle with weight 0 is never selected by `Resample`.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Residual-Form Kalman Update — Overview
2+
3+
## What it is
4+
The measurement-update half of a Kalman filter, packaged as a standalone step. The caller states how far
5+
the measurement is from the prediction (the residual), how that measurement depends on the state and how
6+
noisy it is. The step corrects the state and its uncertainty, or refuses if the measurement is
7+
implausible.
8+
9+
## Why it matters (embedded)
10+
Real estimators rarely fit a fixed "measure, subtract, correct" mould. A bearing of 179° against a
11+
prediction of −179° is a 2° error, not 358°. A robot seeing one landmark among many needs a different
12+
measurement model each time. A reflection or a mismatched landmark must be thrown out before it
13+
corrupts the estimate. Letting the caller form the residual and checking it against the predicted
14+
uncertainty covers all three in a few dozen operations for small states.
15+
16+
## How it works (intuition)
17+
Combining the predicted uncertainty with the measurement noise gives the expected spread of the residual.
18+
Dividing the actual residual by that spread (in the matrix sense) yields the normalised innovation
19+
squared — how many "standard deviations" away the measurement is. If that exceeds a χ² gate the
20+
measurement is ignored. Otherwise the Kalman gain blends prediction and measurement, and the covariance is
21+
updated in Joseph form, which keeps it a valid covariance even with rounding.
22+
23+
## Key parameters
24+
- **residual** `z ⊖ h(x)` — formed by the caller (wrapped for angles).
25+
- **measurement Jacobian `H`** and **noise `R`**.
26+
- **gate** — χ² threshold on the NIS (e.g. 9.21 for 2-D at 99 %).
27+
28+
## Reference
29+
P. S. Maybeck, *Stochastic Models, Estimation, and Control*, Vol. 1 (1979), Ch. 5 (Joseph form);
30+
Y. Bar-Shalom, X.-R. Li, T. Kirubarajan, *Estimation with Applications to Tracking and Navigation* (2001),
31+
Ch. 5 (validation gates).
32+
33+
## See also
34+
`ExtendedKalmanFilter` (fixed-model filter), `ConsistencyMetrics` (NIS/NEES), `AngleUtilities` (item 54);
35+
robotics-toolbox-cpp's odometry fusion (E1) and landmark localization (E3).

0 commit comments

Comments
 (0)