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

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)