pyModeS 3.3.0__tar.gz → 3.4.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 (62) hide show
  1. {pymodes-3.3.0 → pymodes-3.4.0}/CHANGELOG.md +25 -1
  2. {pymodes-3.3.0 → pymodes-3.4.0}/PKG-INFO +16 -16
  3. {pymodes-3.3.0 → pymodes-3.4.0}/README.md +18 -18
  4. {pymodes-3.3.0 → pymodes-3.4.0}/pyproject.toml +1 -2
  5. pymodes-3.4.0/scripts/README.md +58 -0
  6. pymodes-3.4.0/scripts/data/README.md +37 -0
  7. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_pipe.py +148 -14
  8. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/data/airports.py +1 -1
  9. {pymodes-3.3.0 → pymodes-3.4.0}/.gitignore +0 -0
  10. {pymodes-3.3.0 → pymodes-3.4.0}/LICENSE +0 -0
  11. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/__init__.py +0 -0
  12. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_aero.py +0 -0
  13. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_altcode.py +0 -0
  14. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_bits.py +0 -0
  15. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_callsign.py +0 -0
  16. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_idcode.py +0 -0
  17. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_schema.py +0 -0
  18. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_uncertainty.py +0 -0
  19. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_v2_removed.py +0 -0
  20. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/__init__.py +0 -0
  21. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/__main__.py +0 -0
  22. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_args.py +0 -0
  23. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_sink.py +0 -0
  24. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_source.py +0 -0
  25. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_tui.py +0 -0
  26. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/decode.py +0 -0
  27. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/live.py +0 -0
  28. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/core.py +0 -0
  29. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/data/__init__.py +0 -0
  30. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/__init__.py +0 -0
  31. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/_base.py +0 -0
  32. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/acas.py +0 -0
  33. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/adsb.py +0 -0
  34. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/allcall.py +0 -0
  35. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/__init__.py +0 -0
  36. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/_helpers.py +0 -0
  37. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/_infer.py +0 -0
  38. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds05.py +0 -0
  39. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds06.py +0 -0
  40. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds08.py +0 -0
  41. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds09.py +0 -0
  42. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds10.py +0 -0
  43. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds17.py +0 -0
  44. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds20.py +0 -0
  45. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds30.py +0 -0
  46. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds40.py +0 -0
  47. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds44.py +0 -0
  48. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds45.py +0 -0
  49. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds50.py +0 -0
  50. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds60.py +0 -0
  51. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds61.py +0 -0
  52. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds62.py +0 -0
  53. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds65.py +0 -0
  54. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/commb.py +0 -0
  55. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/surv.py +0 -0
  56. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/errors.py +0 -0
  57. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/message.py +0 -0
  58. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/__init__.py +0 -0
  59. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/_airports.py +0 -0
  60. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/_cpr.py +0 -0
  61. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/py.typed +0 -0
  62. {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/util.py +0 -0
@@ -5,6 +5,30 @@ All notable changes to pyModeS are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [3.4.0] — 2026-07-13
9
+
10
+ ### Fixed
11
+
12
+ - `PipeDecoder` no longer scans every per-ICAO cache on every timestamped
13
+ message. Full TTL eviction is throttled to a configurable one-second
14
+ interval by default, capped at the TTL itself. A bounded per-ICAO check
15
+ still expires state exactly before it is used, so sweep throttling cannot
16
+ change BDS inference at the TTL boundary. Set `eviction_interval=0` for
17
+ the former sweep-per-message memory-reclamation behavior.
18
+ - Timestamped messages that contain no fields used for stateful
19
+ disambiguation no longer create empty per-ICAO state entries. Existing
20
+ useful state is still refreshed when such a message arrives.
21
+ - State updates skip field extraction for downlink formats that cannot
22
+ contribute to stateful inference.
23
+
24
+ ### Added
25
+
26
+ - `scripts/stream_filtered.py`, a configurable live/replay Beast example
27
+ demonstrating high-volume DF/typecode prefiltering before `PipeDecoder`.
28
+ - Reproducible version-isolated benchmarks for mixed live traffic and
29
+ high-cardinality streaming, including checked-in reports and the public
30
+ Beast capture used by the mixed-traffic benchmark.
31
+
8
32
  ## [3.1.0] — 2026-04-14
9
33
 
10
34
  Streaming-robustness release. Adds defences against phantom positions
@@ -98,7 +122,7 @@ the migration guide.
98
122
  `bin2hex`, `crc`, `df`, `icao`, `typecode`, `altcode`, `idcode`,
99
123
  `cprNL`. Thin wrappers over `_bits.py`, `_altcode.py`, `_idcode.py`
100
124
  and `position/_cpr.py` with no logic duplication.
101
- - `scripts/smoke_test.sh` installs the freshly built wheel
125
+ - `scripts/smoke_test_wheel.sh` installs the freshly built wheel
102
126
  into a clean Python 3.12 venv and verifies the public API end to
103
127
  end (single-message decode, PipeDecoder baseline, batch CPR pair
104
128
  resolution, error-dict on malformed input).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyModeS
3
- Version: 3.3.0
3
+ Version: 3.4.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
@@ -205,24 +205,24 @@ the full command reference.
205
205
 
206
206
  ## Performance
207
207
 
208
- Measured on jet1090's
209
- [`long_flight.csv`](https://github.com/xoolive/jet1090/blob/master/crates/rs1090/data/long_flight.csv)
210
- (172,432 Beast-format messages, 7 runs × 1 loop, mean timings,
211
- single-core only):
208
+ The committed benchmarks run each released/working-tree version in an
209
+ isolated environment and reject output mismatches before reporting timings.
210
+ On the captured 176,612-frame mixed Beast feed, the header-prefiltered v3 path
211
+ processes 85,963 incoming messages/s:
212
212
 
213
- | Decoder | Wall time | Throughput | vs pyModeS v3 |
214
- |---|---|---|---|
215
- | **pyModeS v3 (pure Python)** | **2.06s ± 0.01** | **83,549 msg/s** | **1.00×** |
216
- | pyModeS 2.21.1 (Python with compiled C) | 5.03s ± 0.01 | 34,303 msg/s | 0.41× |
217
- | rs1090 (Rust) | 5.60s ± 0.01 | 30,798 msg/s | 0.37× |
218
- | pyModeS 2.21.1 (Python) | 9.09s ± 0.02 | 18,959 msg/s | 0.23× |
213
+ | Decoder path | Throughput |
214
+ |---|---:|
215
+ | pyModeS 2.21.1 selective helpers | 52,998 msg/s |
216
+ | released v3.3.0 `PipeDecoder` | 3,897 msg/s |
217
+ | v3.4.0 `PipeDecoder` | 49,243 msg/s |
218
+ | v3.4.0 + header prefilter | **85,963 msg/s** |
219
219
 
220
- pyModeS v3 is **2.44× faster** than `pyModeS 2.21.1`'s compiled C
221
- extension, **4.41× faster** than `pyModeS 2.21.1` pure-Python, and
222
- **2.71× faster** than rs1090's single-core Rust — all while remaining
223
- pure Python with no C/Cython build.
220
+ On the 2,000-aircraft synthetic PipeDecoder workload, v3.4.0 processes
221
+ 38,838 msg/s versus v2's 39,604 msg/s, with identical normalized output.
224
222
 
225
- Reproduce with `scripts/benchmark_decode.py`.
223
+ See [`scripts/README.md`](./scripts/README.md) for the reproducible commands
224
+ and [`scripts/benchmark_results/`](./scripts/benchmark_results/) for the full
225
+ methodology, timings, and output digests.
226
226
 
227
227
  ## Supported messages
228
228
 
@@ -178,24 +178,24 @@ the full command reference.
178
178
 
179
179
  ## Performance
180
180
 
181
- Measured on jet1090's
182
- [`long_flight.csv`](https://github.com/xoolive/jet1090/blob/master/crates/rs1090/data/long_flight.csv)
183
- (172,432 Beast-format messages, 7 runs × 1 loop, mean timings,
184
- single-core only):
185
-
186
- | Decoder | Wall time | Throughput | vs pyModeS v3 |
187
- |---|---|---|---|
188
- | **pyModeS v3 (pure Python)** | **2.06s ± 0.01** | **83,549 msg/s** | **1.00×** |
189
- | pyModeS 2.21.1 (Python with compiled C) | 5.03s ± 0.01 | 34,303 msg/s | 0.41× |
190
- | rs1090 (Rust) | 5.60s ± 0.01 | 30,798 msg/s | 0.37× |
191
- | pyModeS 2.21.1 (Python) | 9.09s ± 0.02 | 18,959 msg/s | 0.23× |
192
-
193
- pyModeS v3 is **2.44× faster** than `pyModeS 2.21.1`'s compiled C
194
- extension, **4.41× faster** than `pyModeS 2.21.1` pure-Python, and
195
- **2.71× faster** than rs1090's single-core Rust — all while remaining
196
- pure Python with no C/Cython build.
197
-
198
- Reproduce with `scripts/benchmark_decode.py`.
181
+ The committed benchmarks run each released/working-tree version in an
182
+ isolated environment and reject output mismatches before reporting timings.
183
+ On the captured 176,612-frame mixed Beast feed, the header-prefiltered v3 path
184
+ processes 85,963 incoming messages/s:
185
+
186
+ | Decoder path | Throughput |
187
+ |---|---:|
188
+ | pyModeS 2.21.1 selective helpers | 52,998 msg/s |
189
+ | released v3.3.0 `PipeDecoder` | 3,897 msg/s |
190
+ | v3.4.0 `PipeDecoder` | 49,243 msg/s |
191
+ | v3.4.0 + header prefilter | **85,963 msg/s** |
192
+
193
+ On the 2,000-aircraft synthetic PipeDecoder workload, v3.4.0 processes
194
+ 38,838 msg/s versus v2's 39,604 msg/s, with identical normalized output.
195
+
196
+ See [`scripts/README.md`](./scripts/README.md) for the reproducible commands
197
+ and [`scripts/benchmark_results/`](./scripts/benchmark_results/) for the full
198
+ methodology, timings, and output digests.
199
199
 
200
200
  ## Supported messages
201
201
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyModeS"
3
- version = "3.3.0"
3
+ version = "3.4.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" }
@@ -57,7 +57,6 @@ dev = [
57
57
  [tool.ruff]
58
58
  target-version = "py311"
59
59
  line-length = 88
60
- extend-exclude = ["scripts/bench_pms_v2.py"]
61
60
 
62
61
  [tool.ruff.lint]
63
62
  select = [
@@ -0,0 +1,58 @@
1
+ # Maintainer scripts
2
+
3
+ Run these commands from the repository root. Scripts are grouped by purpose
4
+ through their names; generated benchmark reports live in
5
+ `scripts/benchmark_results/`, and committed input data lives in
6
+ `scripts/data/`.
7
+
8
+ ## Streaming example
9
+
10
+ - `stream_filtered.py` — configurable high-throughput example using a live
11
+ Mode-S Beast feed or the committed replay capture. Defaults to DF17/20/21
12
+ while excluding ADS-B typecodes 28/29/31.
13
+
14
+ ```bash
15
+ uv run python scripts/stream_filtered.py \
16
+ --network airsquitter.lr.tudelft.nl:10006 > decoded.jsonl
17
+ ```
18
+
19
+ ## Benchmarks
20
+
21
+ - `benchmark_mixed_traffic.py` — isolated v2, released-v3, updated-v3, and
22
+ prefiltered-v3 comparison on mixed captured traffic. Writes
23
+ `benchmark_results/mixed_traffic.md` by default.
24
+ - `benchmark_pipe.py` — synthetic high-cardinality PipeDecoder comparison.
25
+ Writes `benchmark_results/pipe.md` by default.
26
+ - `benchmark_cpr.py` — focused CPR primitive comparison against the v2 NumPy
27
+ implementation.
28
+
29
+ ```bash
30
+ uv run python scripts/benchmark_mixed_traffic.py
31
+ uv run python scripts/benchmark_pipe.py
32
+ uv run --with numpy python scripts/benchmark_cpr.py
33
+ ```
34
+
35
+ The version-comparison benchmarks build the working tree and resolve released
36
+ versions from PyPI in isolated temporary environments. They require network
37
+ access unless those packages are already cached.
38
+
39
+ ## Generated data
40
+
41
+ - `generate_airport_db.py` — refreshes `src/pyModeS/data/airports.py` from
42
+ OurAirports.
43
+ - `generate_v2_fixture.py` — regenerates the committed v2.21.1 golden oracle.
44
+
45
+ ```bash
46
+ uv run python scripts/generate_airport_db.py
47
+ uv run --no-project --with pyModeS==2.21.1 \
48
+ python scripts/generate_v2_fixture.py
49
+ ```
50
+
51
+ ## Release smoke tests
52
+
53
+ - `smoke_test_wheel.sh [wheel]` — installs a built wheel in a clean virtual
54
+ environment and checks the public API.
55
+ - `smoke_test_cli.sh` — checks CLI decoding and a short live Beast connection.
56
+
57
+ The CLI smoke test requires network access. The wheel smoke test uses the most
58
+ recent `dist/pymodes-*.whl` when no path is supplied.
@@ -0,0 +1,37 @@
1
+ # Benchmark capture data
2
+
3
+ ## `airsquitter_2026-07-13_120s.csv.gz`
4
+
5
+ Two-minute capture from the public TU Delft AirSquitter Mode-S Beast feed:
6
+
7
+ - Source: `airsquitter.lr.tudelft.nl:10006` (TCP, Beast binary)
8
+ - Capture date: 2026-07-13
9
+ - Frames: 176,612
10
+ - Uncompressed format: `timestamp,hex_message`, one frame per line
11
+ - Compressed size: approximately 2 MiB
12
+ - SHA-256: `fa433eb8dc2ec9cf6ebd40c1bf0a095ae753960d5e8c42ae6da15cc28eb3baea`
13
+
14
+ The timestamps are reconstructed by `pyModeS.cli._source.NetworkSource`
15
+ from the Beast MLAT counter, anchored to wall-clock time when the TCP data
16
+ is received. Mode A/C and Beast status frames are omitted because
17
+ `NetworkSource` yields only Mode-S short and long frames.
18
+
19
+ Capture and compact conversion:
20
+
21
+ ```bash
22
+ timeout --signal=INT 120 \
23
+ modes live \
24
+ --network airsquitter.lr.tudelft.nl:10006 \
25
+ --quiet \
26
+ --dump-to /tmp/pymodes-live-mixed.jsonl
27
+
28
+ jq -r '"\(.timestamp),\(.raw_msg)"' \
29
+ /tmp/pymodes-live-mixed.jsonl \
30
+ | gzip -9 > scripts/data/airsquitter_2026-07-13_120s.csv.gz
31
+ ```
32
+
33
+ Run the associated isolated three-version benchmark with:
34
+
35
+ ```bash
36
+ python scripts/benchmark_mixed_traffic.py
37
+ ```
@@ -82,6 +82,12 @@ _DECODED_TO_KNOWN: dict[str, str] = {
82
82
  "altitude": "altitude", # informational; not in scoring tables
83
83
  }
84
84
 
85
+ # Only these downlink formats can currently emit fields consumed by
86
+ # stateful Comm-B inference. Keeping the set explicit lets the hot path
87
+ # skip nine result-dict lookups for DF5/DF11 and unsupported DFs while
88
+ # still refreshing useful state that already exists for the ICAO.
89
+ _STATEFUL_DFS = frozenset((0, 4, 16, 17, 18, 20, 21))
90
+
85
91
  # BDS-payload fields cleared from a DF20 result that fails altitude
86
92
  # cross-check. The `altitude` field itself is preserved (it's the
87
93
  # 13-bit AC-code that triggered the mismatch — callers want it to
@@ -171,18 +177,25 @@ class PipeDecoder:
171
177
  pair_window: Maximum age difference (seconds) between an even
172
178
  and odd CPR frame for them to count as a pair. Default 10s.
173
179
  eviction_ttl: Per-ICAO state and pending CPR frames older than
174
- this many seconds are dropped lazily on the next decode
175
- call. Default 300s (5 minutes).
180
+ this many seconds are ignored immediately for the aircraft
181
+ being decoded and dropped globally by a periodic sweep.
182
+ Default 300s (5 minutes).
183
+ eviction_interval: Minimum timestamp gap between full cache
184
+ eviction sweeps. The effective interval is capped at
185
+ ``eviction_ttl``. Default 1s; set to 0 to sweep on every
186
+ timestamped decode call.
176
187
  """
177
188
 
178
189
  __slots__ = (
179
190
  "_adsb_altitude",
180
191
  "_adsb_velocity",
181
192
  "_bootstrap",
193
+ "_eviction_interval",
182
194
  "_eviction_ttl",
183
195
  "_full_dict",
184
196
  "_max_speed_kmps",
185
197
  "_motion_margin_km",
198
+ "_next_eviction_at",
186
199
  "_pair_window",
187
200
  "_pending_even",
188
201
  "_pending_odd",
@@ -200,13 +213,23 @@ class PipeDecoder:
200
213
  full_dict: bool = False,
201
214
  pair_window: float = 10.0,
202
215
  eviction_ttl: float = 300.0,
216
+ eviction_interval: float = 1.0,
203
217
  max_speed_kt: float = 1500.0,
204
218
  motion_margin_km: float = 2.0,
205
219
  ) -> None:
206
220
  self._surface_ref = surface_ref
207
221
  self._full_dict = full_dict
208
222
  self._pair_window = pair_window
223
+ if eviction_ttl < 0:
224
+ raise ValueError("eviction_ttl must be >= 0")
225
+ if eviction_interval < 0:
226
+ raise ValueError("eviction_interval must be >= 0")
209
227
  self._eviction_ttl = eviction_ttl
228
+ # Never delay a sweep by longer than the TTL itself. This keeps
229
+ # sub-second TTL configurations useful while avoiding an O(cache)
230
+ # walk for every message in normal high-rate streams.
231
+ self._eviction_interval = min(eviction_interval, eviction_ttl)
232
+ self._next_eviction_at = float("-inf")
210
233
  # 1500 kt is ~2x typical airliner cruise — loose enough not to
211
234
  # reject fast business jets or wind-boosted ground speed, tight
212
235
  # enough that a phantom position hundreds of km away cannot
@@ -284,7 +307,7 @@ class PipeDecoder:
284
307
  into state for future calls.
285
308
  """
286
309
  if timestamp is not None:
287
- self._evict_expired(timestamp)
310
+ self._maybe_evict_expired(timestamp)
288
311
  self._stats["total"] += 1
289
312
 
290
313
  try:
@@ -293,13 +316,23 @@ class PipeDecoder:
293
316
  return Decoded({"error": str(e), "raw_msg": msg})
294
317
 
295
318
  # Look up prior state for this ICAO so the decoder can use it
296
- # for Comm-B BDS 5,0/6,0 disambiguation. Filter out housekeeping
297
- # keys (those starting with _) before passing as `known=`.
319
+ # for Comm-B BDS 5,0/6,0 disambiguation. Remove the internal
320
+ # timestamp before passing the state as `known=`.
298
321
  icao = message.icao
299
- prior_state = self._state.get(icao)
322
+ if timestamp is not None:
323
+ # Full sweeps are deliberately throttled, but stale state must
324
+ # never influence the current aircraft's decode. This bounded
325
+ # per-ICAO prune preserves exact TTL semantics without bringing
326
+ # back an O(all active aircraft) operation on every message.
327
+ prior_state = self._evict_icao_expired(icao, message.df, timestamp)
328
+ else:
329
+ prior_state = self._state.get(icao)
300
330
  known: dict[str, Any] | None
301
331
  if prior_state:
302
- known = {k: v for k, v in prior_state.items() if not k.startswith("_")}
332
+ # dict.copy() runs in C and is measurably cheaper in this hot
333
+ # path than filtering the small state dict with a Python loop.
334
+ known = prior_state.copy()
335
+ known.pop("_last_seen", None)
303
336
  # Derive the BDS 6,0 scoring fields (ias, mach) and the
304
337
  # BDS 5,0 tas slot from cached groundspeed + altitude
305
338
  # when the caller hasn't supplied observed values. Most
@@ -445,7 +478,7 @@ class PipeDecoder:
445
478
  return result
446
479
 
447
480
  self._handle_cpr_pair(result, icao, timestamp)
448
- self._update_state(icao, result, timestamp)
481
+ self._update_state(icao, message.df, result, timestamp)
449
482
  return result
450
483
 
451
484
  def _reject_on_altitude_mismatch(
@@ -671,13 +704,28 @@ class PipeDecoder:
671
704
  self._stats["velocity_mismatch"] += 1
672
705
  return True
673
706
 
707
+ def _maybe_evict_expired(self, now: float) -> None:
708
+ """Run a full eviction sweep only when its interval is due.
709
+
710
+ A sweep touches every live per-ICAO cache, so running it for every
711
+ message makes decode cost proportional to the number of active
712
+ aircraft. At high message rates that dominates the actual decoder.
713
+ Timestamp throttling bounds stale memory retention by at most
714
+ ``eviction_interval`` while making sweep cost amortized. Exact
715
+ per-aircraft expiry happens separately before cached state is used.
716
+ """
717
+ if now < self._next_eviction_at:
718
+ return
719
+ self._evict_expired(now)
720
+ self._next_eviction_at = now + self._eviction_interval
721
+
674
722
  def _evict_expired(self, now: float) -> None:
675
723
  """Drop state and pending CPR entries older than eviction_ttl.
676
724
 
677
- Runs lazily at the start of each decode() call when a timestamp
678
- is provided. The trusted ICAO set is intentionally NOT evicted —
679
- once a plain-text DF17/18 has been seen for an ICAO, it remains
680
- trusted for the lifetime of the PipeDecoder (until reset()).
725
+ Called periodically by :meth:`_maybe_evict_expired`. The trusted
726
+ ICAO set is intentionally NOT evicted — once a plain-text DF17/18
727
+ has been seen for an ICAO, it remains trusted for the lifetime of
728
+ the PipeDecoder (until reset()).
681
729
  """
682
730
  cutoff = now - self._eviction_ttl
683
731
 
@@ -742,6 +790,76 @@ class PipeDecoder:
742
790
  else:
743
791
  del self._bootstrap[icao]
744
792
 
793
+ def _evict_icao_expired(
794
+ self, icao: str, downlink_format: int, now: float
795
+ ) -> dict[str, Any] | None:
796
+ """Drop expired caches and return usable state for one ICAO.
797
+
798
+ Periodic full sweeps keep total memory bounded, but their interval
799
+ must not extend the semantic lifetime of state used for Comm-B
800
+ inference or validation. Every collection touched here has a small,
801
+ fixed per-ICAO bound, so this remains O(1) with respect to the number
802
+ of active aircraft.
803
+ """
804
+ cutoff = now - self._eviction_ttl
805
+
806
+ state = self._state.get(icao)
807
+ if state is not None and state.get("_last_seen", float("inf")) < cutoff:
808
+ self._state.pop(icao, None)
809
+ state = None
810
+
811
+ # Short surveillance/ACAS messages cannot consume validation or
812
+ # CPR caches. Their altitude can still refresh `_state` later, but
813
+ # the state expiry above is the only exact-TTL work they need here.
814
+ if downlink_format not in (17, 18, 20, 21):
815
+ return state
816
+
817
+ if downlink_format in (17, 18, 20):
818
+ altitude_anchor = self._adsb_altitude.get(icao)
819
+ if altitude_anchor is not None and altitude_anchor[0] < cutoff:
820
+ self._adsb_altitude.pop(icao, None)
821
+
822
+ velocity_anchor = self._adsb_velocity.get(icao)
823
+ if velocity_anchor is not None and velocity_anchor[0] < cutoff:
824
+ self._adsb_velocity.pop(icao, None)
825
+
826
+ # Only ADS-B frames participate in CPR pairing, bootstrap, and
827
+ # position-history validation.
828
+ if downlink_format not in (17, 18):
829
+ return state
830
+
831
+ for pending in (self._pending_even, self._pending_odd):
832
+ entries = pending.get(icao)
833
+ if entries is None:
834
+ continue
835
+ fresh_entries = [entry for entry in entries if entry[0] >= cutoff]
836
+ dropped = len(entries) - len(fresh_entries)
837
+ if fresh_entries:
838
+ pending[icao] = fresh_entries
839
+ else:
840
+ pending.pop(icao, None)
841
+ self._stats["pending_pairs"] = max(
842
+ 0, self._stats["pending_pairs"] - dropped
843
+ )
844
+
845
+ history = self._position_history.get(icao)
846
+ if history is not None:
847
+ fresh_history = [entry for entry in history if entry[2] >= cutoff]
848
+ if fresh_history:
849
+ self._position_history[icao] = fresh_history
850
+ else:
851
+ self._position_history.pop(icao, None)
852
+
853
+ bootstrap = self._bootstrap.get(icao)
854
+ if bootstrap is not None:
855
+ fresh_bootstrap = [entry for entry in bootstrap if entry[2] >= cutoff]
856
+ if fresh_bootstrap:
857
+ self._bootstrap[icao] = fresh_bootstrap
858
+ else:
859
+ self._bootstrap.pop(icao, None)
860
+
861
+ return state
862
+
745
863
  def _motion_consistent(
746
864
  self,
747
865
  icao: str,
@@ -1074,10 +1192,17 @@ class PipeDecoder:
1074
1192
  def _update_state(
1075
1193
  self,
1076
1194
  icao: str,
1195
+ downlink_format: int,
1077
1196
  result: Decoded,
1078
1197
  timestamp: float | None,
1079
1198
  ) -> None:
1080
1199
  """Merge tracked fields from the result into per-ICAO state."""
1200
+ existing = self._state.get(icao)
1201
+ if downlink_format not in _STATEFUL_DFS:
1202
+ if existing is not None and timestamp is not None:
1203
+ existing["_last_seen"] = timestamp
1204
+ return
1205
+
1081
1206
  new_fields: dict[str, Any] = {}
1082
1207
  for decoded_key, known_key in _DECODED_TO_KNOWN.items():
1083
1208
  val = result.get(decoded_key)
@@ -1095,10 +1220,18 @@ class PipeDecoder:
1095
1220
  elif airspeed is not None and airspeed_type == "TAS":
1096
1221
  new_fields["tas"] = airspeed
1097
1222
 
1098
- if not new_fields and timestamp is None:
1223
+ if not new_fields:
1224
+ # A timestamp alone is not useful state. Creating an entry here
1225
+ # used to retain every parseable message's derived ICAO — even
1226
+ # for untracked DFs and corrupt traffic — and made the periodic
1227
+ # eviction scan much larger than the active-aircraft set.
1228
+ if existing is not None and timestamp is not None:
1229
+ existing["_last_seen"] = timestamp
1099
1230
  return
1100
1231
 
1101
- existing = self._state.setdefault(icao, {})
1232
+ if existing is None:
1233
+ existing = {}
1234
+ self._state[icao] = existing
1102
1235
  existing.update(new_fields)
1103
1236
  if timestamp is not None:
1104
1237
  existing["_last_seen"] = timestamp
@@ -1118,5 +1251,6 @@ class PipeDecoder:
1118
1251
  self._adsb_velocity.clear()
1119
1252
  self._position_history.clear()
1120
1253
  self._bootstrap.clear()
1254
+ self._next_eviction_at = float("-inf")
1121
1255
  for k in self._stats:
1122
1256
  self._stats[k] = 0
@@ -1,6 +1,6 @@
1
1
  """Curated airport database.
2
2
 
3
- Generated from OurAirports dataset by scripts/build_airport_db.py.
3
+ Generated from OurAirports dataset by scripts/generate_airport_db.py.
4
4
  Source: https://davidmegginson.github.io/ourairports-data/airports.csv
5
5
  Filter: large_airport + medium_airport with an assigned 4-letter ICAO code.
6
6
 
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