@@ -38,6 +38,7 @@ def decode_one(msg: str, ts: float):
3838from typing import Any
3939
4040from pyModeS ._aero import gs_to_ias , gs_to_mach
41+ from pyModeS ._pipe_validation import ValidationMixin
4142from pyModeS .errors import InvalidHexError , InvalidLengthError
4243from 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