Multi-Platform Fusion
Watch two drones report the same contact, side by side, and follow each 4817 message onto the map — what is generated where, where it is published, what gets gated, what correlates, and what fuses into one track.
BLUF
This is the picture behind the Fused tracks panel on
/testing. Two drones each detect a contact and
each publish a DYNAMIC_UPDATE. The COP gates them (are they even close
enough to be the same thing?), correlates them (z/t test — same object?), and
fuses the matches by inverse variance into one track (trk-0001). A third
contact that falls outside the gate stays its own track. Follow it on the map
below.
Watch two drones make one track
Colour = which drone. Drone A (EO) is the better sensor (σ = 5 m, small error circle); Drone B (radar) is coarser (σ = 10 m, big circle). Both see Contact 1. Only Drone B sees Contact 2, off to the side.
Two DYNAMIC_UPDATE messages → one fused track. The dashed circle is the gate;
Drone B's Contact-2 fix falls outside it, so it never even reaches the correlation
test.
The two messages, side by side
A contact is not a special message — it is a DYNAMIC_UPDATE whose pose is
the contact's position and whose pose.accuracy is the sensor's covariance.
The only difference between the two messages is the position (a few metres
apart) and the variance (the EO is 4× tighter) — that variance is what decides the
fusion weights.
Drone A · EO — the tighter sensor (xx_variance: 25):
{
"header": { "message_type": "DYNAMIC_UPDATE", "source": "DRONE-A-EO" },
"body": { "dynamic_update": { "track": {
"pose": {
"position": { "latitude_longitude_altitude": {
"latitude": 10.000000, "longitude": 12.000000, "altitude": [{ "value": 123.0 }] } },
"accuracy": { "xx_variance": 25, "yy_variance": 25, "zz_variance": 100 }
}
}}}
}Drone B · radar — the coarser sensor (xx_variance: 100):
{
"header": { "message_type": "DYNAMIC_UPDATE", "source": "DRONE-B-RADAR" },
"body": { "dynamic_update": { "track": {
"pose": {
"position": { "latitude_longitude_altitude": {
"latitude": 10.000080, "longitude": 12.000110, "altitude": [{ "value": 121.0 }] } },
"accuracy": { "xx_variance": 100, "yy_variance": 144, "zz_variance": 400 }
}
}}}
}Follow one contact, end to end
| # | Stage | What happens to this message | Where you see it |
|---|---|---|---|
| 1 | Generate | Drone A's tracker fixes Contact 1 and builds a DYNAMIC_UPDATE: pose = contact position, pose.accuracy = its own error (var 25). Drone B does the same with var 100. | the two cards above |
| 2 | Publish | Each drone publishes to its contact topic (e.g. …/world/contact). | /testing → publish a fusion example |
| 3 | Validate | The COP subscribes, schema-checks the message. | /testing → Live validation (DYNAMIC_UPDATE ✓) |
| 4 | Normalize | Pull lat/lon/alt + the xx/yy/zz_variance diagonal → one measurement per axis. | — |
| 5 | Gate | Reject anything beyond 5σ of the combined error. B's Contact-1 fix is inside A's gate → candidate. B's Contact-2 fix is outside → never tested. | dashed circles on the map |
| 6 | Correlate | On survivors, z/t-test each axis, combine by geometric mean. Contact 1: similarity ≥ α → same object. | /testing → trk-0001 · sim 0.98 |
| 7 | Fuse | Inverse-variance: A (var 25) gets weight 0.8, B (var 100) 0.2. Fused latitude = 10.000016, combined variance 20 (tighter than either). | Fused tracks → provenance weights |
| 8 | Publish track | COP emits a fused DYNAMIC_UPDATE, COP track-number trk-0001, contributors in extra. | Fused tracks panel |
What fuses, what doesn't — and where the gate is
| Pair | Distance vs gate | Correlation test | Result |
|---|---|---|---|
| A·Contact 1 ↔ B·Contact 1 | inside 5σ gate | similarity ≥ α ✓ | fused → trk-0001 |
| A·Contact 1 ↔ B·Contact 2 | outside gate | not even run | kept separate → trk-0002 |
The gate is the cheap spatial filter that runs before the statistical test — no gate, no scale (§ It scales to N below). The test is the statistical decision that two fixes inside the gate are really the same object. Two contacts a gate-width apart never get confused; one contact seen by two drones always converges to one track.
This is exactly the trk-0001 on /testing: publish
catl_fusion_track_sensor_eo then …_radar and watch one fused track appear with
LAT 10 ±1.38e-5, the provenance table showing each sensor's inverse-variance
weight, and the coarse radar rows weighted far below the EO rows.
Friendly vs unknown — anchor, don't average
The two-drone picture above is the unknown case (no ground truth → fuse the detections). Friendlies are different, and getting this right is what stops 100 platforms that all see each other from minting thousands of phantom tracks:
- Friendly — it self-reports via
NODE_STATUS(own nav truth) and AIS gives identity. So anchor the entity to its self-report; use everyone else's detections only to confirm and to estimate each sensor's bias (free calibration). Mutual detections collapse onto the known platform, not new tracks. - Unknown — no truth, so inverse-variance fuse the detections (exactly the map above) under a stable COP alias.
Stable aliases & persistence
The alias is the contract with everything downstream. Contact 1 detected by drones
A and B gets trk-0001; when A turns away and a drone C picks it up, C's detection
re-associates to the same alias (it gates the existing fused track) — one
continuous "shared contact image", not a flicker of births and deaths.
It scales to N
With D detections per cycle, all-pairs correlation is D·(D−1)/2. The spatial gate
collapses that to local candidates — for a 100-platform / 100-unknown semicircle
(~16 k detections/cycle) that is the difference between ~10⁸ naive pairs and a few
hundred-thousand gated tests. The runnable scenario lives in
interop/validation-api/cop_scenario.py (deterministic, seed 4817); the reference
engine it drives is cop_fusion.py, built entirely on the test-covered fusion.py
primitives.
The decision core (the new primitives)
fuse / correlate / combine are the stateless
primitives (already shown there in
Python / TypeScript / Rust / Java). This page adds two: the gate and the
same-object decision.
import math
import fusion as fz
def gate_distance(a_var, b_var, k=5.0):
return k * math.sqrt(a_var + b_var) # (5) cheap reject, before the test
def same_object(ax, ay, avar, bx, by, bvar, alpha=0.05):
sea, seb = math.sqrt(avar), math.sqrt(bvar)
cx = fz.correlate(ax, sea, 1, bx, seb, 1, alpha=alpha) # (6) z/t per axis
cy = fz.correlate(ay, sea, 1, by, seb, 1, alpha=alpha)
sim = fz.combine_scores([cx.similarity, cy.similarity]) # geometric mean
return sim >= alpha, sim
# association order: AIS id -> NODE_STATUS self-report (anchor) ->
# existing track (KEEP alias) -> new alias. Full engine: cop_fusion.py// similarity() and combine() come from the Correlation & Fusion page.
function gateDistance(aVar: number, bVar: number, k = 5.0): number {
return k * Math.sqrt(aVar + bVar); // (5) cheap reject
}
function sameObject(
ax: number, ay: number, aVar: number,
bx: number, by: number, bVar: number, alpha = 0.05,
): { match: boolean; sim: number } {
const seA = Math.sqrt(aVar), seB = Math.sqrt(bVar);
const sx = similarity(ax, seA, 1, bx, seB, 1); // (6) z/t per axis
const sy = similarity(ay, seA, 1, by, seB, 1);
const sim = combine([sx, sy]); // geometric mean
return { match: sim >= alpha, sim };
}// similarity() and combine() come from the Correlation & Fusion page.
fn gate_distance(a_var: f64, b_var: f64, k: f64) -> f64 {
k * (a_var + b_var).sqrt() // (5) cheap reject
}
fn same_object(ax: f64, ay: f64, a_var: f64,
bx: f64, by: f64, b_var: f64, alpha: f64) -> (bool, f64) {
let (se_a, se_b) = (a_var.sqrt(), b_var.sqrt());
let sx = similarity(ax, se_a, 1, bx, se_b, 1); // (6) z/t per axis
let sy = similarity(ay, se_a, 1, by, se_b, 1);
let sim = combine(&[sx, sy]); // geometric mean
(sim >= alpha, sim)
}// similarity(...) and combine(...) come from the Correlation & Fusion page.
static double gateDistance(double aVar, double bVar, double k) {
return k * Math.sqrt(aVar + bVar); // (5) cheap reject
}
static double[] sameObject(double ax, double ay, double aVar,
double bx, double by, double bVar, double alpha) {
double seA = Math.sqrt(aVar), seB = Math.sqrt(bVar);
double sx = similarity(ax, seA, 1, bx, seB, 1); // (6) z/t per axis
double sy = similarity(ay, seA, 1, by, seB, 1);
double sim = combine(sx, sy); // geometric mean
return new double[]{ sim >= alpha ? 1 : 0, sim };
}Correlation & Fusion
How to decide two sensors saw the same object (correlation) and combine their measurements optimally (inverse-variance fusion) — and how it maps onto STANAG 4817.
API Reference
The complete STANAG 4817 / AEP-105 HIBW data model — every package, struct, union, enum and typedef, with class diagrams.