Context: Build a spatial risk assessment system correlating air quality data with children's respiratory disease incidence across Wuhan. Approach: FastAPI backend serving PostGIS spatial queries, React frontend with Deck.gl maps, and a PyTorch SpatialTemporalGCN pipeline for multi-day (1d/3d/7d) risk prediction. Changes: - backend/ — FastAPI API with auth (JWT), alerts, risk analysis, geocoded case data, grid statistics, and report endpoints - frontend/ — React dashboard with interactive risk maps, alert monitoring, district comparison charts, and timeline player - models/ — SpatialTemporalGCN model with trained weights and ONNX export for inference - scripts/ — ETL pipeline for weather + medical data, grid generation, feature engineering, training, and daily inference - deploy/ — Docker Compose configs for backend, frontend, and MLflow - docs/ — API docs, deployment guide, user guide, and code review Impact: Enables spatial risk visualization, alert monitoring, and ML-driven health risk forecasting for environmental health teams.
201 lines
6.8 KiB
Python
201 lines
6.8 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
|
||
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
|
||
|
||
|
||
def generate_alerts_for_date(date: str) -> List[Alert]:
|
||
"""Generate alerts for high-risk grids on a specific 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]
|
||
|
||
|
||
@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 = 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 = 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 = 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 = 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 = 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()
|
||
)
|