STANAG 4817 / AEP-105 · 0.3.0-rc4 (SD-3 RC4)
Concepts

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 from two drones; the COP gates, correlates and fuses Contact 1 into trk-0001, while Contact 2 falls outside the gate and stays a separate track

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

#StageWhat happens to this messageWhere you see it
1GenerateDrone 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
2PublishEach drone publishes to its contact topic (e.g. …/world/contact)./testing → publish a fusion example
3ValidateThe COP subscribes, schema-checks the message./testing → Live validation (DYNAMIC_UPDATE ✓)
4NormalizePull lat/lon/alt + the xx/yy/zz_variance diagonal → one measurement per axis.—
5GateReject 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
6CorrelateOn survivors, z/t-test each axis, combine by geometric mean. Contact 1: similarity ≥ α → same object./testing → trk-0001 · sim 0.98
7FuseInverse-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
8Publish trackCOP 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

PairDistance vs gateCorrelation testResult
A·Contact 1 ↔ B·Contact 1inside 5σ gatesimilarity ≥ α ✓fused → trk-0001
A·Contact 1 ↔ B·Contact 2outside gatenot even runkept 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.
Loading diagram…

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 };
}

On this page