kredd25 Claude Opus 4.6 (1M context) commited on
Commit
fb2ab55
·
0 Parent(s):

✨ feat(flood): add SurgeInk backend + flood risk visualization

Browse files

Add 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 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