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.
388 lines
13 KiB
Python
388 lines
13 KiB
Python
"""
|
||
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)
|