Merge PR #801 altitude plausibility filter from Lob26

Preserve Pedro Lobato’s original commits. Move his credited release note into a changelog fragment when resolving the current-main conflict.
This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-20 16:48:28 +02:00
commit 68d87aa107
6 files changed
+313 -5

No files matched your search

@@ -0,0 +1,8 @@
- Local ADS-B no longer draws an aircraft at an altitude it cannot have
reached. A corrupt frame can still pass CRC, and one reached the globe as a
DC-9 at 108,800 ft. Each altitude report must now be reachable from the last
accepted one at a plausible vertical rate, whether it came from the browser
SDR or a decoder feed. A refused report keeps the previous altitude for the
marker, the card and the 3D model alike. Three refusals in a row start over
from the new value, and a surface report is never compared with the last
airborne altitude. The layer stats count refusals as `rejectedAltitudes` (Pedro Lobato, #801).
+16
View File
@@ -5689,6 +5689,22 @@ on purpose:
Refused fixes are counted (`positionsRejected` in the SDR state; the layer
applies the same check to merged feed records and reports the total as
`rejectedPositions` in its stats). Three refusals in a row re-anchor.
- **Altitude filter.** A corrupt frame can pass CRC, and a decoder feed can
relay one: a DC-9 was logged at 108,800 ft. The browser decoder cannot
produce that value, since it decodes only 25 ft (Q=1) altitudes and those
stop at 50,175 ft, so the check runs in the layer where both paths merge
(`gateLocalAdsbAltitude` in `src/sources/adsbRecords.js`). Each new report
must be reachable from the last accepted altitude at 1.5 × the larger
reported vertical rate + 2,000 ft/min (12,500 ft/min without a rate) over
the elapsed time + 1 s, plus 300 ft. A refused report holds the last
accepted altitude for the marker height, the card, the model ceiling and
route plausibility alike, and is counted as `rejectedAltitudes` in the
layer stats. Only a newer message is judged, so the layer's repeated
re-reads of one record neither count twice nor let a refused value
through. Three refusals in a row re-anchor, so a bad first altitude clears
itself. A surface report passes unjudged, because a feed's `ground` is 0
and not a barometric reading. There is no absolute ceiling: balloons really
do fly above 100,000 ft.
- **Trail.** A selected aircraft draws a magenta trail of the positions the
receiver heard (up to 10 minutes / 600 fixes, dropped with the aircraft),
with the tracked-flight trail look and a live head segment. No network
+16 -5
View File
@@ -10,6 +10,8 @@ import { isPointerFree } from '../../data/inputOwnership.js';
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { routePlausible } from '../../data/routePlausible.js';
import {
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbPositionIsFresh,
mergeLocalAdsbRecords,
summarizeLocalAdsb,
@@ -145,6 +147,7 @@ export function createLocalAdsbLayer({
let models = null;
let geoidReady = false;
let rejectedFixes = 0;
let rejectedAltitudes = 0;
let state = receiver.getState();
const markers = new Map();
// Per-ICAO receptions (band|source -> last message) across merges.
@@ -408,30 +411,36 @@ export function createLocalAdsbLayer({
// ── Markers ─────────────────────────────────────────────────────────────
function upsertMarker(record, at) {
const id = `${ENTITY_PREFIX}${record.icao}`;
function upsertMarker(heard, at) {
const id = `${ENTITY_PREFIX}${heard.icao}`;
let marker = markers.get(id);
if (!marker) {
marker = {
id,
entity: null,
record,
record: heard,
motion: new LocalAdsbMotion(),
altitude: createLocalAdsbAltitudeGate(),
position: null,
lat: null,
lon: null,
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
courseDeg: Number.isFinite(heard.trackDeg) ? heard.trackDeg : null,
lastRotation: 0,
klass: null,
evidence: false,
iconKind: null,
iconScale: null,
onGround: Boolean(record.onGround),
onGround: Boolean(heard.onGround),
geoidN: null,
modelOwnsVisual: false,
};
markers.set(id, marker);
}
// Gated before anything reads the record, so the marker height, the card,
// the 3D-model ceiling and route plausibility agree on one altitude.
const altitudesRejectedBefore = marker.altitude.rejected;
const record = gateLocalAdsbAltitude(heard, marker.altitude);
rejectedAltitudes += marker.altitude.rejected - altitudesRejectedBefore;
marker.record = record;
marker.onGround = Boolean(record.onGround);
const rejectedBefore = marker.motion.rejectedFixes;
@@ -810,6 +819,8 @@ export function createLocalAdsbLayer({
// plus the layer's check on every merged record.
rejectedPositions:
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
// Altitude reports refused by the vertical-rate check.
rejectedAltitudes,
...localAdsbStatus({
receiver: current,
feedState: feeds?.getState?.() || null,
+49
View File
@@ -399,6 +399,55 @@ test('an implausible jump in a feed record is ignored by the display', async (t)
assert.equal(layer.getStats().rejectedPositions, 1);
});
test('a corrupt altitude reaches neither the marker height nor the card', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
const { layer, sources } = await enabledLayer(receiver, clock);
t.after(() => layer.destroy());
const entity = () => sources[0].entities.getById('local-adsb:abc123');
const heightM = () =>
Cesium.Cartographic.fromCartesian(entity().position.getValue()).height;
const altitudeLine = () =>
entity().gevLabelModel.details.find((line) => line.startsWith('ALT '));
receiver.set({ aircraft: [record()] });
await layer.update();
const cruiseM = heightM();
assert.match(altitudeLine(), /^ALT 10,000 FT/);
clock.now = 101_000;
receiver.set({
aircraft: [
record({
altitudeFt: 108_800,
lastPositionAt: 101_000,
lastMessageAt: 101_000,
}),
],
});
await layer.update();
await layer.update();
assert.ok(
Math.abs(heightM() - cruiseM) < 1,
`held at cruise (${heightM()} m vs ${cruiseM} m)`,
);
assert.match(altitudeLine(), /^ALT 10,000 FT/);
assert.equal(layer.getStats().rejectedAltitudes, 1, 'counted once');
clock.now = 102_000;
receiver.set({
aircraft: [
record({
altitudeFt: 10_100,
lastPositionAt: 102_000,
lastMessageAt: 102_000,
}),
],
});
await layer.update();
assert.match(altitudeLine(), /^ALT 10,100 FT/);
assert.equal(layer.getStats().rejectedAltitudes, 1);
});
test('selecting an aircraft draws a magenta trail of the fixes the receiver heard', async (t) => {
const receiver = fakeReceiver({ mode: 'adsb', connected: true });
const clock = { now: 100_000 };
+119
View File
@@ -54,6 +54,16 @@ export const LOCAL_ADSB_POSITION_MARGIN_M = 500;
* reference: the previously accepted fix was the outlier, not the stream.
*/
export const LOCAL_ADSB_REANCHOR_AFTER = 3;
/**
* Altitude change always allowed between two reports: Gillham altitudes step
* in 100 ft and barometric readings jitter.
*/
export const LOCAL_ADSB_ALTITUDE_MARGIN_FT = 300;
/** Vertical-rate limit (ft/min) for an aircraft that has not reported one. */
export const LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM = 12_500;
/** Reported vertical rate is multiplied by this, plus the margin below. */
export const LOCAL_ADSB_VERTICAL_RATE_FACTOR = 1.5;
export const LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM = 2_000;
const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']);
const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8;
@@ -164,6 +174,115 @@ export function localAdsbFixIsPlausible(
);
}
/**
* The fastest an aircraft is assumed to climb or descend, in ft/min: 1.5 × the
* larger reported vertical rate + 2,000 ft/min, or 12,500 ft/min when neither
* report carries one.
* @param {...(number|null|undefined)} rates Reported vertical rates (ft/min).
* @returns {number}
*/
export function localAdsbVerticalRateLimitFpm(...rates) {
const known = rates.filter(Number.isFinite).map(Math.abs);
if (!known.length) return LOCAL_ADSB_UNKNOWN_VERTICAL_RATE_FPM;
return (
Math.max(...known) * LOCAL_ADSB_VERTICAL_RATE_FACTOR +
LOCAL_ADSB_VERTICAL_RATE_MARGIN_FPM
);
}
/**
* The altitude counterpart of {@link localAdsbFixIsPlausible}: whether `next`
* is reachable from the previous accepted altitude at a plausible vertical
* rate. A corrupt frame can still pass CRC, and a decoder feed can relay one,
* so a single report of a DC-9 at 108,800 ft has to be refused here. The
* allowance is a fixed margin plus the climb covered in the elapsed time + 1 s
* at {@link localAdsbVerticalRateLimitFpm}. Without a reference every altitude
* is plausible. There is no reference age limit, unlike positions: the
* allowance grows with the gap anyway, 125,000 ft over 10 minutes at the
* unknown rate.
* @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}|null}
* previous Last accepted altitude.
* @param {{altitudeFt:number, at:number, verticalRateFpm?:number|null}} next
* Candidate altitude.
* @returns {boolean}
*/
export function localAdsbAltitudeIsPlausible(previous, next) {
if (
!Number.isFinite(previous?.altitudeFt) ||
!Number.isFinite(previous?.at) ||
!Number.isFinite(next?.altitudeFt) ||
!Number.isFinite(next?.at)
)
return true;
const elapsedMs = Math.max(0, next.at - previous.at);
const limitFpm = localAdsbVerticalRateLimitFpm(
previous.verticalRateFpm,
next.verticalRateFpm,
);
const allowedFt =
LOCAL_ADSB_ALTITUDE_MARGIN_FT + ((elapsedMs + 1_000) / 60_000) * limitFpm;
return Math.abs(next.altitudeFt - previous.altitudeFt) <= allowedFt;
}
/**
* State for {@link gateLocalAdsbAltitude}, one per aircraft, owned by the
* caller and dropped with the aircraft.
* @returns {{accepted:object|null, judgedAt:number|null, rejectStreak:number,
* rejected:number}}
*/
export function createLocalAdsbAltitudeGate() {
return { accepted: null, judgedAt: null, rejectStreak: 0, rejected: 0 };
}
/**
* Hold a record's barometric altitude at its last plausible value.
*
* Returns `record` itself, or a copy whose `altitudeFt` is the last accepted
* altitude, so the marker height, the card, the 3D-model ceiling and route
* plausibility all read the same value. A refused report is counted in
* `gate.rejected`. From the third consecutive refusal on, the candidate
* becomes the new reference, as positions do: a bad first altitude cannot
* freeze the aircraft at it.
*
* Only a report with a newer `lastMessageAt` is judged. The layer re-reads the
* same record several times a second, and a re-read must neither count toward
* a re-anchor nor slip a refused value through. A surface report passes
* unjudged: a feed reports `alt_baro: "ground"` as 0, which is not a
* barometric reading, so a landing at a high field is not a 5,000 ft drop.
* @param {object} record Local ADS-B record.
* @param {ReturnType<typeof createLocalAdsbAltitudeGate>} gate Mutated.
* @returns {object}
*/
export function gateLocalAdsbAltitude(record, gate) {
if (record?.onGround) return record;
const altitudeFt = finiteOrNull(record?.altitudeFt);
const at = finiteOrNull(record?.lastMessageAt);
if (altitudeFt === null || at === null) return record;
if (gate.judgedAt === null || at > gate.judgedAt) {
gate.judgedAt = at;
const candidate = {
altitudeFt,
at,
verticalRateFpm: finiteOrNull(record.verticalRateFpm),
};
if (localAdsbAltitudeIsPlausible(gate.accepted, candidate)) {
gate.accepted = candidate;
gate.rejectStreak = 0;
} else {
gate.rejected += 1;
gate.rejectStreak += 1;
if (gate.rejectStreak >= LOCAL_ADSB_REANCHOR_AFTER) {
gate.accepted = candidate;
gate.rejectStreak = 0;
}
}
}
const held = gate.accepted?.altitudeFt;
return Number.isFinite(held) && held !== altitudeFt
? { ...record, altitudeFt: held }
: record;
}
function normalizePosition(lat, lon) {
const latitude = finiteOrNull(lat);
const longitude = finiteOrNull(lon);
+105
View File
@@ -6,6 +6,9 @@ import {
LOCAL_ADSB_MESSAGE_STALE_MS,
LOCAL_ADSB_POSITION_STALE_MS,
LOCAL_ADSB_REFERENCE_MAX_AGE_MS,
createLocalAdsbAltitudeGate,
gateLocalAdsbAltitude,
localAdsbAltitudeIsPlausible,
localAdsbFixIsPlausible,
localAdsbPositionIsFresh,
localAdsbRecordIsLive,
@@ -324,3 +327,105 @@ test('the position sanity check follows dump1090: speed × 1.5 + margin over ela
'the fixed 500 m margin absorbs CPR and reception error',
);
});
test('the altitude check allows the vertical rate × 1.5 + margin over elapsed + 1 s', () => {
const from = { altitudeFt: 30_000, at: 0, verticalRateFpm: null };
// 9 s later, so the allowance covers 10 s: a sixth of a minute.
const at = 9_000;
const check = (altitudeFt, rates = {}) =>
localAdsbAltitudeIsPlausible(
{ ...from, verticalRateFpm: rates.previous ?? null },
{ altitudeFt, at, verticalRateFpm: rates.next ?? null },
);
assert.equal(
localAdsbAltitudeIsPlausible(null, { altitudeFt: 108_800, at }),
true,
'no reference',
);
// Unknown rate: 12,500 ft/min → 2,083 ft + 300 ft.
assert.equal(check(32_380), true);
assert.equal(check(32_390), false);
assert.equal(check(27_620), true, 'the limit is symmetric');
assert.equal(check(27_610), false);
// Level flight reported: 2,000 ft/min → 333 ft + 300 ft.
assert.equal(check(30_630, { previous: 0 }), true);
assert.equal(check(30_640, { previous: 0 }), false);
// The larger of the two reported rates counts: 6,000 × 1.5 + 2,000
// = 11,000 ft/min → 1,833 ft + 300 ft.
assert.equal(check(27_870, { previous: 0, next: -6_000 }), true);
assert.equal(check(27_860, { previous: 0, next: -6_000 }), false);
assert.equal(
localAdsbAltitudeIsPlausible(
{ altitudeFt: 30_000, at: 9_000 },
{ altitudeFt: 30_400, at: 0 },
),
true,
'an out-of-order report gets the zero-gap allowance, not a negative one',
);
});
/** Feed one aircraft's reports, `[seconds, altitudeFt, extra]`, through a gate. */
function gateReports(reports) {
const gate = createLocalAdsbAltitudeGate();
const altitudes = reports.map(
([seconds, altitudeFt, extra = {}]) =>
gateLocalAdsbAltitude(
{
icao: 'abc123',
lastMessageAt: seconds * 1_000,
altitudeFt,
...extra,
},
gate,
).altitudeFt,
);
return { altitudes, rejected: gate.rejected };
}
test('a corrupt altitude is held at the last accepted one and judged once', () => {
// The logged case: a DC-9 cruising at 35,000 ft reported once at 108,800 ft.
// The layer re-reads the same record several times a second; a re-read of
// the refused report is neither let through nor counted again.
assert.deepEqual(
gateReports([
[0, 35_000],
[0.5, 35_000],
[1, 108_800],
[1, 108_800],
[1, 108_800],
[1.5, 35_025],
]),
{
altitudes: [35_000, 35_000, 35_000, 35_000, 35_000, 35_025],
rejected: 1,
},
);
});
test('a bad first altitude clears itself on the third refusal', () => {
assert.deepEqual(
gateReports([
[0, 108_800],
[0.5, 35_000],
[1, 35_000],
[1.5, 35_000],
[2, 35_000],
]),
{ altitudes: [108_800, 108_800, 108_800, 35_000, 35_000], rejected: 3 },
);
});
test('a surface report is not compared with the last airborne altitude', () => {
// Denver: the last airborne report is 5,600 ft barometric, and a feed
// reports `alt_baro: "ground"` as 0. Neither the landing nor the next
// takeoff is a jump.
assert.deepEqual(
gateReports([
[0, 5_600],
[20, 0, { onGround: true }],
[300, 5_700],
[301, 5_720],
]),
{ altitudes: [5_600, 0, 5_700, 5_720], rejected: 0 },
);
});