From 069bf9b12b0ba67ff092a7cb5d0640c96defcbca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 00:18:43 +0000 Subject: [PATCH] Overhaul ADS-B pipeline: dead reckoning, signal-gap handling, stale rendering Server side (poller): - Dead-reckon stale BEAST positions forward along the last known velocity (bounded by ADSB_DEAD_RECKON_MAX_SECONDS, default 60s) so tracks keep moving through CPR gaps instead of freezing; altitude projects with the vertical rate. DR positions are flagged position_dr, never enter trails, DB observations, or geofence checks. - Seed the decoder's Tier-2 CPR reference from OpenSky/ultrafeeder positions so a single odd/even frame resolves position on (re-)entry into SDR range instead of waiting up to ~60s for a fresh even+odd pair. - Refresh decoder entities on every snapshot tick so DR advances during a total feed outage; bound the CPR heading-consistency guard to <30s gaps so it can't reject the first good fix after a long gap. - Fold in the raw-byte DF/length fast path from PR #115 (credit: Jules). - normalize_tar1090: handle alt_baro=="ground" (no more "ground" string in altitude, correct on_ground status), honor seen_pos for stale/ancient positions, null non-numeric track/gs/baro_rate. - Redis-hydrated aircraft now report position_stale until a real fix arrives. Frontend: - PVB keeps projecting dead-reckoned tracks between reports (previously stale tracks froze then snapped); positionDr joins the report key. - Stale/DR local aircraft render dimmed like OpenSky supplements. - Entity/Track types carry position_stale/position_dr/position_age_s. Tests: 13 new decoder tests (fast path, DR bounds, seed arbitration), 9 new tar1090 tests. Full poller suite passes (155), tsc clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01G6ZwvPTHAZ76MxF6swwryH --- .env.example | 5 + TASK_LOG.md | 22 +++ frontend/src/entityUtils.ts | 4 +- frontend/src/layers/buildEntityLayers.ts | 18 ++- frontend/src/layers/pvb.ts | 7 +- frontend/src/storeTypes.ts | 7 + poller/config.py | 8 ++ poller/db.py | 8 ++ poller/normalizers/aircraft.py | 36 ++++- poller/normalizers/beast_decoder.py | 95 +++++++++++-- poller/pollers/adsb.py | 42 +++++- poller/tests/test_adsb_normalization.py | 41 ++++++ poller/tests/test_beast_decoder.py | 166 +++++++++++++++++++++++ 13 files changed, 430 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index bfaf2b2..0f5aafc 100644 --- a/.env.example +++ b/.env.example @@ -103,6 +103,11 @@ ADSB_BEAST_RECONNECT_MAX_SECONDS=30 # Seconds without a BEAST frame before HTTP fallback activates (Mode C) ADSB_BEAST_STALE_THRESHOLD_SECONDS=30 ADSB_PUBLISH_ONLY_CHANGES=true +# Seconds since the last resolved CPR fix before a BEAST track is flagged stale +ADSB_POSITION_STALE_SECONDS=10 +# Max seconds a stale position is dead-reckoned forward along the last known +# velocity (keeps icons moving through short signal gaps; 0 disables) +ADSB_DEAD_RECKON_MAX_SECONDS=60 # Mode D — OpenSky supplement alongside local sources # Enabled by default. Local sources (beast/ultrafeeder) remain primary; OpenSky diff --git a/TASK_LOG.md b/TASK_LOG.md index 5e95a65..fd899eb 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -5,6 +5,28 @@ Format: `## YYYY-MM-DD — ` with bullet points for details. --- +## 2026-07-06 — ADS-B pipeline overhaul: dead reckoning, signal-gap handling, stale-track rendering + +- **Server-side dead reckoning** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [config.py](poller/config.py)): + - `_to_entity` now projects a stale position forward along the last known velocity (finally using the previously-unused `project_position` in `beast_math.py`) instead of freezing it. Velocity messages (DF17 TC19) usually keep decoding through CPR position gaps, so the track keeps moving where the aircraft actually is. Altitude is projected with the vertical rate. + - New `_AircraftState.last_velocity_ts` gates DR on velocity freshness; DR requires airborne status and is bounded by new `ADSB_DEAD_RECKON_MAX_SECONDS` (default 60). The stale threshold is now configurable via `ADSB_POSITION_STALE_SECONDS` (default 10). + - Entities carry `position_dr` and `position_age_s` alongside `position_stale`. DR positions never enter the trail ring buffer, are excluded from DB observations (`record_observation=False` in the frame worker + a hard guard in [db.py](poller/db.py)), and never trigger geofence entry/exit events. + - Redis-hydrated aircraft (position but no fix timestamp) now correctly report `position_stale=true` until a real fix arrives, matching the hydration docstring's intent. +- **Cross-source CPR seeding** ([beast_decoder.py](poller/normalizers/beast_decoder.py), [adsb.py](poller/pollers/adsb.py)): + - New `BeastAircraftDecoder.seed_reference()` — OpenSky and ultrafeeder positions seed the decoder's Tier-2 local CPR reference, so a single odd/even frame resolves a position the moment an aircraft (re-)enters SDR range instead of waiting up to ~60s for a fresh even+odd pair (the main cause of cold-lock gaps). Local fixes stay authoritative: seeds only apply when the decoder's own fix is missing or stale, and never touch trails or snapshots. +- **Signal-gap fixes** ([adsb.py](poller/pollers/adsb.py), [beast_decoder.py](poller/normalizers/beast_decoder.py)): + - The registry tick loop now refreshes decoder entities on every snapshot tick (not only when new BEAST frames arrived), so DR positions advance during a total feed outage instead of the whole snapshot freezing. + - The CPR heading-consistency guard is now bounded to gaps <30s — across a longer gap the aircraft may legitimately have turned, and rejecting the first good fix after a gap extended outages indefinitely. +- **tar1090 normalizer fixes** ([aircraft.py](poller/normalizers/aircraft.py)): + - `alt_baro: "ground"` (readsb's actual ground signal — there is no `on_ground` key in aircraft.json) now sets `on_ground` status and no longer leaks the string `"ground"` into the numeric `altitude` field. + - `seen_pos` is honored: positions older than 60s are dropped, older than 10s are flagged `position_stale`; non-numeric track/gs/baro_rate values are nulled. +- **Frontend stale/DR rendering** ([pvb.ts](frontend/src/layers/pvb.ts), [entityUtils.ts](frontend/src/entityUtils.ts), [buildEntityLayers.ts](frontend/src/layers/buildEntityLayers.ts), [storeTypes.ts](frontend/src/storeTypes.ts)): + - PVB keeps projecting server-dead-reckoned tracks between reports (previously stale tracks froze, then snapped to the next fix). `positionDr` is part of the report key so DR↔fix transitions re-blend smoothly. + - Stale/dead-reckoned local aircraft render dimmed (alpha 140) like OpenSky supplements, so estimated positions are visually distinct from live fixes. +- **Hot-path optimization** ([beast_decoder.py](poller/normalizers/beast_decoder.py)): DF and frame length are checked on raw bytes (`byte >> 3`) before any hex-string allocation or pyModeS call — same technique as PR #115 (credit: Jules), folded in so the overhaul builds on it. +- **Tests**: 13 new decoder tests (fast-path rejection, dead reckoning bounds, seed arbitration) in [test_beast_decoder.py](poller/tests/test_beast_decoder.py); 9 new tar1090 tests in [test_adsb_normalization.py](poller/tests/test_adsb_normalization.py). Full poller suite: 155 passed. +- **Motivation**: User asked for an ADS-B pipeline overhaul targeting dead reckoning, odd signal gaps, and frontend rendering across both the local SDR BEAST feed and the OpenSky supplement. + ## 2026-07-05 — Gated mesh nodes to region bbox and made entity filters persistent - **Mesh node bbox gating** ([meshcore.py](poller/pollers/meshcore.py), [config.py](poller/config.py)): diff --git a/frontend/src/entityUtils.ts b/frontend/src/entityUtils.ts index ae79189..e91e4da 100644 --- a/frontend/src/entityUtils.ts +++ b/frontend/src/entityUtils.ts @@ -28,7 +28,8 @@ export function entityToTrack(entity: Entity, existing?: Track): Track | null { const altMeters = isAir ? (entity.altitude ?? 0) * ALT_FT_TO_M : 0 const speedMs = (entity.speed ?? 0) * SPD_KT_TO_MS const courseTrue = entity.heading ?? 0 - const positionStale = Boolean((entity as Entity & { position_stale?: boolean }).position_stale) + const positionStale = Boolean(entity.position_stale) + const positionDr = Boolean(entity.position_dr) // ── Build raw trail ────────────────────────────────────────────────────── // Trail sources (merged in order, oldest → newest): @@ -102,6 +103,7 @@ export function entityToTrack(entity: Entity, existing?: Track): Track | null { source: entity.source, lastSeen: entity.last_seen, positionStale, + positionDr, lat: entity.lat, lon: entity.lon, altMeters, diff --git a/frontend/src/layers/buildEntityLayers.ts b/frontend/src/layers/buildEntityLayers.ts index bb5fbaf..8e7b49b 100644 --- a/frontend/src/layers/buildEntityLayers.ts +++ b/frontend/src/layers/buildEntityLayers.ts @@ -160,11 +160,17 @@ export function buildEntityLayers( if (t.type === 'hazard') return FIRE_ICON_COLOR if (t.type === 'rail') return tagColorMap?.[t.uid] ?? TRAIN_ICON_COLOR if (t.type === 'sensor') return RF_SENSOR_COLOR - // OpenSky-supplemented aircraft are lower-fidelity (coarser, delayed - // positions filling a local BEAST signal gap) — render them dimmed so it - // is visually clear the track is not from the local feed. - if (t.type === 'air' && (t.source ?? '').toLowerCase() === 'opensky') { - return tagColorMap?.[t.uid] ?? entityColor(t, 120) + // Lower-fidelity aircraft positions render dimmed so it is visually + // clear the track is not a live local fix: OpenSky supplements (coarse, + // delayed) and stale/dead-reckoned local tracks (estimated during a + // signal gap). + if (t.type === 'air') { + if ((t.source ?? '').toLowerCase() === 'opensky') { + return tagColorMap?.[t.uid] ?? entityColor(t, 120) + } + if (t.positionStale || t.positionDr) { + return tagColorMap?.[t.uid] ?? entityColor(t, 140) + } } return tagColorMap?.[t.uid] ?? entityColor(t) }, @@ -175,7 +181,7 @@ export function buildEntityLayers( updateTriggers: { getIcon: zoom, getAngle: trackArr.map(t => t.courseTrue), - getColor: trackArr.map(t => tagColorMap?.[t.uid]?.join(',') ?? `${t.altMeters + t.speedMs}${t.stationType ?? ''}${t.source ?? ''}`), + getColor: trackArr.map(t => tagColorMap?.[t.uid]?.join(',') ?? `${t.altMeters + t.speedMs}${t.stationType ?? ''}${t.source ?? ''}${t.positionStale ? 's' : ''}${t.positionDr ? 'd' : ''}`), getSize: [selectedUid, zoom], }, }) diff --git a/frontend/src/layers/pvb.ts b/frontend/src/layers/pvb.ts index 6695260..5c8e205 100644 --- a/frontend/src/layers/pvb.ts +++ b/frontend/src/layers/pvb.ts @@ -36,6 +36,7 @@ function reportKey(track: Track, lastTs: string): string { track.lon.toFixed(5), track.lat.toFixed(5), track.positionStale ? '1' : '0', + track.positionDr ? '1' : '0', ].join('|') } @@ -79,7 +80,11 @@ export function applyPVB( ): [number, number] { const lastTs = track.trail[track.trail.length - 1]?.[4] ?? '' const lastReportKey = reportKey(track, lastTs) - const projectedSpeed = track.positionStale ? 0 : track.speedMs + // Stale positions freeze — except server-side dead-reckoned ones, whose + // lat/lon are already advancing along the last known velocity. Keep + // projecting those between reports so motion stays continuous instead of + // stepping once per snapshot. + const projectedSpeed = track.positionStale && !track.positionDr ? 0 : track.speedMs const state = pvb[track.uid] if (!state) { diff --git a/frontend/src/storeTypes.ts b/frontend/src/storeTypes.ts index d672c2f..ddff6ad 100644 --- a/frontend/src/storeTypes.ts +++ b/frontend/src/storeTypes.ts @@ -14,6 +14,12 @@ export interface Entity { signal_quality?: number status?: string last_seen?: string + // Position freshness (BEAST decoder / normalizers): + // stale = last real fix is older than the stale threshold; + // dr = lat/lon are dead-reckoned forward from that fix server-side. + position_stale?: boolean + position_dr?: boolean + position_age_s?: number | null identity?: Record tags?: string[] // Server-side position ring buffer emitted by the BEAST decoder. @@ -57,6 +63,7 @@ export interface Track { source: string lastSeen?: string positionStale?: boolean + positionDr?: boolean // position is a server-side dead-reckoned estimate lat: number lon: number altMeters: number // metres MSL (0 for vessels) diff --git a/poller/config.py b/poller/config.py index 0a8b77e..afe0c35 100644 --- a/poller/config.py +++ b/poller/config.py @@ -138,6 +138,14 @@ def regions(self) -> list[RegionConfig]: adsb_publish_only_changes: bool = True allow_private_ips: bool = False + # Seconds since the last resolved CPR fix before a BEAST track's position + # is flagged stale (freezes client-side extrapolation without dead reckoning). + adsb_position_stale_seconds: int = 10 + # Maximum age of the last position fix (seconds) that the decoder will + # dead-reckon forward using the aircraft's last known velocity. Beyond + # this the position freezes at the last real fix. 0 disables dead reckoning. + adsb_dead_reckon_max_seconds: int = 60 + # Mode D — OpenSky supplement alongside local sources (beast or ultrafeeder). # When enabled, OpenSky polls on its own interval and fills in aircraft not # seen locally within adsb_opensky_stale_threshold seconds. On by default — diff --git a/poller/db.py b/poller/db.py index 7ee24f2..3aa6743 100644 --- a/poller/db.py +++ b/poller/db.py @@ -135,6 +135,14 @@ async def write_entity_observation(entity: dict, record_observation: bool = True entity_id_key = entity["entity_id"] now_ts = time.time() + # Dead-reckoned positions are estimates projected from the last real fix — + # never let them trigger geofence entry/exit events or land in the + # observation history. The entity row update (identity/last_seen) still runs. + if entity.get("position_dr"): + lat = None + lon = None + record_observation = False + # Determine if we should update the entity row should_write_entity = False last_write = _last_entity_write_ts.get(entity_id_key, 0.0) diff --git a/poller/normalizers/aircraft.py b/poller/normalizers/aircraft.py index a5c4096..8c73c18 100644 --- a/poller/normalizers/aircraft.py +++ b/poller/normalizers/aircraft.py @@ -51,10 +51,34 @@ def normalize_opensky(state: list) -> Optional[dict]: } +TAR1090_POSITION_STALE_SECONDS = 10 +TAR1090_POSITION_MAX_AGE_SECONDS = 60 + + +def _numeric_or_none(value) -> Optional[float]: + return value if isinstance(value, (int, float)) and not isinstance(value, bool) else None + + def normalize_tar1090(ac: dict) -> Optional[dict]: icao = ac.get("hex", "").lower() if not icao or ac.get("lat") is None or ac.get("lon") is None: return None + + # tar1090/readsb signals ground via alt_baro == "ground" (there is no + # on_ground key in the aircraft.json schema); accept both for robustness. + on_ground = ac.get("alt_baro") == "ground" or bool(ac.get("on_ground")) + altitude = _numeric_or_none(ac.get("alt_baro")) + if altitude is None: + altitude = _numeric_or_none(ac.get("alt_geom")) + + # seen_pos = seconds since the last position fix. Skip aircraft whose fix is + # ancient (readsb retains them for minutes) and flag moderately old ones so + # the frontend freezes extrapolation instead of projecting stale motion. + seen_pos = _numeric_or_none(ac.get("seen_pos")) + if seen_pos is not None and seen_pos > TAR1090_POSITION_MAX_AGE_SECONDS: + return None + position_stale = seen_pos is not None and seen_pos > TAR1090_POSITION_STALE_SECONDS + return { "entity_id": f"aircraft:{icao}", "entity_type": "aircraft", @@ -68,11 +92,13 @@ def normalize_tar1090(ac: dict) -> Optional[dict]: }, "lat": ac.get("lat"), "lon": ac.get("lon"), - "altitude": ac.get("alt_baro") or ac.get("alt_geom"), - "heading": ac.get("track"), - "speed": ac.get("gs"), - "vertical_rate": ac.get("baro_rate"), - "status": "on_ground" if ac.get("on_ground") else "airborne", + "position_stale": position_stale, + "position_age_s": seen_pos, + "altitude": altitude, + "heading": _numeric_or_none(ac.get("track")), + "speed": _numeric_or_none(ac.get("gs")), + "vertical_rate": _numeric_or_none(ac.get("baro_rate")), + "status": "on_ground" if on_ground else "airborne", "last_seen": _now(), "tags": ["aircraft"], } diff --git a/poller/normalizers/beast_decoder.py b/poller/normalizers/beast_decoder.py index 682156e..bf2305e 100644 --- a/poller/normalizers/beast_decoder.py +++ b/poller/normalizers/beast_decoder.py @@ -40,6 +40,7 @@ class _AircraftState: even_ts: float | None = None odd_ts: float | None = None last_position_ts: float | None = None + last_velocity_ts: float | None = None last_mlat_ticks: int | None = None signal_quality: int | None = None msg_count: int = 0 @@ -100,18 +101,18 @@ def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: self._warned_missing_dep = True return None - hex_msg = message_bytes.hex().upper() - if len(hex_msg) not in (14, 28): - return None - - try: - df = pms.df(hex_msg) - except Exception: + # Fast path: derive Downlink Format and length from the raw bytes so the + # flood of irrelevant Mode S traffic is discarded before any hex-string + # allocation or pyModeS call (~2x hot-path speedup, credit PR #115). + if len(message_bytes) not in (7, 14): return None + df = message_bytes[0] >> 3 if df not in (4, 5, 11, 17, 18, 20, 21): return None + hex_msg = message_bytes.hex().upper() + try: icao = (pms.icao(hex_msg) or "").lower() except Exception: @@ -168,6 +169,8 @@ def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: ac.heading = float(trk) if vr is not None: ac.vertical_rate = float(vr) + if spd is not None and trk is not None: + ac.last_velocity_ts = now if df in (4, 20): alt = self._decode_altitude_reply(hex_msg) @@ -188,6 +191,40 @@ def ingest(self, message_bytes: bytes, *, mlat_ticks: int | None = None, signal: return self._to_entity(ac) + def seed_reference(self, icao: str, lat: float, lon: float, ts: float | None = None) -> bool: + """Seed a position reference from another source (OpenSky / ultrafeeder). + + Gives Tier-2 local CPR decode an immediate reference so a single odd or + even frame resolves a position without waiting up to ~60 s for a fresh + even+odd pair — the main cause of cold-lock gaps when an aircraft + re-enters SDR range. Never touches the trail or last_seen_ts, so seeded + aircraft do not appear in snapshots until real frames arrive. + + Returns True if the reference was applied. + """ + icao = (icao or "").lower() + if not icao or lat is None or lon is None: + return False + + now = time.time() + seed_ts = float(ts) if ts is not None else now + + ac = self._aircraft.get(icao) + if ac is None: + ac = _AircraftState(icao=icao) + self._aircraft[icao] = ac + elif ac.last_position_ts is not None: + # Keep the local fix unless it is older than the incoming reference + # and already past the stale threshold — local CPR beats a supplement. + stale_after = float(settings.adsb_position_stale_seconds) + if ac.last_position_ts >= seed_ts or (now - ac.last_position_ts) <= stale_after: + return False + + ac.lat = float(lat) + ac.lon = float(lon) + ac.last_position_ts = seed_ts + return True + def snapshot_entities(self, stale_seconds: int = 60) -> list[dict]: now = time.time() result: list[dict] = [] @@ -253,9 +290,13 @@ def _update_cpr(self, ac: _AircraftState, hex_msg: str, now: float): return # Heading-consistency guard: reject bad Tier-2/3 CPR decodes where - # the candidate bearing differs >90° from the known track. + # the candidate bearing differs >90° from the known track. Only + # applied while the elapsed gap is short — across a long gap the + # aircraft may legitimately have turned, and rejecting the first + # good fix after a gap would extend the outage indefinitely. if ( distance_km > 0.1 + and elapsed_seconds < 30.0 and ac.heading is not None and ac.speed is not None and ac.speed > 50 # knots — ignore heading at low taxi speed @@ -287,10 +328,38 @@ def _to_entity(self, ac: _AircraftState, now: float | None = None) -> dict | Non display_lat = ac.lat display_lon = ac.lon - position_stale = ( - ac.last_position_ts is not None - and (now_ts - ac.last_position_ts) > 10.0 + display_alt = ac.altitude + + stale_after = float(settings.adsb_position_stale_seconds) + pos_age = ( + max(0.0, now_ts - ac.last_position_ts) + if ac.last_position_ts is not None else None ) + # No fix timestamp (Redis-hydrated state) counts as stale until a real + # fix or cross-source seed arrives. + position_stale = pos_age is None or pos_age > stale_after + + # Dead reckoning: velocity messages (TC19) usually keep decoding through + # CPR position gaps, so project the display position forward along the + # last known track instead of freezing it. Bounded by the configured + # window, never written to the trail, and flagged so downstream + # consumers (DB observations, geofences, UI) can treat it as estimated. + position_dr = False + dr_max = float(settings.adsb_dead_reckon_max_seconds) + if ( + position_stale + and pos_age is not None + and pos_age <= dr_max + and ac.last_velocity_ts is not None + and (now_ts - ac.last_velocity_ts) <= dr_max + and not ac.on_ground + ): + projected = project_position(ac.lat, ac.lon, ac.heading, ac.speed, pos_age) + if projected is not None: + display_lat, display_lon = projected + position_dr = True + if display_alt is not None and ac.vertical_rate is not None: + display_alt = max(0.0, display_alt + ac.vertical_rate * pos_age / 60.0) comm_b = self._build_comm_b_snapshot(ac, now_ts) @@ -313,7 +382,9 @@ def _to_entity(self, ac: _AircraftState, now: float | None = None) -> dict | Non "lat": display_lat, "lon": display_lon, "position_stale": position_stale, - "altitude": ac.altitude, + "position_dr": position_dr, + "position_age_s": round(pos_age, 1) if pos_age is not None else None, + "altitude": display_alt, "heading": ac.heading, "speed": ac.speed, "vertical_rate": ac.vertical_rate, diff --git a/poller/pollers/adsb.py b/poller/pollers/adsb.py index 1528c97..162f519 100644 --- a/poller/pollers/adsb.py +++ b/poller/pollers/adsb.py @@ -232,7 +232,12 @@ async def _process_beast_frames(self): now = time.time() if now - _last_published.get(icao, 0.0) >= _BEAST_PUBLISH_MIN_INTERVAL: _last_published[icao] = now - await publish_entity(entity) + # Dead-reckoned positions are estimates — keep them out + # of the observation history (trails stay real fixes only). + await publish_entity( + entity, + record_observation=not entity.get("position_dr"), + ) except Exception as exc: logger.warning("[adsb] frame processing error: %s", exc) @@ -264,11 +269,14 @@ async def _registry_tick_loop(self): del self._last_seen_by_source[icao] try: - # Pull fresh BEAST positions into the unified registry only when - # new frames have arrived since the last tick — skips the O(aircraft) + # Pull fresh BEAST positions into the unified registry when new + # frames have arrived since the last tick — skips the O(aircraft) # entity reconstruction when the decoder state is unchanged. + # Also refresh on every snapshot tick regardless: dead-reckoned + # display positions advance with wall-clock time, so a total + # feed gap must not freeze the published snapshot. current_frames = self._transport.frames_seen - if current_frames != _last_frames_seen: + if current_frames != _last_frames_seen or self._tick_count % _SNAPSHOT_INTERVAL == 0: _last_frames_seen = current_frames for ac in self._beast_decoder.snapshot_entities(): icao = (ac.get("identity") or {}).get("icao24", "").lower() @@ -393,6 +401,30 @@ async def close(self): # last batch write so they survive the restart. self._adsbdb.flush() + def _seed_decoder_reference(self, icao: str, entity: dict) -> None: + """Feed another source's position into the BEAST decoder as a CPR reference. + + A seeded reference lets Tier-2 local CPR decode resolve a position from + the very first odd/even frame when an aircraft (re-)enters SDR range, + instead of waiting up to ~60 s for a fresh even+odd pair. The decoder + keeps local fixes authoritative — seeds only apply when its own fix is + missing or stale. + """ + if not settings.adsb_enable_beast or not icao: + return + lat, lon = entity.get("lat"), entity.get("lon") + if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)): + return + ts: float | None = None + last_seen = entity.get("last_seen") + if isinstance(last_seen, str): + try: + from datetime import datetime + ts = datetime.fromisoformat(last_seen).timestamp() + except ValueError: + ts = None + self._beast_decoder.seed_reference(icao, float(lat), float(lon), ts=ts) + # ── Best Mode Arbitration ────────────────────────────────────────────── def _record_source_seen(self, icao: str, source: str) -> None: @@ -503,6 +535,7 @@ async def _poll_opensky_supplement(self) -> None: if icao: icao = icao.lower() self._record_source_seen(icao, "opensky") + self._seed_decoder_reference(icao, entity) if self._is_local_recent(icao): skipped_local += 1 continue @@ -532,6 +565,7 @@ async def _poll_ultrafeeder(self, url: str): if entity: icao = (entity.get("identity") or {}).get("icao24", "").lower() self._record_source_seen(icao, "ultrafeeder") + self._seed_decoder_reference(icao, entity) # Only update the shared entity registry when ultrafeeder is the best # available source for this ICAO. If BEAST has been seen within the # freshness window, keep the BEAST-decoded entity in the registry so diff --git a/poller/tests/test_adsb_normalization.py b/poller/tests/test_adsb_normalization.py index 664711c..511171b 100644 --- a/poller/tests/test_adsb_normalization.py +++ b/poller/tests/test_adsb_normalization.py @@ -184,3 +184,44 @@ def test_entity_id_format(self): assert result is not None assert result["entity_id"] == "aircraft:a1b2c3" + # ── ground / stale-position handling (readsb aircraft.json semantics) ──── + + def test_alt_baro_ground_string_sets_on_ground(self): + result = normalize_tar1090(self._ac({"alt_baro": "ground", "on_ground": None})) + assert result is not None + assert result["status"] == "on_ground" + + def test_alt_baro_ground_string_does_not_leak_into_altitude(self): + result = normalize_tar1090(self._ac({"alt_baro": "ground", "alt_geom": None})) + assert result is not None + assert result["altitude"] is None + + def test_alt_baro_ground_falls_back_to_alt_geom(self): + result = normalize_tar1090(self._ac({"alt_baro": "ground", "alt_geom": 150})) + assert result is not None + assert result["altitude"] == 150 + + def test_ancient_seen_pos_skipped(self): + assert normalize_tar1090(self._ac({"seen_pos": 120.0})) is None + + def test_old_seen_pos_marked_stale(self): + result = normalize_tar1090(self._ac({"seen_pos": 30.0})) + assert result is not None + assert result["position_stale"] is True + assert result["position_age_s"] == 30.0 + + def test_fresh_seen_pos_not_stale(self): + result = normalize_tar1090(self._ac({"seen_pos": 1.2})) + assert result is not None + assert result["position_stale"] is False + + def test_missing_seen_pos_not_stale(self): + result = normalize_tar1090(self._ac()) + assert result is not None + assert result["position_stale"] is False + + def test_non_numeric_track_dropped(self): + result = normalize_tar1090(self._ac({"track": "n/a"})) + assert result is not None + assert result["heading"] is None + diff --git a/poller/tests/test_beast_decoder.py b/poller/tests/test_beast_decoder.py index e0acc79..dce8a95 100644 --- a/poller/tests/test_beast_decoder.py +++ b/poller/tests/test_beast_decoder.py @@ -26,6 +26,8 @@ _mock_settings.adsb_beast_host = "localhost" _mock_settings.adsb_beast_port = 30005 _mock_settings.adsb_publish_only_changes = True +_mock_settings.adsb_position_stale_seconds = 10 +_mock_settings.adsb_dead_reckon_max_seconds = 60 for _mod in [ "config", @@ -410,6 +412,170 @@ def test_snapshot_returns_empty_without_aircraft(self): self.assertEqual(decoder.snapshot_entities(), []) +# ============================================================================ +# 5b. Ingest fast path — raw-byte DF/length rejection (no pyModeS calls) +# ============================================================================ +class TestIngestFastPath(unittest.TestCase): + """Frames rejected by length/DF must never reach pyModeS (hot-path guard).""" + + def _decoder_with_mock_pms(self): + mock_pms = MagicMock() + patcher = patch("normalizers.beast_decoder.pms", mock_pms) + patcher.start() + self.addCleanup(patcher.stop) + return BeastAircraftDecoder(), mock_pms + + def test_wrong_length_rejected_before_pymodes(self): + decoder, mock_pms = self._decoder_with_mock_pms() + self.assertIsNone(decoder.ingest(b"\x8d" * 8)) # 8 bytes: not 7 or 14 + mock_pms.icao.assert_not_called() + mock_pms.df.assert_not_called() + + def test_irrelevant_df_rejected_before_pymodes(self): + decoder, mock_pms = self._decoder_with_mock_pms() + # First byte 0x00 → DF0 (ACAS short reply), not in the accepted set. + self.assertIsNone(decoder.ingest(b"\x00" + b"\x11" * 6)) + mock_pms.icao.assert_not_called() + + def test_df_derived_from_leading_byte(self): + # 0x8D = 10001101 → DF17 (accepted); 0x20 = 00100000 → DF4 (accepted) + self.assertEqual(0x8D >> 3, 17) + self.assertEqual(0x20 >> 3, 4) + self.assertEqual(0x00 >> 3, 0) + + +# ============================================================================ +# 5c. Dead reckoning + cross-source seeding (no pyModeS required) +# ============================================================================ +class TestDeadReckoning(unittest.TestCase): + """_to_entity projects stale positions along the last known velocity.""" + + def _aircraft(self, *, pos_age_s: float, vel_age_s: float | None, now: float) -> _AircraftState: + ac = _AircraftState(icao="abc123") + ac.lat = 45.0 + ac.lon = -122.0 + ac.altitude = 10000.0 + ac.heading = 90.0 # due east + ac.speed = 360.0 # knots → 0.1 nm/s + ac.vertical_rate = 0.0 + ac.on_ground = False + ac.last_seen_ts = now + ac.last_position_ts = now - pos_age_s + ac.last_velocity_ts = None if vel_age_s is None else now - vel_age_s + return ac + + def test_fresh_position_not_dead_reckoned(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=2.0, vel_age_s=1.0, now=now) + entity = decoder._to_entity(ac, now=now) + self.assertFalse(entity["position_stale"]) + self.assertFalse(entity["position_dr"]) + self.assertEqual(entity["lat"], 45.0) + self.assertEqual(entity["lon"], -122.0) + + def test_stale_position_dead_reckoned_along_track(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=30.0, vel_age_s=5.0, now=now) + entity = decoder._to_entity(ac, now=now) + self.assertTrue(entity["position_stale"]) + self.assertTrue(entity["position_dr"]) + self.assertAlmostEqual(entity["position_age_s"], 30.0, delta=0.5) + # 360 kt due east for 30 s ≈ 5.56 km → ~0.07° longitude at 45°N. + self.assertGreater(entity["lon"], -122.0 + 0.03) + self.assertAlmostEqual(entity["lat"], 45.0, delta=0.01) + + def test_dead_reckoning_capped_at_max_window(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=120.0, vel_age_s=5.0, now=now) + entity = decoder._to_entity(ac, now=now) + self.assertTrue(entity["position_stale"]) + self.assertFalse(entity["position_dr"]) + self.assertEqual(entity["lon"], -122.0) # frozen at last real fix + + def test_no_dead_reckoning_without_velocity_timestamp(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=30.0, vel_age_s=None, now=now) + entity = decoder._to_entity(ac, now=now) + self.assertTrue(entity["position_stale"]) + self.assertFalse(entity["position_dr"]) + + def test_hydrated_state_without_fix_ts_is_stale(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=0.0, vel_age_s=None, now=now) + ac.last_position_ts = None # Redis-hydrated: position but no fix time + entity = decoder._to_entity(ac, now=now) + self.assertTrue(entity["position_stale"]) + self.assertFalse(entity["position_dr"]) + self.assertIsNone(entity["position_age_s"]) + + def test_altitude_projected_with_vertical_rate(self): + import time + now = time.time() + decoder = BeastAircraftDecoder() + ac = self._aircraft(pos_age_s=30.0, vel_age_s=5.0, now=now) + ac.vertical_rate = -1200.0 # fpm descent + entity = decoder._to_entity(ac, now=now) + self.assertTrue(entity["position_dr"]) + self.assertAlmostEqual(entity["altitude"], 10000.0 - 600.0, delta=20.0) + + +class TestSeedReference(unittest.TestCase): + """Cross-source CPR reference seeding (OpenSky / ultrafeeder → decoder).""" + + def test_seeds_unknown_aircraft(self): + decoder = BeastAircraftDecoder() + self.assertTrue(decoder.seed_reference("ABC123", 45.5, -122.3)) + ac = decoder._aircraft.get("abc123") + self.assertIsNotNone(ac) + self.assertEqual(ac.lat, 45.5) + self.assertEqual(ac.lon, -122.3) + # Seeded-only aircraft never appear in snapshots (no real frames yet). + self.assertEqual(decoder.snapshot_entities(), []) + + def test_does_not_override_fresh_local_fix(self): + import time + decoder = BeastAircraftDecoder() + ac = _AircraftState(icao="abc123") + ac.lat, ac.lon = 45.0, -122.0 + ac.last_position_ts = time.time() # fresh local CPR fix + decoder._aircraft["abc123"] = ac + self.assertFalse(decoder.seed_reference("abc123", 46.0, -121.0)) + self.assertEqual(ac.lat, 45.0) + + def test_overrides_stale_local_fix_with_newer_reference(self): + import time + decoder = BeastAircraftDecoder() + ac = _AircraftState(icao="abc123") + ac.lat, ac.lon = 45.0, -122.0 + ac.last_position_ts = time.time() - 60.0 # stale + decoder._aircraft["abc123"] = ac + self.assertTrue(decoder.seed_reference("abc123", 46.0, -121.0)) + self.assertEqual(ac.lat, 46.0) + self.assertEqual(ac.lon, -121.0) + + def test_rejects_reference_older_than_local_fix(self): + import time + decoder = BeastAircraftDecoder() + ac = _AircraftState(icao="abc123") + ac.lat, ac.lon = 45.0, -122.0 + ac.last_position_ts = time.time() - 60.0 + decoder._aircraft["abc123"] = ac + self.assertFalse( + decoder.seed_reference("abc123", 46.0, -121.0, ts=time.time() - 300.0) + ) + self.assertEqual(ac.lat, 45.0) + + # ============================================================================ # 6. BeastAircraftDecoder — full decode (requires pyModeS v2) # ============================================================================