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