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.
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from dopplertrack!"
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()
@@ -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))
@@ -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]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dopplertrack = dopplertrack.cli:main
3
+
@@ -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.