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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user