feat: Initial CBPOA commit — 武汉儿童呼吸疾病风险评估系统
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.
This commit is contained in:
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
200
backend/routers/alerts.py
Normal file
200
backend/routers/alerts.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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()
|
||||
)
|
||||
273
backend/routers/analysis.py
Normal file
273
backend/routers/analysis.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Router for CBPOA analysis endpoints
|
||||
Time series trends, district aggregation, and weather-health correlations
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Literal
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config import DATA_DIR, RISK_HIGH
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
from utils.geo import point_in_polygon
|
||||
from utils.risk import calculate_trend
|
||||
|
||||
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
||||
|
||||
|
||||
class TrendResponse(BaseModel):
|
||||
"""Response for trend data"""
|
||||
dates: List[str] = Field(..., description="Date labels")
|
||||
values: List[float] = Field(..., description="Risk values")
|
||||
trend: Literal["up", "down", "stable"] = Field(..., description="Trend direction")
|
||||
|
||||
|
||||
class DistrictRisk(BaseModel):
|
||||
"""District-level risk aggregation"""
|
||||
name: str = Field(..., description="District name")
|
||||
avg_risk: float = Field(..., description="Average risk value")
|
||||
high_risk_count: int = Field(..., description="Count of high risk grids")
|
||||
total_grids: int = Field(..., description="Total grids in district")
|
||||
total_cases: int = Field(..., description="Estimated total cases")
|
||||
|
||||
|
||||
class DistrictsResponse(BaseModel):
|
||||
"""Response for districts aggregation"""
|
||||
districts: List[DistrictRisk] = Field(..., description="District risk data")
|
||||
timestamp: str = Field(..., description="Response timestamp")
|
||||
|
||||
|
||||
class CorrelationFactor(BaseModel):
|
||||
"""Correlation factor data"""
|
||||
factor: str = Field(..., description="Factor name")
|
||||
correlation: float = Field(..., description="Correlation coefficient (-1 to 1)")
|
||||
significance: Literal["high", "medium", "low"] = Field(..., description="Statistical significance")
|
||||
description: str = Field(..., description="Factor description")
|
||||
|
||||
|
||||
class CorrelationsResponse(BaseModel):
|
||||
"""Response for correlations"""
|
||||
correlations: List[CorrelationFactor] = Field(..., description="Correlation factors")
|
||||
timestamp: str = Field(..., description="Response timestamp")
|
||||
|
||||
|
||||
@router.get("/trend", response_model=TrendResponse)
|
||||
async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
"""
|
||||
Get time series trend data from ACTUAL historical observations
|
||||
|
||||
Args:
|
||||
days: Number of days for trend (1-30)
|
||||
|
||||
Returns:
|
||||
Trend data with dates, values, and trend direction
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
|
||||
try:
|
||||
base_date = datetime.strptime(latest_date, "%Y%m%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=500, detail="Invalid date format in data files")
|
||||
|
||||
dates = []
|
||||
values = []
|
||||
|
||||
for i in range(days):
|
||||
date = base_date - timedelta(days=days - 1 - i)
|
||||
date_str = date.strftime("%Y%m%d")
|
||||
filepath = DATA_DIR / f"risk_{date_str}.geojson"
|
||||
|
||||
if filepath.exists():
|
||||
grids = parse_geojson_file(filepath)
|
||||
if grids:
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids)
|
||||
values.append(round(avg_risk, 4))
|
||||
else:
|
||||
values.append(0)
|
||||
else:
|
||||
values.append(0)
|
||||
dates.append(date.strftime("%Y-%m-%d"))
|
||||
|
||||
# Filter out zero values
|
||||
valid_data = [(d, v) for d, v in zip(dates, values) if v > 0]
|
||||
if valid_data:
|
||||
dates, values = zip(*valid_data)
|
||||
dates, values = list(dates), list(values)
|
||||
|
||||
trend_direction = calculate_trend(values)
|
||||
|
||||
return TrendResponse(
|
||||
dates=dates,
|
||||
values=values,
|
||||
trend=trend_direction,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/districts", response_model=DistrictsResponse)
|
||||
async def get_districts():
|
||||
"""
|
||||
Get district-level risk aggregation
|
||||
|
||||
Returns:
|
||||
District-level risk data with averages and counts
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
districts = load_districts()
|
||||
|
||||
if not districts:
|
||||
# Fallback: return city-wide aggregation
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) if grids else 0
|
||||
high_risk_count = sum(1 for g in grids if g["risk_1d"] >= RISK_HIGH)
|
||||
|
||||
return DistrictsResponse(
|
||||
districts=[
|
||||
DistrictRisk(
|
||||
name="武汉市",
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=high_risk_count,
|
||||
total_grids=len(grids),
|
||||
total_cases=int(len(grids) * avg_risk * 0.1) # Mock case rate
|
||||
)
|
||||
],
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
# Aggregate grids by district using point-in-polygon
|
||||
district_data = {d["name"]: {"grids": [], "high_risk": 0} for d in districts}
|
||||
unassigned = {"grids": [], "high_risk": 0}
|
||||
|
||||
for grid in grids:
|
||||
assigned = False
|
||||
for district in districts:
|
||||
if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]):
|
||||
district_data[district["name"]]["grids"].append(grid)
|
||||
if grid["risk_1d"] >= RISK_HIGH:
|
||||
district_data[district["name"]]["high_risk"] += 1
|
||||
assigned = True
|
||||
break
|
||||
|
||||
if not assigned:
|
||||
unassigned["grids"].append(grid)
|
||||
if grid["risk_1d"] >= RISK_HIGH:
|
||||
unassigned["high_risk"] += 1
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
for district in districts:
|
||||
name = district["name"]
|
||||
grids_in_district = district_data[name]["grids"]
|
||||
|
||||
if not grids_in_district:
|
||||
continue
|
||||
|
||||
avg_risk = sum(g["risk_1d"] for g in grids_in_district) / len(grids_in_district)
|
||||
high_risk_count = district_data[name]["high_risk"]
|
||||
|
||||
# Mock total cases based on risk and grid count
|
||||
total_cases = int(len(grids_in_district) * avg_risk * 0.1)
|
||||
|
||||
result.append(
|
||||
DistrictRisk(
|
||||
name=name,
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=high_risk_count,
|
||||
total_grids=len(grids_in_district),
|
||||
total_cases=total_cases
|
||||
)
|
||||
)
|
||||
|
||||
# Add unassigned as "其他" if significant
|
||||
if unassigned["grids"]:
|
||||
avg_risk = sum(g["risk_1d"] for g in unassigned["grids"]) / len(unassigned["grids"])
|
||||
result.append(
|
||||
DistrictRisk(
|
||||
name="其他",
|
||||
avg_risk=round(avg_risk, 4),
|
||||
high_risk_count=unassigned["high_risk"],
|
||||
total_grids=len(unassigned["grids"]),
|
||||
total_cases=int(len(unassigned["grids"]) * avg_risk * 0.1)
|
||||
)
|
||||
)
|
||||
|
||||
return DistrictsResponse(
|
||||
districts=result,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/correlations", response_model=CorrelationsResponse)
|
||||
async def get_correlations():
|
||||
"""
|
||||
Get weather-health correlation analysis
|
||||
|
||||
Returns:
|
||||
Correlation factors with coefficients and significance
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
# Calculate mock correlations based on risk patterns
|
||||
# In production, this would use actual weather and health data
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids)
|
||||
risk_variance = sum((g["risk_1d"] - avg_risk) ** 2 for g in grids) / len(grids)
|
||||
|
||||
# Generate realistic correlation coefficients
|
||||
correlations = [
|
||||
CorrelationFactor(
|
||||
factor="temperature",
|
||||
correlation=round(-0.45 - 0.1 * (avg_risk - 0.5), 3),
|
||||
significance="high" if risk_variance > 0.05 else "medium",
|
||||
description="Temperature vs risk: Lower temps correlate with higher risk"
|
||||
),
|
||||
CorrelationFactor(
|
||||
factor="humidity",
|
||||
correlation=round(0.32 + 0.15 * (avg_risk - 0.5), 3),
|
||||
significance="medium",
|
||||
description="Humidity vs risk: Higher humidity slightly increases risk"
|
||||
),
|
||||
CorrelationFactor(
|
||||
factor="PM2.5",
|
||||
correlation=round(0.58 + 0.1 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="PM2.5 vs risk: Strong positive correlation"
|
||||
),
|
||||
CorrelationFactor(
|
||||
factor="PM10",
|
||||
correlation=round(0.51 + 0.08 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="PM10 vs risk: Moderate positive correlation"
|
||||
),
|
||||
CorrelationFactor(
|
||||
factor="wind_speed",
|
||||
correlation=round(-0.28 - 0.05 * (avg_risk - 0.5), 3),
|
||||
significance="low",
|
||||
description="Wind speed vs risk: Higher wind disperses pollutants"
|
||||
),
|
||||
CorrelationFactor(
|
||||
factor="population_density",
|
||||
correlation=round(0.42 + 0.12 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="Population density vs risk: Dense areas show higher transmission"
|
||||
),
|
||||
]
|
||||
|
||||
return CorrelationsResponse(
|
||||
correlations=correlations,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
370
backend/routers/cases.py
Normal file
370
backend/routers/cases.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
医疗病例数据 API 路由
|
||||
|
||||
提供门诊和住院数据的统计、趋势、区域分布等接口
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
import pandas as pd
|
||||
import re
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
||||
|
||||
# 数据缓存
|
||||
_cache = {
|
||||
"outpatient": None,
|
||||
"inpatient": None,
|
||||
"loaded_at": None,
|
||||
}
|
||||
|
||||
# 武汉市区映射
|
||||
WUHAN_DISTRICTS = {
|
||||
'江岸区': ['江岸'],
|
||||
'江汉区': ['江汉'],
|
||||
'武昌区': ['武昌'],
|
||||
'洪山区': ['洪山'],
|
||||
'汉阳区': ['汉阳'],
|
||||
'东西湖区': ['东西湖'],
|
||||
'黄陂区': ['黄陂'],
|
||||
'硚口区': ['硚口'],
|
||||
'江夏区': ['江夏'],
|
||||
'青山区': ['青山'],
|
||||
'新洲区': ['新洲'],
|
||||
'蔡甸区': ['蔡甸'],
|
||||
'东湖新技术开发区': ['东湖新技术开发区', '光谷'],
|
||||
'经开(汉南)区': ['经开', '汉南', '经济开发区'],
|
||||
'东湖生态旅游风景区': ['东湖生态旅游风景区']
|
||||
}
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "Datas"
|
||||
|
||||
|
||||
def _extract_district(addr: str) -> str:
|
||||
"""从地址提取武汉市区名"""
|
||||
if pd.isna(addr):
|
||||
return '未知'
|
||||
addr = str(addr)
|
||||
for district, keywords in WUHAN_DISTRICTS.items():
|
||||
for kw in keywords:
|
||||
if kw in addr:
|
||||
return district
|
||||
return '其他'
|
||||
|
||||
|
||||
def _load_data():
|
||||
"""加载并缓存数据"""
|
||||
if _cache["loaded_at"] is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
# 加载门诊数据
|
||||
df_out = pd.read_excel(DATA_DIR / "view_门诊.xlsx")
|
||||
df_out['date'] = pd.to_datetime(df_out['门诊日期_re'])
|
||||
df_out['district'] = df_out['现住址区'].fillna('未知')
|
||||
_cache["outpatient"] = df_out
|
||||
|
||||
# 加载住院数据
|
||||
df_in = pd.read_excel(DATA_DIR / "view_住院.xlsx")
|
||||
df_in['date'] = pd.to_datetime(df_in['入院日期_re'])
|
||||
df_in['district'] = df_in['现住址_脱敏'].apply(_extract_district)
|
||||
_cache["inpatient"] = df_in
|
||||
|
||||
_cache["loaded_at"] = datetime.now()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"数据加载失败:{str(e)}")
|
||||
|
||||
|
||||
def _get_combined_data():
|
||||
"""获取合并的病例数据"""
|
||||
_load_data()
|
||||
|
||||
df_out = _cache["outpatient"][['date', 'district', '初诊', '主诉']].copy()
|
||||
df_out['type'] = 'outpatient'
|
||||
df_out['diagnosis'] = df_out['初诊']
|
||||
|
||||
df_in = _cache["inpatient"][['date', 'district', '诊断名称']].copy()
|
||||
df_in['type'] = 'inpatient'
|
||||
df_in['diagnosis'] = df_in['诊断名称']
|
||||
df_in['主诉'] = None
|
||||
|
||||
return pd.concat([df_out, df_in], ignore_index=True)
|
||||
|
||||
|
||||
# ============== Response Models ==============
|
||||
|
||||
class StatsResponse(BaseModel):
|
||||
"""统计数据响应"""
|
||||
total_outpatient: int
|
||||
total_inpatient: int
|
||||
date_range: dict
|
||||
top_districts: list
|
||||
top_diagnoses: list
|
||||
|
||||
|
||||
class TrendPoint(BaseModel):
|
||||
"""趋势数据点"""
|
||||
date: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class TrendResponse(BaseModel):
|
||||
"""趋势数据响应"""
|
||||
trend: list[TrendPoint]
|
||||
summary: dict
|
||||
|
||||
|
||||
class DistrictData(BaseModel):
|
||||
"""区域数据"""
|
||||
district: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
outpatient_ratio: float
|
||||
inpatient_ratio: float
|
||||
|
||||
|
||||
class DistrictsResponse(BaseModel):
|
||||
"""区域分布响应"""
|
||||
districts: list[DistrictData]
|
||||
total: int
|
||||
|
||||
|
||||
class RealtimeData(BaseModel):
|
||||
"""实时数据"""
|
||||
today_outpatient: int
|
||||
today_inpatient: int
|
||||
today_total: int
|
||||
last_7d_avg: int
|
||||
change_ratio: float
|
||||
status: str
|
||||
|
||||
|
||||
# ============== API Endpoints ==============
|
||||
|
||||
@router.get("/stats", response_model=StatsResponse, summary="获取病例统计数据")
|
||||
async def get_cases_stats():
|
||||
"""
|
||||
获取病例总体统计信息
|
||||
|
||||
- 总门诊量、总住院量
|
||||
- 数据日期范围
|
||||
- 就诊量前 10 的区域
|
||||
- 最常见诊断前 10
|
||||
"""
|
||||
_load_data()
|
||||
|
||||
df_out = _cache["outpatient"]
|
||||
df_in = _cache["inpatient"]
|
||||
|
||||
# 计算统计
|
||||
total_outpatient = len(df_out)
|
||||
total_inpatient = len(df_in)
|
||||
|
||||
# 日期范围
|
||||
min_date = min(df_out['date'].min(), df_in['date'].min())
|
||||
max_date = max(df_out['date'].max(), df_in['date'].max())
|
||||
|
||||
# 区域统计
|
||||
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
|
||||
in_districts = df_in[df_in['district'] != '其他']['district'].value_counts().head(10)
|
||||
|
||||
combined_districts = pd.concat([out_districts, in_districts]).groupby(level=0).sum().nlargest(10)
|
||||
top_districts = [{"district": d, "count": int(c)} for d, c in combined_districts.items()]
|
||||
|
||||
# 诊断统计
|
||||
out_diagnoses = df_out['初诊'].value_counts().head(10)
|
||||
in_diagnoses = df_in['诊断名称'].value_counts().head(10)
|
||||
|
||||
top_diagnoses = [
|
||||
{"diagnosis": str(d), "outpatient": int(out_diagnoses.get(d, 0)), "inpatient": int(in_diagnoses.get(d, 0))}
|
||||
for d in set(list(out_diagnoses.index[:5]) + list(in_diagnoses.index[:5]))
|
||||
][:10]
|
||||
|
||||
return StatsResponse(
|
||||
total_outpatient=total_outpatient,
|
||||
total_inpatient=total_inpatient,
|
||||
date_range={
|
||||
"start": min_date.strftime("%Y-%m-%d"),
|
||||
"end": max_date.strftime("%Y-%m-%d")
|
||||
},
|
||||
top_districts=top_districts,
|
||||
top_diagnoses=top_diagnoses
|
||||
)
|
||||
|
||||
|
||||
@router.get("/trend", response_model=TrendResponse, summary="获取病例趋势数据")
|
||||
async def get_cases_trend(
|
||||
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
|
||||
group_by: str = Query("day", description="分组粒度:day, week, month"),
|
||||
):
|
||||
"""
|
||||
获取病例时间趋势数据
|
||||
|
||||
- 支持按日、周、月分组
|
||||
- 可指定日期范围
|
||||
- 返回门诊、住院、总计趋势
|
||||
"""
|
||||
if start_date and not DATE_PATTERN.match(start_date):
|
||||
raise HTTPException(status_code=400, detail="Invalid start_date format. Use YYYY-MM-DD")
|
||||
if end_date and not DATE_PATTERN.match(end_date):
|
||||
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
|
||||
|
||||
df = _get_combined_data()
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
df = df[df['date'] >= pd.to_datetime(start_date)]
|
||||
if end_date:
|
||||
df = df[df['date'] <= pd.to_datetime(end_date)]
|
||||
|
||||
# 分组
|
||||
if group_by == "week":
|
||||
df['period'] = df['date'].dt.to_period('W').dt.start_time
|
||||
elif group_by == "month":
|
||||
df['period'] = df['date'].dt.to_period('M').dt.start_time
|
||||
else:
|
||||
df['period'] = df['date'].dt.date
|
||||
|
||||
# 聚合
|
||||
out_trend = df[df['type'] == 'outpatient'].groupby('period').size()
|
||||
in_trend = df[df['type'] == 'inpatient'].groupby('period').size()
|
||||
|
||||
periods = sorted(set(out_trend.index.tolist() + in_trend.index.tolist()))
|
||||
|
||||
trend = []
|
||||
total_out = total_in = 0
|
||||
for p in periods:
|
||||
out_count = int(out_trend.get(p, 0))
|
||||
in_count = int(in_trend.get(p, 0))
|
||||
total_out += out_count
|
||||
total_in += in_count
|
||||
trend.append(TrendPoint(
|
||||
date=pd.Timestamp(p).strftime("%Y-%m-%d"),
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count
|
||||
))
|
||||
|
||||
return TrendResponse(
|
||||
trend=trend,
|
||||
summary={
|
||||
"total_outpatient": total_out,
|
||||
"total_inpatient": total_in,
|
||||
"period_count": len(periods),
|
||||
"avg_daily_outpatient": round(total_out / max(len(periods), 1), 2),
|
||||
"avg_daily_inpatient": round(total_in / max(len(periods), 1), 2),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/districts", response_model=DistrictsResponse, summary="获取区域分布数据")
|
||||
async def get_cases_districts(
|
||||
case_type: Optional[str] = Query(None, description="病例类型:outpatient, inpatient, all"),
|
||||
min_count: int = Query(10, description="最小病例数过滤"),
|
||||
):
|
||||
"""
|
||||
获取病例区域分布数据
|
||||
|
||||
- 支持按病例类型筛选
|
||||
- 可设置最小病例数过滤
|
||||
- 返回各区门诊、住院量及占比
|
||||
"""
|
||||
df = _get_combined_data()
|
||||
|
||||
# 类型过滤
|
||||
if case_type == "outpatient":
|
||||
df = df[df['type'] == 'outpatient']
|
||||
elif case_type == "inpatient":
|
||||
df = df[df['type'] == 'inpatient']
|
||||
|
||||
# 过滤未知区域
|
||||
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
||||
|
||||
# 聚合
|
||||
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
||||
|
||||
if 'outpatient' not in district_stats.columns:
|
||||
district_stats['outpatient'] = 0
|
||||
if 'inpatient' not in district_stats.columns:
|
||||
district_stats['inpatient'] = 0
|
||||
|
||||
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
||||
|
||||
# 过滤
|
||||
district_stats = district_stats[district_stats['total'] >= min_count]
|
||||
district_stats = district_stats.sort_values('total', ascending=False)
|
||||
|
||||
total = int(district_stats['total'].sum())
|
||||
|
||||
districts = []
|
||||
for district, row in district_stats.iterrows():
|
||||
districts.append(DistrictData(
|
||||
district=district,
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
outpatient_ratio=round(row['outpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0,
|
||||
inpatient_ratio=round(row['inpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0
|
||||
))
|
||||
|
||||
return DistrictsResponse(districts=districts, total=total)
|
||||
|
||||
|
||||
@router.get("/realtime", response_model=RealtimeData, summary="获取实时数据")
|
||||
async def get_cases_realtime():
|
||||
"""
|
||||
获取实时病例数据
|
||||
|
||||
- 今日就诊量
|
||||
- 近 7 日平均值
|
||||
- 变化率
|
||||
- 状态评估 (正常/偏高/偏低)
|
||||
"""
|
||||
df = _get_combined_data()
|
||||
|
||||
today = pd.Timestamp.today().normalize()
|
||||
last_7d = today - pd.Timedelta(days=7)
|
||||
|
||||
# 今日数据
|
||||
today_data = df[df['date'] >= today]
|
||||
today_total = len(today_data)
|
||||
today_out = len(today_data[today_data['type'] == 'outpatient'])
|
||||
today_in = len(today_data[today_data['type'] == 'inpatient'])
|
||||
|
||||
# 近 7 日平均
|
||||
last_7d_data = df[(df['date'] >= last_7d) & (df['date'] < today)]
|
||||
last_7d_avg = round(len(last_7d_data) / 7, 2) if len(last_7d_data) > 0 else 0
|
||||
|
||||
# 变化率
|
||||
if last_7d_avg > 0:
|
||||
change_ratio = round((today_total - last_7d_avg) / last_7d_avg * 100, 2)
|
||||
else:
|
||||
change_ratio = 0.0
|
||||
|
||||
# 状态评估
|
||||
if change_ratio > 20:
|
||||
status = "偏高"
|
||||
elif change_ratio < -20:
|
||||
status = "偏低"
|
||||
else:
|
||||
status = "正常"
|
||||
|
||||
return RealtimeData(
|
||||
today_outpatient=today_out,
|
||||
today_inpatient=today_in,
|
||||
today_total=today_total,
|
||||
last_7d_avg=last_7d_avg,
|
||||
change_ratio=change_ratio,
|
||||
status=status
|
||||
)
|
||||
172
backend/routers/geocoded.py
Normal file
172
backend/routers/geocoded.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Router for geocoded case data and grid aggregated data
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("cbpoa.geocoded")
|
||||
|
||||
router = APIRouter(prefix="/api/geocoded", tags=["geocoded"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs"
|
||||
|
||||
class GridCaseData(BaseModel):
|
||||
"""Grid case data for visualization"""
|
||||
grid_id: int
|
||||
latitude: float
|
||||
longitude: float
|
||||
total_cases: int
|
||||
outpatient_cases: int
|
||||
inpatient_cases: int
|
||||
case_density: float
|
||||
risk_index: float
|
||||
risk_level: str
|
||||
|
||||
class GridCaseResponse(BaseModel):
|
||||
grids: List[GridCaseData]
|
||||
total_count: int
|
||||
total_cases: int
|
||||
|
||||
class GeocodedCaseData(BaseModel):
|
||||
"""Individual geocoded case"""
|
||||
case_id: str
|
||||
case_type: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
district: str
|
||||
street: Optional[str]
|
||||
geocode_method: str
|
||||
confidence: float
|
||||
|
||||
class GeocodedResponse(BaseModel):
|
||||
cases: List[GeocodedCaseData]
|
||||
total_count: int
|
||||
|
||||
@router.get("/grid", response_model=GridCaseResponse, summary="Get aggregated grid case data")
|
||||
async def get_grid_cases():
|
||||
"""
|
||||
Get 100x100m grid aggregated case data for high-resolution visualization.
|
||||
|
||||
Returns grid cells with case counts, density, and risk indices.
|
||||
"""
|
||||
grid_file = DATA_DIR / "grid_risk_summary.csv"
|
||||
|
||||
if not grid_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Grid data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(grid_file)
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
grids.append(GridCaseData(
|
||||
grid_id=int(row['grid_id']),
|
||||
latitude=float(row['center_y']),
|
||||
longitude=float(row['center_x']),
|
||||
total_cases=int(row['total_cases']),
|
||||
outpatient_cases=int(row['outpatient_cases']),
|
||||
inpatient_cases=int(row['inpatient_cases']),
|
||||
case_density=float(row['cases_per_km2']),
|
||||
risk_index=float(row['risk_index']),
|
||||
risk_level=str(row['risk_level'])
|
||||
))
|
||||
|
||||
total_cases = int(df['total_cases'].sum())
|
||||
|
||||
return GridCaseResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
total_cases=total_cases
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error loading grid case data")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@router.get("/geocoded", response_model=GeocodedResponse, summary="Get geocoded case data")
|
||||
async def get_geocoded_cases(
|
||||
limit: int = 1000,
|
||||
district: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Get individual geocoded case data.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of cases to return (for performance)
|
||||
district: Filter by district name
|
||||
"""
|
||||
cases_file = DATA_DIR / "geocoded_all_cases.csv"
|
||||
|
||||
if not cases_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Geocoded data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(cases_file)
|
||||
|
||||
# Drop rows with missing coordinates
|
||||
df = df.dropna(subset=['latitude', 'longitude'])
|
||||
|
||||
# Fix swapped lat/lon (Wuhan: lat ~29.9-31.4, lon ~113.7-115.1)
|
||||
swapped = df['latitude'] > 50 # longitude values are >113
|
||||
df.loc[swapped, ['latitude', 'longitude']] = df.loc[swapped, ['longitude', 'latitude']].values
|
||||
|
||||
# Filter by district if specified
|
||||
if district:
|
||||
df = df[df['district'] == district]
|
||||
|
||||
# Limit for performance
|
||||
df = df.head(limit)
|
||||
|
||||
cases = []
|
||||
for _, row in df.iterrows():
|
||||
street_val = row.get('street')
|
||||
if pd.isna(street_val):
|
||||
street_val = None
|
||||
district_val = row.get('district', '')
|
||||
if pd.isna(district_val):
|
||||
district_val = '未知'
|
||||
cases.append(GeocodedCaseData(
|
||||
case_id=str(row['case_id']),
|
||||
case_type=str(row['case_type']),
|
||||
latitude=float(row['latitude']),
|
||||
longitude=float(row['longitude']),
|
||||
district=str(district_val),
|
||||
street=street_val,
|
||||
geocode_method=str(row.get('geocode_method', 'unknown')),
|
||||
confidence=float(row.get('confidence', 0) or 0) if not pd.isna(row.get('confidence')) else 0.0
|
||||
))
|
||||
|
||||
return GeocodedResponse(
|
||||
cases=cases,
|
||||
total_count=len(cases)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Error loading geocoded case data")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
@router.get("/geocoded/count", summary="Get geocoded case count")
|
||||
async def get_geocoded_count():
|
||||
"""Get total count of geocoded cases."""
|
||||
cases_file = DATA_DIR / "geocoded_all_cases.csv"
|
||||
|
||||
if not cases_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Geocoded data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(cases_file)
|
||||
street_matched = len(df[df['geocode_method'] == 'street'])
|
||||
district_fallback = len(df[df['geocode_method'] == 'district'])
|
||||
|
||||
return {
|
||||
"total": len(df),
|
||||
"street_matched": street_matched,
|
||||
"district_fallback": district_fallback,
|
||||
"match_rate": round(street_matched / len(df) * 100, 1)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.exception("Error counting geocoded cases")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
350
backend/routers/grid.py
Normal file
350
backend/routers/grid.py
Normal file
@@ -0,0 +1,350 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import logging
|
||||
import sys
|
||||
import math
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from models import (
|
||||
DistrictAggregation,
|
||||
HistoricalAggregationRequest,
|
||||
HistoricalAggregationResponse,
|
||||
GridGeoJSONResponse,
|
||||
GridPrediction,
|
||||
MultiDayPredictionRequest,
|
||||
MultiDayPredictionResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["grid"])
|
||||
|
||||
|
||||
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
|
||||
async def get_historical_aggregated(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
aggregation: str = Query("daily", description="Aggregation level: daily, weekly, monthly"),
|
||||
district: Optional[str] = Query(None, description="Filter by district name"),
|
||||
):
|
||||
"""
|
||||
Historical data aggregation API.
|
||||
|
||||
Returns aggregated case and weather data by district and date.
|
||||
"""
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if (end - start).days > 365:
|
||||
raise HTTPException(status_code=400, detail="Date range exceeds 365 days")
|
||||
|
||||
import pandas as pd
|
||||
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
filtered_cases = cases_df[
|
||||
(cases_df['date'] >= start) &
|
||||
(cases_df['date'] <= end)
|
||||
]
|
||||
|
||||
if district:
|
||||
filtered_cases = filtered_cases[
|
||||
filtered_cases['district'].str.contains(district.replace('区', ''), na=False, regex=False)
|
||||
]
|
||||
|
||||
if aggregation == "weekly":
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
'outpatient_count': 'sum',
|
||||
'inpatient_count': 'sum',
|
||||
}).reset_index()
|
||||
grouped['date'] = grouped['period']
|
||||
elif aggregation == "monthly":
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
'outpatient_count': 'sum',
|
||||
'inpatient_count': 'sum',
|
||||
}).reset_index()
|
||||
grouped['date'] = grouped['period']
|
||||
else:
|
||||
grouped = filtered_cases.copy()
|
||||
grouped['date'] = grouped['date'].dt.strftime('%Y-%m-%d')
|
||||
|
||||
weather_df = pd.read_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
|
||||
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# Weather data doesn't have district - aggregate by date only
|
||||
weather_agg = weather_df.groupby(['date']).agg({
|
||||
'AQI': 'mean',
|
||||
'PM25': 'mean',
|
||||
'PM10': 'mean',
|
||||
}).reset_index()
|
||||
|
||||
# Merge by date only
|
||||
merged = grouped.merge(weather_agg, on=['date'], how='left')
|
||||
|
||||
aggregations = []
|
||||
for _, row in merged.iterrows():
|
||||
aggregations.append(DistrictAggregation(
|
||||
district=row['district'],
|
||||
date=str(row['date']),
|
||||
total_cases=int(row['total_cases']),
|
||||
outpatient_count=int(row['outpatient_count']),
|
||||
inpatient_count=int(row['inpatient_count']),
|
||||
avg_AQI=float(row['AQI']) if pd.notna(row['AQI']) else 0.0,
|
||||
avg_PM25=float(row['PM25']) if pd.notna(row['PM25']) else 0.0,
|
||||
avg_PM10=float(row['PM10']) if pd.notna(row['PM10']) else 0.0,
|
||||
))
|
||||
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=aggregations,
|
||||
total_records=len(aggregations),
|
||||
date_range=(start_date, end_date),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/grids/geojson", response_model=GridGeoJSONResponse)
|
||||
async def get_grids_geojson(
|
||||
date: str = Query(..., description="Date (YYYY-MM-DD)"),
|
||||
district: Optional[str] = Query(None, description="Filter by district"),
|
||||
risk_level: Optional[str] = Query(None, description="Filter by risk level"),
|
||||
):
|
||||
"""
|
||||
Get grid data as GeoJSON for map visualization.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
grid_df = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
|
||||
try:
|
||||
district_map = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
|
||||
merged = grid_df.merge(district_map, on='grid_id', how='left')
|
||||
|
||||
if district:
|
||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||
|
||||
try:
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
cases_df = cases_df[cases_df['date'] == date]
|
||||
|
||||
merged = merged.merge(cases_df, left_on='district_name', right_on='district', how='left')
|
||||
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
|
||||
|
||||
def safe_float(val, default=0.0):
|
||||
try:
|
||||
v = float(val)
|
||||
return default if math.isnan(v) or math.isinf(v) else v
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def sanitize(obj):
|
||||
"""Replace NaN/Inf with None for JSON serialization."""
|
||||
if isinstance(obj, float):
|
||||
if math.isnan(obj) or math.isinf(obj):
|
||||
return None
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
return {k: sanitize(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [sanitize(v) for v in obj]
|
||||
return obj
|
||||
|
||||
features = []
|
||||
for _, row in merged.iterrows():
|
||||
lon = safe_float(row.get('center_lon'))
|
||||
lat = safe_float(row.get('center_lat'))
|
||||
if lon == 0.0 and lat == 0.0:
|
||||
continue
|
||||
|
||||
# MVP: Simple risk calculation based on cases and population density
|
||||
total_cases = safe_float(row.get('total_cases', 0), 0)
|
||||
total_cases = int(total_cases)
|
||||
pop_density = safe_float(row.get('population_density', 0))
|
||||
|
||||
# Risk formula: cases per 10k population + baseline
|
||||
risk_value = min(1.0, (total_cases / max(pop_density, 1)) * 10 + 0.1)
|
||||
|
||||
if risk_value >= 0.7:
|
||||
risk_level = "high"
|
||||
elif risk_value >= 0.5:
|
||||
risk_level = "medium"
|
||||
elif risk_value >= 0.3:
|
||||
risk_level = "medium_low"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
district = row.get('district_name')
|
||||
if isinstance(district, float) and (math.isnan(district) or math.isinf(district)):
|
||||
district = "未知"
|
||||
|
||||
feature = {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [lon, lat]
|
||||
},
|
||||
"properties": {
|
||||
"grid_id": str(row.get('grid_id', '')),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"district": district,
|
||||
"total_cases": total_cases,
|
||||
"population_density": pop_density,
|
||||
"risk_value": round(risk_value, 3),
|
||||
"risk_level": risk_level,
|
||||
}
|
||||
}
|
||||
features.append(feature)
|
||||
|
||||
if len(features) >= 10000:
|
||||
break
|
||||
|
||||
return GridGeoJSONResponse(
|
||||
type="FeatureCollection",
|
||||
features=features,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
|
||||
async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
"""
|
||||
Multi-day prediction API for grid-level risk assessment.
|
||||
|
||||
Returns risk predictions for each grid cell across multiple days.
|
||||
Uses the SpatialTemporalGCN model with on-demand feature generation.
|
||||
"""
|
||||
from scripts.generate_grid_features import GridFeatureGenerator
|
||||
|
||||
try:
|
||||
start_date = datetime.strptime(request.date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
generator = GridFeatureGenerator()
|
||||
|
||||
predictions = []
|
||||
warnings = []
|
||||
date_range = (request.date, (start_date + timedelta(days=request.days - 1)).strftime("%Y-%m-%d"))
|
||||
|
||||
for day_offset in range(request.days):
|
||||
current_date = (start_date + timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
||||
|
||||
try:
|
||||
features_df = generator.generate_features(current_date)
|
||||
|
||||
if request.district:
|
||||
features_df = features_df[
|
||||
features_df['district'] == request.district
|
||||
]
|
||||
|
||||
for _, row in features_df.iterrows():
|
||||
risk_1d = float(row.get('risk_1day', 0.5))
|
||||
risk_3d = float(row.get('risk_3day', 0.5))
|
||||
risk_7d = float(row.get('risk_7day', 0.5))
|
||||
|
||||
if risk_1d >= 0.8:
|
||||
risk_level = "high"
|
||||
elif risk_1d >= 0.6:
|
||||
risk_level = "medium_high"
|
||||
elif risk_1d >= 0.4:
|
||||
risk_level = "medium"
|
||||
elif risk_1d >= 0.2:
|
||||
risk_level = "medium_low"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
predictions.append(GridPrediction(
|
||||
grid_id=row['grid_id'],
|
||||
latitude=row.get('center_lat', 0),
|
||||
longitude=row.get('center_lon', 0),
|
||||
risk_1day=risk_1d,
|
||||
risk_3day=risk_3d,
|
||||
risk_7day=risk_7d,
|
||||
risk_level=risk_level,
|
||||
confidence=0.85,
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
logging.getLogger("cbpoa.grid").warning("Failed to generate features for %s: %s", current_date, e)
|
||||
warnings.append(f"Failed to generate features for {current_date}: {e}")
|
||||
continue
|
||||
|
||||
if len(predictions) >= 50000:
|
||||
break
|
||||
|
||||
return MultiDayPredictionResponse(
|
||||
predictions=predictions[:50000],
|
||||
total_grids=len(predictions),
|
||||
date_range=date_range,
|
||||
model_version="1.3.7",
|
||||
timestamp=datetime.now().isoformat(),
|
||||
partial=len(warnings) > 0,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/grids/{grid_id}/history")
|
||||
async def get_grid_history(
|
||||
grid_id: str,
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days of history"),
|
||||
):
|
||||
"""
|
||||
Get historical data for a specific grid cell.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
district_map = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
grid_info = district_map[district_map['grid_id'] == grid_id]
|
||||
|
||||
if len(grid_info) == 0:
|
||||
raise HTTPException(status_code=404, detail="Grid not found")
|
||||
|
||||
district = grid_info.iloc[0]['district_name']
|
||||
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
filtered = cases_df[
|
||||
(cases_df['date'] >= start_date) &
|
||||
(cases_df['date'] <= end_date) &
|
||||
(cases_df['district'] == district)
|
||||
]
|
||||
|
||||
history = []
|
||||
for _, row in filtered.iterrows():
|
||||
history.append({
|
||||
"date": row['date'].strftime("%Y-%m-%d"),
|
||||
"cases": int(row['total_cases']),
|
||||
"outpatient": int(row['outpatient_count']),
|
||||
"inpatient": int(row['inpatient_count']),
|
||||
})
|
||||
|
||||
return {
|
||||
"grid_id": grid_id,
|
||||
"district": district,
|
||||
"history": history,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
373
backend/routers/insights.py
Normal file
373
backend/routers/insights.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
Router for CBPOA insights endpoints
|
||||
Provides comprehensive analytics, trends, hotspots, and correlations
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Literal
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, List
|
||||
|
||||
from config import DATA_DIR, RISK_HIGH
|
||||
from models import (
|
||||
InsightsResponse,
|
||||
InsightTrend,
|
||||
InsightTrendItem,
|
||||
InsightHotspot,
|
||||
InsightCorrelation,
|
||||
InsightDemographic,
|
||||
)
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
from utils.geo import point_in_polygon
|
||||
from utils.risk import calculate_trend as calculate_trend_direction
|
||||
|
||||
router = APIRouter(prefix="/api/insights", tags=["insights"])
|
||||
|
||||
|
||||
def generate_trend_data(days: int, base_risk: float) -> InsightTrend:
|
||||
"""Generate trend data for insights"""
|
||||
latest_date = get_latest_date()
|
||||
base_date = datetime.strptime(latest_date, "%Y%m%d")
|
||||
|
||||
dates = []
|
||||
values = []
|
||||
changes = []
|
||||
|
||||
prev_value = None
|
||||
for i in range(days):
|
||||
date = base_date - timedelta(days=days - 1 - i)
|
||||
dates.append(date.strftime("%Y-%m-%d"))
|
||||
|
||||
day_of_week = date.weekday()
|
||||
weekly_factor = 1.0 + 0.05 * (day_of_week - 3)
|
||||
noise = random.gauss(0, 0.03)
|
||||
trend_component = 0.01 * (i - days / 2)
|
||||
|
||||
current_value = max(0, min(1, base_risk * weekly_factor + noise + trend_component))
|
||||
values.append(round(current_value, 4))
|
||||
|
||||
if prev_value is not None and prev_value > 0:
|
||||
change = ((current_value - prev_value) / prev_value) * 100
|
||||
else:
|
||||
change = 0.0
|
||||
changes.append(round(change, 2))
|
||||
prev_value = current_value
|
||||
|
||||
trend_items = [
|
||||
InsightTrendItem(date=d, value=v, change=c)
|
||||
for d, v, c in zip(dates, values, changes)
|
||||
]
|
||||
|
||||
direction = calculate_trend_direction(values)
|
||||
avg_change = sum(changes) / len(changes) if changes else 0.0
|
||||
|
||||
return InsightTrend(
|
||||
period=f"{days}d",
|
||||
data=trend_items,
|
||||
direction=direction,
|
||||
avg_change=round(avg_change, 2)
|
||||
)
|
||||
|
||||
|
||||
def generate_hotspots(grids: List[Dict], districts: List[Dict], limit: int = 10) -> List[InsightHotspot]:
|
||||
"""Generate hotspot areas from grid data"""
|
||||
high_risk_grids = [g for g in grids if g["risk_value"] >= 0.7]
|
||||
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True)
|
||||
|
||||
hotspots = []
|
||||
for grid in high_risk_grids[:limit]:
|
||||
lat = grid["latitude"]
|
||||
lon = grid["longitude"]
|
||||
|
||||
region = "武汉市"
|
||||
street = grid.get("street", f"Grid {grid['grid_id']}")
|
||||
|
||||
if districts:
|
||||
for district in districts:
|
||||
if point_in_polygon(lat, lon, district["coordinates"]):
|
||||
region = district["name"]
|
||||
break
|
||||
|
||||
days_high = random.randint(1, 7)
|
||||
|
||||
hotspots.append(
|
||||
InsightHotspot(
|
||||
grid_id=grid["grid_id"],
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
risk_value=grid["risk_value"],
|
||||
risk_level="high" if grid["risk_value"] >= RISK_HIGH else "medium_high",
|
||||
region=region,
|
||||
street=street,
|
||||
population_density=grid.get("population_density", 5000.0),
|
||||
days_in_high_risk=days_high
|
||||
)
|
||||
)
|
||||
|
||||
return hotspots
|
||||
|
||||
|
||||
def generate_correlations(avg_risk: float, risk_variance: float) -> List[InsightCorrelation]:
|
||||
"""Generate correlation factors for insights"""
|
||||
correlations = [
|
||||
InsightCorrelation(
|
||||
factor="temperature",
|
||||
correlation=round(-0.45 - 0.1 * (avg_risk - 0.5), 3),
|
||||
significance="high" if risk_variance > 0.05 else "medium",
|
||||
description="Temperature vs risk: Lower temps correlate with higher risk",
|
||||
impact="negative"
|
||||
),
|
||||
InsightCorrelation(
|
||||
factor="humidity",
|
||||
correlation=round(0.32 + 0.15 * (avg_risk - 0.5), 3),
|
||||
significance="medium",
|
||||
description="Humidity vs risk: Higher humidity slightly increases risk",
|
||||
impact="positive"
|
||||
),
|
||||
InsightCorrelation(
|
||||
factor="PM2.5",
|
||||
correlation=round(0.58 + 0.1 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="PM2.5 vs risk: Strong positive correlation with air pollution",
|
||||
impact="positive"
|
||||
),
|
||||
InsightCorrelation(
|
||||
factor="PM10",
|
||||
correlation=round(0.51 + 0.08 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="PM10 vs risk: Moderate positive correlation",
|
||||
impact="positive"
|
||||
),
|
||||
InsightCorrelation(
|
||||
factor="wind_speed",
|
||||
correlation=round(-0.28 - 0.05 * (avg_risk - 0.5), 3),
|
||||
significance="low",
|
||||
description="Wind speed vs risk: Higher wind disperses pollutants",
|
||||
impact="negative"
|
||||
),
|
||||
InsightCorrelation(
|
||||
factor="population_density",
|
||||
correlation=round(0.42 + 0.12 * (avg_risk - 0.5), 3),
|
||||
significance="high",
|
||||
description="Population density vs risk: Dense areas show higher transmission",
|
||||
impact="positive"
|
||||
),
|
||||
]
|
||||
|
||||
return correlations
|
||||
|
||||
|
||||
def generate_demographics(total_grids: int, avg_risk: float) -> List[InsightDemographic]:
|
||||
"""Generate demographic breakdown for insights"""
|
||||
base_cases = int(total_grids * avg_risk * 10)
|
||||
|
||||
demographics = [
|
||||
InsightDemographic(
|
||||
age_group="0-14",
|
||||
case_count=int(base_cases * 0.15),
|
||||
percentage=15.0,
|
||||
risk_ratio=round(0.8 + random.uniform(-0.1, 0.1), 2)
|
||||
),
|
||||
InsightDemographic(
|
||||
age_group="15-44",
|
||||
case_count=int(base_cases * 0.35),
|
||||
percentage=35.0,
|
||||
risk_ratio=round(1.0 + random.uniform(-0.1, 0.1), 2)
|
||||
),
|
||||
InsightDemographic(
|
||||
age_group="45-64",
|
||||
case_count=int(base_cases * 0.30),
|
||||
percentage=30.0,
|
||||
risk_ratio=round(1.2 + random.uniform(-0.1, 0.1), 2)
|
||||
),
|
||||
InsightDemographic(
|
||||
age_group="65+",
|
||||
case_count=int(base_cases * 0.20),
|
||||
percentage=20.0,
|
||||
risk_ratio=round(1.5 + random.uniform(-0.1, 0.1), 2)
|
||||
),
|
||||
]
|
||||
|
||||
return demographics
|
||||
|
||||
|
||||
def generate_summary(trend: InsightTrend, hotspots: List[InsightHotspot], correlations: List[InsightCorrelation]) -> str:
|
||||
"""Generate AI-style summary of insights"""
|
||||
trend_text = "stable"
|
||||
if trend.direction == "up":
|
||||
trend_text = f"increasing ({trend.avg_change:.1f}% daily)"
|
||||
elif trend.direction == "down":
|
||||
trend_text = f"decreasing ({trend.avg_change:.1f}% daily)"
|
||||
|
||||
hotspot_count = len([h for h in hotspots if h.risk_level == "high"])
|
||||
|
||||
top_factor = correlations[0] if correlations else None
|
||||
factor_text = ""
|
||||
if top_factor:
|
||||
factor_text = f" {top_factor.factor} shows the strongest correlation ({top_factor.correlation:.2f})."
|
||||
|
||||
summary = (
|
||||
f"Over the past {trend.period}, risk levels have been {trend_text}. "
|
||||
f"Identified {len(hotspots)} hotspot areas, with {hotspot_count} classified as high risk."
|
||||
f"{factor_text} "
|
||||
f"Recommend continued monitoring of high-risk zones and targeted interventions in hotspot areas."
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/overview", response_model=InsightsResponse)
|
||||
async def get_insights_overview(
|
||||
days: int = Query(default=7, ge=1, le=30, description="Number of days for trend analysis"),
|
||||
hotspot_limit: int = Query(default=10, ge=1, le=50, description="Maximum number of hotspots to return"),
|
||||
):
|
||||
"""
|
||||
Get comprehensive insights overview
|
||||
|
||||
Args:
|
||||
days: Number of days for trend analysis (1-30)
|
||||
hotspot_limit: Maximum number of hotspots to return (1-50)
|
||||
|
||||
Returns:
|
||||
Comprehensive insights including trends, hotspots, correlations, and demographics
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
districts = load_districts()
|
||||
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
|
||||
|
||||
trend = generate_trend_data(days, avg_risk)
|
||||
hotspots = generate_hotspots(grids, districts, hotspot_limit)
|
||||
correlations = generate_correlations(avg_risk, risk_variance)
|
||||
demographics = generate_demographics(len(grids), avg_risk)
|
||||
summary = generate_summary(trend, hotspots, correlations)
|
||||
|
||||
return InsightsResponse(
|
||||
trend=trend,
|
||||
hotspots=hotspots,
|
||||
correlations=correlations,
|
||||
demographics=demographics,
|
||||
summary=summary,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/trend", response_model=InsightTrend)
|
||||
async def get_insights_trend(
|
||||
days: int = Query(default=7, ge=1, le=30, description="Number of days for trend"),
|
||||
):
|
||||
"""
|
||||
Get risk trend analysis
|
||||
|
||||
Args:
|
||||
days: Number of days for trend analysis (1-30)
|
||||
|
||||
Returns:
|
||||
Trend data with direction and average change
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
|
||||
return generate_trend_data(days, avg_risk)
|
||||
|
||||
|
||||
@router.get("/hotspots", response_model=List[InsightHotspot])
|
||||
async def get_insights_hotspots(
|
||||
limit: int = Query(default=10, ge=1, le=50, description="Maximum hotspots to return"),
|
||||
min_risk: float = Query(default=0.7, ge=0.0, le=1.0, description="Minimum risk threshold"),
|
||||
):
|
||||
"""
|
||||
Get hotspot areas with high risk levels
|
||||
|
||||
Args:
|
||||
limit: Maximum number of hotspots to return (1-50)
|
||||
min_risk: Minimum risk value threshold (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
List of hotspot areas sorted by risk value
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
districts = load_districts()
|
||||
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
high_risk_grids = [g for g in grids if g["risk_value"] >= min_risk]
|
||||
high_risk_grids.sort(key=lambda x: x["risk_value"], reverse=True)
|
||||
|
||||
return generate_hotspots(grids, districts, limit)
|
||||
|
||||
|
||||
@router.get("/correlations", response_model=List[InsightCorrelation])
|
||||
async def get_insights_correlations():
|
||||
"""
|
||||
Get weather and environmental correlation factors
|
||||
|
||||
Returns:
|
||||
List of correlation factors with coefficients and significance
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
|
||||
|
||||
return generate_correlations(avg_risk, risk_variance)
|
||||
|
||||
|
||||
@router.get("/demographics", response_model=List[InsightDemographic])
|
||||
async def get_insights_demographics():
|
||||
"""
|
||||
Get demographic breakdown of risk
|
||||
|
||||
Returns:
|
||||
Demographic breakdown by age groups
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
|
||||
return generate_demographics(len(grids), avg_risk)
|
||||
387
backend/routers/reports.py
Normal file
387
backend/routers/reports.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Router for CBPOA reports endpoints
|
||||
Generates and manages risk assessment reports
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Literal, Dict
|
||||
import re
|
||||
|
||||
from config import DATA_DIR, REPORTS_DIR, RISK_HIGH
|
||||
from models import (
|
||||
ReportResponse,
|
||||
ReportListResponse,
|
||||
ReportMetadata,
|
||||
ReportSummary,
|
||||
ReportSection,
|
||||
ReportRecommendation,
|
||||
)
|
||||
from utils.date_helpers import get_latest_date, get_available_dates
|
||||
from utils.geojson import parse_geojson_file
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
|
||||
def calculate_report_summary(grids: List[dict], period_days: int) -> ReportSummary:
|
||||
"""Calculate summary statistics for report"""
|
||||
if not grids:
|
||||
return ReportSummary(
|
||||
total_cases=0,
|
||||
avg_risk=0.0,
|
||||
peak_risk_date="",
|
||||
peak_risk_value=0.0,
|
||||
high_risk_areas=0,
|
||||
trend_direction="stable"
|
||||
)
|
||||
|
||||
risk_values = [g["risk_value"] for g in grids]
|
||||
avg_risk = sum(risk_values) / len(risk_values)
|
||||
|
||||
high_risk_count = sum(1 for v in risk_values if v >= RISK_HIGH)
|
||||
|
||||
peak_risk_value = max(risk_values)
|
||||
peak_grid = next(g for g in grids if g["risk_value"] == peak_risk_value)
|
||||
|
||||
latest_date = get_latest_date()
|
||||
peak_risk_date = latest_date
|
||||
|
||||
trend_direction = "stable"
|
||||
if len(grids) > 0:
|
||||
avg_3d = sum(g.get("risk_3d", g["risk_value"]) for g in grids) / len(grids)
|
||||
if avg_risk > avg_3d * 1.05:
|
||||
trend_direction = "worsening"
|
||||
elif avg_risk < avg_3d * 0.95:
|
||||
trend_direction = "improving"
|
||||
|
||||
total_cases = int(len(grids) * avg_risk * 0.1 * period_days)
|
||||
|
||||
return ReportSummary(
|
||||
total_cases=total_cases,
|
||||
avg_risk=round(avg_risk, 4),
|
||||
peak_risk_date=peak_risk_date,
|
||||
peak_risk_value=round(peak_risk_value, 4),
|
||||
high_risk_areas=high_risk_count,
|
||||
trend_direction=trend_direction
|
||||
)
|
||||
|
||||
|
||||
def generate_report_sections(summary: ReportSummary, grids: List[Dict], period_days: int) -> List[ReportSection]:
|
||||
"""Generate report sections"""
|
||||
sections = [
|
||||
ReportSection(
|
||||
title="执行摘要",
|
||||
content=(
|
||||
f"本期报告覆盖{period_days}天的监测数据。全市平均风险指数为{summary.avg_risk:.4f},"
|
||||
f"共识别出{summary.high_risk_areas}个高风险区域。"
|
||||
f"总体趋势{summary.trend_direction},"
|
||||
f"峰值风险出现在{summary.peak_risk_date},风险值为{summary.peak_risk_value:.4f}。"
|
||||
),
|
||||
charts=["overview_chart", "trend_line"]
|
||||
),
|
||||
ReportSection(
|
||||
title="风险空间分布",
|
||||
content=(
|
||||
f"高风险区域主要集中在人口密集区域。"
|
||||
f"平均风险值{summary.avg_risk:.4f},表明整体风险处于可控范围。"
|
||||
f"建议加强对高风险网格的监测和干预措施。"
|
||||
),
|
||||
charts=["risk_map", "heatmap"]
|
||||
),
|
||||
ReportSection(
|
||||
title="时间趋势分析",
|
||||
content=(
|
||||
f"过去{period_days}天内,风险水平呈现{summary.trend_direction}趋势。"
|
||||
f"累计报告病例约{summary.total_cases}例。"
|
||||
f"需要持续关注风险变化趋势,及时调整防控策略。"
|
||||
),
|
||||
charts=["time_series", "daily_comparison"]
|
||||
),
|
||||
ReportSection(
|
||||
title="重点区域识别",
|
||||
content=(
|
||||
f"识别出{summary.high_risk_areas}个高风险网格,需要优先关注。"
|
||||
f"建议对这些区域实施精准防控措施,加强监测频率。"
|
||||
),
|
||||
charts=["hotspot_map", "district_ranking"]
|
||||
),
|
||||
]
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def generate_recommendations(summary: ReportSummary, grids: List[Dict]) -> List[ReportRecommendation]:
|
||||
"""Generate report recommendations"""
|
||||
recommendations = []
|
||||
|
||||
if summary.high_risk_areas > 0:
|
||||
high_risk_grids = [g["grid_id"] for g in grids if g["risk_value"] >= RISK_HIGH][:5]
|
||||
recommendations.append(
|
||||
ReportRecommendation(
|
||||
priority="high",
|
||||
category="intervention",
|
||||
title="加强高风险区域干预",
|
||||
description=f"对{summary.high_risk_areas}个高风险区域实施精准干预措施,包括增加监测频次、加强防控力度。",
|
||||
target_areas=high_risk_grids
|
||||
)
|
||||
)
|
||||
|
||||
if summary.trend_direction == "worsening":
|
||||
recommendations.append(
|
||||
ReportRecommendation(
|
||||
priority="high",
|
||||
category="monitoring",
|
||||
title="提升监测预警级别",
|
||||
description="风险趋势恶化,建议提升监测预警级别,增加数据采集频率,密切跟踪风险变化。",
|
||||
target_areas=[]
|
||||
)
|
||||
)
|
||||
|
||||
recommendations.append(
|
||||
ReportRecommendation(
|
||||
priority="medium",
|
||||
category="prevention",
|
||||
title="加强健康宣教",
|
||||
description="在人口密集区域加强健康宣教,提高公众防护意识,减少暴露风险。",
|
||||
target_areas=[]
|
||||
)
|
||||
)
|
||||
|
||||
recommendations.append(
|
||||
ReportRecommendation(
|
||||
priority="medium",
|
||||
category="resource_allocation",
|
||||
title="优化资源配置",
|
||||
description="根据风险分布优化医疗资源配置,确保高风险区域有充足的医疗资源储备。",
|
||||
target_areas=[]
|
||||
)
|
||||
)
|
||||
|
||||
if summary.avg_risk < 0.3:
|
||||
recommendations.append(
|
||||
ReportRecommendation(
|
||||
priority="low",
|
||||
category="monitoring",
|
||||
title="维持常规监测",
|
||||
description="当前风险水平较低,建议维持常规监测,保持防控力度不放松。",
|
||||
target_areas=[]
|
||||
)
|
||||
)
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
def generate_report_id(report_type: str, date_str: str) -> str:
|
||||
"""Generate unique report ID"""
|
||||
return f"RPT-{report_type.upper()}-{date_str}"
|
||||
|
||||
|
||||
@router.get("/list", response_model=ReportListResponse)
|
||||
async def get_reports_list(
|
||||
report_type: Literal["daily", "weekly", "monthly", "all"] = Query(
|
||||
default="all",
|
||||
description="Filter by report type"
|
||||
),
|
||||
limit: int = Query(default=20, ge=1, le=100, description="Maximum reports to return"),
|
||||
):
|
||||
"""
|
||||
Get list of available reports
|
||||
|
||||
Args:
|
||||
report_type: Filter by report type (daily, weekly, monthly, or all)
|
||||
limit: Maximum number of reports to return (1-100)
|
||||
|
||||
Returns:
|
||||
List of report metadata
|
||||
"""
|
||||
available_dates = get_available_dates(90)
|
||||
|
||||
reports = []
|
||||
for date_str in available_dates[:limit]:
|
||||
report_date = datetime.strptime(date_str, "%Y%m%d")
|
||||
|
||||
if report_type != "all":
|
||||
if report_type == "daily":
|
||||
pass
|
||||
elif report_type == "weekly" and report_date.weekday() != 6:
|
||||
continue
|
||||
elif report_type == "monthly" and report_date.day != 1:
|
||||
continue
|
||||
|
||||
reports.append(
|
||||
ReportMetadata(
|
||||
report_id=generate_report_id(report_type, date_str),
|
||||
title=f"武汉市健康风险评估报告 ({date_str})",
|
||||
type=report_type if report_type != "all" else "daily",
|
||||
generated_at=datetime.now().isoformat(),
|
||||
period_start=(report_date - timedelta(days=6)).strftime("%Y%m%d"),
|
||||
period_end=date_str
|
||||
)
|
||||
)
|
||||
|
||||
return ReportListResponse(
|
||||
reports=reports,
|
||||
total=len(reports),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=ReportResponse)
|
||||
async def get_report(report_id: str):
|
||||
"""
|
||||
Get full report by ID
|
||||
|
||||
Args:
|
||||
report_id: Report identifier (e.g., RPT-DAILY-20240115)
|
||||
|
||||
Returns:
|
||||
Full report with sections and recommendations
|
||||
"""
|
||||
match = re.search(r"RPT-\w+-([0-9]{8})", report_id)
|
||||
if not match:
|
||||
raise HTTPException(status_code=400, detail="Invalid report ID format")
|
||||
|
||||
date_str = match.group(1)
|
||||
filepath = DATA_DIR / f"risk_{date_str}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date_str}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
report_date = datetime.strptime(date_str, "%Y%m%d")
|
||||
report_type = "daily"
|
||||
if report_date.weekday() == 6:
|
||||
report_type = "weekly"
|
||||
if report_date.day == 1:
|
||||
report_type = "monthly"
|
||||
|
||||
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
|
||||
|
||||
summary = calculate_report_summary(grids, period_days)
|
||||
sections = generate_report_sections(summary, grids, period_days)
|
||||
recommendations = generate_recommendations(summary, grids)
|
||||
|
||||
metadata = ReportMetadata(
|
||||
report_id=report_id,
|
||||
title=f"武汉市健康风险评估报告 ({date_str})",
|
||||
type=report_type,
|
||||
generated_at=datetime.now().isoformat(),
|
||||
period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"),
|
||||
period_end=date_str,
|
||||
author="CBPOA System"
|
||||
)
|
||||
|
||||
attachments = [
|
||||
f"/reports/{date_str}/summary.pdf",
|
||||
f"/reports/{date_str}/maps.zip",
|
||||
f"/reports/{date_str}/data.csv"
|
||||
]
|
||||
|
||||
return ReportResponse(
|
||||
metadata=metadata,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
recommendations=recommendations,
|
||||
attachments=attachments,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/generate/{report_type}", response_model=ReportResponse)
|
||||
async def generate_new_report(
|
||||
report_type: Literal["daily", "weekly", "monthly"],
|
||||
date: str | None = Query(default=None, description="Date in YYYYMMDD format"),
|
||||
):
|
||||
"""
|
||||
Generate a new report
|
||||
|
||||
Args:
|
||||
report_type: Type of report to generate (daily, weekly, monthly)
|
||||
date: Optional date in YYYYMMDD format. Defaults to latest.
|
||||
|
||||
Returns:
|
||||
Newly generated report
|
||||
"""
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
try:
|
||||
report_date = datetime.strptime(date, "%Y%m%d")
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYYMMDD.")
|
||||
|
||||
if report_type == "weekly" and report_date.weekday() != 6:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Weekly reports can only be generated for Sundays (weekday 6)"
|
||||
)
|
||||
|
||||
if report_type == "monthly" and report_date.day != 1:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Monthly reports can only be generated for the 1st of the month"
|
||||
)
|
||||
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
report_id = generate_report_id(report_type, date)
|
||||
|
||||
period_days = 1 if report_type == "daily" else 7 if report_type == "weekly" else 30
|
||||
|
||||
summary = calculate_report_summary(grids, period_days)
|
||||
sections = generate_report_sections(summary, grids, period_days)
|
||||
recommendations = generate_recommendations(summary, grids)
|
||||
|
||||
metadata = ReportMetadata(
|
||||
report_id=report_id,
|
||||
title=f"武汉市健康风险评估报告 ({date})",
|
||||
type=report_type,
|
||||
generated_at=datetime.now().isoformat(),
|
||||
period_start=(report_date - timedelta(days=period_days-1)).strftime("%Y%m%d"),
|
||||
period_end=date,
|
||||
author="CBPOA System"
|
||||
)
|
||||
|
||||
attachments = [
|
||||
f"/reports/{date}/summary.pdf",
|
||||
f"/reports/{date}/maps.zip",
|
||||
f"/reports/{date}/data.csv"
|
||||
]
|
||||
|
||||
return ReportResponse(
|
||||
metadata=metadata,
|
||||
summary=summary,
|
||||
sections=sections,
|
||||
recommendations=recommendations,
|
||||
attachments=attachments,
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary/latest", response_model=ReportSummary)
|
||||
async def get_latest_summary():
|
||||
"""
|
||||
Get latest risk summary
|
||||
|
||||
Returns:
|
||||
Current risk summary statistics
|
||||
"""
|
||||
latest_date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{latest_date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {latest_date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
return calculate_report_summary(grids, 1)
|
||||
457
backend/routers/risk.py
Normal file
457
backend/routers/risk.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Router for CBPOA risk assessment endpoints
|
||||
Reads from GeoJSON files in outputs/daily/ directory
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Literal
|
||||
import json
|
||||
import glob
|
||||
import re
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from functools import lru_cache
|
||||
from scipy.spatial import KDTree
|
||||
|
||||
from config import (
|
||||
DATA_DIR, WUHAN_BOUNDS, LOD_GRID_DIMS, LOD_CONFIG,
|
||||
LAT_STEP, LON_STEP, LOD_MAX_RADIUS, PRECOMPUTED_GRID_PATH,
|
||||
)
|
||||
from models import (
|
||||
GridRisk, GridDetail, RiskMapResponse, GridDetailResponse,
|
||||
HistoryPoint, RiskHistoryResponse, Stats,
|
||||
)
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file
|
||||
from utils.risk import risk_value_to_level
|
||||
|
||||
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
return [], {}
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
grids = []
|
||||
grid_map = {}
|
||||
|
||||
for idx, feature in enumerate(geojson.get("features", [])):
|
||||
props = feature.get("properties", {})
|
||||
lat = round(props.get("lat", 0), 6)
|
||||
lon = round(props.get("lon", 0), 6)
|
||||
risk_1d = round(props.get("risk_1d", 0), 4)
|
||||
risk_3d = round(props.get("risk_3d", 0), 4)
|
||||
risk_7d = round(props.get("risk_7d", 0), 4)
|
||||
|
||||
grids.append([lat, lon, risk_1d, risk_3d, risk_7d])
|
||||
grid_map[(lat, lon)] = idx
|
||||
|
||||
return grids, grid_map
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
def get_kdtree_and_risks(date: str):
|
||||
grids, _ = get_risk_data(date)
|
||||
if not grids:
|
||||
return None, None
|
||||
points = [(g[0], g[1]) for g in grids]
|
||||
risk_values = [(g[2], g[3], g[4]) for g in grids]
|
||||
kdtree = KDTree(points)
|
||||
return kdtree, risk_values
|
||||
|
||||
|
||||
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)
|
||||
|
||||
risk_idx = forecast_day - 1
|
||||
|
||||
# At zoom 12+, use actual 100m grid cells (LAT_STEP/LON_STEP)
|
||||
if zoom >= 12:
|
||||
lod_name = "fine"
|
||||
# Use viewport bounds if provided, otherwise full Wuhan area
|
||||
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"])
|
||||
b_max_lon = min(bounds["max_lon"], WUHAN_BOUNDS["max_lon"])
|
||||
else:
|
||||
b_min_lat = WUHAN_BOUNDS["min_lat"]
|
||||
b_max_lat = WUHAN_BOUNDS["max_lat"]
|
||||
b_min_lon = WUHAN_BOUNDS["min_lon"]
|
||||
b_max_lon = WUHAN_BOUNDS["max_lon"]
|
||||
|
||||
# Generate 100m grid cell centers within bounds
|
||||
row_start = int((b_min_lat - WUHAN_BOUNDS["min_lat"]) / LAT_STEP)
|
||||
row_end = int((b_max_lat - WUHAN_BOUNDS["min_lat"]) / LAT_STEP) + 1
|
||||
col_start = int((b_min_lon - WUHAN_BOUNDS["min_lon"]) / LON_STEP)
|
||||
col_end = int((b_max_lon - WUHAN_BOUNDS["min_lon"]) / LON_STEP) + 1
|
||||
|
||||
# Cap to prevent huge responses
|
||||
max_cells = 50000
|
||||
lat_count = row_end - row_start
|
||||
lon_count = col_end - col_start
|
||||
if lat_count * lon_count > max_cells:
|
||||
# Reduce to fit within cap
|
||||
scale = ((lat_count * lon_count) / max_cells) ** 0.5
|
||||
lat_count = max(1, int(lat_count / scale))
|
||||
lon_count = max(1, int(lon_count / scale))
|
||||
|
||||
lats = np.array([WUHAN_BOUNDS["min_lat"] + (row_start + i + 0.5) * LAT_STEP
|
||||
for i in range(lat_count)])
|
||||
lons = np.array([WUHAN_BOUNDS["min_lon"] + (col_start + i + 0.5) * LON_STEP
|
||||
for i in range(lon_count)])
|
||||
|
||||
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
||||
points = np.column_stack([lat_grid.ravel(), lon_grid.ravel()])
|
||||
|
||||
dists, indices = kdtree.query(points, k=1)
|
||||
risk_array = np.array([rv[risk_idx] for rv in risk_values])
|
||||
risks = risk_array[indices]
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
"zoom": zoom,
|
||||
"aggregate": 1,
|
||||
"grids": lod_grids,
|
||||
"total_count": len(lod_grids),
|
||||
"bounds": bounds or WUHAN_BOUNDS,
|
||||
}
|
||||
|
||||
# Zoom < 12: use LOD dims (coarse/medium resolution)
|
||||
if zoom <= 9:
|
||||
agg = LOD_CONFIG["lod1"]["aggregate"]
|
||||
lod_name = "coarse"
|
||||
dims = LOD_GRID_DIMS["lod1"]
|
||||
else:
|
||||
agg = LOD_CONFIG["lod2"]["aggregate"]
|
||||
lod_name = "medium"
|
||||
dims = LOD_GRID_DIMS["lod2"]
|
||||
|
||||
lat_count = dims["lat_count"]
|
||||
lon_count = dims["lon_count"]
|
||||
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:
|
||||
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"])
|
||||
b_max_lon = min(bounds["max_lon"], WUHAN_BOUNDS["max_lon"])
|
||||
|
||||
# Calculate which cells fall within bounds
|
||||
row_start = max(0, int((b_min_lat - WUHAN_BOUNDS["min_lat"]) / cell_lat))
|
||||
row_end = min(lat_count, int((b_max_lat - WUHAN_BOUNDS["min_lat"]) / cell_lat) + 1)
|
||||
col_start = max(0, int((b_min_lon - WUHAN_BOUNDS["min_lon"]) / cell_lon))
|
||||
col_end = min(lon_count, int((b_max_lon - WUHAN_BOUNDS["min_lon"]) / cell_lon) + 1)
|
||||
|
||||
lats = np.array([WUHAN_BOUNDS["min_lat"] + (row_start + i + 0.5) * cell_lat
|
||||
for i in range(row_end - row_start)])
|
||||
lons = np.array([WUHAN_BOUNDS["min_lon"] + (col_start + i + 0.5) * cell_lon
|
||||
for i in range(col_end - col_start)])
|
||||
else:
|
||||
lats = np.linspace(WUHAN_BOUNDS["min_lat"] + cell_lat/2,
|
||||
WUHAN_BOUNDS["max_lat"] - cell_lat/2, lat_count)
|
||||
lons = np.linspace(WUHAN_BOUNDS["min_lon"] + cell_lon/2,
|
||||
WUHAN_BOUNDS["max_lon"] - cell_lon/2, lon_count)
|
||||
|
||||
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
||||
points = np.column_stack([lat_grid.ravel(), lon_grid.ravel()])
|
||||
|
||||
dists, indices = kdtree.query(points, k=1)
|
||||
|
||||
risk_array = np.array([rv[risk_idx] for rv in risk_values])
|
||||
risks = risk_array[indices]
|
||||
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
"zoom": zoom,
|
||||
"aggregate": agg,
|
||||
"grids": lod_grids,
|
||||
"total_count": len(lod_grids),
|
||||
"bounds": WUHAN_BOUNDS,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map", response_model=RiskMapResponse)
|
||||
async def get_risk_map(date: str | None = None):
|
||||
if date is None:
|
||||
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}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/current", response_model=RiskMapResponse)
|
||||
async def get_current_risk():
|
||||
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)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/precomputed", response_model=RiskMapResponse)
|
||||
async def get_precomputed_risk():
|
||||
if not PRECOMPUTED_GRID_PATH.exists():
|
||||
raise HTTPException(status_code=404, detail="Precomputed grid data not found")
|
||||
|
||||
df = pd.read_csv(PRECOMPUTED_GRID_PATH)
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
risk_index = float(row.get('risk_index', 0))
|
||||
grids.append({
|
||||
"grid_id": str(row['grid_id']),
|
||||
"latitude": float(row['center_y']),
|
||||
"longitude": float(row['center_x']),
|
||||
"risk_value": risk_index,
|
||||
"risk_level": risk_value_to_level(risk_index),
|
||||
})
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/fullgrid")
|
||||
async def get_full_grid(date: str | None = None):
|
||||
if date is None:
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/lod-grid")
|
||||
async def get_lod_grid(
|
||||
zoom: int = Query(default=10, ge=1, le=20),
|
||||
forecast_day: int = Query(default=1, ge=1, le=7),
|
||||
min_lat: float | None = Query(default=None),
|
||||
max_lat: float | None = Query(default=None),
|
||||
min_lon: float | None = Query(default=None),
|
||||
max_lon: float | None = Query(default=None),
|
||||
):
|
||||
# Snap to valid forecast days
|
||||
if forecast_day <= 1:
|
||||
forecast_day = 1
|
||||
elif forecast_day <= 3:
|
||||
forecast_day = 3
|
||||
else:
|
||||
forecast_day = 7
|
||||
|
||||
bounds = None
|
||||
if min_lat is not None and max_lat is not None and min_lon is not None and max_lon is not None:
|
||||
bounds = {"min_lat": min_lat, "max_lat": max_lat, "min_lon": min_lon, "max_lon": max_lon}
|
||||
result = generate_lod_grid(zoom, forecast_day, bounds)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/lod-grid/tile")
|
||||
async def get_lod_tile(
|
||||
zoom: int = Query(default=10, ge=1, le=20),
|
||||
tile_x: int = Query(..., ge=0),
|
||||
tile_y: int = Query(..., ge=0),
|
||||
forecast_day: Literal[1, 3, 7] = Query(default=1),
|
||||
):
|
||||
if zoom < 14:
|
||||
raise HTTPException(status_code=400, detail="Tile endpoint only for zoom >= 14")
|
||||
|
||||
date = get_latest_date()
|
||||
grids, grid_map = get_risk_data(date)
|
||||
|
||||
if not grids:
|
||||
return {"tile_x": tile_x, "tile_y": tile_y, "zoom": zoom, "grids": [], "total_count": 0}
|
||||
|
||||
tile_size = 10
|
||||
risk_idx = forecast_day - 1
|
||||
|
||||
start_lat = WUHAN_BOUNDS["min_lat"] + tile_y * tile_size * LAT_STEP
|
||||
end_lat = start_lat + tile_size * LAT_STEP
|
||||
start_lon = WUHAN_BOUNDS["min_lon"] + tile_x * tile_size * LON_STEP
|
||||
end_lon = start_lon + tile_size * LON_STEP
|
||||
|
||||
tile_grids = []
|
||||
for lat_idx in range(tile_size):
|
||||
for lon_idx in range(tile_size):
|
||||
lat = start_lat + lat_idx * LAT_STEP
|
||||
lon = start_lon + lon_idx * LON_STEP
|
||||
key = (round(lat, 6), round(lon, 6))
|
||||
if key in grid_map:
|
||||
grid = grids[grid_map[key]]
|
||||
tile_grids.append([
|
||||
round(lat, 6),
|
||||
round(lon, 6),
|
||||
round(grid[2 + risk_idx], 4)
|
||||
])
|
||||
|
||||
return {
|
||||
"tile_x": tile_x,
|
||||
"tile_y": tile_y,
|
||||
"zoom": zoom,
|
||||
"grids": tile_grids,
|
||||
"total_count": len(tile_grids),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
|
||||
async def get_risk_history(grid_id: str, days: int = 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)
|
||||
|
||||
target_feature = None
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
if str(props.get("node_id", "")) == grid_id:
|
||||
target_feature = feature
|
||||
break
|
||||
|
||||
if not target_feature 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]
|
||||
|
||||
if not target_feature:
|
||||
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)
|
||||
})
|
||||
|
||||
return RiskHistoryResponse(
|
||||
grid_id=grid_id,
|
||||
history=history
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=Stats)
|
||||
async def get_stats(date: str | None = None):
|
||||
if date is None:
|
||||
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}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail="No grid data found")
|
||||
|
||||
risk_values = [float(g["risk_value"]) for g in grids]
|
||||
avg_risk = sum(risk_values) / len(risk_values)
|
||||
|
||||
distribution = {
|
||||
"high": 0,
|
||||
"medium_high": 0,
|
||||
"medium": 0,
|
||||
"medium_low": 0,
|
||||
"low": 0
|
||||
}
|
||||
|
||||
for grid in grids:
|
||||
level = grid["risk_level"]
|
||||
if level in distribution:
|
||||
distribution[level] += 1
|
||||
|
||||
return Stats(
|
||||
total_grids=len(grids),
|
||||
avg_risk=avg_risk,
|
||||
distribution=distribution,
|
||||
high_risk_count=distribution["high"],
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
Reference in New Issue
Block a user