Skip to content

Commit 5fa89f5

Browse files
feat: geospatial risk primitives and HazardEvent schema with dedup key
1 parent 6f1737d commit 5fa89f5

4 files changed

Lines changed: 127 additions & 0 deletions

File tree

src/aegis/geo.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Geospatial primitives for hazard risk scoring.
2+
3+
Great-circle distance + a distance-decay proximity risk score. Pure-numpy so the core
4+
risk math has no heavy geo dependencies (GeoPandas/H3 arrive in later phases for the
5+
polygon/indexing work).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import math
11+
12+
EARTH_RADIUS_KM = 6371.0088
13+
14+
15+
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
16+
"""Great-circle distance between two (lat, lon) points in kilometres."""
17+
p1, p2 = math.radians(lat1), math.radians(lat2)
18+
dphi = math.radians(lat2 - lat1)
19+
dlmb = math.radians(lon2 - lon1)
20+
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
21+
return 2 * EARTH_RADIUS_KM * math.asin(math.sqrt(a))
22+
23+
24+
def proximity_risk(distance_km: float, radius_km: float = 50.0) -> float:
25+
"""Distance-decay risk in [0, 1].
26+
27+
1.0 at the hazard, decaying exponentially with distance. ``radius_km`` is the
28+
characteristic scale at which risk falls to ~37% (one e-folding). Negative distances
29+
are treated as 0 (at the event).
30+
"""
31+
if radius_km <= 0:
32+
raise ValueError("radius_km must be positive")
33+
d = max(distance_km, 0.0)
34+
return math.exp(-d / radius_km)
35+
36+
37+
def asset_risk(
38+
asset: tuple[float, float],
39+
hazard: tuple[float, float],
40+
radius_km: float = 50.0,
41+
) -> float:
42+
"""Convenience: proximity risk of a hazard to an asset, both as (lat, lon)."""
43+
return proximity_risk(haversine_km(*asset, *hazard), radius_km=radius_km)

src/aegis/schema.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Canonical hazard-event schema.
2+
3+
Every source (FIRMS fires, USGS quakes, NOAA alerts) is normalized into ``HazardEvent``
4+
so enrichment, scoring, and RAG stay source-agnostic. ``dedup_key`` is the basis for
5+
change-detection: two ingests producing the same key are the same real-world event.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from datetime import datetime
11+
from enum import StrEnum
12+
13+
from pydantic import BaseModel, Field
14+
15+
16+
class HazardType(StrEnum):
17+
WILDFIRE = "wildfire"
18+
EARTHQUAKE = "earthquake"
19+
SEVERE_WEATHER = "severe_weather"
20+
FLOOD = "flood"
21+
OTHER = "other"
22+
23+
24+
class HazardEvent(BaseModel):
25+
"""A normalized hazard observation."""
26+
27+
source: str = Field(description="Originating feed, e.g. 'firms', 'usgs', 'noaa'")
28+
source_id: str = Field(description="Stable id within the source feed")
29+
hazard_type: HazardType
30+
latitude: float = Field(ge=-90, le=90)
31+
longitude: float = Field(ge=-180, le=180)
32+
observed_at: datetime
33+
magnitude: float | None = Field(default=None, description="Quake magnitude / fire FRP / etc.")
34+
raw: dict = Field(default_factory=dict, description="Original source payload")
35+
36+
@property
37+
def dedup_key(self) -> str:
38+
"""Stable identity for change-detection across re-ingests."""
39+
return f"{self.source}:{self.source_id}"

tests/test_geo.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import math
2+
3+
import pytest
4+
5+
from aegis.geo import asset_risk, haversine_km, proximity_risk
6+
7+
8+
def test_haversine_known_distance():
9+
# College Station, TX -> Austin, TX is ~135 km.
10+
d = haversine_km(30.6280, -96.3344, 30.2672, -97.7431)
11+
assert 120 < d < 150
12+
13+
14+
def test_proximity_risk_bounds_and_decay():
15+
assert proximity_risk(0.0) == 1.0
16+
assert math.isclose(proximity_risk(50.0, radius_km=50.0), math.exp(-1), rel_tol=1e-9)
17+
assert proximity_risk(1000.0) < proximity_risk(10.0)
18+
19+
20+
def test_proximity_risk_rejects_bad_radius():
21+
with pytest.raises(ValueError):
22+
proximity_risk(10.0, radius_km=0)
23+
24+
25+
def test_asset_risk_higher_when_closer():
26+
hazard = (30.0, -96.0)
27+
near = asset_risk((30.1, -96.1), hazard)
28+
far = asset_risk((35.0, -100.0), hazard)
29+
assert near > far

tests/test_schema.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from datetime import datetime
2+
3+
from aegis.schema import HazardEvent, HazardType
4+
5+
6+
def test_dedup_key_is_stable():
7+
ev = HazardEvent(
8+
source="usgs",
9+
source_id="ak0231",
10+
hazard_type=HazardType.EARTHQUAKE,
11+
latitude=61.2,
12+
longitude=-149.9,
13+
observed_at=datetime(2026, 6, 8, 0, 0, 0),
14+
magnitude=4.5,
15+
)
16+
assert ev.dedup_key == "usgs:ak0231"

0 commit comments

Comments
 (0)