dopplertrack 0.1.0__py3-none-any.whl
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/__init__.py +2 -0
- dopplertrack/adsb.py +161 -0
- dopplertrack/audio.py +101 -0
- dopplertrack/cli.py +99 -0
- dopplertrack/doppler.py +72 -0
- dopplertrack/geo.py +59 -0
- dopplertrack/pipeline.py +114 -0
- dopplertrack/plots.py +61 -0
- dopplertrack/py.typed +0 -0
- dopplertrack/score.py +89 -0
- dopplertrack/search.py +137 -0
- dopplertrack/terrain.py +49 -0
- dopplertrack/tracks.py +127 -0
- dopplertrack-0.1.0.dist-info/METADATA +149 -0
- dopplertrack-0.1.0.dist-info/RECORD +18 -0
- dopplertrack-0.1.0.dist-info/WHEEL +4 -0
- dopplertrack-0.1.0.dist-info/entry_points.txt +3 -0
- dopplertrack-0.1.0.dist-info/licenses/LICENSE +21 -0
dopplertrack/__init__.py
ADDED
dopplertrack/adsb.py
ADDED
|
@@ -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
|
dopplertrack/audio.py
ADDED
|
@@ -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))
|
dopplertrack/cli.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""dopplertrack command line."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import pipeline
|
|
11
|
+
from .adsb import load_traces
|
|
12
|
+
from .audio import Spectrogram
|
|
13
|
+
from .search import Grid
|
|
14
|
+
from .terrain import Terrain
|
|
15
|
+
from .tracks import EventFit
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main(argv=None):
|
|
19
|
+
ap = argparse.ArgumentParser(prog="dopplertrack")
|
|
20
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
|
|
22
|
+
s = sub.add_parser("spectrogram", help="audio/video file -> whitened low-band spectrogram (.npz)")
|
|
23
|
+
s.add_argument("audio"); s.add_argument("out"); s.add_argument("--hop", type=float, default=0.25)
|
|
24
|
+
s.add_argument("--fmin", type=float, default=40); s.add_argument("--fmax", type=float, default=450)
|
|
25
|
+
|
|
26
|
+
f = sub.add_parser("fit", help="fit an ideal Doppler curve inside a time/frequency window")
|
|
27
|
+
f.add_argument("spec"); f.add_argument("--window", nargs=2, type=float, required=True, metavar=("T0", "T1"))
|
|
28
|
+
f.add_argument("--band", nargs=2, type=float, required=True, metavar=("FLO", "FHI"))
|
|
29
|
+
f.add_argument("--harmonics", type=int, default=1); f.add_argument("--out")
|
|
30
|
+
|
|
31
|
+
e = sub.add_parser("extract-region", help="pull traces touching a bbox out of an adsb.lol day archive")
|
|
32
|
+
e.add_argument("archive_dir"); e.add_argument("out")
|
|
33
|
+
e.add_argument("--bbox", nargs=4, type=float, required=True, metavar=("LATMIN", "LATMAX", "LONMIN", "LONMAX"))
|
|
34
|
+
e.add_argument("--keep-jets", action="store_true")
|
|
35
|
+
|
|
36
|
+
r = sub.add_parser("search", help="rank candidate locations for a two-event recording")
|
|
37
|
+
r.add_argument("spec"); r.add_argument("--events", nargs=2, required=True, metavar=("EV1_JSON", "EV2_JSON"))
|
|
38
|
+
r.add_argument("--traces", required=True); r.add_argument("--out", required=True)
|
|
39
|
+
r.add_argument("--bbox", nargs=4, type=float, metavar=("LATMIN", "LATMAX", "LONMIN", "LONMAX"))
|
|
40
|
+
r.add_argument("--point", nargs=2, type=float, metavar=("LAT", "LON"), help="score a single known point instead of a grid")
|
|
41
|
+
r.add_argument("--spacing", type=float, default=200.0); r.add_argument("--harmonics", nargs=2, type=int, default=(1, 1))
|
|
42
|
+
r.add_argument("--no-dem", action="store_true"); r.add_argument("--no-silence", action="store_true")
|
|
43
|
+
r.add_argument("--tol", type=float, default=6.0)
|
|
44
|
+
|
|
45
|
+
g = sub.add_parser("search-single", help="rank locations for ONE event whose absolute time is known")
|
|
46
|
+
g.add_argument("spec"); g.add_argument("--event", required=True); g.add_argument("--traces", required=True)
|
|
47
|
+
g.add_argument("--utc", required=True, help="heard CPA time of the event, unix seconds or ISO-8601 UTC")
|
|
48
|
+
g.add_argument("--tol", type=float, default=30.0); g.add_argument("--out", required=True)
|
|
49
|
+
g.add_argument("--bbox", nargs=4, type=float, required=True, metavar=("LATMIN", "LATMAX", "LONMIN", "LONMAX"))
|
|
50
|
+
g.add_argument("--spacing", type=float, default=200.0); g.add_argument("--harmonics", type=int, default=1)
|
|
51
|
+
g.add_argument("--no-dem", action="store_true")
|
|
52
|
+
|
|
53
|
+
a = ap.parse_args(argv)
|
|
54
|
+
if a.cmd == "spectrogram":
|
|
55
|
+
sp = pipeline.make_spectrogram(a.audio, a.out, hop_s=a.hop, fmin=a.fmin, fmax=a.fmax)
|
|
56
|
+
print(f"wrote {a.out}: {sp.S.shape[0]} bins x {sp.S.shape[1]} frames")
|
|
57
|
+
elif a.cmd == "fit":
|
|
58
|
+
sp = Spectrogram.load(a.spec)
|
|
59
|
+
ef = pipeline.fit_event(sp, a.window[0], a.window[1], a.band[0], a.band[1], harmonics=a.harmonics)
|
|
60
|
+
js = json.dumps(ef.to_dict(), indent=1)
|
|
61
|
+
print(js)
|
|
62
|
+
if a.out:
|
|
63
|
+
open(a.out, "w").write(js)
|
|
64
|
+
elif a.cmd == "extract-region":
|
|
65
|
+
pipeline.extract_region(a.archive_dir, tuple(a.bbox), a.out, prop_only=not a.keep_jets)
|
|
66
|
+
elif a.cmd == "search":
|
|
67
|
+
sp = Spectrogram.load(a.spec)
|
|
68
|
+
ev1, ev2 = (EventFit(**json.load(open(p))) for p in a.events)
|
|
69
|
+
traces = load_traces(a.traces)
|
|
70
|
+
elev = None if a.no_dem else Terrain().elevation
|
|
71
|
+
if a.point:
|
|
72
|
+
el = elev(np.array([a.point[0]]), np.array([a.point[1]])) if elev else np.zeros(1)
|
|
73
|
+
grid = Grid.points([a.point[0]], [a.point[1]], el)
|
|
74
|
+
else:
|
|
75
|
+
grid = Grid.make(*a.bbox, a.spacing, elev)
|
|
76
|
+
res, segs = pipeline.run_search(sp, ev1, ev2, traces, grid, harmonics=tuple(a.harmonics),
|
|
77
|
+
silence_d_m=None if a.no_silence else 6000.0, tol_s=a.tol)
|
|
78
|
+
res["lat0"] = grid.lat0; res["lon0"] = grid.lon0
|
|
79
|
+
res.to_csv(a.out, index=False)
|
|
80
|
+
print(res.head(20).to_string())
|
|
81
|
+
elif a.cmd == "search-single":
|
|
82
|
+
from datetime import datetime, timezone
|
|
83
|
+
sp = Spectrogram.load(a.spec)
|
|
84
|
+
ev = EventFit(**json.load(open(a.event)))
|
|
85
|
+
traces = load_traces(a.traces)
|
|
86
|
+
try:
|
|
87
|
+
t_unix = float(a.utc)
|
|
88
|
+
except ValueError:
|
|
89
|
+
t_unix = datetime.fromisoformat(a.utc.replace("Z", "+00:00")).astimezone(timezone.utc).timestamp()
|
|
90
|
+
elev = None if a.no_dem else Terrain().elevation
|
|
91
|
+
grid = Grid.make(*a.bbox, a.spacing, elev)
|
|
92
|
+
res, segs = pipeline.run_single_search(sp, ev, traces, grid, t_unix, tol_s=a.tol, harmonics=a.harmonics)
|
|
93
|
+
res["lat0"] = grid.lat0; res["lon0"] = grid.lon0
|
|
94
|
+
res.to_csv(a.out, index=False)
|
|
95
|
+
print(res.head(20).to_string())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
dopplertrack/doppler.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Doppler curve physics.
|
|
2
|
+
|
|
3
|
+
All functions use *emission-time* kinematics: the frequency heard at
|
|
4
|
+
observation time t_obs was produced at emission time t_e where
|
|
5
|
+
t_obs = t_e + r(t_e)/c, and the shift is set by the range rate at t_e.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def speed_of_sound(temp_c: float = 15.0) -> float:
|
|
13
|
+
return 331.3 * np.sqrt(1.0 + temp_c / 273.15)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def observed_frequency(f0, range_rate, c):
|
|
17
|
+
"""Frequency heard for a source of rest frequency f0 with range rate dr/dt
|
|
18
|
+
(positive = receding)."""
|
|
19
|
+
return f0 * c / (c + range_rate)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ideal_curve(t_obs, f0, v, d_cpa, t_cpa, c=None, span=600.0, n=4001):
|
|
23
|
+
"""Frequency heard at times t_obs for a straight, constant-speed pass.
|
|
24
|
+
|
|
25
|
+
v: speed (m/s); d_cpa: slant distance at closest approach (m);
|
|
26
|
+
t_cpa: *emission* time of closest approach.
|
|
27
|
+
"""
|
|
28
|
+
c = speed_of_sound() if c is None else c
|
|
29
|
+
te = t_cpa + np.linspace(-span, span, n)
|
|
30
|
+
r = np.sqrt(d_cpa**2 + (v * (te - t_cpa)) ** 2)
|
|
31
|
+
rr = v * v * (te - t_cpa) / r
|
|
32
|
+
f = observed_frequency(f0, rr, c)
|
|
33
|
+
tobs = te + r / c
|
|
34
|
+
return np.interp(np.asarray(t_obs, float), tobs, f, left=np.nan, right=np.nan)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def ideal_curve_params_from_endpoints(f_pre, f_post, c=None):
|
|
38
|
+
"""Recover (f0, v) from the approach and recede asymptote frequencies."""
|
|
39
|
+
c = speed_of_sound() if c is None else c
|
|
40
|
+
# f_pre = f0 c/(c-v), f_post = f0 c/(c+v)
|
|
41
|
+
v = c * (f_pre - f_post) / (f_pre + f_post)
|
|
42
|
+
f0 = f_pre * (c - v) / c
|
|
43
|
+
return f0, v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def curve_from_trajectory(t_e, pos_enu, obs_enu, f0=1.0, c=None):
|
|
47
|
+
"""Doppler curve a stationary observer hears from a sampled trajectory.
|
|
48
|
+
|
|
49
|
+
t_e: (N,) emission times (s); pos_enu: (N,3) source positions (m);
|
|
50
|
+
obs_enu: (3,) observer position (m). Returns (t_obs, f, r) with f for
|
|
51
|
+
rest frequency f0 (use f0=1 to get the ratio).
|
|
52
|
+
"""
|
|
53
|
+
c = speed_of_sound() if c is None else c
|
|
54
|
+
t_e = np.asarray(t_e, float)
|
|
55
|
+
d = np.asarray(pos_enu, float) - np.asarray(obs_enu, float)
|
|
56
|
+
r = np.linalg.norm(d, axis=-1)
|
|
57
|
+
rr = np.gradient(r, t_e)
|
|
58
|
+
f = observed_frequency(f0, rr, c)
|
|
59
|
+
t_obs = t_e + r / c
|
|
60
|
+
return t_obs, f, r
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cpa_from_trajectory(t_e, pos_enu, obs_enu, c=None):
|
|
64
|
+
"""Closest point of approach: (t_cpa_emission, t_cpa_heard, slant_m, speed_mps)."""
|
|
65
|
+
c = speed_of_sound() if c is None else c
|
|
66
|
+
t_e = np.asarray(t_e, float)
|
|
67
|
+
d = np.asarray(pos_enu, float) - np.asarray(obs_enu, float)
|
|
68
|
+
r = np.linalg.norm(d, axis=-1)
|
|
69
|
+
i = int(np.argmin(r))
|
|
70
|
+
vel = np.gradient(np.asarray(pos_enu, float), t_e, axis=0)
|
|
71
|
+
speed = float(np.linalg.norm(vel[i]))
|
|
72
|
+
return float(t_e[i]), float(t_e[i] + r[i] / c), float(r[i]), speed
|
dopplertrack/geo.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Geodesy helpers: WGS-84 <-> ECEF <-> local East-North-Up (ENU)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
_A = 6378137.0
|
|
7
|
+
_F = 1 / 298.257223563
|
|
8
|
+
_E2 = _F * (2 - _F)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def lla_to_ecef(lat_deg, lon_deg, alt_m):
|
|
12
|
+
lat = np.radians(lat_deg)
|
|
13
|
+
lon = np.radians(lon_deg)
|
|
14
|
+
s, c = np.sin(lat), np.cos(lat)
|
|
15
|
+
n = _A / np.sqrt(1 - _E2 * s * s)
|
|
16
|
+
x = (n + alt_m) * c * np.cos(lon)
|
|
17
|
+
y = (n + alt_m) * c * np.sin(lon)
|
|
18
|
+
z = (n * (1 - _E2) + alt_m) * s
|
|
19
|
+
return np.stack([x, y, z], axis=-1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def enu_rotation(lat0_deg, lon0_deg):
|
|
23
|
+
lat, lon = np.radians(lat0_deg), np.radians(lon0_deg)
|
|
24
|
+
sl, cl = np.sin(lat), np.cos(lat)
|
|
25
|
+
so, co = np.sin(lon), np.cos(lon)
|
|
26
|
+
return np.array([
|
|
27
|
+
[-so, co, 0.0],
|
|
28
|
+
[-sl * co, -sl * so, cl],
|
|
29
|
+
[cl * co, cl * so, sl],
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lla_to_enu(lat_deg, lon_deg, alt_m, lat0_deg, lon0_deg, alt0_m=0.0):
|
|
34
|
+
"""Convert points to ENU metres relative to the origin. Broadcasts."""
|
|
35
|
+
p = lla_to_ecef(np.asarray(lat_deg, float), np.asarray(lon_deg, float), np.asarray(alt_m, float))
|
|
36
|
+
o = lla_to_ecef(lat0_deg, lon0_deg, alt0_m)
|
|
37
|
+
return (p - o) @ enu_rotation(lat0_deg, lon0_deg).T
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def enu_to_lla(e, n, u, lat0_deg, lon0_deg, alt0_m=0.0):
|
|
41
|
+
r = enu_rotation(lat0_deg, lon0_deg)
|
|
42
|
+
o = lla_to_ecef(lat0_deg, lon0_deg, alt0_m)
|
|
43
|
+
xyz = np.stack([np.asarray(e, float), np.asarray(n, float), np.asarray(u, float)], -1) @ r + o
|
|
44
|
+
x, y, z = xyz[..., 0], xyz[..., 1], xyz[..., 2]
|
|
45
|
+
lon = np.arctan2(y, x)
|
|
46
|
+
p = np.hypot(x, y)
|
|
47
|
+
lat = np.arctan2(z, p * (1 - _E2))
|
|
48
|
+
for _ in range(5):
|
|
49
|
+
s = np.sin(lat)
|
|
50
|
+
nn = _A / np.sqrt(1 - _E2 * s * s)
|
|
51
|
+
alt = p / np.cos(lat) - nn
|
|
52
|
+
lat = np.arctan2(z, p * (1 - _E2 * nn / (nn + alt)))
|
|
53
|
+
return np.degrees(lat), np.degrees(lon), alt
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def haversine_m(lat1, lon1, lat2, lon2):
|
|
57
|
+
lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
|
|
58
|
+
a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
|
|
59
|
+
return 2 * 6371000.0 * np.arcsin(np.sqrt(a))
|
dopplertrack/pipeline.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""High-level pipeline steps that the CLI and notebooks share."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from .adsb import AircraftTypes, Trace, load_region, load_traces, save_traces
|
|
12
|
+
from .audio import Spectrogram, compute_spectrogram, load_audio
|
|
13
|
+
from .doppler import speed_of_sound
|
|
14
|
+
from .score import EventWindow, locked_score
|
|
15
|
+
from .search import Grid, Segment, build_segments, cpa_table, pair_candidates
|
|
16
|
+
from .terrain import Terrain
|
|
17
|
+
from .tracks import EventFit, fit_event
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def log(msg):
|
|
21
|
+
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def make_spectrogram(audio_path, out_path, hop_s=0.25, fmin=40, fmax=450, nperseg=8192, whiten_window_s=600):
|
|
25
|
+
x, sr = load_audio(audio_path)
|
|
26
|
+
sp = compute_spectrogram(x, sr, nperseg=nperseg, hop_s=hop_s, fmin=fmin, fmax=fmax, whiten_window_s=whiten_window_s)
|
|
27
|
+
sp.save(out_path)
|
|
28
|
+
return sp
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def extract_region(archive_dir, bbox, out_path, prop_only=True, workers=None) -> list[Trace]:
|
|
32
|
+
log(f"scanning {archive_dir} for traces in {bbox}")
|
|
33
|
+
traces = load_region(archive_dir, bbox, workers=workers)
|
|
34
|
+
log(f"{len(traces)} traces touch the region")
|
|
35
|
+
if prop_only:
|
|
36
|
+
types = AircraftTypes()
|
|
37
|
+
traces = [t for t in traces if types.is_prop(t.typ)]
|
|
38
|
+
log(f"{len(traces)} after dropping known jets")
|
|
39
|
+
save_traces(traces, out_path)
|
|
40
|
+
return traces
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_search(sp: Spectrogram, ev1: EventFit, ev2: EventFit, traces: list[Trace], grid: Grid,
|
|
44
|
+
harmonics=(1, 1), tol_s=6.0, v_tol=0.35, d_tol=(0.4, 2.5), silence_d_m=6000.0,
|
|
45
|
+
max_range_m=12000.0, c=None, top_pairs=None, progress=True) -> tuple[pd.DataFrame, list[Segment]]:
|
|
46
|
+
c = speed_of_sound() if c is None else c
|
|
47
|
+
e0, e1 = grid.enu[:, 0].min() - max_range_m, grid.enu[:, 0].max() + max_range_m
|
|
48
|
+
n0, n1 = grid.enu[:, 1].min() - max_range_m, grid.enu[:, 1].max() + max_range_m
|
|
49
|
+
segs = build_segments(traces, grid.lat0, grid.lon0, clip_enu=(e0, e1, n0, n1))
|
|
50
|
+
log(f"{len(segs)} airborne segments; grid of {len(grid)} points")
|
|
51
|
+
cpa = cpa_table(grid, segs, max_range_m=max_range_m, c=c)
|
|
52
|
+
log(f"{len(cpa)} (point, flight) CPA records")
|
|
53
|
+
dt = ev2.t_cpa - ev1.t_cpa
|
|
54
|
+
pairs = pair_candidates(cpa, dt, tol_s=tol_s,
|
|
55
|
+
v1=(ev1.v * (1 - v_tol), ev1.v * (1 + v_tol)), d1=(ev1.d * d_tol[0], ev1.d * d_tol[1]),
|
|
56
|
+
v2=(ev2.v * (1 - v_tol), ev2.v * (1 + v_tol)), d2=(ev2.d * d_tol[0], ev2.d * d_tol[1]),
|
|
57
|
+
silence_d_m=silence_d_m)
|
|
58
|
+
log(f"{len(pairs)} candidate (point, flight1, flight2) triples after offset + silence filters")
|
|
59
|
+
w1 = EventWindow.from_fit(sp, ev1, harmonics[0]); w2 = EventWindow.from_fit(sp, ev2, harmonics[1])
|
|
60
|
+
out = []
|
|
61
|
+
t0 = time.time()
|
|
62
|
+
for k, p in enumerate(pairs.itertuples()):
|
|
63
|
+
tot, s1, s2, sh = locked_score(w1, w2, segs[p.seg1], segs[p.seg2], grid.enu[p.gidx], float(p.t_heard1), c=c)
|
|
64
|
+
out.append((p.gidx, grid.lat[p.gidx], grid.lon[p.gidx], p.seg1, p.seg2, p.icao1, p.icao2, p.t_heard1, p.d1, p.v1, p.d2, p.v2, tot, s1, s2, sh))
|
|
65
|
+
if progress and (k + 1) % 500 == 0:
|
|
66
|
+
log(f"scored {k + 1}/{len(pairs)} ({(time.time() - t0) / (k + 1) * 1000:.0f} ms each)")
|
|
67
|
+
cols = ["gidx", "lat", "lon", "seg1", "seg2", "icao1", "icao2", "t_heard1", "d1", "v1", "d2", "v2", "score", "s1", "s2", "shift"]
|
|
68
|
+
res = pd.DataFrame(out, columns=cols).sort_values("score", ascending=False).reset_index(drop=True)
|
|
69
|
+
return res, segs
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def refine(sp, ev1, ev2, segs, res_row, spacing_m=50.0, half_m=400.0, harmonics=(1, 1), elevation=None, c=None):
|
|
73
|
+
"""Fine grid around one result row for one flight pair."""
|
|
74
|
+
c = speed_of_sound() if c is None else c
|
|
75
|
+
lat, lon = float(res_row.lat), float(res_row.lon)
|
|
76
|
+
dlat = half_m / 111320.0; dlon = half_m / (111320.0 * np.cos(np.radians(lat)))
|
|
77
|
+
g = Grid.make(lat - dlat, lat + dlat, lon - dlon, lon + dlon, spacing_m, elevation)
|
|
78
|
+
# re-express grid in the segments' ENU frame (segments were built on the search grid origin)
|
|
79
|
+
from .geo import lla_to_enu
|
|
80
|
+
seg1, seg2 = segs[int(res_row.seg1)], segs[int(res_row.seg2)]
|
|
81
|
+
w1 = EventWindow.from_fit(sp, ev1, harmonics[0]); w2 = EventWindow.from_fit(sp, ev2, harmonics[1])
|
|
82
|
+
rows = []
|
|
83
|
+
for i in range(len(g)):
|
|
84
|
+
obs = lla_to_enu(g.lat[i], g.lon[i], g.elev[i], res_row.lat0, res_row.lon0)
|
|
85
|
+
d = np.linalg.norm(seg1.enu - obs, axis=1); j = int(np.argmin(d)); t_heard1 = seg1.t[j] + d[j] / c
|
|
86
|
+
tot, s1, s2, sh = locked_score(w1, w2, seg1, seg2, obs, float(t_heard1), c=c)
|
|
87
|
+
rows.append((g.lat[i], g.lon[i], tot, s1, s2, sh))
|
|
88
|
+
return pd.DataFrame(rows, columns=["lat", "lon", "score", "s1", "s2", "shift"]).sort_values("score", ascending=False).reset_index(drop=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_single_search(sp: Spectrogram, ev: EventFit, traces: list[Trace], grid: Grid, t_cpa_unix: float,
|
|
92
|
+
tol_s: float = 30.0, harmonics: int = 1, v_tol=0.35, d_tol=(0.4, 2.5),
|
|
93
|
+
max_range_m=12000.0, c=None) -> tuple[pd.DataFrame, list[Segment]]:
|
|
94
|
+
"""One event whose absolute time is known (±tol_s): rank grid points by how well the
|
|
95
|
+
curve they would hear from each nearby flight matches the spectrogram."""
|
|
96
|
+
from .score import single_score
|
|
97
|
+
c = speed_of_sound() if c is None else c
|
|
98
|
+
e0, e1 = grid.enu[:, 0].min() - max_range_m, grid.enu[:, 0].max() + max_range_m
|
|
99
|
+
n0, n1 = grid.enu[:, 1].min() - max_range_m, grid.enu[:, 1].max() + max_range_m
|
|
100
|
+
segs = build_segments(traces, grid.lat0, grid.lon0, clip_enu=(e0, e1, n0, n1))
|
|
101
|
+
# only segments alive around the event time
|
|
102
|
+
keep = [s for s in segs if s.t[0] - 300 <= t_cpa_unix <= s.t[-1] + 300]
|
|
103
|
+
log(f"{len(keep)} of {len(segs)} segments overlap the event time")
|
|
104
|
+
cpa = cpa_table(grid, keep, max_range_m=max_range_m, c=c)
|
|
105
|
+
m = cpa[(cpa.t_heard - t_cpa_unix).abs() <= tol_s]
|
|
106
|
+
m = m[m.v.between(ev.v * (1 - v_tol), ev.v * (1 + v_tol)) & m.d.between(ev.d * d_tol[0], ev.d * d_tol[1])]
|
|
107
|
+
log(f"{len(m)} candidate (point, flight) pairs")
|
|
108
|
+
w = EventWindow.from_fit(sp, ev, harmonics)
|
|
109
|
+
out = []
|
|
110
|
+
for p in m.itertuples():
|
|
111
|
+
s = single_score(w, keep[p.seg], grid.enu[p.gidx], float(p.t_heard), c=c)
|
|
112
|
+
out.append((p.gidx, grid.lat[p.gidx], grid.lon[p.gidx], p.seg, p.icao, p.t_heard, p.d, p.v, s))
|
|
113
|
+
res = pd.DataFrame(out, columns=["gidx", "lat", "lon", "seg", "icao", "t_heard", "d", "v", "score"]).sort_values("score", ascending=False).reset_index(drop=True)
|
|
114
|
+
return res, keep
|
dopplertrack/plots.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Diagnostic figures: event fits, modelled curves over the spectrogram, score maps."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import matplotlib
|
|
6
|
+
matplotlib.use("Agg")
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
|
|
9
|
+
from .audio import Spectrogram
|
|
10
|
+
from .doppler import ideal_curve, speed_of_sound
|
|
11
|
+
from .score import modelled_curve
|
|
12
|
+
from .tracks import EventFit, local_whiten
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _show(ax, sp: Spectrogram, vmax=10):
|
|
16
|
+
ax.imshow(sp.S, origin="lower", aspect="auto", extent=[sp.t[0], sp.t[-1], sp.f[0], sp.f[-1]],
|
|
17
|
+
vmin=0, vmax=vmax, cmap="magma")
|
|
18
|
+
ax.set_ylabel("Hz")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plot_event_fit(sp: Spectrogram, fit: EventFit, harmonics=1, pad=30, fmax=None, ax=None):
|
|
22
|
+
ax = ax or plt.gca()
|
|
23
|
+
w = local_whiten(sp.crop(fit.t_start - pad, fit.t_end + pad, fit.f_post * 0.7, fmax or fit.f_pre * 1.3 * harmonics))
|
|
24
|
+
_show(ax, w)
|
|
25
|
+
tt = np.linspace(w.t[0], w.t[-1], 600)
|
|
26
|
+
for h in range(1, harmonics + 1):
|
|
27
|
+
ax.plot(tt, h * ideal_curve(tt, fit.f0, fit.v, fit.d, fit.t_cpa_emit), "c-", lw=0.8)
|
|
28
|
+
ax.set_title(f"ideal fit: f0={fit.f0:.1f} Hz v={fit.v:.0f} m/s d={fit.d:.0f} m t_cpa={fit.t_cpa:.1f} s")
|
|
29
|
+
return ax
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plot_modelled(sp: Spectrogram, fit: EventFit, seg, obs_enu, t_audio_offset, f0_scale=1.0, harmonics=1,
|
|
33
|
+
pad=30, ax=None, c=None, label=None):
|
|
34
|
+
"""Overlay the curve a candidate observer would hear from `seg`. t_audio_offset maps
|
|
35
|
+
unix time to recording time (t_audio = t_unix + offset)."""
|
|
36
|
+
c = speed_of_sound() if c is None else c
|
|
37
|
+
ax = ax or plt.gca()
|
|
38
|
+
w = local_whiten(sp.crop(fit.t_start - pad, fit.t_end + pad, fit.f_post * 0.7, fit.f_pre * 1.3 * harmonics))
|
|
39
|
+
_show(ax, w)
|
|
40
|
+
to, ratio = modelled_curve(seg, obs_enu, c)
|
|
41
|
+
ta = to + t_audio_offset
|
|
42
|
+
for h in range(1, harmonics + 1):
|
|
43
|
+
ax.plot(ta, h * fit.f0 * f0_scale * ratio, "c-", lw=0.9)
|
|
44
|
+
ax.set_xlim(w.t[0], w.t[-1]); ax.set_ylim(w.f[0], w.f[-1])
|
|
45
|
+
if label:
|
|
46
|
+
ax.set_title(label)
|
|
47
|
+
return ax
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def plot_score_map(res, truth=None, ax=None, top=None, s=6):
|
|
51
|
+
"""Scatter of candidate scores (best per grid point)."""
|
|
52
|
+
ax = ax or plt.gca()
|
|
53
|
+
best = res.sort_values("score", ascending=False).drop_duplicates("gidx")
|
|
54
|
+
if top:
|
|
55
|
+
best = best.head(top)
|
|
56
|
+
sc = ax.scatter(best.lon, best.lat, c=best.score, s=s, cmap="viridis")
|
|
57
|
+
plt.colorbar(sc, ax=ax, label="combined score")
|
|
58
|
+
if truth is not None:
|
|
59
|
+
ax.plot(truth[1], truth[0], "r+", ms=14, mew=2, label="truth"); ax.legend()
|
|
60
|
+
ax.set_xlabel("lon"); ax.set_ylabel("lat"); ax.set_aspect(1 / np.cos(np.radians(best.lat.mean())))
|
|
61
|
+
return ax
|
dopplertrack/py.typed
ADDED
|
File without changes
|
dopplertrack/score.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Score candidate observers by modelling the exact Doppler curve they would hear."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .audio import Spectrogram
|
|
9
|
+
from .doppler import curve_from_trajectory, speed_of_sound
|
|
10
|
+
from .search import Segment
|
|
11
|
+
from .tracks import EventFit, local_whiten
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class EventWindow:
|
|
16
|
+
"""Whitened spectrogram crop for one event plus its measured parameters."""
|
|
17
|
+
fit: EventFit
|
|
18
|
+
sp: Spectrogram
|
|
19
|
+
harmonics: int = 1
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_fit(cls, sp: Spectrogram, fit: EventFit, harmonics: int = 1, pad_s: float = 40.0):
|
|
23
|
+
w = sp.crop(fit.t_start - pad_s, fit.t_end + pad_s, max(fit.f_post * 0.8, sp.f[0]),
|
|
24
|
+
min(fit.f_pre * 1.25 * harmonics, sp.f[-1]))
|
|
25
|
+
return cls(fit, local_whiten(w), harmonics)
|
|
26
|
+
|
|
27
|
+
def curve_score(self, t_audio, ratio, f0_scale=1.0) -> float:
|
|
28
|
+
"""Mean brightness under the curve, within the event's fit window."""
|
|
29
|
+
m = (t_audio >= self.fit.t_start) & (t_audio <= self.fit.t_end) & np.isfinite(ratio)
|
|
30
|
+
if m.sum() < 10:
|
|
31
|
+
return 0.0
|
|
32
|
+
s = 0.0
|
|
33
|
+
for h in range(1, self.harmonics + 1):
|
|
34
|
+
s += float(np.mean(self.sp.sample(t_audio[m], h * self.fit.f0 * f0_scale * ratio[m])))
|
|
35
|
+
return s / self.harmonics
|
|
36
|
+
|
|
37
|
+
def sweep_scores(self, t_audio, ratio, shifts, f0_scales, n=160) -> np.ndarray:
|
|
38
|
+
"""Scores for every (shift, f0_scale) combination in one vectorised pass.
|
|
39
|
+
The curve is resampled onto n points of the fit window for each shift."""
|
|
40
|
+
tq = np.linspace(self.fit.t_start, self.fit.t_end, n)
|
|
41
|
+
rel = tq[None, :] - np.asarray(shifts)[:, None] # (S, n) un-shifted audio time
|
|
42
|
+
ok = np.isfinite(ratio)
|
|
43
|
+
rat = np.interp(rel, t_audio[ok], ratio[ok], left=np.nan, right=np.nan) # (S, n)
|
|
44
|
+
valid = np.isfinite(rat).sum(1) >= 10 # (S,)
|
|
45
|
+
total = np.zeros((len(shifts), len(f0_scales)))
|
|
46
|
+
for h in range(1, self.harmonics + 1):
|
|
47
|
+
F = h * self.fit.f0 * np.asarray(f0_scales)[None, :, None] * rat[:, None, :] # (S, K, n)
|
|
48
|
+
T = np.broadcast_to(tq[None, None, :], F.shape)
|
|
49
|
+
v = self.sp.sample(T.ravel(), np.nan_to_num(F.ravel(), nan=-1.0)).reshape(F.shape)
|
|
50
|
+
v = np.where(np.isfinite(F), v, np.nan)
|
|
51
|
+
total += np.nanmean(v, axis=2)
|
|
52
|
+
total[~valid] = 0.0
|
|
53
|
+
return np.nan_to_num(total / self.harmonics, nan=0.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def modelled_curve(seg: Segment, obs_enu, c):
|
|
57
|
+
"""(t_obs unix, ratio f/f0) the observer at obs_enu hears from this segment."""
|
|
58
|
+
t_obs, ratio, r = curve_from_trajectory(seg.t, seg.enu, obs_enu, f0=1.0, c=c)
|
|
59
|
+
return t_obs, ratio
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def locked_score(ev1: EventWindow, ev2: EventWindow, seg1: Segment, seg2: Segment, obs_enu,
|
|
63
|
+
t_heard1: float, c: float | None = None,
|
|
64
|
+
shifts=np.arange(-8, 8.01, 0.5), f0_scales=np.arange(0.95, 1.0501, 0.005)):
|
|
65
|
+
"""Two-event locked scoring.
|
|
66
|
+
|
|
67
|
+
Event 1's placement is swept over small time shifts and f0 scales (both are
|
|
68
|
+
measurements). Event 2 is placed with the *same* time shift, so its timing
|
|
69
|
+
relative to event 1 is fixed purely by flight geometry.
|
|
70
|
+
Returns (total, s1, s2, best_shift).
|
|
71
|
+
"""
|
|
72
|
+
c = speed_of_sound() if c is None else c
|
|
73
|
+
# map unix time -> audio time so that the modelled heard-CPA of event 1 lands on the measured one
|
|
74
|
+
t0_map = ev1.fit.t_cpa - t_heard1
|
|
75
|
+
to1, r1 = modelled_curve(seg1, obs_enu, c)
|
|
76
|
+
to2, r2 = modelled_curve(seg2, obs_enu, c)
|
|
77
|
+
sc1 = ev1.sweep_scores(to1 + t0_map, r1, shifts, f0_scales)
|
|
78
|
+
i, j = np.unravel_index(np.argmax(sc1), sc1.shape)
|
|
79
|
+
s1, sh = float(sc1[i, j]), float(shifts[i])
|
|
80
|
+
s2 = float(ev2.sweep_scores(to2 + t0_map, r2, [sh], f0_scales).max())
|
|
81
|
+
return s1 + s2, s1, s2, sh
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def single_score(ev: EventWindow, seg: Segment, obs_enu, t_heard: float, c=None,
|
|
85
|
+
shifts=np.arange(-8, 8.01, 0.5), f0_scales=np.arange(0.95, 1.0501, 0.005)):
|
|
86
|
+
c = speed_of_sound() if c is None else c
|
|
87
|
+
to, r = modelled_curve(seg, obs_enu, c)
|
|
88
|
+
t0_map = ev.fit.t_cpa - t_heard
|
|
89
|
+
return float(ev.sweep_scores(to + t0_map, r, shifts, f0_scales).max())
|
dopplertrack/search.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Candidate observer generation: who could have heard which flight, when, how far."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from scipy.spatial import cKDTree
|
|
9
|
+
|
|
10
|
+
from .adsb import Trace
|
|
11
|
+
from .doppler import speed_of_sound
|
|
12
|
+
from .geo import lla_to_enu, enu_to_lla
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Grid:
|
|
17
|
+
lat: np.ndarray
|
|
18
|
+
lon: np.ndarray
|
|
19
|
+
elev: np.ndarray
|
|
20
|
+
enu: np.ndarray # (G,3) relative to (lat0, lon0, 0)
|
|
21
|
+
lat0: float
|
|
22
|
+
lon0: float
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def make(cls, lat_min, lat_max, lon_min, lon_max, spacing_m, elevation_fn=None):
|
|
26
|
+
lat0, lon0 = (lat_min + lat_max) / 2, (lon_min + lon_max) / 2
|
|
27
|
+
dlat = spacing_m / 111320.0
|
|
28
|
+
dlon = spacing_m / (111320.0 * np.cos(np.radians(lat0)))
|
|
29
|
+
la, lo = np.meshgrid(np.arange(lat_min, lat_max, dlat), np.arange(lon_min, lon_max, dlon), indexing="ij")
|
|
30
|
+
la, lo = la.ravel(), lo.ravel()
|
|
31
|
+
el = elevation_fn(la, lo) if elevation_fn else np.zeros_like(la)
|
|
32
|
+
el = np.maximum(el, 0.0)
|
|
33
|
+
return cls(la, lo, el, lla_to_enu(la, lo, el, lat0, lon0), lat0, lon0)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def points(cls, lat, lon, elev, lat0=None, lon0=None):
|
|
37
|
+
lat = np.atleast_1d(np.asarray(lat, float)); lon = np.atleast_1d(np.asarray(lon, float)); elev = np.atleast_1d(np.asarray(elev, float))
|
|
38
|
+
lat0 = float(lat.mean()) if lat0 is None else lat0; lon0 = float(lon.mean()) if lon0 is None else lon0
|
|
39
|
+
return cls(lat, lon, elev, lla_to_enu(lat, lon, elev, lat0, lon0), lat0, lon0)
|
|
40
|
+
|
|
41
|
+
def __len__(self):
|
|
42
|
+
return len(self.lat)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Segment:
|
|
47
|
+
trace_idx: int
|
|
48
|
+
icao: str
|
|
49
|
+
t: np.ndarray # emission times (unix s), 1 Hz
|
|
50
|
+
enu: np.ndarray # (N,3)
|
|
51
|
+
speed: np.ndarray # (N,) m/s
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_segments(traces: list[Trace], lat0: float, lon0: float, dt: float = 1.0, min_len: int = 20,
|
|
55
|
+
clip_enu: tuple[float, float, float, float] | None = None) -> list[Segment]:
|
|
56
|
+
"""1 Hz ENU segments. clip_enu=(e_min, e_max, n_min, n_max) keeps only the parts of a
|
|
57
|
+
track inside that box (split where it leaves), which bounds memory in cpa_table."""
|
|
58
|
+
segs = []
|
|
59
|
+
for i, tr in enumerate(traces):
|
|
60
|
+
for sl in tr.segments():
|
|
61
|
+
t, enu = tr.enu(sl, lat0, lon0, dt)
|
|
62
|
+
if len(t) < min_len or not np.all(np.isfinite(enu)):
|
|
63
|
+
continue
|
|
64
|
+
vel = np.gradient(enu, t, axis=0)
|
|
65
|
+
spd = np.linalg.norm(vel, axis=1)
|
|
66
|
+
if clip_enu is None:
|
|
67
|
+
segs.append(Segment(i, tr.icao, t, enu, spd))
|
|
68
|
+
continue
|
|
69
|
+
inside = (enu[:, 0] >= clip_enu[0]) & (enu[:, 0] <= clip_enu[1]) & (enu[:, 1] >= clip_enu[2]) & (enu[:, 1] <= clip_enu[3])
|
|
70
|
+
if not inside.any():
|
|
71
|
+
continue
|
|
72
|
+
edges = np.flatnonzero(np.diff(inside.astype(int))) + 1
|
|
73
|
+
for a, b in zip(np.r_[0, edges], np.r_[edges, len(t)]):
|
|
74
|
+
if inside[a] and b - a >= min_len:
|
|
75
|
+
segs.append(Segment(i, tr.icao, t[a:b], enu[a:b], spd[a:b]))
|
|
76
|
+
return segs
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cpa_table(grid: Grid, segs: list[Segment], max_range_m: float = 10000.0, c: float | None = None,
|
|
80
|
+
chunk: int = 20000) -> pd.DataFrame:
|
|
81
|
+
"""For every (grid point, segment) within max_range: CPA emission time, heard time,
|
|
82
|
+
slant distance and ground-track speed at CPA."""
|
|
83
|
+
c = speed_of_sound() if c is None else c
|
|
84
|
+
rows = []
|
|
85
|
+
tree = cKDTree(grid.enu[:, :2])
|
|
86
|
+
for si, s in enumerate(segs):
|
|
87
|
+
# grid points within max_range (horizontally) of the track polyline, via a KD-tree
|
|
88
|
+
# on track samples spaced <= max_range/4 apart (so the union of balls covers the corridor)
|
|
89
|
+
step = max(1, int(max_range_m / 4 / max(np.median(s.speed), 1.0)))
|
|
90
|
+
probes = s.enu[::step, :2]
|
|
91
|
+
hits = tree.query_ball_point(probes, r=max_range_m * 1.05)
|
|
92
|
+
m = np.unique(np.concatenate([np.asarray(h, int) for h in hits])) if len(hits) else np.array([], int)
|
|
93
|
+
if len(m) == 0:
|
|
94
|
+
continue
|
|
95
|
+
for k in range(0, len(m), chunk):
|
|
96
|
+
idx = m[k:k + chunk]
|
|
97
|
+
g = grid.enu[idx].astype(np.float32) # (G,3)
|
|
98
|
+
nstep = max(1, int(2e7 // max(len(idx), 1)))
|
|
99
|
+
best = np.full(len(idx), np.inf); j = np.zeros(len(idx), int)
|
|
100
|
+
for n0 in range(0, len(s.t), nstep):
|
|
101
|
+
d2 = ((g[:, None, :] - s.enu[None, n0:n0 + nstep, :].astype(np.float32)) ** 2).sum(-1) # (G,n)
|
|
102
|
+
jj = np.argmin(d2, axis=1)
|
|
103
|
+
dd = d2[np.arange(len(idx)), jj]
|
|
104
|
+
upd = dd < best
|
|
105
|
+
best[upd] = dd[upd]; j[upd] = jj[upd] + n0
|
|
106
|
+
r = np.sqrt(best)
|
|
107
|
+
ok = (r <= max_range_m) & (j > 0) & (j < len(s.t) - 1) # interior CPA only
|
|
108
|
+
if not ok.any():
|
|
109
|
+
continue
|
|
110
|
+
rows.append(pd.DataFrame({
|
|
111
|
+
"gidx": idx[ok], "seg": si, "trace": s.trace_idx, "icao": s.icao,
|
|
112
|
+
"t_emit": s.t[j[ok]], "t_heard": s.t[j[ok]] + r[ok] / c,
|
|
113
|
+
"d": r[ok], "v": s.speed[j[ok]],
|
|
114
|
+
}))
|
|
115
|
+
if not rows:
|
|
116
|
+
return pd.DataFrame(columns=["gidx", "seg", "trace", "icao", "t_emit", "t_heard", "d", "v"])
|
|
117
|
+
return pd.concat(rows, ignore_index=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pair_candidates(cpa: pd.DataFrame, dt_offset: float, tol_s: float = 6.0,
|
|
121
|
+
v1=(0, 1e9), d1=(0, 1e9), v2=(0, 1e9), d2=(0, 1e9),
|
|
122
|
+
silence_d_m: float | None = 6000.0, silence_margin_s: float = 40.0) -> pd.DataFrame:
|
|
123
|
+
"""Grid points that hear an event-1-like CPA then, dt_offset later, an event-2-like CPA."""
|
|
124
|
+
a = cpa[(cpa.v.between(*v1)) & (cpa.d.between(*d1))]
|
|
125
|
+
b = cpa[(cpa.v.between(*v2)) & (cpa.d.between(*d2))]
|
|
126
|
+
m = a.merge(b, on="gidx", suffixes=("1", "2"))
|
|
127
|
+
m = m[(m.seg1 != m.seg2) & ((m.t_heard2 - m.t_heard1 - dt_offset).abs() <= tol_s)].copy()
|
|
128
|
+
if silence_d_m is not None and len(m):
|
|
129
|
+
near = cpa[cpa.d <= silence_d_m][["gidx", "seg", "t_heard"]]
|
|
130
|
+
x = m[["gidx", "seg1", "seg2", "t_heard1", "t_heard2"]].reset_index().merge(near, on="gidx")
|
|
131
|
+
bad = x[(x.seg != x.seg1) & (x.seg != x.seg2) & (x.t_heard > x.t_heard1 + silence_margin_s) & (x.t_heard < x.t_heard2 - silence_margin_s)]
|
|
132
|
+
m = m.drop(index=bad["index"].unique())
|
|
133
|
+
return m.reset_index(drop=True)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def grid_lla(grid: Grid, gidx):
|
|
137
|
+
return grid.lat[gidx], grid.lon[gidx], grid.elev[gidx]
|
dopplertrack/terrain.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Ground elevation from AWS Terrain Tiles (Terrarium encoding), cached on disk."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import requests
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
_URL = "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Terrain:
|
|
16
|
+
def __init__(self, zoom: int = 12, cache_dir: str | Path | None = None):
|
|
17
|
+
self.z = zoom
|
|
18
|
+
self.cache = Path(cache_dir or os.environ.get("DOPPLERTRACK_CACHE", Path.home() / ".cache" / "dopplertrack")) / "terrarium" / str(zoom)
|
|
19
|
+
self.cache.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
self._mem: dict[tuple[int, int], np.ndarray] = {}
|
|
21
|
+
|
|
22
|
+
def _tile(self, x: int, y: int) -> np.ndarray:
|
|
23
|
+
k = (x, y)
|
|
24
|
+
if k in self._mem:
|
|
25
|
+
return self._mem[k]
|
|
26
|
+
p = self.cache / f"{x}_{y}.png"
|
|
27
|
+
if not p.exists():
|
|
28
|
+
r = requests.get(_URL.format(z=self.z, x=x, y=y), timeout=60)
|
|
29
|
+
r.raise_for_status()
|
|
30
|
+
p.write_bytes(r.content)
|
|
31
|
+
img = np.asarray(Image.open(p).convert("RGB"), dtype=np.float64)
|
|
32
|
+
elev = img[..., 0] * 256 + img[..., 1] + img[..., 2] / 256 - 32768
|
|
33
|
+
self._mem[k] = elev
|
|
34
|
+
return elev
|
|
35
|
+
|
|
36
|
+
def elevation(self, lat, lon) -> np.ndarray:
|
|
37
|
+
"""Elevation in metres (MSL) for arrays of lat/lon. Nearest pixel."""
|
|
38
|
+
lat = np.atleast_1d(np.asarray(lat, float)); lon = np.atleast_1d(np.asarray(lon, float))
|
|
39
|
+
n = 2 ** self.z
|
|
40
|
+
xf = (lon + 180.0) / 360.0 * n
|
|
41
|
+
latr = np.radians(lat)
|
|
42
|
+
yf = (1 - np.log(np.tan(latr) + 1 / np.cos(latr)) / np.pi) / 2 * n
|
|
43
|
+
xt = np.floor(xf).astype(int); yt = np.floor(yf).astype(int)
|
|
44
|
+
px = np.clip(((xf - xt) * 256).astype(int), 0, 255); py = np.clip(((yf - yt) * 256).astype(int), 0, 255)
|
|
45
|
+
out = np.zeros(lat.shape)
|
|
46
|
+
for (tx, ty) in set(zip(xt.tolist(), yt.tolist())):
|
|
47
|
+
m = (xt == tx) & (yt == ty)
|
|
48
|
+
out[m] = self._tile(tx, ty)[py[m], px[m]]
|
|
49
|
+
return out
|
dopplertrack/tracks.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Extract a Doppler event from a spectrogram window and fit the ideal model."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, asdict
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from scipy.optimize import minimize
|
|
8
|
+
|
|
9
|
+
from .audio import Spectrogram
|
|
10
|
+
from .doppler import ideal_curve, speed_of_sound
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class EventFit:
|
|
15
|
+
t_cpa: float # heard CPA time in the recording, s (observation time of the f0 crossing)
|
|
16
|
+
t_cpa_emit: float # emission-time CPA (t_cpa - d/c)
|
|
17
|
+
f0: float # rest fundamental, Hz
|
|
18
|
+
v: float # speed, m/s
|
|
19
|
+
d: float # CPA slant distance, m
|
|
20
|
+
t_start: float
|
|
21
|
+
t_end: float
|
|
22
|
+
score: float # mean whitened dB along the fitted curve
|
|
23
|
+
f_pre: float
|
|
24
|
+
f_post: float
|
|
25
|
+
|
|
26
|
+
def to_dict(self):
|
|
27
|
+
return asdict(self)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def local_whiten(sp: Spectrogram, floor: float = 0.0) -> Spectrogram:
|
|
31
|
+
"""Remove tones that are stationary over this window (per-bin median), clip at floor."""
|
|
32
|
+
S = sp.S - np.median(sp.S, axis=1, keepdims=True)
|
|
33
|
+
return Spectrogram(sp.t, sp.f, np.maximum(S, floor).astype(np.float32))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ridge(sp: Spectrogram, max_step_bins: int = 3, smooth: float = 1.5) -> np.ndarray:
|
|
37
|
+
"""Viterbi ridge through S (F,T): maximise sum(S) - smooth*|Δbin|. Returns freq per frame."""
|
|
38
|
+
S = sp.S
|
|
39
|
+
F, T = S.shape
|
|
40
|
+
cost = np.full((T, F), -np.inf)
|
|
41
|
+
back = np.zeros((T, F), np.int16)
|
|
42
|
+
cost[0] = S[:, 0]
|
|
43
|
+
offs = np.arange(-max_step_bins, max_step_bins + 1)
|
|
44
|
+
pen = smooth * np.abs(offs)
|
|
45
|
+
for t in range(1, T):
|
|
46
|
+
prev = cost[t - 1]
|
|
47
|
+
cand = np.full((len(offs), F), -np.inf)
|
|
48
|
+
for k, o in enumerate(offs):
|
|
49
|
+
if o >= 0:
|
|
50
|
+
cand[k, o:] = prev[: F - o] - pen[k]
|
|
51
|
+
else:
|
|
52
|
+
cand[k, :o] = prev[-o:] - pen[k]
|
|
53
|
+
kbest = np.argmax(cand, axis=0)
|
|
54
|
+
cost[t] = cand[kbest, np.arange(F)] + S[:, t]
|
|
55
|
+
back[t] = offs[kbest]
|
|
56
|
+
path = np.zeros(T, int)
|
|
57
|
+
path[-1] = int(np.argmax(cost[-1]))
|
|
58
|
+
for t in range(T - 1, 0, -1):
|
|
59
|
+
path[t - 1] = path[t] - back[t, path[t]]
|
|
60
|
+
return sp.f[path]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _shape(v, d, c, span=400.0, n=161):
|
|
64
|
+
"""Observation-time offsets and frequency ratios for a straight pass, relative to
|
|
65
|
+
emission-time CPA (tau=0)."""
|
|
66
|
+
tau = np.linspace(-span, span, n)
|
|
67
|
+
r = np.sqrt(d * d + (v * tau) ** 2)
|
|
68
|
+
rr = v * v * tau / r
|
|
69
|
+
return tau + r / c, c / (c + rr)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def grid_search(sp: Spectrogram, t_start, t_end, f_lo, f_hi, c, harmonics=1,
|
|
73
|
+
v_grid=None, d_grid=None, f0_step=1.0, t0_step=1.0, n_curve=120):
|
|
74
|
+
"""Coarse maximisation of brightness under ideal curves. Returns (score, f0, v, d, t0_emit)."""
|
|
75
|
+
v_grid = np.arange(30, 170, 10.0) if v_grid is None else v_grid
|
|
76
|
+
d_grid = np.geomspace(300, 9000, 14) if d_grid is None else d_grid
|
|
77
|
+
f0s = np.arange(f_lo * 1.15, f_hi * 0.85, f0_step)
|
|
78
|
+
t0s = np.arange(t_start, t_end, t0_step)
|
|
79
|
+
tq = np.linspace(t_start, t_end, n_curve)
|
|
80
|
+
best = (-np.inf, None)
|
|
81
|
+
for v in v_grid:
|
|
82
|
+
for d in d_grid:
|
|
83
|
+
dto, ratio = _shape(v, d, c)
|
|
84
|
+
# for each t0: curve observation times = t0 + dto; frequencies = f0*ratio
|
|
85
|
+
# sample at tq by interpolating ratio onto tq - t0
|
|
86
|
+
rel = tq[None, :] - t0s[:, None] # (T0, N)
|
|
87
|
+
rat = np.interp(rel, dto, ratio, left=np.nan, right=np.nan) # (T0, N)
|
|
88
|
+
total = np.zeros((len(t0s), len(f0s)))
|
|
89
|
+
for h in range(1, harmonics + 1):
|
|
90
|
+
F = h * f0s[None, None, :] * rat[:, :, None] # (T0, N, F0)
|
|
91
|
+
T = np.broadcast_to(tq[None, :, None], F.shape)
|
|
92
|
+
vals = sp.sample(T.ravel(), F.ravel()).reshape(F.shape)
|
|
93
|
+
total += np.nanmean(vals, axis=1)
|
|
94
|
+
i, j = np.unravel_index(np.argmax(total), total.shape)
|
|
95
|
+
if total[i, j] > best[0]:
|
|
96
|
+
best = (float(total[i, j] / harmonics), (float(f0s[j]), float(v), float(d), float(t0s[i])))
|
|
97
|
+
return best
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fit_event(sp: Spectrogram, t_start: float, t_end: float, f_lo: float, f_hi: float,
|
|
101
|
+
c: float | None = None, harmonics: int = 1, whiten: bool = True) -> EventFit:
|
|
102
|
+
"""Fit (f0, v, d, t_cpa) to the brightest Doppler-shaped curve in a window."""
|
|
103
|
+
c = speed_of_sound() if c is None else c
|
|
104
|
+
pad = 0.3 * (f_hi - f_lo)
|
|
105
|
+
win = sp.crop(t_start - 30, t_end + 30, max(f_lo - pad, sp.f[0]), min(f_hi * harmonics + pad, sp.f[-1]))
|
|
106
|
+
if whiten:
|
|
107
|
+
win = local_whiten(win)
|
|
108
|
+
score0, (f0, v, d, t0) = grid_search(win, t_start, t_end, f_lo, f_hi, c, harmonics)
|
|
109
|
+
tt = np.linspace(t_start, t_end, 400)
|
|
110
|
+
|
|
111
|
+
def neg(p):
|
|
112
|
+
f0_, v_, d_, t0_ = p
|
|
113
|
+
if not (10 < v_ < 300 and 100 < d_ < 30000):
|
|
114
|
+
return 1e3
|
|
115
|
+
s = 0.0
|
|
116
|
+
for h in range(1, harmonics + 1):
|
|
117
|
+
s += float(np.nanmean(win.sample(tt, ideal_curve(tt, h * f0_, v_, d_, t0_, c=c))))
|
|
118
|
+
return -s / harmonics
|
|
119
|
+
|
|
120
|
+
best = minimize(neg, [f0, v, d, t0], method="Nelder-Mead",
|
|
121
|
+
options={"xatol": 1e-2, "fatol": 1e-4, "maxiter": 4000,
|
|
122
|
+
"initial_simplex": np.array([[f0, v, d, t0], [f0 + 2, v, d, t0], [f0, v + 10, d, t0],
|
|
123
|
+
[f0, v, d * 1.3, t0], [f0, v, d, t0 + 2]])})
|
|
124
|
+
f0, v, d, t0 = best.x
|
|
125
|
+
return EventFit(t_cpa=float(t0 + d / c), t_cpa_emit=float(t0), f0=float(f0), v=float(v), d=float(d),
|
|
126
|
+
t_start=t_start, t_end=t_end, score=float(-best.fun),
|
|
127
|
+
f_pre=float(f0 * c / (c - v)), f_post=float(f0 * c / (c + v)))
|
|
@@ -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,18 @@
|
|
|
1
|
+
dopplertrack/__init__.py,sha256=adQDvyRTcwnIYAkk1RW-3DWa50_jxjeHc0C3je-JYtE,58
|
|
2
|
+
dopplertrack/adsb.py,sha256=Z7l5ydWRE1TYYSCmENVWMQd-UbgJfu8wlgwSH2i-Al0,5658
|
|
3
|
+
dopplertrack/audio.py,sha256=TMdnX_VOm1mOdm8RxvzJG5RF4bMrWf8OrT-QISKnY08,4254
|
|
4
|
+
dopplertrack/cli.py,sha256=ba9-9rVHrzo03k0UUwCLs6SyILAuarzz0O0FFQ1Dq9A,5218
|
|
5
|
+
dopplertrack/doppler.py,sha256=T5Pkt_WfNAj9olOLsjDCIgIy69rI0JLgO1exmtYAt98,2668
|
|
6
|
+
dopplertrack/geo.py,sha256=xKwgXrj-5uZjOocm1nX64h7l2MsJ23SbjfBIvGHrLcY,2051
|
|
7
|
+
dopplertrack/pipeline.py,sha256=KYon4w9Cz8hMPF404YEk0aJM2oZz4TruIA76X3P_7Rk,6529
|
|
8
|
+
dopplertrack/plots.py,sha256=5nWj7WSZ1ZCCaBv-bHTZBVD9gXXaSbssfpa-ZffwVEw,2582
|
|
9
|
+
dopplertrack/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
dopplertrack/score.py,sha256=zMTph87GiLaxN9Q4C41wSVXuCqL_xa34990_7ww3TJk,4202
|
|
11
|
+
dopplertrack/search.py,sha256=oIRGlwkF5XdwAJ6dM0cVvypO8GLVfROUGNBicWalYY4,6392
|
|
12
|
+
dopplertrack/terrain.py,sha256=8JdxYh095bcIR4jyMf06epyb4SoXaLImfDFayDfVlq8,1997
|
|
13
|
+
dopplertrack/tracks.py,sha256=nZylbmojHqlXEebi_MQx9LRpScOOwB8AMS27FkKo3so,5526
|
|
14
|
+
dopplertrack-0.1.0.dist-info/licenses/LICENSE,sha256=XT1Wkw8MQ_neNrlQzFNUgmK09_zMGI7QENUyqaUwu1w,1077
|
|
15
|
+
dopplertrack-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
16
|
+
dopplertrack-0.1.0.dist-info/entry_points.txt,sha256=Xl1GMJAzPVkwf2h4qKQ-vVYEXHsB4T9IZl6ob0jefNg,56
|
|
17
|
+
dopplertrack-0.1.0.dist-info/METADATA,sha256=hMLA7bz0IhwwauVKYTJRsEPnqq3eoDWF8VJmu3nkdxA,6951
|
|
18
|
+
dopplertrack-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|