pyModeS 3.2.0__tar.gz → 3.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pymodes-3.2.0 → pymodes-3.3.0}/PKG-INFO +1 -1
- {pymodes-3.2.0 → pymodes-3.3.0}/pyproject.toml +2 -1
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_pipe.py +425 -61
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/_infer.py +2 -5
- {pymodes-3.2.0 → pymodes-3.3.0}/.gitignore +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/CHANGELOG.md +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/LICENSE +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/README.md +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_aero.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_altcode.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_bits.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_callsign.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_idcode.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_schema.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_uncertainty.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/_v2_removed.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/__main__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/_args.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/_sink.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/_source.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/_tui.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/decode.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/cli/live.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/core.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/data/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/data/airports.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/_base.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/acas.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/adsb.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/allcall.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/_helpers.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds05.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds06.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds08.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds09.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds10.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds17.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds20.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds30.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds40.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds44.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds45.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds50.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds60.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds61.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds62.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds65.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/commb.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/decoder/surv.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/errors.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/message.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/position/__init__.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/position/_airports.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/position/_cpr.py +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/py.typed +0 -0
- {pymodes-3.2.0 → pymodes-3.3.0}/src/pyModeS/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyModeS"
|
|
3
|
-
version = "3.
|
|
3
|
+
version = "3.3.0"
|
|
4
4
|
description = "Python Mode-S and ADS-B Decoder"
|
|
5
5
|
authors = [{ name = "Junzi Sun", email = "git@junzis.com" }]
|
|
6
6
|
license = { text = "GNU GPL v3" }
|
|
@@ -44,6 +44,7 @@ issues = "https://github.com/junzis/pyModeS/issues"
|
|
|
44
44
|
[dependency-groups]
|
|
45
45
|
dev = [
|
|
46
46
|
"mypy>=1.13",
|
|
47
|
+
"pre-commit>=4.5.1",
|
|
47
48
|
"pytest>=7.2.0",
|
|
48
49
|
"pytest-cov>=4.0.0",
|
|
49
50
|
"ruff>=0.15.8",
|
|
@@ -140,6 +140,26 @@ def _altitude_tolerance(dt: float) -> float:
|
|
|
140
140
|
return max(500.0, min(dt * 100.0, 5000.0))
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def _groundspeed_tolerance(dt: float) -> float:
|
|
144
|
+
"""Max plausible |gs_now - gs_anchor| in kt after dt seconds.
|
|
145
|
+
|
|
146
|
+
5 kt/s covers aggressive jet accel/decel plus ~30 kt wind gusts,
|
|
147
|
+
floored at 20 kt (sample-to-sample noise + 1-kt LSB), capped at
|
|
148
|
+
200 kt so a stale anchor can't rubber-stamp arbitrary jumps.
|
|
149
|
+
"""
|
|
150
|
+
return max(20.0, min(dt * 5.0, 200.0))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _track_tolerance(dt: float) -> float:
|
|
154
|
+
"""Max plausible circular |track_now - track_anchor| in degrees.
|
|
155
|
+
|
|
156
|
+
10°/s covers a rate-3 (9°/s) commercial turn with margin, floored
|
|
157
|
+
at 20° for sample noise, effectively unconstrained past ~16 s
|
|
158
|
+
(capped at 180° — the maximum circular distance).
|
|
159
|
+
"""
|
|
160
|
+
return max(20.0, min(dt * 10.0, 180.0))
|
|
161
|
+
|
|
162
|
+
|
|
143
163
|
class PipeDecoder:
|
|
144
164
|
"""Stateful Mode-S decoder with per-ICAO state and CPR pair matching.
|
|
145
165
|
|
|
@@ -157,6 +177,7 @@ class PipeDecoder:
|
|
|
157
177
|
|
|
158
178
|
__slots__ = (
|
|
159
179
|
"_adsb_altitude",
|
|
180
|
+
"_adsb_velocity",
|
|
160
181
|
"_bootstrap",
|
|
161
182
|
"_eviction_ttl",
|
|
162
183
|
"_full_dict",
|
|
@@ -193,8 +214,15 @@ class PipeDecoder:
|
|
|
193
214
|
self._max_speed_kmps = max_speed_kt * 1.852 / 3600.0
|
|
194
215
|
self._motion_margin_km = motion_margin_km
|
|
195
216
|
self._state: dict[str, dict[str, Any]] = {}
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
# Pending CPR frames: keyed by ICAO, each value is a list of
|
|
218
|
+
# (timestamp, cpr_lat, cpr_lon, result_dict) entries sorted by
|
|
219
|
+
# timestamp. Keeping a deque per parity (instead of a single
|
|
220
|
+
# slot) ensures that when two same-parity frames arrive before
|
|
221
|
+
# an opposite-parity does, the earlier frame isn't silently
|
|
222
|
+
# dropped: on the next opposite arrival we pair it with every
|
|
223
|
+
# fresh deque entry, giving each its own resolved position.
|
|
224
|
+
self._pending_even: dict[str, list[tuple[float, int, int, Decoded]]] = {}
|
|
225
|
+
self._pending_odd: dict[str, list[tuple[float, int, int, Decoded]]] = {}
|
|
198
226
|
self._trusted_icaos: set[str] = set()
|
|
199
227
|
# ADS-B-derived altitude anchor per ICAO, used to reject DF20
|
|
200
228
|
# messages whose AC-code altitude is inconsistent with the
|
|
@@ -203,6 +231,14 @@ class PipeDecoder:
|
|
|
203
231
|
# Populated only from CRC-valid DF17/18 airborne positions
|
|
204
232
|
# (BDS 0,5); value is (timestamp, altitude_ft).
|
|
205
233
|
self._adsb_altitude: dict[str, tuple[float, float]] = {}
|
|
234
|
+
# ADS-B-derived velocity anchor per ICAO, used to reject DF17/18
|
|
235
|
+
# TC=19 airborne-velocity messages whose decoded groundspeed or
|
|
236
|
+
# track jumps implausibly from the last CRC-valid sample (a
|
|
237
|
+
# signature of a bit-corruption phantom that happened to pass
|
|
238
|
+
# CRC with the ICAO field intact). Value is
|
|
239
|
+
# (timestamp, groundspeed_kt, track_deg) and only updated on
|
|
240
|
+
# messages that *pass* the cross-check.
|
|
241
|
+
self._adsb_velocity: dict[str, tuple[float, float, float]] = {}
|
|
206
242
|
# Rolling window of the last _POSITION_HISTORY_SIZE resolved
|
|
207
243
|
# (lat, lon, timestamp) tuples per ICAO. Only populated AFTER
|
|
208
244
|
# the per-ICAO bootstrap cluster analysis has locked in an
|
|
@@ -217,13 +253,17 @@ class PipeDecoder:
|
|
|
217
253
|
# cluster locks, we retroactively set latitude/longitude on
|
|
218
254
|
# those dicts so batch callers (who keep the list around) still
|
|
219
255
|
# see the resolved positions for their early samples.
|
|
220
|
-
|
|
256
|
+
# Each bootstrap entry stores a *list* of result dicts — both
|
|
257
|
+
# halves of a CPR pair resolve to the same lat/lon and should
|
|
258
|
+
# both be retro-filled when the cluster locks.
|
|
259
|
+
self._bootstrap: dict[str, list[tuple[float, float, float, list[Decoded]]]] = {}
|
|
221
260
|
self._stats: dict[str, int] = {
|
|
222
261
|
"total": 0,
|
|
223
262
|
"decoded": 0,
|
|
224
263
|
"crc_fail": 0,
|
|
225
264
|
"pending_pairs": 0,
|
|
226
265
|
"altitude_mismatch": 0,
|
|
266
|
+
"velocity_mismatch": 0,
|
|
227
267
|
"position_rejected": 0,
|
|
228
268
|
"bootstrap_held": 0,
|
|
229
269
|
"bootstrap_reset": 0,
|
|
@@ -321,9 +361,23 @@ class PipeDecoder:
|
|
|
321
361
|
# State would be poisoned if updated from this message.
|
|
322
362
|
return result
|
|
323
363
|
|
|
364
|
+
# Altitude cross-check for CRC-valid DF17/18 airborne-position
|
|
365
|
+
# (BDS 0,5) frames. A CRC-lucky FRUIT phantom can produce an
|
|
366
|
+
# altitude thousands of feet off from the running anchor; reject
|
|
367
|
+
# those so they don't pollute the altitude trace or pair into
|
|
368
|
+
# garbage CPR positions.
|
|
369
|
+
if (
|
|
370
|
+
message.df in (17, 18)
|
|
371
|
+
and result.get("crc_valid") is True
|
|
372
|
+
and result.get("bds") == "0,5"
|
|
373
|
+
and timestamp is not None
|
|
374
|
+
and self._reject_df17_altitude_mismatch(result, icao, timestamp)
|
|
375
|
+
):
|
|
376
|
+
return result
|
|
377
|
+
|
|
324
378
|
# ADS-B position altitude becomes the per-ICAO anchor for the
|
|
325
|
-
# next round of DF20 cross-checks. Only plain-text-ICAO
|
|
326
|
-
# with verified CRC qualify.
|
|
379
|
+
# next round of DF20 and DF17 cross-checks. Only plain-text-ICAO
|
|
380
|
+
# frames with verified CRC qualify.
|
|
327
381
|
if (
|
|
328
382
|
message.df in (17, 18)
|
|
329
383
|
and result.get("crc_valid") is True
|
|
@@ -333,6 +387,63 @@ class PipeDecoder:
|
|
|
333
387
|
):
|
|
334
388
|
self._adsb_altitude[icao] = (timestamp, float(result["altitude"]))
|
|
335
389
|
|
|
390
|
+
# Velocity cross-check: DF17/18 TC=19 subtype 1/2 carries gs +
|
|
391
|
+
# track. A single frame whose values jump implausibly from the
|
|
392
|
+
# most recent CRC-valid anchor is almost certainly a CRC-lucky
|
|
393
|
+
# bit-corruption phantom attributed to this ICAO by mistake.
|
|
394
|
+
# Rejection scrubs the velocity fields AND skips the anchor
|
|
395
|
+
# update below so the phantom can't poison future checks.
|
|
396
|
+
if (
|
|
397
|
+
message.df in (17, 18)
|
|
398
|
+
and result.get("typecode") == 19
|
|
399
|
+
and result.get("crc_valid") is True
|
|
400
|
+
and timestamp is not None
|
|
401
|
+
and self._reject_velocity_mismatch(result, icao, timestamp)
|
|
402
|
+
):
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
# ADS-B velocity becomes the per-ICAO anchor for the next
|
|
406
|
+
# TC=19 cross-check — only when both gs and track are present
|
|
407
|
+
# (subtype 1/2) and the message passed the check above.
|
|
408
|
+
if (
|
|
409
|
+
message.df in (17, 18)
|
|
410
|
+
and result.get("typecode") == 19
|
|
411
|
+
and result.get("crc_valid") is True
|
|
412
|
+
and result.get("groundspeed") is not None
|
|
413
|
+
and result.get("track") is not None
|
|
414
|
+
and timestamp is not None
|
|
415
|
+
):
|
|
416
|
+
self._adsb_velocity[icao] = (
|
|
417
|
+
timestamp,
|
|
418
|
+
float(result["groundspeed"]),
|
|
419
|
+
float(result["track"]),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Comm-B BDS 5,0 (track-and-turn) velocity cross-check. A DF20/21
|
|
423
|
+
# reply can pass CRC (XOR'd with ICAO) while originating from a
|
|
424
|
+
# different aircraft whose bits happen to land in BDS 5,0's valid
|
|
425
|
+
# range. Reuse the per-ICAO ADS-B velocity anchor to reject
|
|
426
|
+
# those when gs/true_track disagrees with recent TC=19.
|
|
427
|
+
if (
|
|
428
|
+
message.df in (20, 21)
|
|
429
|
+
and result.get("bds") == "5,0"
|
|
430
|
+
and timestamp is not None
|
|
431
|
+
and self._reject_bds50_velocity_mismatch(result, icao, timestamp)
|
|
432
|
+
):
|
|
433
|
+
return result
|
|
434
|
+
|
|
435
|
+
# Same idea for BDS 6,0 (heading and speed): magnetic heading is
|
|
436
|
+
# the cleanest discriminator (±60° covers magnetic variation and
|
|
437
|
+
# wind correction). Mach and IAS are intentionally not checked —
|
|
438
|
+
# they overlap too much with real variation across wind/altitude.
|
|
439
|
+
if (
|
|
440
|
+
message.df in (20, 21)
|
|
441
|
+
and result.get("bds") == "6,0"
|
|
442
|
+
and timestamp is not None
|
|
443
|
+
and self._reject_bds60_heading_mismatch(result, icao, timestamp)
|
|
444
|
+
):
|
|
445
|
+
return result
|
|
446
|
+
|
|
336
447
|
self._handle_cpr_pair(result, icao, timestamp)
|
|
337
448
|
self._update_state(icao, result, timestamp)
|
|
338
449
|
return result
|
|
@@ -370,6 +481,196 @@ class PipeDecoder:
|
|
|
370
481
|
self._stats["altitude_mismatch"] += 1
|
|
371
482
|
return True
|
|
372
483
|
|
|
484
|
+
def _reject_df17_altitude_mismatch(
|
|
485
|
+
self,
|
|
486
|
+
result: Decoded,
|
|
487
|
+
icao: str,
|
|
488
|
+
timestamp: float,
|
|
489
|
+
) -> bool:
|
|
490
|
+
"""Flag & scrub a CRC-valid DF17/18 BDS 0,5 position whose
|
|
491
|
+
altitude disagrees with the recent ADS-B altitude anchor.
|
|
492
|
+
|
|
493
|
+
A FRUIT phantom that survives CRC can land with plausible ICAO
|
|
494
|
+
bits but a garbage AC-code altitude. The header altitude is
|
|
495
|
+
preserved (so callers see what was flagged); the CPR fields are
|
|
496
|
+
cleared so the phantom can't pair with nearby real frames and
|
|
497
|
+
produce a far-off position that the motion check would catch
|
|
498
|
+
only after more work.
|
|
499
|
+
|
|
500
|
+
Returns True if rejected (caller skips anchor update and all
|
|
501
|
+
downstream state mutations).
|
|
502
|
+
"""
|
|
503
|
+
new_alt = result.get("altitude")
|
|
504
|
+
if new_alt is None:
|
|
505
|
+
return False
|
|
506
|
+
anchor = self._adsb_altitude.get(icao)
|
|
507
|
+
if anchor is None:
|
|
508
|
+
return False
|
|
509
|
+
adsb_t, adsb_alt = anchor
|
|
510
|
+
dt = abs(timestamp - adsb_t)
|
|
511
|
+
if dt > self._eviction_ttl:
|
|
512
|
+
return False
|
|
513
|
+
if abs(new_alt - adsb_alt) <= _altitude_tolerance(dt):
|
|
514
|
+
return False
|
|
515
|
+
|
|
516
|
+
result["altitude_mismatch"] = True
|
|
517
|
+
for key in ("cpr_lat", "cpr_lon", "cpr_format"):
|
|
518
|
+
if key in result:
|
|
519
|
+
result[key] = None
|
|
520
|
+
self._stats["altitude_mismatch"] += 1
|
|
521
|
+
return True
|
|
522
|
+
|
|
523
|
+
def _reject_velocity_mismatch(
|
|
524
|
+
self,
|
|
525
|
+
result: Decoded,
|
|
526
|
+
icao: str,
|
|
527
|
+
timestamp: float,
|
|
528
|
+
) -> bool:
|
|
529
|
+
"""Flag & scrub this TC=19 result if gs/track disagrees with the
|
|
530
|
+
recent anchor, OR if the decoded vertical rate exceeds the
|
|
531
|
+
physical envelope of a commercial aircraft (~±6000 fpm; we use
|
|
532
|
+
±10 000 fpm as an absolute cutoff).
|
|
533
|
+
|
|
534
|
+
Returns True if rejected (caller skips state/anchor update).
|
|
535
|
+
The VR check fires even without an anchor and even when gs/track
|
|
536
|
+
pass, so a phantom whose gs/track match the anchor but whose VR
|
|
537
|
+
is garbage (-24 704 fpm from a single corrupted frame) is still
|
|
538
|
+
caught.
|
|
539
|
+
"""
|
|
540
|
+
vr = result.get("vertical_rate")
|
|
541
|
+
vr_implausible = vr is not None and abs(vr) > 10000.0
|
|
542
|
+
|
|
543
|
+
anchor_mismatch = False
|
|
544
|
+
gs = result.get("groundspeed")
|
|
545
|
+
track = result.get("track")
|
|
546
|
+
if gs is not None and track is not None:
|
|
547
|
+
anchor = self._adsb_velocity.get(icao)
|
|
548
|
+
if anchor is not None:
|
|
549
|
+
a_t, a_gs, a_track = anchor
|
|
550
|
+
dt = abs(timestamp - a_t)
|
|
551
|
+
if dt <= self._eviction_ttl:
|
|
552
|
+
d_gs = abs(gs - a_gs)
|
|
553
|
+
d_track = abs(track - a_track) % 360.0
|
|
554
|
+
if d_track > 180.0:
|
|
555
|
+
d_track = 360.0 - d_track
|
|
556
|
+
if d_gs > _groundspeed_tolerance(dt) or d_track > _track_tolerance(
|
|
557
|
+
dt
|
|
558
|
+
):
|
|
559
|
+
anchor_mismatch = True
|
|
560
|
+
|
|
561
|
+
if not (vr_implausible or anchor_mismatch):
|
|
562
|
+
return False
|
|
563
|
+
|
|
564
|
+
result["velocity_mismatch"] = True
|
|
565
|
+
# Scrub velocity fields — the caller would otherwise merge them
|
|
566
|
+
# into state, propagating the phantom's values downstream.
|
|
567
|
+
for key in (
|
|
568
|
+
"groundspeed",
|
|
569
|
+
"track",
|
|
570
|
+
"vertical_rate",
|
|
571
|
+
"heading",
|
|
572
|
+
"airspeed",
|
|
573
|
+
"airspeed_type",
|
|
574
|
+
"velocity_type",
|
|
575
|
+
):
|
|
576
|
+
if key in result:
|
|
577
|
+
result[key] = None
|
|
578
|
+
self._stats["velocity_mismatch"] += 1
|
|
579
|
+
return True
|
|
580
|
+
|
|
581
|
+
def _reject_bds50_velocity_mismatch(
|
|
582
|
+
self,
|
|
583
|
+
result: Decoded,
|
|
584
|
+
icao: str,
|
|
585
|
+
timestamp: float,
|
|
586
|
+
) -> bool:
|
|
587
|
+
"""Flag & scrub a DF20/21 BDS 5,0 reply whose gs or true_track
|
|
588
|
+
disagrees with the per-ICAO ADS-B velocity anchor.
|
|
589
|
+
|
|
590
|
+
Mirrors `_reject_velocity_mismatch` but reads `true_track`
|
|
591
|
+
(BDS 5,0's heading field) instead of `track` (TC=19's).
|
|
592
|
+
|
|
593
|
+
Returns True if rejected (caller skips handle_cpr_pair and
|
|
594
|
+
_update_state so the phantom's values don't propagate).
|
|
595
|
+
"""
|
|
596
|
+
gs = result.get("groundspeed")
|
|
597
|
+
track = result.get("true_track")
|
|
598
|
+
if gs is None and track is None:
|
|
599
|
+
return False
|
|
600
|
+
anchor = self._adsb_velocity.get(icao)
|
|
601
|
+
if anchor is None:
|
|
602
|
+
return False
|
|
603
|
+
a_t, a_gs, a_track = anchor
|
|
604
|
+
dt = abs(timestamp - a_t)
|
|
605
|
+
if dt > self._eviction_ttl:
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
gs_ok = True
|
|
609
|
+
track_ok = True
|
|
610
|
+
if gs is not None:
|
|
611
|
+
gs_ok = abs(gs - a_gs) <= _groundspeed_tolerance(dt)
|
|
612
|
+
if track is not None:
|
|
613
|
+
d_track = abs(track - a_track) % 360.0
|
|
614
|
+
if d_track > 180.0:
|
|
615
|
+
d_track = 360.0 - d_track
|
|
616
|
+
track_ok = d_track <= _track_tolerance(dt)
|
|
617
|
+
|
|
618
|
+
if gs_ok and track_ok:
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
result["velocity_mismatch"] = True
|
|
622
|
+
# Scrub the whole BDS payload — the fields might individually
|
|
623
|
+
# look valid but the frame is from a different aircraft.
|
|
624
|
+
for key in _BDS_FIELDS_TO_CLEAR:
|
|
625
|
+
if key in result:
|
|
626
|
+
result[key] = None
|
|
627
|
+
self._stats["velocity_mismatch"] += 1
|
|
628
|
+
return True
|
|
629
|
+
|
|
630
|
+
def _reject_bds60_heading_mismatch(
|
|
631
|
+
self,
|
|
632
|
+
result: Decoded,
|
|
633
|
+
icao: str,
|
|
634
|
+
timestamp: float,
|
|
635
|
+
) -> bool:
|
|
636
|
+
"""Flag & scrub a DF20/21 BDS 6,0 reply whose magnetic_heading
|
|
637
|
+
disagrees with the per-ICAO ADS-B track anchor by more than
|
|
638
|
+
magnetic variation + wind-correction angle can legitimately
|
|
639
|
+
explain.
|
|
640
|
+
|
|
641
|
+
Tolerance: ``max(60°, min(Δt · 10°, 180°))``. The 60° floor
|
|
642
|
+
covers worst-case magnetic variation (±20°) + strong-crosswind
|
|
643
|
+
wind-correction angle (±20°) + short lag during turns; the
|
|
644
|
+
per-second slope matches the rate-3 turn envelope used elsewhere.
|
|
645
|
+
|
|
646
|
+
Returns True if rejected.
|
|
647
|
+
"""
|
|
648
|
+
hdg = result.get("magnetic_heading")
|
|
649
|
+
if hdg is None:
|
|
650
|
+
return False
|
|
651
|
+
anchor = self._adsb_velocity.get(icao)
|
|
652
|
+
if anchor is None:
|
|
653
|
+
return False
|
|
654
|
+
a_t, _a_gs, a_track = anchor
|
|
655
|
+
dt = abs(timestamp - a_t)
|
|
656
|
+
if dt > self._eviction_ttl:
|
|
657
|
+
return False
|
|
658
|
+
|
|
659
|
+
d = abs(hdg - a_track) % 360.0
|
|
660
|
+
if d > 180.0:
|
|
661
|
+
d = 360.0 - d
|
|
662
|
+
|
|
663
|
+
tol = max(60.0, min(dt * 10.0, 180.0))
|
|
664
|
+
if d <= tol:
|
|
665
|
+
return False
|
|
666
|
+
|
|
667
|
+
result["velocity_mismatch"] = True
|
|
668
|
+
for key in _BDS_FIELDS_TO_CLEAR:
|
|
669
|
+
if key in result:
|
|
670
|
+
result[key] = None
|
|
671
|
+
self._stats["velocity_mismatch"] += 1
|
|
672
|
+
return True
|
|
673
|
+
|
|
373
674
|
def _evict_expired(self, now: float) -> None:
|
|
374
675
|
"""Drop state and pending CPR entries older than eviction_ttl.
|
|
375
676
|
|
|
@@ -380,12 +681,21 @@ class PipeDecoder:
|
|
|
380
681
|
"""
|
|
381
682
|
cutoff = now - self._eviction_ttl
|
|
382
683
|
|
|
383
|
-
# Evict pending CPR frames (and decrement the stat)
|
|
684
|
+
# Evict pending CPR frames (and decrement the stat). Per-ICAO
|
|
685
|
+
# value is a deque; trim entries older than cutoff and drop the
|
|
686
|
+
# key if the deque empties out.
|
|
384
687
|
for pending in (self._pending_even, self._pending_odd):
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
688
|
+
for icao in list(pending):
|
|
689
|
+
deque = pending[icao]
|
|
690
|
+
fresh_deque = [e for e in deque if e[0] >= cutoff]
|
|
691
|
+
dropped = len(deque) - len(fresh_deque)
|
|
692
|
+
if fresh_deque:
|
|
693
|
+
pending[icao] = fresh_deque
|
|
694
|
+
else:
|
|
695
|
+
pending.pop(icao, None)
|
|
696
|
+
self._stats["pending_pairs"] = max(
|
|
697
|
+
0, self._stats["pending_pairs"] - dropped
|
|
698
|
+
)
|
|
389
699
|
|
|
390
700
|
# Evict per-ICAO state. Only entries with a _last_seen timestamp
|
|
391
701
|
# are evictable; entries without (decoded with timestamp=None)
|
|
@@ -406,6 +716,13 @@ class PipeDecoder:
|
|
|
406
716
|
for icao in stale_anchors:
|
|
407
717
|
self._adsb_altitude.pop(icao, None)
|
|
408
718
|
|
|
719
|
+
# Same for velocity anchors.
|
|
720
|
+
stale_vel = [
|
|
721
|
+
icao for icao, (t, _, _) in self._adsb_velocity.items() if t < cutoff
|
|
722
|
+
]
|
|
723
|
+
for icao in stale_vel:
|
|
724
|
+
self._adsb_velocity.pop(icao, None)
|
|
725
|
+
|
|
409
726
|
# Prune position-history entries older than cutoff; drop the
|
|
410
727
|
# ICAO key entirely once its buffer is empty.
|
|
411
728
|
for icao in list(self._position_history):
|
|
@@ -522,13 +839,14 @@ class PipeDecoder:
|
|
|
522
839
|
)
|
|
523
840
|
cluster: list[tuple[float, float, float]] = []
|
|
524
841
|
for i in cluster_indices:
|
|
525
|
-
lat, lon, t,
|
|
526
|
-
# Retroactively emit the resolved position on
|
|
527
|
-
# result dict
|
|
528
|
-
#
|
|
529
|
-
# lat/lon.
|
|
530
|
-
|
|
531
|
-
|
|
842
|
+
lat, lon, t, result_dicts = candidates[i]
|
|
843
|
+
# Retroactively emit the resolved position on every held
|
|
844
|
+
# result dict for this pair — typically both the even and
|
|
845
|
+
# odd frame — so callers who kept references (e.g. batch-
|
|
846
|
+
# mode consumers) now see a valid lat/lon on both halves.
|
|
847
|
+
for rd in result_dicts:
|
|
848
|
+
rd["latitude"] = lat
|
|
849
|
+
rd["longitude"] = lon
|
|
532
850
|
cluster.append((lat, lon, t))
|
|
533
851
|
# Seed the history with the (up to _POSITION_HISTORY_SIZE) most
|
|
534
852
|
# recent members of the cluster.
|
|
@@ -538,23 +856,30 @@ class PipeDecoder:
|
|
|
538
856
|
|
|
539
857
|
def _bootstrap_accumulate(
|
|
540
858
|
self,
|
|
541
|
-
|
|
859
|
+
results: Decoded | list[Decoded],
|
|
542
860
|
icao: str,
|
|
543
861
|
lat: float,
|
|
544
862
|
lon: float,
|
|
545
863
|
timestamp: float,
|
|
546
864
|
) -> None:
|
|
547
|
-
"""Hold a pre-lock candidate position. Clears lat/lon from
|
|
548
|
-
result (we don't emit unverified positions) and —
|
|
549
|
-
reaches _BOOTSTRAP_K — triggers cluster analysis.
|
|
865
|
+
"""Hold a pre-lock candidate position. Clears lat/lon from all
|
|
866
|
+
supplied result dicts (we don't emit unverified positions) and —
|
|
867
|
+
when the buffer reaches _BOOTSTRAP_K — triggers cluster analysis.
|
|
868
|
+
|
|
869
|
+
``results`` accepts a single dict (back-compat for tests that
|
|
870
|
+
supply synthetic candidates) or a list of dicts. In the normal
|
|
871
|
+
decode path the caller passes both halves of the resolved pair
|
|
872
|
+
so both get retro-filled together when a cluster locks.
|
|
550
873
|
"""
|
|
874
|
+
result_dicts = results if isinstance(results, list) else [results]
|
|
551
875
|
buf = self._bootstrap.setdefault(icao, [])
|
|
552
|
-
buf.append((lat, lon, timestamp,
|
|
876
|
+
buf.append((lat, lon, timestamp, result_dicts))
|
|
553
877
|
# Don't emit lat/lon while the anchor is still being chosen;
|
|
554
|
-
# the CPR raw fields remain on
|
|
878
|
+
# the CPR raw fields remain on each result so callers can see
|
|
555
879
|
# that a pair was seen.
|
|
556
|
-
|
|
557
|
-
|
|
880
|
+
for rd in result_dicts:
|
|
881
|
+
rd["latitude"] = None
|
|
882
|
+
rd["longitude"] = None
|
|
558
883
|
self._stats["bootstrap_held"] += 1
|
|
559
884
|
|
|
560
885
|
if len(buf) >= _BOOTSTRAP_K and not self._bootstrap_try_lock(
|
|
@@ -582,9 +907,10 @@ class PipeDecoder:
|
|
|
582
907
|
for icao in list(self._bootstrap):
|
|
583
908
|
buf = self._bootstrap[icao]
|
|
584
909
|
if len(buf) == 1:
|
|
585
|
-
lat, lon, t,
|
|
586
|
-
|
|
587
|
-
|
|
910
|
+
lat, lon, t, result_dicts = buf[0]
|
|
911
|
+
for rd in result_dicts:
|
|
912
|
+
rd["latitude"] = lat
|
|
913
|
+
rd["longitude"] = lon
|
|
588
914
|
self._position_history[icao] = [(lat, lon, t)]
|
|
589
915
|
self._bootstrap.pop(icao, None)
|
|
590
916
|
else:
|
|
@@ -619,43 +945,80 @@ class PipeDecoder:
|
|
|
619
945
|
this_pending = self._pending_odd
|
|
620
946
|
other_pending = self._pending_even
|
|
621
947
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
948
|
+
opposite_deque = other_pending.get(icao, [])
|
|
949
|
+
fresh = [
|
|
950
|
+
e for e in opposite_deque if abs(timestamp - e[0]) <= self._pair_window
|
|
951
|
+
]
|
|
952
|
+
|
|
953
|
+
if fresh:
|
|
954
|
+
# Primary = the most-recent fresh opposite. That pair gives
|
|
955
|
+
# the best estimate of the current (arriving) frame's
|
|
956
|
+
# position; older fresh opposites become "orphan pairs"
|
|
957
|
+
# resolved independently so each gets its own lat/lon.
|
|
958
|
+
fresh.sort(key=lambda e: e[0], reverse=True)
|
|
959
|
+
_primary_t, primary_lat, primary_lon, primary_result = fresh[0]
|
|
960
|
+
|
|
961
|
+
self._resolve_pair(
|
|
962
|
+
result, bds, cpr_format, cpr_lat, cpr_lon, primary_lat, primary_lon
|
|
963
|
+
)
|
|
964
|
+
lat = result.get("latitude")
|
|
965
|
+
lon = result.get("longitude")
|
|
966
|
+
|
|
967
|
+
paired_dicts: list[Decoded] = [result, primary_result]
|
|
968
|
+
if lat is not None and lon is not None:
|
|
969
|
+
primary_result["latitude"] = lat
|
|
970
|
+
primary_result["longitude"] = lon
|
|
971
|
+
|
|
972
|
+
# Orphan pairs — each opposite entry older than the primary
|
|
973
|
+
# pairs independently with the arriving frame's cpr values.
|
|
974
|
+
for _o_t, o_lat, o_lon, o_result in fresh[1:]:
|
|
975
|
+
temp: Decoded = Decoded({"cpr_format": cpr_format})
|
|
627
976
|
self._resolve_pair(
|
|
628
|
-
|
|
629
|
-
bds,
|
|
630
|
-
cpr_format,
|
|
631
|
-
cpr_lat,
|
|
632
|
-
cpr_lon,
|
|
633
|
-
opp_lat,
|
|
634
|
-
opp_lon,
|
|
977
|
+
temp, bds, cpr_format, cpr_lat, cpr_lon, o_lat, o_lon
|
|
635
978
|
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
979
|
+
o_lat_out = temp.get("latitude")
|
|
980
|
+
o_lon_out = temp.get("longitude")
|
|
981
|
+
if o_lat_out is not None:
|
|
982
|
+
o_result["latitude"] = o_lat_out
|
|
983
|
+
o_result["longitude"] = o_lon_out
|
|
984
|
+
paired_dicts.append(o_result)
|
|
985
|
+
|
|
986
|
+
# Fresh opposites have all been consumed; only stale entries
|
|
987
|
+
# remain in the deque (they'll be evicted at next
|
|
988
|
+
# `_evict_expired`, but keeping them until then is harmless).
|
|
989
|
+
stale = [
|
|
990
|
+
e for e in opposite_deque if abs(timestamp - e[0]) > self._pair_window
|
|
991
|
+
]
|
|
992
|
+
if stale:
|
|
993
|
+
other_pending[icao] = stale
|
|
994
|
+
else:
|
|
651
995
|
other_pending.pop(icao, None)
|
|
652
|
-
|
|
653
|
-
|
|
996
|
+
self._stats["pending_pairs"] = max(
|
|
997
|
+
0, self._stats["pending_pairs"] - len(fresh)
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
# Position-filter dispatch: bootstrapping ICAOs hold every
|
|
1001
|
+
# paired dict (primary + orphans) as a single candidate so
|
|
1002
|
+
# that nothing unverified reaches the caller; locked ICAOs
|
|
1003
|
+
# run the motion check against the primary resolution and
|
|
1004
|
+
# share the verdict with the orphans.
|
|
1005
|
+
if lat is not None and lon is not None:
|
|
1006
|
+
if icao in self._position_history:
|
|
1007
|
+
if not self._motion_consistent(icao, lat, lon, timestamp):
|
|
1008
|
+
for d in paired_dicts:
|
|
1009
|
+
d["latitude"] = None
|
|
1010
|
+
d["longitude"] = None
|
|
1011
|
+
self._stats["position_rejected"] += 1
|
|
1012
|
+
self._update_position_history(icao, lat, lon, timestamp)
|
|
1013
|
+
else:
|
|
1014
|
+
self._bootstrap_accumulate(paired_dicts, icao, lat, lon, timestamp)
|
|
1015
|
+
return
|
|
654
1016
|
|
|
655
|
-
# No
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1017
|
+
# No fresh opposite — append this frame to its own parity deque,
|
|
1018
|
+
# keeping a reference to its result dict for later retro-fill.
|
|
1019
|
+
deque = this_pending.setdefault(icao, [])
|
|
1020
|
+
deque.append((timestamp, cpr_lat, cpr_lon, result))
|
|
1021
|
+
self._stats["pending_pairs"] += 1
|
|
659
1022
|
|
|
660
1023
|
def _resolve_pair(
|
|
661
1024
|
self,
|
|
@@ -752,6 +1115,7 @@ class PipeDecoder:
|
|
|
752
1115
|
self._pending_odd.clear()
|
|
753
1116
|
self._trusted_icaos.clear()
|
|
754
1117
|
self._adsb_altitude.clear()
|
|
1118
|
+
self._adsb_velocity.clear()
|
|
755
1119
|
self._position_history.clear()
|
|
756
1120
|
self._bootstrap.clear()
|
|
757
1121
|
for k in self._stats:
|
|
@@ -224,8 +224,7 @@ def infer(
|
|
|
224
224
|
|
|
225
225
|
if df != 20 and df != 21:
|
|
226
226
|
raise ValueError(
|
|
227
|
-
"infer() only supports DF 17/18 (ADS-B) or DF 20/21 (Comm-B),"
|
|
228
|
-
f" got df={df}"
|
|
227
|
+
f"infer() only supports DF 17/18 (ADS-B) or DF 20/21 (Comm-B), got df={df}"
|
|
229
228
|
)
|
|
230
229
|
|
|
231
230
|
if payload == 0:
|
|
@@ -276,9 +275,7 @@ def infer(
|
|
|
276
275
|
)
|
|
277
276
|
pre = candidates[:first_heuristic_idx]
|
|
278
277
|
tail = [
|
|
279
|
-
c
|
|
280
|
-
for c in candidates[first_heuristic_idx:]
|
|
281
|
-
if c not in ("5,0", "6,0")
|
|
278
|
+
c for c in candidates[first_heuristic_idx:] if c not in ("5,0", "6,0")
|
|
282
279
|
]
|
|
283
280
|
candidates = pre + scored + tail
|
|
284
281
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|