Skip to content

Commit df1ef53

Browse files
committed
refactor(pipe): isolate plausibility validation
1 parent e7160d6 commit df1ef53

2 files changed

Lines changed: 236 additions & 301 deletions

File tree

src/pyModeS/_pipe.py

Lines changed: 2 additions & 301 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def decode_one(msg: str, ts: float):
3838
from typing import Any
3939

4040
from pyModeS._aero import gs_to_ias, gs_to_mach
41+
from pyModeS._pipe_validation import ValidationMixin
4142
from pyModeS.errors import InvalidHexError, InvalidLengthError
4243
from pyModeS.message import Decoded, Message
4344

@@ -95,85 +96,8 @@ def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
9596
# still refreshing useful state that already exists for the ICAO.
9697
_STATEFUL_DFS = frozenset((0, 4, 16, 17, 18, 20, 21))
9798

98-
# BDS-payload fields cleared from a DF20 result that fails altitude
99-
# cross-check. The `altitude` field itself is preserved (it's the
100-
# 13-bit AC-code that triggered the mismatch — callers want it to
101-
# see why the message was flagged).
102-
_BDS_FIELDS_TO_CLEAR: tuple[str, ...] = (
103-
"bds",
104-
"bds_candidates",
105-
# BDS 1,0 / 1,7 — data-link capability / supported-BDS bitmap
106-
"supported_bds",
107-
# BDS 2,0 — aircraft identification
108-
"callsign",
109-
# BDS 4,0 — selected vertical intention
110-
"selected_altitude_mcp",
111-
"selected_altitude_fms",
112-
"baro_pressure_setting",
113-
"vnav_mode",
114-
"altitude_hold_mode",
115-
"approach_mode",
116-
"target_altitude_source",
117-
# BDS 4,4 / 4,5 — meteorological routine (only populated when
118-
# include_meteo is enabled, but strip the slots regardless)
119-
"wind_speed",
120-
"wind_direction",
121-
"static_air_temperature",
122-
"static_pressure",
123-
"humidity",
124-
"turbulence",
125-
"figure_of_merit",
126-
"wind_shear",
127-
"microburst",
128-
"icing",
129-
"radio_height",
130-
# BDS 5,0 — track and turn
131-
"true_airspeed",
132-
"true_track",
133-
"roll",
134-
"track_rate",
135-
"groundspeed",
136-
# BDS 6,0 — heading and speed
137-
"magnetic_heading",
138-
"indicated_airspeed",
139-
"mach",
140-
"baro_vertical_rate",
141-
"inertial_vertical_rate",
142-
)
143-
144-
145-
def _altitude_tolerance(dt: float) -> float:
146-
"""Max plausible |ADS-B - Comm-B AC-code| altitude diff after dt seconds.
147-
148-
Grows linearly with dt at 100 ft/s (≈ 6000 fpm, emergency descent
149-
rate for commercial jets), floored at 500 ft (signal noise +
150-
between-sample rounding) and capped at 5000 ft so stale ADS-B
151-
doesn't accept arbitrarily-wrong Comm-B altitudes.
152-
"""
153-
return max(500.0, min(dt * 100.0, 5000.0))
154-
155-
156-
def _groundspeed_tolerance(dt: float) -> float:
157-
"""Max plausible |gs_now - gs_anchor| in kt after dt seconds.
158-
159-
5 kt/s covers aggressive jet accel/decel plus ~30 kt wind gusts,
160-
floored at 20 kt (sample-to-sample noise + 1-kt LSB), capped at
161-
200 kt so a stale anchor can't rubber-stamp arbitrary jumps.
162-
"""
163-
return max(20.0, min(dt * 5.0, 200.0))
164-
16599

166-
def _track_tolerance(dt: float) -> float:
167-
"""Max plausible circular |track_now - track_anchor| in degrees.
168-
169-
10°/s covers a rate-3 (9°/s) commercial turn with margin, floored
170-
at 20° for sample noise, effectively unconstrained past ~16 s
171-
(capped at 180° — the maximum circular distance).
172-
"""
173-
return max(20.0, min(dt * 10.0, 180.0))
174-
175-
176-
class PipeDecoder:
100+
class PipeDecoder(ValidationMixin):
177101
"""Stateful Mode-S decoder with per-ICAO state and CPR pair matching.
178102
179103
Args:
@@ -529,229 +453,6 @@ def decode(
529453
self._update_state(icao, message.df, result, timestamp)
530454
return result
531455

532-
def _reject_on_altitude_mismatch(
533-
self,
534-
result: Decoded,
535-
icao: str,
536-
timestamp: float,
537-
) -> bool:
538-
"""Flag & scrub this DF20 result if its AC-code altitude disagrees
539-
with the recent ADS-B-anchored altitude for the same ICAO.
540-
541-
Returns True if the message was rejected (caller should skip
542-
state updates). No-ops if we have no ADS-B anchor yet or the
543-
message has no altitude field.
544-
"""
545-
cb_alt = result.get("altitude")
546-
if cb_alt is None:
547-
return False
548-
anchor = self._adsb_altitude.get(icao)
549-
if anchor is None:
550-
return False
551-
adsb_t, adsb_alt = anchor
552-
dt = abs(timestamp - adsb_t)
553-
if dt > self._eviction_ttl:
554-
return False # anchor too stale to trust
555-
if abs(cb_alt - adsb_alt) <= _altitude_tolerance(dt):
556-
return False
557-
558-
result["altitude_mismatch"] = True
559-
for key in _BDS_FIELDS_TO_CLEAR:
560-
if key in result:
561-
result[key] = None
562-
self._stats["altitude_mismatch"] += 1
563-
return True
564-
565-
def _reject_df17_altitude_mismatch(
566-
self,
567-
result: Decoded,
568-
icao: str,
569-
timestamp: float,
570-
) -> bool:
571-
"""Flag & scrub a CRC-valid DF17/18 BDS 0,5 position whose
572-
altitude disagrees with the recent ADS-B altitude anchor.
573-
574-
A FRUIT phantom that survives CRC can land with plausible ICAO
575-
bits but a garbage AC-code altitude. The header altitude is
576-
preserved (so callers see what was flagged); the CPR fields are
577-
cleared so the phantom can't pair with nearby real frames and
578-
produce a far-off position that the motion check would catch
579-
only after more work.
580-
581-
Returns True if rejected (caller skips anchor update and all
582-
downstream state mutations).
583-
"""
584-
new_alt = result.get("altitude")
585-
if new_alt is None:
586-
return False
587-
anchor = self._adsb_altitude.get(icao)
588-
if anchor is None:
589-
return False
590-
adsb_t, adsb_alt = anchor
591-
dt = abs(timestamp - adsb_t)
592-
if dt > self._eviction_ttl:
593-
return False
594-
if abs(new_alt - adsb_alt) <= _altitude_tolerance(dt):
595-
return False
596-
597-
result["altitude_mismatch"] = True
598-
for key in ("cpr_lat", "cpr_lon", "cpr_format"):
599-
if key in result:
600-
result[key] = None
601-
self._stats["altitude_mismatch"] += 1
602-
return True
603-
604-
def _reject_velocity_mismatch(
605-
self,
606-
result: Decoded,
607-
icao: str,
608-
timestamp: float,
609-
) -> bool:
610-
"""Flag & scrub this TC=19 result if gs/track disagrees with the
611-
recent anchor, OR if the decoded vertical rate exceeds the
612-
physical envelope of a commercial aircraft (~±6000 fpm; we use
613-
±10 000 fpm as an absolute cutoff).
614-
615-
Returns True if rejected (caller skips state/anchor update).
616-
The VR check fires even without an anchor and even when gs/track
617-
pass, so a phantom whose gs/track match the anchor but whose VR
618-
is garbage (-24 704 fpm from a single corrupted frame) is still
619-
caught.
620-
"""
621-
vr = result.get("vertical_rate")
622-
vr_implausible = vr is not None and abs(vr) > 10000.0
623-
624-
anchor_mismatch = False
625-
gs = result.get("groundspeed")
626-
track = result.get("track")
627-
if gs is not None and track is not None:
628-
anchor = self._adsb_velocity.get(icao)
629-
if anchor is not None:
630-
a_t, a_gs, a_track = anchor
631-
dt = abs(timestamp - a_t)
632-
if dt <= self._eviction_ttl:
633-
d_gs = abs(gs - a_gs)
634-
d_track = abs(track - a_track) % 360.0
635-
if d_track > 180.0:
636-
d_track = 360.0 - d_track
637-
if d_gs > _groundspeed_tolerance(dt) or d_track > _track_tolerance(
638-
dt
639-
):
640-
anchor_mismatch = True
641-
642-
if not (vr_implausible or anchor_mismatch):
643-
return False
644-
645-
result["velocity_mismatch"] = True
646-
# Scrub velocity fields — the caller would otherwise merge them
647-
# into state, propagating the phantom's values downstream.
648-
for key in (
649-
"groundspeed",
650-
"track",
651-
"vertical_rate",
652-
"heading",
653-
"airspeed",
654-
"airspeed_type",
655-
"velocity_type",
656-
):
657-
if key in result:
658-
result[key] = None
659-
self._stats["velocity_mismatch"] += 1
660-
return True
661-
662-
def _reject_bds50_velocity_mismatch(
663-
self,
664-
result: Decoded,
665-
icao: str,
666-
timestamp: float,
667-
) -> bool:
668-
"""Flag & scrub a DF20/21 BDS 5,0 reply whose gs or true_track
669-
disagrees with the per-ICAO ADS-B velocity anchor.
670-
671-
Mirrors `_reject_velocity_mismatch` but reads `true_track`
672-
(BDS 5,0's heading field) instead of `track` (TC=19's).
673-
674-
Returns True if rejected (caller skips handle_cpr_pair and
675-
_update_state so the phantom's values don't propagate).
676-
"""
677-
gs = result.get("groundspeed")
678-
track = result.get("true_track")
679-
if gs is None and track is None:
680-
return False
681-
anchor = self._adsb_velocity.get(icao)
682-
if anchor is None:
683-
return False
684-
a_t, a_gs, a_track = anchor
685-
dt = abs(timestamp - a_t)
686-
if dt > self._eviction_ttl:
687-
return False
688-
689-
gs_ok = True
690-
track_ok = True
691-
if gs is not None:
692-
gs_ok = abs(gs - a_gs) <= _groundspeed_tolerance(dt)
693-
if track is not None:
694-
d_track = abs(track - a_track) % 360.0
695-
if d_track > 180.0:
696-
d_track = 360.0 - d_track
697-
track_ok = d_track <= _track_tolerance(dt)
698-
699-
if gs_ok and track_ok:
700-
return False
701-
702-
result["velocity_mismatch"] = True
703-
# Scrub the whole BDS payload — the fields might individually
704-
# look valid but the frame is from a different aircraft.
705-
for key in _BDS_FIELDS_TO_CLEAR:
706-
if key in result:
707-
result[key] = None
708-
self._stats["velocity_mismatch"] += 1
709-
return True
710-
711-
def _reject_bds60_heading_mismatch(
712-
self,
713-
result: Decoded,
714-
icao: str,
715-
timestamp: float,
716-
) -> bool:
717-
"""Flag & scrub a DF20/21 BDS 6,0 reply whose magnetic_heading
718-
disagrees with the per-ICAO ADS-B track anchor by more than
719-
magnetic variation + wind-correction angle can legitimately
720-
explain.
721-
722-
Tolerance: ``max(60°, min(Δt · 10°, 180°))``. The 60° floor
723-
covers worst-case magnetic variation (±20°) + strong-crosswind
724-
wind-correction angle (±20°) + short lag during turns; the
725-
per-second slope matches the rate-3 turn envelope used elsewhere.
726-
727-
Returns True if rejected.
728-
"""
729-
hdg = result.get("magnetic_heading")
730-
if hdg is None:
731-
return False
732-
anchor = self._adsb_velocity.get(icao)
733-
if anchor is None:
734-
return False
735-
a_t, _a_gs, a_track = anchor
736-
dt = abs(timestamp - a_t)
737-
if dt > self._eviction_ttl:
738-
return False
739-
740-
d = abs(hdg - a_track) % 360.0
741-
if d > 180.0:
742-
d = 360.0 - d
743-
744-
tol = max(60.0, min(dt * 10.0, 180.0))
745-
if d <= tol:
746-
return False
747-
748-
result["velocity_mismatch"] = True
749-
for key in _BDS_FIELDS_TO_CLEAR:
750-
if key in result:
751-
result[key] = None
752-
self._stats["velocity_mismatch"] += 1
753-
return True
754-
755456
def _maybe_evict_expired(self, now: float) -> None:
756457
"""Run a full eviction sweep only when its interval is due.
757458

0 commit comments

Comments
 (0)