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

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.

BLUF

When several platforms observe the same scene, you must answer two questions before you can trust one picture:

  1. Correlation — are two measurements the same object? Decide it with a The same-object check you use when you have lots of samples (20 or more). It leans on the smooth bell curve. (≥ 20 samples) or The same-object check you use when you have only a few samples. It uses a slightly wider, more forgiving bell curve. (< 20) on the standardised difference; combine independent features (position, size, …) into one score.
  2. Fusion — given they match, what is the best combined estimate? Combine by A fancy way of saying 'trust the steadier sensor more.' Each reading's say is 1 divided by its variance, so a 4x calmer sensor counts 4x as much.: lower-variance (better) measurements count more. The result has a smaller How jittery a sensor is. Small variance = the readings sit close together (precise); big variance = they scatter (sloppy). than any single input.

The 4817 hook is already in the model: position variance rides natively in Pose.accuracy (a CovarianceMatrix — xx/yy/zz_variance in m²), classification trust in Confidence (0–1), and the rest of the Blending several measurements of the same thing into one best answer. The steadier sensors get more of a say. metadata (sample count, Short for standard error: how much the average itself might wobble. More readings make it smaller., distribution fit, size-descriptor variances) rides in the sanctioned extra extension map. No schema change is required — the enhanced examples below validate against the stock schema.

Loading diagram…

Fidelity note. This correlation/fusion capability is an R2D2 operational extension (r2d2.office.ilab.zone) built on the 4817 spec — implemented and validated here, not in the upstream spec. The equations below follow the corrected canonical forms. The source paper (Research/variance/) contains a few probable transcription errors — verified against the original .docx/PDF, not our conversion — captured in ERRATA_Korb_variance_20260520.md: the eqn-(2) mean prints a sum of squares, eqn (11a) drops a factor of n, and the Appendix-4 weighting is inverted. We implement the statistically correct forms, flag each deviation, and feed the errata back to the spec authors.

Deep dive — the mathematics

Per-sensor statistics

For nn measurements {x1,…,xn}\{x_1,\dots,x_n\} from one sensor of a target, the arithmetic mean, unbiased sample variance, standard deviation, and standard error of the mean are

xˉ=1n∑i=1nxi,s2=1n−1∑i=1n(xi−xˉ)2,s=s2,sxˉ=sn.\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i, \qquad s^2 = \frac{1}{n-1}\sum_{i=1}^{n}(x_i-\bar{x})^2, \qquad s = \sqrt{s^2}, \qquad s_{\bar{x}} = \frac{s}{\sqrt{n}}.

Against a known calibration truth μ\mu, the bias is   bias=xˉ−μ  \;\text{bias}=\bar{x}-\mu\; and future values are corrected as   xcorr=x−bias\;x_\text{corr}=x-\text{bias}. Each sensor's total variance is the sum of independent components (platform + sensor):

si2=si,platform2+si,sensor2.s_i^2 = s_{i,\text{platform}}^2 + s_{i,\text{sensor}}^2.

Inverse-variance fusion

The optimal combined estimate weights each sensor mean inversely to its variance — better measurements dominate:

ωi=1/si2∑j=1n1/sj2,xwtd=∑i=1nωi xi.\omega_i = \frac{1/s_i^2}{\sum_{j=1}^{n} 1/s_j^2}, \qquad x_\text{wtd} = \sum_{i=1}^{n} \omega_i\, x_i .

The classical combined variance is   scomb2=(∑i1/si2)−1\;s^2_\text{comb} = \big(\sum_i 1/s_i^2\big)^{-1}, which is smaller than any input variance. Following Kirchner (2006), the empirical weighted variance (which also captures disagreement between sensors) is

swtd2=n n−1 ∑i=1nωi (xi−xwtd)2(∑iωi=1),s^2_\text{wtd} = \frac{n}{\,n-1\,}\sum_{i=1}^{n} \omega_i\,(x_i - x_\text{wtd})^2 \quad(\textstyle\sum_i \omega_i = 1),

with swtd=swtd2s_\text{wtd}=\sqrt{s^2_\text{wtd}} and swtd,xˉ=swtd/ns_{\text{wtd},\bar{x}} = s_\text{wtd}/\sqrt{n}.

Correlation (same object?)

For a pair of measurements with means xˉa,xˉb\bar{x}_a,\bar{x}_b and standard errors sea,sebse_a,se_b, the combined error adds in quadrature and the standardised difference is the test statistic:

setot=sea2+seb2,T=xˉa−xˉbsetot.se_\text{tot} = \sqrt{se_a^2 + se_b^2}, \qquad T = \frac{\bar{x}_a - \bar{x}_b}{se_\text{tot}} .

Use the z-distribution when min⁡(na,nb)≥20\min(n_a,n_b)\ge 20, otherwise a two-sample t (dof=na+nb−2\text{dof}=n_a+n_b-2). The similarity is the two-sided tail probability   P(∣T′∣≥∣T∣)  \;P(|T'|\ge|T|)\;; the pair correlates when similarity ≥α\ge\alpha (default α=0.05\alpha=0.05). Intuitively, agreement to within 0, 1, 2, 3 standard errors scores ≈ 1.00, 0.32, 0.05, 0.003.

Combining independent features

Independent feature scores (e.g. position-x, position-y, length, width) combine by the geometric mean of their similarity probabilities (each floored above zero):

similaritycombined=(∏k=1mpk)1/m.\text{similarity}_\text{combined} = \left(\prod_{k=1}^{m} p_k\right)^{1/m}.
Loading diagram…

In the weeds — mapping onto STANAG 4817

Quantity4817 carrierNotes
Position valuePose.position.latitude_longitude_altitudex = lat, y = lon, z = alt
Position variancePose.accuracy → CovarianceMatrix xx_variance, yy_variance, zz_variancediagonal, in m² (per the model's documented axis/unit mapping)
Classification trustClassification.confidence → Confidence0–1 hypothesis-test probability
Size descriptorsLengthValue / WidthValue (vessel & MCM structures)native magnitudes
Sample count, std-error, bias, GoF, similarity, size-descriptor variancesBaseEntity.extra string-mapnamespaced fusion/* keys (values are strings)
Loading diagram…

The fusion/* extension convention

extra is a string → string map, so values are strings. Recommended keys:

KeyMeaning
fusion/sensor_id, fusion/platform_idprovenance
fusion/nsample count behind the measurement
fusion/distribution, fusion/gof_pdistribution type + Anderson–Darling GoF p
fusion/bias_correctedtrue if bias has been removed
fusion/length_m, fusion/length_var_m2 (and width/height)size descriptors + variances

Math for developers — a worked example, four ways

You don't need the equations above to use any of this. This section is the same two operations — fuse and correlate — explained in plain English, run on two real example messages, with the verified outputs and runnable code in Python, TypeScript, Rust, and Java. Every number below is produced by the validator's own pure functions (interop/validation-api/fusion.py) and locked in by a 24-case analytic test suite (test_fusion.py, test_fusion_api.py) — so the code samples reproduce the API to the last digit.

The whole pipeline in one breath

Equation (above)In plain EnglishWhat you call
ωi=(1/si2)/∑j1/sj2\omega_i = (1/s_i^2)/\sum_j 1/s_j^2Trust the tighter sensor more. A measurement with 4× less variance counts 4× more.fuse(values, variances)
xwtd=∑iωixix_\text{wtd} = \sum_i \omega_i x_iThe fused value is that weighted average..value
scomb2=(∑i1/si2)−1s^2_\text{comb} = (\sum_i 1/s_i^2)^{-1}The classical fused variance — tighter than either input..combined_variance
swtd2=nn−1∑iωi(xi−xwtd)2s^2_\text{wtd} = \tfrac{n}{n-1}\sum_i \omega_i (x_i-x_\text{wtd})^2The empirical variance — grows when the sensors disagree (honest error bars)..variance
T=(xˉa−xˉb)/setotT = (\bar x_a-\bar x_b)/se_\text{tot}How many error-bars apart are the two readings?correlate(a, b).test_value
similarity =P(∣T′∣≥∣T∣)=P(\lvert T'\rvert\ge\lvert T\rvert)A 0–1 score: 1 = identical, → 0 = far apart. ≥ α ⇒ same object..similarity
geometric mean of pkp_kFold per-feature agreement (length, width, …) into one decision.combine(scores)

The two example messages catl_fusion_track_sensor_eo and …_radar are a A 4817 message type that reports a track's latest position as it moves — the kind the two example messages use. pair — an Electro-optical: a camera-style sensor. Here it's the more accurate of the two, so it gets the bigger weight. platform (more accurate) and a A radio-ranging sensor. Here it's the less accurate of the two, so it gets the smaller weight. (less accurate) reporting the same surface contact. Both validate against the stock schema; the only thing that makes them "fusable" is their native pose.accuracy A little table of accuracy numbers that says how much the position could be off along each axis (north, east, up). diagonal.

Step 1 — Fuse the two positions

The real data, pulled straight from each message's pose:

AxisEO valueEO variance (m²)Radar valueRadar variance (m²)
latitude10.025 (σ = 5 m)10.00008100 (σ = 10 m)
longitude12.025 (σ = 5 m)12.00011144 (σ = 12 m)
altitude123.0100 (σ = 10 m)121.0400 (σ = 20 m)

The arithmetic (latitude). A fancy way of saying 'trust the steadier sensor more.' Each reading's say is 1 divided by its variance, so a 4x calmer sensor counts 4x as much. 1/25 = 0.04 and 1/100 = 0.01 sum to 0.05, so the How big a say each sensor gets in the blended answer. All the weights add up to 1 (i.e. 100%). are 0.04/0.05 = 0.8 (EO) and 0.2 (radar) — EO counts 4× more. The To blend several measurements of the same thing into one best answer, letting the steadier sensors count for more. is 0.8·10.0 + 0.2·10.00008 = 10.000016, and the classical The textbook 'how good is the blend' number. It's always smaller (tighter) than any single sensor on its own — that's the payoff of fusing. is 1/0.05 = 20 m² — smaller than either input. The two sensors nearly agree on latitude, so the The honest spread once you actually look at the readings. If the sensors disagree, this grows to admit it — unlike the textbook number. is tiny (2.05e-09). Longitude has uneven weights — EO variance 25 vs radar 144 gives 0.852 / 0.148, not 4:1. Altitude is the interesting axis: the readings differ by 2 m (123 vs 121), so the empirical variance is 1.28 m² (The Greek letter σ. It's the standard deviation — the typical distance between a reading and the average. ≈ 1.13 m) — the math carries an honest spread when sensors disagree, distinct from the classical 80 m².

curl -s http://localhost:8817/fuse/position \
  -H 'content-type: application/json' \
  -d '{"messages": [<eo message>, <radar message>]}'
{
  "axes": {
    "latitude":  { "value": 10.000016, "variance": 2.048e-09, "std_dev": 4.5254834e-05,
                   "std_error": 3.2e-05, "combined_variance": 20.0, "weights": [0.8, 0.2], "n": 2 },
    "longitude": { "value": 12.000016, "variance": 3.0503134e-09, "std_dev": 5.5229642e-05,
                   "std_error": 3.9053254e-05, "combined_variance": 21.301775,
                   "weights": [0.852071, 0.147929], "n": 2 },
    "altitude":  { "value": 122.6, "variance": 1.28, "std_dev": 1.1313708,
                   "std_error": 0.8, "combined_variance": 80.0, "weights": [0.8, 0.2], "n": 2 }
  },
  "used": 2
}

Step 2 — Correlate (is it the same object?)

Now decide whether a second track is the same contact, using independent size features (carried in the fusion/* extra keys). Each feature is a two-sample test of the standardised difference; because both samples are small (n < 20) the code uses a The same-object check you use when you have only a few samples. It uses a slightly wider, more forgiving bell curve. (Short for degrees of freedom: roughly how many independent samples you had. More means a more trustworthy result. = nₐ + n_b − 2 = 40), and the A score from 0 to 1 for 'are these the same object?'. 1 means a perfect match; near 0 means clearly different. is the The chance that two readings would land this far apart purely by luck, even if they were the same object. Bigger = more likely the same..

The real data and arithmetic. For length, the Adding two error amounts the Pythagoras way: square them, add, take the square root. It's how independent wobbles combine. is √(0.87² + 0.73²) = 1.1357, so T = (120 − 121)/1.1357 = −0.8805 — under one error-bar apart — giving similarity 0.3838. For width, T = +1.0636 → similarity 0.2939. Both are far above The pass mark for calling two readings the same object. By default it's 0.05 — score above it and they're declared a match. = 0.05, and their An average for combining 0-to-1 scores. You multiply them and take the root — so one very low score drags the result down honestly. √(0.3838 · 0.2939) = 0.3359 is the combined score.

# one feature in detail
curl -s http://localhost:8817/correlate \
  -H 'content-type: application/json' \
  -d '{"a":{"mean":120,"std_error":0.87,"n":12},"b":{"mean":121,"std_error":0.73,"n":30}}'
{ "test": "t", "test_value": -0.880519, "dof": 40.0, "similarity": 0.383838,
  "correlated": true, "alpha": 0.05, "total_std_error": 1.135694 }
# combine independent features into one decision
curl -s http://localhost:8817/correlate/features \
  -H 'content-type: application/json' \
  -d '{"features": [
        {"name":"length","a":{"mean":120,"std_error":0.87,"n":12},"b":{"mean":121,"std_error":0.73,"n":30}},
        {"name":"width", "a":{"mean":18, "std_error":0.29,"n":12},"b":{"mean":17.5,"std_error":0.37,"n":30}}
      ], "alpha": 0.05}'
{
  "per_feature": [
    { "name": "length", "similarity": 0.383838, "correlated": true },
    { "name": "width",  "similarity": 0.293894, "correlated": true }
  ],
  "combined_similarity": 0.335868,
  "correlated": true,
  "alpha": 0.05
}

The combined similarity clears α, so the two tracks are declared the same object and fused per Step 1.

The same math in your language

Each program is self-contained (no dependencies) and prints the verified numbers above. The fusion is plain arithmetic; the t-test similarity uses the regularised incomplete beta (Numerical-Recipes continued fraction) — a direct port of fusion.py.

# ============================================================================
#  Sensor fusion & correlation — the whole idea in plain Python (stdlib only)
# ============================================================================
#  Two jobs:
#    1. fuse()       blend several readings of ONE number into one best guess.
#    2. similarity() score how likely two readings came from the SAME object.
#  Nothing to install. Run it with:  python3 fusion_demo.py
# ============================================================================

import math


# ---------------------------------------------------------------------------
#  1. INVERSE-VARIANCE FUSION  —  "trust the steadier sensor more"
#
#  Every reading comes with a `variance`: how jittery that sensor is.
#  Small variance = precise, big variance = sloppy. We give each reading a
#  weight of 1/variance, then take a weighted average — so a sensor with 4x
#  less variance ends up counting 4x as much.
# ---------------------------------------------------------------------------
def fuse(values, variances):
    # Step 1: turn each variance into a "trust" score (1 / variance).
    inverse_variances = [1.0 / v for v in variances]

    # Step 2: add up all the trust, so we can turn the scores into fractions.
    total_trust = math.fsum(inverse_variances)

    # Step 3: each weight is that sensor's share of the total trust.
    #         All the weights add up to 1.0.
    weights = [iv / total_trust for iv in inverse_variances]

    # Step 4: the fused value is the weighted average of the readings.
    mean = math.fsum(w * x for w, x in zip(weights, values))

    # Step 5: report how spread out the readings were around that average.
    #         This grows when the sensors DISAGREE (honest error bars).
    n = len(values)
    spread = math.fsum(w * (x - mean) ** 2 for w, x in zip(weights, values))
    variance = spread * n / (n - 1)          # small-sample (Bessel) correction

    return {
        "value": mean,
        "variance": variance,                    # empirical spread
        "std_dev": math.sqrt(variance),
        "std_error": math.sqrt(variance) / math.sqrt(n),
        "combined_variance": 1.0 / total_trust,  # classical, always tighter
        "weights": weights,
        "n": n,
    }


# ---------------------------------------------------------------------------
#  2. CORRELATION  —  "are these two readings the same object?"
#
#  Measure how many error-bars apart the two readings are (call it `t`), then
#  ask: if they really were the same thing, how often would pure chance push
#  them this far apart? That probability is the "similarity" (0..1).
#  High similarity = probably the same object.
#
#  Working out that probability needs the area under a bell-shaped curve. The
#  three helpers below (lgamma is built in; _betacf and _betai are written out)
#  are standard, well-tested numerical recipes for exactly that. Treat them as a
#  sealed calculator. The plain-English part is similarity() and combine().
# ---------------------------------------------------------------------------

# _betacf + _betai together compute the "regularised incomplete beta", which is
# the math behind the Student-t tail. Method: Lentz's continued fraction
# (Numerical Recipes section 6.4). You should rarely need to touch this.
def _betacf(a, b, x):
    tiny = 1e-30          # a stand-in for "basically zero", avoids divide-by-0
    c = 1.0
    d = 1.0 - (a + b) * x / (a + 1.0)
    d = 1.0 / (tiny if abs(d) < tiny else d)
    h = d
    for m in range(1, 200):           # loop until the fraction stops changing
        m2 = 2 * m
        # --- even step of the continued fraction ---
        aa = m * (b - m) * x / ((a - 1.0 + m2) * (a + m2))
        d = 1.0 / (1.0 + aa * d)
        c = 1.0 + aa / c
        h *= d * c
        # --- odd step of the continued fraction ---
        aa = -(a + m) * (a + b + m) * x / ((a + m2) * (a + 1.0 + m2))
        d = 1.0 / (1.0 + aa * d)
        c = 1.0 + aa / c
        delta = d * c
        h *= delta
        if abs(delta - 1.0) < 3e-11:  # converged: more steps wouldn't change it
            break
    return h


def _betai(a, b, x):
    if x <= 0.0:
        return 0.0
    if x >= 1.0:
        return 1.0
    # the log-scale prefactor keeps the numbers from overflowing
    front = math.exp(math.lgamma(a + b) - math.lgamma(a) - math.lgamma(b)
                     + a * math.log(x) + b * math.log(1.0 - x))
    # use whichever side of the fraction converges faster
    if x < (a + 1.0) / (a + b + 2.0):
        return front * _betacf(a, b, x) / a
    return 1.0 - front * _betacf(b, a, 1.0 - x) / b


def similarity(mean_a, se_a, n_a, mean_b, se_b, n_b):
    # how far apart are the two readings, measured in combined error-bars?
    combined_error = math.hypot(se_a, se_b)        # = sqrt(se_a**2 + se_b**2)
    t = (mean_a - mean_b) / combined_error

    # with 20+ samples the curve is "normal"; its tail uses erfc.
    if min(n_a, n_b) >= 20:                         # z-test
        return math.erfc(abs(t) / math.sqrt(2.0))

    # with few samples, use the fatter-tailed Student-t curve instead.
    dof = n_a + n_b - 2                             # t-test "degrees of freedom"
    return _betai(dof / 2.0, 0.5, dof / (dof + t * t))


def combine(scores):
    # Fold several feature scores (length, width, ...) into one number by taking
    # their geometric mean. Each score is floored at 1e-6 so a single 0 can't
    # wipe out the whole product.
    logs = [math.log(max(1e-6, s)) for s in scores]
    return math.exp(math.fsum(logs) / len(scores))


# ---------------------------------------------------------------------------
#  Run it on the two real sensors (EO first, radar second) and print the
#  verified numbers.
# ---------------------------------------------------------------------------
print(fuse([10.0, 10.00008], [25.0, 100.0]))     # latitude
print(fuse([123.0, 121.0],   [100.0, 400.0]))    # altitude
sl = similarity(120, 0.87, 12, 121, 0.73, 30)    # length feature
sw = similarity(18,  0.29, 12, 17.5, 0.37, 30)   # width feature
print(sl, sw, combine([sl, sw]))

# latitude → {'value': 10.000016, 'variance': 2.048e-09, ..., 'combined_variance': 20.0, 'weights': [0.8, 0.2], 'n': 2}
# altitude → {'value': 122.6, 'variance': 1.28, 'std_dev': 1.1313708..., 'combined_variance': 80.0, 'weights': [0.8, 0.2], 'n': 2}
# 0.383838  0.293894  0.335868   →  combined ≥ 0.05  →  same object
// ============================================================================
//  Sensor fusion & correlation — the whole idea in plain TypeScript
// ============================================================================
//  Two jobs:
//    1. fuse()       blend several readings of ONE number into one best guess.
//    2. similarity() score how likely two readings came from the SAME object.
//  No dependencies. Run with:  npx tsx fusionDemo.ts   (or compile with tsc)
// ============================================================================

// ---------------------------------------------------------------------------
//  1. INVERSE-VARIANCE FUSION  —  "trust the steadier sensor more"
//     Each reading has a `variance` (small = precise, big = sloppy). We weight
//     every reading by 1/variance, then take the weighted average — so a sensor
//     with 4x less variance counts 4x as much.
// ---------------------------------------------------------------------------
function fuse(values: number[], variances: number[]) {
  // Step 1: turn each variance into a "trust" score (1 / variance).
  const inverseVariances = variances.map((v) => 1 / v);

  // Step 2: total trust, used to turn the scores into fractions.
  const totalTrust = inverseVariances.reduce((a, b) => a + b, 0);

  // Step 3: each weight is that sensor's share of the total (weights sum to 1).
  const weights = inverseVariances.map((iv) => iv / totalTrust);

  // Step 4: the fused value is the weighted average of the readings.
  const mean = weights.reduce((sum, w, i) => sum + w * values[i], 0);

  // Step 5: how spread out the readings were (grows when sensors disagree).
  const n = values.length;
  const spread = weights.reduce((sum, w, i) => sum + w * (values[i] - mean) ** 2, 0);
  const variance = (spread * n) / (n - 1);   // small-sample (Bessel) correction

  return {
    value: mean,
    variance,                                 // empirical spread
    stdDev: Math.sqrt(variance),
    stdError: Math.sqrt(variance) / Math.sqrt(n),
    combinedVariance: 1 / totalTrust,         // classical, always tighter
    weights,
    n,
  };
}

// ---------------------------------------------------------------------------
//  2. CORRELATION  —  "are these two readings the same object?"
//     Measure how many error-bars apart they are, then ask how often pure
//     chance would spread them that far. That probability is the "similarity".
//
//     lgamma / betacf / betai below are standard numerical recipes that compute
//     the area under the Student-t curve. Treat them as a sealed calculator;
//     the plain-English part is similarity() and combine() at the bottom.
// ---------------------------------------------------------------------------

// lgamma(z) = log of the Gamma function, via the Lanczos approximation.
// (JavaScript has no built-in lgamma, so we supply one.)
const LANCZOS = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,
  771.32342877765313, -176.61502916214059, 12.507343278686905,
  -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
function lgamma(z: number): number {
  // reflection formula keeps the approximation valid for small z
  if (z < 0.5) return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z);
  z -= 1;
  let x = LANCZOS[0];
  for (let i = 1; i < 9; i++) x += LANCZOS[i] / (z + i);
  const t = z + 7.5;
  return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}

// Continued fraction for the incomplete beta (Lentz's method, Num. Recipes 6.4).
function betacf(a: number, b: number, x: number): number {
  const tiny = 1e-30;         // stand-in for "basically zero", avoids /0
  let c = 1;
  let d = 1 - ((a + b) * x) / (a + 1);
  d = 1 / (Math.abs(d) < tiny ? tiny : d);
  let h = d;
  for (let m = 1; m < 200; m++) {     // loop until it stops changing
    const m2 = 2 * m;
    // even step
    let aa = (m * (b - m) * x) / ((a - 1 + m2) * (a + m2));
    d = 1 / (1 + aa * d);
    c = 1 + aa / c;
    h *= d * c;
    // odd step
    aa = (-(a + m) * (a + b + m) * x) / ((a + m2) * (a + 1 + m2));
    d = 1 / (1 + aa * d);
    c = 1 + aa / c;
    const delta = d * c;
    h *= delta;
    if (Math.abs(delta - 1) < 3e-11) break;   // converged
  }
  return h;
}

// Regularised incomplete beta I_x(a, b) — the Student-t tail builds on this.
function betai(a: number, b: number, x: number): number {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  const front = Math.exp(
    lgamma(a + b) - lgamma(a) - lgamma(b) + a * Math.log(x) + b * Math.log(1 - x));
  // use whichever side of the fraction converges faster
  return x < (a + 1) / (a + b + 2)
    ? (front * betacf(a, b, x)) / a
    : 1 - (front * betacf(b, a, 1 - x)) / b;
}

// erfc(x) = the normal-curve tail, via the Abramowitz & Stegun 7.1.26 polynomial.
function erfc(x: number): number {
  const z = Math.abs(x);
  const t = 1 / (1 + 0.3275911 * z);
  const y = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741
          + t * (-1.453152027 + t * 1.061405429)))) * Math.exp(-z * z);
  return x >= 0 ? y : 2 - y;
}

function similarity(mA: number, seA: number, nA: number,
                    mB: number, seB: number, nB: number): number {
  // how far apart, measured in combined error-bars
  const combinedError = Math.hypot(seA, seB);    // = sqrt(seA^2 + seB^2)
  const t = (mA - mB) / combinedError;
  if (Math.min(nA, nB) >= 20) {                  // 20+ samples → normal curve
    return erfc(Math.abs(t) / Math.SQRT2);       // z-test
  }
  const dof = nA + nB - 2;                        // few samples → Student-t
  return betai(dof / 2, 0.5, dof / (dof + t * t)); // t-test
}

function combine(scores: number[]): number {
  // geometric mean; floor each score at 1e-6 so one 0 can't wipe out the rest
  const sumOfLogs = scores.reduce((s, v) => s + Math.log(Math.max(1e-6, v)), 0);
  return Math.exp(sumOfLogs / scores.length);
}

// Run it on the two real sensors (EO first, radar second).
console.log(fuse([10.0, 10.00008], [25, 100]));   // latitude
console.log(fuse([123, 121], [100, 400]));        // altitude
const sl = similarity(120, 0.87, 12, 121, 0.73, 30);   // length
const sw = similarity(18, 0.29, 12, 17.5, 0.37, 30);   // width
console.log(sl, sw, combine([sl, sw]));

// latitude → { value: 10.000016, variance: 2.048e-9, ..., combinedVariance: 20, weights: [0.8, 0.2], n: 2 }
// altitude → { value: 122.6, variance: 1.28, stdDev: 1.1313708..., combinedVariance: 80, weights: [0.8, 0.2], n: 2 }
// 0.383838  0.293894  0.335868   →  combined ≥ 0.05  →  same object
// ============================================================================
//  Sensor fusion & correlation — the whole idea in plain Rust (no crates)
// ============================================================================
//  Two jobs:
//    1. fuse()       blend several readings of ONE number into one best guess.
//    2. similarity() score how likely two readings came from the SAME object.
//  Run with:  rustc fusion_demo.rs && ./fusion_demo
// ============================================================================

// ---------------------------------------------------------------------------
//  1. INVERSE-VARIANCE FUSION  —  "trust the steadier sensor more"
//     Each reading has a `variance` (small = precise, big = sloppy). We weight
//     every reading by 1/variance and take the weighted average — so a sensor
//     with 4x less variance counts 4x as much.
// ---------------------------------------------------------------------------
struct Fused {
    value: f64,
    variance: f64,             // empirical spread (grows when sensors disagree)
    std_dev: f64,
    std_error: f64,
    combined_variance: f64,    // classical fused variance, always tighter
    weights: Vec<f64>,
    n: usize,
}

fn fuse(values: &[f64], variances: &[f64]) -> Fused {
    // Step 1: turn each variance into a "trust" score (1 / variance).
    let inverse_variances: Vec<f64> = variances.iter().map(|v| 1.0 / v).collect();

    // Step 2: total trust, used to turn the scores into fractions.
    let total_trust: f64 = inverse_variances.iter().sum();

    // Step 3: each weight is that sensor's share of the total (weights sum to 1).
    let weights: Vec<f64> = inverse_variances.iter().map(|iv| iv / total_trust).collect();

    // Step 4: the fused value is the weighted average of the readings.
    let mean: f64 = weights.iter().zip(values).map(|(w, x)| w * x).sum();

    // Step 5: how spread out the readings were around that average.
    let n = values.len();
    let spread: f64 = weights.iter().zip(values).map(|(w, x)| w * (x - mean).powi(2)).sum();
    let variance = spread * n as f64 / (n as f64 - 1.0);   // small-sample correction

    Fused {
        value: mean,
        variance,
        std_dev: variance.sqrt(),
        std_error: variance.sqrt() / (n as f64).sqrt(),
        combined_variance: 1.0 / total_trust,
        weights,
        n,
    }
}

// ---------------------------------------------------------------------------
//  2. CORRELATION  —  "are these two readings the same object?"
//     Measure how many error-bars apart they are, then ask how often pure
//     chance would spread them that far. That probability is the "similarity".
//
//     lgamma / betacf / betai are standard numerical recipes computing the area
//     under the Student-t curve — treat them as a sealed calculator. The plain-
//     English part is similarity() and combine().
// ---------------------------------------------------------------------------

// lgamma(z) = log of the Gamma function, via the Lanczos approximation.
fn lgamma(z: f64) -> f64 {
    const C: [f64; 9] = [0.99999999999980993, 676.5203681218851, -1259.1392167224028,
        771.32342877765313, -176.61502916214059, 12.507343278686905,
        -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7];
    use std::f64::consts::PI;
    if z < 0.5 {                                  // reflection formula for small z
        return (PI / (PI * z).sin()).ln() - lgamma(1.0 - z);
    }
    let z = z - 1.0;
    let mut x = C[0];
    for i in 1..9 {
        x += C[i] / (z + i as f64);
    }
    let t = z + 7.5;
    0.5 * (2.0 * PI).ln() + (z + 0.5) * t.ln() - t + x.ln()
}

// Continued fraction for the incomplete beta (Lentz's method, Num. Recipes 6.4).
fn betacf(a: f64, b: f64, x: f64) -> f64 {
    let tiny = 1e-30;                 // stand-in for "basically zero", avoids /0
    let mut c = 1.0;
    let mut d = 1.0 - (a + b) * x / (a + 1.0);
    if d.abs() < tiny {
        d = tiny;
    }
    d = 1.0 / d;
    let mut h = d;
    for m in 1..200 {                 // loop until it stops changing
        let (mf, m2) = (m as f64, (2 * m) as f64);
        // even step
        let mut aa = mf * (b - mf) * x / ((a - 1.0 + m2) * (a + m2));
        d = 1.0 / (1.0 + aa * d);
        c = 1.0 + aa / c;
        h *= d * c;
        // odd step
        aa = -(a + mf) * (a + b + mf) * x / ((a + m2) * (a + 1.0 + m2));
        d = 1.0 / (1.0 + aa * d);
        c = 1.0 + aa / c;
        let delta = d * c;
        h *= delta;
        if (delta - 1.0).abs() < 3e-11 {          // converged
            break;
        }
    }
    h
}

// Regularised incomplete beta I_x(a, b) — the Student-t tail builds on this.
fn betai(a: f64, b: f64, x: f64) -> f64 {
    if x <= 0.0 {
        return 0.0;
    }
    if x >= 1.0 {
        return 1.0;
    }
    let front = (lgamma(a + b) - lgamma(a) - lgamma(b)
        + a * x.ln() + b * (1.0 - x).ln()).exp();
    // use whichever side of the fraction converges faster
    if x < (a + 1.0) / (a + b + 2.0) {
        front * betacf(a, b, x) / a
    } else {
        1.0 - front * betacf(b, a, 1.0 - x) / b
    }
}

// erfc(x) = the normal-curve tail, via the Abramowitz & Stegun 7.1.26 polynomial.
fn erfc(x: f64) -> f64 {
    let z = x.abs();
    let t = 1.0 / (1.0 + 0.3275911 * z);
    let y = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741
          + t * (-1.453152027 + t * 1.061405429)))) * (-z * z).exp();
    if x >= 0.0 { y } else { 2.0 - y }
}

fn similarity(ma: f64, sea: f64, na: u32, mb: f64, seb: f64, nb: u32) -> f64 {
    let combined_error = sea.hypot(seb);          // = sqrt(sea^2 + seb^2)
    let t = (ma - mb) / combined_error;
    if na.min(nb) >= 20 {                          // 20+ samples → normal curve
        erfc(t.abs() / std::f64::consts::SQRT_2)   // z-test
    } else {
        let dof = (na + nb - 2) as f64;            // few samples → Student-t
        betai(dof / 2.0, 0.5, dof / (dof + t * t)) // t-test
    }
}

fn combine(scores: &[f64]) -> f64 {
    // geometric mean; floor each score at 1e-6 so one 0 can't wipe out the rest
    let sum_of_logs: f64 = scores.iter().map(|s| s.max(1e-6).ln()).sum();
    (sum_of_logs / scores.len() as f64).exp()
}

fn main() {
    let lat = fuse(&[10.0, 10.00008], &[25.0, 100.0]);   // latitude
    let alt = fuse(&[123.0, 121.0], &[100.0, 400.0]);    // altitude
    println!("lat  value={} cvar={} weights={:?}", lat.value, lat.combined_variance, lat.weights);
    println!("alt  value={} variance={} std_dev={} cvar={}", alt.value, alt.variance, alt.std_dev, alt.combined_variance);
    let sl = similarity(120.0, 0.87, 12, 121.0, 0.73, 30);   // length
    let sw = similarity(18.0, 0.29, 12, 17.5, 0.37, 30);     // width
    println!("length={:.6} width={:.6} combined={:.6}", sl, sw, combine(&[sl, sw]));
    // lat  value=10.000016 cvar=20 weights=[0.8, 0.2]
    // alt  value=122.6 variance=1.28 std_dev=1.1313708498984762 cvar=80
    // length=0.383838 width=0.293894 combined=0.335868   →  same object
}
import java.util.Arrays;

// ============================================================================
//  Sensor fusion & correlation — the whole idea in plain Java (no libraries)
// ============================================================================
//  Two jobs:
//    1. fuse()       blend several readings of ONE number into one best guess.
//    2. similarity() score how likely two readings came from the SAME object.
//  Run with:  javac FusionDemo.java && java FusionDemo
// ============================================================================
public class FusionDemo {

    // -----------------------------------------------------------------------
    //  1. INVERSE-VARIANCE FUSION  —  "trust the steadier sensor more"
    //     Each reading has a variance (small = precise, big = sloppy). We weight
    //     each reading by 1/variance and take the weighted average — so a sensor
    //     with 4x less variance counts 4x as much.
    // -----------------------------------------------------------------------
    record Fused(double value, double variance, double stdDev, double stdError,
                 double combinedVariance, double[] weights, int n) {}

    static Fused fuse(double[] values, double[] variances) {
        int n = values.length;

        // Step 1 + 2: trust = 1/variance per reading; add up the total trust.
        double totalTrust = 0;
        for (double v : variances) totalTrust += 1.0 / v;

        // Step 3 + 4: weight = each reading's share of the trust (sums to 1);
        //             the fused value is the weighted average.
        double[] weights = new double[n];
        double mean = 0;
        for (int i = 0; i < n; i++) {
            weights[i] = (1.0 / variances[i]) / totalTrust;
            mean += weights[i] * values[i];
        }

        // Step 5: how spread out the readings were (grows when sensors disagree).
        double spread = 0;
        for (int i = 0; i < n; i++) spread += weights[i] * Math.pow(values[i] - mean, 2);
        double variance = spread * (double) n / (n - 1);   // small-sample correction

        return new Fused(mean, variance, Math.sqrt(variance),
                Math.sqrt(variance) / Math.sqrt(n), 1.0 / totalTrust, weights, n);
    }

    // -----------------------------------------------------------------------
    //  2. CORRELATION  —  "are these two readings the same object?"
    //     Measure how many error-bars apart they are, then ask how often pure
    //     chance would spread them that far. That probability is the similarity.
    //
    //     lgamma / betacf / betai are standard numerical recipes that compute the
    //     area under the Student-t curve — treat them as a sealed calculator. The
    //     plain-English part is similarity() and combine().
    // -----------------------------------------------------------------------

    // lgamma(z) = log of the Gamma function, via the Lanczos approximation.
    static final double[] LANCZOS = {0.99999999999980993, 676.5203681218851, -1259.1392167224028,
        771.32342877765313, -176.61502916214059, 12.507343278686905,
        -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7};
    static double lgamma(double z) {
        if (z < 0.5)                                  // reflection formula for small z
            return Math.log(Math.PI / Math.sin(Math.PI * z)) - lgamma(1 - z);
        z -= 1;
        double x = LANCZOS[0];
        for (int i = 1; i < 9; i++) x += LANCZOS[i] / (z + i);
        double t = z + 7.5;
        return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
    }

    // Continued fraction for the incomplete beta (Lentz's method, Num. Recipes 6.4).
    static double betacf(double a, double b, double x) {
        double tiny = 1e-30;          // stand-in for "basically zero", avoids /0
        double c = 1;
        double d = 1 - (a + b) * x / (a + 1);
        if (Math.abs(d) < tiny) d = tiny;
        d = 1 / d;
        double h = d;
        for (int m = 1; m < 200; m++) {     // loop until it stops changing
            int m2 = 2 * m;
            // even step
            double aa = m * (b - m) * x / ((a - 1 + m2) * (a + m2));
            d = 1 / (1 + aa * d);
            c = 1 + aa / c;
            h *= d * c;
            // odd step
            aa = -(a + m) * (a + b + m) * x / ((a + m2) * (a + 1 + m2));
            d = 1 / (1 + aa * d);
            c = 1 + aa / c;
            double delta = d * c;
            h *= delta;
            if (Math.abs(delta - 1) < 3e-11) break;   // converged
        }
        return h;
    }

    // Regularised incomplete beta I_x(a, b) — the Student-t tail builds on this.
    static double betai(double a, double b, double x) {
        if (x <= 0) return 0;
        if (x >= 1) return 1;
        double front = Math.exp(lgamma(a + b) - lgamma(a) - lgamma(b)
                + a * Math.log(x) + b * Math.log(1 - x));
        // use whichever side of the fraction converges faster
        return x < (a + 1) / (a + b + 2) ? front * betacf(a, b, x) / a
                                         : 1 - front * betacf(b, a, 1 - x) / b;
    }

    // erfc(x) = the normal-curve tail, via the Abramowitz & Stegun 7.1.26 polynomial.
    static double erfc(double x) {
        double z = Math.abs(x);
        double t = 1 / (1 + 0.3275911 * z);
        double y = t * (0.254829592 + t * (-0.284496736 + t * (1.421413741
                 + t * (-1.453152027 + t * 1.061405429)))) * Math.exp(-z * z);
        return x >= 0 ? y : 2 - y;
    }

    static double similarity(double mA, double seA, int nA, double mB, double seB, int nB) {
        double combinedError = Math.hypot(seA, seB);   // = sqrt(seA^2 + seB^2)
        double t = (mA - mB) / combinedError;
        if (Math.min(nA, nB) >= 20)                     // 20+ samples → normal curve
            return erfc(Math.abs(t) / Math.sqrt(2));    // z-test
        double dof = nA + nB - 2;                       // few samples → Student-t
        return betai(dof / 2, 0.5, dof / (dof + t * t)); // t-test
    }

    static double combine(double... scores) {
        // geometric mean; floor each score at 1e-6 so one 0 can't wipe out the rest
        double sumOfLogs = 0;
        for (double v : scores) sumOfLogs += Math.log(Math.max(1e-6, v));
        return Math.exp(sumOfLogs / scores.length);
    }

    public static void main(String[] args) {
        Fused lat = fuse(new double[]{10.0, 10.00008}, new double[]{25, 100});   // latitude
        Fused alt = fuse(new double[]{123, 121}, new double[]{100, 400});        // altitude
        System.out.printf("lat  value=%.6f cvar=%.1f weights=%s%n",
                lat.value(), lat.combinedVariance(), Arrays.toString(lat.weights()));
        System.out.printf("alt  value=%.1f variance=%.1f std_dev=%.6f cvar=%.1f%n",
                alt.value(), alt.variance(), alt.stdDev(), alt.combinedVariance());
        double sl = similarity(120, 0.87, 12, 121, 0.73, 30);   // length
        double sw = similarity(18, 0.29, 12, 17.5, 0.37, 30);   // width
        System.out.printf("length=%.6f width=%.6f combined=%.6f%n", sl, sw, combine(sl, sw));
        // lat  value=10.000016 cvar=20.0 weights=[0.8, 0.2]
        // alt  value=122.6 variance=1.28 std_dev=1.131371 cvar=80.0
        // length=0.383838 width=0.293894 combined=0.335868   →  same object
    }
}

Verified. The fusion outputs are checked end-to-end against these two example messages in test_fuse_position_end_to_end_with_enhanced_examples (weights [0.8, 0.2], combined_variance = 20.0); the distribution tails are checked against standard normal/t tables in test_fusion.py. The four programs above are line-for-line ports of fusion.py and reproduce the API responses exactly. See the API reference (the fusion tag) to run them live.

On this page