feat(v0.5.10): nws + usgs_quake + swpc handlers
Three more per-adapter handlers landing in the same v0.5.9-incident-pipeline pattern: nws_handler.py with severity-floor gate (Warning+ broadcasts only, Moderate/Minor/Unknown skipped to event_log handled=0), event-type emoji map, CAP-id-based first-sight dedup via nws_alerts table; quake_handler.py with magnitude-floor gate (M3.0 globally + M2.5 within 250mi of Idaho centroid + tsunami at any M) using Haversine for the distance check, USGS data.place curated string preferred for the place anchor, leading emoji escalation (🌐 routine / ⚠️ M5+ / 🚨 tsunami), Magnitude spelled out per Matts call; swpc_handler.py with aggressive G3+/R3+/S1+ gate, plain-English wire headlines with (NOAA scale / underlying scalar) tail tag per Matts option C (e.g. "Strong geomagnetic storm (G3/Kp7) -- HF degraded, aurora possible"), routine Kp + protons persisted to swpc_events.payload_json for trending but never broadcast. All three share the v0.5.9 universal freshness gate and the no-Update first-sight-only pattern. Persistence uses the existing v0.5.8b nws_alerts, quake_events, swpc_events tables -- no migrations needed. Tests: was 634 (v0.5.9 baseline), now 686 (+52 net new; over-delivered because parametrized emoji map adds 14 rows). Synthetic probe over the 4 nws + 1 quake + 16,217 swpc captured envelopes from the batched investigation: Phase 1 = 0/0/0 broadcasts (all real captures correctly filtered by their respective gates); Phase 2 = 5/5 synthesized fresh test events broadcast correctly (Severe T-Storm warning, M4.1 Garden Valley quake, G3 geomagnetic storm, X1.2 flare, S1 proton). WFIGS handler unchanged. usgs_nwis deferred to v0.5.12 (threshold-curation work). Master OFF in prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:27:01 +00:00
|
|
|
"""Tests for v0.5.10 USGS earthquakes handler."""
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from meshai.central.quake_handler import (
|
|
|
|
|
handle_quake,
|
|
|
|
|
within_250mi_of_idaho,
|
|
|
|
|
)
|
|
|
|
|
from meshai.persistence import close_thread_connection, init_db
|
|
|
|
|
from meshai.persistence import db as persistence_db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mem_db(monkeypatch, tmp_path):
|
|
|
|
|
db_path = str(tmp_path / "quake-test.sqlite")
|
|
|
|
|
monkeypatch.setenv("MESHAI_DB_PATH", db_path)
|
|
|
|
|
persistence_db._initialised.clear()
|
|
|
|
|
close_thread_connection()
|
|
|
|
|
conn = init_db()
|
|
|
|
|
yield conn
|
|
|
|
|
close_thread_connection()
|
|
|
|
|
persistence_db._initialised.discard(db_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _quake_env(*, event_id="uu80141266", mag=3.5, depth_km=9.0,
|
|
|
|
|
place="9 km SW of Stanley, Idaho",
|
|
|
|
|
lat=44.094, lon=-115.962,
|
|
|
|
|
tsunami=0, alert=None,
|
|
|
|
|
time_ms=1780006952030,
|
|
|
|
|
category="quake.event.minor"):
|
|
|
|
|
return {
|
|
|
|
|
"id": event_id, "subject": "central.quake.event.minor.unknown",
|
|
|
|
|
"data": {
|
|
|
|
|
"id": event_id, "adapter": "usgs_quake", "category": category,
|
|
|
|
|
"severity": 0,
|
|
|
|
|
"geo": {"centroid": [lon, lat], "primary_region": None},
|
|
|
|
|
"data": {
|
|
|
|
|
"id": event_id, "magnitude": mag, "place": place,
|
|
|
|
|
"depth_km": depth_km, "time_ms": time_ms,
|
|
|
|
|
"tsunami": tsunami, "alert": alert, "status": "reviewed",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _commit(data, t):
|
|
|
|
|
data["_on_broadcast_committed"](float(t))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- magnitude floor ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_m3_anywhere_broadcasts(mem_db):
|
|
|
|
|
env = _quake_env(mag=3.5, lat=37.0, lon=-122.0) # SF Bay area, outside Idaho
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is not None
|
2026-06-10 03:43:06 +00:00
|
|
|
assert "M3.5" in wire
|
feat(v0.5.10): nws + usgs_quake + swpc handlers
Three more per-adapter handlers landing in the same v0.5.9-incident-pipeline pattern: nws_handler.py with severity-floor gate (Warning+ broadcasts only, Moderate/Minor/Unknown skipped to event_log handled=0), event-type emoji map, CAP-id-based first-sight dedup via nws_alerts table; quake_handler.py with magnitude-floor gate (M3.0 globally + M2.5 within 250mi of Idaho centroid + tsunami at any M) using Haversine for the distance check, USGS data.place curated string preferred for the place anchor, leading emoji escalation (🌐 routine / ⚠️ M5+ / 🚨 tsunami), Magnitude spelled out per Matts call; swpc_handler.py with aggressive G3+/R3+/S1+ gate, plain-English wire headlines with (NOAA scale / underlying scalar) tail tag per Matts option C (e.g. "Strong geomagnetic storm (G3/Kp7) -- HF degraded, aurora possible"), routine Kp + protons persisted to swpc_events.payload_json for trending but never broadcast. All three share the v0.5.9 universal freshness gate and the no-Update first-sight-only pattern. Persistence uses the existing v0.5.8b nws_alerts, quake_events, swpc_events tables -- no migrations needed. Tests: was 634 (v0.5.9 baseline), now 686 (+52 net new; over-delivered because parametrized emoji map adds 14 rows). Synthetic probe over the 4 nws + 1 quake + 16,217 swpc captured envelopes from the batched investigation: Phase 1 = 0/0/0 broadcasts (all real captures correctly filtered by their respective gates); Phase 2 = 5/5 synthesized fresh test events broadcast correctly (Severe T-Storm warning, M4.1 Garden Valley quake, G3 geomagnetic storm, X1.2 flare, S1 proton). WFIGS handler unchanged. usgs_nwis deferred to v0.5.12 (threshold-curation work). Master OFF in prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:27:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_m25_inside_idaho_broadcasts(mem_db):
|
|
|
|
|
env = _quake_env(mag=2.7, lat=44.094, lon=-115.962, event_id="uu1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is not None
|
2026-06-10 03:43:06 +00:00
|
|
|
assert "M2.7" in wire
|
feat(v0.5.10): nws + usgs_quake + swpc handlers
Three more per-adapter handlers landing in the same v0.5.9-incident-pipeline pattern: nws_handler.py with severity-floor gate (Warning+ broadcasts only, Moderate/Minor/Unknown skipped to event_log handled=0), event-type emoji map, CAP-id-based first-sight dedup via nws_alerts table; quake_handler.py with magnitude-floor gate (M3.0 globally + M2.5 within 250mi of Idaho centroid + tsunami at any M) using Haversine for the distance check, USGS data.place curated string preferred for the place anchor, leading emoji escalation (🌐 routine / ⚠️ M5+ / 🚨 tsunami), Magnitude spelled out per Matts call; swpc_handler.py with aggressive G3+/R3+/S1+ gate, plain-English wire headlines with (NOAA scale / underlying scalar) tail tag per Matts option C (e.g. "Strong geomagnetic storm (G3/Kp7) -- HF degraded, aurora possible"), routine Kp + protons persisted to swpc_events.payload_json for trending but never broadcast. All three share the v0.5.9 universal freshness gate and the no-Update first-sight-only pattern. Persistence uses the existing v0.5.8b nws_alerts, quake_events, swpc_events tables -- no migrations needed. Tests: was 634 (v0.5.9 baseline), now 686 (+52 net new; over-delivered because parametrized emoji map adds 14 rows). Synthetic probe over the 4 nws + 1 quake + 16,217 swpc captured envelopes from the batched investigation: Phase 1 = 0/0/0 broadcasts (all real captures correctly filtered by their respective gates); Phase 2 = 5/5 synthesized fresh test events broadcast correctly (Severe T-Storm warning, M4.1 Garden Valley quake, G3 geomagnetic storm, X1.2 flare, S1 proton). WFIGS handler unchanged. usgs_nwis deferred to v0.5.12 (threshold-curation work). Master OFF in prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:27:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_m25_outside_idaho_skipped(mem_db):
|
|
|
|
|
# San Francisco -- well outside 250mi of Idaho centroid.
|
|
|
|
|
env = _quake_env(mag=2.7, lat=37.0, lon=-122.0, event_id="uu2")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_below_25_skipped(mem_db):
|
|
|
|
|
env = _quake_env(mag=1.01, lat=44.0, lon=-114.0, event_id="uu3")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- tsunami special ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_tsunami_any_magnitude_broadcasts(mem_db):
|
|
|
|
|
env = _quake_env(mag=4.5, lat=10.0, lon=140.0, tsunami=1, event_id="japan1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is not None
|
|
|
|
|
assert "TSUNAMI WARNING" in wire
|
|
|
|
|
assert wire.startswith("🚨")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- PAGER alert ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pager_orange_broadcasts(mem_db):
|
|
|
|
|
env = _quake_env(mag=2.0, lat=37.0, lon=-122.0, alert="orange",
|
|
|
|
|
event_id="pager1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pager_red_broadcasts(mem_db):
|
|
|
|
|
env = _quake_env(mag=2.0, lat=37.0, lon=-122.0, alert="red",
|
|
|
|
|
event_id="pager2")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- wire format ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_uses_usgs_place_string(mem_db):
|
|
|
|
|
env = _quake_env(mag=4.1, place="9 km SW of Stanley, Idaho",
|
|
|
|
|
event_id="usgs1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert "9 km SW of Stanley, Idaho" in wire
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_m5_uses_warning_emoji(mem_db):
|
|
|
|
|
env = _quake_env(mag=5.2, lat=44.0, lon=-114.0, event_id="big1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
|
|
|
|
assert wire.startswith("⚠️")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wire_includes_depth_and_coords(mem_db):
|
|
|
|
|
env = _quake_env(mag=4.1, depth_km=9.0, lat=44.094, lon=-115.962,
|
|
|
|
|
event_id="d1")
|
|
|
|
|
wire = handle_quake(env, env["subject"], data={}, now=1_000_000)
|
2026-06-10 03:43:06 +00:00
|
|
|
assert "Depth: 9 km" in wire
|
|
|
|
|
assert "@ 44.094, -115.962" in wire
|
feat(v0.5.10): nws + usgs_quake + swpc handlers
Three more per-adapter handlers landing in the same v0.5.9-incident-pipeline pattern: nws_handler.py with severity-floor gate (Warning+ broadcasts only, Moderate/Minor/Unknown skipped to event_log handled=0), event-type emoji map, CAP-id-based first-sight dedup via nws_alerts table; quake_handler.py with magnitude-floor gate (M3.0 globally + M2.5 within 250mi of Idaho centroid + tsunami at any M) using Haversine for the distance check, USGS data.place curated string preferred for the place anchor, leading emoji escalation (🌐 routine / ⚠️ M5+ / 🚨 tsunami), Magnitude spelled out per Matts call; swpc_handler.py with aggressive G3+/R3+/S1+ gate, plain-English wire headlines with (NOAA scale / underlying scalar) tail tag per Matts option C (e.g. "Strong geomagnetic storm (G3/Kp7) -- HF degraded, aurora possible"), routine Kp + protons persisted to swpc_events.payload_json for trending but never broadcast. All three share the v0.5.9 universal freshness gate and the no-Update first-sight-only pattern. Persistence uses the existing v0.5.8b nws_alerts, quake_events, swpc_events tables -- no migrations needed. Tests: was 634 (v0.5.9 baseline), now 686 (+52 net new; over-delivered because parametrized emoji map adds 14 rows). Synthetic probe over the 4 nws + 1 quake + 16,217 swpc captured envelopes from the batched investigation: Phase 1 = 0/0/0 broadcasts (all real captures correctly filtered by their respective gates); Phase 2 = 5/5 synthesized fresh test events broadcast correctly (Severe T-Storm warning, M4.1 Garden Valley quake, G3 geomagnetic storm, X1.2 flare, S1 proton). WFIGS handler unchanged. usgs_nwis deferred to v0.5.12 (threshold-curation work). Master OFF in prod.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 07:27:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- per-event dedup ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_per_event_id_dedup_no_reissue(mem_db):
|
|
|
|
|
env = _quake_env(mag=4.0, event_id="dedup1")
|
|
|
|
|
data1 = {}
|
|
|
|
|
handle_quake(env, env["subject"], data=data1, now=1_000_000)
|
|
|
|
|
_commit(data1, 1_000_001)
|
|
|
|
|
|
|
|
|
|
# Same event_id republishes (magnitude revision). Should NOT re-broadcast.
|
|
|
|
|
env_rev = _quake_env(mag=4.2, event_id="dedup1") # higher mag, same id
|
|
|
|
|
wire2 = handle_quake(env_rev, env_rev["subject"], data={}, now=1_000_300)
|
|
|
|
|
assert wire2 is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- distance helper ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_within_250mi_of_idaho_boundary():
|
|
|
|
|
# Boise, ID -- inside
|
|
|
|
|
assert within_250mi_of_idaho(43.6, -116.2) is True
|
|
|
|
|
# San Francisco -- outside
|
|
|
|
|
assert within_250mi_of_idaho(37.0, -122.0) is False
|
|
|
|
|
# Seattle -- inside (250mi from Idaho centroid; verify the boundary)
|
|
|
|
|
assert within_250mi_of_idaho(47.6, -122.3) is False
|
|
|
|
|
# Boundary edge case (Idaho center)
|
|
|
|
|
assert within_250mi_of_idaho(44.36, -114.61) is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- commit callback ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_commit_callback_updates_last_broadcast(mem_db):
|
|
|
|
|
env = _quake_env(mag=4.0, event_id="cb1")
|
|
|
|
|
data = {}
|
|
|
|
|
handle_quake(env, env["subject"], data=data, now=1_000_000)
|
|
|
|
|
pre = mem_db.execute(
|
|
|
|
|
"SELECT last_broadcast_at FROM quake_events WHERE event_id='cb1'"
|
|
|
|
|
).fetchone()
|
|
|
|
|
assert pre["last_broadcast_at"] is None
|
|
|
|
|
_commit(data, 1_000_001)
|
|
|
|
|
post = mem_db.execute(
|
|
|
|
|
"SELECT last_broadcast_at FROM quake_events WHERE event_id='cb1'"
|
|
|
|
|
).fetchone()
|
|
|
|
|
assert post["last_broadcast_at"] == 1_000_001
|