Multi-sensor fusion tracking for counter-UAS, in Rust: a scenario simulator, filters, and (planned) a head-to-head benchmark of classical vs. learned data association.
Status: v0.4, in progress. A multi-target GNN tracker (gating, score-based track confirmation, OSPA scoring) runs on simulated radar data with missed detections, clutter and manoeuvring targets, with an optional IMM filter on each track. JPDA, additional sensors and the learned-association comparison are not built yet; see the roadmap.
Part of physical-ai-portfolio.
Fielded counter-UAS systems fuse noisy, asynchronous reports from radar, EO/IR and RF sensors into one track picture. The hard parts are nonlinear measurements, manoeuvring targets, clutter, and deciding which measurement belongs to which target. This project builds that stack from first principles and measures it honestly.
- Simulator (
sim): a target driven by piecewise-constant random acceleration, seeded for reproducibility. - Radar model (
models): 2D range-bearing sensor with Gaussian noise and angle wrapping. - EKF (
ekf): constant-velocity model, nonlinear range-bearing update, Joseph-form covariance update, bearing innovation wrapped across the ±π branch cut. - Multi-target scenarios (
sim): several targets, detection probability Pd, Poisson clutter uniform over the surveillance disc, shuffled unlabelled measurements. - Assignment (
assignment): Hungarian algorithm, checked against brute force on random matrices. - GNN tracker (
tracker): chi-square gating, cost = NIS + ln|S|, optimal one-to-one assignment, tentative tracks confirmed on 3 hits in 5 scans (default) or by a sequential likelihood-ratio score, confirmed tracks deleted after 5 consecutive misses. - IMM (
imm): constant-velocity plus coordinated-turn (±ω) models with Markov switching, moment-matched mixing and combination. Derivation in docs/imm-derivation.md. - Metrics (
metrics,eval): OSPA (cutoff 200 m, order 2) per scan, averaged over seeded runs.
Monte Carlo over 100 seeded runs of 100 steps each (first 10 steps discarded), with data generated by the filter's own motion model. Radar: σ_range = 15 m, σ_bearing = 0.003 rad; target acceleration σ = 2 m/s².
| Metric | Measured | Ideal |
|---|---|---|
| Mean NEES (state error vs. filter covariance) | 3.89 | 4 (state dimension) |
| Mean NIS (innovation vs. predicted covariance) | 1.97 | 2 (measurement dimension) |
| Position RMSE, filtered | 14.2 m | |
| Position RMSE, raw radar fixes | 23.2 m |
NEES and NIS near their ideal values mean the filter's reported uncertainty matches its actual error. Reproduce with cargo test --test ekf_consistency -- --nocapture.
5 targets, 100 scans, 20 seeds per cell, one radar (σ_range = 15 m, σ_bearing = 0.003 rad) with a 15 km surveillance disc. Targets start 2-8 km out at 15-35 m/s with σ_accel = 1 m/s². OSPA is scored from scan 11 onward (cutoff 200 m, p = 2); lower is better. "Tracks" is the mean number of confirmed tracks against 5 true targets.
| Clutter / scan | Pd | Mean OSPA | Tracks |
|---|---|---|---|
| 0 | 1.0 | 12.5 m | 5.00 |
| 10 | 1.0 | 12.8 m | 5.00 |
| 50 | 1.0 | 22.3 m | 5.15 |
| 200 | 1.0 | 156.0 m | 13.50 |
| 0 | 0.9 | 13.3 m | 5.00 |
| 10 | 0.9 | 14.3 m | 5.02 |
| 50 | 0.9 | 26.1 m | 5.20 |
| 200 | 0.9 | 154.8 m | 13.08 |
| 0 | 0.7 | 16.2 m | 4.99 |
| 10 | 0.7 | 15.9 m | 4.99 |
| 50 | 0.7 | 28.6 m | 5.17 |
| 200 | 0.7 | 156.0 m | 13.53 |
The default tracker is accurate up to about 10 clutter returns per scan and degrades gracefully at 50. At 200 it breaks down: it confirms roughly 13 tracks for 5 targets, so OSPA is dominated by false tracks. The next section diagnoses this and fixes it. Reproduce this table with cargo run --release --example clutter_sweep (about 100 s).
Diagnosis. Counting separately the confirmed tracks with no true target within 100 m ("false") and the targets with no confirmed track within 100 m ("missed") shows the failure is entirely false confirmations: at 200 clutter returns per scan and Pd 0.9 the default rule has 7.99 false tracks per scan and 0.00 missed targets. Every clutter return spawns a tentative track, so about 200 tentative tracks are born each scan, and a few of them chain through further clutter and satisfy "3 hits in 5 scans" by chance. I have not run an ablation that isolates the wide gate around a fresh track (initial velocity σ = 30 m/s) as the specific cause, so that part is a plausible explanation, not a measured one.
Fixes compared. A stricter M-of-N rule, and a sequential likelihood-ratio score (Blackman) that adds ln(Pd · N(y; 0, S) / (ρ · range)) on a hit and ln(1 − Pd) on a miss, where ρ is the clutter density per m². Confirm at score ≥ 12 or ≥ 20; delete a tentative track below −5. The score rule assumes Pd and the clutter density are known; here they are set to the simulator's true values. Same scenarios as above, 10 seeds. "Confirm delay" is the mean number of scans until each target first has a confirmed track.
| Clutter / scan | Pd | Confirmation rule | Mean OSPA | False | Missed | Confirm delay |
|---|---|---|---|---|---|---|
| 200 | 0.9 | 3 of 5 (default) | 154.2 m | 7.99 | 0.00 | 3.4 scans |
| 200 | 0.9 | 4 of 6 | 24.5 m | 0.15 | 0.00 | 4.6 |
| 200 | 0.9 | 5 of 7 | 15.8 m | 0.01 | 0.00 | 5.7 |
| 200 | 0.9 | score ≥ 12 | 15.1 m | 0.00 | 0.00 | 4.5 |
| 200 | 0.9 | score ≥ 20 | 14.6 m | 0.00 | 0.00 | 5.9 |
| 200 | 0.7 | 3 of 5 (default) | 154.8 m | 8.23 | 0.02 | 4.5 |
| 200 | 0.7 | 4 of 6 | 35.3 m | 0.25 | 0.06 | 7.5 |
| 200 | 0.7 | 5 of 7 | 27.0 m | 0.01 | 0.18 | 10.1 |
| 200 | 0.7 | score ≥ 12 | 18.3 m | 0.01 | 0.04 | 6.5 |
| 200 | 0.7 | score ≥ 20 | 19.4 m | 0.00 | 0.06 | 8.4 |
| 10 | 0.7 | 3 of 5 (default) | 15.4 m | 0.00 | 0.01 | 4.7 |
| 10 | 0.7 | 5 of 7 | 24.2 m | 0.00 | 0.14 | 9.9 |
| 10 | 0.7 | score ≥ 12 | 15.2 m | 0.00 | 0.01 | 4.6 |
What this shows:
- Heavy clutter is a false-confirmation problem, and a confirmation rule fixes it. At 200 returns per scan and Pd 0.9, the score rule cuts mean OSPA from 154 m to 15 m and false tracks from about 8 to 0, at the cost of about one extra scan to confirm.
- Stricter M-of-N rules also work at Pd 0.9, but the cost shows up at low Pd: 5 of 7 takes about 10 scans to confirm at Pd 0.7 and misses targets, even in light clutter (OSPA 24.2 m vs. 15.4 m at 10 returns per scan).
- The score rule does not pay that price. It adapts to Pd and clutter density, so it is equal to the default in light clutter and better in heavy clutter, and it beats 5 of 7 on both OSPA and confirmation delay at Pd 0.7.
Caveats: the thresholds (12 and 20) were tried on these same scenarios, so the numbers are tuned, not held out; only two thresholds and one delete level were tried; and the score rule needs Pd and clutter density, which a real system must estimate. Reproduce with cargo run --release --example clutter_diagnose (about 5 minutes). The default configuration is unchanged; the score rule is opt-in via TrackerConfig::score.
One radar (σ_range = 15 m, σ_bearing = 0.003 rad), one target at 40 m/s: 40 scans straight, 40 turning at +ω, 40 straight, 40 turning at −ω. Truth acceleration noise σ = 0.5 m/s². 200 seeds. The IMM runs constant velocity and coordinated turns at ±0.10 rad/s (σ = 0.5, stay probability 0.9). Position RMSE in metres; lower is better.
| True ω (rad/s) | Filter | Overall | Straight | Turning |
|---|---|---|---|---|
| 0.10 (matched) | CV EKF, σ_a = 0.5 | 64.0 | 29.7 | 84.4 |
| 0.10 | CV EKF, σ_a = 3.0 | 17.0 | 13.5 | 19.6 |
| 0.10 | IMM | 12.6 | 12.4 | 12.8 |
| 0.06 (mismatched) | CV EKF, σ_a = 0.5 | 34.3 | 16.5 | 44.9 |
| 0.06 | CV EKF, σ_a = 3.0 | 13.2 | 12.6 | 13.8 |
| 0.06 | IMM | 11.9 | 11.7 | 12.1 |
| 0.14 (mismatched) | CV EKF, σ_a = 0.5 | 85.3 | 38.9 | 112.6 |
| 0.14 | CV EKF, σ_a = 3.0 | 20.3 | 14.0 | 24.7 |
| 0.14 | IMM | 19.8 | 13.1 | 24.5 |
Two readings. First, a CV filter tuned for straight flight (σ_a = 0.5) falls apart in turns, with 4-5x the error of the IMM. Second, the fair baseline is a CV filter with inflated process noise (σ_a = 3.0), which tracks turns acceptably at the cost of noisier straight legs. Against that baseline the IMM gains 26% at the matched turn rate, 10% at ω = 0.06, and only 2% at ω = 0.14, where the fixed ±0.10 rad/s turn models are too far from the truth to help. The gain depends on the turn models being roughly right. Reproduce with cargo run --release --example imm_maneuver.
Same radar and tracker as above (3-of-5 confirmation), but the 5 targets now manoeuvre: each flies random 25-scan segments of straight flight, a turn at +ω, or a turn at −ω, with truth acceleration noise σ = 0.5 m/s². 100 scans, Pd 0.9, 20 seeds. IMM tracks gate and associate on the combined predicted state and covariance, then update each model by its own likelihood. The IMM uses constant velocity plus coordinated turns at ±0.10 rad/s. "False" and "missed" are counted within 100 m of truth.
| True ω (rad/s) | Clutter / scan | Track filter | Mean OSPA | False | Missed |
|---|---|---|---|---|---|
| 0.10 (matched) | 0 | CV EKF, σ_a = 0.5 | 55.9 m | 0.45 | 0.08 |
| 0.10 | 0 | CV EKF, σ_a = 3.0 | 18.5 m | 0.02 | 0.00 |
| 0.10 | 0 | IMM | 13.1 m | 0.00 | 0.00 |
| 0.10 | 10 | CV EKF, σ_a = 0.5 | 56.1 m | 0.45 | 0.07 |
| 0.10 | 10 | CV EKF, σ_a = 3.0 | 17.9 m | 0.01 | 0.00 |
| 0.10 | 10 | IMM | 13.5 m | 0.00 | 0.00 |
| 0.06 (mismatched) | 0 | CV EKF, σ_a = 3.0 | 15.9 m | 0.00 | 0.00 |
| 0.06 | 0 | IMM | 13.9 m | 0.00 | 0.00 |
| 0.14 (mismatched) | 0 | CV EKF, σ_a = 3.0 | 23.0 m | 0.05 | 0.01 |
| 0.14 | 0 | IMM | 24.0 m | 0.03 | 0.00 |
The pattern matches the single-target result. A CV tracker tuned for straight flight (σ_a = 0.5) loses and re-creates tracks in turns (about 0.45 false tracks per scan), which inflates OSPA by 4x. Inflating its process noise (σ_a = 3.0) recovers most of that. The IMM is best when its turn models are close to the truth (13.1 m vs. 18.5 m at the matched rate, 13.9 m vs. 15.9 m at ω = 0.06) and slightly worse than the inflated CV tracker when they are far off (24.0 m vs. 23.0 m at ω = 0.14). The 13 m floor is mostly measurement noise: the clean, non-manoeuvring scenario scores 12.5 m. Rows at clutter 10 for ω = 0.06 and 0.14 are in the example output and follow the same ordering. Reproduce with cargo run --release --example imm_tracker (a few seconds). The IMM costs roughly three filters per track, so it was not run at the 200-return clutter level.
- One sensor.
- The IMM is not combined with score-based confirmation at heavy clutter, and its cost (about three filters per track) is not benchmarked.
- The IMM uses fixed turn rates (±0.10 rad/s). The turn rate is not estimated, and a strong mismatch removes its advantage.
- Targets are present for the whole scenario (no births or deaths), and clutter is uniform and independent between scans.
- The score-based confirmation assumes known Pd and clutter density.
- Hard assignment (GNN) only; a track is initialised from one measurement with isotropic position uncertainty and zero velocity.
- Simulated data throughout, generated from the same motion model the filter assumes, which is the easy case. No real sensor data.
- Results are averages over a modest number of seeds, with no confidence intervals yet.
cargo test
cargo run --release --example single_target
cargo run --release --example clutter_sweep
cargo run --release --example imm_maneuver
cargo run --release --example imm_tracker
cargo run --release --example clutter_diagnoseRequires a stable Rust toolchain. CI runs fmt, clippy -D warnings and the tests.
JPDA association, ID-switch and fragmentation metrics, EO bearing-only and RF sensors, OSPA/GOSPA evaluation, out-of-sequence measurement handling, a learned association model benchmarked against classical baselines, and Cursor-on-Target output.
Simulation only. Defensive sensing and tracking; no weapons or targeting functionality. Public methods and synthetic data only.
MIT. See LICENSE.