mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
68d87aa107
6 files changed
+313
-5
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user