""" 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=1) 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, ): """ Get individual geocoded case data. Args: limit: Maximum number of cases to return (for performance) district: Filter by district name """ cases_file = DATA_DIR / "geocoded_all_cases.csv" if not cases_file.exists(): raise HTTPException(status_code=404, detail="Geocoded data not found") try: df = _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 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")