Frontend features:
- 报表中心 (ReportsCenter): list/detail views, diagnosis breakdown chart, CSV export
- 多级行政下钻 (AdminBreadcrumb): 湖北省→武汉市→区→街道 hierarchical drill-down
- 按病种筛选 (DiseaseFilter): multi-select diagnosis filter on monitoring + reports pages
Backend:
- Add /forecast/{days} endpoint, diagnosis filter params on cases endpoints
- Add /streets aggregation endpoint, enrich reports with real case data
- Extract shared case_loader module
Bug fixes (14):
- Fix missing /risk/forecast route (404), historyApi pointing to non-existent router
- Fix min_risk filter silently ignored in insights/hotspots
- Fix type mismatches: CaseTrendResponse, CaseStatsResponse shapes
- Fix silent .catch(() => {}) swallowing errors, fetchAlerts not clearing stale state
- Fix lru_cache caching exceptions, generateReport used cachedGet for write op
- Fix missing useEffect deps in Insights, DistrictComparison, ReportsCenter
Performance (9):
- Zustand selectors across 9 components (eliminate re-render cascades)
- Fix districtCases.sort() mutating store state, inline IIFE → memo'd component
- CaseLocationMap: React.memo, race protection, correct deps
- AlertCard: stable callbacks, TimelinePlayer: useMemo, TopNav: clock isolation
- SideNav: modules array to module scope, DiseaseFilter: memoized filter
370 lines
13 KiB
Python
370 lines
13 KiB
Python
from fastapi import APIRouter, HTTPException, Query
|
|
from datetime import datetime, timedelta
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
import logging
|
|
import sys
|
|
import math
|
|
|
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from models import (
|
|
DistrictAggregation,
|
|
HistoricalAggregationRequest,
|
|
HistoricalAggregationResponse,
|
|
GridGeoJSONResponse,
|
|
GridPrediction,
|
|
MultiDayPredictionRequest,
|
|
MultiDayPredictionResponse,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api", tags=["grid"])
|
|
|
|
|
|
_parquet_cache: dict[str, "pd.DataFrame"] = {}
|
|
|
|
def _load_parquet(path: Path) -> "pd.DataFrame":
|
|
import pandas as pd
|
|
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
|
|
|
|
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(),
|
|
)
|
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
|
|
|
filtered_cases = cases_df[
|
|
(cases_df['date'] >= start) &
|
|
(cases_df['date'] <= end)
|
|
]
|
|
|
|
if district:
|
|
filtered_cases = filtered_cases[
|
|
filtered_cases['district'].str.contains(district.replace('区', ''), na=False, regex=False)
|
|
]
|
|
|
|
if aggregation == "weekly":
|
|
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
|
|
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
|
'total_cases': 'sum',
|
|
'outpatient_count': 'sum',
|
|
'inpatient_count': 'sum',
|
|
}).reset_index()
|
|
grouped['date'] = grouped['period']
|
|
elif aggregation == "monthly":
|
|
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
|
|
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
|
'total_cases': 'sum',
|
|
'outpatient_count': 'sum',
|
|
'inpatient_count': 'sum',
|
|
}).reset_index()
|
|
grouped['date'] = grouped['period']
|
|
else:
|
|
grouped = filtered_cases.copy()
|
|
grouped['date'] = grouped['date'].dt.strftime('%Y-%m-%d')
|
|
|
|
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['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
|
|
|
|
# Weather data doesn't have district - aggregate by date only
|
|
weather_agg = weather_df.groupby(['date']).agg({
|
|
'AQI': 'mean',
|
|
'PM25': 'mean',
|
|
'PM10': 'mean',
|
|
}).reset_index()
|
|
|
|
# Merge by date only
|
|
merged = grouped.merge(weather_agg, on=['date'], how='left')
|
|
|
|
aggregations = []
|
|
for _, row in merged.iterrows():
|
|
aggregations.append(DistrictAggregation(
|
|
district=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,
|
|
))
|
|
|
|
return HistoricalAggregationResponse(
|
|
aggregations=aggregations,
|
|
total_records=len(aggregations),
|
|
date_range=(start_date, end_date),
|
|
timestamp=datetime.now().isoformat(),
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
try:
|
|
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
|
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(),
|
|
)
|
|
|
|
|
|
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
|
|
async def predict_multi_day(request: MultiDayPredictionRequest):
|
|
"""
|
|
Multi-day prediction API for grid-level risk assessment.
|
|
|
|
Returns risk predictions for each grid cell across multiple days.
|
|
Uses the SpatialTemporalGCN model with on-demand feature generation.
|
|
"""
|
|
from scripts.generate_grid_features import GridFeatureGenerator
|
|
|
|
try:
|
|
start_date = datetime.strptime(request.date, "%Y-%m-%d")
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
|
|
|
generator = GridFeatureGenerator()
|
|
|
|
predictions = []
|
|
warnings = []
|
|
date_range = (request.date, (start_date + timedelta(days=request.days - 1)).strftime("%Y-%m-%d"))
|
|
|
|
for day_offset in range(request.days):
|
|
current_date = (start_date + timedelta(days=day_offset)).strftime("%Y-%m-%d")
|
|
|
|
try:
|
|
features_df = generator.generate_features(current_date)
|
|
|
|
if request.district:
|
|
features_df = features_df[
|
|
features_df['district'] == request.district
|
|
]
|
|
|
|
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))
|
|
|
|
if risk_1d >= 0.8:
|
|
risk_level = "high"
|
|
elif risk_1d >= 0.6:
|
|
risk_level = "medium_high"
|
|
elif risk_1d >= 0.4:
|
|
risk_level = "medium"
|
|
elif risk_1d >= 0.2:
|
|
risk_level = "medium_low"
|
|
else:
|
|
risk_level = "low"
|
|
|
|
predictions.append(GridPrediction(
|
|
grid_id=row['grid_id'],
|
|
latitude=row.get('center_lat', 0),
|
|
longitude=row.get('center_lon', 0),
|
|
risk_1day=risk_1d,
|
|
risk_3day=risk_3d,
|
|
risk_7day=risk_7d,
|
|
risk_level=risk_level,
|
|
confidence=0.85,
|
|
))
|
|
|
|
except Exception as e:
|
|
logging.getLogger("cbpoa.grid").warning("Failed to generate features for %s: %s", current_date, e)
|
|
warnings.append(f"Failed to generate features for {current_date}: {e}")
|
|
continue
|
|
|
|
if len(predictions) >= 50000:
|
|
break
|
|
|
|
return MultiDayPredictionResponse(
|
|
predictions=predictions[:50000],
|
|
total_grids=len(predictions),
|
|
date_range=date_range,
|
|
model_version="1.3.7",
|
|
timestamp=datetime.now().isoformat(),
|
|
partial=len(warnings) > 0,
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
@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.
|
|
"""
|
|
import pandas as pd
|
|
|
|
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
|
grid_info = district_map[district_map['grid_id'] == grid_id]
|
|
|
|
if len(grid_info) == 0:
|
|
raise HTTPException(status_code=404, detail="Grid not found")
|
|
|
|
district = grid_info.iloc[0]['district_name']
|
|
|
|
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
|
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
|
|
|
end_date = datetime.now()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
filtered = cases_df[
|
|
(cases_df['date'] >= start_date) &
|
|
(cases_df['date'] <= end_date) &
|
|
(cases_df['district'] == district)
|
|
]
|
|
|
|
history = []
|
|
for _, row in filtered.iterrows():
|
|
history.append({
|
|
"date": row['date'].strftime("%Y-%m-%d"),
|
|
"cases": int(row['total_cases']),
|
|
"outpatient": int(row['outpatient_count']),
|
|
"inpatient": int(row['inpatient_count']),
|
|
})
|
|
|
|
return {
|
|
"grid_id": grid_id,
|
|
"district": district,
|
|
"history": history,
|
|
"timestamp": datetime.now().isoformat(),
|
|
} |