dopplertrack 0.1.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.
- dopplertrack-0.1.0/LICENSE +21 -0
- dopplertrack-0.1.0/PKG-INFO +149 -0
- dopplertrack-0.1.0/README.md +124 -0
- dopplertrack-0.1.0/pyproject.toml +44 -0
- dopplertrack-0.1.0/src/dopplertrack/__init__.py +2 -0
- dopplertrack-0.1.0/src/dopplertrack/adsb.py +161 -0
- dopplertrack-0.1.0/src/dopplertrack/audio.py +101 -0
- dopplertrack-0.1.0/src/dopplertrack/cli.py +99 -0
- dopplertrack-0.1.0/src/dopplertrack/doppler.py +72 -0
- dopplertrack-0.1.0/src/dopplertrack/geo.py +59 -0
- dopplertrack-0.1.0/src/dopplertrack/pipeline.py +114 -0
- dopplertrack-0.1.0/src/dopplertrack/plots.py +61 -0
- dopplertrack-0.1.0/src/dopplertrack/py.typed +0 -0
- dopplertrack-0.1.0/src/dopplertrack/score.py +89 -0
- dopplertrack-0.1.0/src/dopplertrack/search.py +137 -0
- dopplertrack-0.1.0/src/dopplertrack/terrain.py +49 -0
- dopplertrack-0.1.0/src/dopplertrack/tracks.py +127 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MacCallister Higgins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dopplertrack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Acoustic geolocation from inaudible aircraft Doppler curves matched against ADS-B flight tracks
|
|
5
|
+
Keywords: doppler,ads-b,geolocation,acoustics,osint,spectrogram
|
|
6
|
+
Author: MacCallister Higgins
|
|
7
|
+
Author-email: MacCallister Higgins <maccallister.higgins@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Requires-Dist: matplotlib>=3.11.2
|
|
15
|
+
Requires-Dist: numpy>=2.5.4
|
|
16
|
+
Requires-Dist: pandas>=3.1.0
|
|
17
|
+
Requires-Dist: pillow>=13.0.0
|
|
18
|
+
Requires-Dist: requests>=2.34.2
|
|
19
|
+
Requires-Dist: scipy>=1.18.1
|
|
20
|
+
Requires-Dist: soundfile>=0.14.0
|
|
21
|
+
Requires-Python: >=3.12
|
|
22
|
+
Project-URL: Homepage, https://github.com/mjshiggins/dopplertrack
|
|
23
|
+
Project-URL: Issues, https://github.com/mjshiggins/dopplertrack/issues
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# dopplertrack
|
|
27
|
+
|
|
28
|
+
Acoustic geolocation from the Doppler curves of propeller aircraft.
|
|
29
|
+
|
|
30
|
+
Any outdoor (and many indoor) recordings contain faint, usually inaudible
|
|
31
|
+
tones from aircraft flying over: the propeller blade-pass fundamental and its
|
|
32
|
+
harmonics, between roughly 60 and 400 Hz. As the aircraft passes, the tone
|
|
33
|
+
sweeps down in frequency. The exact shape of that sweep depends on where the
|
|
34
|
+
listener was relative to the aircraft's real trajectory. With historical
|
|
35
|
+
ADS-B data for every flight that day, each candidate location on the ground
|
|
36
|
+
predicts a different curve, and the recording picks out the one that matches.
|
|
37
|
+
|
|
38
|
+
This is a reimplementation of the technique shown in colsto's video
|
|
39
|
+
["I discovered a sound that leaks your exact location"](https://www.youtube.com/watch?v=UytYrhYLOKI).
|
|
40
|
+
No code was released with that video; this project is an open, automatic
|
|
41
|
+
version of the method. See `docs/superpowers/specs/` for the design.
|
|
42
|
+
|
|
43
|
+
## Status
|
|
44
|
+
|
|
45
|
+
Proof of concept. Validated against the author's reference recording
|
|
46
|
+
(known answer: Oyster Bay, NY, 2026-09-08). See the results section below.
|
|
47
|
+
|
|
48
|
+
## Pipeline
|
|
49
|
+
|
|
50
|
+
1. `spectrogram` — decode any audio/video with ffmpeg, high-resolution STFT of
|
|
51
|
+
the 40–450 Hz band, whitened per frequency bin so only *changing* tones stand out.
|
|
52
|
+
2. `fit` — fit an ideal straight-pass Doppler curve (f0, speed, CPA slant
|
|
53
|
+
distance, CPA time) to a time/frequency window. The fit seeds a search
|
|
54
|
+
with coarse parameter ranges.
|
|
55
|
+
3. `extract-region` — pull every flight that touched a bounding box out of an
|
|
56
|
+
[adsb.lol](https://github.com/adsblol/globe_history_2026) daily archive and
|
|
57
|
+
drop known jets using ICAO Doc 8643 engine classes.
|
|
58
|
+
4. `search` — for every grid point (200 m) and every flight, compute the
|
|
59
|
+
closest point of approach (CPA), its slant distance (using a DEM for the
|
|
60
|
+
observer's elevation) and the *heard* CPA time (propagation delay). Keep
|
|
61
|
+
points that hear an event-1-like pass, then exactly Δt later an
|
|
62
|
+
event-2-like pass, with no other prop flight in between. Model the exact
|
|
63
|
+
Doppler curve each survivor would hear from the real trajectory
|
|
64
|
+
(emission-time radial velocity) and score it against the spectrogram.
|
|
65
|
+
Event 1's placement absorbs measurement error; event 2's placement is
|
|
66
|
+
locked to the flight geometry.
|
|
67
|
+
|
|
68
|
+
## Usage
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv sync
|
|
72
|
+
uv run dopplertrack spectrogram recording.mp4 rec.npz
|
|
73
|
+
uv run dopplertrack fit rec.npz --window 2090 2200 --band 95 200 --harmonics 2 --out ev1.json
|
|
74
|
+
uv run dopplertrack fit rec.npz --window 3020 3130 --band 95 195 --out ev2.json
|
|
75
|
+
uv run dopplertrack extract-region /path/to/adsb-day --bbox 40.4 41.3 -74.4 -72.6 traces_ny.npz
|
|
76
|
+
uv run dopplertrack search rec.npz --events ev1.json ev2.json --traces traces_ny.npz \
|
|
77
|
+
--bbox 40.4 41.3 -74.4 -72.6 --spacing 200 --out results.csv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`search --point LAT LON` scores a single known location instead of a grid,
|
|
81
|
+
which is the quickest way to validate the data and physics on a recording
|
|
82
|
+
whose location you already know.
|
|
83
|
+
|
|
84
|
+
## Changes from the method in the video
|
|
85
|
+
|
|
86
|
+
The physics and the search strategy are the author's. What differs:
|
|
87
|
+
|
|
88
|
+
- **Automatic curve measurement.** The video measures each Doppler curve by
|
|
89
|
+
hand, sliding f0 / speed / distance / CPA-time until the overlay looks
|
|
90
|
+
right, then choosing "generous ranges" by eye. `fit` does this without a
|
|
91
|
+
person: the window is whitened per frequency bin so stationary tones
|
|
92
|
+
(mains hum, machinery) vanish, a vectorised grid search over all four
|
|
93
|
+
parameters maximises spectrogram brightness under the curve, and a
|
|
94
|
+
Nelder-Mead pass refines it. The ideal-curve fit still only seeds coarse
|
|
95
|
+
filters; final ranking uses real trajectories.
|
|
96
|
+
- **Harmonic-aware fitting and scoring.** When a flyover shows a second
|
|
97
|
+
harmonic, fundamental and harmonic are scored together, which constrains
|
|
98
|
+
the curve more tightly than one line.
|
|
99
|
+
- **Open, documented data path.** The video used a paid/scraped ADS-B feed
|
|
100
|
+
and did not release code. Here every input is a public dataset with a
|
|
101
|
+
loader: adsb.lol daily archives, ICAO Doc 8643 engine classes, AWS
|
|
102
|
+
terrain tiles.
|
|
103
|
+
- **Search engineering.** A KD-tree corridor around each flight path limits
|
|
104
|
+
CPA computation to grid points that could possibly hear it; distance math
|
|
105
|
+
is chunked so multi-hour tracks do not exhaust memory; the time-shift and
|
|
106
|
+
f0 sweeps of the locked scorer run as one array operation per candidate.
|
|
107
|
+
- **Single-event, known-time mode as a first-class command.** The video
|
|
108
|
+
demonstrates locating from one faint one-minute curve when the recording
|
|
109
|
+
time is known; `search-single` exposes that directly.
|
|
110
|
+
|
|
111
|
+
Kept unchanged on purpose: emission-time radial velocity (not
|
|
112
|
+
observation-time), propagation-delayed CPA times, slant distance with a DEM,
|
|
113
|
+
the inter-event offset filter, the silence filter, and locked two-event
|
|
114
|
+
scoring where event 1 absorbs measurement error and event 2 is placed purely
|
|
115
|
+
by flight geometry.
|
|
116
|
+
|
|
117
|
+
## Data sources (all free)
|
|
118
|
+
|
|
119
|
+
| Need | Source |
|
|
120
|
+
|---|---|
|
|
121
|
+
| Historical ADS-B | adsb.lol `globe_history_YYYY` GitHub releases (~4 GB/day) |
|
|
122
|
+
| Aircraft engine class | OpenSky mirror of ICAO Doc 8643 |
|
|
123
|
+
| Elevation | AWS Terrain Tiles (Terrarium) |
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
uv run pytest
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Releasing
|
|
132
|
+
|
|
133
|
+
Releases are driven by git tags. Pushing a tag `vX.Y.Z` that matches the
|
|
134
|
+
version in `pyproject.toml` runs `.github/workflows/release.yml`, which runs
|
|
135
|
+
the tests, builds the sdist and wheel, attaches them to a GitHub Release with
|
|
136
|
+
generated notes, and publishes to PyPI through trusted publishing (no API
|
|
137
|
+
token stored anywhere).
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
uv version 0.2.0 # bump pyproject.toml
|
|
141
|
+
git commit -am "Release 0.2.0"
|
|
142
|
+
git tag v0.2.0 && git push && git push --tags
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
One-time setup for PyPI: on pypi.org, add a *pending publisher* for project
|
|
146
|
+
`dopplertrack` with owner `mjshiggins`, repository `dopplertrack`, workflow
|
|
147
|
+
`release.yml`, environment `pypi`, and create a `pypi` environment in the
|
|
148
|
+
GitHub repository settings. Until that exists the `pypi` job fails and the
|
|
149
|
+
GitHub Release still succeeds.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# dopplertrack
|
|
2
|
+
|
|
3
|
+
Acoustic geolocation from the Doppler curves of propeller aircraft.
|
|
4
|
+
|
|
5
|
+
Any outdoor (and many indoor) recordings contain faint, usually inaudible
|
|
6
|
+
tones from aircraft flying over: the propeller blade-pass fundamental and its
|
|
7
|
+
harmonics, between roughly 60 and 400 Hz. As the aircraft passes, the tone
|
|
8
|
+
sweeps down in frequency. The exact shape of that sweep depends on where the
|
|
9
|
+
listener was relative to the aircraft's real trajectory. With historical
|
|
10
|
+
ADS-B data for every flight that day, each candidate location on the ground
|
|
11
|
+
predicts a different curve, and the recording picks out the one that matches.
|
|
12
|
+
|
|
13
|
+
This is a reimplementation of the technique shown in colsto's video
|
|
14
|
+
["I discovered a sound that leaks your exact location"](https://www.youtube.com/watch?v=UytYrhYLOKI).
|
|
15
|
+
No code was released with that video; this project is an open, automatic
|
|
16
|
+
version of the method. See `docs/superpowers/specs/` for the design.
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
Proof of concept. Validated against the author's reference recording
|
|
21
|
+
(known answer: Oyster Bay, NY, 2026-09-08). See the results section below.
|
|
22
|
+
|
|
23
|
+
## Pipeline
|
|
24
|
+
|
|
25
|
+
1. `spectrogram` — decode any audio/video with ffmpeg, high-resolution STFT of
|
|
26
|
+
the 40–450 Hz band, whitened per frequency bin so only *changing* tones stand out.
|
|
27
|
+
2. `fit` — fit an ideal straight-pass Doppler curve (f0, speed, CPA slant
|
|
28
|
+
distance, CPA time) to a time/frequency window. The fit seeds a search
|
|
29
|
+
with coarse parameter ranges.
|
|
30
|
+
3. `extract-region` — pull every flight that touched a bounding box out of an
|
|
31
|
+
[adsb.lol](https://github.com/adsblol/globe_history_2026) daily archive and
|
|
32
|
+
drop known jets using ICAO Doc 8643 engine classes.
|
|
33
|
+
4. `search` — for every grid point (200 m) and every flight, compute the
|
|
34
|
+
closest point of approach (CPA), its slant distance (using a DEM for the
|
|
35
|
+
observer's elevation) and the *heard* CPA time (propagation delay). Keep
|
|
36
|
+
points that hear an event-1-like pass, then exactly Δt later an
|
|
37
|
+
event-2-like pass, with no other prop flight in between. Model the exact
|
|
38
|
+
Doppler curve each survivor would hear from the real trajectory
|
|
39
|
+
(emission-time radial velocity) and score it against the spectrogram.
|
|
40
|
+
Event 1's placement absorbs measurement error; event 2's placement is
|
|
41
|
+
locked to the flight geometry.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv sync
|
|
47
|
+
uv run dopplertrack spectrogram recording.mp4 rec.npz
|
|
48
|
+
uv run dopplertrack fit rec.npz --window 2090 2200 --band 95 200 --harmonics 2 --out ev1.json
|
|
49
|
+
uv run dopplertrack fit rec.npz --window 3020 3130 --band 95 195 --out ev2.json
|
|
50
|
+
uv run dopplertrack extract-region /path/to/adsb-day --bbox 40.4 41.3 -74.4 -72.6 traces_ny.npz
|
|
51
|
+
uv run dopplertrack search rec.npz --events ev1.json ev2.json --traces traces_ny.npz \
|
|
52
|
+
--bbox 40.4 41.3 -74.4 -72.6 --spacing 200 --out results.csv
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`search --point LAT LON` scores a single known location instead of a grid,
|
|
56
|
+
which is the quickest way to validate the data and physics on a recording
|
|
57
|
+
whose location you already know.
|
|
58
|
+
|
|
59
|
+
## Changes from the method in the video
|
|
60
|
+
|
|
61
|
+
The physics and the search strategy are the author's. What differs:
|
|
62
|
+
|
|
63
|
+
- **Automatic curve measurement.** The video measures each Doppler curve by
|
|
64
|
+
hand, sliding f0 / speed / distance / CPA-time until the overlay looks
|
|
65
|
+
right, then choosing "generous ranges" by eye. `fit` does this without a
|
|
66
|
+
person: the window is whitened per frequency bin so stationary tones
|
|
67
|
+
(mains hum, machinery) vanish, a vectorised grid search over all four
|
|
68
|
+
parameters maximises spectrogram brightness under the curve, and a
|
|
69
|
+
Nelder-Mead pass refines it. The ideal-curve fit still only seeds coarse
|
|
70
|
+
filters; final ranking uses real trajectories.
|
|
71
|
+
- **Harmonic-aware fitting and scoring.** When a flyover shows a second
|
|
72
|
+
harmonic, fundamental and harmonic are scored together, which constrains
|
|
73
|
+
the curve more tightly than one line.
|
|
74
|
+
- **Open, documented data path.** The video used a paid/scraped ADS-B feed
|
|
75
|
+
and did not release code. Here every input is a public dataset with a
|
|
76
|
+
loader: adsb.lol daily archives, ICAO Doc 8643 engine classes, AWS
|
|
77
|
+
terrain tiles.
|
|
78
|
+
- **Search engineering.** A KD-tree corridor around each flight path limits
|
|
79
|
+
CPA computation to grid points that could possibly hear it; distance math
|
|
80
|
+
is chunked so multi-hour tracks do not exhaust memory; the time-shift and
|
|
81
|
+
f0 sweeps of the locked scorer run as one array operation per candidate.
|
|
82
|
+
- **Single-event, known-time mode as a first-class command.** The video
|
|
83
|
+
demonstrates locating from one faint one-minute curve when the recording
|
|
84
|
+
time is known; `search-single` exposes that directly.
|
|
85
|
+
|
|
86
|
+
Kept unchanged on purpose: emission-time radial velocity (not
|
|
87
|
+
observation-time), propagation-delayed CPA times, slant distance with a DEM,
|
|
88
|
+
the inter-event offset filter, the silence filter, and locked two-event
|
|
89
|
+
scoring where event 1 absorbs measurement error and event 2 is placed purely
|
|
90
|
+
by flight geometry.
|
|
91
|
+
|
|
92
|
+
## Data sources (all free)
|
|
93
|
+
|
|
94
|
+
| Need | Source |
|
|
95
|
+
|---|---|
|
|
96
|
+
| Historical ADS-B | adsb.lol `globe_history_YYYY` GitHub releases (~4 GB/day) |
|
|
97
|
+
| Aircraft engine class | OpenSky mirror of ICAO Doc 8643 |
|
|
98
|
+
| Elevation | AWS Terrain Tiles (Terrarium) |
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
uv run pytest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Releasing
|
|
107
|
+
|
|
108
|
+
Releases are driven by git tags. Pushing a tag `vX.Y.Z` that matches the
|
|
109
|
+
version in `pyproject.toml` runs `.github/workflows/release.yml`, which runs
|
|
110
|
+
the tests, builds the sdist and wheel, attaches them to a GitHub Release with
|
|
111
|
+
generated notes, and publishes to PyPI through trusted publishing (no API
|
|
112
|
+
token stored anywhere).
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv version 0.2.0 # bump pyproject.toml
|
|
116
|
+
git commit -am "Release 0.2.0"
|
|
117
|
+
git tag v0.2.0 && git push && git push --tags
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
One-time setup for PyPI: on pypi.org, add a *pending publisher* for project
|
|
121
|
+
`dopplertrack` with owner `mjshiggins`, repository `dopplertrack`, workflow
|
|
122
|
+
`release.yml`, environment `pypi`, and create a `pypi` environment in the
|
|
123
|
+
GitHub repository settings. Until that exists the `pypi` job fails and the
|
|
124
|
+
GitHub Release still succeeds.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "dopplertrack"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Acoustic geolocation from inaudible aircraft Doppler curves matched against ADS-B flight tracks"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "MacCallister Higgins", email = "maccallister.higgins@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = ["doppler", "ads-b", "geolocation", "acoustics", "osint", "spectrogram"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Scientific/Engineering",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
dependencies = [
|
|
21
|
+
"matplotlib>=3.11.2",
|
|
22
|
+
"numpy>=2.5.4",
|
|
23
|
+
"pandas>=3.1.0",
|
|
24
|
+
"pillow>=13.0.0",
|
|
25
|
+
"requests>=2.34.2",
|
|
26
|
+
"scipy>=1.18.1",
|
|
27
|
+
"soundfile>=0.14.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/mjshiggins/dopplertrack"
|
|
32
|
+
Issues = "https://github.com/mjshiggins/dopplertrack/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
dopplertrack = "dopplertrack.cli:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.8.3,<0.9.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.2.0",
|
|
44
|
+
]
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Read adsb.lol / readsb `globe_history` daily archives and classify aircraft."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import gzip
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from .geo import lla_to_enu
|
|
17
|
+
|
|
18
|
+
FT = 0.3048
|
|
19
|
+
KT = 0.514444
|
|
20
|
+
_DOC8643_URL = "https://opensky-network.org/datasets/metadata/doc8643AircraftTypes.csv"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _cache_dir() -> Path:
|
|
24
|
+
p = Path(os.environ.get("DOPPLERTRACK_CACHE", Path.home() / ".cache" / "dopplertrack"))
|
|
25
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AircraftTypes:
|
|
30
|
+
"""ICAO Doc 8643 designator -> engine class ('Piston', 'Turboprop', 'Jet', ...)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: str | Path | None = None):
|
|
33
|
+
p = Path(path) if path else _cache_dir() / "doc8643AircraftTypes.csv"
|
|
34
|
+
if not p.exists():
|
|
35
|
+
p.write_bytes(requests.get(_DOC8643_URL, timeout=120).content)
|
|
36
|
+
self.types: dict[str, dict] = {}
|
|
37
|
+
with open(p, newline="", encoding="utf-8") as fh:
|
|
38
|
+
for row in csv.DictReader(fh):
|
|
39
|
+
self.types.setdefault(row["Designator"], row)
|
|
40
|
+
|
|
41
|
+
def engine(self, designator: str | None) -> str | None:
|
|
42
|
+
r = self.types.get(designator or "")
|
|
43
|
+
return r["EngineType"] if r else None
|
|
44
|
+
|
|
45
|
+
def is_prop(self, designator: str | None, unknown_ok: bool = True) -> bool:
|
|
46
|
+
e = self.engine(designator)
|
|
47
|
+
if e is None:
|
|
48
|
+
return unknown_ok
|
|
49
|
+
return any(k in e for k in ("Piston", "Turboprop", "Electric"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Trace:
|
|
54
|
+
icao: str
|
|
55
|
+
reg: str | None
|
|
56
|
+
typ: str | None
|
|
57
|
+
desc: str | None
|
|
58
|
+
t: np.ndarray # unix seconds
|
|
59
|
+
lat: np.ndarray
|
|
60
|
+
lon: np.ndarray
|
|
61
|
+
alt: np.ndarray # metres MSL (geometric when available, else barometric)
|
|
62
|
+
gs: np.ndarray # m/s (nan if missing)
|
|
63
|
+
on_ground: np.ndarray
|
|
64
|
+
|
|
65
|
+
def __len__(self):
|
|
66
|
+
return len(self.t)
|
|
67
|
+
|
|
68
|
+
def segments(self, max_gap_s: float = 90.0) -> list[slice]:
|
|
69
|
+
"""Index slices of contiguous airborne data."""
|
|
70
|
+
out = []
|
|
71
|
+
if len(self.t) == 0:
|
|
72
|
+
return out
|
|
73
|
+
brk = np.where((np.diff(self.t) > max_gap_s) | (self.on_ground[1:] != self.on_ground[:-1]))[0] + 1
|
|
74
|
+
starts = np.r_[0, brk]; ends = np.r_[brk, len(self.t)]
|
|
75
|
+
for s, e in zip(starts, ends):
|
|
76
|
+
if e - s >= 2 and not self.on_ground[s]:
|
|
77
|
+
out.append(slice(s, e))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
def resample(self, sl: slice, dt: float = 1.0):
|
|
81
|
+
"""Uniformly resampled (t, lat, lon, alt) over one segment."""
|
|
82
|
+
t = self.t[sl]
|
|
83
|
+
tq = np.arange(t[0], t[-1], dt)
|
|
84
|
+
return (tq, np.interp(tq, t, self.lat[sl]), np.interp(tq, t, self.lon[sl]), np.interp(tq, t, self.alt[sl]))
|
|
85
|
+
|
|
86
|
+
def enu(self, sl: slice, lat0: float, lon0: float, dt: float = 1.0):
|
|
87
|
+
tq, la, lo, al = self.resample(sl, dt)
|
|
88
|
+
return tq, lla_to_enu(la, lo, al, lat0, lon0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_trace_file(path: str | Path) -> Trace | None:
|
|
92
|
+
raw = Path(path).read_bytes()
|
|
93
|
+
if raw[:2] == b"\x1f\x8b":
|
|
94
|
+
raw = gzip.decompress(raw)
|
|
95
|
+
d = json.loads(raw)
|
|
96
|
+
base = float(d.get("timestamp", 0.0))
|
|
97
|
+
rows = d.get("trace") or []
|
|
98
|
+
t, lat, lon, alt, gs, grd = [], [], [], [], [], []
|
|
99
|
+
for r in rows:
|
|
100
|
+
if r[1] is None or r[2] is None:
|
|
101
|
+
continue
|
|
102
|
+
a = r[3]
|
|
103
|
+
on_ground = a == "ground"
|
|
104
|
+
geom = r[10] if len(r) > 10 else None
|
|
105
|
+
if isinstance(geom, (int, float)):
|
|
106
|
+
am = geom * FT
|
|
107
|
+
elif isinstance(a, (int, float)):
|
|
108
|
+
am = a * FT
|
|
109
|
+
elif on_ground:
|
|
110
|
+
am = np.nan
|
|
111
|
+
else:
|
|
112
|
+
continue
|
|
113
|
+
t.append(base + r[0]); lat.append(r[1]); lon.append(r[2]); alt.append(am)
|
|
114
|
+
gs.append(r[4] * KT if isinstance(r[4], (int, float)) else np.nan); grd.append(on_ground)
|
|
115
|
+
if not t:
|
|
116
|
+
return None
|
|
117
|
+
return Trace(d.get("icao", Path(path).stem.split("_")[-1]), d.get("r"), d.get("t"), d.get("desc"),
|
|
118
|
+
np.array(t), np.array(lat), np.array(lon), np.array(alt), np.array(gs), np.array(grd))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _scan_one(args):
|
|
122
|
+
path, bbox = args
|
|
123
|
+
try:
|
|
124
|
+
tr = parse_trace_file(path)
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
if tr is None:
|
|
128
|
+
return None
|
|
129
|
+
lat0, lat1, lon0, lon1 = bbox
|
|
130
|
+
m = (tr.lat >= lat0) & (tr.lat <= lat1) & (tr.lon >= lon0) & (tr.lon <= lon1)
|
|
131
|
+
if not m.any():
|
|
132
|
+
return None
|
|
133
|
+
return tr
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_region(archive_dir: str | Path, bbox: tuple[float, float, float, float],
|
|
137
|
+
workers: int | None = None) -> list[Trace]:
|
|
138
|
+
"""All traces with at least one point inside bbox=(lat_min, lat_max, lon_min, lon_max)."""
|
|
139
|
+
files = sorted(Path(archive_dir).glob("traces/*/trace_full_*.json"))
|
|
140
|
+
out = []
|
|
141
|
+
with ProcessPoolExecutor(workers or os.cpu_count()) as ex:
|
|
142
|
+
for tr in ex.map(_scan_one, ((str(f), bbox) for f in files), chunksize=256):
|
|
143
|
+
if tr is not None:
|
|
144
|
+
out.append(tr)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save_traces(traces: list[Trace], path: str | Path):
|
|
149
|
+
np.savez_compressed(path, n=len(traces),
|
|
150
|
+
meta=np.array([json.dumps([t.icao, t.reg, t.typ, t.desc]) for t in traces]),
|
|
151
|
+
**{f"a{i}": np.stack([t.t, t.lat, t.lon, t.alt, t.gs, t.on_ground.astype(float)]) for i, t in enumerate(traces)})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_traces(path: str | Path) -> list[Trace]:
|
|
155
|
+
z = np.load(path, allow_pickle=False)
|
|
156
|
+
out = []
|
|
157
|
+
for i in range(int(z["n"])):
|
|
158
|
+
icao, reg, typ, desc = json.loads(str(z["meta"][i]))
|
|
159
|
+
a = z[f"a{i}"]
|
|
160
|
+
out.append(Trace(icao, reg, typ, desc, a[0], a[1], a[2], a[3], a[4], a[5].astype(bool)))
|
|
161
|
+
return out
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Audio decoding and whitened low-band spectrograms."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import scipy.signal as ss
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_audio(path: str | Path, sr: int = 4000) -> tuple[np.ndarray, int]:
|
|
13
|
+
"""Decode any ffmpeg-readable file to mono float32 at `sr` Hz."""
|
|
14
|
+
cmd = ["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(sr),
|
|
15
|
+
"-f", "f32le", "-acodec", "pcm_f32le", "-"]
|
|
16
|
+
raw = subprocess.run(cmd, check=True, capture_output=True).stdout
|
|
17
|
+
return np.frombuffer(raw, dtype=np.float32).copy(), sr
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Spectrogram:
|
|
22
|
+
t: np.ndarray # (T,) centre time of each frame, s
|
|
23
|
+
f: np.ndarray # (F,) bin frequencies, Hz
|
|
24
|
+
S: np.ndarray # (F, T) whitened power, dB above per-bin median
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def dt(self) -> float:
|
|
28
|
+
return float(self.t[1] - self.t[0])
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def df(self) -> float:
|
|
32
|
+
return float(self.f[1] - self.f[0])
|
|
33
|
+
|
|
34
|
+
def sample(self, t, f, fill=0.0):
|
|
35
|
+
"""Bilinear sample at (t, f) pairs (arrays); outside the grid -> fill."""
|
|
36
|
+
t = np.asarray(t, float); f = np.asarray(f, float)
|
|
37
|
+
ti = (t - self.t[0]) / self.dt
|
|
38
|
+
fi = (f - self.f[0]) / self.df
|
|
39
|
+
ok = (ti >= 0) & (ti <= len(self.t) - 1) & (fi >= 0) & (fi <= len(self.f) - 1) & np.isfinite(ti) & np.isfinite(fi)
|
|
40
|
+
out = np.full(t.shape, fill, float)
|
|
41
|
+
ti = ti[ok]; fi = fi[ok]
|
|
42
|
+
i0 = np.floor(ti).astype(int); j0 = np.floor(fi).astype(int)
|
|
43
|
+
i1 = np.minimum(i0 + 1, len(self.t) - 1); j1 = np.minimum(j0 + 1, len(self.f) - 1)
|
|
44
|
+
wt = ti - i0; wf = fi - j0
|
|
45
|
+
v = (self.S[j0, i0] * (1 - wt) * (1 - wf) + self.S[j0, i1] * wt * (1 - wf)
|
|
46
|
+
+ self.S[j1, i0] * (1 - wt) * wf + self.S[j1, i1] * wt * wf)
|
|
47
|
+
out[ok] = v
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
def crop(self, t0, t1, f0=None, f1=None) -> "Spectrogram":
|
|
51
|
+
m = (self.t >= t0) & (self.t <= t1)
|
|
52
|
+
fm = np.ones_like(self.f, bool)
|
|
53
|
+
if f0 is not None: fm &= self.f >= f0
|
|
54
|
+
if f1 is not None: fm &= self.f <= f1
|
|
55
|
+
return Spectrogram(self.t[m], self.f[fm], self.S[np.ix_(fm, m)])
|
|
56
|
+
|
|
57
|
+
def save(self, path):
|
|
58
|
+
np.savez_compressed(path, t=self.t, f=self.f, S=self.S.astype(np.float32))
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def load(cls, path) -> "Spectrogram":
|
|
62
|
+
z = np.load(path)
|
|
63
|
+
return cls(z["t"], z["f"], z["S"])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def compute_spectrogram(x: np.ndarray, sr: int, nperseg: int = 8192, hop_s: float = 0.25,
|
|
67
|
+
fmin: float = 40.0, fmax: float = 450.0,
|
|
68
|
+
whiten_window_s: float | None = 600.0) -> Spectrogram:
|
|
69
|
+
"""High-resolution STFT in the low band, converted to dB and whitened.
|
|
70
|
+
|
|
71
|
+
Whitening subtracts, per frequency bin, the running median over
|
|
72
|
+
`whiten_window_s` (or the global median if None). This flattens mains
|
|
73
|
+
hum and stationary machinery so only *changing* tones stand out.
|
|
74
|
+
"""
|
|
75
|
+
hop = max(1, int(round(hop_s * sr)))
|
|
76
|
+
f, t, Z = ss.stft(x, sr, nperseg=nperseg, noverlap=nperseg - hop, window="hann",
|
|
77
|
+
boundary=None, padded=False)
|
|
78
|
+
band = (f >= fmin) & (f <= fmax)
|
|
79
|
+
L = 10 * np.log10(np.abs(Z[band]) ** 2 + 1e-20)
|
|
80
|
+
f = f[band]
|
|
81
|
+
if whiten_window_s is None:
|
|
82
|
+
L = L - np.median(L, axis=1, keepdims=True)
|
|
83
|
+
else:
|
|
84
|
+
w = int(whiten_window_s / (hop / sr))
|
|
85
|
+
if w >= L.shape[1]:
|
|
86
|
+
L = L - np.median(L, axis=1, keepdims=True)
|
|
87
|
+
else:
|
|
88
|
+
# block-wise running median (cheap, adequate for slow drifts)
|
|
89
|
+
nb = int(np.ceil(L.shape[1] / w))
|
|
90
|
+
med = np.zeros_like(L)
|
|
91
|
+
centers = []
|
|
92
|
+
for b in range(nb):
|
|
93
|
+
sl = slice(b * w, min((b + 1) * w, L.shape[1]))
|
|
94
|
+
med[:, sl] = np.median(L[:, sl], axis=1, keepdims=True)
|
|
95
|
+
centers.append((sl.start + sl.stop) / 2)
|
|
96
|
+
# linearly blend block medians to avoid seams
|
|
97
|
+
cm = np.array([np.median(L[:, b * w:min((b + 1) * w, L.shape[1])], axis=1) for b in range(nb)]) # (nb, F)
|
|
98
|
+
idx = np.arange(L.shape[1])
|
|
99
|
+
med = np.stack([np.interp(idx, centers, cm[:, k]) for k in range(L.shape[0])])
|
|
100
|
+
L = L - med
|
|
101
|
+
return Spectrogram(t, f, L.astype(np.float32))
|