diff --git a/changelog.d/1448-local-adsb-geom-rate.md b/changelog.d/1448-local-adsb-geom-rate.md new file mode 100644 index 000000000..a4a0c54f8 --- /dev/null +++ b/changelog.d/1448-local-adsb-geom-rate.md @@ -0,0 +1,6 @@ +- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate + (`geom_rate`) when no barometric rate is sent, so the card shows V/S + instead of a dash (Aditya, @Rohilalala, #1448). +- Local ADS-B, military and the adsb.lol flight fallback read the vertical + rate through one shared readsb field reader, so all three prefer the + barometric rate and fall back to the GNSS rate the same way (#1448). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 030138906..84d42cb7b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6322,7 +6322,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js` `lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is `webusb` (browser SDR) or `feed` (decoder feed). `normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ -skyaware978 `aircraft.json` document into the same records. Voice +skyaware978 `aircraft.json` document into the same records; a row without +`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate +is sent. Voice `set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", "my antenna"). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0cd9edca5..11134114f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1183,5 +1183,6 @@ "src/firstRunLauncherView.test.mjs", "src/firstRunCopy.js", "src/data/headingDegrees.test.mjs", - "src/data/distanceFormat.test.mjs" + "src/data/distanceFormat.test.mjs", + "src/sources/adsbFields.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 96057df6c..303107647 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -179,6 +179,7 @@ "runtime": "node", "exports": ["./server/providers/live"], "modules": [ + "src/sources/adsbFields.js", "server/providers/live.js", "server/providers/common/http.js", "server/providers/common/query.js", @@ -198,7 +199,7 @@ }, "adsb-lol-source": { "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"], "external": [] }, "place-providers": { @@ -1247,6 +1248,7 @@ "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/sources/adsbFields.js", "src/i18n/marker.js", "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", @@ -2203,6 +2205,7 @@ "./standalone/catalog" ], "modules": [ + "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/ui/placeNames.js", @@ -3179,6 +3182,7 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", "src/cyberSonar.js", @@ -4721,6 +4725,7 @@ "tools-services": { "exports": ["./tools/services"], "modules": [ + "src/sources/adsbFields.js", "src/tools/services.js", "src/layers/bikeshare/registry.js", "src/layers/bikeshare/source.js", diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 40dd2bbc0..61d07387f 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -1,3 +1,5 @@ +import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js'; + const KNOT_TO_MPS = 0.514444; const FOOT_TO_M = 0.3048; const FPM_TO_MPS = 0.00508; @@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro); const geometricFeet = finiteNumber(aircraft?.alt_geom); const groundSpeedKnots = finiteNumber(aircraft?.gs); - const verticalRateFpm = - finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate); + const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); const track = finiteNumber(aircraft?.track); return [ diff --git a/src/sources/adsbFields.js b/src/sources/adsbFields.js new file mode 100644 index 000000000..4d4822e2a --- /dev/null +++ b/src/sources/adsbFields.js @@ -0,0 +1,16 @@ +/** A readsb rate field: a finite JSON number, else null. */ +function rateField(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * Vertical rate in ft/min from a readsb-style aircraft row (readsb, tar1090, + * adsb.lol): the barometric `baro_rate`, else the GNSS-sourced `geom_rate` + * readsb sends instead when it has no barometric rate. A barometric 0 is kept. + * Only a finite number counts; null when neither field has one. + * @param {object|null|undefined} row + * @returns {number|null} + */ +export function readAdsbVerticalRateFpm(row) { + return rateField(row?.baro_rate) ?? rateField(row?.geom_rate); +} diff --git a/src/sources/adsbFields.test.mjs b/src/sources/adsbFields.test.mjs new file mode 100644 index 000000000..68fc52f79 --- /dev/null +++ b/src/sources/adsbFields.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readAdsbVerticalRateFpm } from './adsbFields.js'; + +test('the barometric rate wins, a barometric 0 included', () => { + assert.equal( + readAdsbVerticalRateFpm({ baro_rate: -640, geom_rate: -832 }), + -640, + ); + assert.equal(readAdsbVerticalRateFpm({ baro_rate: 0, geom_rate: 600 }), 0); +}); + +test('a GNSS rate stands in when no barometric rate is usable', () => { + for (const baro_rate of [ + undefined, + null, + Number.NaN, + Infinity, + '300', + false, + ]) { + assert.equal(readAdsbVerticalRateFpm({ baro_rate, geom_rate: -832 }), -832); + } +}); + +test('no usable rate reads as null', () => { + assert.equal(readAdsbVerticalRateFpm({}), null); + assert.equal(readAdsbVerticalRateFpm(null), null); + assert.equal( + readAdsbVerticalRateFpm({ baro_rate: '300', geom_rate: true }), + null, + ); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 814da60b2..afa952503 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -30,6 +30,8 @@ * This module is portable: no Cesium, DOM, network or storage access. */ +import { readAdsbVerticalRateFpm } from './adsbFields.js'; + /** A marker is dropped once its newest position is this old. */ export const LOCAL_ADSB_POSITION_STALE_MS = 60_000; /** An aircraft is forgotten once no message has arrived for this long. */ @@ -247,7 +249,7 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { altitudeFt: altitude, groundSpeedKt: finiteOrNull(entry.gs), trackDeg: normalizeTrack(entry.track), - verticalRateFpm: finiteOrNull(entry.baro_rate), + verticalRateFpm: readAdsbVerticalRateFpm(entry), lastPositionAt: hasPosition ? receivedAt - Math.max(0, seenPosS) * 1000 : null, diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index 12070d47b..aa14133b6 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -169,6 +169,20 @@ test('a feed document names its band on every record', () => { assert.ok(uat.every((record) => record.source === 'feed')); }); +test('a decoder feed reads a GNSS-sourced vertical rate when no baro rate is sent', () => { + // dump1090-fa and readsb file a velocity message's rate under geom_rate + // instead of baro_rate when its source bit says GNSS. + const rate = (fields) => + normalizeDump1090Aircraft( + { aircraft: [{ hex: 'abc123', seen: 1, ...fields }] }, + 10_000, + )[0].verticalRateFpm; + assert.equal(rate({ geom_rate: -832 }), -832); + assert.equal(rate({ baro_rate: -768, geom_rate: -832 }), -768); + assert.equal(rate({ baro_rate: 0, geom_rate: -832 }), 0); + assert.equal(rate({}), null); +}); + function heard(overrides = {}) { return { icao: 'abc123', diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 9da4f7a5f..19472a92a 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -6,6 +6,7 @@ import { epoch, finite, } from './contract.js'; +import { readAdsbVerticalRateFpm } from '../adsbFields.js'; const openSkyId = (row) => Array.isArray(row) ? cleanText(row[0]).toLowerCase() : ''; @@ -48,8 +49,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom); const speedKts = finite(row.gs), - // readsb sends a GNSS-sourced rate as geom_rate instead of baro_rate. - rateFtMin = finite(row.baro_rate) ?? finite(row.geom_rate); + rateFtMin = readAdsbVerticalRateFpm(row); const seen = finite(row.seen), seenPos = finite(row.seen_pos); return {