pyModeS 3.1.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.
Files changed (60) hide show
  1. {pymodes-3.1.0 → pymodes-3.3.0}/PKG-INFO +1 -1
  2. {pymodes-3.1.0 → pymodes-3.3.0}/pyproject.toml +2 -1
  3. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_pipe.py +425 -61
  4. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/_infer.py +84 -19
  5. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds45.py +10 -0
  6. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/commb.py +3 -1
  7. {pymodes-3.1.0 → pymodes-3.3.0}/.gitignore +0 -0
  8. {pymodes-3.1.0 → pymodes-3.3.0}/CHANGELOG.md +0 -0
  9. {pymodes-3.1.0 → pymodes-3.3.0}/LICENSE +0 -0
  10. {pymodes-3.1.0 → pymodes-3.3.0}/README.md +0 -0
  11. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/__init__.py +0 -0
  12. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_aero.py +0 -0
  13. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_altcode.py +0 -0
  14. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_bits.py +0 -0
  15. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_callsign.py +0 -0
  16. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_idcode.py +0 -0
  17. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_schema.py +0 -0
  18. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_uncertainty.py +0 -0
  19. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/_v2_removed.py +0 -0
  20. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/__init__.py +0 -0
  21. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/__main__.py +0 -0
  22. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/_args.py +0 -0
  23. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/_sink.py +0 -0
  24. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/_source.py +0 -0
  25. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/_tui.py +0 -0
  26. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/decode.py +0 -0
  27. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/cli/live.py +0 -0
  28. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/core.py +0 -0
  29. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/data/__init__.py +0 -0
  30. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/data/airports.py +0 -0
  31. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/__init__.py +0 -0
  32. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/_base.py +0 -0
  33. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/acas.py +0 -0
  34. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/adsb.py +0 -0
  35. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/allcall.py +0 -0
  36. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/__init__.py +0 -0
  37. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/_helpers.py +0 -0
  38. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds05.py +0 -0
  39. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds06.py +0 -0
  40. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds08.py +0 -0
  41. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds09.py +0 -0
  42. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds10.py +0 -0
  43. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds17.py +0 -0
  44. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds20.py +0 -0
  45. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds30.py +0 -0
  46. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds40.py +0 -0
  47. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds44.py +0 -0
  48. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds50.py +0 -0
  49. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds60.py +0 -0
  50. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds61.py +0 -0
  51. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds62.py +0 -0
  52. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/bds/bds65.py +0 -0
  53. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/decoder/surv.py +0 -0
  54. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/errors.py +0 -0
  55. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/message.py +0 -0
  56. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/position/__init__.py +0 -0
  57. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/position/_airports.py +0 -0
  58. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/position/_cpr.py +0 -0
  59. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/py.typed +0 -0
  60. {pymodes-3.1.0 → pymodes-3.3.0}/src/pyModeS/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyModeS
3
- Version: 3.1.0
3
+ Version: 3.3.0
4
4
  Summary: Python Mode-S and ADS-B Decoder
5
5
  Project-URL: homepage, https://mode-s.org
6
6
  Project-URL: repository, https://github.com/junzis/pyModeS
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyModeS"
3
- version = "3.1.0"
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
- self._pending_even: dict[str, tuple[float, int, int]] = {}
197
- self._pending_odd: dict[str, tuple[float, int, int]] = {}
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
- self._bootstrap: dict[str, list[tuple[float, float, float, Decoded]]] = {}
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 frames
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
- stale = [icao for icao, (t, _, _) in pending.items() if t < cutoff]
386
- for icao in stale:
387
- pending.pop(icao, None)
388
- self._stats["pending_pairs"] = max(0, self._stats["pending_pairs"] - 1)
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, result_dict = candidates[i]
526
- # Retroactively emit the resolved position on the held
527
- # result dict so callers who kept it (e.g. batch-mode
528
- # consumers via ``pms.decode(list)``) now see a valid
529
- # lat/lon.
530
- result_dict["latitude"] = lat
531
- result_dict["longitude"] = lon
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
- result: Decoded,
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 the
548
- result (we don't emit unverified positions) and — when the buffer
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, result))
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 the result so callers can see
878
+ # the CPR raw fields remain on each result so callers can see
555
879
  # that a pair was seen.
556
- result["latitude"] = None
557
- result["longitude"] = None
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, result_dict = buf[0]
586
- result_dict["latitude"] = lat
587
- result_dict["longitude"] = lon
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
- opposite = other_pending.get(icao)
623
- if opposite is not None:
624
- opp_t, opp_lat, opp_lon = opposite
625
- if abs(timestamp - opp_t) <= self._pair_window:
626
- # Pair found — resolve and pop
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
- result,
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
- # Position-filter dispatch: bootstrapping ICAOs have
637
- # their candidates held in `_bootstrap` until a cluster
638
- # analysis picks a good anchor; locked ICAOs fall
639
- # through to the motion-consistency check.
640
- lat = result.get("latitude")
641
- lon = result.get("longitude")
642
- if lat is not None and lon is not None:
643
- if icao in self._position_history:
644
- if not self._motion_consistent(icao, lat, lon, timestamp):
645
- result["latitude"] = None
646
- result["longitude"] = None
647
- self._stats["position_rejected"] += 1
648
- self._update_position_history(icao, lat, lon, timestamp)
649
- else:
650
- self._bootstrap_accumulate(result, icao, lat, lon, timestamp)
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
- self._stats["pending_pairs"] = max(0, self._stats["pending_pairs"] - 1)
653
- return
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 pair (or out of window) — store this frame as pending
656
- if icao not in this_pending:
657
- self._stats["pending_pairs"] += 1
658
- this_pending[icao] = (timestamp, cpr_lat, cpr_lon)
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:
@@ -1,6 +1,12 @@
1
- """Two-phase BDS inference dispatch for Comm-B (DF20/21).
1
+ """BDS inference dispatch for ADS-B ES (DF17/18) and Comm-B (DF20/21).
2
2
 
3
- Phase 1 -- format-ID fast path
3
+ ADS-B ES path (DF 17/18)
4
+ ES ME payloads carry an explicit Type Code (TC) in the top 5 bits.
5
+ ``infer()`` extracts TC and maps it to the ICAO-standard BDS-like
6
+ code via ``_infer_es()``. This path ignores ``include_meteo`` and
7
+ ``known`` (the TC is deterministic; no heuristic is needed).
8
+
9
+ Phase 1 -- format-ID fast path (Comm-B only)
4
10
  The top 8 bits of the payload (bits 0-7) contain an explicit
5
11
  BDS identifier for registers 1,0 / 2,0 / 3,0. We check these
6
12
  first: the validator is still run so reserved-bit and field-range
@@ -12,7 +18,7 @@ Phase 1 -- format-ID fast path
12
18
  map. Its entry uses ``None`` as the expected prefix and runs its
13
19
  validator directly.
14
20
 
15
- Phase 2 -- heuristic slow path
21
+ Phase 2 -- heuristic slow path (Comm-B only)
16
22
  Registers 4,0 / 5,0 / 6,0 have no format identifier and must be
17
23
  detected by status-bit and range heuristics. Each heuristic
18
24
  validator is run unconditionally and every match is added to the
@@ -23,7 +29,7 @@ Phase 2 -- heuristic slow path
23
29
  and produce false positives unless the caller opts in via
24
30
  ``include_meteo=True``.
25
31
 
26
- Phase 3 -- reference-assisted disambiguation
32
+ Phase 3 -- reference-assisted disambiguation (Comm-B only)
27
33
  When multiple heuristic candidates (5,0 / 6,0) survive Phase 2
28
34
  and the caller passes ``known=`` aircraft state (groundspeed,
29
35
  track, heading, IAS, mach), each candidate is decoded and
@@ -135,33 +141,92 @@ def matches(bds_code: str, payload: int) -> bool:
135
141
  return validator(payload)
136
142
 
137
143
 
144
+ def _infer_es(payload: int) -> list[str]:
145
+ """Classify an ADS-B ES (DF 17/18) payload by Type Code.
146
+
147
+ TC is the top 5 bits of the 56-bit ME field: ``(payload >> 51) & 0x1F``.
148
+
149
+ Returns:
150
+ A single-element list with the ICAO-standard BDS-like code, or
151
+ an empty list for reserved / unused Type Codes (0, 23-27, 30).
152
+ """
153
+ tc = (payload >> 51) & 0x1F
154
+ if 1 <= tc <= 4:
155
+ return ["0,8"]
156
+ if 5 <= tc <= 8:
157
+ return ["0,6"]
158
+ if 9 <= tc <= 18 or 20 <= tc <= 22:
159
+ return ["0,5"]
160
+ if tc == 19:
161
+ return ["0,9"]
162
+ if tc == 28:
163
+ return ["6,1"]
164
+ if tc == 29:
165
+ return ["6,2"]
166
+ if tc == 31:
167
+ return ["6,5"]
168
+ return []
169
+
170
+
138
171
  def infer(
139
172
  payload: int,
173
+ df: int,
140
174
  *,
141
175
  include_meteo: bool = False,
142
176
  known: dict[str, Any] | None = None,
143
177
  ) -> list[str]:
144
- """Return plausible BDS codes for a Comm-B payload.
178
+ """Return plausible BDS codes for a payload, routing on DF.
145
179
 
146
- The first element (when non-empty) is the best candidate -- either
147
- the format-ID'd register (Phase 1) or the first heuristic match
148
- (Phase 2). Any additional heuristic matches follow.
180
+ **ADS-B ES path (DF 17 or 18)**
181
+ The Type Code in the top 5 bits of the 56-bit ME field
182
+ unambiguously identifies the message type. ``include_meteo``
183
+ and ``known`` are ignored on this path -- the TC is
184
+ deterministic and no heuristic scoring is needed.
185
+
186
+ **Comm-B path (DF 20 or 21)**
187
+ Three-phase scan:
188
+
189
+ Phase 1 -- format-ID fast path.
190
+ The first element (when non-empty) is the best candidate --
191
+ either the format-ID'd register (Phase 1) or the first
192
+ heuristic match (Phase 2). Any additional heuristic matches
193
+ follow.
194
+
195
+ Phase 2 -- heuristic slow path.
196
+ BDS 4,0 / 5,0 / 6,0 validators run unconditionally.
197
+ BDS 4,4 / 4,5 are tried only when ``include_meteo=True``.
198
+
199
+ Phase 3 -- known-state disambiguation.
200
+ When multiple heuristic candidates (BDS 5,0 / 6,0) survive
201
+ Phase 2, each is scored against the known aircraft state and
202
+ the best match is moved to the front.
149
203
 
150
204
  Args:
151
- payload: The 56-bit Comm-B payload as a Python int.
152
- include_meteo: When True, also try BDS 4,4 and 4,5 heuristic
153
- validators. Defaults to False because these share bit
154
- patterns with non-meteorological traffic.
155
- known: Optional aircraft state for reference-assisted
156
- disambiguation. When multiple heuristic candidates
157
- (BDS 5,0 / 6,0) survive Phase 2, each is scored against
158
- the known state and the best match is moved to the front
159
- of the heuristic block.
205
+ payload: The 56-bit payload (ME field for ES, MB field for
206
+ Comm-B) as a Python int.
207
+ df: Downlink Format. Must be 17, 18 (ADS-B ES) or 20, 21
208
+ (Comm-B). Any other value raises ``ValueError``.
209
+ include_meteo: Comm-B only. When True, also try BDS 4,4 and
210
+ 4,5 heuristic validators. Ignored for DF 17/18.
211
+ known: Comm-B only. Optional aircraft state for Phase 3
212
+ disambiguation of BDS 5,0 / 6,0. Ignored for DF 17/18.
160
213
 
161
214
  Returns:
162
- A list of BDS code strings (e.g. "1,0", "5,0"). Empty list if
163
- nothing plausible matched or `payload == 0`.
215
+ A list of BDS code strings (e.g. ``"1,0"``, ``"5,0"``,
216
+ ``"0,5"``). Empty list if nothing plausible matched or
217
+ ``payload == 0``.
218
+
219
+ Raises:
220
+ ValueError: If ``df`` is not 17, 18, 20, or 21.
164
221
  """
222
+ if df == 17 or df == 18:
223
+ return _infer_es(payload)
224
+
225
+ if df != 20 and df != 21:
226
+ raise ValueError(
227
+ f"infer() only supports DF 17/18 (ADS-B) or DF 20/21 (Comm-B), got df={df}"
228
+ )
229
+
165
230
  if payload == 0:
166
231
  return []
167
232
 
@@ -33,6 +33,7 @@ where it decoded temperature unconditionally.
33
33
  from typing import Any
34
34
 
35
35
  from pyModeS.decoder.bds._helpers import signed, wrong_status
36
+ from pyModeS.decoder.bds.bds17 import is_bds17
36
37
 
37
38
 
38
39
  def is_bds45(payload: int) -> bool:
@@ -40,6 +41,15 @@ def is_bds45(payload: int) -> bool:
40
41
  if payload == 0:
41
42
  return False
42
43
 
44
+ # Disambiguate from BDS 1,7 (GICB Capability Report). A BDS 1,7
45
+ # payload has 32 trailing zero bits, which trivially satisfies
46
+ # 4,5's 5-bit reserved check; its top-24 capability map then
47
+ # passes 4,5's status/range heuristics often enough to cause
48
+ # ~97% of real BDS 1,7 traffic to match here. The 1,7 pattern
49
+ # (bit 6 set + 32 trailing zeros) is strictly more specific.
50
+ if is_bds17(payload):
51
+ return False
52
+
43
53
  # Reserved bits 51-55 must all be zero.
44
54
  if (payload & 0x1F) != 0:
45
55
  return False
@@ -74,7 +74,9 @@ class CommB(DecoderBase):
74
74
  # BDS inference. `known` is threaded through for Phase 3
75
75
  # disambiguation of BDS 5,0 / 6,0 when the caller supplies
76
76
  # aircraft state.
77
- candidates = _infer.infer(self._payload, include_meteo=False, known=known)
77
+ candidates = _infer.infer(
78
+ self._payload, self._df, include_meteo=False, known=known
79
+ )
78
80
  if not candidates:
79
81
  return result
80
82
 
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