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

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(),
)