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.