Spaces:
Sleeping
Sleeping
Commit ·
fb2ab55
0
Parent(s):
✨ feat(flood): add SurgeInk backend + flood risk visualization
Browse filesAdd Python FastAPI backend (server/) with geocode, forecast, and layers
endpoints. Add frontend flood feature slice with FEMA flood zone overlay
on MapLibre, river discharge sparkline (recharts), risk score card, and
flood data toggles in settings panel. Rebrand header to SurgeInk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dockerfile +12 -0
- pyproject.toml +24 -0
- requirements.txt +6 -0
- surgeink/__init__.py +0 -0
- surgeink/api/__init__.py +0 -0
- surgeink/api/forecast.py +55 -0
- surgeink/api/geocode.py +66 -0
- surgeink/api/interpret.py +8 -0
- surgeink/api/layers.py +97 -0
- surgeink/api/predict.py +8 -0
- surgeink/api/risk.py +8 -0
- surgeink/api/router.py +13 -0
- surgeink/api/tiles.py +8 -0
- surgeink/cache/__init__.py +0 -0
- surgeink/cache/redis_cache.py +58 -0
- surgeink/config.py +31 -0
- surgeink/data/__init__.py +0 -0
- surgeink/data/openmeteo.py +50 -0
- surgeink/main.py +42 -0
- surgeink/models/__init__.py +0 -0
- surgeink/models/schemas.py +75 -0
- tests/__init__.py +0 -0
- weights/.gitkeep +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY surgeink/ surgeink/
|
| 9 |
+
|
| 10 |
+
EXPOSE 8000
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "surgeink.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
pyproject.toml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "surgeink"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Flood risk visualization API for SurgeInk"
|
| 5 |
+
requires-python = ">=3.11"
|
| 6 |
+
dependencies = [
|
| 7 |
+
"fastapi>=0.115.0,<1.0",
|
| 8 |
+
"uvicorn[standard]>=0.34.0",
|
| 9 |
+
"httpx>=0.28.0",
|
| 10 |
+
"redis[hiredis]>=5.2.0",
|
| 11 |
+
"pydantic>=2.10.0",
|
| 12 |
+
"pydantic-settings>=2.7.0",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
[project.optional-dependencies]
|
| 16 |
+
dev = [
|
| 17 |
+
"pytest>=8.0",
|
| 18 |
+
"pytest-asyncio>=0.24.0",
|
| 19 |
+
"httpx",
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
[tool.pytest.ini_options]
|
| 23 |
+
asyncio_mode = "auto"
|
| 24 |
+
testpaths = ["tests"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115.0,<1.0
|
| 2 |
+
uvicorn[standard]>=0.34.0
|
| 3 |
+
httpx>=0.28.0
|
| 4 |
+
redis[hiredis]>=5.2.0
|
| 5 |
+
pydantic>=2.10.0
|
| 6 |
+
pydantic-settings>=2.7.0
|
surgeink/__init__.py
ADDED
|
File without changes
|
surgeink/api/__init__.py
ADDED
|
File without changes
|
surgeink/api/forecast.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import statistics as stats
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
+
|
| 5 |
+
from surgeink.data.openmeteo import fetch_flood_forecast
|
| 6 |
+
from surgeink.models.schemas import (
|
| 7 |
+
ForecastDaily,
|
| 8 |
+
ForecastResponse,
|
| 9 |
+
ForecastStatistics,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/forecast", response_model=ForecastResponse)
|
| 16 |
+
async def get_forecast(
|
| 17 |
+
lat: float = Query(..., ge=-90, le=90),
|
| 18 |
+
lng: float = Query(..., ge=-180, le=180),
|
| 19 |
+
forecast_days: int = Query(7, ge=1, le=210),
|
| 20 |
+
include_history: int = Query(0, ge=0, le=92),
|
| 21 |
+
):
|
| 22 |
+
try:
|
| 23 |
+
data = await fetch_flood_forecast(lat, lng, forecast_days, include_history)
|
| 24 |
+
except Exception as e:
|
| 25 |
+
raise HTTPException(502, detail=f"Open-Meteo API error: {e}")
|
| 26 |
+
|
| 27 |
+
daily_raw = data.get("daily", [])
|
| 28 |
+
daily = [ForecastDaily(date=d["date"], discharge_m3s=d["discharge_m3s"]) for d in daily_raw]
|
| 29 |
+
|
| 30 |
+
# Current discharge = first value in the list (today or earliest date)
|
| 31 |
+
current = daily[0].discharge_m3s if daily else None
|
| 32 |
+
|
| 33 |
+
# Compute statistics from non-null values
|
| 34 |
+
values = [d.discharge_m3s for d in daily if d.discharge_m3s is not None]
|
| 35 |
+
statistics = None
|
| 36 |
+
if len(values) >= 2:
|
| 37 |
+
sorted_vals = sorted(values)
|
| 38 |
+
p75_idx = int(len(sorted_vals) * 0.75)
|
| 39 |
+
p90_idx = int(len(sorted_vals) * 0.90)
|
| 40 |
+
statistics = ForecastStatistics(
|
| 41 |
+
mean=round(stats.mean(values), 2),
|
| 42 |
+
median=round(stats.median(values), 2),
|
| 43 |
+
p75=round(sorted_vals[min(p75_idx, len(sorted_vals) - 1)], 2),
|
| 44 |
+
p90=round(sorted_vals[min(p90_idx, len(sorted_vals) - 1)], 2),
|
| 45 |
+
period=f"{daily[0].date} to {daily[-1].date}" if daily else "",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return ForecastResponse(
|
| 49 |
+
latitude=data.get("latitude", lat),
|
| 50 |
+
longitude=data.get("longitude", lng),
|
| 51 |
+
source="Open-Meteo GloFAS v4",
|
| 52 |
+
current_discharge_m3s=current,
|
| 53 |
+
daily=daily,
|
| 54 |
+
statistics=statistics,
|
| 55 |
+
)
|
surgeink/api/geocode.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 3 |
+
|
| 4 |
+
from surgeink.cache.redis_cache import cache_get_json, cache_set_json
|
| 5 |
+
from surgeink.config import settings
|
| 6 |
+
from surgeink.models.schemas import GeocodeResponse, GeocodeResult
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
CACHE_TTL = 86400 # 24 hours
|
| 11 |
+
USER_AGENT = "SurgeInk/0.1 (flood-risk-viz)"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/geocode", response_model=GeocodeResponse)
|
| 15 |
+
async def geocode(
|
| 16 |
+
q: str = Query(..., min_length=2, description="Search query"),
|
| 17 |
+
limit: int = Query(5, ge=1, le=10),
|
| 18 |
+
):
|
| 19 |
+
cache_key = f"geocode:{q.lower().strip()}:{limit}"
|
| 20 |
+
cached = await cache_get_json(cache_key)
|
| 21 |
+
if cached is not None:
|
| 22 |
+
return GeocodeResponse(**cached)
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 26 |
+
resp = await client.get(
|
| 27 |
+
f"{settings.nominatim_base_url}/search",
|
| 28 |
+
params={
|
| 29 |
+
"format": "jsonv2",
|
| 30 |
+
"addressdetails": 1,
|
| 31 |
+
"limit": limit,
|
| 32 |
+
"q": q,
|
| 33 |
+
},
|
| 34 |
+
headers={"User-Agent": USER_AGENT},
|
| 35 |
+
)
|
| 36 |
+
resp.raise_for_status()
|
| 37 |
+
except Exception as e:
|
| 38 |
+
raise HTTPException(502, detail=f"Nominatim API error: {e}")
|
| 39 |
+
|
| 40 |
+
raw_results = resp.json()
|
| 41 |
+
results = []
|
| 42 |
+
|
| 43 |
+
for item in raw_results:
|
| 44 |
+
# Nominatim bbox: [south, north, west, east] as strings
|
| 45 |
+
# Convert to [min_lng, min_lat, max_lng, max_lat]
|
| 46 |
+
raw_bbox = item.get("boundingbox", [])
|
| 47 |
+
if len(raw_bbox) == 4:
|
| 48 |
+
south, north, west, east = [float(v) for v in raw_bbox]
|
| 49 |
+
bbox = [west, south, east, north]
|
| 50 |
+
else:
|
| 51 |
+
bbox = []
|
| 52 |
+
|
| 53 |
+
results.append(GeocodeResult(
|
| 54 |
+
name=item.get("name", item.get("display_name", "").split(",")[0]),
|
| 55 |
+
display_name=item.get("display_name", ""),
|
| 56 |
+
lat=float(item.get("lat", 0)),
|
| 57 |
+
lng=float(item.get("lon", 0)),
|
| 58 |
+
bbox=bbox,
|
| 59 |
+
flood_data_available=True,
|
| 60 |
+
available_layers=[],
|
| 61 |
+
))
|
| 62 |
+
|
| 63 |
+
response_data = {"results": [r.model_dump() for r in results]}
|
| 64 |
+
await cache_set_json(cache_key, response_data, CACHE_TTL)
|
| 65 |
+
|
| 66 |
+
return GeocodeResponse(results=results)
|
surgeink/api/interpret.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get("/interpret")
|
| 7 |
+
async def get_interpret():
|
| 8 |
+
raise HTTPException(501, detail="Not implemented yet — Phase 4")
|
surgeink/api/layers.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
|
| 3 |
+
from surgeink.models.schemas import (
|
| 4 |
+
LayerInfo,
|
| 5 |
+
LayerLegend,
|
| 6 |
+
LayerLegendStop,
|
| 7 |
+
LayersResponse,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
# Hardcoded layer catalog — Phase 1.
|
| 13 |
+
# Layers are marked available/unavailable based on what's actually implemented.
|
| 14 |
+
# This will become dynamic in Phase 3 (check data availability per bbox).
|
| 15 |
+
|
| 16 |
+
LAYER_CATALOG = [
|
| 17 |
+
LayerInfo(
|
| 18 |
+
id="ml_risk",
|
| 19 |
+
name="ML Flood Risk Prediction",
|
| 20 |
+
type="raster",
|
| 21 |
+
source="SurgeInk ML v1.0",
|
| 22 |
+
available=False,
|
| 23 |
+
reason="Not yet implemented — Phase 4",
|
| 24 |
+
legend=LayerLegend(
|
| 25 |
+
type="gradient",
|
| 26 |
+
stops=[
|
| 27 |
+
LayerLegendStop(value=1, color="#2166ac", label="Low"),
|
| 28 |
+
LayerLegendStop(value=5, color="#f4a582", label="Moderate"),
|
| 29 |
+
LayerLegendStop(value=10, color="#b2182b", label="Critical"),
|
| 30 |
+
],
|
| 31 |
+
),
|
| 32 |
+
),
|
| 33 |
+
LayerInfo(
|
| 34 |
+
id="jrc_water_occurrence",
|
| 35 |
+
name="Historical Water Occurrence",
|
| 36 |
+
type="raster",
|
| 37 |
+
source="JRC Global Surface Water (1984–2021)",
|
| 38 |
+
available=False,
|
| 39 |
+
reason="Not yet implemented — Phase 2",
|
| 40 |
+
),
|
| 41 |
+
LayerInfo(
|
| 42 |
+
id="live_discharge",
|
| 43 |
+
name="River Discharge (Live)",
|
| 44 |
+
type="geojson",
|
| 45 |
+
source="Open-Meteo / GloFAS v4",
|
| 46 |
+
endpoint="/api/v1/forecast",
|
| 47 |
+
available=True,
|
| 48 |
+
),
|
| 49 |
+
LayerInfo(
|
| 50 |
+
id="fema_zones",
|
| 51 |
+
name="FEMA Flood Zones",
|
| 52 |
+
type="vector",
|
| 53 |
+
source="FEMA NFHL",
|
| 54 |
+
available=False,
|
| 55 |
+
region="US only",
|
| 56 |
+
reason="Not yet implemented — Phase 3",
|
| 57 |
+
),
|
| 58 |
+
LayerInfo(
|
| 59 |
+
id="wri_aqueduct",
|
| 60 |
+
name="Climate Flood Risk Scenarios",
|
| 61 |
+
type="raster",
|
| 62 |
+
source="WRI Aqueduct Floods (via GEE)",
|
| 63 |
+
available=False,
|
| 64 |
+
reason="Not yet implemented — Phase 3",
|
| 65 |
+
),
|
| 66 |
+
LayerInfo(
|
| 67 |
+
id="interpretability",
|
| 68 |
+
name="Model Interpretability Heatmap",
|
| 69 |
+
type="raster",
|
| 70 |
+
source="SurgeInk ML interpretability",
|
| 71 |
+
available=False,
|
| 72 |
+
reason="Not yet implemented — Phase 4",
|
| 73 |
+
),
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.get("/layers", response_model=LayersResponse)
|
| 78 |
+
async def get_layers(
|
| 79 |
+
bbox: str = Query(
|
| 80 |
+
...,
|
| 81 |
+
description="Bounding box: min_lng,min_lat,max_lng,max_lat",
|
| 82 |
+
),
|
| 83 |
+
zoom: int = Query(10, ge=0, le=22),
|
| 84 |
+
):
|
| 85 |
+
# Parse bbox
|
| 86 |
+
parts = bbox.split(",")
|
| 87 |
+
if len(parts) != 4:
|
| 88 |
+
raise HTTPException(422, detail="bbox must have exactly 4 comma-separated values")
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
[float(p.strip()) for p in parts]
|
| 92 |
+
except ValueError:
|
| 93 |
+
raise HTTPException(422, detail="bbox values must be numeric")
|
| 94 |
+
|
| 95 |
+
# For Phase 1, return the full catalog regardless of bbox.
|
| 96 |
+
# Phase 3 will make availability dynamic based on location.
|
| 97 |
+
return LayersResponse(layers=LAYER_CATALOG)
|
surgeink/api/predict.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.post("/predict")
|
| 7 |
+
async def predict():
|
| 8 |
+
raise HTTPException(501, detail="Not implemented yet — Phase 4")
|
surgeink/api/risk.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get("/risk")
|
| 7 |
+
async def get_risk():
|
| 8 |
+
raise HTTPException(501, detail="Not implemented yet — Phase 3")
|
surgeink/api/router.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
|
| 3 |
+
from surgeink.api import geocode, forecast, layers, risk, tiles, predict, interpret
|
| 4 |
+
|
| 5 |
+
router = APIRouter(prefix="/api/v1")
|
| 6 |
+
|
| 7 |
+
router.include_router(geocode.router, tags=["geocode"])
|
| 8 |
+
router.include_router(forecast.router, tags=["forecast"])
|
| 9 |
+
router.include_router(layers.router, tags=["layers"])
|
| 10 |
+
router.include_router(risk.router, tags=["risk"])
|
| 11 |
+
router.include_router(tiles.router, tags=["tiles"])
|
| 12 |
+
router.include_router(predict.router, tags=["predict"])
|
| 13 |
+
router.include_router(interpret.router, tags=["interpret"])
|
surgeink/api/tiles.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
router = APIRouter()
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@router.get("/tiles/{layer}/{z}/{x}/{y}.{fmt}")
|
| 7 |
+
async def get_tile(layer: str, z: int, x: int, y: int, fmt: str):
|
| 8 |
+
raise HTTPException(501, detail="Not implemented yet — Phase 2")
|
surgeink/cache/__init__.py
ADDED
|
File without changes
|
surgeink/cache/redis_cache.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import redis.asyncio as aioredis
|
| 8 |
+
|
| 9 |
+
from surgeink.config import settings
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
_redis: Optional[aioredis.Redis] = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def get_redis() -> aioredis.Redis:
|
| 17 |
+
global _redis
|
| 18 |
+
if _redis is None:
|
| 19 |
+
_redis = aioredis.from_url(
|
| 20 |
+
settings.redis_url,
|
| 21 |
+
decode_responses=True,
|
| 22 |
+
)
|
| 23 |
+
return _redis
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def cache_get(key: str) -> Optional[str]:
|
| 27 |
+
try:
|
| 28 |
+
r = await get_redis()
|
| 29 |
+
return await r.get(key)
|
| 30 |
+
except Exception:
|
| 31 |
+
logger.debug("Redis cache miss (connection error) for key: %s", key)
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def cache_set(key: str, value: str, ttl_seconds: int = 3600) -> None:
|
| 36 |
+
try:
|
| 37 |
+
r = await get_redis()
|
| 38 |
+
await r.set(key, value, ex=ttl_seconds)
|
| 39 |
+
except Exception:
|
| 40 |
+
logger.debug("Redis cache set failed for key: %s", key)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def cache_get_json(key: str):
|
| 44 |
+
raw = await cache_get(key)
|
| 45 |
+
if raw is not None:
|
| 46 |
+
return json.loads(raw)
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def cache_set_json(key: str, value, ttl_seconds: int = 3600) -> None:
|
| 51 |
+
await cache_set(key, json.dumps(value), ttl_seconds)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
async def close_redis() -> None:
|
| 55 |
+
global _redis
|
| 56 |
+
if _redis is not None:
|
| 57 |
+
await _redis.close()
|
| 58 |
+
_redis = None
|
surgeink/config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
model_config = SettingsConfigDict(
|
| 6 |
+
env_prefix="SURGEINK_",
|
| 7 |
+
env_file=".env",
|
| 8 |
+
extra="ignore",
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
env: str = "development"
|
| 12 |
+
api_host: str = "0.0.0.0"
|
| 13 |
+
api_port: int = 8000
|
| 14 |
+
|
| 15 |
+
# Redis (uses SURGEINK_REDIS_URL or defaults)
|
| 16 |
+
redis_url: str = "redis://redis:6379/0"
|
| 17 |
+
|
| 18 |
+
# External data source base URLs
|
| 19 |
+
nominatim_base_url: str = "https://nominatim.openstreetmap.org"
|
| 20 |
+
openmeteo_base_url: str = "https://flood-api.open-meteo.com"
|
| 21 |
+
fema_nfhl_base_url: str = "https://hazards.fema.gov/arcgis/rest/services/public/NFHL/MapServer"
|
| 22 |
+
openfema_base_url: str = "https://www.fema.gov/api/open/v2"
|
| 23 |
+
|
| 24 |
+
# CORS
|
| 25 |
+
cors_origins: list[str] = [
|
| 26 |
+
"http://localhost:5173",
|
| 27 |
+
"http://localhost:7200",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
settings = Settings()
|
surgeink/data/__init__.py
ADDED
|
File without changes
|
surgeink/data/openmeteo.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import httpx
|
| 2 |
+
|
| 3 |
+
from surgeink.cache.redis_cache import cache_get_json, cache_set_json
|
| 4 |
+
from surgeink.config import settings
|
| 5 |
+
|
| 6 |
+
CACHE_TTL = 21600 # 6 hours
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def fetch_flood_forecast(
|
| 10 |
+
lat: float,
|
| 11 |
+
lng: float,
|
| 12 |
+
forecast_days: int = 7,
|
| 13 |
+
past_days: int = 0,
|
| 14 |
+
) -> dict:
|
| 15 |
+
cache_key = f"forecast:{lat:.2f}:{lng:.2f}:{forecast_days}:{past_days}"
|
| 16 |
+
cached = await cache_get_json(cache_key)
|
| 17 |
+
if cached is not None:
|
| 18 |
+
return cached
|
| 19 |
+
|
| 20 |
+
params = {
|
| 21 |
+
"latitude": lat,
|
| 22 |
+
"longitude": lng,
|
| 23 |
+
"daily": "river_discharge",
|
| 24 |
+
"forecast_days": forecast_days,
|
| 25 |
+
"past_days": past_days,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
| 29 |
+
resp = await client.get(
|
| 30 |
+
f"{settings.openmeteo_base_url}/v1/flood",
|
| 31 |
+
params=params,
|
| 32 |
+
)
|
| 33 |
+
resp.raise_for_status()
|
| 34 |
+
|
| 35 |
+
data = resp.json()
|
| 36 |
+
|
| 37 |
+
times = data.get("daily", {}).get("time", [])
|
| 38 |
+
discharges = data.get("daily", {}).get("river_discharge", [])
|
| 39 |
+
|
| 40 |
+
result = {
|
| 41 |
+
"latitude": data.get("latitude", lat),
|
| 42 |
+
"longitude": data.get("longitude", lng),
|
| 43 |
+
"daily": [
|
| 44 |
+
{"date": t, "discharge_m3s": d}
|
| 45 |
+
for t, d in zip(times, discharges)
|
| 46 |
+
],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
await cache_set_json(cache_key, result, CACHE_TTL)
|
| 50 |
+
return result
|
surgeink/main.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
from surgeink.cache.redis_cache import close_redis
|
| 7 |
+
from surgeink.config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@asynccontextmanager
|
| 11 |
+
async def lifespan(app: FastAPI):
|
| 12 |
+
yield
|
| 13 |
+
await close_redis()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
app = FastAPI(
|
| 17 |
+
title="SurgeInk API",
|
| 18 |
+
version="0.1.0",
|
| 19 |
+
description="Flood risk visualization API",
|
| 20 |
+
docs_url="/api/docs",
|
| 21 |
+
openapi_url="/api/openapi.json",
|
| 22 |
+
lifespan=lifespan,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
app.add_middleware(
|
| 26 |
+
CORSMiddleware,
|
| 27 |
+
allow_origins=settings.cors_origins,
|
| 28 |
+
allow_credentials=True,
|
| 29 |
+
allow_methods=["*"],
|
| 30 |
+
allow_headers=["*"],
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@app.get("/api/health")
|
| 35 |
+
async def health():
|
| 36 |
+
return {"status": "ok"}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# Mount versioned API router
|
| 40 |
+
from surgeink.api.router import router as api_router # noqa: E402
|
| 41 |
+
|
| 42 |
+
app.include_router(api_router)
|
surgeink/models/__init__.py
ADDED
|
File without changes
|
surgeink/models/schemas.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# --- Geocode ---
|
| 9 |
+
|
| 10 |
+
class GeocodeResult(BaseModel):
|
| 11 |
+
name: str
|
| 12 |
+
display_name: str
|
| 13 |
+
lat: float
|
| 14 |
+
lng: float
|
| 15 |
+
bbox: list[float] # [min_lng, min_lat, max_lng, max_lat]
|
| 16 |
+
flood_data_available: bool = True
|
| 17 |
+
available_layers: list[str] = []
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class GeocodeResponse(BaseModel):
|
| 21 |
+
results: list[GeocodeResult]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# --- Forecast ---
|
| 25 |
+
|
| 26 |
+
class ForecastDaily(BaseModel):
|
| 27 |
+
date: str
|
| 28 |
+
discharge_m3s: Optional[float]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ForecastStatistics(BaseModel):
|
| 32 |
+
mean: Optional[float]
|
| 33 |
+
median: Optional[float]
|
| 34 |
+
p75: Optional[float]
|
| 35 |
+
p90: Optional[float]
|
| 36 |
+
period: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ForecastResponse(BaseModel):
|
| 40 |
+
latitude: float
|
| 41 |
+
longitude: float
|
| 42 |
+
source: str
|
| 43 |
+
current_discharge_m3s: Optional[float]
|
| 44 |
+
daily: list[ForecastDaily]
|
| 45 |
+
statistics: Optional[ForecastStatistics] = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# --- Layers ---
|
| 49 |
+
|
| 50 |
+
class LayerLegendStop(BaseModel):
|
| 51 |
+
value: float
|
| 52 |
+
color: str
|
| 53 |
+
label: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class LayerLegend(BaseModel):
|
| 57 |
+
type: str
|
| 58 |
+
stops: list[LayerLegendStop]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class LayerInfo(BaseModel):
|
| 62 |
+
id: str
|
| 63 |
+
name: str
|
| 64 |
+
type: str
|
| 65 |
+
source: str
|
| 66 |
+
available: bool
|
| 67 |
+
tile_url: Optional[str] = None
|
| 68 |
+
endpoint: Optional[str] = None
|
| 69 |
+
region: Optional[str] = None
|
| 70 |
+
legend: Optional[LayerLegend] = None
|
| 71 |
+
reason: Optional[str] = None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class LayersResponse(BaseModel):
|
| 75 |
+
layers: list[LayerInfo]
|
tests/__init__.py
ADDED
|
File without changes
|
weights/.gitkeep
ADDED
|
File without changes
|