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:
47
backend/routers/CLAUDE.md
Normal file
47
backend/routers/CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Routers — API Endpoints
|
||||
|
||||
## Pattern
|
||||
|
||||
Each router file defines one `APIRouter(prefix=..., tags=[...])` with typed endpoints.
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from models import SomeResponse
|
||||
|
||||
router = APIRouter(prefix="/api/domain", tags=["domain"])
|
||||
|
||||
@router.get("/endpoint", response_model=SomeResponse)
|
||||
async def get_something(...):
|
||||
...
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Return Pydantic models (`response_model=`), never raw dicts
|
||||
- Use `Annotated[Type, Query(...)]` / `Path(...)` for request params
|
||||
- Spatial queries use `scipy.spatial.KDTree` for nearest-neighbor lookups
|
||||
- GeoJSON parsing is delegated to `utils/geojson.py`
|
||||
- Risk level mapping is in `utils/risk.py` — use `risk_value_to_level()` not inline thresholds
|
||||
- Large repeated queries use `@lru_cache` (from `functools`)
|
||||
- Date helpers from `utils/date_helpers.py` — always `get_latest_date()`, never guess
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Domain |
|
||||
|------|--------|
|
||||
| `risk.py` | Risk maps, grid detail, history (largest router, ~42 file reads) |
|
||||
| `alerts.py` | Alert feed, stats |
|
||||
| `cases.py` | Medical case queries (age, disease, district filters) |
|
||||
| `analysis.py` | Trend analysis, statistics |
|
||||
| `grid.py` | Grid metadata, elevation, population |
|
||||
| `reports.py` | Report generation, export |
|
||||
| `insights.py` | AI-generated insights |
|
||||
| `chat.py` | Chatbot endpoint |
|
||||
| `geocoded.py` | Geocoded case data |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- Don't use sync I/O in `async def` — use `async with db.get_connection()` for DB
|
||||
- Don't catch bare `Exception` — use specific HTTPException or let it propagate to middleware
|
||||
- Don't return raw GeoJSON dicts without Pydantic validation
|
||||
- Don't inline risk thresholds — use `utils/risk.risk_value_to_level()`
|
||||
@@ -5,6 +5,8 @@ Generates alerts from high-risk grids in GeoJSON files
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from functools import lru_cache
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STEP, LON_STEP, MAX_ALERTS
|
||||
@@ -32,8 +34,13 @@ def grid_id_to_center(grid_id: str) -> tuple[float, float]:
|
||||
return lat, lon
|
||||
|
||||
|
||||
def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
"""Generate alerts for high-risk grids on a specific date.
|
||||
@lru_cache(maxsize=8)
|
||||
def _generate_alerts_cached(date: str) -> List[Alert]:
|
||||
"""Heavy synchronous worker: parse the ~45MB GeoJSON and build alerts.
|
||||
|
||||
Cached by date so the file is parsed once per date. This runs blocking
|
||||
json.load + per-feature loops, so callers must invoke it off the event
|
||||
loop (see generate_alerts_for_date).
|
||||
|
||||
Phase 1: iterate features, aggregate max risk per 100m grid cell.
|
||||
Phase 2: build Alert objects from aggregated grid cells.
|
||||
@@ -111,6 +118,16 @@ def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
return alerts[:MAX_ALERTS]
|
||||
|
||||
|
||||
async def generate_alerts_for_date(date: str) -> List[Alert]:
|
||||
"""Async accessor: run the cached heavy parser in a thread pool.
|
||||
|
||||
Offloading the blocking json.load + per-feature aggregation keeps the
|
||||
event loop free. The lru_cache lives on the worker, so warm dates return
|
||||
near-instantly without re-parsing.
|
||||
"""
|
||||
return await asyncio.to_thread(_generate_alerts_cached, date)
|
||||
|
||||
|
||||
@router.get("", response_model=AlertResponse)
|
||||
async def list_alerts(date: str | None = None, priority: str | None = None, min_risk: float | None = None):
|
||||
if date is not None and not validate_date_format(date):
|
||||
@@ -118,7 +135,7 @@ async def list_alerts(date: str | None = None, priority: str | None = None, min_
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
|
||||
if priority:
|
||||
alerts = [a for a in alerts if a.priority == priority]
|
||||
@@ -140,7 +157,7 @@ async def get_alert(alert_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
|
||||
for alert in alerts:
|
||||
if alert.alert_id == alert_id:
|
||||
@@ -156,7 +173,7 @@ async def get_p1_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
p1_alerts = [a for a in alerts if a.priority == "P1"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -173,7 +190,7 @@ async def get_p2_alerts(date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
p2_alerts = [a for a in alerts if a.priority == "P2"]
|
||||
|
||||
return AlertResponse(
|
||||
@@ -190,7 +207,7 @@ async def get_grid_alerts(grid_id: str, date: str | None = None):
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
|
||||
alerts = generate_alerts_for_date(date)
|
||||
alerts = await generate_alerts_for_date(date)
|
||||
grid_alerts = [a for a in alerts if a.grid_id == grid_id]
|
||||
|
||||
return AlertResponse(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,11 @@ from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, date
|
||||
import asyncio
|
||||
import pandas as pd
|
||||
import json
|
||||
|
||||
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, WUHAN_DISTRICTS, DATE_PATTERN
|
||||
from data.case_loader import load_data, get_combined_data, get_outpatient_data, get_inpatient_data, get_diagnoses, WUHAN_DISTRICTS, DATE_PATTERN
|
||||
|
||||
router = APIRouter(prefix="/api/cases", tags=["cases"])
|
||||
|
||||
@@ -90,37 +91,38 @@ async def get_cases_stats(
|
||||
if diagnosis:
|
||||
df_out = df_out[df_out['初诊'].str.contains(diagnosis, na=False, case=False)]
|
||||
df_in = df_in[df_in['诊断名称'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
|
||||
# 计算统计
|
||||
total_outpatient = len(df_out)
|
||||
total_inpatient = len(df_in)
|
||||
|
||||
# 日期范围
|
||||
min_date = min(df_out['date'].min(), df_in['date'].min())
|
||||
max_date = max(df_out['date'].max(), df_in['date'].max())
|
||||
|
||||
|
||||
# 日期范围(过滤后可能为空,需防御 NaT)
|
||||
all_dates = pd.concat([df_out['date'], df_in['date']]).dropna()
|
||||
date_start = all_dates.min().strftime("%Y-%m-%d") if len(all_dates) else ""
|
||||
date_end = all_dates.max().strftime("%Y-%m-%d") if len(all_dates) else ""
|
||||
|
||||
# 区域统计
|
||||
out_districts = df_out[df_out['district'] != '未知']['district'].value_counts().head(10)
|
||||
in_districts = df_in[df_in['district'] != '其他']['district'].value_counts().head(10)
|
||||
|
||||
|
||||
combined_districts = pd.concat([out_districts, in_districts]).groupby(level=0).sum().nlargest(10)
|
||||
top_districts = [{"district": d, "count": int(c)} for d, c in combined_districts.items()]
|
||||
|
||||
|
||||
# 诊断统计
|
||||
out_diagnoses = df_out['初诊'].value_counts().head(10)
|
||||
in_diagnoses = df_in['诊断名称'].value_counts().head(10)
|
||||
|
||||
|
||||
top_diagnoses = [
|
||||
{"diagnosis": str(d), "outpatient": int(out_diagnoses.get(d, 0)), "inpatient": int(in_diagnoses.get(d, 0))}
|
||||
for d in set(list(out_diagnoses.index[:5]) + list(in_diagnoses.index[:5]))
|
||||
][:10]
|
||||
|
||||
|
||||
return StatsResponse(
|
||||
total_outpatient=total_outpatient,
|
||||
total_inpatient=total_inpatient,
|
||||
date_range={
|
||||
"start": min_date.strftime("%Y-%m-%d"),
|
||||
"end": max_date.strftime("%Y-%m-%d")
|
||||
"start": date_start,
|
||||
"end": date_end
|
||||
},
|
||||
top_districts=top_districts,
|
||||
top_diagnoses=top_diagnoses
|
||||
@@ -146,7 +148,7 @@ async def get_cases_trend(
|
||||
if end_date and not DATE_PATTERN.match(end_date):
|
||||
raise HTTPException(status_code=400, detail="Invalid end_date format. Use YYYY-MM-DD")
|
||||
|
||||
df = get_combined_data()
|
||||
df = get_combined_data().copy()
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
@@ -157,7 +159,7 @@ async def get_cases_trend(
|
||||
# 诊断过滤
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
|
||||
# 分组
|
||||
if group_by == "week":
|
||||
df['period'] = df['date'].dt.to_period('W').dt.start_time
|
||||
@@ -165,13 +167,13 @@ async def get_cases_trend(
|
||||
df['period'] = df['date'].dt.to_period('M').dt.start_time
|
||||
else:
|
||||
df['period'] = df['date'].dt.date
|
||||
|
||||
|
||||
# 聚合
|
||||
out_trend = df[df['type'] == 'outpatient'].groupby('period').size()
|
||||
in_trend = df[df['type'] == 'inpatient'].groupby('period').size()
|
||||
|
||||
|
||||
periods = sorted(set(out_trend.index.tolist() + in_trend.index.tolist()))
|
||||
|
||||
|
||||
trend = []
|
||||
total_out = total_in = 0
|
||||
for p in periods:
|
||||
@@ -180,12 +182,12 @@ async def get_cases_trend(
|
||||
total_out += out_count
|
||||
total_in += in_count
|
||||
trend.append(TrendPoint(
|
||||
date=pd.Timestamp(p).strftime("%Y-%m-%d"),
|
||||
date=str(p).split(' ')[0] if hasattr(p, 'strftime') else str(p)[:10],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count
|
||||
))
|
||||
|
||||
|
||||
return TrendResponse(
|
||||
trend=trend,
|
||||
summary={
|
||||
@@ -198,69 +200,93 @@ async def get_cases_trend(
|
||||
)
|
||||
|
||||
|
||||
def _compute_cases_districts(
|
||||
case_type: Optional[str],
|
||||
min_count: int,
|
||||
diagnosis: Optional[str],
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
) -> DistrictsResponse:
|
||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||
df = get_combined_data()
|
||||
|
||||
# 诊断过滤
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
df = df[df['date'] >= pd.to_datetime(start_date)]
|
||||
if end_date:
|
||||
df = df[df['date'] <= pd.to_datetime(end_date)]
|
||||
|
||||
# 类型过滤
|
||||
if case_type == "outpatient":
|
||||
df = df[df['type'] == 'outpatient']
|
||||
elif case_type == "inpatient":
|
||||
df = df[df['type'] == 'inpatient']
|
||||
|
||||
# 过滤未知区域
|
||||
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
||||
|
||||
# 聚合
|
||||
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
||||
|
||||
if 'outpatient' not in district_stats.columns:
|
||||
district_stats['outpatient'] = 0
|
||||
if 'inpatient' not in district_stats.columns:
|
||||
district_stats['inpatient'] = 0
|
||||
|
||||
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
||||
|
||||
# 过滤
|
||||
district_stats = district_stats[district_stats['total'] >= min_count]
|
||||
district_stats = district_stats.sort_values('total', ascending=False)
|
||||
|
||||
total = int(district_stats['total'].sum())
|
||||
|
||||
districts = []
|
||||
for district, row in district_stats.iterrows():
|
||||
districts.append(DistrictData(
|
||||
district=str(district),
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
outpatient_ratio=round(float(row['outpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0,
|
||||
inpatient_ratio=round(float(row['inpatient']) / float(row['total']) * 100, 2) if row['total'] > 0 else 0
|
||||
))
|
||||
|
||||
return DistrictsResponse(districts=districts, total=total)
|
||||
|
||||
|
||||
@router.get("/districts", response_model=DistrictsResponse, summary="获取区域分布数据")
|
||||
async def get_cases_districts(
|
||||
case_type: Optional[str] = Query(None, description="病例类型:outpatient, inpatient, all"),
|
||||
min_count: int = Query(10, description="最小病例数过滤"),
|
||||
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
|
||||
start_date: Optional[str] = Query(None, description="开始日期 (YYYY-MM-DD)"),
|
||||
end_date: Optional[str] = Query(None, description="结束日期 (YYYY-MM-DD)"),
|
||||
):
|
||||
"""
|
||||
获取病例区域分布数据
|
||||
|
||||
- 支持按病例类型筛选
|
||||
- 可设置最小病例数过滤
|
||||
- 支持日期范围过滤
|
||||
- 返回各区门诊、住院量及占比
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
# 诊断过滤
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# 类型过滤
|
||||
if case_type == "outpatient":
|
||||
df = df[df['type'] == 'outpatient']
|
||||
elif case_type == "inpatient":
|
||||
df = df[df['type'] == 'inpatient']
|
||||
|
||||
# 过滤未知区域
|
||||
df = df[(df['district'] != '未知') & (df['district'] != '其他')]
|
||||
|
||||
# 聚合
|
||||
district_stats = df.groupby(['district', 'type']).size().unstack(fill_value=0)
|
||||
|
||||
if 'outpatient' not in district_stats.columns:
|
||||
district_stats['outpatient'] = 0
|
||||
if 'inpatient' not in district_stats.columns:
|
||||
district_stats['inpatient'] = 0
|
||||
|
||||
district_stats['total'] = district_stats['outpatient'] + district_stats['inpatient']
|
||||
|
||||
# 过滤
|
||||
district_stats = district_stats[district_stats['total'] >= min_count]
|
||||
district_stats = district_stats.sort_values('total', ascending=False)
|
||||
|
||||
total = int(district_stats['total'].sum())
|
||||
|
||||
districts = []
|
||||
for district, row in district_stats.iterrows():
|
||||
districts.append(DistrictData(
|
||||
district=district,
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
outpatient_ratio=round(row['outpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0,
|
||||
inpatient_ratio=round(row['inpatient'] / row['total'] * 100, 2) if row['total'] > 0 else 0
|
||||
))
|
||||
|
||||
return DistrictsResponse(districts=districts, total=total)
|
||||
Pandas processing runs in a thread pool to avoid blocking the async event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
_compute_cases_districts, case_type, min_count, diagnosis, start_date, end_date
|
||||
)
|
||||
|
||||
|
||||
@router.get("/realtime", response_model=RealtimeData, summary="获取实时数据")
|
||||
async def get_cases_realtime():
|
||||
"""
|
||||
获取实时病例数据
|
||||
|
||||
|
||||
- 今日就诊量
|
||||
- 近 7 日平均值
|
||||
- 变化率
|
||||
@@ -270,23 +296,23 @@ async def get_cases_realtime():
|
||||
|
||||
today = pd.Timestamp.today().normalize()
|
||||
last_7d = today - pd.Timedelta(days=7)
|
||||
|
||||
|
||||
# 今日数据
|
||||
today_data = df[df['date'] >= today]
|
||||
today_total = len(today_data)
|
||||
today_out = len(today_data[today_data['type'] == 'outpatient'])
|
||||
today_in = len(today_data[today_data['type'] == 'inpatient'])
|
||||
|
||||
|
||||
# 近 7 日平均
|
||||
last_7d_data = df[(df['date'] >= last_7d) & (df['date'] < today)]
|
||||
last_7d_avg = round(len(last_7d_data) / 7, 2) if len(last_7d_data) > 0 else 0
|
||||
|
||||
|
||||
# 变化率
|
||||
if last_7d_avg > 0:
|
||||
change_ratio = round((today_total - last_7d_avg) / last_7d_avg * 100, 2)
|
||||
else:
|
||||
change_ratio = 0.0
|
||||
|
||||
|
||||
# 状态评估
|
||||
if change_ratio > 20:
|
||||
status = "偏高"
|
||||
@@ -294,12 +320,12 @@ async def get_cases_realtime():
|
||||
status = "偏低"
|
||||
else:
|
||||
status = "正常"
|
||||
|
||||
|
||||
return RealtimeData(
|
||||
today_outpatient=today_out,
|
||||
today_inpatient=today_in,
|
||||
today_total=today_total,
|
||||
last_7d_avg=last_7d_avg,
|
||||
last_7d_avg=int(last_7d_avg),
|
||||
change_ratio=change_ratio,
|
||||
status=status
|
||||
)
|
||||
@@ -311,8 +337,323 @@ class DiagnosesResponse(BaseModel):
|
||||
|
||||
|
||||
@router.get("/diagnoses", response_model=DiagnosesResponse, summary="获取所有诊断名称列表")
|
||||
async def get_diagnoses():
|
||||
"""Returns deduplicated, sorted list of unique diagnosis names"""
|
||||
df = get_combined_data()
|
||||
diagnoses = sorted(df['diagnosis'].dropna().unique().tolist())
|
||||
async def get_diagnoses_list():
|
||||
"""Returns deduplicated, sorted list of unique diagnosis names (cached, fast)."""
|
||||
diagnoses = get_diagnoses()
|
||||
return DiagnosesResponse(diagnoses=diagnoses)
|
||||
|
||||
|
||||
# ============== Seasonal & Distribution Endpoints ==============
|
||||
|
||||
class SeasonalPoint(BaseModel):
|
||||
"""月度聚合数据点"""
|
||||
month: int # 1-12
|
||||
month_label: str # "1月", "2月", ...
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class SeasonalResponse(BaseModel):
|
||||
"""月度季节性响应"""
|
||||
monthly: list[SeasonalPoint]
|
||||
period_years: list[int] # e.g. [2022, 2023, 2024]
|
||||
total_cases: int
|
||||
|
||||
|
||||
class DiagnosisDistributionItem(BaseModel):
|
||||
"""诊断分布数据项"""
|
||||
diagnosis: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class DiagnosisDistributionResponse(BaseModel):
|
||||
"""诊断分布响应"""
|
||||
diagnoses: list[DiagnosisDistributionItem]
|
||||
total_cases: int
|
||||
|
||||
|
||||
# ============== Demographics Models ==============
|
||||
|
||||
class AgeBin(BaseModel):
|
||||
"""年龄分段数据"""
|
||||
age_bin: int # 0-17
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
|
||||
|
||||
class GenderSplit(BaseModel):
|
||||
"""性别拆分数据"""
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
|
||||
|
||||
class GenderSplitData(BaseModel):
|
||||
"""性别分布响应内层"""
|
||||
male: GenderSplit
|
||||
female: GenderSplit
|
||||
|
||||
|
||||
class AgeDiagnosisMatrixItem(BaseModel):
|
||||
"""年龄-诊断矩阵项"""
|
||||
age_group: str # "0-1", "1-3", "3-6", "6-12", "12-18"
|
||||
diagnosis: str
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class DemographicsResponse(BaseModel):
|
||||
"""人口统计响应"""
|
||||
age_distribution: list[AgeBin]
|
||||
gender_split: GenderSplitData
|
||||
age_diagnosis_matrix: list[AgeDiagnosisMatrixItem]
|
||||
|
||||
|
||||
# ============== Disease Seasonality Models ==============
|
||||
|
||||
class DiseaseSeasonalityPoint(BaseModel):
|
||||
"""疾病月度季节性数据点"""
|
||||
diagnosis: str
|
||||
month: int # 1-12
|
||||
month_label: str # "1月"-"12月"
|
||||
outpatient: int
|
||||
inpatient: int
|
||||
total: int
|
||||
|
||||
|
||||
class DiseaseDistrictItem(BaseModel):
|
||||
"""单个诊断的区域分布(按病例数排序的前若干区)"""
|
||||
diagnosis: str
|
||||
district: str
|
||||
total: int
|
||||
|
||||
|
||||
class DiseaseSeasonalityResponse(BaseModel):
|
||||
"""疾病季节性响应"""
|
||||
seasonality: list[DiseaseSeasonalityPoint]
|
||||
diagnoses: list[str]
|
||||
# 每个诊断的真实区域分布(按区聚合),使前端可为每个诊断显示其各自的"主要区域"
|
||||
diagnosis_districts: list[DiseaseDistrictItem]
|
||||
|
||||
|
||||
@router.get("/seasonal", response_model=SeasonalResponse, summary="获取季节性月度聚合数据")
|
||||
async def get_cases_seasonal(
|
||||
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
|
||||
):
|
||||
"""
|
||||
按月聚合所有年份的病例数据
|
||||
|
||||
- 返回 1-12 月各月门诊/住院/总计均值
|
||||
- 支持诊断过滤
|
||||
- 用于季节性分解图表
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# Extract month and aggregate
|
||||
df = df.copy()
|
||||
df['month'] = df['date'].dt.month
|
||||
years = sorted(df['date'].dt.year.unique().tolist())
|
||||
|
||||
out_monthly = df[df['type'] == 'outpatient'].groupby('month').size()
|
||||
in_monthly = df[df['type'] == 'inpatient'].groupby('month').size()
|
||||
|
||||
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月']
|
||||
|
||||
monthly = []
|
||||
total_cases = 0
|
||||
for m in range(1, 13):
|
||||
out_count = int(out_monthly.get(m, 0))
|
||||
in_count = int(in_monthly.get(m, 0))
|
||||
total_cases += out_count + in_count
|
||||
monthly.append(SeasonalPoint(
|
||||
month=m,
|
||||
month_label=month_labels[m - 1],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count,
|
||||
))
|
||||
|
||||
return SeasonalResponse(
|
||||
monthly=monthly,
|
||||
period_years=years,
|
||||
total_cases=total_cases,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/diagnosis-distribution", response_model=DiagnosisDistributionResponse, summary="获取诊断分布统计")
|
||||
async def get_diagnosis_distribution(
|
||||
limit: int = Query(default=20, ge=1, le=50, description="Maximum diagnoses to return"),
|
||||
):
|
||||
"""
|
||||
获取诊断名称分布统计(门诊+住院分列)
|
||||
|
||||
- 返回前 N 个诊断及门诊/住院/总计/占比
|
||||
- 用于诊断分布饼图、树图等
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
# Compute O/I counts per diagnosis
|
||||
breakdown = df.groupby(['diagnosis', 'type']).size().unstack(fill_value=0)
|
||||
if 'outpatient' not in breakdown.columns:
|
||||
breakdown['outpatient'] = 0
|
||||
if 'inpatient' not in breakdown.columns:
|
||||
breakdown['inpatient'] = 0
|
||||
breakdown['total'] = breakdown['outpatient'] + breakdown['inpatient']
|
||||
breakdown = breakdown.sort_values('total', ascending=False).head(limit)
|
||||
|
||||
grand_total = int(breakdown['total'].sum())
|
||||
|
||||
diagnoses = []
|
||||
for diagnosis_name, row in breakdown.iterrows():
|
||||
diagnoses.append(DiagnosisDistributionItem(
|
||||
diagnosis=str(diagnosis_name),
|
||||
outpatient=int(row['outpatient']),
|
||||
inpatient=int(row['inpatient']),
|
||||
total=int(row['total']),
|
||||
percentage=round(float(row['total']) / float(grand_total) * 100, 2) if grand_total > 0 else 0,
|
||||
))
|
||||
|
||||
return DiagnosisDistributionResponse(
|
||||
diagnoses=diagnoses,
|
||||
total_cases=grand_total,
|
||||
)
|
||||
|
||||
|
||||
# ============== Demographics Endpoint ==============
|
||||
|
||||
@router.get("/demographics", response_model=DemographicsResponse, summary="获取人口统计信息")
|
||||
async def get_cases_demographics():
|
||||
"""
|
||||
获取病例人口统计信息
|
||||
|
||||
- 年龄分布(0-17岁,按1岁分段,仅住院数据)
|
||||
- 性别分布(仅住院数据)
|
||||
- 年龄-诊断矩阵(按年龄段分组,仅住院数据)
|
||||
|
||||
注意:门诊数据不包含人口统计信息(性别/年龄),因此门诊计数均为 0。
|
||||
"""
|
||||
df = get_inpatient_data()
|
||||
df = df.copy()
|
||||
df['age_bin'] = df['年龄'].clip(0, 17).astype(int)
|
||||
|
||||
# --- Age distribution: 1-year bins from 0 to 17 ---
|
||||
age_counts = df.groupby('age_bin').size()
|
||||
age_distribution = [
|
||||
AgeBin(age_bin=a, outpatient=0, inpatient=int(age_counts.get(a, 0)))
|
||||
for a in range(0, 18)
|
||||
]
|
||||
|
||||
# --- Gender split ---
|
||||
gender_counts = df['性别'].value_counts()
|
||||
gender_split = GenderSplitData(
|
||||
male=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('男性', 0))),
|
||||
female=GenderSplit(outpatient=0, inpatient=int(gender_counts.get('女性', 0))),
|
||||
)
|
||||
|
||||
# --- Age-diagnosis matrix ---
|
||||
age_bins = [
|
||||
(0, 1, "0-1"), (1, 3, "1-3"), (3, 6, "3-6"),
|
||||
(6, 12, "6-12"), (12, 18, "12-18"),
|
||||
]
|
||||
|
||||
matrix_rows: list[AgeDiagnosisMatrixItem] = []
|
||||
for low, high, label in age_bins:
|
||||
group = df[(df['年龄'] >= low) & (df['年龄'] < high)]
|
||||
for diag, count in group['诊断名称'].value_counts().items():
|
||||
matrix_rows.append(AgeDiagnosisMatrixItem(
|
||||
age_group=label, diagnosis=str(diag),
|
||||
outpatient=0, inpatient=int(count), total=int(count),
|
||||
))
|
||||
|
||||
return DemographicsResponse(
|
||||
age_distribution=age_distribution,
|
||||
gender_split=gender_split,
|
||||
age_diagnosis_matrix=matrix_rows,
|
||||
)
|
||||
|
||||
|
||||
# ============== Disease Seasonality Endpoint ==============
|
||||
|
||||
@router.get("/disease-seasonality", response_model=DiseaseSeasonalityResponse, summary="获取疾病季节性数据")
|
||||
async def get_disease_seasonality(
|
||||
diagnosis: Optional[str] = Query(None, description="Filter by diagnosis name"),
|
||||
):
|
||||
"""
|
||||
获取各诊断的月度季节性分布数据
|
||||
|
||||
- 基于门诊+住院合并数据
|
||||
- 按月聚合所有年份,返回 top 10 诊断的月度分布
|
||||
- 支持可选诊断过滤
|
||||
- 用于疾病季节性热力图、雷达图等
|
||||
"""
|
||||
df = get_combined_data()
|
||||
|
||||
if diagnosis:
|
||||
df = df[df['diagnosis'].str.contains(diagnosis, na=False, case=False)]
|
||||
|
||||
# Extract month
|
||||
df = df.copy()
|
||||
df['month'] = df['date'].dt.month
|
||||
|
||||
# Get top 10 diagnoses by total case count
|
||||
diag_totals = df.groupby('diagnosis').size().nlargest(10)
|
||||
top_diagnoses = diag_totals.index.tolist()
|
||||
|
||||
month_labels = ['1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月']
|
||||
|
||||
# Filter to top diagnoses
|
||||
df_top = df[df['diagnosis'].isin(top_diagnoses)]
|
||||
|
||||
# Group by diagnosis + month
|
||||
breakdown = df_top.groupby(['diagnosis', 'month', 'type']).size().unstack(fill_value=0)
|
||||
if 'outpatient' not in breakdown.columns:
|
||||
breakdown['outpatient'] = 0
|
||||
if 'inpatient' not in breakdown.columns:
|
||||
breakdown['inpatient'] = 0
|
||||
|
||||
seasonality: list[DiseaseSeasonalityPoint] = []
|
||||
for diag in top_diagnoses:
|
||||
for m in range(1, 13):
|
||||
row = breakdown.loc[(diag, m)] if (diag, m) in breakdown.index else None
|
||||
out_count = int(row['outpatient']) if row is not None else 0
|
||||
in_count = int(row['inpatient']) if row is not None else 0
|
||||
seasonality.append(DiseaseSeasonalityPoint(
|
||||
diagnosis=str(diag),
|
||||
month=m,
|
||||
month_label=month_labels[m - 1],
|
||||
outpatient=out_count,
|
||||
inpatient=in_count,
|
||||
total=out_count + in_count,
|
||||
))
|
||||
|
||||
# Per-diagnosis district distribution (real aggregation by diagnosis × district).
|
||||
# Previously the frontend showed the same "主要区域" for every diagnosis because
|
||||
# no per-diagnosis district data was exposed. Top 3 districts per diagnosis.
|
||||
df_districts = df_top[(df_top['district'] != '未知') & (df_top['district'] != '其他')]
|
||||
diag_district_counts = df_districts.groupby(['diagnosis', 'district']).size()
|
||||
diagnosis_districts: list[DiseaseDistrictItem] = []
|
||||
for diag in top_diagnoses:
|
||||
if diag not in diag_district_counts.index.get_level_values('diagnosis'):
|
||||
continue
|
||||
top_d = diag_district_counts.loc[diag].sort_values(ascending=False).head(3)
|
||||
for district_name, count in top_d.items():
|
||||
diagnosis_districts.append(DiseaseDistrictItem(
|
||||
diagnosis=str(diag),
|
||||
district=str(district_name),
|
||||
total=int(count),
|
||||
))
|
||||
|
||||
return DiseaseSeasonalityResponse(
|
||||
seasonality=seasonality,
|
||||
diagnoses=[str(d) for d in top_diagnoses],
|
||||
diagnosis_districts=diagnosis_districts,
|
||||
)
|
||||
|
||||
325
backend/routers/environment.py
Normal file
325
backend/routers/environment.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
环境数据 API 路由
|
||||
|
||||
提供空气污染物时间序列和滞后相关性分析接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from scipy.stats import pearsonr
|
||||
|
||||
logger = logging.getLogger("cbpoa.environment")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
router = APIRouter(prefix="/api/environment", tags=["environment"])
|
||||
|
||||
# 污染物列表(CO 无基值列,仅存在于 lag_features.parquet 的滞后列中)
|
||||
POLLUTANTS = ["AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"]
|
||||
LAGS = [1, 2, 3, 5, 7, 14]
|
||||
|
||||
# station_daily 列名映射(PM25 无点号 -> PM2.5 带点号)
|
||||
_STATION_COL_MAP: dict[str, str] | None = None
|
||||
# lag_features 基值列映射
|
||||
_LAG_BASE_MAP: dict[str, str] | None = None
|
||||
|
||||
|
||||
# ============== Response Models ==============
|
||||
|
||||
|
||||
class LagCorrelationItem(BaseModel):
|
||||
pollutant: str # "AQI", "PM2.5", "PM10", "SO2", "NO2", "O3"
|
||||
lag_days: int # 1, 2, 3, 5, 7, 14
|
||||
correlation: float # Pearson r
|
||||
|
||||
|
||||
class LagCorrelationResponse(BaseModel):
|
||||
correlations: list[LagCorrelationItem]
|
||||
data_note: str # "CO excluded - no base column in lag_features.parquet"
|
||||
|
||||
|
||||
class PollutantPoint(BaseModel):
|
||||
date: str
|
||||
AQI: float
|
||||
PM25: float
|
||||
PM10: float
|
||||
SO2: float
|
||||
NO2: float
|
||||
O3: float
|
||||
CO: float
|
||||
|
||||
|
||||
class PollutantResponse(BaseModel):
|
||||
data: list[PollutantPoint]
|
||||
station_count: int
|
||||
date_range: dict # {start, end}
|
||||
|
||||
|
||||
# ============== Helper Functions ==============
|
||||
|
||||
|
||||
def _get_station_col_map() -> dict[str, str]:
|
||||
"""返回 station_daily parquet 中实际列名到标准名称的映射。
|
||||
|
||||
station_daily 文件中 PM2.5 列名为 "PM25"(无点号),
|
||||
需要映射到前端期望的 "PM2.5"。
|
||||
"""
|
||||
global _STATION_COL_MAP
|
||||
if _STATION_COL_MAP is not None:
|
||||
return _STATION_COL_MAP
|
||||
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
|
||||
df = pd.read_parquet(path)
|
||||
cols = set(df.columns)
|
||||
|
||||
col_map = {}
|
||||
for standard in POLLUTANTS:
|
||||
if standard in cols:
|
||||
col_map[standard] = standard
|
||||
elif "PM25" in cols and standard == "PM2.5":
|
||||
col_map[standard] = "PM25"
|
||||
else:
|
||||
col_map[standard] = standard # fallback
|
||||
|
||||
# CO is in station_daily but not in POLLUTANTS
|
||||
if "CO" in cols:
|
||||
col_map["CO"] = "CO"
|
||||
|
||||
_STATION_COL_MAP = col_map
|
||||
return _STATION_COL_MAP
|
||||
|
||||
|
||||
def _get_lag_base_map() -> dict[str, str]:
|
||||
"""返回 lag_features parquet 中基值列名到标准名称的映射。
|
||||
|
||||
lag_features 中 PM2.5 列名为 "PM2.5"(带点号),与标准名称一致。
|
||||
此函数在运行时验证实际列名。
|
||||
"""
|
||||
global _LAG_BASE_MAP
|
||||
if _LAG_BASE_MAP is not None:
|
||||
return _LAG_BASE_MAP
|
||||
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
|
||||
df = pd.read_parquet(path)
|
||||
cols = set(df.columns)
|
||||
|
||||
base_map = {}
|
||||
for p in POLLUTANTS:
|
||||
if p in cols:
|
||||
base_map[p] = p
|
||||
elif p == "PM2.5" and "PM25" in cols:
|
||||
base_map[p] = "PM25"
|
||||
else:
|
||||
base_map[p] = p # will be checked later
|
||||
|
||||
_LAG_BASE_MAP = base_map
|
||||
return _LAG_BASE_MAP
|
||||
|
||||
|
||||
def _load_lag_features() -> pd.DataFrame:
|
||||
"""加载 lag_features.parquet 并转换日期列。"""
|
||||
path = PROJECT_ROOT / "processed" / "weather" / "lag_features.parquet"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"lag_features.parquet not found at {path}")
|
||||
df = pd.read_parquet(path)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
return df
|
||||
|
||||
|
||||
def _load_cases_daily() -> pd.DataFrame:
|
||||
"""加载 cases_combined.parquet 并按日期汇总每日总病例数。"""
|
||||
path = PROJECT_ROOT / "processed" / "cases_combined.parquet"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"cases_combined.parquet not found at {path}")
|
||||
df = pd.read_parquet(path)
|
||||
df["date"] = pd.to_datetime(df["date"])
|
||||
daily = df.groupby("date").size().reset_index(name="total_cases")
|
||||
return daily
|
||||
|
||||
|
||||
def _load_station_daily() -> pd.DataFrame:
|
||||
"""加载并合并 station_daily_2022.parquet 和 station_daily_2023.parquet。"""
|
||||
dfs = []
|
||||
for year in [2022, 2023]:
|
||||
path = PROJECT_ROOT / "processed" / "weather" / f"station_daily_{year}.parquet"
|
||||
if not path.exists():
|
||||
logger.warning("station_daily_%s.parquet not found at %s", year, path)
|
||||
continue
|
||||
df = pd.read_parquet(path)
|
||||
dfs.append(df)
|
||||
if not dfs:
|
||||
raise FileNotFoundError("No station_daily parquet files found")
|
||||
combined = pd.concat(dfs, ignore_index=True)
|
||||
combined["date"] = pd.to_datetime(combined["date"])
|
||||
return combined
|
||||
|
||||
|
||||
# ============== Endpoints ==============
|
||||
|
||||
|
||||
@router.get("/lag-correlations", response_model=LagCorrelationResponse)
|
||||
async def get_lag_correlations():
|
||||
"""获取污染物滞后相关性分析数据。
|
||||
|
||||
计算各污染物在不同滞后天数(1, 2, 3, 5, 7, 14 天)下
|
||||
与每日病例总数之间的 Pearson 相关系数。
|
||||
|
||||
Returns:
|
||||
LagCorrelationResponse: 包含 36 个相关系数(6 种污染物 × 6 个滞后天数)
|
||||
"""
|
||||
try:
|
||||
lag_df = _load_lag_features()
|
||||
cases_daily = _load_cases_daily()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("Data file not found for lag-correlations: %s", e)
|
||||
return LagCorrelationResponse(
|
||||
correlations=[],
|
||||
data_note="CO excluded - no base column in lag_features.parquet",
|
||||
)
|
||||
|
||||
# 计算每日全市均值(按日期聚合,对 23 个站点取平均)
|
||||
base_map = _get_lag_base_map()
|
||||
mean_cols = {p: base_map.get(p, p) for p in POLLUTANTS}
|
||||
daily_mean = lag_df.groupby("date")[list(mean_cols.values())].mean().reset_index()
|
||||
|
||||
# 重命名列为标准名称以便一致访问
|
||||
rename_map = {v: k for k, v in mean_cols.items() if v != k}
|
||||
if rename_map:
|
||||
daily_mean = daily_mean.rename(columns=rename_map)
|
||||
|
||||
# 对每种污染物计算每日均值
|
||||
# 对齐污染物时间序列与病例数据
|
||||
merged = daily_mean.merge(cases_daily, on="date", how="inner")
|
||||
merged = merged.sort_values("date")
|
||||
|
||||
results: list[LagCorrelationItem] = []
|
||||
|
||||
for pollutant in POLLUTANTS:
|
||||
# 构建滞后列映射(lag_features 文件名用点号 "PM2.5")
|
||||
lag_base_name = base_map.get(pollutant, pollutant)
|
||||
|
||||
for lag in LAGS:
|
||||
lag_col = f"{lag_base_name}_lag{lag}"
|
||||
if lag_col not in lag_df.columns:
|
||||
logger.debug("Lag column %s not found, skipping", lag_col)
|
||||
continue
|
||||
|
||||
# 从原始 lag_features 提取该污染物的滞后数据(按日期取全市均值)
|
||||
lag_series = lag_df.groupby("date")[lag_col].mean().reset_index()
|
||||
lag_series = lag_series.rename(columns={lag_col: f"{pollutant}_lag{lag}"})
|
||||
|
||||
# 将滞后污染物数据与病例数据对齐
|
||||
# 滞后列的值代表的是 t-lag 时刻的污染物,病例是 t 时刻
|
||||
# 所以将 lag 列的时间向后平移 lag 天,使其与病例时间对齐
|
||||
lag_series["align_date"] = lag_series["date"] + pd.Timedelta(days=lag)
|
||||
combined = lag_series.merge(
|
||||
cases_daily, left_on="align_date", right_on="date", how="inner"
|
||||
)
|
||||
|
||||
if len(combined) < 10:
|
||||
logger.debug(
|
||||
"Insufficient data for %s lag%d: %d rows, skipping",
|
||||
pollutant,
|
||||
lag,
|
||||
len(combined),
|
||||
)
|
||||
continue
|
||||
|
||||
# 计算 Pearson 相关系数
|
||||
r, _ = pearsonr(
|
||||
combined[f"{pollutant}_lag{lag}"], combined["total_cases"]
|
||||
)
|
||||
results.append(
|
||||
LagCorrelationItem(
|
||||
pollutant=pollutant, lag_days=lag, correlation=round(float(r), 4)
|
||||
)
|
||||
)
|
||||
|
||||
return LagCorrelationResponse(
|
||||
correlations=results,
|
||||
data_note="CO excluded - no base column in lag_features.parquet",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pollutants", response_model=PollutantResponse)
|
||||
async def get_pollutants(
|
||||
days: Optional[int] = Query(default=30, ge=1, le=730, description="返回最近 N 天的数据"),
|
||||
start_date: Optional[str] = Query(default=None, description="开始日期 YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(default=None, description="结束日期 YYYY-MM-DD"),
|
||||
):
|
||||
"""获取每日全市均值污染物时间序列。
|
||||
|
||||
合并 2022/2023 两个年度的站点日数据,按日期聚合所有站点取均值。
|
||||
|
||||
可通过 ?days=N(默认 30)或 ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD 筛选日期范围。
|
||||
"""
|
||||
try:
|
||||
df = _load_station_daily()
|
||||
except FileNotFoundError as e:
|
||||
logger.warning("Data file not found for pollutants: %s", e)
|
||||
return PollutantResponse(data=[], station_count=0, date_range={})
|
||||
|
||||
station_count = df["station_id"].nunique()
|
||||
|
||||
# 获取列名映射
|
||||
col_map = _get_station_col_map()
|
||||
|
||||
# 选择污染物列
|
||||
poll_cols = [col_map.get(p, p) for p in POLLUTANTS] + ["CO"]
|
||||
# 确保需要的列都存在
|
||||
available_cols = [c for c in poll_cols if c in df.columns]
|
||||
|
||||
# 按日期聚合取均值
|
||||
daily_mean = df.groupby("date")[available_cols].mean().reset_index()
|
||||
|
||||
# 重命名为标准名称
|
||||
rename_map = {}
|
||||
for std_name, actual_name in col_map.items():
|
||||
if actual_name != std_name and actual_name in daily_mean.columns:
|
||||
rename_map[actual_name] = std_name
|
||||
if rename_map:
|
||||
daily_mean = daily_mean.rename(columns=rename_map)
|
||||
|
||||
# 日期筛选
|
||||
if start_date and end_date:
|
||||
start_dt = pd.to_datetime(start_date)
|
||||
end_dt = pd.to_datetime(end_date)
|
||||
date_mask = (daily_mean["date"] >= start_dt) & (
|
||||
daily_mean["date"] <= end_dt
|
||||
)
|
||||
daily_mean = daily_mean[date_mask].copy()
|
||||
else:
|
||||
daily_mean = daily_mean.sort_values("date").tail(days)
|
||||
|
||||
daily_mean = daily_mean.sort_values("date")
|
||||
|
||||
# 构建响应
|
||||
data: list[PollutantPoint] = []
|
||||
for _, row in daily_mean.iterrows():
|
||||
pt = PollutantPoint(
|
||||
date=row["date"].strftime("%Y-%m-%d"),
|
||||
AQI=round(float(row.get("AQI", 0)), 2),
|
||||
PM25=round(float(row.get("PM2.5", 0)), 2),
|
||||
PM10=round(float(row.get("PM10", 0)), 2),
|
||||
SO2=round(float(row.get("SO2", 0)), 2),
|
||||
NO2=round(float(row.get("NO2", 0)), 2),
|
||||
O3=round(float(row.get("O3", 0)), 2),
|
||||
CO=round(float(row.get("CO", 0)), 2),
|
||||
)
|
||||
data.append(pt)
|
||||
|
||||
date_range = {}
|
||||
if daily_mean.shape[0] > 0:
|
||||
date_range = {
|
||||
"start": daily_mean["date"].iloc[0].strftime("%Y-%m-%d"),
|
||||
"end": daily_mean["date"].iloc[-1].strftime("%Y-%m-%d"),
|
||||
}
|
||||
|
||||
return PollutantResponse(
|
||||
data=data, station_count=station_count, date_range=date_range
|
||||
)
|
||||
@@ -17,7 +17,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "outputs"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_csv(path: Path) -> pd.DataFrame:
|
||||
return pd.read_csv(path)
|
||||
|
||||
@@ -109,19 +109,21 @@ async def get_grid_cases():
|
||||
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)
|
||||
|
||||
@@ -132,6 +134,11 @@ async def get_geocoded_cases(
|
||||
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]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -6,6 +7,7 @@ from typing import Optional
|
||||
import logging
|
||||
import sys
|
||||
import math
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -22,47 +24,33 @@ from models import (
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["grid"])
|
||||
|
||||
logger = logging.getLogger("cbpoa.grid")
|
||||
|
||||
_parquet_cache: dict[str, "pd.DataFrame"] = {}
|
||||
_parquet_cache: dict[str, pd.DataFrame] = {}
|
||||
|
||||
def _load_parquet(path: Path) -> "pd.DataFrame":
|
||||
import pandas as pd
|
||||
def _load_parquet(path: Path) -> pd.DataFrame:
|
||||
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
|
||||
|
||||
def _compute_historical_aggregation(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
aggregation: str,
|
||||
district: Optional[str],
|
||||
) -> HistoricalAggregationResponse:
|
||||
"""Run the full pandas aggregation pipeline (called in thread pool)."""
|
||||
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(),
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
cases_df = cases_df.copy()
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date'])
|
||||
|
||||
filtered_cases = cases_df[
|
||||
@@ -76,6 +64,7 @@ async def get_historical_aggregated(
|
||||
]
|
||||
|
||||
if aggregation == "weekly":
|
||||
filtered_cases = filtered_cases.copy()
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('W').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
@@ -84,6 +73,7 @@ async def get_historical_aggregated(
|
||||
}).reset_index()
|
||||
grouped['date'] = grouped['period']
|
||||
elif aggregation == "monthly":
|
||||
filtered_cases = filtered_cases.copy()
|
||||
filtered_cases['period'] = filtered_cases['date'].dt.to_period('M').astype(str)
|
||||
grouped = filtered_cases.groupby(['period', 'district']).agg({
|
||||
'total_cases': 'sum',
|
||||
@@ -98,7 +88,8 @@ async def get_historical_aggregated(
|
||||
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 = pd.DataFrame({'date': pd.Series(dtype='str'), 'AQI': pd.Series(dtype='float64'), 'PM25': pd.Series(dtype='float64'), 'PM10': pd.Series(dtype='float64')})
|
||||
weather_df = weather_df.copy()
|
||||
weather_df['date'] = pd.to_datetime(weather_df['date']).dt.strftime('%Y-%m-%d')
|
||||
|
||||
# Weather data doesn't have district - aggregate by date only
|
||||
@@ -114,135 +105,173 @@ async def get_historical_aggregated(
|
||||
aggregations = []
|
||||
for _, row in merged.iterrows():
|
||||
aggregations.append(DistrictAggregation(
|
||||
district=row['district'],
|
||||
district=str(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,
|
||||
avg_AQI=float(row['AQI']) if bool(pd.notna(row['AQI'])) else 0.0,
|
||||
avg_PM25=float(row['PM25']) if bool(pd.notna(row['PM25'])) else 0.0,
|
||||
avg_PM10=float(row['PM10']) if bool(pd.notna(row['PM10'])) else 0.0,
|
||||
))
|
||||
|
||||
return HistoricalAggregationResponse(
|
||||
aggregations=aggregations,
|
||||
total_records=len(aggregations),
|
||||
date_range=(start_date, end_date),
|
||||
date_range=(start.strftime("%Y-%m-%d"), end.strftime("%Y-%m-%d")),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
Pandas processing runs in a thread pool to avoid blocking the async event loop.
|
||||
"""
|
||||
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")
|
||||
|
||||
# Offload all pandas I/O and processing to a thread pool
|
||||
# to prevent blocking the async event loop
|
||||
return await asyncio.to_thread(
|
||||
_compute_historical_aggregation, start, end, aggregation, district
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _grid_geojson_base():
|
||||
"""Date-independent base merge: grid centroid + district + real population.
|
||||
|
||||
Merged once and cached (the source frames are ~1M rows each, so the join
|
||||
must not run per request). Raises FileNotFoundError if the core grid files
|
||||
are missing (caller handles it).
|
||||
"""
|
||||
import pandas as pd
|
||||
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
base = grid_df.merge(district_map, on='grid_id', how='left')
|
||||
try:
|
||||
pop_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_with_dem_pop.parquet")
|
||||
base = base.merge(pop_df[['grid_id', 'population_density']], on='grid_id', how='left')
|
||||
except FileNotFoundError:
|
||||
base['population_density'] = 0.0
|
||||
base['population_density'] = base['population_density'].fillna(0.0)
|
||||
return base
|
||||
|
||||
|
||||
def _risk_level_of(v: float) -> str:
|
||||
if v >= 0.7:
|
||||
return "high"
|
||||
if v >= 0.5:
|
||||
return "medium"
|
||||
if v >= 0.3:
|
||||
return "medium_low"
|
||||
return "low"
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _grids_geojson_body(date: str, district: Optional[str], risk_level: Optional[str]) -> str:
|
||||
"""Build + serialize the grid GeoJSON once per (date, district, risk_level).
|
||||
|
||||
Risk is computed vectorised over the full grid (no per-row Python loop) and
|
||||
the highest-risk grids are returned as hotspots, so the map shows real
|
||||
high→low variation. Cached, so warm calls are near-instant. Raises
|
||||
FileNotFoundError if the core grid files are missing.
|
||||
"""
|
||||
merged = _grid_geojson_base()
|
||||
if district:
|
||||
merged = merged[merged['district_name'].str.contains(district.replace('区', ''), na=False, regex=False)]
|
||||
|
||||
cases_df = _load_parquet(PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet").copy()
|
||||
cases_df['date'] = pd.to_datetime(cases_df['date']).dt.strftime('%Y-%m-%d')
|
||||
cases_df = cases_df[cases_df['date'] == date]
|
||||
|
||||
# Normalise district case load to 0..1 across districts for this date.
|
||||
max_district_cases = float(cases_df['total_cases'].max()) if len(cases_df) else 0.0
|
||||
if max_district_cases <= 0:
|
||||
max_district_cases = 1.0
|
||||
|
||||
merged = merged.merge(cases_df[['district', 'total_cases']], left_on='district_name', right_on='district', how='left')
|
||||
merged = merged.copy()
|
||||
merged['total_cases'] = merged['total_cases'].fillna(0).astype(int)
|
||||
merged['center_lon'] = pd.to_numeric(merged['center_lon'], errors='coerce').fillna(0.0)
|
||||
merged['center_lat'] = pd.to_numeric(merged['center_lat'], errors='coerce').fillna(0.0)
|
||||
merged['population_density'] = merged['population_density'].fillna(0.0).clip(lower=0.0)
|
||||
|
||||
# Drop grids without coordinates.
|
||||
merged = merged[(merged['center_lon'] != 0.0) | (merged['center_lat'] != 0.0)]
|
||||
|
||||
# Demo risk model (vectorised): a district's relative case load × each grid's
|
||||
# own population exposure. Sparse cells stay low; densely-populated cells in
|
||||
# high-case districts rise toward 1.0.
|
||||
district_load = (merged['total_cases'] / max_district_cases).clip(upper=1.0)
|
||||
pop_factor = (merged['population_density'] / 50.0).clip(upper=1.0)
|
||||
merged['risk_value'] = (0.1 + 0.85 * district_load * pop_factor).clip(upper=1.0).round(3)
|
||||
|
||||
# Show the highest-risk grids (hotspots), not arbitrary cells.
|
||||
merged = merged.nlargest(10000, 'risk_value')
|
||||
|
||||
features = []
|
||||
for rec in merged.to_dict('records'):
|
||||
rv = float(rec['risk_value'])
|
||||
lvl = _risk_level_of(rv)
|
||||
if risk_level and lvl != risk_level:
|
||||
continue
|
||||
name = rec.get('district_name')
|
||||
if not isinstance(name, str):
|
||||
name = "未知"
|
||||
lon = round(float(rec['center_lon']), 6)
|
||||
lat = round(float(rec['center_lat']), 6)
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": {
|
||||
"grid_id": str(rec.get('grid_id', '')),
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"district": name,
|
||||
"total_cases": int(rec.get('total_cases', 0)),
|
||||
"population_density": round(float(rec.get('population_density', 0.0)), 2),
|
||||
"risk_value": rv,
|
||||
"risk_level": lvl,
|
||||
}
|
||||
})
|
||||
|
||||
return GridGeoJSONResponse(
|
||||
type="FeatureCollection",
|
||||
features=features,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
).model_dump_json()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
"""Get grid data as GeoJSON for map visualization (cached per query)."""
|
||||
try:
|
||||
grid_df = _load_parquet(PROJECT_ROOT / "processed" / "grid_100m_index.parquet")
|
||||
# Offload the parquet merges + vectorised compute to a thread so the
|
||||
# cold-cache build doesn't block the event loop.
|
||||
body = await asyncio.to_thread(_grids_geojson_body, date, district, risk_level)
|
||||
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(),
|
||||
)
|
||||
return Response(content=body, media_type="application/json")
|
||||
|
||||
|
||||
@router.post("/predict/multi-day", response_model=MultiDayPredictionResponse)
|
||||
@@ -278,9 +307,9 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
]
|
||||
|
||||
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))
|
||||
risk_1d = float(row.get('risk_1day', 0.5)) # type: ignore[arg-type]
|
||||
risk_3d = float(row.get('risk_3day', 0.5)) # type: ignore[arg-type]
|
||||
risk_7d = float(row.get('risk_7day', 0.5)) # type: ignore[arg-type]
|
||||
|
||||
if risk_1d >= 0.8:
|
||||
risk_level = "high"
|
||||
@@ -323,14 +352,8 @@ async def predict_multi_day(request: MultiDayPredictionRequest):
|
||||
)
|
||||
|
||||
|
||||
@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.
|
||||
"""
|
||||
def _compute_grid_history(grid_id: str, days: int) -> dict:
|
||||
"""Heavy synchronous parquet reads + per-row loop (called in thread pool)."""
|
||||
import pandas as pd
|
||||
|
||||
district_map = _load_parquet(PROJECT_ROOT / "processed" / "grid_district_mapping.parquet")
|
||||
@@ -367,4 +390,18 @@ async def get_grid_history(
|
||||
"district": district,
|
||||
"history": history,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Parquet reads + aggregation run in a thread pool to avoid blocking the
|
||||
async event loop.
|
||||
"""
|
||||
return await asyncio.to_thread(_compute_grid_history, grid_id, days)
|
||||
|
||||
@@ -4,6 +4,7 @@ Provides comprehensive analytics, trends, hotspots, and correlations
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from datetime import datetime, timedelta
|
||||
from functools import lru_cache
|
||||
import random
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -20,6 +21,13 @@ from models import (
|
||||
)
|
||||
from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _cached_parquet(path_str: str):
|
||||
"""Load a parquet file once and reuse it (read-only) across requests."""
|
||||
import pandas as pd
|
||||
return pd.read_parquet(path_str)
|
||||
from utils.geo import point_in_polygon
|
||||
from utils.risk import calculate_trend as calculate_trend_direction
|
||||
|
||||
@@ -41,6 +49,7 @@ class InsightCardResponse(BaseModel):
|
||||
warning_count: int
|
||||
info_count: int
|
||||
success_count: int
|
||||
danger_count: int
|
||||
cards: list[InsightCardItem]
|
||||
|
||||
|
||||
@@ -482,7 +491,7 @@ async def get_insights_cards():
|
||||
|
||||
cases_path = PROJECT_ROOT / "processed" / "cases_by_district_daily.parquet"
|
||||
if cases_path.exists():
|
||||
cases_df = pd.read_parquet(cases_path)
|
||||
cases_df = _cached_parquet(str(cases_path))
|
||||
latest_case_date = cases_df["date"].max()
|
||||
latest_cases = cases_df[cases_df["date"] == latest_case_date].copy()
|
||||
latest_cases["base_district"] = latest_cases["district"].str.replace("区", "")
|
||||
@@ -533,7 +542,7 @@ async def get_insights_cards():
|
||||
grids_df["col"] = ((grids_df["longitude"] - MIN_LON) / STEP).astype(int)
|
||||
grids_df["grid_id"] = "r" + grids_df["row"].astype(str) + "_c" + grids_df["col"].astype(str)
|
||||
|
||||
mapping = pd.read_parquet(mapping_path)
|
||||
mapping = _cached_parquet(str(mapping_path))
|
||||
merged = grids_df.merge(mapping, on="grid_id", how="inner")
|
||||
|
||||
if len(merged) > 0:
|
||||
@@ -574,7 +583,7 @@ async def get_insights_cards():
|
||||
|
||||
weather_path = PROJECT_ROOT / "processed" / "weather" / "station_daily_2022.parquet"
|
||||
if weather_path.exists():
|
||||
weather_df = pd.read_parquet(weather_path)
|
||||
weather_df = _cached_parquet(str(weather_path))
|
||||
daily_wx = weather_df.groupby("date").agg(
|
||||
AQI=("AQI", "mean"), PM25=("PM25", "mean"), PM10=("PM10", "mean"),
|
||||
).reset_index()
|
||||
@@ -664,11 +673,13 @@ async def get_insights_cards():
|
||||
warning_count = sum(1 for c in cards if c.type == "warning")
|
||||
info_count = sum(1 for c in cards if c.type == "info")
|
||||
success_count = sum(1 for c in cards if c.type == "success")
|
||||
danger_count = sum(1 for c in cards if c.type == "danger")
|
||||
|
||||
return InsightCardResponse(
|
||||
total_insights=len(cards),
|
||||
warning_count=warning_count,
|
||||
info_count=info_count,
|
||||
success_count=success_count,
|
||||
danger_count=danger_count,
|
||||
cards=cards,
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Router for CBPOA risk assessment endpoints
|
||||
Reads from GeoJSON files in outputs/daily/ directory
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, List, Literal
|
||||
import asyncio
|
||||
import json
|
||||
import glob
|
||||
import re
|
||||
@@ -28,8 +28,12 @@ from utils.risk import risk_value_to_level
|
||||
|
||||
router = APIRouter(prefix="/api/risk", tags=["risk"])
|
||||
|
||||
# Cells with risk at/below this are culled from LOD responses; the frontend
|
||||
# discards risk==0 cells anyway, so emitting them only bloats the payload.
|
||||
LOD_RISK_EPSILON = 1e-6
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
@@ -55,7 +59,7 @@ def get_risk_data(date: str) -> tuple[list[list], dict]:
|
||||
return grids, grid_map
|
||||
|
||||
|
||||
@lru_cache(maxsize=3)
|
||||
@lru_cache(maxsize=8)
|
||||
def get_kdtree_and_risks(date: str):
|
||||
grids, _ = get_risk_data(date)
|
||||
if not grids:
|
||||
@@ -70,6 +74,8 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
bounds: dict | None = None) -> dict:
|
||||
date = get_latest_date()
|
||||
kdtree, risk_values = get_kdtree_and_risks(date)
|
||||
if kdtree is None or risk_values is None:
|
||||
return {"lod": "empty", "zoom": zoom, "aggregate": 1, "grids": [], "total_count": 0, "bounds": bounds or WUHAN_BOUNDS}
|
||||
|
||||
risk_idx = forecast_day - 1
|
||||
|
||||
@@ -117,7 +123,12 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
risks = risk_array[indices]
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
# Drop zero-risk cells (incl. out-of-radius). The frontend discards
|
||||
# them anyway, so culling here shrinks the payload substantially.
|
||||
flat_lat = lat_grid.ravel()
|
||||
flat_lon = lon_grid.ravel()
|
||||
keep = risks > LOD_RISK_EPSILON
|
||||
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -143,8 +154,9 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
cell_lat = (WUHAN_BOUNDS["max_lat"] - WUHAN_BOUNDS["min_lat"]) / lat_count
|
||||
cell_lon = (WUHAN_BOUNDS["max_lon"] - WUHAN_BOUNDS["min_lon"]) / lon_count
|
||||
|
||||
# Apply viewport bounds filtering for zoom >= 10
|
||||
if bounds and zoom >= 10:
|
||||
# Apply viewport bounds filtering at all zoom levels when bounds are given,
|
||||
# so even the coarse zoom<=9 lod1 grid is clipped to the viewport.
|
||||
if bounds:
|
||||
b_min_lat = max(bounds["min_lat"], WUHAN_BOUNDS["min_lat"])
|
||||
b_max_lat = min(bounds["max_lat"], WUHAN_BOUNDS["max_lat"])
|
||||
b_min_lon = max(bounds["min_lon"], WUHAN_BOUNDS["min_lon"])
|
||||
@@ -176,7 +188,11 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
|
||||
risks[dists > LOD_MAX_RADIUS] = 0.0
|
||||
|
||||
lod_grids = np.column_stack([lat_grid.ravel(), lon_grid.ravel(), risks]).tolist()
|
||||
# Drop zero-risk cells (incl. out-of-radius) before serialization.
|
||||
flat_lat = lat_grid.ravel()
|
||||
flat_lon = lon_grid.ravel()
|
||||
keep = risks > LOD_RISK_EPSILON
|
||||
lod_grids = np.column_stack([flat_lat[keep], flat_lon[keep], risks[keep]]).tolist()
|
||||
|
||||
return {
|
||||
"lod": lod_name,
|
||||
@@ -188,6 +204,21 @@ def generate_lod_grid(zoom: int, forecast_day: Literal[1, 3, 7] = 1,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _risk_map_body(date: str) -> str:
|
||||
"""Serialize the full ~140k-grid risk map for a date once (cached).
|
||||
|
||||
The 140k-element response costs ~0.5s of Pydantic validation + JSON
|
||||
serialization; caching the serialized body makes warm calls ~instant.
|
||||
"""
|
||||
grids = parse_geojson_file(DATA_DIR / f"risk_{date}.geojson")
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat(),
|
||||
).model_dump_json()
|
||||
|
||||
|
||||
@router.get("/map", response_model=RiskMapResponse)
|
||||
async def get_risk_map(date: str | None = None):
|
||||
if date is None:
|
||||
@@ -197,13 +228,7 @@ async def get_risk_map(date: str | None = None):
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/current", response_model=RiskMapResponse)
|
||||
@@ -214,28 +239,7 @@ async def get_current_risk():
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
grids: list[dict[str, str | float]] = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
coords = feature.get("geometry", {}).get("coordinates", [0, 0])
|
||||
|
||||
risk_value = props.get("risk_1d", 0)
|
||||
grids.append({
|
||||
"grid_id": str(props.get("node_id", "")),
|
||||
"latitude": props.get("lat", coords[1] if len(coords) > 1 else 0),
|
||||
"longitude": props.get("lon", coords[0] if len(coords) > 0 else 0),
|
||||
"risk_value": risk_value,
|
||||
"risk_level": risk_value_to_level(risk_value),
|
||||
})
|
||||
|
||||
return RiskMapResponse(
|
||||
grids=grids,
|
||||
total_count=len(grids),
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
return Response(content=_risk_map_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/precomputed", response_model=RiskMapResponse)
|
||||
@@ -247,7 +251,7 @@ async def get_precomputed_risk():
|
||||
|
||||
grids = []
|
||||
for _, row in df.iterrows():
|
||||
risk_index = float(row.get('risk_index', 0))
|
||||
risk_index = float(row.get('risk_index', 0)) # type: ignore[arg-type]
|
||||
grids.append({
|
||||
"grid_id": str(row['grid_id']),
|
||||
"latitude": float(row['center_y']),
|
||||
@@ -263,6 +267,18 @@ async def get_precomputed_risk():
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _fullgrid_body(date: str) -> str:
|
||||
"""Serialize the compact full-grid payload once (cached)."""
|
||||
grids, _ = get_risk_data(date)
|
||||
return json.dumps({
|
||||
"date": date,
|
||||
"total_count": len(grids),
|
||||
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
|
||||
"grids": grids,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/fullgrid")
|
||||
async def get_full_grid(date: str | None = None):
|
||||
if date is None:
|
||||
@@ -272,26 +288,7 @@ async def get_full_grid(date: str | None = None):
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
|
||||
grids = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
grids.append([
|
||||
round(props.get("lat", 0), 6),
|
||||
round(props.get("lon", 0), 6),
|
||||
round(props.get("risk_1d", 0), 4),
|
||||
round(props.get("risk_3d", 0), 4),
|
||||
round(props.get("risk_7d", 0), 4),
|
||||
])
|
||||
|
||||
return {
|
||||
"date": date,
|
||||
"total_count": len(grids),
|
||||
"columns": ["lat", "lon", "risk_1d", "risk_3d", "risk_7d"],
|
||||
"grids": grids,
|
||||
}
|
||||
return Response(content=_fullgrid_body(date), media_type="application/json")
|
||||
|
||||
|
||||
@router.get("/lod-grid")
|
||||
@@ -366,50 +363,44 @@ async def get_lod_tile(
|
||||
|
||||
|
||||
@router.get("/history/{grid_id}", response_model=RiskHistoryResponse)
|
||||
async def get_risk_history(grid_id: str, days: int = 7):
|
||||
async def get_risk_history(grid_id: str, days: Annotated[int, Query(ge=1, le=30)] = 7):
|
||||
date = get_latest_date()
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
geojson = json.load(f)
|
||||
# Use cached parsed grids + cached KDTree instead of re-reading the ~44MB file.
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
target_feature = None
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
if str(props.get("node_id", "")) == grid_id:
|
||||
target_feature = feature
|
||||
base_risk = None
|
||||
# Exact node_id match
|
||||
for g in grids:
|
||||
if g["grid_id"] == grid_id:
|
||||
base_risk = g["risk_value"]
|
||||
break
|
||||
|
||||
if not target_feature and re.match(r'r\d+_c\d+', grid_id):
|
||||
# Fallback: nearest grid for r{row}_c{col} ids
|
||||
if base_risk is None and re.match(r'r\d+_c\d+', grid_id):
|
||||
parts = grid_id.replace("r", "").split("_c")
|
||||
row, col = int(parts[0]), int(parts[1])
|
||||
center_lat = WUHAN_BOUNDS["min_lat"] + (row + 0.5) * LAT_STEP
|
||||
center_lon = WUHAN_BOUNDS["min_lon"] + (col + 0.5) * LON_STEP
|
||||
points = []
|
||||
features_list = []
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties", {})
|
||||
points.append([props.get("lat", 0), props.get("lon", 0)])
|
||||
features_list.append(feature)
|
||||
if points:
|
||||
tree = KDTree(points)
|
||||
_, idx = tree.query([center_lat, center_lon])
|
||||
target_feature = features_list[idx]
|
||||
kdtree, risk_values = get_kdtree_and_risks(date)
|
||||
if kdtree is not None and risk_values is not None:
|
||||
_, idx = kdtree.query([center_lat, center_lon])
|
||||
base_risk = risk_values[idx][0] # risk_1d
|
||||
|
||||
if not target_feature:
|
||||
if base_risk is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grid {grid_id} not found")
|
||||
|
||||
props = target_feature.get("properties", {})
|
||||
base_risk = props.get("risk_1d", 0)
|
||||
|
||||
history = []
|
||||
for i in range(days):
|
||||
history.append({
|
||||
"date": (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"),
|
||||
"risk_value": base_risk * (1 - i * 0.05)
|
||||
"risk_value": max(0.0, base_risk * (1 - i * 0.05))
|
||||
})
|
||||
|
||||
return RiskHistoryResponse(
|
||||
@@ -432,7 +423,7 @@ async def get_forecast_map(
|
||||
|
||||
if not filepath.exists():
|
||||
# Fall back to current data
|
||||
return await get_current_risk_map()
|
||||
return await get_current_risk()
|
||||
|
||||
grids = parse_geojson_file(filepath)
|
||||
if not grids:
|
||||
@@ -495,3 +486,71 @@ async def get_stats(date: str | None = None):
|
||||
high_risk_count=distribution["high"],
|
||||
timestamp=datetime.now().isoformat()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raster LOD tiles — full-Wuhan 100m risk grid served as XYZ map tiles.
|
||||
# The browser loads PNG images (cached by Leaflet); no per-cell JS work.
|
||||
# See utils/risk_raster.py for the rendering engine.
|
||||
# ---------------------------------------------------------------------------
|
||||
from utils import risk_raster # noqa: E402 (kept local to this feature block)
|
||||
|
||||
_VALID_DAYS = {1, 3, 7}
|
||||
|
||||
|
||||
@router.get("/tiles/{z}/{x}/{y}.png")
|
||||
async def get_risk_tile(
|
||||
z: Annotated[int, Path(ge=0, le=22)],
|
||||
x: Annotated[int, Path(ge=0)],
|
||||
y: Annotated[int, Path(ge=0)],
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Render one web-mercator risk tile (256x256 PNG) for the 100m grid."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
png = await asyncio.to_thread(risk_raster.render_tile, z, x, y, date, day)
|
||||
return Response(
|
||||
content=png,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "public, max-age=3600"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/grid-stats")
|
||||
async def get_risk_grid_stats(
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Aggregate stats over the in-boundary 100m grid (cell count / avg / max / high)."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
return await asyncio.to_thread(risk_raster.grid_stats, date, day)
|
||||
|
||||
|
||||
@router.get("/cell")
|
||||
async def get_risk_cell(
|
||||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||
date: str | None = None,
|
||||
day: Annotated[int, Query()] = 1,
|
||||
):
|
||||
"""Risk at the 100m cell containing (lat, lon) — used for click-to-inspect."""
|
||||
if day not in _VALID_DAYS:
|
||||
raise HTTPException(status_code=400, detail="day must be 1, 3, or 7")
|
||||
if date is None:
|
||||
date = get_latest_date()
|
||||
if not (DATA_DIR / f"risk_{date}.geojson").exists():
|
||||
raise HTTPException(status_code=404, detail=f"No data found for date {date}")
|
||||
|
||||
return await asyncio.to_thread(risk_raster.query_cell, lat, lon, date, day)
|
||||
|
||||
Reference in New Issue
Block a user