geo-hazard
ShippedA public spatial API over Spain's live natural-hazard feeds.
geo-hazard is an open-source backend that turns three heterogeneous public feeds - AEMET weather warnings, IGN earthquakes and EFFIS (Copernicus) wildfires - into one queryable spatial contract: every event has a normalized 1-4 severity, a WGS84 geometry and its raw source attributes preserved. On top of that single table it serves bounding-box listings with cursor pagination, radius searches measured to the true polygon edge, parametric DBSCAN clustering, and a separate analytics plane for aggregates. FastAPI and PostgreSQL/PostGIS on the operational side, DuckDB over GeoParquet on the analytical side, and Postgres-native jobs plus a transactional outbox in between. The console below runs against the live API.
- 10
- endpoints
- 3
- data sources
- 2
- data planes
- 221
- tests
- 85%
- min coverage
- 19
- ADRs
Interactive console (requires JavaScript). The API surface is described in the text.
Results
Analytics plane (DuckDB over GeoParquet)
wildfires / burned-area
earthquakes / frequency
warnings / summary
The problem: three feeds, one contract
AEMET publishes CAP-XML warning bulletins behind a two-hop API, IGN publishes earthquakes as GeoRSS with Spanish local timestamps, and EFFIS serves wildfire layers over WFS. Different formats, different cadences, different failure modes - and none of them is queryable spatially. geo-hazard normalizes all three into a single hazard_events table: one severity scale (1-4) derived per source, one geometry column in WGS84, and the untouched source payload kept in a JSONB attrs column so normalization never destroys information. The console above queries exactly that contract.
Ingestion that expects sources to fail
Each source has a scheduled job and an idempotent content-hash upsert: re-serving the same bulletin produces zero writes and zero derived work, while a warning whose polygon grows is detected and updated. Derived products (the GeoParquet snapshots) hang off a transactional outbox, so they are computed at-least-once after the ingest commits, with exponential backoff on failure. When the EFFIS WFS broke server-side for two days, the honest answer was to pause that one job in the database - the API reported zero wildfire rows instead of inventing data, and the other two sources never noticed. And because sources rarely publish endings, lifecycle is modeled per source: a warning closes when its bulletin supersedes it, a quake is an instant, and a burnt area stays open exactly as long as EFFIS keeps serving it in its near-real-time catalog.
Spatial queries: bbox, radius, clusters
Everything is stored in EPSG:4326 and measured in EPSG:25830, projecting the query parameter instead of the indexed column so the GiST index keeps working. The near endpoint prefilters candidates with an envelope around the buffered point and then refines with ST_DWithin, measuring to the polygon edge rather than the centroid - being 3 km inside a warning area means distance zero, not 40 km. Clusters run ST_ClusterDBSCAN with eps and min_points straight from the query string. Listings paginate with a keyset cursor on (starts_at, id), which stays stable while ingest jobs write underneath.
Two data planes: Postgres and DuckDB
Operational questions (what is active, what is near me) and analytical questions (hectares burned per province per month) have different shapes, and one engine serves both badly. geo-hazard keeps them apart: the operational plane reads PostGIS; the analytics plane reads only GeoParquet snapshots with an in-process DuckDB engine, joining against a committed CNIG provinces reference. The boundary is not a convention - import-linter contracts forbid the analytics package from importing the database layer at all, so the separation is checked on every CI run.
Built to stay up, and to be observed
A public API on a shared VPS gets abused, so availability is defended in the app (ADR-0017): per-IP rate limiting - a generous global budget plus a strict 20/minute on the expensive clusters and analytics endpoints, answered with 429 and a Retry-After header - a transversal statement_timeout so no single query can pin the database, and a rule that /clusters must carry a bounding filter (a bbox or a time window) rather than scan the whole table. Operability is first-class too (ADR-0018 and 0019): GET /v1/sources/status reports per-source freshness judged against the cadence of each source - the trust surface the console renders and the signal a cron alert watches - application logs are structured JSON carrying a request-id, /metrics exposes Prometheus counters privately, and the database plus the irreproducible GeoParquet history are backed up daily with a restore that is actually tested.
An API contract worth testing
Responses are GeoJSON FeatureCollections with numberReturned and nextCursor as foreign members, errors share a single {detail, code} envelope, and validation failures distinguish 422 (malformed request) from 400 (well-formed but impossible, like an inverted bbox). Behind the contract: 221 tests including integration suites against real PostGIS containers, a global 85% coverage gate with per-module targets up to 90%, and 19 ADRs recording why each non-obvious decision was taken. The deploy pipeline only reaches the server after CI is green, over an SSH key that can execute exactly one command.