mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(adapters): add USGS earthquake adapter
USGS Earthquake Hazards Program adapter: - Polls GeoJSON feed (all_hour default, configurable) - Magnitude tier classification (minor/light/moderate/strong/major/great) - Deduplication via USGS stable event ID - Region filter via shapely point-in-bbox - Skips events with null magnitude (quarry blasts, etc.) Includes comprehensive unit tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
be307b000c
commit
aacf06499b
2 changed files with 882 additions and 0 deletions
482
tests/test_usgs_quake.py
Normal file
482
tests/test_usgs_quake.py
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
"""Tests for USGS earthquake adapter."""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
from central.adapters.usgs_quake import (
|
||||
USGSQuakeAdapter,
|
||||
magnitude_tier,
|
||||
magnitude_to_severity,
|
||||
)
|
||||
from central.config_models import AdapterConfig, RegionConfig
|
||||
from central.models import Event, Geo
|
||||
|
||||
|
||||
# Sample USGS GeoJSON response
|
||||
SAMPLE_GEOJSON = {
|
||||
"type": "FeatureCollection",
|
||||
"metadata": {
|
||||
"generated": 1715878800000,
|
||||
"url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson",
|
||||
"title": "USGS All Earthquakes, Past Hour",
|
||||
"status": 200,
|
||||
"api": "1.10.3",
|
||||
"count": 3
|
||||
},
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"mag": 2.5,
|
||||
"place": "10km N of Boise, Idaho",
|
||||
"time": 1715878500000,
|
||||
"updated": 1715878600000,
|
||||
"tz": None,
|
||||
"url": "https://earthquake.usgs.gov/earthquakes/eventpage/us1234",
|
||||
"detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us1234.geojson",
|
||||
"felt": None,
|
||||
"cdi": None,
|
||||
"mmi": None,
|
||||
"alert": None,
|
||||
"status": "automatic",
|
||||
"tsunami": 0,
|
||||
"sig": 100,
|
||||
"net": "us",
|
||||
"code": "1234",
|
||||
"ids": ",us1234,",
|
||||
"sources": ",us,",
|
||||
"types": ",origin,",
|
||||
"nst": 10,
|
||||
"dmin": 0.5,
|
||||
"rms": 0.3,
|
||||
"gap": 100,
|
||||
"magType": "ml",
|
||||
"type": "earthquake",
|
||||
"title": "M 2.5 - 10km N of Boise, Idaho"
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-116.2, 43.7, 10.5]
|
||||
},
|
||||
"id": "us1234"
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"mag": 4.5,
|
||||
"place": "20km S of Portland, Oregon",
|
||||
"time": 1715878400000,
|
||||
"updated": 1715878500000,
|
||||
"tz": None,
|
||||
"url": "https://earthquake.usgs.gov/earthquakes/eventpage/us5678",
|
||||
"detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us5678.geojson",
|
||||
"felt": 50,
|
||||
"cdi": 4.0,
|
||||
"mmi": 3.5,
|
||||
"alert": "green",
|
||||
"status": "reviewed",
|
||||
"tsunami": 0,
|
||||
"sig": 300,
|
||||
"net": "us",
|
||||
"code": "5678",
|
||||
"ids": ",us5678,",
|
||||
"sources": ",us,",
|
||||
"types": ",origin,shakemap,",
|
||||
"nst": 25,
|
||||
"dmin": 0.2,
|
||||
"rms": 0.2,
|
||||
"gap": 50,
|
||||
"magType": "mw",
|
||||
"type": "earthquake",
|
||||
"title": "M 4.5 - 20km S of Portland, Oregon"
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-122.6, 45.3, 15.0]
|
||||
},
|
||||
"id": "us5678"
|
||||
},
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"mag": 3.0,
|
||||
"place": "50km E of San Francisco, California",
|
||||
"time": 1715878300000,
|
||||
"updated": 1715878400000,
|
||||
"tz": None,
|
||||
"url": "https://earthquake.usgs.gov/earthquakes/eventpage/us9999",
|
||||
"detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us9999.geojson",
|
||||
"felt": None,
|
||||
"cdi": None,
|
||||
"mmi": None,
|
||||
"alert": None,
|
||||
"status": "automatic",
|
||||
"tsunami": 0,
|
||||
"sig": 150,
|
||||
"net": "us",
|
||||
"code": "9999",
|
||||
"ids": ",us9999,",
|
||||
"sources": ",us,",
|
||||
"types": ",origin,",
|
||||
"nst": 15,
|
||||
"dmin": 0.3,
|
||||
"rms": 0.25,
|
||||
"gap": 80,
|
||||
"magType": "ml",
|
||||
"type": "earthquake",
|
||||
"title": "M 3.0 - 50km E of San Francisco, California"
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-121.5, 37.8, 8.0]
|
||||
},
|
||||
"id": "us9999"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Sample with null magnitude
|
||||
SAMPLE_NULL_MAG = {
|
||||
"type": "FeatureCollection",
|
||||
"metadata": {"count": 1},
|
||||
"features": [
|
||||
{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"mag": None,
|
||||
"place": "Quarry blast",
|
||||
"time": 1715878500000,
|
||||
"type": "quarry blast"
|
||||
},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [-116.0, 44.0, 0.0]
|
||||
},
|
||||
"id": "usquarry1"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def make_adapter_config(
|
||||
region: dict | None = None,
|
||||
feed: str = "all_hour",
|
||||
) -> AdapterConfig:
|
||||
"""Create an AdapterConfig for testing."""
|
||||
settings = {"feed": feed}
|
||||
if region:
|
||||
settings["region"] = region
|
||||
else:
|
||||
settings["region"] = {
|
||||
"north": 49.5,
|
||||
"south": 40.0,
|
||||
"east": -110.0,
|
||||
"west": -125.0,
|
||||
}
|
||||
|
||||
return AdapterConfig(
|
||||
name="usgs_quake",
|
||||
enabled=True,
|
||||
cadence_s=60,
|
||||
settings=settings,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path():
|
||||
"""Create a temporary database path for testing."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
yield Path(f.name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_store():
|
||||
"""Create a mock ConfigStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestMagnitudeTier:
|
||||
"""Test magnitude tier classification."""
|
||||
|
||||
def test_minor(self):
|
||||
assert magnitude_tier(0.5) == "minor"
|
||||
assert magnitude_tier(2.9) == "minor"
|
||||
|
||||
def test_light(self):
|
||||
assert magnitude_tier(3.0) == "light"
|
||||
assert magnitude_tier(3.9) == "light"
|
||||
|
||||
def test_moderate(self):
|
||||
assert magnitude_tier(4.0) == "moderate"
|
||||
assert magnitude_tier(4.9) == "moderate"
|
||||
|
||||
def test_strong(self):
|
||||
assert magnitude_tier(5.0) == "strong"
|
||||
assert magnitude_tier(5.9) == "strong"
|
||||
|
||||
def test_major(self):
|
||||
assert magnitude_tier(6.0) == "major"
|
||||
assert magnitude_tier(6.9) == "major"
|
||||
|
||||
def test_great(self):
|
||||
assert magnitude_tier(7.0) == "great"
|
||||
assert magnitude_tier(9.5) == "great"
|
||||
|
||||
|
||||
class TestMagnitudeToSeverity:
|
||||
"""Test magnitude to severity mapping."""
|
||||
|
||||
def test_severity_levels(self):
|
||||
assert magnitude_to_severity(2.0) == 0
|
||||
assert magnitude_to_severity(3.5) == 1
|
||||
assert magnitude_to_severity(4.5) == 2
|
||||
assert magnitude_to_severity(5.5) == 3
|
||||
assert magnitude_to_severity(6.5) == 4
|
||||
assert magnitude_to_severity(7.5) == 5
|
||||
|
||||
|
||||
class TestRegionFiltering:
|
||||
"""Test region/bbox filtering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filters_out_of_bbox(self, temp_db_path, mock_config_store):
|
||||
"""Test that quakes outside bbox are filtered."""
|
||||
# Region covers PNW only (north of 40, west of -110)
|
||||
config = make_adapter_config(
|
||||
region={"north": 49.5, "south": 40.0, "east": -110.0, "west": -125.0}
|
||||
)
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_GEOJSON
|
||||
|
||||
events = []
|
||||
async for event in adapter.poll():
|
||||
events.append(event)
|
||||
|
||||
# us1234 (Boise) and us5678 (Portland) are in bbox
|
||||
# us9999 (SF, lat 37.8) is outside bbox (south < 40)
|
||||
assert len(events) == 2
|
||||
event_ids = {e.id for e in events}
|
||||
assert "us1234" in event_ids
|
||||
assert "us5678" in event_ids
|
||||
assert "us9999" not in event_ids
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
|
||||
class TestDeduplication:
|
||||
"""Test deduplication logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedup_marks_published(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
event_id = "us1234"
|
||||
|
||||
assert not adapter.is_published(event_id)
|
||||
adapter.mark_published(event_id)
|
||||
assert adapter.is_published(event_id)
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_second_poll_no_duplicates(self, temp_db_path, mock_config_store):
|
||||
"""Test that second poll with same events yields nothing."""
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_GEOJSON
|
||||
|
||||
# First poll
|
||||
events1 = []
|
||||
async for event in adapter.poll():
|
||||
events1.append(event)
|
||||
|
||||
# Second poll - same data
|
||||
events2 = []
|
||||
async for event in adapter.poll():
|
||||
events2.append(event)
|
||||
|
||||
# First poll should have events (2 in bbox)
|
||||
assert len(events1) == 2
|
||||
# Second poll should have 0 (all deduped)
|
||||
assert len(events2) == 0
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
|
||||
class TestNullMagnitude:
|
||||
"""Test handling of null magnitude events."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_null_magnitude(self, temp_db_path, mock_config_store):
|
||||
"""Test that events with null magnitude are skipped."""
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_NULL_MAG
|
||||
|
||||
events = []
|
||||
async for event in adapter.poll():
|
||||
events.append(event)
|
||||
|
||||
# Should skip the null-magnitude event
|
||||
assert len(events) == 0
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
|
||||
class TestEventGeneration:
|
||||
"""Test Event generation from features."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_category(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_GEOJSON
|
||||
|
||||
events = []
|
||||
async for event in adapter.poll():
|
||||
events.append(event)
|
||||
|
||||
# Check categories
|
||||
categories = {e.category for e in events}
|
||||
# us1234 is M2.5 -> minor, us5678 is M4.5 -> moderate
|
||||
assert "quake.event.minor" in categories
|
||||
assert "quake.event.moderate" in categories
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_severity(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_GEOJSON
|
||||
|
||||
events = []
|
||||
async for event in adapter.poll():
|
||||
events.append(event)
|
||||
|
||||
# Find events by ID
|
||||
events_by_id = {e.id: e for e in events}
|
||||
|
||||
# M2.5 -> severity 0
|
||||
assert events_by_id["us1234"].severity == 0
|
||||
# M4.5 -> severity 2
|
||||
assert events_by_id["us5678"].severity == 2
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_geo(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config()
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch:
|
||||
mock_fetch.return_value = SAMPLE_GEOJSON
|
||||
|
||||
events = []
|
||||
async for event in adapter.poll():
|
||||
events.append(event)
|
||||
|
||||
events_by_id = {e.id: e for e in events}
|
||||
|
||||
# Check Boise quake coordinates
|
||||
boise = events_by_id["us1234"]
|
||||
assert boise.geo.centroid == (-116.2, 43.7)
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
|
||||
class TestApplyConfig:
|
||||
"""Test hot-reload configuration application."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_config_updates_region(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config(
|
||||
region={"north": 49.5, "south": 40.0, "east": -110.0, "west": -125.0}
|
||||
)
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
assert adapter.region.north == 49.5
|
||||
|
||||
new_config = make_adapter_config(
|
||||
region={"north": 48.0, "south": 45.0, "east": -115.0, "west": -125.0}
|
||||
)
|
||||
await adapter.apply_config(new_config)
|
||||
|
||||
assert adapter.region.north == 48.0
|
||||
assert adapter.region.south == 45.0
|
||||
|
||||
await adapter.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_config_updates_feed(self, temp_db_path, mock_config_store):
|
||||
config = make_adapter_config(feed="all_hour")
|
||||
adapter = USGSQuakeAdapter(
|
||||
config=config,
|
||||
config_store=mock_config_store,
|
||||
cursor_db_path=temp_db_path,
|
||||
)
|
||||
await adapter.startup()
|
||||
|
||||
assert adapter._feed == "all_hour"
|
||||
|
||||
new_config = make_adapter_config(feed="all_day")
|
||||
await adapter.apply_config(new_config)
|
||||
|
||||
assert adapter._feed == "all_day"
|
||||
|
||||
await adapter.shutdown()
|
||||
Loading…
Add table
Add a link
Reference in a new issue