""" 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() )