Merge pull request #1732 from bilawalsidhu/fix/adsblol-true-heading

fix(flights): an aircraft with no ground track faces its true heading, not north
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 09:10:58 -08:00
commit f24abfc88f
7 files changed
+69 -3

No files matched your search

@@ -0,0 +1,4 @@
- An aircraft that reports no ground track (parked or taxiing, from adsb.lol
or a local receiver) now faces its reported true heading instead of north,
so one taxiing south no longer looks like it is moving backwards
(samehkhamis, #1732).
+5 -1
View File
@@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
const geometricFeet = finiteNumber(aircraft?.alt_geom);
const groundSpeedKnots = finiteNumber(aircraft?.gs);
const verticalRateFpm = readAdsbVerticalRateFpm(aircraft);
const track = finiteNumber(aircraft?.track);
// A parked or slow aircraft reports no ground track, but often its true
// heading (surface messages): the way its nose points, which is what an
// aircraft standing still is drawn by. Magnetic heading is not true north.
const track =
finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading);
return [
hex,
+12
View File
@@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () =
assert.equal(normalized.states[0][7], null);
assert.equal(normalized.states[0][8], true);
});
test('an aircraft with no track takes its true heading, never its magnetic one', () => {
const at = (extra) =>
normalizeAdsbLolAircraftState(
{ hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra },
1000,
)[10];
assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first');
assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading');
assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic');
assert.equal(at({}), null, 'none');
});
+2 -1
View File
@@ -248,7 +248,8 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
lon: hasPosition ? position.lon : null,
altitudeFt: altitude,
groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track),
// No track (parked, slow): the true heading, where the nose points.
trackDeg: normalizeTrack(entry.track ?? entry.true_heading),
verticalRateFpm: readAdsbVerticalRateFpm(entry),
lastPositionAt: hasPosition
? receivedAt - Math.max(0, seenPosS) * 1000
+28
View File
@@ -68,6 +68,34 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => {
assert.deepEqual(summary, { heard: 4, positioned: 2 });
});
test('a dump1090 aircraft with no track takes its true heading, never its magnetic one', () => {
const trackOf = (extra) =>
normalizeDump1090Aircraft(
{
now: 1,
aircraft: [
{
hex: 'a4418c',
lat: 37.6,
lon: -122.38,
alt_baro: 'ground',
seen: 1,
...extra,
},
],
},
1000,
)[0].trackDeg;
assert.equal(
trackOf({ track: 56.25, true_heading: 40 }),
56.25,
'track first',
);
assert.equal(trackOf({ true_heading: 185.62 }), 185.62, 'true heading');
assert.equal(trackOf({ mag_heading: 278.44 }), null, 'not magnetic');
assert.equal(trackOf({}), null, 'none');
});
test('dump1090 adapter skips invalid addresses and tolerates missing fields', () => {
const records = normalizeDump1090Aircraft(
{
+2 -1
View File
@@ -71,7 +71,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048,
onGround: cleanText(row.alt_baro).toLowerCase() === 'ground',
speedMps: speedKts == null ? null : speedKts * 0.514444,
courseDeg: finite(row.track),
// No track (parked, slow): the true heading, where the nose points.
courseDeg: finite(row.track) ?? finite(row.true_heading),
verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508,
category: row.category ?? null,
typeCode: cleanText(row.t),
+16
View File
@@ -168,6 +168,22 @@ test('readsb units and position/contact ages are normalized once against the sou
);
});
test('a readsb aircraft with no track takes its true heading, never its magnetic one', () => {
const course = (extra) =>
normalizeReadsbAircraft(
{ hex: 'a4418c', lat: 37.6, lon: -122.38, alt_baro: 'ground', ...extra },
now,
).courseDeg;
assert.equal(
course({ track: 56.25, true_heading: 40 }),
56.25,
'track first',
);
assert.equal(course({ true_heading: 185.62 }), 185.62, 'true heading');
assert.equal(course({ mag_heading: 278.44 }), null, 'not magnetic');
assert.equal(course({}), null, 'none');
});
test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => {
// readsb reports the rate under geom_rate when the aircraft's source is
// GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that.