feat: add analysis pages and raster risk map

Ship a new app version with broader analytics, restructured
dashboards, and a server-rendered risk map.

Frontend:
- Add Overview, Demographic, Disease, and Environmental Health
  analysis pages
- Add AnomalyMarkers, CalendarHeatmap, and MetricHeatmapTable
  components
- Rebuild Alerts map onto server-rendered raster risk tiles;
  expand Monitoring, Trend, and District Comparison views
- Extend API client, stores, and TypeScript types

Backend:
- Add environment router (pollutants, lag correlations)
- Add risk_raster util serving XYZ 100m risk tiles
- Expand cases endpoints (demographics, seasonality, diagnoses)
  and insights; harden auth and file-based loaders

Data & tooling:
- Add processed outpatient/inpatient/combined case parquet (LFS)
- Add nested CLAUDE.md guides, pyrightconfig, and test updates
This commit is contained in:
2026-06-21 17:35:03 +08:00
parent f092c3c550
commit e95e2f1338
63 changed files with 8534 additions and 988 deletions

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Backend package

42
backend/auth/CLAUDE.md Normal file
View File

@@ -0,0 +1,42 @@
# Auth — JWT Authentication
## Stack
python-jose (JWT signing/verification) + passlib (bcrypt password hashing). Token-based, stateless.
## Structure
```
auth/
models.py # Pydantic models: UserCreate, UserLogin, Token, UserOut
service.py # Business logic: authenticate_user, create_user, create_access_token
dependencies.py # FastAPI Depends: get_current_user, require_admin
middleware.py # ASGI middleware (if any global auth checks)
router.py # APIRouter: /login, /register, /whoami
```
## Patterns
- Passwords hashed with bcrypt via `passlib` — never store plaintext
- JWT tokens signed with `python-jose`, include `sub` (username) and `exp`
- `get_current_user()` is the standard `Depends()` to inject user into endpoints
- Auth endpoints return Pydantic models: `Token(access_token=...)`, `UserOut(username=...)`
- HTTP status codes: 401 for bad credentials, 409 for duplicate user
## Usage in Routers
```python
from auth.dependencies import get_current_user
@router.get("/protected")
async def protected_route(current_user = Depends(get_current_user)):
...
```
## Anti-Patterns
- Don't hardcode secret keys — use `Settings` from environment
- Don't store tokens client-side without HttpOnly cookies
- Don't skip `response_model` on auth endpoints
- Don't leak whether username or password was wrong — always "incorrect username or password"
- Don't bypass `Depends(get_current_user)` for protected routes

View File

@@ -8,7 +8,13 @@ from passlib.context import CryptContext
logger = logging.getLogger("cbpoa.auth")
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", "cbpoa-dev-secret-change-in-production")
_DEFAULT_SECRET = "cbpoa-dev-secret-change-in-production"
SECRET_KEY = os.getenv("AUTH_SECRET_KEY", _DEFAULT_SECRET)
if SECRET_KEY == _DEFAULT_SECRET:
logger.warning(
"AUTH_SECRET_KEY is not set — using the built-in development secret. "
"Set AUTH_SECRET_KEY in the environment before deploying; the default is public and allows token forgery."
)
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("AUTH_TOKEN_EXPIRE_MINUTES", "480"))
@@ -61,6 +67,10 @@ def create_access_token(data: dict) -> str:
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
# python-jose ignores PyJWT's options={"require": [...]}, so enforce exp manually:
# a token with no exp claim would otherwise never expire.
if "exp" not in payload:
return None
return payload
except JWTError:
return None

View File

@@ -3,22 +3,34 @@ Shared data-loading module for case data (outpatient + inpatient).
Extracted from routers/cases.py so both cases and reports routers can use
the same cached data without circular imports.
Performance: reads from pre-generated Parquet files (~0.1s) instead of
Excel (~10s). Falls back to Excel if parquet files are missing.
"""
import re
import logging
import threading
from typing import Optional, cast
import pandas as pd
from pathlib import Path
from datetime import datetime
logger = logging.getLogger("cbpoa.case_loader")
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Data cache
_cache = {
_cache: dict[str, Optional[pd.DataFrame | datetime]] = {
"outpatient": None,
"inpatient": None,
"combined": None,
"loaded_at": None,
}
# Guards the lazy build so concurrent callers don't duplicate the load/concat.
_load_lock = threading.RLock()
# Wuhan district mapping
WUHAN_DISTRICTS = {
'江岸区': ['江岸'],
@@ -40,6 +52,7 @@ WUHAN_DISTRICTS = {
PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "Datas"
PROCESSED_DIR = PROJECT_ROOT / "processed"
def _extract_district(addr: str) -> str:
@@ -54,52 +67,106 @@ def _extract_district(addr: str) -> str:
return '其他'
def _load_from_parquet() -> bool:
"""Try to load data from pre-generated Parquet files. Returns True on success."""
outpatient_path = PROCESSED_DIR / "cases_outpatient.parquet"
inpatient_path = PROCESSED_DIR / "cases_inpatient.parquet"
if not outpatient_path.exists() or not inpatient_path.exists():
logger.info("Parquet files not found, falling back to Excel")
return False
try:
_cache["outpatient"] = pd.read_parquet(outpatient_path)
_cache["inpatient"] = pd.read_parquet(inpatient_path)
_cache["loaded_at"] = datetime.now()
logger.info("Loaded case data from Parquet (%d outpatient, %d inpatient)",
len(_cache["outpatient"]), len(_cache["inpatient"]))
return True
except Exception as e:
logger.warning("Parquet load failed (%s), falling back to Excel", e)
return False
def _load_from_excel():
"""Load data from Excel files (slow fallback)."""
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()
def load_data():
"""Load and cache outpatient + inpatient data from Excel files"""
"""Load and cache outpatient + inpatient data.
Uses pre-generated Parquet files for fast loading (~0.1s).
Falls back to Excel files (~10s) if Parquet is unavailable.
"""
if _cache["loaded_at"] is not None:
return
try:
# Load outpatient data
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
# Load inpatient data
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"Data loading failed: {str(e)}")
with _load_lock:
if _cache["loaded_at"] is not None: # another thread loaded while we waited
return
if not _load_from_parquet():
try:
_load_from_excel()
except Exception as e:
raise RuntimeError(f"Data loading failed: {str(e)}")
def get_combined_data():
"""Return merged outpatient + inpatient data with unified diagnosis column"""
def get_combined_data() -> pd.DataFrame:
"""Return merged outpatient + inpatient data with unified diagnosis column.
Caches the result in memory after first call (~0.1s on cached hit).
"""
if _cache["combined"] is not None:
return cast(pd.DataFrame, _cache["combined"])
with _load_lock:
if _cache["combined"] is not None: # built while we waited for the lock
return cast(pd.DataFrame, _cache["combined"])
load_data()
df_out = cast(pd.DataFrame, _cache["outpatient"])
df_in = cast(pd.DataFrame, _cache["inpatient"])
df_out = df_out[['date', 'district', '初诊', '主诉']].copy()
df_out['type'] = 'outpatient'
df_out['diagnosis'] = df_out['初诊']
df_in = df_in[['date', 'district', '诊断名称']].copy()
df_in['type'] = 'inpatient'
df_in['diagnosis'] = df_in['诊断名称']
df_in['主诉'] = None
_cache["combined"] = pd.concat([df_out, df_in], ignore_index=True) # type: ignore[assignment]
return cast(pd.DataFrame, _cache["combined"])
def get_diagnoses() -> list[str]:
"""Return sorted list of unique diagnosis names (fast: reads from cached DataFrames)."""
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)
out_diag = cast(pd.DataFrame, _cache["outpatient"])['初诊'].dropna().unique()
in_diag = cast(pd.DataFrame, _cache["inpatient"])['诊断名称'].dropna().unique()
return sorted(set(out_diag.tolist() + in_diag.tolist()))
def get_outpatient_data():
def get_outpatient_data() -> pd.DataFrame:
"""Return the cached outpatient dataframe"""
load_data()
return _cache["outpatient"]
return _cache["outpatient"] # type: ignore[return-value]
def get_inpatient_data():
def get_inpatient_data() -> pd.DataFrame:
"""Return the cached inpatient dataframe"""
load_data()
return _cache["inpatient"]
return _cache["inpatient"] # type: ignore[return-value]

View File

@@ -17,7 +17,7 @@ class Settings(BaseSettings):
POSTGRES_USER: str = ""
POSTGRES_PASSWORD: str = ""
POSTGRES_DB: str = ""
class Config:
env_file = ".env"
@@ -33,10 +33,10 @@ if not settings.POSTGRES_USER or not settings.POSTGRES_PASSWORD or not settings.
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:
@@ -48,29 +48,29 @@ class Database:
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()
assert self.pool is not None
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()
assert self.pool is not None
async with self.pool.acquire() as connection:
async with connection.transaction():
yield connection

View File

@@ -13,7 +13,7 @@ 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, chat
from routers import risk, alerts, analysis, insights, reports, cases, geocoded, grid, chat, environment
setup_logging()
@@ -27,7 +27,7 @@ app = FastAPI(
)
app.add_middleware(RequestLoggerMiddleware)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1)
logger = logging.getLogger("cbpoa.main")
@@ -58,6 +58,7 @@ app.include_router(cases.router)
app.include_router(geocoded.router)
app.include_router(grid.router)
app.include_router(chat.router)
app.include_router(environment.router)
@app.get("/")

View File

@@ -2,7 +2,7 @@
Pydantic models for CBPOA risk assessment API
Aligned with frontend types from CBPOA/frontend/src/types/index.ts
"""
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Literal
from datetime import datetime
@@ -280,6 +280,9 @@ class MultiDayPredictionRequest(BaseModel):
class MultiDayPredictionResponse(BaseModel):
"""Response for multi-day grid predictions"""
# `model_version` collides with Pydantic's protected `model_` namespace; opt out.
model_config = ConfigDict(protected_namespaces=())
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")

View File

@@ -16,3 +16,4 @@ pandas>=2.0.0
numpy>=1.24.0
pyarrow>=14.0.0
openpyxl>=3.1.0
Pillow>=10.0.0

47
backend/routers/CLAUDE.md Normal file
View File

@@ -0,0 +1,47 @@
# Routers — API Endpoints
## Pattern
Each router file defines one `APIRouter(prefix=..., tags=[...])` with typed endpoints.
```python
from fastapi import APIRouter
from models import SomeResponse
router = APIRouter(prefix="/api/domain", tags=["domain"])
@router.get("/endpoint", response_model=SomeResponse)
async def get_something(...):
...
```
## Conventions
- Return Pydantic models (`response_model=`), never raw dicts
- Use `Annotated[Type, Query(...)]` / `Path(...)` for request params
- Spatial queries use `scipy.spatial.KDTree` for nearest-neighbor lookups
- GeoJSON parsing is delegated to `utils/geojson.py`
- Risk level mapping is in `utils/risk.py` — use `risk_value_to_level()` not inline thresholds
- Large repeated queries use `@lru_cache` (from `functools`)
- Date helpers from `utils/date_helpers.py` — always `get_latest_date()`, never guess
## File Map
| File | Domain |
|------|--------|
| `risk.py` | Risk maps, grid detail, history (largest router, ~42 file reads) |
| `alerts.py` | Alert feed, stats |
| `cases.py` | Medical case queries (age, disease, district filters) |
| `analysis.py` | Trend analysis, statistics |
| `grid.py` | Grid metadata, elevation, population |
| `reports.py` | Report generation, export |
| `insights.py` | AI-generated insights |
| `chat.py` | Chatbot endpoint |
| `geocoded.py` | Geocoded case data |
## Anti-Patterns
- Don't use sync I/O in `async def` — use `async with db.get_connection()` for DB
- Don't catch bare `Exception` — use specific HTTPException or let it propagate to middleware
- Don't return raw GeoJSON dicts without Pydantic validation
- Don't inline risk thresholds — use `utils/risk.risk_value_to_level()`

View File

@@ -5,6 +5,8 @@ Generates alerts from high-risk grids in GeoJSON files
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime
from typing import List
from functools import lru_cache
import asyncio
import json
from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STEP, LON_STEP, MAX_ALERTS
@@ -32,8 +34,13 @@ def grid_id_to_center(grid_id: str) -> tuple[float, float]:
return lat, lon
def generate_alerts_for_date(date: str) -> List[Alert]:
"""Generate alerts for high-risk grids on a specific date.
@lru_cache(maxsize=8)
def _generate_alerts_cached(date: str) -> List[Alert]:
"""Heavy synchronous worker: parse the ~45MB GeoJSON and build alerts.
Cached by date so the file is parsed once per date. This runs blocking
json.load + per-feature loops, so callers must invoke it off the event
loop (see generate_alerts_for_date).
Phase 1: iterate features, aggregate max risk per 100m grid cell.
Phase 2: build Alert objects from aggregated grid cells.
@@ -111,6 +118,16 @@ def generate_alerts_for_date(date: str) -> List[Alert]:
return alerts[:MAX_ALERTS]
async def generate_alerts_for_date(date: str) -> List[Alert]:
"""Async accessor: run the cached heavy parser in a thread pool.
Offloading the blocking json.load + per-feature aggregation keeps the
event loop free. The lru_cache lives on the worker, so warm dates return
near-instantly without re-parsing.
"""
return await asyncio.to_thread(_generate_alerts_cached, date)
@router.get("", response_model=AlertResponse)
async def list_alerts(date: str | None = None, priority: str | None = None, min_risk: float | None = None):
if date is not None and not validate_date_format(date):
@@ -118,7 +135,7 @@ async def list_alerts(date: str | None = None, priority: str | None = None, min_
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
if priority:
alerts = [a for a in alerts if a.priority == priority]
@@ -140,7 +157,7 @@ async def get_alert(alert_id: str, date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
for alert in alerts:
if alert.alert_id == alert_id:
@@ -156,7 +173,7 @@ async def get_p1_alerts(date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
p1_alerts = [a for a in alerts if a.priority == "P1"]
return AlertResponse(
@@ -173,7 +190,7 @@ async def get_p2_alerts(date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
p2_alerts = [a for a in alerts if a.priority == "P2"]
return AlertResponse(
@@ -190,7 +207,7 @@ async def get_grid_alerts(grid_id: str, date: str | None = None):
if date is None:
date = get_latest_date()
alerts = generate_alerts_for_date(date)
alerts = await generate_alerts_for_date(date)
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
return AlertResponse(

View File

@@ -5,11 +5,13 @@ 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
from functools import lru_cache
import random
import pandas as pd
from pydantic import BaseModel, Field
from config import DATA_DIR, RISK_HIGH
from config import DATA_DIR, RISK_HIGH, PROJECT_ROOT, WUHAN_BOUNDS, LAT_STEP, LON_STEP
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
from utils.geo import point_in_polygon
@@ -27,11 +29,13 @@ class TrendResponse(BaseModel):
class DistrictRisk(BaseModel):
"""District-level risk aggregation"""
name: str = Field(..., description="District name")
district: str = Field(..., description="District name")
avg_risk: float = Field(..., description="Average risk value")
avg_aqi: float = Field(..., description="Average AQI from weather stations in this district")
population: int = Field(..., description="Population (sum of 100m LandScan cells in district)")
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")
total_cases: int = Field(..., description="Total recorded cases (real, from cases_by_district_daily)")
class DistrictsResponse(BaseModel):
@@ -91,12 +95,9 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
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)
# Preserve the full requested date range: a "7天" request must return 7
# contiguous points. Days with no geojson (or empty grids) stay 0 rather
# than being dropped, which previously produced fewer, non-contiguous points.
trend_direction = calculate_trend(values)
return TrendResponse(
@@ -106,101 +107,153 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
)
@lru_cache(maxsize=1)
def _grid_district_lookup() -> dict:
"""Map precomputed r{row}_c{col} grid id -> district name (loaded once)."""
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path)
# Some grids have a null district_name; drop them so the lookup only ever
# returns valid strings (missing keys fall back to "其他").
df = df.dropna(subset=["district_name"])
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
@lru_cache(maxsize=1)
def _district_population() -> dict:
"""Real population per district.
Sums the LandScan-derived population_density of every 100m cell
(grid_100m_with_dem_pop.parquet) grouped by district via the
grid->district mapping. Returns {district_name: total_population}.
"""
pop_path = PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet"
map_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
if not pop_path.exists() or not map_path.exists():
return {}
pop = pd.read_parquet(pop_path, columns=["grid_id", "population_density"])
mapping = pd.read_parquet(map_path).dropna(subset=["district_name"])
joined = pop.merge(mapping, on="grid_id", how="inner")
by_d = joined.groupby("district_name")["population_density"].sum()
return {str(k): int(round(v)) for k, v in by_d.items()}
@lru_cache(maxsize=1)
def _district_avg_aqi() -> dict:
"""Real average AQI per district from weather station daily data.
Each station (with lat/lon) is assigned to a district using the same
grid->district mapping (100m grid spacing of 1/1110 deg, the convention
the mapping was built with), then AQI is averaged per district across
all daily observations. Returns {district_name: avg_aqi}. Districts with
no station fall back to the city-wide mean in the caller.
"""
map_path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
station_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
if not map_path.exists() or not station_path.exists():
return {}
mapping = pd.read_parquet(map_path).dropna(subset=["district_name"])
lookup = dict(zip(mapping["grid_id"].astype(str), mapping["district_name"].astype(str)))
station = pd.read_parquet(station_path, columns=["station_id", "lat", "lon", "AQI"])
step = 1.0 / 1110.0 # mapping grid spacing in degrees
min_lat = WUHAN_BOUNDS["min_lat"]
min_lon = WUHAN_BOUNDS["min_lon"]
coords = station[["station_id", "lat", "lon"]].drop_duplicates()
station_to_district = {}
for _, r in coords.iterrows():
row = int((r["lat"] - min_lat) / step)
col = int((r["lon"] - min_lon) / step)
station_to_district[r["station_id"]] = lookup.get(f"r{row}_c{col}", "其他")
station = station.copy()
station["district"] = station["station_id"].map(station_to_district)
in_district = station[station["district"] != "其他"]
by_d = in_district.groupby("district")["AQI"].mean()
return {str(k): round(float(v), 1) for k, v in by_d.items()}
@lru_cache(maxsize=1)
def _district_total_cases() -> dict:
"""Real total recorded cases per district from cases_by_district_daily.
District labels in the case file are inconsistent ("武昌" vs "武昌区"),
so names are normalized by stripping the "" suffix and summed, then
keyed by the canonical mapping name (with ""). Returns {district: cases}.
"""
path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if not path.exists():
return {}
df = pd.read_parquet(path, columns=["district", "total_cases"])
df = df.copy()
df["base"] = df["district"].str.replace("", "", regex=False)
by_base = df.groupby("base")["total_cases"].sum()
return {f"{base}": int(v) for base, v in by_base.items()}
@lru_cache(maxsize=8)
def _aggregate_districts(date: str) -> list:
"""Aggregate per-district risk for a date.
Assigns each 100m risk grid to a district via the precomputed
grid->district mapping (O(1) dict lookup per grid) instead of per-grid
point-in-polygon (which is ~100x slower over 140k grids). Cached by date.
"""
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
lookup = _grid_district_lookup()
agg: dict = {}
for g in grids:
row = int((g["latitude"] - WUHAN_BOUNDS["min_lat"]) / LAT_STEP)
col = int((g["longitude"] - WUHAN_BOUNDS["min_lon"]) / LON_STEP)
name = lookup.get(f"r{row}_c{col}", "其他")
a = agg.setdefault(name, {"sum": 0.0, "count": 0, "high": 0})
risk = g["risk_value"]
a["sum"] += risk
a["count"] += 1
if risk >= RISK_HIGH:
a["high"] += 1
pop_by_district = _district_population()
aqi_by_district = _district_avg_aqi()
cases_by_district = _district_total_cases()
# City-wide mean AQI as fallback for districts without a weather station.
city_avg_aqi = round(sum(aqi_by_district.values()) / len(aqi_by_district), 1) if aqi_by_district else 0.0
result = []
for name, a in agg.items():
if a["count"] == 0:
continue
avg = a["sum"] / a["count"]
result.append({
"district": name,
"avg_risk": round(avg, 4),
"avg_aqi": aqi_by_district.get(name, city_avg_aqi),
"population": pop_by_district.get(name, 0),
"high_risk_count": a["high"],
"total_grids": a["count"],
"total_cases": cases_by_district.get(name, 0),
})
# '其他' (unassigned) last, otherwise by descending risk
result.sort(key=lambda d: (d["district"] == "其他", -d["avg_risk"]))
return result
@router.get("/districts", response_model=DistrictsResponse)
async def get_districts():
"""
Get district-level risk aggregation
Returns:
District-level risk data with averages and counts
"""
"""Get district-level risk aggregation (cached per date)."""
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_value"] for g in grids) / len(grids) if grids else 0
high_risk_count = sum(1 for g in grids if g["risk_value"] >= 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_value"] >= RISK_HIGH:
district_data[district["name"]]["high_risk"] += 1
assigned = True
break
if not assigned:
unassigned["grids"].append(grid)
if grid["risk_value"] >= 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_value"] 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_value"] 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)
)
)
districts = [DistrictRisk(**d) for d in _aggregate_districts(latest_date)]
return DistrictsResponse(
districts=result,
timestamp=datetime.now().isoformat()
districts=districts,
timestamp=datetime.now().isoformat(),
)

View File

@@ -8,10 +8,11 @@ from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from datetime import datetime, date
import asyncio
import pandas as pd
import json
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, WUHAN_DISTRICTS, DATE_PATTERN
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, get_diagnoses, WUHAN_DISTRICTS, DATE_PATTERN
router = APIRouter(prefix="/api/cases", tags=["cases"])
@@ -90,37 +91,38 @@ async def get_cases_stats(
if diagnosis:
df_out = df_out[df_out['初诊'].str.contains(diagnosis, na=False, case=False)]
df_in = df_in[df_in['诊断名称'].str.contains(diagnosis, na=False, case=False)]
# 计算统计
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())
# 日期范围(过滤后可能为空,需防御 NaT
all_dates = pd.concat([df_out['date'], df_in['date']]).dropna()
date_start = all_dates.min().strftime("%Y-%m-%d") if len(all_dates) else ""
date_end = all_dates.max().strftime("%Y-%m-%d") if len(all_dates) else ""
# 区域统计
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")
"start": date_start,
"end": date_end
},
top_districts=top_districts,
top_diagnoses=top_diagnoses
@@ -146,7 +148,7 @@ async def get_cases_trend(
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()
df = get_combined_data().copy()
# 日期过滤
if start_date:
@@ -157,7 +159,7 @@ async def get_cases_trend(
# 诊断过滤
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 分组
if group_by == "week":
df['period'] = df['date'].dt.to_period('W').dt.start_time
@@ -165,13 +167,13 @@ async def get_cases_trend(
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:
@@ -180,12 +182,12 @@ async def get_cases_trend(
total_out += out_count
total_in += in_count
trend.append(TrendPoint(
date=pd.Timestamp(p).strftime("%Y-%m-%d"),
date=str(p).split(' ')[0] if hasattr(p, 'strftime') else str(p)[:10],
outpatient=out_count,
inpatient=in_count,
total=out_count + in_count
))
return TrendResponse(
trend=trend,
summary={
@@ -198,69 +200,93 @@ async def get_cases_trend(
)
def _compute_cases_districts(
case_type: Optional[str],
min_count: int,
diagnosis: Optional[str],
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> DistrictsResponse:
"""Run the full pandas aggregation pipeline (called in thread pool)."""
df = get_combined_data()
# 诊断过滤
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 日期过滤
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 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=str(district),
outpatient=int(row['outpatient']),
inpatient=int(row['inpatient']),
total=int(row['total']),
outpatient_ratio=round(float(row['outpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0,
inpatient_ratio=round(float(row['inpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0
))
return DistrictsResponse(districts=districts, total=total)
@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="最小病例数过滤"),
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
):
"""
获取病例区域分布数据
- 支持按病例类型筛选
- 可设置最小病例数过滤
- 支持日期范围过滤
- 返回各区门诊、住院量及占比
"""
df = get_combined_data()
# 诊断过滤
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# 类型过滤
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)
Pandas processing runs in a thread pool to avoid blocking the async event loop.
"""
return await asyncio.to_thread(
_compute_cases_districts, case_type, min_count, diagnosis, start_date, end_date
)
@router.get("/realtime", response_model=RealtimeData, summary="获取实时数据")
async def get_cases_realtime():
"""
获取实时病例数据
- 今日就诊量
- 近 7 日平均值
- 变化率
@@ -270,23 +296,23 @@ async def get_cases_realtime():
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 = "偏高"
@@ -294,12 +320,12 @@ async def get_cases_realtime():
status = "偏低"
else:
status = "正常"
return RealtimeData(
today_outpatient=today_out,
today_inpatient=today_in,
today_total=today_total,
last_7d_avg=last_7d_avg,
last_7d_avg=int(last_7d_avg),
change_ratio=change_ratio,
status=status
)
@@ -311,8 +337,323 @@ class DiagnosesResponse(BaseModel):
@router.get("/diagnoses", response_model=DiagnosesResponse, summary="获取所有诊断名称列表")
async def get_diagnoses():
"""Returns deduplicated, sorted list of unique diagnosis names"""
df = get_combined_data()
diagnoses = sorted(df['diagnosis'].dropna().unique().tolist())
async def get_diagnoses_list():
"""Returns deduplicated, sorted list of unique diagnosis names (cached, fast)."""
diagnoses = get_diagnoses()
return DiagnosesResponse(diagnoses=diagnoses)
# ============== Seasonal & Distribution Endpoints ==============
class SeasonalPoint(BaseModel):
"""月度聚合数据点"""
month: int # 1-12
month_label: str # "1月", "2月", ...
outpatient: int
inpatient: int
total: int
class SeasonalResponse(BaseModel):
"""月度季节性响应"""
monthly: list[SeasonalPoint]
period_years: list[int] # e.g. [2022, 2023, 2024]
total_cases: int
class DiagnosisDistributionItem(BaseModel):
"""诊断分布数据项"""
diagnosis: str
outpatient: int
inpatient: int
total: int
percentage: float
class DiagnosisDistributionResponse(BaseModel):
"""诊断分布响应"""
diagnoses: list[DiagnosisDistributionItem]
total_cases: int
# ============== Demographics Models ==============
class AgeBin(BaseModel):
"""年龄分段数据"""
age_bin: int # 0-17
outpatient: int
inpatient: int
class GenderSplit(BaseModel):
"""性别拆分数据"""
outpatient: int
inpatient: int
class GenderSplitData(BaseModel):
"""性别分布响应内层"""
male: GenderSplit
female: GenderSplit
class AgeDiagnosisMatrixItem(BaseModel):
"""年龄-诊断矩阵项"""
age_group: str # "0-1", "1-3", "3-6", "6-12", "12-18"
diagnosis: str
outpatient: int
inpatient: int
total: int
class DemographicsResponse(BaseModel):
"""人口统计响应"""
age_distribution: list[AgeBin]
gender_split: GenderSplitData
age_diagnosis_matrix: list[AgeDiagnosisMatrixItem]
# ============== Disease Seasonality Models ==============
class DiseaseSeasonalityPoint(BaseModel):
"""疾病月度季节性数据点"""
diagnosis: str
month: int # 1-12
month_label: str # "1月"-"12月"
outpatient: int
inpatient: int
total: int
class DiseaseDistrictItem(BaseModel):
"""单个诊断的区域分布(按病例数排序的前若干区)"""
diagnosis: str
district: str
total: int
class DiseaseSeasonalityResponse(BaseModel):
"""疾病季节性响应"""
seasonality: list[DiseaseSeasonalityPoint]
diagnoses: list[str]
# 每个诊断的真实区域分布(按区聚合),使前端可为每个诊断显示其各自的"主要区域"
diagnosis_districts: list[DiseaseDistrictItem]
@router.get("/seasonal", response_model=SeasonalResponse, summary="获取季节性月度聚合数据")
async def get_cases_seasonal(
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
):
"""
按月聚合所有年份的病例数据
- 返回 1-12 月各月门诊/住院/总计均值
- 支持诊断过滤
- 用于季节性分解图表
"""
df = get_combined_data()
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# Extract month and aggregate
df = df.copy()
df['month'] = df['date'].dt.month
years = sorted(df['date'].dt.year.unique().tolist())
out_monthly = df[df['type'] == 'outpatient'].groupby('month').size()
in_monthly = df[df['type'] == 'inpatient'].groupby('month').size()
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月']
monthly = []
total_cases = 0
for m in range(1, 13):
out_count = int(out_monthly.get(m, 0))
in_count = int(in_monthly.get(m, 0))
total_cases += out_count + in_count
monthly.append(SeasonalPoint(
month=m,
month_label=month_labels[m - 1],
outpatient=out_count,
inpatient=in_count,
total=out_count + in_count,
))
return SeasonalResponse(
monthly=monthly,
period_years=years,
total_cases=total_cases,
)
@router.get("/diagnosis-distribution", response_model=DiagnosisDistributionResponse, summary="获取诊断分布统计")
async def get_diagnosis_distribution(
limit: int = Query(default=20, ge=1, le=50, description="Maximum diagnoses to return"),
):
"""
获取诊断名称分布统计(门诊+住院分列)
- 返回前 N 个诊断及门诊/住院/总计/占比
- 用于诊断分布饼图、树图等
"""
df = get_combined_data()
# Compute O/I counts per diagnosis
breakdown = df.groupby(['diagnosis', 'type']).size().unstack(fill_value=0)
if 'outpatient' not in breakdown.columns:
breakdown['outpatient'] = 0
if 'inpatient' not in breakdown.columns:
breakdown['inpatient'] = 0
breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient']
breakdown = breakdown.sort_values('total', ascending=False).head(limit)
grand_total = int(breakdown['total'].sum())
diagnoses = []
for diagnosis_name, row in breakdown.iterrows():
diagnoses.append(DiagnosisDistributionItem(
diagnosis=str(diagnosis_name),
outpatient=int(row['outpatient']),
inpatient=int(row['inpatient']),
total=int(row['total']),
percentage=round(float(row['total']) / float(grand_total) * 100, 2) if grand_total > 0 else 0,
))
return DiagnosisDistributionResponse(
diagnoses=diagnoses,
total_cases=grand_total,
)
# ============== Demographics Endpoint ==============
@router.get("/demographics", response_model=DemographicsResponse, summary="获取人口统计信息")
async def get_cases_demographics():
"""
获取病例人口统计信息
- 年龄分布0-17岁按1岁分段仅住院数据
- 性别分布(仅住院数据)
- 年龄-诊断矩阵(按年龄段分组,仅住院数据)
注意:门诊数据不包含人口统计信息(性别/年龄),因此门诊计数均为 0。
"""
df = get_inpatient_data()
df = df.copy()
df['age_bin'] = df['年龄'].clip(0, 17).astype(int)
# --- Age distribution: 1-year bins from 0 to 17 ---
age_counts = df.groupby('age_bin').size()
age_distribution = [
AgeBin(age_bin=a, outpatient=0, inpatient=int(age_counts.get(a, 0)))
for a in range(0, 18)
]
# --- Gender split ---
gender_counts = df['性别'].value_counts()
gender_split = GenderSplitData(
male=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('男性', 0))),
female=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('女性', 0))),
)
# --- Age-diagnosis matrix ---
age_bins = [
(0, 1, "0-1"), (1, 3, "1-3"), (3, 6, "3-6"),
(6, 12, "6-12"), (12, 18, "12-18"),
]
matrix_rows: list[AgeDiagnosisMatrixItem] = []
for low, high, label in age_bins:
group = df[(df['年龄'] >= low) & (df['年龄'] < high)]
for diag, count in group['诊断名称'].value_counts().items():
matrix_rows.append(AgeDiagnosisMatrixItem(
age_group=label, diagnosis=str(diag),
outpatient=0, inpatient=int(count), total=int(count),
))
return DemographicsResponse(
age_distribution=age_distribution,
gender_split=gender_split,
age_diagnosis_matrix=matrix_rows,
)
# ============== Disease Seasonality Endpoint ==============
@router.get("/disease-seasonality", response_model=DiseaseSeasonalityResponse, summary="获取疾病季节性数据")
async def get_disease_seasonality(
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
):
"""
获取各诊断的月度季节性分布数据
- 基于门诊+住院合并数据
- 按月聚合所有年份,返回 top 10 诊断的月度分布
- 支持可选诊断过滤
- 用于疾病季节性热力图、雷达图等
"""
df = get_combined_data()
if diagnosis:
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
# Extract month
df = df.copy()
df['month'] = df['date'].dt.month
# Get top 10 diagnoses by total case count
diag_totals = df.groupby('diagnosis').size().nlargest(10)
top_diagnoses = diag_totals.index.tolist()
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月']
# Filter to top diagnoses
df_top = df[df['diagnosis'].isin(top_diagnoses)]
# Group by diagnosis + month
breakdown = df_top.groupby(['diagnosis', 'month', 'type']).size().unstack(fill_value=0)
if 'outpatient' not in breakdown.columns:
breakdown['outpatient'] = 0
if 'inpatient' not in breakdown.columns:
breakdown['inpatient'] = 0
seasonality: list[DiseaseSeasonalityPoint] = []
for diag in top_diagnoses:
for m in range(1, 13):
row = breakdown.loc[(diag, m)] if (diag, m) in breakdown.index else None
out_count = int(row['outpatient']) if row is not None else 0
in_count = int(row['inpatient']) if row is not None else 0
seasonality.append(DiseaseSeasonalityPoint(
diagnosis=str(diag),
month=m,
month_label=month_labels[m - 1],
outpatient=out_count,
inpatient=in_count,
total=out_count + in_count,
))
# Per-diagnosis district distribution (real aggregation by diagnosis × district).
# Previously the frontend showed the same "主要区域" for every diagnosis because
# no per-diagnosis district data was exposed. Top 3 districts per diagnosis.
df_districts = df_top[(df_top['district'] != '未知') & (df_top['district'] != '其他')]
diag_district_counts = df_districts.groupby(['diagnosis', 'district']).size()
diagnosis_districts: list[DiseaseDistrictItem] = []
for diag in top_diagnoses:
if diag not in diag_district_counts.index.get_level_values('diagnosis'):
continue
top_d = diag_district_counts.loc[diag].sort_values(ascending=False).head(3)
for district_name, count in top_d.items():
diagnosis_districts.append(DiseaseDistrictItem(
diagnosis=str(diag),
district=str(district_name),
total=int(count),
))
return DiseaseSeasonalityResponse(
seasonality=seasonality,
diagnoses=[str(d) for d in top_diagnoses],
diagnosis_districts=diagnosis_districts,
)

View File

@@ -0,0 +1,325 @@
"""
环境数据 API 路由
提供空气污染物时间序列和滞后相关性分析接口
"""
import logging
from pathlib import Path
from typing import Optional
import pandas as pd
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from scipy.stats import pearsonr
logger = logging.getLogger("cbpoa.environment")
PROJECT_ROOT = Path(__file__).parent.parent.parent
router = APIRouter(prefix="/api/environment", tags=["environment"])
# 污染物列表CO 无基值列,仅存在于 lag_features.parquet 的滞后列中)
POLLUTANTS = ["AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"]
LAGS = [1, 2, 3, 5, 7, 14]
# station_daily 列名映射PM25 无点号 -> PM2.5 带点号)
_STATION_COL_MAP: dict[str, str] | None = None
# lag_features 基值列映射
_LAG_BASE_MAP: dict[str, str] | None = None
# ============== Response Models ==============
class LagCorrelationItem(BaseModel):
pollutant: str # "AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"
lag_days: int # 1, 2, 3, 5, 7, 14
correlation: float # Pearson r
class LagCorrelationResponse(BaseModel):
correlations: list[LagCorrelationItem]
data_note: str # "CO excluded - no base column in lag_features.parquet"
class PollutantPoint(BaseModel):
date: str
AQI: float
PM25: float
PM10: float
SO2: float
NO2: float
O3: float
CO: float
class PollutantResponse(BaseModel):
data: list[PollutantPoint]
station_count: int
date_range: dict # {start, end}
# ============== Helper Functions ==============
def _get_station_col_map() -> dict[str, str]:
"""返回 station_daily parquet 中实际列名到标准名称的映射。
station_daily 文件中 PM2.5 列名为 "PM25"(无点号),
需要映射到前端期望的 "PM2.5"
"""
global _STATION_COL_MAP
if _STATION_COL_MAP is not None:
return _STATION_COL_MAP
path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
df = pd.read_parquet(path)
cols = set(df.columns)
col_map = {}
for standard in POLLUTANTS:
if standard in cols:
col_map[standard] = standard
elif "PM25" in cols and standard == "PM2.5":
col_map[standard] = "PM25"
else:
col_map[standard] = standard # fallback
# CO is in station_daily but not in POLLUTANTS
if "CO" in cols:
col_map["CO"] = "CO"
_STATION_COL_MAP = col_map
return _STATION_COL_MAP
def _get_lag_base_map() -> dict[str, str]:
"""返回 lag_features parquet 中基值列名到标准名称的映射。
lag_features 中 PM2.5 列名为 "PM2.5"(带点号),与标准名称一致。
此函数在运行时验证实际列名。
"""
global _LAG_BASE_MAP
if _LAG_BASE_MAP is not None:
return _LAG_BASE_MAP
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
df = pd.read_parquet(path)
cols = set(df.columns)
base_map = {}
for p in POLLUTANTS:
if p in cols:
base_map[p] = p
elif p == "PM2.5" and "PM25" in cols:
base_map[p] = "PM25"
else:
base_map[p] = p # will be checked later
_LAG_BASE_MAP = base_map
return _LAG_BASE_MAP
def _load_lag_features() -> pd.DataFrame:
"""加载 lag_features.parquet 并转换日期列。"""
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
if not path.exists():
raise FileNotFoundError(f"lag_features.parquet not found at {path}")
df = pd.read_parquet(path)
df["date"] = pd.to_datetime(df["date"])
return df
def _load_cases_daily() -> pd.DataFrame:
"""加载 cases_combined.parquet 并按日期汇总每日总病例数。"""
path = PROJECT_ROOT / "processed" / "cases_combined.parquet"
if not path.exists():
raise FileNotFoundError(f"cases_combined.parquet not found at {path}")
df = pd.read_parquet(path)
df["date"] = pd.to_datetime(df["date"])
daily = df.groupby("date").size().reset_index(name="total_cases")
return daily
def _load_station_daily() -> pd.DataFrame:
"""加载并合并 station_daily_2022.parquet 和 station_daily_2023.parquet。"""
dfs = []
for year in [2022, 2023]:
path = PROJECT_ROOT / "processed" / "weather" / f"station_daily_{year}.parquet"
if not path.exists():
logger.warning("station_daily_%s.parquet not found at %s", year, path)
continue
df = pd.read_parquet(path)
dfs.append(df)
if not dfs:
raise FileNotFoundError("No station_daily parquet files found")
combined = pd.concat(dfs, ignore_index=True)
combined["date"] = pd.to_datetime(combined["date"])
return combined
# ============== Endpoints ==============
@router.get("/lag-correlations", response_model=LagCorrelationResponse)
async def get_lag_correlations():
"""获取污染物滞后相关性分析数据。
计算各污染物在不同滞后天数1, 2, 3, 5, 7, 14 天)下
与每日病例总数之间的 Pearson 相关系数。
Returns:
LagCorrelationResponse: 包含 36 个相关系数6 种污染物 × 6 个滞后天数)
"""
try:
lag_df = _load_lag_features()
cases_daily = _load_cases_daily()
except FileNotFoundError as e:
logger.warning("Data file not found for lag-correlations: %s", e)
return LagCorrelationResponse(
correlations=[],
data_note="CO excluded - no base column in lag_features.parquet",
)
# 计算每日全市均值(按日期聚合,对 23 个站点取平均)
base_map = _get_lag_base_map()
mean_cols = {p: base_map.get(p, p) for p in POLLUTANTS}
daily_mean = lag_df.groupby("date")[list(mean_cols.values())].mean().reset_index()
# 重命名列为标准名称以便一致访问
rename_map = {v: k for k, v in mean_cols.items() if v != k}
if rename_map:
daily_mean = daily_mean.rename(columns=rename_map)
# 对每种污染物计算每日均值
# 对齐污染物时间序列与病例数据
merged = daily_mean.merge(cases_daily, on="date", how="inner")
merged = merged.sort_values("date")
results: list[LagCorrelationItem] = []
for pollutant in POLLUTANTS:
# 构建滞后列映射lag_features 文件名用点号 "PM2.5"
lag_base_name = base_map.get(pollutant, pollutant)
for lag in LAGS:
lag_col = f"{lag_base_name}_lag{lag}"
if lag_col not in lag_df.columns:
logger.debug("Lag column %s not found, skipping", lag_col)
continue
# 从原始 lag_features 提取该污染物的滞后数据(按日期取全市均值)
lag_series = lag_df.groupby("date")[lag_col].mean().reset_index()
lag_series = lag_series.rename(columns={lag_col: f"{pollutant}_lag{lag}"})
# 将滞后污染物数据与病例数据对齐
# 滞后列的值代表的是 t-lag 时刻的污染物,病例是 t 时刻
# 所以将 lag 列的时间向后平移 lag 天,使其与病例时间对齐
lag_series["align_date"] = lag_series["date"] + pd.Timedelta(days=lag)
combined = lag_series.merge(
cases_daily, left_on="align_date", right_on="date", how="inner"
)
if len(combined) < 10:
logger.debug(
"Insufficient data for %s lag%d: %d rows, skipping",
pollutant,
lag,
len(combined),
)
continue
# 计算 Pearson 相关系数
r, _ = pearsonr(
combined[f"{pollutant}_lag{lag}"], combined["total_cases"]
)
results.append(
LagCorrelationItem(
pollutant=pollutant, lag_days=lag, correlation=round(float(r), 4)
)
)
return LagCorrelationResponse(
correlations=results,
data_note="CO excluded - no base column in lag_features.parquet",
)
@router.get("/pollutants", response_model=PollutantResponse)
async def get_pollutants(
days: Optional[int] = Query(default=30, ge=1, le=730, description="返回最近 N 天的数据"),
start_date: Optional[str] = Query(default=None, description="开始日期 YYYY-MM-DD"),
end_date: Optional[str] = Query(default=None, description="结束日期 YYYY-MM-DD"),
):
"""获取每日全市均值污染物时间序列。
合并 2022/2023 两个年度的站点日数据,按日期聚合所有站点取均值。
可通过 ?days=N默认 30或 ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD 筛选日期范围。
"""
try:
df = _load_station_daily()
except FileNotFoundError as e:
logger.warning("Data file not found for pollutants: %s", e)
return PollutantResponse(data=[], station_count=0, date_range={})
station_count = df["station_id"].nunique()
# 获取列名映射
col_map = _get_station_col_map()
# 选择污染物列
poll_cols = [col_map.get(p, p) for p in POLLUTANTS] + ["CO"]
# 确保需要的列都存在
available_cols = [c for c in poll_cols if c in df.columns]
# 按日期聚合取均值
daily_mean = df.groupby("date")[available_cols].mean().reset_index()
# 重命名为标准名称
rename_map = {}
for std_name, actual_name in col_map.items():
if actual_name != std_name and actual_name in daily_mean.columns:
rename_map[actual_name] = std_name
if rename_map:
daily_mean = daily_mean.rename(columns=rename_map)
# 日期筛选
if start_date and end_date:
start_dt = pd.to_datetime(start_date)
end_dt = pd.to_datetime(end_date)
date_mask = (daily_mean["date"] >= start_dt) & (
daily_mean["date"] <= end_dt
)
daily_mean = daily_mean[date_mask].copy()
else:
daily_mean = daily_mean.sort_values("date").tail(days)
daily_mean = daily_mean.sort_values("date")
# 构建响应
data: list[PollutantPoint] = []
for _, row in daily_mean.iterrows():
pt = PollutantPoint(
date=row["date"].strftime("%Y-%m-%d"),
AQI=round(float(row.get("AQI", 0)), 2),
PM25=round(float(row.get("PM2.5", 0)), 2),
PM10=round(float(row.get("PM10", 0)), 2),
SO2=round(float(row.get("SO2", 0)), 2),
NO2=round(float(row.get("NO2", 0)), 2),
O3=round(float(row.get("O3", 0)), 2),
CO=round(float(row.get("CO", 0)), 2),
)
data.append(pt)
date_range = {}
if daily_mean.shape[0] > 0:
date_range = {
"start": daily_mean["date"].iloc[0].strftime("%Y-%m-%d"),
"end": daily_mean["date"].iloc[-1].strftime("%Y-%m-%d"),
}
return PollutantResponse(
data=data, station_count=station_count, date_range=date_range
)

View File

@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "outputs"
@lru_cache(maxsize=1)
@lru_cache(maxsize=4)
def _load_csv(path: Path) -> pd.DataFrame:
return pd.read_csv(path)
@@ -109,19 +109,21 @@ async def get_grid_cases():
async def get_geocoded_cases(
limit: int = 1000,
district: Optional[str] = None,
date: Optional[str] = Query(None, description="Filter by date (YYYY-MM-DD)"),
):
"""
Get individual geocoded case data.
Args:
limit: Maximum number of cases to return (for performance)
district: Filter by district name
date: Filter by specific date
"""
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 = _load_csv(cases_file)
@@ -132,6 +134,11 @@ async def get_geocoded_cases(
swapped = df['latitude'] > 50 # longitude values are >113
df.loc[swapped, ['latitude', 'longitude']] = df.loc[swapped, ['longitude', 'latitude']].values
# Filter by date if specified
if date and 'date' in df.columns:
df['date_str'] = pd.to_datetime(df['date']).dt.strftime('%Y-%m-%d')
df = df[df['date_str'] == date]
# Filter by district if specified
if district:
df = df[df['district'] == district]

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, HTTPException, Query
import asyncio
from fastapi import APIRouter, HTTPException, Query, Response
from datetime import datetime, timedelta
from functools import lru_cache
from pathlib import Path
@@ -6,6 +7,7 @@ from typing import Optional
import logging
import sys
import math
import pandas as pd
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
@@ -22,47 +24,33 @@ from models import (
router = APIRouter(prefix="/api", tags=["grid"])
logger = logging.getLogger("cbpoa.grid")
_parquet_cache: dict[str, "pd.DataFrame"] = {}
_parquet_cache: dict[str, pd.DataFrame] = {}
def _load_parquet(path: Path) -> "pd.DataFrame":
import pandas as pd
def _load_parquet(path: Path) -> pd.DataFrame:
key = str(path)
if key not in _parquet_cache:
_parquet_cache[key] = pd.read_parquet(path)
return _parquet_cache[key]
@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
def _compute_historical_aggregation(
start: datetime,
end: datetime,
aggregation: str,
district: Optional[str],
) -> HistoricalAggregationResponse:
"""Run the full pandas aggregation pipeline (called in thread pool)."""
try:
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
except FileNotFoundError:
return HistoricalAggregationResponse(
aggregations=[], total_records=0,
date_range=(start_date, end_date), timestamp=datetime.now().isoformat(),
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(),
)
cases_df = cases_df.copy()
cases_df['date'] = pd.to_datetime(cases_df['date'])
filtered_cases = cases_df[
@@ -76,6 +64,7 @@ async def get_historical_aggregated(
]
if aggregation == "weekly":
filtered_cases = filtered_cases.copy()
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
grouped = filtered_cases.groupby(['period', 'district']).agg({
'total_cases': 'sum',
@@ -84,6 +73,7 @@ async def get_historical_aggregated(
}).reset_index()
grouped['date'] = grouped['period']
elif aggregation == "monthly":
filtered_cases = filtered_cases.copy()
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
grouped = filtered_cases.groupby(['period', 'district']).agg({
'total_cases': 'sum',
@@ -98,7 +88,8 @@ async def get_historical_aggregated(
try:
weather_df = _load_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
except FileNotFoundError:
weather_df = pd.DataFrame(columns=['date', 'AQI', 'PM25', 'PM10'])
weather_df = pd.DataFrame({'date': pd.Series(dtype='str'), 'AQI': pd.Series(dtype='float64'), 'PM25': pd.Series(dtype='float64'), 'PM10': pd.Series(dtype='float64')})
weather_df = weather_df.copy()
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
# Weather data doesn't have district - aggregate by date only
@@ -114,135 +105,173 @@ async def get_historical_aggregated(
aggregations = []
for _, row in merged.iterrows():
aggregations.append(DistrictAggregation(
district=row['district'],
district=str(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,
avg_AQI=float(row['AQI']) if bool(pd.notna(row['AQI'])) else 0.0,
avg_PM25=float(row['PM25']) if bool(pd.notna(row['PM25'])) else 0.0,
avg_PM10=float(row['PM10']) if bool(pd.notna(row['PM10'])) else 0.0,
))
return HistoricalAggregationResponse(
aggregations=aggregations,
total_records=len(aggregations),
date_range=(start_date, end_date),
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
timestamp=datetime.now().isoformat(),
)
@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.
Pandas processing runs in a thread pool to avoid blocking the async event loop.
"""
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")
# Offload all pandas I/O and processing to a thread pool
# to prevent blocking the async event loop
return await asyncio.to_thread(
_compute_historical_aggregation, start, end, aggregation, district
)
@lru_cache(maxsize=1)
def _grid_geojson_base():
"""Date-independent base merge: grid centroid + district + real population.
Merged once and cached (the source frames are ~1M rows each, so the join
must not run per request). Raises FileNotFoundError if the core grid files
are missing (caller handles it).
"""
import pandas as pd
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
base = grid_df.merge(district_map, on='grid_id', how='left')
try:
pop_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet")
base = base.merge(pop_df[['grid_id', 'population_density']], on='grid_id', how='left')
except FileNotFoundError:
base['population_density'] = 0.0
base['population_density'] = base['population_density'].fillna(0.0)
return base
def _risk_level_of(v: float) -> str:
if v >= 0.7:
return "high"
if v >= 0.5:
return "medium"
if v >= 0.3:
return "medium_low"
return "low"
@lru_cache(maxsize=32)
def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional[str]) -> str:
"""Build + serialize the grid GeoJSON once per (date, district, risk_level).
Risk is computed vectorised over the full grid (no per-row Python loop) and
the highest-risk grids are returned as hotspots, so the map shows real
high→low variation. Cached, so warm calls are near-instant. Raises
FileNotFoundError if the core grid files are missing.
"""
merged = _grid_geojson_base()
if district:
merged = merged[merged['district_name'].str.contains(district.replace('', ''), na=False, regex=False)]
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy()
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
cases_df = cases_df[cases_df['date'] == date]
# Normalise district case load to 0..1 across districts for this date.
max_district_cases = float(cases_df['total_cases'].max()) if len(cases_df) else 0.0
if max_district_cases <= 0:
max_district_cases = 1.0
merged = merged.merge(cases_df[['district', 'total_cases']], left_on='district_name', right_on='district', how='left')
merged = merged.copy()
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
merged['center_lon'] = pd.to_numeric(merged['center_lon'], errors='coerce').fillna(0.0)
merged['center_lat'] = pd.to_numeric(merged['center_lat'], errors='coerce').fillna(0.0)
merged['population_density'] = merged['population_density'].fillna(0.0).clip(lower=0.0)
# Drop grids without coordinates.
merged = merged[(merged['center_lon'] != 0.0) | (merged['center_lat'] != 0.0)]
# Demo risk model (vectorised): a district's relative case load × each grid's
# own population exposure. Sparse cells stay low; densely-populated cells in
# high-case districts rise toward 1.0.
district_load = (merged['total_cases'] / max_district_cases).clip(upper=1.0)
pop_factor = (merged['population_density'] / 50.0).clip(upper=1.0)
merged['risk_value'] = (0.1 + 0.85 * district_load * pop_factor).clip(upper=1.0).round(3)
# Show the highest-risk grids (hotspots), not arbitrary cells.
merged = merged.nlargest(10000, 'risk_value')
features = []
for rec in merged.to_dict('records'):
rv = float(rec['risk_value'])
lvl = _risk_level_of(rv)
if risk_level and lvl != risk_level:
continue
name = rec.get('district_name')
if not isinstance(name, str):
name = "未知"
lon = round(float(rec['center_lon']), 6)
lat = round(float(rec['center_lat']), 6)
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"grid_id": str(rec.get('grid_id', '')),
"latitude": lat,
"longitude": lon,
"district": name,
"total_cases": int(rec.get('total_cases', 0)),
"population_density": round(float(rec.get('population_density', 0.0)), 2),
"risk_value": rv,
"risk_level": lvl,
}
})
return GridGeoJSONResponse(
type="FeatureCollection",
features=features,
timestamp=datetime.now().isoformat(),
).model_dump_json()
@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
"""Get grid data as GeoJSON for map visualization (cached per query)."""
try:
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
# Offload the parquet merges + vectorised compute to a thread so the
# cold-cache build doesn't block the event loop.
body = await asyncio.to_thread(_grids_geojson_body, date, district, risk_level)
except FileNotFoundError:
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
try:
district_map = _load_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 = _load_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(),
)
return Response(content=body, media_type="application/json")
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
@@ -278,9 +307,9 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
]
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))
risk_1d = float(row.get('risk_1day', 0.5)) # type: ignore[arg-type]
risk_3d = float(row.get('risk_3day', 0.5)) # type: ignore[arg-type]
risk_7d = float(row.get('risk_7day', 0.5)) # type: ignore[arg-type]
if risk_1d >= 0.8:
risk_level = "high"
@@ -323,14 +352,8 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
)
@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.
"""
def _compute_grid_history(grid_id: str, days: int) -> dict:
"""Heavy synchronous parquet reads + per-row loop (called in thread pool)."""
import pandas as pd
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
@@ -367,4 +390,18 @@ async def get_grid_history(
"district": district,
"history": history,
"timestamp": datetime.now().isoformat(),
}
}
@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.
Parquet reads + aggregation run in a thread pool to avoid blocking the
async event loop.
"""
return await asyncio.to_thread(_compute_grid_history, grid_id, days)

View File

@@ -4,6 +4,7 @@ Provides comprehensive analytics, trends, hotspots, and correlations
"""
from fastapi import APIRouter, HTTPException, Query
from datetime import datetime, timedelta
from functools import lru_cache
import random
from pydantic import BaseModel, Field
@@ -20,6 +21,13 @@ from models import (
)
from utils.date_helpers import get_latest_date
from utils.geojson import parse_geojson_file, load_districts
@lru_cache(maxsize=8)
def _cached_parquet(path_str: str):
"""Load a parquet file once and reuse it (read-only) across requests."""
import pandas as pd
return pd.read_parquet(path_str)
from utils.geo import point_in_polygon
from utils.risk import calculate_trend as calculate_trend_direction
@@ -41,6 +49,7 @@ class InsightCardResponse(BaseModel):
warning_count: int
info_count: int
success_count: int
danger_count: int
cards: list[InsightCardItem]
@@ -482,7 +491,7 @@ async def get_insights_cards():
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
if cases_path.exists():
cases_df = pd.read_parquet(cases_path)
cases_df = _cached_parquet(str(cases_path))
latest_case_date = cases_df["date"].max()
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
latest_cases["base_district"] = latest_cases["district"].str.replace("", "")
@@ -533,7 +542,7 @@ async def get_insights_cards():
grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int)
grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str)
mapping = pd.read_parquet(mapping_path)
mapping = _cached_parquet(str(mapping_path))
merged = grids_df.merge(mapping, on="grid_id", how="inner")
if len(merged) > 0:
@@ -574,7 +583,7 @@ async def get_insights_cards():
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
if weather_path.exists():
weather_df = pd.read_parquet(weather_path)
weather_df = _cached_parquet(str(weather_path))
daily_wx = weather_df.groupby("date").agg(
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
).reset_index()
@@ -664,11 +673,13 @@ async def get_insights_cards():
warning_count = sum(1 for c in cards if c.type == "warning")
info_count = sum(1 for c in cards if c.type == "info")
success_count = sum(1 for c in cards if c.type == "success")
danger_count = sum(1 for c in cards if c.type == "danger")
return InsightCardResponse(
total_insights=len(cards),
warning_count=warning_count,
info_count=info_count,
success_count=success_count,
danger_count=danger_count,
cards=cards,
)

View File

@@ -2,10 +2,10 @@
Router for CBPOA risk assessment endpoints
Reads from GeoJSON files in outputs/daily/ directory
"""
from fastapi import APIRouter, HTTPException, Path, Query
from fastapi import APIRouter, HTTPException, Path, Query, Response
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, List, Literal
import asyncio
import json
import glob
import re
@@ -28,8 +28,12 @@ from utils.risk import risk_value_to_level
router = APIRouter(prefix="/api/risk", tags=["risk"])
# Cells with risk at/below this are culled from LOD responses; the frontend
# discards risk==0 cells anyway, so emitting them only bloats the payload.
LOD_RISK_EPSILON = 1e-6
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_risk_data(date: str) -> tuple[list[list], dict]:
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
@@ -55,7 +59,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
return grids, grid_map
@lru_cache(maxsize=3)
@lru_cache(maxsize=8)
def get_kdtree_and_risks(date: str):
grids, _ = get_risk_data(date)
if not grids:
@@ -70,6 +74,8 @@ 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)
if kdtree is None or risk_values is None:
return {"lod": "empty", "zoom": zoom, "aggregate": 1, "grids": [], "total_count": 0, "bounds": bounds or WUHAN_BOUNDS}
risk_idx = forecast_day - 1
@@ -117,7 +123,12 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks = risk_array[indices]
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius). The frontend discards
# them anyway, so culling here shrinks the payload substantially.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -143,8 +154,9 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
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:
# Apply viewport bounds filtering at all zoom levels when bounds are given,
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
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"])
@@ -176,7 +188,11 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
risks[dists > LOD_MAX_RADIUS] = 0.0
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
# Drop zero-risk cells (incl. out-of-radius) before serialization.
flat_lat = lat_grid.ravel()
flat_lon = lon_grid.ravel()
keep = risks > LOD_RISK_EPSILON
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
return {
"lod": lod_name,
@@ -188,6 +204,21 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
}
@lru_cache(maxsize=8)
def _risk_map_body(date: str) -> str:
"""Serialize the full ~140k-grid risk map for a date once (cached).
The 140k-element response costs ~0.5s of Pydantic validation + JSON
serialization; caching the serialized body makes warm calls ~instant.
"""
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
return RiskMapResponse(
grids=grids,
total_count=len(grids),
timestamp=datetime.now().isoformat(),
).model_dump_json()
@router.get("/map", response_model=RiskMapResponse)
async def get_risk_map(date: str | None = None):
if date is None:
@@ -197,13 +228,7 @@ async def get_risk_map(date: str | None = None):
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()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/current", response_model=RiskMapResponse)
@@ -214,28 +239,7 @@ async def get_current_risk():
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()
)
return Response(content=_risk_map_body(date), media_type="application/json")
@router.get("/precomputed", response_model=RiskMapResponse)
@@ -247,7 +251,7 @@ async def get_precomputed_risk():
grids = []
for _, row in df.iterrows():
risk_index = float(row.get('risk_index', 0))
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
grids.append({
"grid_id": str(row['grid_id']),
"latitude": float(row['center_y']),
@@ -263,6 +267,18 @@ async def get_precomputed_risk():
)
@lru_cache(maxsize=8)
def _fullgrid_body(date: str) -> str:
"""Serialize the compact full-grid payload once (cached)."""
grids, _ = get_risk_data(date)
return json.dumps({
"date": date,
"total_count": len(grids),
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
"grids": grids,
})
@router.get("/fullgrid")
async def get_full_grid(date: str | None = None):
if date is None:
@@ -272,26 +288,7 @@ async def get_full_grid(date: str | None = None):
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,
}
return Response(content=_fullgrid_body(date), media_type="application/json")
@router.get("/lod-grid")
@@ -366,50 +363,44 @@ async def get_lod_tile(
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
async def get_risk_history(grid_id: str, days: int = 7):
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 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)
# Use cached parsed grids + cached KDTree instead of re-reading the ~44MB file.
grids = parse_geojson_file(filepath)
if not grids:
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
target_feature = None
for feature in geojson.get("features", []):
props = feature.get("properties", {})
if str(props.get("node_id", "")) == grid_id:
target_feature = feature
base_risk = None
# Exact node_id match
for g in grids:
if g["grid_id"] == grid_id:
base_risk = g["risk_value"]
break
if not target_feature and re.match(r'r\d+_c\d+', grid_id):
# Fallback: nearest grid for r{row}_c{col} ids
if base_risk is None 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]
kdtree, risk_values = get_kdtree_and_risks(date)
if kdtree is not None and risk_values is not None:
_, idx = kdtree.query([center_lat, center_lon])
base_risk = risk_values[idx][0] # risk_1d
if not target_feature:
if base_risk is None:
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)
"risk_value": max(0.0, base_risk * (1 - i * 0.05))
})
return RiskHistoryResponse(
@@ -432,7 +423,7 @@ async def get_forecast_map(
if not filepath.exists():
# Fall back to current data
return await get_current_risk_map()
return await get_current_risk()
grids = parse_geojson_file(filepath)
if not grids:
@@ -495,3 +486,71 @@ async def get_stats(date: str | None = None):
high_risk_count=distribution["high"],
timestamp=datetime.now().isoformat()
)
# ---------------------------------------------------------------------------
# Raster LOD tiles — full-Wuhan 100m risk grid served as XYZ map tiles.
# The browser loads PNG images (cached by Leaflet); no per-cell JS work.
# See utils/risk_raster.py for the rendering engine.
# ---------------------------------------------------------------------------
from utils import risk_raster # noqa: E402 (kept local to this feature block)
_VALID_DAYS = {1, 3, 7}
@router.get("/tiles/{z}/{x}/{y}.png")
async def get_risk_tile(
z: Annotated[int, Path(ge=0, le=22)],
x: Annotated[int, Path(ge=0)],
y: Annotated[int, Path(ge=0)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Render one web-mercator risk tile (256x256 PNG) for the 100m grid."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
png = await asyncio.to_thread(risk_raster.render_tile, z, x, y, date, day)
return Response(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/grid-stats")
async def get_risk_grid_stats(
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Aggregate stats over the in-boundary 100m grid (cell count / avg / max / high)."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.grid_stats, date, day)
@router.get("/cell")
async def get_risk_cell(
lat: Annotated[float, Query(ge=-90, le=90)],
lon: Annotated[float, Query(ge=-180, le=180)],
date: str | None = None,
day: Annotated[int, Query()] = 1,
):
"""Risk at the 100m cell containing (lat, lon) — used for click-to-inspect."""
if day not in _VALID_DAYS:
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
if date is None:
date = get_latest_date()
if not (DATA_DIR / f"risk_{date}.geojson").exists():
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
return await asyncio.to_thread(risk_raster.query_cell, lat, lon, date, day)

46
backend/tests/CLAUDE.md Normal file
View File

@@ -0,0 +1,46 @@
# Backend Tests
## Framework
pytest + FastAPI `TestClient` (sync, in-process). No database mocking needed — tests hit real endpoints with real data files.
## Structure
- `conftest.py` — shared fixtures (`client`, `auth_headers`, test data)
- `test_api.py` — endpoint integration tests, organized by router class
- `test_auth.py` — authentication flow tests
- `test_error_handling.py` — edge cases, error responses
- `test_utils.py` — pure utility function tests
## Patterns
- Tests organized in classes: `class TestRiskEndpoints:`
- One test method per scenario: `test_current_risk_map()`, `test_risk_map_with_date()`
- Fixture naming: `client: TestClient`, `auth_headers: dict`
- Assert response status, then JSON structure, then field types/values
```python
class TestSomeRouter:
def test_something(self, client: TestClient):
resp = client.get("/api/some/endpoint")
assert resp.status_code == 200
data = resp.json()
assert "key" in data
assert isinstance(data["key"], list)
```
## Running
```bash
cd backend
source venv/bin/activate
pytest tests/ -v
pytest tests/test_api.py -v -k "test_risk"
```
## Anti-Patterns
- Don't mock endpoints you can test with real data
- Don't hardcode test dates that will go stale
- Don't skip assertions on response structure just because status is 200
- Don't share mutable state between test classes — use fixtures

View File

@@ -37,8 +37,11 @@ class TestMissingResources:
class TestInvalidForecastDay:
def test_forecast_out_of_range(self, client: TestClient):
# days=0 violates the ge=1 bound on /forecast/{days}, so FastAPI returns 422.
# (This previously returned 200 because `Path` was shadowed by `pathlib.Path`,
# silently disabling validation — fixed by the risk.py import correction.)
resp = client.get("/api/risk/forecast/0")
assert resp.status_code in (200, 404)
assert resp.status_code == 422
def test_forecast_too_large(self, client: TestClient):
resp = client.get("/api/risk/forecast/999")

View File

@@ -9,7 +9,7 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
return False
# MultiPolygon: check each polygon
if isinstance(polygon_coords[0], list) and isinstance(polygon_coords[0][0], list):
if isinstance(polygon_coords[0], list) and polygon_coords[0] 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
@@ -25,6 +25,8 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
def point_in_ring(lat: float, lon: float, ring: list) -> bool:
"""Ray casting algorithm for point-in-ring test."""
n = len(ring)
if n < 3:
return False
inside = False
x, y = lon, lat

View File

@@ -13,7 +13,7 @@ from utils.risk import risk_value_to_level
logger = logging.getLogger(__name__)
@lru_cache(maxsize=8)
@lru_cache(maxsize=16)
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
"""Parse GeoJSON file and extract grid data with standard fields."""
try:

View File

@@ -0,0 +1,375 @@
"""
Risk raster tile engine — renders the full-Wuhan 100m risk grid as XYZ map tiles.
Why this exists
---------------
The model emits risk at ~140k GCN nodes per day. The product needs to display this
over the full Wuhan 100m grid (~1.5M in-boundary cells) with smooth LOD. Shipping
that many cells to the browser as vectors is impossible, so we rasterize server-side:
1. Build a dense per-cell risk raster R[row, col] once per (date, forecast_day):
scatter each node's risk onto its 100m cell (max per cell), then nearest-fill
empty cells via a Euclidean distance transform (Voronoi over nodes, quantized
to the 100m grid). Cells outside the Wuhan boundary are masked out.
2. Build a max-pooled pyramid for clean LOD at low zoom.
3. Render standard 256x256 web-mercator PNG tiles by sampling the pyramid level
that matches the tile's zoom. Tiles are cached; the browser just loads images.
Coordinate conventions (calibrated from processed/grid_100m_index.parquet):
lat = MIN_LAT + (row + 0.5) * LAT_STEP -> row 0 is SOUTH, row increases north
lon = MIN_LON + (col + 0.5) * LON_STEP -> col 0 is WEST, col increases east
Everything below maps lat/lon -> (row, col) the same way, so orientation is coherent
end to end. Tile pixel py=0 is north (high lat -> high row); we build the RGBA array
with py as the first axis so north ends up at the top of the PNG.
"""
from __future__ import annotations
import io
import json
import math
from functools import lru_cache
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import distance_transform_edt
from config import DATA_DIR, WUHAN_BOUNDS, PROJECT_ROOT
# --- Grid definition (geographically-correct ~100m grid over the Wuhan bbox) ---
MIN_LON = WUHAN_BOUNDS["min_lon"]
MAX_LON = WUHAN_BOUNDS["max_lon"]
MIN_LAT = WUHAN_BOUNDS["min_lat"]
MAX_LAT = WUHAN_BOUNDS["max_lat"]
NROWS = 1550 # matches processed/grid_100m_with_dem_pop.parquet row extent
NCOLS = 1336 # matches its col extent
LAT_STEP = (MAX_LAT - MIN_LAT) / NROWS
LON_STEP = (MAX_LON - MIN_LON) / NCOLS
BOUNDARY_GEOJSON = PROJECT_ROOT / "Datas" / "武汉市.geojson"
# Forecast-day -> property suffix on the risk geojson features.
_DAY_TO_KEY = {1: "risk_1d", 3: "risk_3d", 7: "risk_7d"}
MAX_PYRAMID_LEVEL = 7 # full-res + 7 downsamples covers world zoom range
TILE_PX = 256
# ----------------------------------------------------------------------------
# Affine helpers (lat/lon <-> grid row/col)
# ----------------------------------------------------------------------------
def latlon_to_rowcol(lat: float, lon: float) -> tuple[int, int]:
row = int((lat - MIN_LAT) / LAT_STEP)
col = int((lon - MIN_LON) / LON_STEP)
row = max(0, min(NROWS - 1, row))
col = max(0, min(NCOLS - 1, col))
return row, col
# ----------------------------------------------------------------------------
# Boundary mask (rasterized once)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _boundary_mask() -> np.ndarray:
"""Boolean (NROWS, NCOLS) mask, True for cells inside the Wuhan boundary."""
img = Image.new("1", (NCOLS, NROWS), 0)
draw = ImageDraw.Draw(img)
if not BOUNDARY_GEOJSON.exists():
# No boundary file -> color the whole bbox rather than nothing.
return np.ones((NROWS, NCOLS), dtype=bool)
with open(BOUNDARY_GEOJSON, "r", encoding="utf-8") as f:
gj = json.load(f)
def _draw_ring(ring):
pts = []
for lon, lat in ring:
col = (lon - MIN_LON) / LON_STEP
row = (lat - MIN_LAT) / LAT_STEP
pts.append((col, row))
if len(pts) >= 3:
draw.polygon(pts, fill=1)
def _walk(geom):
gtype = geom.get("type")
coords = geom.get("coordinates", [])
if gtype == "Polygon":
for ring in coords:
_draw_ring(ring)
elif gtype == "MultiPolygon":
for poly in coords:
for ring in poly:
_draw_ring(ring)
if gj.get("type") == "FeatureCollection":
for feat in gj.get("features", []):
_walk(feat.get("geometry", {}))
elif gj.get("type") == "Feature":
_walk(gj.get("geometry", {}))
else:
_walk(gj)
return np.array(img, dtype=bool)
# ----------------------------------------------------------------------------
# Node loading (cached per date)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=8)
def _load_nodes(date: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return (rows, cols, risks[3]) arrays for all nodes on a date.
risks is shape (n_nodes, 3) for [risk_1d, risk_3d, risk_7d].
"""
filepath = DATA_DIR / f"risk_{date}.geojson"
if not filepath.exists():
raise FileNotFoundError(f"No risk data for date {date}")
with open(filepath, "r", encoding="utf-8") as f:
gj = json.load(f)
feats = gj.get("features", [])
n = len(feats)
rows = np.empty(n, dtype=np.int32)
cols = np.empty(n, dtype=np.int32)
risks = np.zeros((n, 3), dtype=np.float32)
for i, feat in enumerate(feats):
p = feat.get("properties", {})
lat = p.get("lat", 0.0)
lon = p.get("lon", 0.0)
r, c = latlon_to_rowcol(lat, lon)
rows[i] = r
cols[i] = c
risks[i, 0] = p.get("risk_1d", 0.0)
risks[i, 1] = p.get("risk_3d", 0.0)
risks[i, 2] = p.get("risk_7d", 0.0)
return rows, cols, risks
# ----------------------------------------------------------------------------
# Risk raster + pyramid (cached per date+day)
# ----------------------------------------------------------------------------
def _maxpool2(a: np.ndarray) -> np.ndarray:
"""Downsample by 2 taking the NaN-aware max of each 2x2 block."""
h, w = a.shape
h2, w2 = (h + 1) // 2, (w + 1) // 2
out = np.full((h2, w2), np.nan, dtype=np.float32)
# Pad to even dims with NaN so the reshape is clean.
ph, pw = h2 * 2, w2 * 2
pad = np.full((ph, pw), np.nan, dtype=np.float32)
pad[:h, :w] = a
blocks = pad.reshape(h2, 2, w2, 2)
# np.nanmax over the 2x2 block axes; suppress all-NaN warnings.
with np.errstate(invalid="ignore"):
out = np.nanmax(blocks, axis=(1, 3))
return out.astype(np.float32)
@lru_cache(maxsize=6)
def _risk_pyramid(date: str, day: int) -> tuple[np.ndarray, ...]:
"""Build the nearest-filled, boundary-masked risk raster and its LOD pyramid.
Returns a tuple of arrays, level 0 = full res (NROWS, NCOLS), each subsequent
level downsampled 2x. NaN marks "no data / outside boundary".
"""
if day not in _DAY_TO_KEY:
raise ValueError(f"invalid forecast day {day}")
day_idx = {1: 0, 3: 1, 7: 2}[day]
rows, cols, risks = _load_nodes(date)
vals = risks[:, day_idx]
# Scatter nodes onto the grid, taking the max risk per cell.
R = np.full((NROWS, NCOLS), -np.inf, dtype=np.float32)
np.maximum.at(R, (rows, cols), vals)
known = np.isfinite(R)
# Nearest-fill empty cells (Voronoi over nodes, quantized to the 100m grid).
if known.any():
idx = distance_transform_edt(~known, return_distances=False, return_indices=True)
R = R[tuple(idx)]
R = R.astype(np.float32)
# Mask out everything outside the Wuhan boundary.
mask = _boundary_mask()
R[~mask] = np.nan
pyramid = [R]
for _ in range(MAX_PYRAMID_LEVEL):
nxt = _maxpool2(pyramid[-1])
pyramid.append(nxt)
if nxt.shape[0] <= 2 or nxt.shape[1] <= 2:
break
return tuple(pyramid)
# ----------------------------------------------------------------------------
# Colormap (risk 0..1 -> RGBA), built once as a 256-entry LUT
# ----------------------------------------------------------------------------
@lru_cache(maxsize=1)
def _color_lut() -> np.ndarray:
"""256x4 uint8 LUT. Green -> yellow -> orange -> red, alpha grows with risk.
Risk below ~0.25 is rendered transparent to keep the map readable.
"""
lut = np.zeros((256, 4), dtype=np.uint8)
# control points: (risk, R, G, B)
stops = [
(0.00, 56, 176, 0), # green (low)
(0.40, 250, 204, 21), # yellow (medium)
(0.60, 249, 115, 22), # orange (high)
(0.80, 239, 68, 68), # red (critical)
(1.00, 153, 27, 27), # dark red (extreme)
]
xs = [s[0] for s in stops]
for i in range(256):
t = i / 255.0
# piecewise-linear RGB interpolation
for k in range(len(stops) - 1):
if xs[k] <= t <= xs[k + 1]:
f = (t - xs[k]) / (xs[k + 1] - xs[k] + 1e-9)
r = stops[k][1] + f * (stops[k + 1][1] - stops[k][1])
g = stops[k][2] + f * (stops[k + 1][2] - stops[k][2])
b = stops[k][3] + f * (stops[k + 1][3] - stops[k][3])
break
else:
r, g, b = stops[-1][1:]
# alpha: transparent below 0.25, then ramp 90 -> 235
if t < 0.25:
a = 0.0
else:
a = 90 + (t - 0.25) / 0.75 * (235 - 90)
lut[i] = (int(r), int(g), int(b), int(a))
return lut
# ----------------------------------------------------------------------------
# Tile rendering
# ----------------------------------------------------------------------------
def _tile_pixel_latlon(z: int, x: int, y: int) -> tuple[np.ndarray, np.ndarray]:
"""Return (lat[256,256], lon[256,256]) for each pixel center of a tile."""
n = 2.0 ** z
px = (np.arange(TILE_PX) + 0.5) / TILE_PX
# longitude is linear in tile-x
X = (x + px) / n
lon = X * 360.0 - 180.0 # shape (256,)
# latitude via inverse web-mercator (nonlinear in tile-y)
Y = (y + px) / n
lat = np.degrees(np.arctan(np.sinh(np.pi * (1.0 - 2.0 * Y)))) # shape (256,)
lon2d = np.broadcast_to(lon, (TILE_PX, TILE_PX)) # varies along axis 1 (px)
lat2d = np.broadcast_to(lat[:, None], (TILE_PX, TILE_PX)) # varies along axis 0 (py)
return lat2d, lon2d
def _level_for_zoom(z: int) -> int:
"""Pick the pyramid level so ~1 source cell maps to ~1 screen pixel."""
# meters/pixel at lat ~30.6: 156543.03 * cos(lat) / 2^z ; /100m per cell
cells_per_px = (156543.03 * math.cos(math.radians(30.6)) / (2.0 ** z)) / 100.0
if cells_per_px <= 1.0:
return 0
return max(0, min(MAX_PYRAMID_LEVEL, int(math.floor(math.log2(cells_per_px)))))
def render_tile(z: int, x: int, y: int, date: str, day: int = 1) -> bytes:
"""Render a single XYZ tile to PNG bytes. Fully transparent tiles return a
tiny cached blank PNG. Result is cached per (z,x,y,date,day)."""
return _render_tile_cached(z, x, y, date, day)
@lru_cache(maxsize=1024)
def _render_tile_cached(z: int, x: int, y: int, date: str, day: int) -> bytes:
pyramid = _risk_pyramid(date, day)
level = _level_for_zoom(z)
level = min(level, len(pyramid) - 1)
R = pyramid[level]
factor = 2 ** level
lh, lw = R.shape
lat2d, lon2d = _tile_pixel_latlon(z, x, y)
# lat/lon -> full-res row/col -> level row/col
row = ((lat2d - MIN_LAT) / LAT_STEP).astype(np.int32) // factor
col = ((lon2d - MIN_LON) / LON_STEP).astype(np.int32) // factor
inside = (row >= 0) & (row < lh) & (col >= 0) & (col < lw)
rc = np.clip(row, 0, lh - 1)
cc = np.clip(col, 0, lw - 1)
sampled = R[rc, cc] # (256,256) float32, NaN where no data
valid = inside & np.isfinite(sampled)
# Map risk -> LUT index (NaN cells become 0 then are zeroed-out below)
lut = _color_lut()
safe = np.nan_to_num(sampled, nan=0.0)
idx = np.clip((safe * 255.0), 0, 255).astype(np.uint8)
rgba = lut[idx] # (256,256,4)
rgba[~valid] = (0, 0, 0, 0) # transparent outside data/boundary
img = Image.fromarray(rgba, mode="RGBA")
buf = io.BytesIO()
img.save(buf, format="PNG", optimize=False)
return buf.getvalue()
# ----------------------------------------------------------------------------
# Point query (for click-to-inspect)
# ----------------------------------------------------------------------------
@lru_cache(maxsize=16)
def grid_stats(date: str, day: int = 1) -> dict:
"""Lightweight aggregate stats over the in-boundary 100m grid for a date/day.
Computed from the cached raster, so this is cheap after the first tile build.
Replaces the old heavy per-viewport LOD fetch the overlay used to do.
"""
R = _risk_pyramid(date, day)[0]
finite = np.isfinite(R)
n = int(finite.sum())
if n == 0:
return {"cell_count": 0, "avg_risk": 0.0, "max_risk": 0.0,
"high_risk_count": 0, "forecast_day": day, "date": date}
vals = R[finite]
return {
"cell_count": n,
"avg_risk": round(float(vals.mean()), 4),
"max_risk": round(float(vals.max()), 4),
"high_risk_count": int((vals >= 0.8).sum()),
"forecast_day": day,
"date": date,
}
def query_cell(lat: float, lon: float, date: str, day: int = 1) -> dict:
"""Return the 100m cell risk at a lat/lon for the given date.
Includes all three forecast horizons (1d/3d/7d) so the click panel can show
them without a separate heavy grid fetch. `risk_value` is the requested day.
"""
row, col = latlon_to_rowcol(lat, lon)
def _sample(d: int) -> tuple[float, bool]:
v = _risk_pyramid(date, d)[0][row, col]
ok = bool(np.isfinite(v))
return (round(float(v), 4) if ok else 0.0), ok
r1, in_b = _sample(1)
r3, _ = _sample(3)
r7, _ = _sample(7)
current = {1: r1, 3: r3, 7: r7}[day]
return {
"grid_id": f"r{row}_c{col}",
"row": row,
"col": col,
"center_lat": round(MIN_LAT + (row + 0.5) * LAT_STEP, 6),
"center_lon": round(MIN_LON + (col + 0.5) * LON_STEP, 6),
"risk_value": current,
"risk_1d": r1,
"risk_3d": r3,
"risk_7d": r7,
"in_boundary": in_b,
"forecast_day": day,
"date": date,
}