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.
- {pymodes-3.3.0 → pymodes-3.4.0}/CHANGELOG.md +25 -1
- {pymodes-3.3.0 → pymodes-3.4.0}/PKG-INFO +16 -16
- {pymodes-3.3.0 → pymodes-3.4.0}/README.md +18 -18
- {pymodes-3.3.0 → pymodes-3.4.0}/pyproject.toml +1 -2
- pymodes-3.4.0/scripts/README.md +58 -0
- pymodes-3.4.0/scripts/data/README.md +37 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_pipe.py +148 -14
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/data/airports.py +1 -1
- {pymodes-3.3.0 → pymodes-3.4.0}/.gitignore +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/LICENSE +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_aero.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_altcode.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_bits.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_callsign.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_idcode.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_schema.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_uncertainty.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/_v2_removed.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/__main__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_args.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_sink.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_source.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/_tui.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/decode.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/cli/live.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/core.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/data/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/_base.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/acas.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/adsb.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/allcall.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/_helpers.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/_infer.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds05.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds06.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds08.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds09.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds10.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds17.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds20.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds30.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds40.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds44.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds45.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds50.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds60.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds61.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds62.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/bds/bds65.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/commb.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/decoder/surv.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/errors.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/message.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/__init__.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/_airports.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/position/_cpr.py +0 -0
- {pymodes-3.3.0 → pymodes-3.4.0}/src/pyModeS/py.typed +0 -0
- {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/
|
|
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
|
+
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
|
216
|
-
|
|
|
217
|
-
|
|
|
218
|
-
|
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
| Decoder
|
|
187
|
-
|
|
188
|
-
|
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
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
|
|
175
|
-
|
|
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.
|
|
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.
|
|
297
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
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
|
|
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/
|
|
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
|
|
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
|