Skip to content

Bug: Label_16_TOD and Label_80 parse DDMM.mm coordinates as decimal degrees ÷ 100, placing aircraft ~15–30 nm off #460

Description

@kevinelliott

Summary

Two position plugins treat the compact NDDMM.mm/WDDDMM.mm coordinate form as plain decimal degrees scaled by 100. The real format is degrees + decimal-minutes (DDMM.mm), so the correct conversion is degrees = floor(value/100) + (value mod 100)/60. Dividing by 100 only works as a wrong-but-close-looking approximation when the minutes portion is small; in general it understates the displacement from the whole-degree boundary by a factor of 60/100, putting the aircraft on the wrong side of the nearest meridian or parallel by up to ~15 nm in latitude and ~30 nm in longitude.

Both plugins have tests that enshrine the buggy output, including a FIXME?: 35.392 comment on the Label 80 test that suggests the maintainers already suspected something is off.

Affected code

1. lib/plugins/Label_16_TOD.ts:52-62 — 2-token branch

} else if (posFields.length === 2) {
  ResultFormatter.position(decodeResult, {
    latitude:
      (CoordinateUtils.getDirection(posFields[0][0]) *
        Number(posFields[0].slice(1))) /
      100,
    longitude:
      (CoordinateUtils.getDirection(posFields[1][0]) *
        Number(posFields[1].slice(1))) /
      100,
  });
}

2. lib/plugins/Label_80.ts:110-124POS IEI

case 'POS': {
  const posResult = POS_REGEX.exec(val);
  const lat = Number(posResult?.groups?.lat) *
              (posResult?.groups?.latd === 'S' ? -1 : 1);
  const lon = Number(posResult?.groups?.lng) *
              (posResult?.groups?.lngd === 'W' ? -1 : 1);
  const position = {
    latitude: Number.isInteger(lat) ? lat / 1000 : lat / 100,
    longitude: Number.isInteger(lon) ? lon / 1000 : lon / 100,
  };
  ...
}

The Number.isInteger(lat) ? lat / 1000 : lat / 100 branch suggests the format is "if integer, scale by 1000; if decimal, scale by 100" — but N3539.2 is degrees-and-decimal-minutes (35° 39.2'), not a fixed-point integer.

Reproduction

Label 16 TOD — using the input from Label_16_TOD.test.ts:92 ('decodes variant 3'):

message.text = '001415,20274,0047, 3740,N3835.95 W07858.88';
plugin.decode(message);
// Plugin returns:
//   latitude:  38.3595   (test asserts this)
//   longitude: -78.5888  (test asserts this)
//
// DDMM.mm interpretation:
//   38° 35.95' = 38 + 35.95/60 = 38.59917  (~14.2 nm north of where the plugin places the aircraft)
//   78° 58.88' = 78 + 58.88/60 = 78.98133  (~29.4 nm west of where the plugin places it)

Label 80 — from Label_80.test.ts:46-57, 'decodes POSRPT variant 2':

message.text = '...\r\n/POS N3539.2W07937.2/FL 360/...';
plugin.decode(message);
// Plugin returns:
//   latitude:  35.391999999999996   // test asserts this; comment says "FIXME?: 35.392"
//   longitude: -79.372              // test asserts this
//
// DDMM.mm interpretation:
//   35° 39.2' = 35.65333    (~15.7 nm off)
//   79° 37.2' = 79.62000    (~22.7 nm off, at this latitude)

The Label 80 test comment is a strong signal that the existing assertion was written defensively to lock current behavior rather than asserting correctness.

For comparison, the 4-token branch in the same Label_16_TOD plugin (lines 45-51) and the decodeStringCoordinatesDecimalMinutes helper in coordinate_utils.ts already handle the ° + ' math correctly — so the fix is to route the compact form through the same path.

Why it matters

Position reports drive every downstream consumer of the decoder — map overlays, ETA/altitude correlation, separation alerts, archival storage. Putting aircraft 15-30 nm off the correct position is the most visible kind of bug a decoder library can ship, and the tests currently make it look intentional.

Suggested fix

Use the existing CoordinateUtils.decodeStringCoordinatesDecimalMinutes helper (which is already imported in several sibling plugins) for the 2-token branch in Label_16_TOD.ts and for the POS case in Label_80.ts. Both plugins would then produce the same answers as Label_83.ts variant 3, which already handles DDMM.mm correctly.

After the fix, update the assertions in Label_16_TOD.test.ts:101-102 and Label_80.test.ts:56-57 to the corrected values, and drop the FIXME?: 35.392 comment.

Test coverage

Once the math is fixed, add a regression test with a coordinate whose minutes portion is large (e.g. N3859.99 W07759.99) where the difference between /100 and °+min/60 is dramatic — that pins the fix and prevents either approach from silently regressing back to the other.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions