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
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
"""
|
||
Router for CBPOA alert management endpoints
|
||
Generates alerts from high-risk grids in GeoJSON files
|
||
"""
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from datetime import datetime
|
||
from typing import List
|
||
from functools import lru_cache
|
||
import asyncio
|
||
import json
|
||
|
||
from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STEP, LON_STEP, MAX_ALERTS
|
||
from models import Alert, AlertResponse
|
||
from utils.date_helpers import get_latest_date, validate_date_format
|
||
from utils.risk import risk_value_to_level
|
||
|
||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||
|
||
|
||
def lat_lon_to_grid_id(lat: float, lon: float) -> str:
|
||
"""Convert lat/lon to 100m grid cell ID in r{row}_c{col} format."""
|
||
row = int((lat - WUHAN_BOUNDS["min_lat"]) / LAT_STEP)
|
||
col = int((lon - WUHAN_BOUNDS["min_lon"]) / LON_STEP)
|
||
return f"r{row}_c{col}"
|
||
|
||
|
||
def grid_id_to_center(grid_id: str) -> tuple[float, float]:
|
||
"""Convert r{row}_c{col} grid ID back to center lat/lon."""
|
||
parts = grid_id.split("_")
|
||
row = int(parts[0][1:])
|
||
col = int(parts[1][1:])
|
||
lat = WUHAN_BOUNDS["min_lat"] + (row + 0.5) * LAT_STEP
|
||
lon = WUHAN_BOUNDS["min_lon"] + (col + 0.5) * LON_STEP
|
||
return lat, lon
|
||
|
||
|
||
@lru_cache(maxsize=8)
|
||
def _generate_alerts_cached(date: str) -> List[Alert]:
|
||
"""Heavy synchronous worker: parse the ~45MB GeoJSON and build alerts.
|
||
|
||
Cached by date so the file is parsed once per date. This runs blocking
|
||
json.load + per-feature loops, so callers must invoke it off the event
|
||
loop (see generate_alerts_for_date).
|
||
|
||
Phase 1: iterate features, aggregate max risk per 100m grid cell.
|
||
Phase 2: build Alert objects from aggregated grid cells.
|
||
Phase 3: sort by (priority, -risk_value), cap at MAX_ALERTS.
|
||
"""
|
||
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)
|
||
|
||
# Phase 1: aggregate by 100m grid cell, taking max risk per cell
|
||
grid_cells: dict[str, dict] = {}
|
||
for feature in geojson.get("features", []):
|
||
props = feature.get("properties", {})
|
||
risk_1d = props.get("risk_1d", 0)
|
||
risk_3d = props.get("risk_3d", 0)
|
||
risk_7d = props.get("risk_7d", 0)
|
||
|
||
if risk_1d < ALERT_P2_RISK and risk_3d < ALERT_P2_RISK:
|
||
continue
|
||
|
||
lat = props.get("lat", 0)
|
||
lon = props.get("lon", 0)
|
||
grid_id = lat_lon_to_grid_id(lat, lon)
|
||
max_risk = max(risk_1d, risk_3d, risk_7d)
|
||
|
||
existing = grid_cells.get(grid_id)
|
||
if existing is None or max_risk > existing["max_risk"]:
|
||
grid_cells[grid_id] = {
|
||
"risk_1d": risk_1d,
|
||
"risk_3d": risk_3d,
|
||
"risk_7d": risk_7d,
|
||
"max_risk": max_risk,
|
||
}
|
||
|
||
# Phase 2: build Alert objects from aggregated grid cells
|
||
alerts = []
|
||
for grid_id, data in grid_cells.items():
|
||
risk_1d = data["risk_1d"]
|
||
risk_3d = data["risk_3d"]
|
||
risk_7d = data["risk_7d"]
|
||
max_risk = data["max_risk"]
|
||
|
||
if risk_1d >= ALERT_P1_RISK or risk_3d >= ALERT_P1_RISK:
|
||
priority = "P1"
|
||
reason = f"高风险区域:1天风险 {risk_1d:.2f}, 3天风险 {risk_3d:.2f}"
|
||
else:
|
||
priority = "P2"
|
||
reason = f"中高风险区域:1天风险 {risk_1d:.2f}, 3天风险 {risk_3d:.2f}"
|
||
|
||
lat, lon = grid_id_to_center(grid_id)
|
||
risk_level = risk_value_to_level(max_risk)
|
||
|
||
alerts.append(
|
||
Alert(
|
||
alert_id=f"alert_{date}_{grid_id}",
|
||
grid_id=grid_id,
|
||
region="武汉市",
|
||
street=f"Grid {grid_id}",
|
||
latitude=lat,
|
||
longitude=lon,
|
||
risk_value=max_risk,
|
||
risk_level=risk_level,
|
||
priority=priority,
|
||
reason=reason,
|
||
timestamp=datetime.now().isoformat(),
|
||
forecast_time=f"{date}T00:00:00"
|
||
)
|
||
)
|
||
|
||
# Phase 3: sort by priority then descending risk, cap at MAX_ALERTS
|
||
alerts.sort(key=lambda x: (0 if x.priority == "P1" else 1, -x.risk_value))
|
||
return alerts[:MAX_ALERTS]
|
||
|
||
|
||
async def generate_alerts_for_date(date: str) -> List[Alert]:
|
||
"""Async accessor: run the cached heavy parser in a thread pool.
|
||
|
||
Offloading the blocking json.load + per-feature aggregation keeps the
|
||
event loop free. The lru_cache lives on the worker, so warm dates return
|
||
near-instantly without re-parsing.
|
||
"""
|
||
return await asyncio.to_thread(_generate_alerts_cached, date)
|
||
|
||
|
||
@router.get("", response_model=AlertResponse)
|
||
async def list_alerts(date: str | None = None, priority: str | None = None, min_risk: float | None = None):
|
||
if date is not None and not validate_date_format(date):
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD")
|
||
if date is None:
|
||
date = get_latest_date()
|
||
|
||
alerts = await generate_alerts_for_date(date)
|
||
|
||
if priority:
|
||
alerts = [a for a in alerts if a.priority == priority]
|
||
|
||
if min_risk is not None:
|
||
alerts = [a for a in alerts if a.risk_value >= min_risk]
|
||
|
||
return AlertResponse(
|
||
alerts=alerts,
|
||
total=len(alerts),
|
||
timestamp=datetime.now().isoformat()
|
||
)
|
||
|
||
|
||
@router.get("/{alert_id}", response_model=Alert)
|
||
async def get_alert(alert_id: str, date: str | None = None):
|
||
if date is not None and not validate_date_format(date):
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD")
|
||
if date is None:
|
||
date = get_latest_date()
|
||
|
||
alerts = await generate_alerts_for_date(date)
|
||
|
||
for alert in alerts:
|
||
if alert.alert_id == alert_id:
|
||
return alert
|
||
|
||
raise HTTPException(status_code=404, detail=f"Alert {alert_id} not found")
|
||
|
||
|
||
@router.get("/priority/p1", response_model=AlertResponse)
|
||
async def get_p1_alerts(date: str | None = None):
|
||
if date is not None and not validate_date_format(date):
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD")
|
||
if date is None:
|
||
date = get_latest_date()
|
||
|
||
alerts = await generate_alerts_for_date(date)
|
||
p1_alerts = [a for a in alerts if a.priority == "P1"]
|
||
|
||
return AlertResponse(
|
||
alerts=p1_alerts,
|
||
total=len(p1_alerts),
|
||
timestamp=datetime.now().isoformat()
|
||
)
|
||
|
||
|
||
@router.get("/priority/p2", response_model=AlertResponse)
|
||
async def get_p2_alerts(date: str | None = None):
|
||
if date is not None and not validate_date_format(date):
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD")
|
||
if date is None:
|
||
date = get_latest_date()
|
||
|
||
alerts = await generate_alerts_for_date(date)
|
||
p2_alerts = [a for a in alerts if a.priority == "P2"]
|
||
|
||
return AlertResponse(
|
||
alerts=p2_alerts,
|
||
total=len(p2_alerts),
|
||
timestamp=datetime.now().isoformat()
|
||
)
|
||
|
||
|
||
@router.get("/grid/{grid_id}", response_model=AlertResponse)
|
||
async def get_grid_alerts(grid_id: str, date: str | None = None):
|
||
if date is not None and not validate_date_format(date):
|
||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD")
|
||
if date is None:
|
||
date = get_latest_date()
|
||
|
||
alerts = await generate_alerts_for_date(date)
|
||
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
|
||
|
||
return AlertResponse(
|
||
alerts=grid_alerts,
|
||
total=len(grid_alerts),
|
||
timestamp=datetime.now().isoformat()
|
||
)
|