An earthquake early-warning (EEW) pipeline in C++23. It reads continuous three-component waveforms from several stations and, for each earthquake:
- detects it at each station (a CNN–BiLSTM–attention detector, 3-seed ensemble; a recursive STA/LTA is available for comparison)
- declares an event when stations agree within the P travel time between them
- estimates the magnitude from 10 s of waveform per station
- picks P and S and locates the event by grid search
All inference is hand-written (no ML runtime), with NEON kernels on aarch64. The target platform is a Raspberry Pi 4 or 5. The input is currently a replay of AFAD miniSEED files at real time or faster; a live SeedLink client is not implemented yet.
Named after my pet bird.
- Meson and Ninja, and a C++23 compiler and standard library with
std::expected,std::printandstd::mdspan. Tested with Meson 1.12, GCC 16.2, and Clang with libc++ through zig 0.16. - For the Raspberry Pi and Windows builds: uv
(it fetches zig) and, to run the aarch64 tests on x86,
qemu-aarch64. - Nothing else. The weights are in the repository, so no Python and no ML runtime are needed to build or run.
meson setup build-release --buildtype=release
ninja -C build-release
meson test -C build-releaseThe tests that compare against PyTorch, scipy, torchaudio and ObsPy need the fixtures
in data/fixtures/ and are skipped if these are missing. The end-to-end test
(demo_m49) replays the 2025-11-10 Sındırgı M4.9. It requires the event to be
detected, located within 10 km and given a magnitude between 3.9 and 5.9.
Raspberry Pi (static aarch64 binary, docs/impl/06-raspberry-pi.md):
meson setup build-pi --cross-file cross/aarch64-pi.ini --buildtype=release
ninja -C build-pi
meson test -C build-pi --timeout-multiplier 10 # under qemu-aarch64ayzek.exe is built on Linux with zig and needs no runtime DLLs beyond what
Windows 10 and 11 already have (docs/impl/11-windows.md):
meson setup build-win --cross-file cross/x86_64-windows.ini --buildtype=release
ninja -C build-win # -> build-win/app/ayzek.exe, 1.6 MBTo run it, put ayzek.exe in a folder with the models folder beside it, and
the waveforms wherever you like:
C:\ayzek\
ayzek.exe
models\ the whole folder from this repository
data\demo\ one .mseed per station
demo.csv optional AFAD catalogue export (tests\catalogs\demo.csv)
In PowerShell or Command Prompt, from C:\ayzek:
ayzek.exe --speed 10 --from 2025-11-10T18:20:00 data\demo\DEMI.mseed data\demo\MANT.mseed data\demo\BAND.mseed
--speed 10replays at ten times real time;--speed 0runs as fast as the machine allows,--speed 1in real time.--catalog demo.csvadds the comparison with the AFAD catalogue. Without it, alarms are still reported, just not scored.--models DIRpoints at the weights if they are not in.\models.- Colour is disabled automatically when the output is redirected to a file.
The colours are ANSI escapes, which Windows Terminal renders and the legacy
console may print as
[32minstead;--no-colorturns them off. - Everything else is the same as on Linux, including the option table below.
Not yet run on Windows. The binaries are built and linked, and the
numerical tests have not been executed on a Windows machine or under wine. If
you run them, meson test -C build-win needs wine on Linux; on Windows the
test executables in build-win\tests\ can be run directly, and test_models,
test_magnitude, test_dsp and test_stalta are the ones that would catch a
floating-point difference.
models/ is tracked: the exported weights (AYZW format), the band-pass
coefficients and the station table, about 5 MB. A clone can run the pipeline
without the PyTorch checkpoints they were exported from.
data/ is not tracked. The quickest way to get a dataset is the release: the
KOERI sets attached to it unpack straight into data/.
# the 2025-11-10 Sındırgı Mw 4.9 on six KO stations, 4 MB
mkdir -p data/demo_ko && curl -L https://github.com/hogib/ayzek/releases/download/v0.1.0-beta.1/ayzek-demo_ko.tar.gz | tar xz -C data/demo_ko
build-release/app/ayzek --speed 10 --from 2025-11-10T18:20:00 \
--catalog data/demo_ko/demo_ko.csv data/demo_ko/*.mseedtools/fetch_ko_data.py rebuilds those sets from the KOERI FDSN service, and
pulls new ones: probe reports which stations have a span, quiet-scan finds
spans with no catalogue event near them, and pull downloads.
The AFAD replays the results below were measured on are cut from archive chunks
with tools/make_demo_data.py, one file per station.
python3 tools/make_demo_data.py --out data/demo \
--start 2025-11-10T18:05:00 --end 2025-11-10T18:35:00 \
ARCHIVE/{DEMI,MANT,BAND,KAND,KIRK}/*_2025-10-29.zip \
ARCHIVE/{ELBA,SEMS}/*_2025-10-21.zipArchive chunks are named by their start date, so a given day is usually in a chunk that starts some days earlier.
Retraining a model and exporting new weights is docs/impl/02-weights.md; the
checkpoints come from the cascade_impl project.
ayzek [options] STATION.mseed...
Each file holds one station's HH? channels. The station code is read from the
records and its coordinates from models/stations.csv.
CAT=tests/catalogs/demo_ko.csv # AFAD catalogue excerpt; any AFAD export works
# six KO stations at 10x real time, triggers from 18:20 on
build-release/app/ayzek --speed 10 --from 2025-11-10T18:20:00 --catalog $CAT \
data/demo_ko/*.mseed
# as fast as possible, with S-wave warning times for two cities
build-release/app/ayzek --speed 0 --catalog $CAT \
--site Istanbul,41.0082,28.9784 --site Bursa,40.1828,29.0667 data/demo_ko/*.mseed
# the AFAD demo, for the numbers under Results
tools/demo.sh # 3 stations, 10x
tools/demo.sh all # 7 stations, full speed| option | default | meaning |
|---|---|---|
--models DIR |
models |
weights directory |
--speed X |
1 | replay speed; 1 = real time, 0 = as fast as possible |
--from TIME |
start of data | no triggers before this UTC time; earlier data only updates the station noise baselines |
--threshold P |
0.8 | trigger when --trigger-windows consecutive windows reach P |
--trigger-windows N |
8 | see --threshold; 8 windows add 3.5 s to a trigger |
--instant-threshold P |
0.9 | also trigger on a single window at or above P; a value > 1 disables this |
--release P |
0.3 | a trigger re-arms after two windows below P |
--detector KIND |
model |
stalta replaces the detector with a recursive STA/LTA; picking, association, location and magnitude are unchanged |
--sta S, --lta S |
1, 30 | STA/LTA averaging lengths in seconds |
--stalta-on R, --stalta-off R |
8, 1.5 | STA/LTA ratio that triggers, and below which a trigger re-arms |
--stalta-band LO,HI |
2,20 | STA/LTA pass band in Hz (4th-order Butterworth high- and low-pass) |
--stalta-3c |
STA/LTA on the energy of all three components instead of the vertical | |
--no-anchor |
do not take the P time of a model trigger from the STA/LTA onset | |
--anchor-on R |
3 | STA/LTA ratio taken as the onset when anchoring |
--anchor-picker, --anchor-magnitude |
also place the picker and early magnitude windows from the anchored P; both make their models worse (docs/impl/09-sta-lta.md) |
|
--require-onset |
a detector trigger also needs an STA/LTA onset; measured, and worse than not requiring one (docs/impl/09-sta-lta.md) |
|
--mag-lead S |
2 | magnitude window start, seconds before P |
--step N |
50 | samples between detector windows (0.5 s at 100 Hz) |
--min-stations N |
2 | station detections needed to declare an event; 3 halves the unmatched alarms in a dense network but costs warning time (docs/impl/05-pipeline.md) |
--slack S |
3 | tolerance on the inter-station P travel time, seconds |
--no-pick |
no P/S picker, and so no location | |
--no-magnitude |
no magnitude regressor. Otherwise each trigger gets an early estimate, and triggers of declared events a second one at the picked P | |
--catalog CSV |
AFAD catalogue export; each alarm is compared with it | |
--site NAME,LAT,LON |
also report the S-wave warning time at this place (repeatable) | |
--scores DIR |
write every window's detector probability to DIR/STATION.csv |
|
--scores-in DIR |
use the probabilities of an earlier --scores run instead of running the detector |
|
--record FILE |
write all station outputs, for tools/network_subsets |
|
--verbose |
print every station detection, pick and magnitude estimate | |
--no-color |
While running, one line per alarm and per magnitude or location update:
ALARM #3 18:21:09.00 earthquake detected by DEMI, MANT (AFAD MW 4.9 at 18:20:51.00, 18.0 s ago)
MAG #3 18:21:09.00 magnitude M4.3 from 1 station (DEMI 4.3)
LOCATE #3 18:22:05.00 located at 39.221N 28.088E, origin 18:20:52.02, rms 0.16 s from 3 stations
At the end:
- One block per alarm: alarm time and stations, magnitude at the alarm and
final magnitude, location, comparison with the AFAD event, and the S-wave
warning time at each station and
--site(S arrival minus alarm time; positive means the alarm came first). - Summary: alarms that match an AFAD event and alarms that do not, AFAD events detected and missed (in total and by magnitude band), alarm delay, and lists of missed events and unmatched alarms.
- Station processing: windows, triggers, picks and magnitude estimates per station, and the time per window, pick and estimate.
An alarm matches an AFAD event if its location agrees (epicentre within 30 km, origin within 5 s), or if the event's predicted P arrival at a station that raised the alarm is within 6 s of that station's detection. An unmatched alarm is not necessarily false: the catalogue is incomplete for small events, especially during aftershock sequences.
| tool | purpose |
|---|---|
tools/sweep_trigger.py |
runs a grid of any ayzek options on several datasets and tabulates detections, unmatched alarms, alarm delay and, with --shifts, detections corrected for chance matches; uses cached probabilities for the model |
tools/network_subsets |
runs the network stage for every subset of stations from one --record file; identical to running ayzek on each subset |
tools/station_geometry.py |
coverage, alert time, blind zone and location error of station subsets over a grid of epicentres, without waveforms |
tools/mseed_dump, tools/validate_mseed.py |
record-level inspection, and a decoding check against ObsPy |
tools/replay_check, tools/gaps_vs_events.py |
gap statistics of the archive, and their relation to earthquake times |
tools/gap_scan.py |
spans and gaps of any miniSEED files, from the record headers alone; flags gaps longer than the sample ring |
tools/scan_encodings.py |
counts the miniSEED data encodings in archive chunks |
tools/fetch_ko_data.py |
pulls continuous KOERI (KO) waveforms into the replay layout: probe which stations have a span, quiet-scan for spans with no catalogue event nearby, pull to download |
bench/ayzek_bench |
time per stage and per layer, the stations that fit in real time; meson test -C build-release --benchmark (docs/impl/10-benchmarks.md) |
tools/scorecard.py |
detection, unmatched and false alarms, magnitude and location on the fixed datasets, for judging a model change; --compare A.json B.json |
tools/bench_compare.py |
two ayzek_bench results side by side, flags regressions |
Trigger sweeps on one dataset:
# model: store the detector probabilities once, then sweep the trigger rule
build-release/app/ayzek --speed 0 --no-magnitude --catalog $CAT --scores data/runs/cache data/demo/*.mseed
python3 tools/sweep_trigger.py --catalog $CAT --dataset 'demo:data/runs/cache:data/demo/*.mseed' \
--set 'threshold=0.9 0.85 0.8' --set 'trigger-windows=1 4 8' --set 'instant-threshold=2 0.9' \
--shifts 127,263,391 --out sweep.csv
# STA/LTA (no cache)
python3 tools/sweep_trigger.py --catalog $CAT --args '--detector stalta' --dataset 'demo::data/demo/*.mseed' \
--set 'stalta-on=4 6 8' --set 'lta=10 30' --set 'stalta-3c=off on' --shifts 127,263,391 --out stalta.csvReplays of AFAD recordings with the default settings, scored against the AFAD catalogue (events within 250 km):
| replay | stations | catalogue events detected | alarms without a catalogue event | main event |
|---|---|---|---|---|
| Sındırgı, 2025-11-10, 30 min | 3 | 7 of 9 | 2 of 9 | Mw 4.9: alarm 18.0 s after origin with M4.3, final M4.6, located 5.3 km off |
| Marmara Sea, 2025-04-23, 3 h | 8 | 44 of 57 | 24 of 68 | Mw 6.2: alarm 11.0 s after origin, M4.6 at +14 s, final M4.8, located 3.1 km off |
| Sındırgı, 2025-08-10, 3 h | 6 | 39 of 66 | 7 of 46 | Mw 6.1: alarm 17.0 s after origin, M4.9 at +17.5 s, final M4.7, located 32.5 km off |
- Trigger settings. Chosen on the first two replays. The third was not used
for the choice. There, the earlier rule (one window at p ≥ 0.9) detected 24
of 66 events with 2 alarms without a catalogue event
(
docs/impl/05-pipeline.md). - Alarm time. Set by the P travel time to the second station, not by computation.
- Magnitude. Saturates for large events (final M4.7 for the Mw 6.1, M4.8
for the Mw 6.2). The regressor was trained on 10 s windows with few events
above M5.5 (
docs/impl/07-magnitude.md). - Unmatched alarms. Some are probably uncatalogued aftershocks. Of the 24 in the Marmara replay, 9 were located and 5 of those within 15 km of the Mw 6.2.
- Against STA/LTA. STA/LTA settings were chosen on the first two replays
to match the model's unmatched alarms. On the held-out replay that setting
detects 25 events against the model's 39, with 9 unmatched alarms against 7.
To detect as many events as the model, STA/LTA needs about three times the
unmatched alarms. It alarms 1–1.3 s earlier for the Mw 6.1 and 6.2, and
costs about 0.001 ms per update against 5 ms (
docs/impl/09-sta-lta.md). - False alarms. Over 33.3 h of recordings with no catalogue event within
250 km of any station, the model raised 6 alarms, 4.3 a day. STA/LTA tuned to
match its unmatched alarms on the replays raised 113, 81.5 a day
(
docs/impl/10-benchmarks.md). - Speed. Measured with
bench/ayzek_benchon a Ryzen 5 5600, scalar backend: 5.2 ms per detector window (three models, every 0.5 s per station, about 1% of a core per station), 12 ms per picker run, 288 ms per magnitude estimate, of which 91% is one attention layer. One core sustains 95 stations, four cores 372. The aarch64 NEON build passes the same agreement tests under qemu; timing on a Raspberry Pi has not been measured yet.
The results above are from AFAD recordings, which cannot be redistributed. The
KOERI sets attached to the release are the reproducible equivalents, and
tools/scorecard.py scores them:
| dataset | result |
|---|---|
demo_ko, 6 stations, 30 min |
Mw 4.9 declared 18.0 s after origin, 8 of 9 catalogue events detected, 2 unmatched alarms, magnitude error 0.35 |
marmara_ko, 8 stations, 1 h 45 min |
Mw 6.2 declared 10.5 s after origin, Mw 5.9 at +11.0 s, 28 of 37 detected; the span starts 49 min early so the noise baselines are warm |
quiet_ko, 6 stations, 6 h, no catalogue event within 250 km |
1 alarm, 4.1 a day; STA/LTA on the same data raises 25, 103 a day |
Waveforms in the published datasets are from the Kandilli Observatory and Earthquake Research Institute (KOERI), Boğaziçi University, network KO, open data under citation:
Kandilli Observatory And Earthquake Research Institute (KOERI), Boğaziçi University (1971). Kandilli Observatory And Earthquake Research Institute (KOERI). International Federation of Digital Seismograph Networks. https://doi.org/10.7914/SN/KO
Catalogue excerpts in tests/catalogs/, and the AFAD replays used for the
results above, are from AFAD (Disaster and Emergency Management Presidency of
Türkiye), https://deprem.afad.gov.tr/.
docs/DESIGN.md: the architecture and the reasoning behind itdocs/IMPLEMENTATION.md: an overview of what was built, with an index ofdocs/impl/01–11(SIMD kernels, weights format, conditioning, models, pipeline, Raspberry Pi, magnitude, station selection, STA/LTA benchmark, benchmarks and the scorecard, Windows build)
src/ ring buffer, miniSEED decoding, reordering, SIMD kernels, layers, models, DSP, magnitude
src/pipeline/ ingest, per-station processor, network association and location, recording
app/ the ayzek binary
tests/ unit tests, agreement with PyTorch/scipy/torchaudio, end-to-end check
tools/ model export, data preparation, evaluation, the scorecard
bench/ ayzek_bench, and reference results in bench/results/
cross/ zig toolchain wrappers and the Raspberry Pi and Windows cross files
docs/ design and implementation notes