<- Volver al trabajo

geo-hazard

Publicado

Una API espacial pública sobre los feeds de riesgos naturales de España, en vivo.

geo-hazard es un backend open source que convierte tres feeds públicos heterogéneos - avisos meteorológicos de AEMET, terremotos del IGN e incendios de EFFIS (Copernicus) - en un único contrato espacial consultable: cada evento tiene una severidad normalizada 1-4, una geometría WGS84 y sus atributos crudos de origen preservados. Sobre esa única tabla sirve listados por bounding box con paginación por cursor, búsquedas por radio medidas al borde real del polígono, clustering DBSCAN paramétrico y un plano analítico separado para agregados. FastAPI y PostgreSQL/PostGIS en el lado operacional, DuckDB sobre GeoParquet en el analítico, y jobs nativos de Postgres más un outbox transaccional en medio. La consola de abajo consulta la API en vivo.

10
endpoints
3
fuentes de datos
2
planos de datos
221
tests
85%
cobertura mín
19
ADRs

Consola interactiva (requiere JavaScript). La superficie de la API se describe en el texto.

La superficie pública completa, en vivo: cambia de modo de consulta, activa los filtros compartidos, haz click en el mapa para mover el centro de near, pagina resultados con el cursor y abre cualquier evento para ver sus atributos crudos de origen. Cada control se corresponde uno a uno con un parámetro de la API.Instantánea estática de la API pública geo-hazard (avisos AEMET + terremotos IGN). Mapa base: Natural Earth (dominio público).

Plano analítico (DuckDB sobre GeoParquet)

wildfires / burned-area

earthquakes / frequency

warnings / summary

El problema: tres feeds, un contrato

AEMET publica boletines de avisos en CAP-XML tras una API de dos saltos, el IGN publica terremotos como GeoRSS con marcas de tiempo locales en castellano y EFFIS sirve capas de incendios por WFS. Formatos distintos, cadencias distintas, modos de fallo distintos - y ninguno es consultable espacialmente. geo-hazard normaliza los tres en una única tabla hazard_events: una escala de severidad (1-4) derivada por fuente, una columna de geometría en WGS84 y el payload de origen intacto en una columna JSONB attrs, de modo que normalizar nunca destruye información. La consola de arriba consulta exactamente ese contrato.

Ingesta que asume que las fuentes fallan

Cada fuente tiene un job programado y un upsert idempotente por content-hash: re-servir el mismo boletín produce cero escrituras y cero trabajo derivado, mientras que un aviso cuyo polígono crece se detecta y se actualiza. Los productos derivados (los snapshots GeoParquet) cuelgan de un outbox transaccional, así que se calculan al menos una vez después del commit de la ingesta, con backoff exponencial si fallan. Cuando el WFS de EFFIS se rompió en el lado del servidor durante dos días, la respuesta honesta fue pausar ese único job en la base de datos: la API devolvió cero filas de incendios en vez de inventar datos, y las otras dos fuentes ni se enteraron. Y como las fuentes rara vez publican finales, el ciclo de vida se modela por fuente: un aviso se cierra cuando su boletín lo supersede, un sismo es un instante, y un área quemada sigue abierta exactamente mientras EFFIS la sirva en su catálogo near-real-time.

Consultas espaciales: bbox, radio, clusters

Todo se almacena en EPSG:4326 y se mide en EPSG:25830, proyectando el parámetro de la consulta en vez de la columna indexada, de modo que el índice GiST sigue funcionando. El endpoint near prefiltra candidatos con un envelope alrededor del punto con buffer y refina con ST_DWithin, midiendo al borde del polígono y no al centroide: estar 3 km dentro de una zona de aviso significa distancia cero, no 40 km. Los clusters ejecutan ST_ClusterDBSCAN con eps y min_points directos de la query string. Los listados paginan con un cursor keyset sobre (starts_at, id), que se mantiene estable mientras los jobs de ingesta escriben debajo.

Dos planos de datos: Postgres y DuckDB

Las preguntas operacionales (qué está activo, qué hay cerca de mí) y las analíticas (hectáreas quemadas por provincia y mes) tienen formas distintas, y un solo motor sirve mal a ambas. geo-hazard las mantiene separadas: el plano operacional lee PostGIS; el plano analítico lee solo snapshots GeoParquet con un motor DuckDB en proceso, cruzando contra una referencia de provincias del CNIG commiteada. La frontera no es una convención: contratos de import-linter prohíben que el paquete de analytics importe la capa de base de datos, así que la separación se comprueba en cada ejecución de CI.

Pensada para aguantar, y para observarse

Una API pública en un VPS compartido recibe abuso, así que la disponibilidad se defiende en la propia app (ADR-0017): rate limiting por IP - un presupuesto global generoso más un límite estricto de 20/minuto en los endpoints caros de clusters y analytics, respondido con 429 y cabecera Retry-After -, un statement_timeout transversal para que ninguna consulta pueda bloquear la base de datos, y la regla de que /clusters debe llevar una cota (un bbox o una ventana temporal) en vez de recorrer la tabla entera. La operabilidad también es de primera (ADR-0018 y 0019): GET /v1/sources/status informa de la frescura por fuente juzgada contra la cadencia propia de cada una - la superficie de confianza que renderiza la consola y la señal que vigila una alerta por cron -, los logs de aplicación son JSON estructurado con un request-id, /metrics expone contadores Prometheus en privado, y la base de datos junto con la historia GeoParquet irreproducible se respaldan a diario con un restore que se prueba de verdad.

Un contrato de API que merece tests

Las respuestas son FeatureCollections GeoJSON con numberReturned y nextCursor como foreign members, los errores comparten un único sobre {detail, code}, y los fallos de validación distinguen 422 (petición malformada) de 400 (bien formada pero imposible, como un bbox invertido). Detrás del contrato: 221 tests, incluidas suites de integración contra contenedores PostGIS reales, un gate global de cobertura del 85% con targets por módulo de hasta el 90%, y 19 ADRs que registran por qué se tomó cada decisión no obvia. El pipeline de deploy solo llega al servidor con la CI en verde, sobre una clave SSH que puede ejecutar exactamente un comando.