Files
CA/backend/routers/geocoded.py
Akiba So e95e2f1338 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
2026-06-21 17:35:03 +08:00

233 lines
7.6 KiB
Python

"""
Router for geocoded case data and grid aggregated data
"""
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import List, Optional
import logging
from functools import lru_cache
import pandas as pd
from pathlib import Path
logger = logging.getLogger("cbpoa.geocoded")
router = APIRouter(prefix="/api/geocoded", tags=["geocoded"])
PROJECT_ROOT = Path(__file__).parent.parent.parent
DATA_DIR = PROJECT_ROOT / "outputs"
@lru_cache(maxsize=4)
def _load_csv(path: Path) -> pd.DataFrame:
return pd.read_csv(path)
class GridCaseData(BaseModel):
"""Grid case data for visualization"""
grid_id: int
latitude: float
longitude: float
total_cases: int
outpatient_cases: int
inpatient_cases: int
case_density: float
risk_index: float
risk_level: str
class GridCaseResponse(BaseModel):
grids: List[GridCaseData]
total_count: int
total_cases: int
class GeocodedCaseData(BaseModel):
"""Individual geocoded case"""
case_id: str
case_type: str
latitude: float
longitude: float
district: str
street: Optional[str]
geocode_method: str
confidence: float
class GeocodedResponse(BaseModel):
cases: List[GeocodedCaseData]
total_count: int
class StreetData(BaseModel):
"""Street-level aggregated case data"""
name: str
total_cases: int
outpatient: int
inpatient: int
class StreetsResponse(BaseModel):
streets: List[StreetData]
@router.get("/grid", response_model=GridCaseResponse, summary="Get aggregated grid case data")
async def get_grid_cases():
"""
Get 100x100m grid aggregated case data for high-resolution visualization.
Returns grid cells with case counts, density, and risk indices.
"""
grid_file = DATA_DIR / "grid_risk_summary.csv"
if not grid_file.exists():
raise HTTPException(status_code=404, detail="Grid data not found")
try:
df = _load_csv(grid_file)
grids = []
for _, row in df.iterrows():
grids.append(GridCaseData(
grid_id=int(row['grid_id']),
latitude=float(row['center_y']),
longitude=float(row['center_x']),
total_cases=int(row['total_cases']),
outpatient_cases=int(row['outpatient_cases']),
inpatient_cases=int(row['inpatient_cases']),
case_density=float(row['cases_per_km2']),
risk_index=float(row['risk_index']),
risk_level=str(row['risk_level'])
))
total_cases = int(df['total_cases'].sum())
return GridCaseResponse(
grids=grids,
total_count=len(grids),
total_cases=total_cases
)
except Exception as e:
logger.exception("Error loading grid case data")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/geocoded", response_model=GeocodedResponse, summary="Get geocoded case data")
async def get_geocoded_cases(
limit: int = 1000,
district: Optional[str] = None,
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)
# Drop rows with missing coordinates
df = df.dropna(subset=['latitude', 'longitude'])
# Fix swapped lat/lon (Wuhan: lat ~29.9-31.4, lon ~113.7-115.1)
swapped = df['latitude'] > 50 # longitude values are >113
df.loc[swapped, ['latitude', 'longitude']] = df.loc[swapped, ['longitude', 'latitude']].values
# Filter by 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]
# Limit for performance
df = df.head(limit)
cases = []
for row in df.to_dict('records'):
street_val = row.get('street')
if pd.isna(street_val):
street_val = None
district_val = row.get('district', '')
if pd.isna(district_val):
district_val = '未知'
cases.append(GeocodedCaseData(
case_id=str(row['case_id']),
case_type=str(row['case_type']),
latitude=float(row['latitude']),
longitude=float(row['longitude']),
district=str(district_val),
street=street_val,
geocode_method=str(row.get('geocode_method', 'unknown')),
confidence=float(row.get('confidence', 0) or 0) if not pd.isna(row.get('confidence')) else 0.0
))
return GeocodedResponse(
cases=cases,
total_count=len(cases)
)
except Exception as e:
logger.exception("Error loading geocoded case data")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/geocoded/count", summary="Get geocoded case count")
async def get_geocoded_count():
"""Get total count of geocoded cases."""
cases_file = DATA_DIR / "geocoded_all_cases.csv"
if not cases_file.exists():
raise HTTPException(status_code=404, detail="Geocoded data not found")
try:
df = _load_csv(cases_file)
street_matched = len(df[df['geocode_method'] == 'street'])
district_fallback = len(df[df['geocode_method'] == 'district'])
return {
"total": len(df),
"street_matched": street_matched,
"district_fallback": district_fallback,
"match_rate": round(street_matched / len(df) * 100, 1)
}
except Exception as e:
logger.exception("Error counting geocoded cases")
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/streets", response_model=StreetsResponse, summary="Get street-level aggregation")
async def get_streets(district: str = Query(..., description="District name")):
"""Get street-level aggregated case data for a district."""
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)
df = df.dropna(subset=['latitude', 'longitude'])
df = df[df['district'] == district]
# Group by street
streets = []
if 'street' in df.columns:
street_groups = df.groupby('street')
for street, group in street_groups:
if pd.isna(street) or str(street).strip() == '':
continue
out_count = len(group[group['case_type'] == 'outpatient'])
in_count = len(group[group['case_type'] == 'inpatient'])
streets.append(StreetData(
name=str(street),
total_cases=len(group),
outpatient=out_count,
inpatient=in_count
))
streets.sort(key=lambda s: s.total_cases, reverse=True)
return StreetsResponse(streets=streets)
except Exception as e:
logger.exception("Error loading street data")
raise HTTPException(status_code=500, detail="Internal server error")