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:
2026-06-05 02:13:49 +08:00
commit fc468464b2
117 changed files with 18282 additions and 0 deletions

41
backend/CLAUDE.md Normal file
View File

@@ -0,0 +1,41 @@
# Backend — FastAPI + PostGIS
## Stack
- FastAPI (async), asyncpg connection pool, Pydantic v2 settings
- PostGIS via GeoAlchemy2, spatial queries with Shapely
- Auth: python-jose + passlib (JWT/bcrypt)
## Structure
```
backend/
main.py # App entry, CORS, router registration
database.py # asyncpg pool, Settings from .env
models.py # Pydantic response/request models
routers/ # One file per domain (risk, alerts, cases, grid, etc.)
app/ # Legacy code (routers/cases.py, routers/grid.py, performance.py)
```
## Patterns
- Routers: `APIRouter()` with prefix, registered in `main.py` via `app.include_router()`
- DB access: `async with db.get_connection()` context manager (global `db` singleton)
- Settings: `pydantic_settings.BaseSettings` loaded from `.env` at module level
- Endpoints return Pydantic models, not raw dicts
## Running
```bash
cd backend
source venv/bin/activate
uvicorn main:app --reload --port 8000
```
## Anti-Patterns
- Don't use sync database drivers — always asyncpg
- Don't put business logic in routers — delegate to service functions
- Don't hardcode DB credentials — use Settings from environment
- Don't skip Pydantic validation on request/response bodies
- Don't import from `app/` — it's legacy, prefer top-level modules

0
backend/auth/__init__.py Normal file
View File

View File

@@ -0,0 +1,27 @@
"""FastAPI dependencies for authentication."""
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .service import decode_access_token
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> str:
"""Extract and validate the current user from the Authorization header."""
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
username: str | None = payload.get("sub")
if not username:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing subject",
)
return username

View File

@@ -0,0 +1,3 @@
"""Auth middleware — currently a no-op placeholder for future rate-limiting / audit logging."""
# Middleware for auth events can be added here (e.g., failed-login rate limiter).
# Kept as a placeholder so the module structure is complete.

21
backend/auth/models.py Normal file
View File

@@ -0,0 +1,21 @@
"""Pydantic models for authentication."""
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=128)
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
username: str

34
backend/auth/router.py Normal file
View File

@@ -0,0 +1,34 @@
"""Authentication endpoints: login, register, whoami."""
from fastapi import APIRouter, Depends, HTTPException, status
from .models import UserCreate, UserLogin, Token, UserOut
from .service import authenticate_user, create_access_token, create_user
from .dependencies import get_current_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=Token)
async def login(body: UserLogin):
if not authenticate_user(body.username, body.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
)
token = create_access_token({"sub": body.username})
return Token(access_token=token)
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(body: UserCreate):
if not create_user(body.username, body.password):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Username already exists",
)
return UserOut(username=body.username)
@router.get("/me", response_model=UserOut)
async def me(username: str = Depends(get_current_user)):
return UserOut(username=username)

66
backend/auth/service.py Normal file
View File

@@ -0,0 +1,66 @@
"""JWT token creation and password hashing utilities."""
import os
import logging
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
logger = logging.getLogger("cbpoa.auth")
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "cbpoa-dev-secret-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "480"))
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# In-memory user store (replace with DB table when auth matures)
_users: dict[str, str] = {}
def seed_default_admin() -> None:
"""Create default admin user if no users exist."""
if not _users:
default_user = os.getenv("AUTH_DEFAULT_USER", "admin")
default_pass = os.getenv("AUTH_DEFAULT_PASSWORD", "admin123")
_users[default_user] = pwd_context.hash(default_pass)
logger.info("Seeded default user '%s'", default_user)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def authenticate_user(username: str, password: str) -> bool:
hashed = _users.get(username)
if not hashed:
return False
return verify_password(password, hashed)
def create_user(username: str, password: str) -> bool:
"""Register a new user. Returns False if username already exists."""
if username in _users:
return False
_users[username] = hash_password(password)
logger.info("Registered new user '%s'", username)
return True
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None

80
backend/config.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Centralized configuration and named constants for CBPOA backend.
Eliminates magic numbers scattered across routers.
"""
from pathlib import Path
# ============================================================================
# Paths
# ============================================================================
PROJECT_ROOT = Path(__file__).parent.parent
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
PRECOMPUTED_GRID_PATH = PROJECT_ROOT / "outputs" / "grid_risk_summary.csv"
# ============================================================================
# Wuhan Geographic Bounds
# ============================================================================
WUHAN_BOUNDS = {
"min_lon": 113.702281,
"max_lon": 115.082378,
"min_lat": 29.969132,
"max_lat": 31.361260,
}
# 100m grid step in degrees (at Wuhan center latitude ~30.66)
LAT_STEP = 0.0009
LON_STEP = 0.001046
# ============================================================================
# Risk Thresholds
# ============================================================================
RISK_HIGH = 0.8
RISK_MEDIUM_HIGH = 0.6
RISK_MEDIUM = 0.4
RISK_MEDIUM_LOW = 0.2
# ============================================================================
# LOD Configuration
# ============================================================================
LOD_GRID_DIMS = {
"lod1": {"lat_count": 100, "lon_count": 150},
"lod2": {"lat_count": 250, "lon_count": 350},
"lod3": {"lat_count": 1400, "lon_count": 2000},
}
LOD_CONFIG = {
"lod1": {"zoom_range": (1, 9), "aggregate": 200, "name": "coarse"},
"lod2": {"zoom_range": (10, 13), "aggregate": 50, "name": "medium"},
"lod3": {"zoom_range": (14, 20), "aggregate": 1, "name": "fine"},
}
# Max radius for KDTree neighbor lookup (degrees, ~5km)
LOD_MAX_RADIUS = 0.05
# ============================================================================
# Alert Thresholds
# ============================================================================
ALERT_P1_RISK = 0.8
ALERT_P2_RISK = 0.6
ALERT_RISK_7D_WEIGHT = 0.5
MAX_ALERTS = 2000
# ============================================================================
# Trend Analysis
# ============================================================================
TREND_SLOPE_THRESHOLD = 0.05
# ============================================================================
# Date Format
# ============================================================================
DATE_FORMAT_GEOJSON = "%Y%m%d"
DATE_FORMAT_ISO = "%Y-%m-%d"

94
backend/database.py Normal file
View File

@@ -0,0 +1,94 @@
"""
Database connection and session management for PostGIS
"""
import logging
import asyncpg
from typing import Optional
from contextlib import asynccontextmanager
from pydantic_settings import BaseSettings
logger = logging.getLogger("cbpoa.database")
class Settings(BaseSettings):
"""Database settings from environment variables"""
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432
POSTGRES_USER: str = ""
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
class Config:
env_file = ".env"
settings = Settings()
if not settings.POSTGRES_USER or not settings.POSTGRES_PASSWORD or not settings.POSTGRES_DB:
raise RuntimeError(
"Missing required database environment variables: POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB. "
"Create a .env file or set them in your environment."
)
class Database:
"""Async database connection pool manager"""
def __init__(self):
self.pool: Optional[asyncpg.Pool] = None
async def connect(self):
"""Initialize database connection pool"""
if self.pool is None:
dsn = f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
self.pool = await asyncpg.create_pool(
dsn=dsn,
min_size=5,
max_size=20,
command_timeout=60
)
logger.info("Database connection pool created successfully")
async def disconnect(self):
"""Close database connection pool"""
if self.pool:
await self.pool.close()
self.pool = None
logger.info("Database connection pool closed")
@asynccontextmanager
async def get_connection(self):
"""Get a connection from the pool"""
if self.pool is None:
await self.connect()
async with self.pool.acquire() as connection:
yield connection
@asynccontextmanager
async def get_transaction(self):
"""Get a transaction context"""
if self.pool is None:
await self.connect()
async with self.pool.acquire() as connection:
async with connection.transaction():
yield connection
# Global database instance
db = Database()
async def init_db():
"""Initialize database on startup - graceful degradation if unavailable"""
try:
await db.connect()
except Exception as e:
logger.warning("Database not available (%s). Running in demo mode.", e)
logger.warning("Set POSTGRES_HOST/POSTGRES_USER/POSTGRES_PASSWORD environment variables for database access.")
async def close_db():
"""Close database on shutdown"""
await db.disconnect()

59
backend/logging_config.py Normal file
View File

@@ -0,0 +1,59 @@
"""
Structured logging configuration for CBPOA backend.
- LOG_LEVEL: DEBUG, INFO, WARNING, ERROR, CRITICAL (default INFO)
- LOG_FORMAT: "json" for production, "text" for human-readable dev output (default text)
"""
import logging
import json
import sys
import os
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
"""Emit structured JSON log lines for production."""
def format(self, record: logging.LogRecord) -> str:
log_entry = {
"timestamp": datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if record.exc_info and record.exc_info[1]:
log_entry["exception"] = self.formatException(record.exc_info)
# Include extra fields (request_id, method, path, etc.)
for key in ("request_id", "method", "path", "status_code", "duration_ms"):
val = getattr(record, key, None)
if val is not None:
log_entry[key] = val
return json.dumps(log_entry, ensure_ascii=False)
def setup_logging() -> None:
"""Configure root logger based on environment variables."""
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
log_format = os.getenv("LOG_FORMAT", "text").lower()
handler = logging.StreamHandler(sys.stdout)
if log_format == "json":
handler.setFormatter(JSONFormatter())
else:
handler.setFormatter(
logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
# Quiet noisy third-party loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)

75
backend/main.py Normal file
View File

@@ -0,0 +1,75 @@
"""
FastAPI application entry point with CORS configuration
"""
import logging
import os
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from logging_config import setup_logging
from middleware.request_logger import RequestLoggerMiddleware
from auth.router import router as auth_router
from auth.service import seed_default_admin
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid
setup_logging()
seed_default_admin()
app = FastAPI(
title="CBPOA Risk Assessment API",
description="API for CBPOA health risk assessment and alert management",
version="1.0.0",
)
app.add_middleware(RequestLoggerMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=1000)
logger = logging.getLogger("cbpoa.main")
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(risk.router)
app.include_router(alerts.router)
app.include_router(analysis.router)
app.include_router(insights.router)
app.include_router(reports.router)
app.include_router(cases.router)
app.include_router(geocoded.router)
app.include_router(grid.router)
@app.get("/")
async def root():
"""Root endpoint - API health check"""
return {
"message": "CBPOA Risk Assessment API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
async def health_check():
"""Health check endpoint for monitoring"""
return {"status": "healthy"}

View File

View File

@@ -0,0 +1,39 @@
"""
FastAPI middleware that logs method, path, status code, and duration for every request.
"""
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
import logging
logger = logging.getLogger("cbpoa.request")
class RequestLoggerMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
request_id = str(uuid.uuid4())
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration_ms = round((time.perf_counter() - start) * 1000, 2)
logger.info(
"%s %s -> %s (%.2fms)",
request.method,
request.url.path,
response.status_code,
duration_ms,
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": duration_ms,
},
)
return response

315
backend/models.py Normal file
View File

@@ -0,0 +1,315 @@
"""
Pydantic models for CBPOA risk assessment API
Aligned with frontend types from CBPOA/frontend/src/types/index.ts
"""
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from datetime import datetime
class GridRisk(BaseModel):
"""Grid risk data for map visualization"""
grid_id: str = Field(..., description="Grid identifier")
latitude: float = Field(..., description="Latitude coordinate")
longitude: float = Field(..., description="Longitude coordinate")
risk_value: float = Field(..., description="Risk value (0-1)")
risk_level: Literal['high', 'medium_high', 'medium', 'medium_low', 'low'] = Field(..., description="Risk level classification")
class GridDetail(GridRisk):
"""Detailed grid information with environmental factors"""
region: str = Field(..., description="Administrative region")
street: str = Field(..., description="Street name")
population_density: float = Field(..., description="Population density per km²")
nearby_schools: int = Field(..., description="Number of nearby schools")
nearby_schools_distance: float = Field(..., description="Distance to nearest school (km)")
nearby_hospitals: int = Field(..., description="Number of nearby hospitals")
nearby_hospitals_distance: float = Field(..., description="Distance to nearest hospital (km)")
traffic_flow: str = Field(..., description="Traffic flow level")
green_coverage: float = Field(..., description="Green coverage percentage")
building_density: float = Field(..., description="Building density percentage")
air_quality: str = Field(..., description="Air quality description")
humidity: float = Field(..., description="Humidity percentage")
wind_speed: float = Field(..., description="Wind speed (m/s)")
temperature: float = Field(..., description="Temperature (°C)")
trend: str = Field(..., description="Risk trend")
forecast_1day: float = Field(..., description="1-day forecast risk value")
forecast_3day: float = Field(..., description="3-day forecast risk value")
forecast_7day: float = Field(..., description="7-day forecast risk value")
timestamp: str = Field(..., description="Data timestamp")
class RiskMapResponse(BaseModel):
"""Response for risk map data"""
grids: List[GridRisk] = Field(..., description="List of grid risk data")
total_count: int = Field(..., description="Total number of grids")
timestamp: str = Field(..., description="Response timestamp")
class GridDetailResponse(BaseModel):
"""Response for grid detail with history"""
grid: GridDetail = Field(..., description="Grid detail information")
history_risk: List[dict[str, str | float]] = Field(..., description="Historical risk data")
class Alert(BaseModel):
"""Health alert for high-risk area"""
alert_id: str = Field(..., description="Alert identifier")
grid_id: str = Field(..., description="Grid identifier")
region: str = Field(..., description="Administrative region")
street: str = Field(..., description="Street name")
latitude: float = Field(..., description="Latitude coordinate")
longitude: float = Field(..., description="Longitude coordinate")
risk_value: float = Field(..., description="Risk value")
risk_level: Literal['high', 'medium_high', 'medium', 'medium_low', 'low'] = Field(..., description="Risk level")
priority: Literal['P1', 'P2'] = Field(..., description="Alert priority")
reason: str = Field(..., description="Alert reason")
timestamp: str = Field(..., description="Alert timestamp")
forecast_time: str = Field(..., description="Forecast time")
class AlertResponse(BaseModel):
"""Response for alerts list"""
alerts: List[Alert] = Field(..., description="List of alerts")
total: int = Field(..., description="Total number of alerts")
timestamp: str = Field(..., description="Response timestamp")
class Stats(BaseModel):
"""Risk statistics summary"""
total_grids: int = Field(..., description="Total number of grids")
avg_risk: float = Field(..., description="Average risk value")
distribution: dict[str, int] = Field(..., description="Risk level distribution")
high_risk_count: int = Field(..., description="Count of high risk grids")
timestamp: str = Field(..., description="Stats timestamp")
class HistoryPoint(BaseModel):
"""Single point in risk history"""
date: str = Field(..., description="Date string")
risk_value: float = Field(..., description="Risk value")
class RiskHistoryResponse(BaseModel):
"""Response for risk history"""
grid_id: str = Field(..., description="Grid identifier")
history: List[HistoryPoint] = Field(..., description="Historical risk data")
ForecastDay = Literal[0, 1, 3, 7]
# ============================================================================
# Insights Models
# ============================================================================
class InsightTrendItem(BaseModel):
"""Single trend data point for insights"""
date: str = Field(..., description="Date string")
value: float = Field(..., description="Risk value")
change: float = Field(default=0, description="Change from previous day")
class InsightTrend(BaseModel):
"""Trend analysis for insights"""
period: str = Field(..., description="Time period (e.g., '7d', '30d')")
data: List[InsightTrendItem] = Field(..., description="Trend data points")
direction: Literal["up", "down", "stable"] = Field(..., description="Overall trend direction")
avg_change: float = Field(..., description="Average daily change percentage")
class InsightHotspot(BaseModel):
"""Hotspot area for insights"""
grid_id: str = Field(..., description="Grid identifier")
latitude: float = Field(..., description="Latitude coordinate")
longitude: float = Field(..., description="Longitude coordinate")
risk_value: float = Field(..., description="Current risk value")
risk_level: Literal['high', 'medium_high', 'medium', 'medium_low', 'low'] = Field(..., description="Risk level")
region: str = Field(..., description="Administrative region")
street: str = Field(..., description="Street name")
population_density: float = Field(..., description="Population density")
days_in_high_risk: int = Field(..., description="Consecutive days in high risk")
class InsightCorrelation(BaseModel):
"""Correlation factor for insights"""
factor: str = Field(..., description="Factor name (e.g., 'temperature', 'PM2.5')")
correlation: float = Field(..., description="Correlation coefficient (-1 to 1)")
significance: Literal["high", "medium", "low"] = Field(..., description="Statistical significance")
description: str = Field(..., description="Factor description")
impact: Literal["positive", "negative", "neutral"] = Field(..., description="Impact direction")
class InsightDemographic(BaseModel):
"""Demographic breakdown for insights"""
age_group: str = Field(..., description="Age group (e.g., '0-14', '15-64', '65+')")
case_count: int = Field(..., description="Number of cases")
percentage: float = Field(..., description="Percentage of total cases")
risk_ratio: float = Field(..., description="Risk ratio compared to baseline")
class InsightsResponse(BaseModel):
"""Response for comprehensive insights"""
trend: InsightTrend = Field(..., description="Risk trend analysis")
hotspots: List[InsightHotspot] = Field(..., description="Top hotspot areas")
correlations: List[InsightCorrelation] = Field(..., description="Key correlation factors")
demographics: List[InsightDemographic] = Field(..., description="Demographic breakdown")
summary: str = Field(..., description="AI-generated summary of insights")
timestamp: str = Field(..., description="Response timestamp")
# ============================================================================
# Reports Models
# ============================================================================
class ReportSection(BaseModel):
"""Single section of a report"""
title: str = Field(..., description="Section title")
content: str = Field(..., description="Section content")
charts: List[str] = Field(default=[], description="Chart identifiers for this section")
class ReportMetadata(BaseModel):
"""Metadata for a report"""
report_id: str = Field(..., description="Report identifier")
title: str = Field(..., description="Report title")
type: Literal["daily", "weekly", "monthly", "custom"] = Field(..., description="Report type")
generated_at: str = Field(..., description="Generation timestamp")
period_start: str = Field(..., description="Report period start date")
period_end: str = Field(..., description="Report period end date")
author: str = Field(default="CBPOA System", description="Report author")
class ReportSummary(BaseModel):
"""Summary statistics for a report"""
total_cases: int = Field(..., description="Total cases in period")
avg_risk: float = Field(..., description="Average risk level")
peak_risk_date: str = Field(..., description="Date of peak risk")
peak_risk_value: float = Field(..., description="Peak risk value")
high_risk_areas: int = Field(..., description="Number of high risk areas")
trend_direction: Literal["improving", "stable", "worsening"] = Field(..., description="Overall trend")
class ReportRecommendation(BaseModel):
"""Recommendation from report"""
priority: Literal["high", "medium", "low"] = Field(..., description="Recommendation priority")
category: Literal["prevention", "monitoring", "intervention", "resource_allocation"] = Field(..., description="Recommendation category")
title: str = Field(..., description="Recommendation title")
description: str = Field(..., description="Detailed recommendation")
target_areas: List[str] = Field(default=[], description="Target grid IDs or regions")
class ReportResponse(BaseModel):
"""Response for full report"""
metadata: ReportMetadata = Field(..., description="Report metadata")
summary: ReportSummary = Field(..., description="Report summary")
sections: List[ReportSection] = Field(..., description="Report sections")
recommendations: List[ReportRecommendation] = Field(..., description="Recommendations")
attachments: List[str] = Field(default=[], description="Attachment file paths")
timestamp: str = Field(..., description="Response timestamp")
class ReportListResponse(BaseModel):
"""Response for list of reports"""
reports: List[ReportMetadata] = Field(..., description="List of report metadata")
total: int = Field(..., description="Total number of reports")
timestamp: str = Field(..., description="Response timestamp")
# ============================================================================
# Grid Data Models (Wave 2 - Task 9)
# ============================================================================
class GridFeature(BaseModel):
"""Single grid cell with features for model input"""
grid_id: str = Field(..., description="Grid identifier (e.g., 'r100_c200')")
latitude: float = Field(..., description="Center latitude")
longitude: float = Field(..., description="Center longitude")
dem: float = Field(..., description="Digital elevation model (meters)")
population_density: float = Field(..., description="Population density per km²")
district: Optional[str] = Field(None, description="District name")
class WeatherFeature(BaseModel):
"""Weather features for a grid cell"""
grid_id: str
AQI: float
PM25: float
PM10: float
SO2: float
NO2: float
O3: float
CO: float
class CaseFeature(BaseModel):
"""Case features for a grid cell"""
grid_id: str
outpatient_count: int = Field(default=0, description="Outpatient count")
inpatient_count: int = Field(default=0, description="Inpatient count")
total_cases: int = Field(default=0, description="Total case count")
class GridPrediction(BaseModel):
"""Prediction result for a single grid cell"""
grid_id: str
latitude: float
longitude: float
risk_1day: float = Field(..., description="1-day risk prediction (0-1)")
risk_3day: float = Field(..., description="3-day risk prediction (0-1)")
risk_7day: float = Field(..., description="7-day risk prediction (0-1)")
risk_level: Literal['high', 'medium_high', 'medium', 'medium_low', 'low']
confidence: Optional[float] = Field(None, description="Prediction confidence")
class MultiDayPredictionRequest(BaseModel):
"""Request for multi-day grid predictions"""
date: str = Field(..., description="Start date (YYYY-MM-DD)")
days: int = Field(default=7, ge=1, le=14, description="Number of days to predict")
district: Optional[str] = Field(None, description="Filter by district")
class MultiDayPredictionResponse(BaseModel):
"""Response for multi-day grid predictions"""
predictions: List[GridPrediction] = Field(..., description="Grid predictions")
total_grids: int = Field(..., description="Total grids predicted")
date_range: tuple[str, str] = Field(..., description="Prediction date range")
model_version: str = Field(default="1.3.7", description="Model version")
timestamp: str = Field(..., description="Response timestamp")
partial: bool = Field(default=False, description="True if some dates failed to generate")
warnings: List[str] = Field(default_factory=list, description="Warnings from partial failures")
class HistoricalAggregationRequest(BaseModel):
"""Request for historical data aggregation"""
start_date: str = Field(..., description="Start date (YYYY-MM-DD)")
end_date: str = Field(..., description="End date (YYYY-MM-DD)")
aggregation: Literal['daily', 'weekly', 'monthly'] = Field(default='daily', description="Aggregation level")
district: Optional[str] = Field(None, description="Filter by district")
class DistrictAggregation(BaseModel):
"""Aggregated data for a district"""
district: str
date: str
total_cases: int
outpatient_count: int
inpatient_count: int
avg_AQI: float
avg_PM25: float
avg_PM10: float
class HistoricalAggregationResponse(BaseModel):
"""Response for historical data aggregation"""
aggregations: List[DistrictAggregation] = Field(..., description="Aggregated data")
total_records: int = Field(..., description="Total records")
date_range: tuple[str, str] = Field(..., description="Data date range")
timestamp: str = Field(..., description="Response timestamp")
class GridGeoJSONResponse(BaseModel):
"""Response for grid data as GeoJSON"""
type: Literal['FeatureCollection'] = 'FeatureCollection'
features: List[dict] = Field(..., description="GeoJSON features")
timestamp: str = Field(..., description="Response timestamp")

17
backend/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
asyncpg==0.29.0
asyncpg-stubs==0.29.0
geoalchemy2==0.14.3
shapely==2.0.2
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
scipy>=1.11.0
pandas>=2.0.0
numpy>=1.24.0
pyarrow>=14.0.0
openpyxl>=3.1.0

View File

200
backend/routers/alerts.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
)

View File

@@ -0,0 +1 @@
"""Shared utility modules for CBPOA backend."""

View File

@@ -0,0 +1,50 @@
"""
Date utilities: finding latest dates from GeoJSON files, parsing date strings.
"""
import glob
import re
from pathlib import Path
from fastapi import HTTPException
from config import DATA_DIR, DATE_FORMAT_GEOJSON
def get_latest_date() -> str:
"""Get latest available date from GeoJSON files in DATA_DIR."""
pattern = str(DATA_DIR / "risk_*.geojson")
files = glob.glob(pattern)
if not files:
raise HTTPException(status_code=500, detail="No risk data files found")
dates = []
for f in files:
match = re.search(r"risk_(\d{8})\.geojson", f)
if match:
dates.append(match.group(1))
if not dates:
raise HTTPException(status_code=500, detail="No valid risk data files found")
return max(dates)
def get_available_dates(days: int = 30) -> list[str]:
"""Get list of available dates, most recent first."""
pattern = str(DATA_DIR / "risk_*.geojson")
files = glob.glob(pattern)
dates: list[str] = []
for f in files:
match = re.search(r"risk_(\d{8})\.geojson", f)
if match:
dates.append(match.group(1))
dates.sort(reverse=True)
return dates[:days]
def validate_date_format(date: str) -> bool:
"""Check if date string matches YYYYMMDD format."""
import re
return bool(re.compile(r"^\d{8}$").match(date))

43
backend/utils/geo.py Normal file
View File

@@ -0,0 +1,43 @@
"""
Geographic utilities: point-in-polygon testing via ray casting.
"""
def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
"""Check if a point is inside a polygon (supports Polygon and MultiPolygon)."""
if not polygon_coords:
return False
# MultiPolygon: check each polygon
if isinstance(polygon_coords[0], list) and isinstance(polygon_coords[0][0], list):
for polygon in polygon_coords:
if polygon and isinstance(polygon[0], list):
ring = polygon[0] if isinstance(polygon[0][0], list) else polygon
if point_in_ring(lat, lon, ring):
return True
return False
# Single Polygon: use first ring (outer boundary)
ring = polygon_coords[0] if isinstance(polygon_coords[0], list) else polygon_coords
return point_in_ring(lat, lon, ring)
def point_in_ring(lat: float, lon: float, ring: list) -> bool:
"""Ray casting algorithm for point-in-ring test."""
n = len(ring)
inside = False
x, y = lon, lat
p1x, p1y = ring[0]
for i in range(1, n + 1):
p2x, p2y = ring[i % n]
if y > min(p1y, p2y):
if y <= max(p1y, p2y):
if x <= max(p1x, p2x):
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) if p1y != p2y else p1x
if p1x == p2x or x <= xinters:
inside = not inside
p1x, p1y = p2x, p2y
return inside

53
backend/utils/geojson.py Normal file
View File

@@ -0,0 +1,53 @@
"""
GeoJSON file parsing utilities.
"""
import json
from pathlib import Path
from typing import Any
from config import WUHAN_BOUNDARY_PATH
from utils.risk import risk_value_to_level
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
"""Parse GeoJSON file and extract grid data with standard fields."""
with open(filepath, "r", encoding="utf-8") as f:
geojson = json.load(f)
grids: list[dict[str, Any]] = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
coords = feature.get("geometry", {}).get("coordinates", [0, 0])
risk_1d = 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_1d,
"risk_3d": props.get("risk_3d", 0),
"risk_7d": props.get("risk_7d", 0),
"risk_level": risk_value_to_level(risk_1d),
})
return grids
def load_districts() -> list[dict[str, Any]]:
"""Load Wuhan district boundaries from GeoJSON."""
if not WUHAN_BOUNDARY_PATH.exists():
return []
with open(WUHAN_BOUNDARY_PATH, "r", encoding="utf-8") as f:
geojson = json.load(f)
districts = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
districts.append({
"name": props.get("name", ""),
"adcode": props.get("adcode", ""),
"coordinates": feature.get("geometry", {}).get("coordinates", []),
})
return districts

56
backend/utils/risk.py Normal file
View File

@@ -0,0 +1,56 @@
"""
Risk level classification and trend calculation utilities.
"""
from typing import Literal
from config import (
RISK_HIGH,
RISK_MEDIUM_HIGH,
RISK_MEDIUM,
RISK_MEDIUM_LOW,
TREND_SLOPE_THRESHOLD,
)
def risk_value_to_level(risk_value: float) -> str:
"""Convert risk value (0-1) to risk level string."""
if risk_value >= RISK_HIGH:
return "high"
elif risk_value >= RISK_MEDIUM_HIGH:
return "medium_high"
elif risk_value >= RISK_MEDIUM:
return "medium"
elif risk_value >= RISK_MEDIUM_LOW:
return "medium_low"
else:
return "low"
def calculate_trend(values: list[float]) -> Literal["up", "down", "stable"]:
"""Calculate trend direction from a series of values using linear regression slope."""
if len(values) < 2:
return "stable"
n = len(values)
x_mean = (n - 1) / 2
y_mean = sum(values) / n
numerator = sum((i - x_mean) * (values[i] - y_mean) for i in range(n))
denominator = sum((i - x_mean) ** 2 for i in range(n))
if denominator == 0:
return "stable"
slope = numerator / denominator
if y_mean == 0:
return "stable"
relative_slope = slope / y_mean
if relative_slope > TREND_SLOPE_THRESHOLD:
return "up"
elif relative_slope < -TREND_SLOPE_THRESHOLD:
return "down"
else:
return "stable"