fix: analysis 500s, caching, alert page perf
P0: Fix KeyError in 3 analysis endpoints. geojson.py stores 1d risk as "risk_value" but analysis.py accessed "risk_1d" — always crashed. Backend: Add lru_cache to GeoJSON/CSV/Parquet loaders, date helpers, and district loader. Add try/except and FileNotFoundError guards. Frontend: Debounce riskRange, merge counts into useMemo, stabilize handleGridClick with ref, memoize nearest-grid scan, wrap AlertMap in React.memo, switch useLodGrid from fetch to cachedGet.
This commit is contained in:
@@ -83,7 +83,7 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
if filepath.exists():
|
||||
grids = parse_geojson_file(filepath)
|
||||
if grids:
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids)
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
values.append(round(avg_risk, 4))
|
||||
else:
|
||||
values.append(0)
|
||||
@@ -125,8 +125,8 @@ async def get_districts():
|
||||
|
||||
if not districts:
|
||||
# Fallback: return city-wide aggregation
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids) if grids else 0
|
||||
high_risk_count = sum(1 for g in grids if g["risk_1d"] >= RISK_HIGH)
|
||||
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=[
|
||||
@@ -150,14 +150,14 @@ async def get_districts():
|
||||
for district in districts:
|
||||
if point_in_polygon(grid["latitude"], grid["longitude"], district["coordinates"]):
|
||||
district_data[district["name"]]["grids"].append(grid)
|
||||
if grid["risk_1d"] >= RISK_HIGH:
|
||||
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_1d"] >= RISK_HIGH:
|
||||
if grid["risk_value"] >= RISK_HIGH:
|
||||
unassigned["high_risk"] += 1
|
||||
|
||||
# Build response
|
||||
@@ -169,7 +169,7 @@ async def get_districts():
|
||||
if not grids_in_district:
|
||||
continue
|
||||
|
||||
avg_risk = sum(g["risk_1d"] for g in grids_in_district) / len(grids_in_district)
|
||||
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
|
||||
@@ -187,7 +187,7 @@ async def get_districts():
|
||||
|
||||
# Add unassigned as "其他" if significant
|
||||
if unassigned["grids"]:
|
||||
avg_risk = sum(g["risk_1d"] for g in unassigned["grids"]) / len(unassigned["grids"])
|
||||
avg_risk = sum(g["risk_value"] for g in unassigned["grids"]) / len(unassigned["grids"])
|
||||
result.append(
|
||||
DistrictRisk(
|
||||
name="其他",
|
||||
@@ -224,8 +224,8 @@ async def get_correlations():
|
||||
|
||||
# Calculate mock correlations based on risk patterns
|
||||
# In production, this would use actual weather and health data
|
||||
avg_risk = sum(g["risk_1d"] for g in grids) / len(grids)
|
||||
risk_variance = sum((g["risk_1d"] - avg_risk) ** 2 for g in grids) / len(grids)
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
risk_variance = sum((g["risk_value"] - avg_risk) ** 2 for g in grids) / len(grids)
|
||||
|
||||
# Generate realistic correlation coefficients
|
||||
correlations = [
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +16,11 @@ 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
|
||||
@@ -60,7 +66,7 @@ async def get_grid_cases():
|
||||
raise HTTPException(status_code=404, detail="Grid data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(grid_file)
|
||||
df = _load_csv(grid_file)
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
@@ -105,7 +111,7 @@ async def get_geocoded_cases(
|
||||
raise HTTPException(status_code=404, detail="Geocoded data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(cases_file)
|
||||
df = _load_csv(cases_file)
|
||||
|
||||
# Drop rows with missing coordinates
|
||||
df = df.dropna(subset=['latitude', 'longitude'])
|
||||
@@ -122,7 +128,7 @@ async def get_geocoded_cases(
|
||||
df = df.head(limit)
|
||||
|
||||
cases = []
|
||||
for _, row in df.iterrows():
|
||||
for row in df.to_dict('records'):
|
||||
street_val = row.get('street')
|
||||
if pd.isna(street_val):
|
||||
street_val = None
|
||||
@@ -157,7 +163,7 @@ async def get_geocoded_count():
|
||||
raise HTTPException(status_code=404, detail="Geocoded data not found")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(cases_file)
|
||||
df = _load_csv(cases_file)
|
||||
street_matched = len(df[df['geocode_method'] == 'street'])
|
||||
district_fallback = len(df[df['geocode_method'] == 'district'])
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -22,6 +23,12 @@ from models import (
|
||||
router = APIRouter(prefix="/api", tags=["grid"])
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_parquet(path: Path) -> "pd.DataFrame":
|
||||
import pandas as pd
|
||||
return pd.read_parquet(path)
|
||||
|
||||
|
||||
@router.get("/history/aggregated", response_model=HistoricalAggregationResponse)
|
||||
async def get_historical_aggregated(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
@@ -45,7 +52,13 @@ async def get_historical_aggregated(
|
||||
|
||||
import pandas as pd
|
||||
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
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[
|
||||
@@ -78,7 +91,10 @@ async def get_historical_aggregated(
|
||||
grouped = filtered_cases.copy()
|
||||
grouped['date'] = grouped['date'].dt.strftime('%Y-%m-%d')
|
||||
|
||||
weather_df = pd.read_parquet(PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet")
|
||||
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
|
||||
@@ -124,12 +140,12 @@ async def get_grids_geojson(
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
grid_df = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
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 = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
except FileNotFoundError:
|
||||
return GridGeoJSONResponse(type="FeatureCollection", features=[], timestamp=datetime.now().isoformat())
|
||||
|
||||
@@ -139,7 +155,7 @@ async def get_grids_geojson(
|
||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||
|
||||
try:
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
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')
|
||||
@@ -313,7 +329,7 @@ async def get_grid_history(
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
district_map = pd.read_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
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:
|
||||
@@ -321,7 +337,7 @@ async def get_grid_history(
|
||||
|
||||
district = grid_info.iloc[0]['district_name']
|
||||
|
||||
cases_df = pd.read_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet")
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,7 @@ Date utilities: finding latest dates from GeoJSON files, parsing date strings.
|
||||
"""
|
||||
import glob
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -10,6 +11,7 @@ from fastapi import HTTPException
|
||||
from config import DATA_DIR, DATE_FORMAT_GEOJSON
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_latest_date() -> str:
|
||||
"""Get latest available date from GeoJSON files in DATA_DIR."""
|
||||
pattern = str(DATA_DIR / "risk_*.geojson")
|
||||
@@ -29,6 +31,7 @@ def get_latest_date() -> str:
|
||||
return max(dates)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_available_dates(days: int = 30) -> list[str]:
|
||||
"""Get list of available dates, most recent first."""
|
||||
pattern = str(DATA_DIR / "risk_*.geojson")
|
||||
|
||||
@@ -2,17 +2,26 @@
|
||||
GeoJSON file parsing utilities.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import WUHAN_BOUNDARY_PATH
|
||||
from utils.risk import risk_value_to_level
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""Parse GeoJSON file and extract grid data with standard fields."""
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
geojson = json.load(f)
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
geojson = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to parse GeoJSON file %s: %s", filepath, e)
|
||||
return []
|
||||
|
||||
grids: list[dict[str, Any]] = []
|
||||
for feature in geojson.get("features", []):
|
||||
@@ -33,6 +42,7 @@ def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
return grids
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_districts() -> list[dict[str, Any]]:
|
||||
"""Load Wuhan district boundaries from GeoJSON."""
|
||||
if not WUHAN_BOUNDARY_PATH.exists():
|
||||
|
||||
Reference in New Issue
Block a user