thunderprint 0.1.0__py3-none-win_amd64.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.
- thunderprint/__init__.py +126 -0
- thunderprint/_cli.py +53 -0
- thunderprint/py.typed +0 -0
- thunderprint/results.py +119 -0
- thunderprint-0.1.0.data/scripts/thunderprint.exe +0 -0
- thunderprint-0.1.0.dist-info/METADATA +193 -0
- thunderprint-0.1.0.dist-info/RECORD +10 -0
- thunderprint-0.1.0.dist-info/WHEEL +4 -0
- thunderprint-0.1.0.dist-info/licenses/LICENSE +21 -0
- thunderprint-0.1.0.dist-info/sboms/thunderprint.cyclonedx.json +4437 -0
thunderprint/__init__.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Locate where and when a recording was made from the thunder in it.
|
|
2
|
+
|
|
3
|
+
import thunderprint as tp
|
|
4
|
+
res = tp.locate("storm.opus", "2025-07-19")
|
|
5
|
+
print(res.best) # Region(rank=1, lat=..., lon=..., start=..., reliable=True, ...)
|
|
6
|
+
env = res.envelope() # observed vs modelled thunder over UTC time
|
|
7
|
+
res.viewer # path to the interactive HTML map
|
|
8
|
+
|
|
9
|
+
Lightning comes from MET Norway's Frost API (Nordic coverage; set FROST_CLIENT_ID or pass
|
|
10
|
+
`frost_client_id`), or from your own strikes via `strikes=` (a CSV path, or rows / a DataFrame
|
|
11
|
+
with time, lat, lon[, peak_ka, cloud, error_km]).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import tempfile
|
|
18
|
+
from datetime import date as _date, datetime, time as _time, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Iterable, Mapping
|
|
21
|
+
|
|
22
|
+
from ._cli import ThunderprintError, run
|
|
23
|
+
from .results import Envelope, Region, Result, Spectrogram, load
|
|
24
|
+
|
|
25
|
+
__all__ = ["locate", "simulate", "fetch", "load", "Result", "Region", "Envelope", "Spectrogram", "ThunderprintError"]
|
|
26
|
+
|
|
27
|
+
STRIKE_COLUMNS = ("time", "lat", "lon", "peak_ka", "cloud", "error_km")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def locate(
|
|
31
|
+
audio: str | Path,
|
|
32
|
+
date: str | _date,
|
|
33
|
+
*,
|
|
34
|
+
start: str | _time | None = None,
|
|
35
|
+
strikes: str | Path | Iterable[Mapping] | None = None,
|
|
36
|
+
out_dir: str | Path | None = None,
|
|
37
|
+
model: str = "improved",
|
|
38
|
+
overrides: str | Path | None = None,
|
|
39
|
+
frost_client_id: str | None = None,
|
|
40
|
+
quiet: bool = True,
|
|
41
|
+
) -> Result:
|
|
42
|
+
"""Searches every strike on UTC `date` for where and when `audio` was recorded.
|
|
43
|
+
|
|
44
|
+
`start` (UTC time of day) narrows the search to ±0.5 s when the recording start is known.
|
|
45
|
+
Results are written to `out_dir` (a temp dir if omitted) and returned parsed. Takes minutes.
|
|
46
|
+
"""
|
|
47
|
+
out = Path(out_dir) if out_dir else Path(tempfile.mkdtemp(prefix="thunderprint-"))
|
|
48
|
+
with _strike_file(strikes) as strikes_path:
|
|
49
|
+
args = ["locate", "--audio", str(audio), "--date", _iso_date(date), "--model", model, "--out", str(out)]
|
|
50
|
+
args += _opt("--start", start and _iso_time(start)) + _opt("--overrides", overrides) + _opt("--strikes", strikes_path)
|
|
51
|
+
run(args, frost_client_id, quiet)
|
|
52
|
+
return load(out)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def simulate(
|
|
56
|
+
date: str | _date,
|
|
57
|
+
lat: float,
|
|
58
|
+
lon: float,
|
|
59
|
+
start: str | _time,
|
|
60
|
+
out_dir: str | Path,
|
|
61
|
+
*,
|
|
62
|
+
strikes: str | Path | Iterable[Mapping] | None = None,
|
|
63
|
+
frost_client_id: str | None = None,
|
|
64
|
+
**options: float,
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""Synthesises a thunder recording at (`lat`, `lon`) from real strikes; returns the audio.wav path.
|
|
67
|
+
|
|
68
|
+
`options` map to `thunderprint sim` flags, e.g. duration=260, temp=14, wind_e=0, noise=0.02, seed=1.
|
|
69
|
+
"""
|
|
70
|
+
out = Path(out_dir)
|
|
71
|
+
with _strike_file(strikes) as strikes_path:
|
|
72
|
+
args = ["sim", "--date", _iso_date(date), f"--lat={lat}", f"--lon={lon}", "--start", _iso_time(start), "--out", str(out)]
|
|
73
|
+
args += [f"--{k.replace('_', '-')}={v}" for k, v in options.items()] + _opt("--strikes", strikes_path)
|
|
74
|
+
run(args, frost_client_id)
|
|
75
|
+
return out / "audio.wav"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fetch(date: str | _date, *, frost_client_id: str | None = None) -> int:
|
|
79
|
+
"""Downloads and caches one UTC day of Frost lightning; returns the strike count."""
|
|
80
|
+
return int(run(["fetch", "--date", _iso_date(date)], frost_client_id).split()[0])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _opt(flag: str, value) -> list[str]:
|
|
84
|
+
return [flag, str(value)] if value else []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _iso_date(d: str | _date) -> str:
|
|
88
|
+
return d.isoformat() if isinstance(d, _date) else d
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _iso_time(t: str | _time) -> str:
|
|
92
|
+
return t.isoformat() if isinstance(t, _time) else t
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _strike_file:
|
|
96
|
+
"""Context manager: a CSV path for `strikes`, writing rows / a DataFrame to a temp file if needed."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, strikes):
|
|
99
|
+
self.strikes, self.tmp = strikes, None
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> str | None:
|
|
102
|
+
s = self.strikes
|
|
103
|
+
if s is None or isinstance(s, (str, Path)):
|
|
104
|
+
return s and str(s)
|
|
105
|
+
rows = s.to_dict("records") if hasattr(s, "to_dict") else list(s)
|
|
106
|
+
self.tmp = tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, newline="")
|
|
107
|
+
cols = [c for c in STRIKE_COLUMNS if rows and c in rows[0]]
|
|
108
|
+
w = csv.writer(self.tmp)
|
|
109
|
+
w.writerow(cols)
|
|
110
|
+
for r in rows:
|
|
111
|
+
w.writerow([_csv_value(r[c]) for c in cols])
|
|
112
|
+
self.tmp.close()
|
|
113
|
+
return self.tmp.name
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc):
|
|
116
|
+
if self.tmp:
|
|
117
|
+
Path(self.tmp.name).unlink(missing_ok=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _csv_value(v) -> str:
|
|
121
|
+
if isinstance(v, datetime):
|
|
122
|
+
# Naive datetimes are UTC here; .timestamp() alone would read them as local time.
|
|
123
|
+
return f"{(v if v.tzinfo else v.replace(tzinfo=timezone.utc)).timestamp():.6f}"
|
|
124
|
+
if isinstance(v, bool):
|
|
125
|
+
return "1" if v else "0"
|
|
126
|
+
return str(v)
|
thunderprint/_cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Locate and run the bundled `thunderprint` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import sysconfig
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
EXE = "thunderprint" + (".exe" if sys.platform == "win32" else "")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ThunderprintError(RuntimeError):
|
|
16
|
+
"""The thunderprint binary exited with an error; `stderr` holds its message."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, args: list[str], stderr: str):
|
|
19
|
+
super().__init__(f"thunderprint {' '.join(args[:1])} failed:\n{stderr.strip()}")
|
|
20
|
+
self.stderr = stderr
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def binary() -> str:
|
|
24
|
+
# The wheel installs the binary into this interpreter's scripts dir, which may not be on PATH (e.g. --user installs).
|
|
25
|
+
for d in (sysconfig.get_path("scripts"), sysconfig.get_path("scripts", f"{os.name}_user")):
|
|
26
|
+
if d and (Path(d) / EXE).is_file():
|
|
27
|
+
return str(Path(d) / EXE)
|
|
28
|
+
found = shutil.which("thunderprint")
|
|
29
|
+
if not found:
|
|
30
|
+
raise FileNotFoundError("thunderprint binary not found; reinstall the package or put it on PATH")
|
|
31
|
+
return found
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_cache() -> Path:
|
|
35
|
+
if sys.platform == "win32":
|
|
36
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
37
|
+
elif sys.platform == "darwin":
|
|
38
|
+
base = Path.home() / "Library" / "Caches"
|
|
39
|
+
else:
|
|
40
|
+
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
41
|
+
return base / "thunderprint" / "frost"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(args: list[str], frost_client_id: str | None = None, quiet: bool = True) -> str:
|
|
45
|
+
"""Runs the binary with `args`; returns stdout. Progress (stderr) is shown unless `quiet`."""
|
|
46
|
+
env = dict(os.environ)
|
|
47
|
+
env.setdefault("THUNDERPRINT_CACHE", str(default_cache()))
|
|
48
|
+
if frost_client_id:
|
|
49
|
+
env["FROST_CLIENT_ID"] = frost_client_id
|
|
50
|
+
proc = subprocess.run([binary(), *args], env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE if quiet else None)
|
|
51
|
+
if proc.returncode != 0:
|
|
52
|
+
raise ThunderprintError(args, proc.stderr or "")
|
|
53
|
+
return proc.stdout
|
thunderprint/py.typed
ADDED
|
File without changes
|
thunderprint/results.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Typed views of a `locate` output directory (candidates.json + trace.json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utc(epoch: float) -> datetime:
|
|
13
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Region:
|
|
18
|
+
"""One candidate recording location. `rank` is 1-based, as in the viewer."""
|
|
19
|
+
|
|
20
|
+
rank: int
|
|
21
|
+
lat: float
|
|
22
|
+
lon: float
|
|
23
|
+
start: datetime
|
|
24
|
+
probability: float
|
|
25
|
+
rho: float
|
|
26
|
+
cr90_area_km2: float
|
|
27
|
+
reliable: bool
|
|
28
|
+
audible_strikes: int
|
|
29
|
+
ellipse_km: tuple[float, float]
|
|
30
|
+
ellipse_bearing_deg: float
|
|
31
|
+
speed_of_sound_mps: float
|
|
32
|
+
wind_mps: tuple[float, float]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Spectrogram:
|
|
37
|
+
"""Log-frequency power, scaled 0–255 over the top 45 dB. `power[band][frame]`."""
|
|
38
|
+
|
|
39
|
+
freqs_hz: list[float]
|
|
40
|
+
offsets_s: list[float]
|
|
41
|
+
power: list[list[int]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Envelope:
|
|
46
|
+
"""Observed and modelled thunder envelopes (each scaled to unit max) for one region, sampled at `times`."""
|
|
47
|
+
|
|
48
|
+
times: list[datetime]
|
|
49
|
+
observed: list[float]
|
|
50
|
+
modeled: list[float]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Result:
|
|
55
|
+
out_dir: Path
|
|
56
|
+
regions: list[Region]
|
|
57
|
+
spectrogram: Spectrogram
|
|
58
|
+
_traces: dict
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def best(self) -> Region | None:
|
|
62
|
+
return self.regions[0] if self.regions else None
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def viewer(self) -> Path:
|
|
66
|
+
"""Self-contained HTML map + trace panel; open it in a browser."""
|
|
67
|
+
return self.out_dir / "index.html"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def geopackage(self) -> Path:
|
|
71
|
+
"""GeoPackage (layers: cells, candidates, ellipses, onsets) for ArcGIS Pro / QGIS / geopandas."""
|
|
72
|
+
return self.out_dir / "result.gpkg"
|
|
73
|
+
|
|
74
|
+
def envelope(self, rank: int = 1) -> Envelope:
|
|
75
|
+
tr = next(r for r in self._traces["regions"] if r["rank"] == rank)
|
|
76
|
+
step = 1.0 / self._traces["rate_hz"]
|
|
77
|
+
t0 = _utc(tr["start"])
|
|
78
|
+
times = [t0 + timedelta(seconds=i * step) for i in range(len(self._traces["observed"]))]
|
|
79
|
+
return Envelope(times, self._traces["observed"], tr["model"])
|
|
80
|
+
|
|
81
|
+
def spectrogram_times(self, rank: int = 1) -> list[datetime]:
|
|
82
|
+
t0 = self.regions[rank - 1].start
|
|
83
|
+
return [t0 + timedelta(seconds=s) for s in self.spectrogram.offsets_s]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load(out_dir: str | Path) -> Result:
|
|
87
|
+
"""Reads a finished `locate` output directory."""
|
|
88
|
+
out_dir = Path(out_dir)
|
|
89
|
+
cand = json.loads((out_dir / "candidates.json").read_text())
|
|
90
|
+
trace = json.loads((out_dir / "trace.json").read_text())
|
|
91
|
+
regions = [_region(r) for r in cand["regions"]]
|
|
92
|
+
return Result(out_dir, regions, _spectrogram(trace["spectrogram"]), trace["traces"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _region(r: dict) -> Region:
|
|
96
|
+
sc, sh, atm = r["score"], r["shape"], r["atmosphere"]
|
|
97
|
+
return Region(
|
|
98
|
+
rank=r["rank"] + 1,
|
|
99
|
+
lat=r["map"]["lat"],
|
|
100
|
+
lon=r["map"]["lon"],
|
|
101
|
+
start=_utc(sc["best_start"]),
|
|
102
|
+
probability=r["region_prob"],
|
|
103
|
+
rho=sc["best_rho"],
|
|
104
|
+
cr90_area_km2=r["cr90_area_km2"],
|
|
105
|
+
reliable=r["reliable"],
|
|
106
|
+
audible_strikes=sh["audible_strikes"],
|
|
107
|
+
ellipse_km=(2 * sh["major_km"], 2 * sh["minor_km"]),
|
|
108
|
+
ellipse_bearing_deg=sh["major_bearing_deg"],
|
|
109
|
+
speed_of_sound_mps=atm["c_mps"],
|
|
110
|
+
wind_mps=(atm["wind_e_mps"], atm["wind_n_mps"]),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _spectrogram(s: dict) -> Spectrogram:
|
|
115
|
+
raw = base64.b64decode(s["data_b64"])
|
|
116
|
+
n = s["frames"]
|
|
117
|
+
power = [list(raw[k * n : (k + 1) * n]) for k in range(len(s["freqs_hz"]))]
|
|
118
|
+
offsets = [s["t0_s"] + i * s["hop_s"] for i in range(n)]
|
|
119
|
+
return Spectrogram(s["freqs_hz"], offsets, power)
|
|
Binary file
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thunderprint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
5
|
+
Classifier: Programming Language :: Rust
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
8
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Summary: Locate where and when an audio recording was made from the thunder in it
|
|
11
|
+
Keywords: geolocation,thunder,lightning,acoustics,osint
|
|
12
|
+
License: MIT
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
15
|
+
Project-URL: Repository, https://github.com/Hexay/thunderprint
|
|
16
|
+
|
|
17
|
+
# thunderprint
|
|
18
|
+
|
|
19
|
+
[](https://github.com/Hexay/thunderprint/actions/workflows/ci.yml)
|
|
20
|
+
[](https://pypi.org/project/thunderprint/)
|
|
21
|
+
[](https://crates.io/crates/thunderprint)
|
|
22
|
+
[](LICENSE)
|
|
23
|
+
|
|
24
|
+
Work out where and when an audio recording was made using only the thunder in it.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
Lightning networks log every strike with millisecond timing and positions accurate to a few hundred metres.
|
|
29
|
+
Each strike's thunder reaches a listener after a delay set by distance. A recording of a storm therefore
|
|
30
|
+
carries the listener's position, as the delay pattern across dozens of strikes. thunderprint searches a
|
|
31
|
+
day of strikes from [MET Norway's Frost API](https://frost.met.no) for the place and start time whose
|
|
32
|
+
predicted thunder best matches the recording. It outputs a probability map with a 90% credible region.
|
|
33
|
+
|
|
34
|
+
The output is a self-contained HTML page, shown above for a real 4-minute recording from northern Norway. The map
|
|
35
|
+
shows the posterior heatmap and the best candidate with its 90% ellipse. The panel below it plots modeled (red) against
|
|
36
|
+
observed (blue) thunder over UTC time, above a spectrogram of the clip.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
Pick one:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pip install thunderprint # CLI + Python API, prebuilt for Linux, macOS, Windows
|
|
44
|
+
cargo install thunderprint # CLI from crates.io
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
or download a prebuilt binary from [Releases](https://github.com/Hexay/thunderprint/releases).
|
|
48
|
+
|
|
49
|
+
You also need:
|
|
50
|
+
|
|
51
|
+
- [`ffmpeg`](https://ffmpeg.org) on `PATH` to decode audio. Any format ffmpeg reads works.
|
|
52
|
+
- A free Frost client ID from [frost.met.no](https://frost.met.no/howto.html), set as `FROST_CLIENT_ID` in the
|
|
53
|
+
environment or in a `.env` file in the working directory:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
FROST_CLIENT_ID=your-client-id
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To build from source (Rust stable, edition 2024): `git clone https://github.com/Hexay/thunderprint && cargo build --release`.
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
You don't need a real recording to try it. `sim` synthesises thunder from real strikes at a listener you
|
|
64
|
+
choose, and you can then check whether `locate` finds that listener:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
thunderprint sim --date 2025-07-19 --lat 65.089 --lon 19.339 --start 14:40:30 \
|
|
68
|
+
--temp=12 --wind-e=4 --wind-n=4 --noise 0.03 --seed 25 --out data/sim/demo
|
|
69
|
+
thunderprint locate --audio data/sim/demo/audio.wav --date 2025-07-19 --out out/demo
|
|
70
|
+
thunderprint eval --run out/demo --truth data/sim/demo/truth.toml
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`eval` should report an error of about 0.4 km. Open `out/demo/index.html` to see the map, with modeled and
|
|
74
|
+
observed thunder and a spectrogram underneath it. Lightning for each UTC day is downloaded once and cached in `data/frost/`
|
|
75
|
+
(override with `THUNDERPRINT_CACHE`).
|
|
76
|
+
|
|
77
|
+
## Python
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
pip install thunderprint
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The wheel bundles the compiled binary, so Rust isn't needed. ffmpeg still has to be on `PATH`.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import thunderprint as tp
|
|
87
|
+
|
|
88
|
+
res = tp.locate("storm.opus", "2025-07-19", frost_client_id="...") # or set FROST_CLIENT_ID
|
|
89
|
+
res.best # Region(rank=1, lat=65.659, lon=14.117, start=datetime(...), reliable=True, ...)
|
|
90
|
+
res.regions # all candidates, best first
|
|
91
|
+
env = res.envelope() # .times (UTC datetimes), .observed, .modeled
|
|
92
|
+
res.spectrogram # .freqs_hz, .offsets_s, .power[band][frame]
|
|
93
|
+
res.viewer # path to the interactive HTML map
|
|
94
|
+
res.geopackage # path to result.gpkg for ArcGIS / QGIS (geopandas.read_file(res.geopackage, layer="cells"))
|
|
95
|
+
|
|
96
|
+
# Your own lightning data: a CSV path, a list of dicts, or a pandas DataFrame
|
|
97
|
+
res = tp.locate("storm.opus", "2023-06-02", strikes=df) # columns: time, lat, lon[, peak_ka, cloud, error_km]
|
|
98
|
+
|
|
99
|
+
tp.simulate("2025-07-19", 65.089, 19.339, "14:40:30", "sim/", temp=12, seed=25) # synthetic test clip
|
|
100
|
+
tp.load("out/run") # re-read a previous run
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Frost downloads are cached in the user cache directory. To use a different location, set `THUNDERPRINT_CACHE`.
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
| Command | What it does |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `locate --audio F --date D [--start HH:MM:SS]` | Searches every strike on UTC day `D` for where and when `F` was recorded. If you know the start time, `--start` narrows the search to ±0.5 s. |
|
|
110
|
+
| `eval --run DIR --truth T` | Scores a finished run against a truth file (`lat`/`lon` TOML). |
|
|
111
|
+
| `sim --date D --lat .. --lon .. --start ..` | Writes a synthetic `audio.wav` and `truth.toml`. Run `--help` for noise, wind, stereo and propagation options. |
|
|
112
|
+
| `fetch --date D` | Downloads and caches one day of Frost lightning ahead of time. |
|
|
113
|
+
| `probe ...` | Diagnostics: scores one point at every pipeline stage. |
|
|
114
|
+
|
|
115
|
+
`locate`, `sim` and `probe` take `--strikes file.csv` to use lightning data from any network instead of Frost:
|
|
116
|
+
|
|
117
|
+
| Column | Required | Meaning |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `time` | yes | UTC, as epoch seconds or ISO-8601 (`2025-07-19T16:00:01.25Z`) |
|
|
120
|
+
| `lat`, `lon` | yes | Strike position, in degrees |
|
|
121
|
+
| `peak_ka` | no | Peak current in kA. Default 10. |
|
|
122
|
+
| `cloud` | no | `1`/`true` for in-cloud, otherwise cloud-to-ground |
|
|
123
|
+
| `error_km` | no | Location error, as a 50% radius. Default 0.5. |
|
|
124
|
+
|
|
125
|
+
Accuracy depends on the network's timing and position errors. Frost/NORDLIS reaches about 200 m; Blitzortung's
|
|
126
|
+
public feed is about 3 km.
|
|
127
|
+
|
|
128
|
+
`locate` writes five files to `--out` (default `out/run`):
|
|
129
|
+
|
|
130
|
+
- `index.html`: the map viewer. Under the map, it plots the modeled and observed thunder envelopes over UTC time, with a 10–1000 Hz spectrogram on the same time axis. Clicking a candidate region switches the modeled curve to that region.
|
|
131
|
+
- `result.gpkg`: a GeoPackage for ArcGIS Pro or QGIS (drag it in), in WGS 84. It has four layers:
|
|
132
|
+
- `cells`: posterior grid polygons
|
|
133
|
+
- `candidates`: points
|
|
134
|
+
- `ellipses`: 90% regions, linked to candidates by `rank`
|
|
135
|
+
- `onsets`: onset-solve fixes
|
|
136
|
+
- `heatmap.geojson`: the same features in a single GeoJSON file
|
|
137
|
+
- `candidates.json`: ranked regions, each with its start time, fitted atmosphere, 90% area and a `reliable` flag
|
|
138
|
+
- `trace.json`: the envelope curves and spectrogram shown in the viewer
|
|
139
|
+
|
|
140
|
+
### Models and overrides
|
|
141
|
+
|
|
142
|
+
- **`--model improved`** (the default) is the model described below.
|
|
143
|
+
- **`--model baseline`** reimplements the method from the video that inspired this project (see [Prior art](#prior-art)). It's there for comparison.
|
|
144
|
+
- **`--overrides file.toml`** overrides search settings. The fields are declared in [`src/config.rs`](src/config.rs) and [`experiments/`](experiments) has examples.
|
|
145
|
+
|
|
146
|
+
## How it works
|
|
147
|
+
|
|
148
|
+
1. **Observed envelope.** The audio is band-passed to 20–250 Hz, where thunder carries far and through walls. It's then reduced to a smoothed, compressed loudness envelope.
|
|
149
|
+
2. **Predicted envelope.** For a candidate listener position, every strike is rendered as a thunder pulse.
|
|
150
|
+
- Cloud-to-ground strikes are modelled as extended vertical channels, intra-cloud strikes as horizontal sources aloft.
|
|
151
|
+
- Arrival time is slant distance divided by the speed of sound, corrected for wind.
|
|
152
|
+
- Loudness scales with peak current and falls off with range.
|
|
153
|
+
- Each pulse has a rumble tail whose length grows with the channel's spread of arrival times.
|
|
154
|
+
3. **Match.** The predicted envelope is correlated with the observed one at every possible start time in the day, using FFT cross-correlation.
|
|
155
|
+
4. **Search, coarse to fine.** A coarse grid covers the whole strike region, then mid and fine grids refine the best peaks.
|
|
156
|
+
- The fine stage marginalises over nuisance atmospheres: temperature (speed of sound), wind, refraction lapse rate and the effective height thunder onsets are timed from.
|
|
157
|
+
5. **Posterior.** Scores are tempered into a posterior, from which the tool reports ranked regions and 90% credible ellipses. A region is marked unreliable when fewer than 30 strikes would have been audible from it.
|
|
158
|
+
|
|
159
|
+
Two physical findings drove the model. Both are written up in [`research/`](research):
|
|
160
|
+
|
|
161
|
+
- **CG onset height.** Real thunder onsets behave as if they come from about 6 km up, not from the channel base (see [empirical_thunder_response.md](research/empirical_thunder_response.md)).
|
|
162
|
+
- **Refraction shadow.** Upward refraction hides the low part of distant channels (see [thunder_physics_lit.md](research/thunder_physics_lit.md)).
|
|
163
|
+
|
|
164
|
+
## Accuracy and limitations
|
|
165
|
+
|
|
166
|
+
| Case | Error |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Synthetic clips, listener near active storms | 0.4–1.0 km |
|
|
169
|
+
| Synthetic twin of the real clip | 1.4 km |
|
|
170
|
+
| Real 4-minute recording, northern Norway | 1.3 km, versus 2.2 km for the baseline method. Not blind: the truth was known during development. |
|
|
171
|
+
|
|
172
|
+
Limitations:
|
|
173
|
+
|
|
174
|
+
- **Coverage.** Frost lightning covers the Nordic detection network (Norway and neighbouring countries) and nothing else. For anywhere else, use `--strikes` with data from another network.
|
|
175
|
+
- **Recording length.** You need several minutes of unedited audio recorded inside an active storm. Short or edited clips don't hear enough strikes to fix a position.
|
|
176
|
+
- **Geometry.** If every strike comes from one direction, the result is a stretched ellipse. Strikes from several directions give a tight fix.
|
|
177
|
+
- **Video clocks.** In a video, the visible flashes give the frame clock. The audio track can be seconds away from that, so `--start` taken from flashes needs care.
|
|
178
|
+
- **Real clips fit worse than simulations.** The model is still incomplete, and the credible regions on real audio should be read as optimistic.
|
|
179
|
+
|
|
180
|
+
## Prior art
|
|
181
|
+
|
|
182
|
+
- The baseline method comes from colsto's videos [*Can you geolocate a video using background thunder sounds?*](https://www.youtube.com/watch?v=SnbkIm56TfI) and [*I located a fan's video using thunder sounds*](https://www.youtube.com/watch?v=6a7Z0I3LAnE). The method is a point source, 343 m/s, inverse-square attenuation and cosine similarity.
|
|
183
|
+
- Related work on timing and locating recordings is surveyed in [`research/prior_art_receiver_location.md`](research/prior_art_receiver_location.md).
|
|
184
|
+
|
|
185
|
+
## Data and attribution
|
|
186
|
+
|
|
187
|
+
- **Lightning data:** from [MET Norway](https://www.met.no) via the [Frost API](https://frost.met.no), licensed under [NLOD 2.0 / CC BY 4.0](https://www.met.no/en/free-meteorological-data/Licensing-and-crediting). Raw strike files are not redistributed here; each user downloads them with their own client ID.
|
|
188
|
+
- **Map tiles in the viewer:** © Esri, HERE, Garmin and © OpenStreetMap contributors.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
[MIT](LICENSE)
|
|
193
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
thunderprint/__init__.py,sha256=QTsTJcSHh4iqapQCVCx03mepoZ-Mnx-ZGCtdyVE6AUo,4915
|
|
2
|
+
thunderprint/_cli.py,sha256=7fPd622rHbvS6w66CL4bcZxUPtnhbV5Q4TcjzYOnE9U,2071
|
|
3
|
+
thunderprint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
thunderprint/results.py,sha256=9mdasU8xTjpOMM2BitHRT4eTQavhKKIwulPCT6S9-wk,3819
|
|
5
|
+
thunderprint-0.1.0.data/scripts/thunderprint.exe,sha256=0iKrDdIM2znfpSNCc5IHYbu_ahBnA1gf3Xchkv3Z_jw,7511040
|
|
6
|
+
thunderprint-0.1.0.dist-info/METADATA,sha256=jSLPLGDc-3TjBTjFXjLbmC53do7QkzKFl8xnvIroFRs,11402
|
|
7
|
+
thunderprint-0.1.0.dist-info/WHEEL,sha256=8Aej0W0a6Cz6apA3IzJrTnxLRVLAt-w0Oh8SA3Con_c,94
|
|
8
|
+
thunderprint-0.1.0.dist-info/licenses/LICENSE,sha256=sGn782b_dSVFDtSU2vrc_N1aa9F4413qazbRivcG3ik,1083
|
|
9
|
+
thunderprint-0.1.0.dist-info/sboms/thunderprint.cyclonedx.json,sha256=D_iJByzMK0qQ5Z1T3M8kT1eZM-jsvywovTNihUcgDSY,138185
|
|
10
|
+
thunderprint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hexay
|
|
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.
|