From fd06272e37c1fb9c4271f938cadf1c74b76e13be Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 08:49:29 -0800 Subject: [PATCH] fix(flights): an aircraft with no ground track faces its true heading, not north readsb rows (adsb.lol and a local dump1090 receiver) often send no track for an aircraft on the ground, only its true heading. The track then read as missing and the aircraft was drawn facing north, so one taxiing south looked like it was moving backwards. Each normalizer now falls back to true_heading, never the magnetic heading. --- src/data/adsbLolFallback.js | 6 +++++- src/data/adsbLolFallback.test.mjs | 12 ++++++++++++ src/sources/adsbRecords.js | 3 ++- src/sources/adsbRecords.test.mjs | 28 ++++++++++++++++++++++++++++ src/sources/live/aircraft.js | 3 ++- src/sources/live/contract.test.mjs | 16 ++++++++++++++++ 6 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 61d07387f..a9d5cbbdf 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -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, diff --git a/src/data/adsbLolFallback.test.mjs b/src/data/adsbLolFallback.test.mjs index 873a8bbe2..a99d273de 100644 --- a/src/data/adsbLolFallback.test.mjs +++ b/src/data/adsbLolFallback.test.mjs @@ -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'); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index afa952503..4c2e164a9 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -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 diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index aa14133b6..37bac81a2 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -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( { diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 481bb6113..85b3acdc5 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -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), diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 0fe9ae913..c789458ec 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -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.