Files
CA/backend/routers/alerts.py
Akiba So e95e2f1338 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
2026-06-21 17:35:03 +08:00

218 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()
)