feat: add analysis pages and raster risk map

Ship a new app version with broader analytics, restructured
dashboards, and a server-rendered risk map.

Frontend:
- Add Overview, Demographic, Disease, and Environmental Health
  analysis pages
- Add AnomalyMarkers, CalendarHeatmap, and MetricHeatmapTable
  components
- Rebuild Alerts map onto server-rendered raster risk tiles;
  expand Monitoring, Trend, and District Comparison views
- Extend API client, stores, and TypeScript types

Backend:
- Add environment router (pollutants, lag correlations)
- Add risk_raster util serving XYZ 100m risk tiles
- Expand cases endpoints (demographics, seasonality, diagnoses)
  and insights; harden auth and file-based loaders

Data & tooling:
- Add processed outpatient/inpatient/combined case parquet (LFS)
- Add nested CLAUDE.md guides, pyrightconfig, and test updates
This commit is contained in:
2026-06-21 17:35:03 +08:00
parent f092c3c550
commit e95e2f1338
63 changed files with 8534 additions and 988 deletions

View File

@@ -2,10 +2,10 @@
Router for CBPOA risk assessment endpoints
Reads from GeoJSON files in outputs/daily/ directory
"""
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query, Response
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, List, Literal
import asyncio
import json
import glob
import re
@@ -28,8 +28,12 @@ from utils.risk import risk_value_to_level
router = APIRouter(prefix="/api/risk", tags=["risk"])
# Cells with risk at/below this are culled from LOD responses; the frontend
# discards risk==0 cells anyway, so emitting them only bloats the payload.
LOD_RISK_EPSILON = 1e-6
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_risk_data(date: str) -> tuple[list[list], dict]:
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
@@ -55,7 +59,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
return grids, grid_map
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_kdtree_and_risks(date: str):
grids, _ = get_risk_data(date)
if not grids:
@@ -70,6 +74,8 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
bounds: dict | None = None) -> dict:
date = get_latest_date()
kdtree, risk_values = get_kdtree_and_risks(date)
if kdtree is None or risk_values is None:
return {"lod": "empty", "zoom": zoom, "aggregate": 1, "grids": [], "total_count": 0, "bounds": bounds or WUHAN_BOUNDS}
risk_idx = forecast_day - 1
@@ -117,7 +123,12 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks = risk_array[indices]
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius). The frontend discards
# them anyway, so culling here shrinks the payload substantially.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -143,8 +154,9 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
cell_lat = (WUHAN_BOUNDS["max_lat"] - WUHAN_BOUNDS["min_lat"]) / lat_count
cell_lon = (WUHAN_BOUNDS["max_lon"] - WUHAN_BOUNDS["min_lon"]) / lon_count
# Apply viewport bounds filtering for zoom >= 10
if bounds and zoom >= 10:
# Apply viewport bounds filtering at all zoom levels when bounds are given,
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
if bounds:
b_min_lat = max(bounds["min_lat"], WUHAN_BOUNDS["min_lat"])
b_max_lat = min(bounds["max_lat"], WUHAN_BOUNDS["max_lat"])
b_min_lon = max(bounds["min_lon"], WUHAN_BOUNDS["min_lon"])
@@ -176,7 +188,11 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius) before serialization.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -188,6 +204,21 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
}
@lru_cache(maxsize=8)
def _risk_map_body(date: str) -> str:
"""Serialize the full ~140k-grid risk map for a date once (cached).
The 140k-element response costs ~0.5s of Pydantic validation + JSON
serialization; caching the serialized body makes warm calls ~instant.
"""
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat(),
).model_dump_json()
@router.get("/map", response_model=RiskMapResponse)
async def get_risk_map(date: str | None = None):
if date is None:
@@ -197,13 +228,7 @@ async def get_risk_map(date: str | None = None):
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
grids = parse_geojson_file(filepath)
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/current", response_model=RiskMapResponse)
@@ -214,28 +239,7 @@ async def get_current_risk():
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
grids: list[dict[str, str | float]] = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
coords = feature.get("geometry", {}).get("coordinates", [0, 0])
risk_value = props.get("risk_1d", 0)
grids.append({
"grid_id": str(props.get("node_id", "")),
"latitude": props.get("lat", coords[1] if len(coords) > 1 else 0),
"longitude": props.get("lon", coords[0] if len(coords) > 0 else 0),
"risk_value": risk_value,
"risk_level": risk_value_to_level(risk_value),
})
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/precomputed", response_model=RiskMapResponse)
@@ -247,7 +251,7 @@ async def get_precomputed_risk():
grids = []
for _, row in df.iterrows():
risk_index = float(row.get('risk_index', 0))
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
grids.append({
"grid_id": str(row['grid_id']),
"latitude": float(row['center_y']),
@@ -263,6 +267,18 @@ async def get_precomputed_risk():
)
@lru_cache(maxsize=8)
def _fullgrid_body(date: str) -> str:
"""Serialize the compact full-grid payload once (cached)."""
grids, _ = get_risk_data(date)
return json.dumps({
"date": date,
"total_count": len(grids),
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
"grids": grids,
})
@router.get("/fullgrid")
async def get_full_grid(date: str | None = None):
if date is None:
@@ -272,26 +288,7 @@ async def get_full_grid(date: str | None = None):
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
grids = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
grids.append([
round(props.get("lat", 0), 6),
round(props.get("lon", 0), 6),
round(props.get("risk_1d", 0), 4),
round(props.get("risk_3d", 0), 4),
round(props.get("risk_7d", 0), 4),
])
return {
"date": date,
"total_count": len(grids),
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
"grids": grids,
}
return Response(content=_fullgrid_body(date), media_type="application/json")
@router.get("/lod-grid")
@@ -366,50 +363,44 @@ async def get_lod_tile(
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
async def get_risk_history(grid_id: str, days: int = 7):
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 7):
date = get_latest_date()
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
with open(filepath, 'r', encoding='utf-8') as f:
geojson = json.load(f)
# Use cached parsed grids + cached KDTree instead of re-reading the ~44MB file.
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
target_feature = None
for feature in geojson.get("features", []):
props = feature.get("properties", {})
if str(props.get("node_id", "")) == grid_id:
target_feature = feature
base_risk = None
# Exact node_id match
for g in grids:
if g["grid_id"] == grid_id:
base_risk = g["risk_value"]
break
if not target_feature and re.match(r'r\d+_c\d+', grid_id):
# Fallback: nearest grid for r{row}_c{col} ids
if base_risk is None and re.match(r'r\d+_c\d+', grid_id):
parts = grid_id.replace("r", "").split("_c")
row, col = int(parts[0]), int(parts[1])
center_lat = WUHAN_BOUNDS["min_lat"] + (row + 0.5) * LAT_STEP
center_lon = WUHAN_BOUNDS["min_lon"] + (col + 0.5) * LON_STEP
points = []
features_list = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
points.append([props.get("lat", 0), props.get("lon", 0)])
features_list.append(feature)
if points:
tree = KDTree(points)
_, idx = tree.query([center_lat, center_lon])
target_feature = features_list[idx]
kdtree, risk_values = get_kdtree_and_risks(date)
if kdtree is not None and risk_values is not None:
_, idx = kdtree.query([center_lat, center_lon])
base_risk = risk_values[idx][0] # risk_1d
if not target_feature:
if base_risk is None:
raise HTTPException(status_code=404, detail=f"Grid {grid_id} not found")
props = target_feature.get("properties", {})
base_risk = props.get("risk_1d", 0)
history = []
for i in range(days):
history.append({
"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
"risk_value": base_risk * (1 - i * 0.05)
"risk_value": max(0.0, base_risk * (1 - i * 0.05))
})
return RiskHistoryResponse(
@@ -432,7 +423,7 @@ async def get_forecast_map(
if not filepath.exists():
# Fall back to current data
return await get_current_risk_map()
return await get_current_risk()
grids = parse_geojson_file(filepath)
if not grids:
@@ -495,3 +486,71 @@ async def get_stats(date: str | None = None):
high_risk_count=distribution["high"],
timestamp=datetime.now().isoformat()
)
# ---------------------------------------------------------------------------
# Raster LOD tiles — full-Wuhan 100m risk grid served as XYZ map tiles.
# The browser loads PNG images (cached by Leaflet); no per-cell JS work.
# See utils/risk_raster.py for the rendering engine.
# ---------------------------------------------------------------------------
from utils import risk_raster # noqa: E402 (kept local to this feature block)
_VALID_DAYS = {1, 3, 7}
@router.get("/tiles/{z}/{x}/{y}.png")
async def get_risk_tile(
z: Annotated[int, Path(ge=0, le=22)],
x: Annotated[int, Path(ge=0)],
y: Annotated[int, Path(ge=0)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Render one web-mercator risk tile (256x256 PNG) for the 100m grid."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
png = await asyncio.to_thread(risk_raster.render_tile, z, x, y, date, day)
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/grid-stats")
async def get_risk_grid_stats(
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Aggregate stats over the in-boundary 100m grid (cell count / avg / max / high)."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.grid_stats, date, day)
@router.get("/cell")
async def get_risk_cell(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Risk at the 100m cell containing (lat, lon) — used for click-to-inspect."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.query_cell, lat, lon, date, day)