feat: GeoScene frontend POC + Docker deploy for remote host
Migrate maps to @geoscene/core, polish monitoring/alerts UX, fix timeline basemap flicker and district alert regions, and ship compose/nginx Docker deploy assets with CBPOA_ROOT data mounts. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@ load_dotenv()
|
||||
# Paths
|
||||
# ============================================================================
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
PROJECT_ROOT = Path(os.environ.get("CBPOA_ROOT", Path(__file__).parent.parent))
|
||||
DATA_DIR = PROJECT_ROOT / "outputs" / "daily"
|
||||
REPORTS_DIR = PROJECT_ROOT / "outputs" / "reports"
|
||||
WUHAN_BOUNDARY_PATH = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||
|
||||
@@ -13,6 +13,7 @@ from config import DATA_DIR, ALERT_P1_RISK, ALERT_P2_RISK, WUHAN_BOUNDS, LAT_STE
|
||||
from models import Alert, AlertResponse
|
||||
from utils.date_helpers import get_latest_date, validate_date_format
|
||||
from utils.risk import risk_value_to_level
|
||||
from utils.district_lookup import district_for_grid
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
|
||||
@@ -95,13 +96,14 @@ def _generate_alerts_cached(date: str) -> List[Alert]:
|
||||
|
||||
lat, lon = grid_id_to_center(grid_id)
|
||||
risk_level = risk_value_to_level(max_risk)
|
||||
district = district_for_grid(grid_id)
|
||||
|
||||
alerts.append(
|
||||
Alert(
|
||||
alert_id=f"alert_{date}_{grid_id}",
|
||||
grid_id=grid_id,
|
||||
region="武汉市",
|
||||
street=f"Grid {grid_id}",
|
||||
region=district,
|
||||
street=grid_id,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
risk_value=max_risk,
|
||||
|
||||
@@ -17,6 +17,8 @@ from utils.date_helpers import get_latest_date
|
||||
from utils.geojson import parse_geojson_file, load_districts
|
||||
from utils.geo import point_in_polygon
|
||||
from utils.risk import calculate_trend
|
||||
from utils.daily_risk_avg import daily_avg_risk
|
||||
from utils.district_lookup import grid_district_lookup
|
||||
|
||||
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
|
||||
|
||||
@@ -83,22 +85,10 @@ async def get_trend(days: int = Query(default=7, ge=1, le=30)):
|
||||
for i in range(days):
|
||||
date = base_date - timedelta(days=days - 1 - i)
|
||||
date_str = date.strftime("%Y%m%d")
|
||||
filepath = DATA_DIR / f"risk_{date_str}.geojson"
|
||||
|
||||
if filepath.exists():
|
||||
grids = parse_geojson_file(filepath)
|
||||
if grids:
|
||||
avg_risk = sum(g["risk_value"] for g in grids) / len(grids)
|
||||
values.append(round(avg_risk, 4))
|
||||
else:
|
||||
values.append(0)
|
||||
else:
|
||||
values.append(0)
|
||||
# Disk+memory cached mean — avoids re-parsing ~45MB GeoJSON every request
|
||||
values.append(daily_avg_risk(date_str))
|
||||
dates.append(date.strftime("%Y-%m-%d"))
|
||||
|
||||
# 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(
|
||||
@@ -110,15 +100,8 @@ 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)))
|
||||
"""Backward-compatible alias — prefer utils.district_lookup."""
|
||||
return grid_district_lookup()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
|
||||
81
backend/utils/daily_risk_avg.py
Normal file
81
backend/utils/daily_risk_avg.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Fast daily city-wide mean risk_1d with on-disk cache.
|
||||
|
||||
Avoids re-parsing ~45MB GeoJSON on every /analysis/trend request.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from config import DATA_DIR, PROJECT_ROOT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_PATH = PROJECT_ROOT / "processed" / "daily_avg_risk.json"
|
||||
|
||||
|
||||
def _read_disk_cache() -> dict[str, float]:
|
||||
if not _CACHE_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(_CACHE_PATH.read_text(encoding="utf-8"))
|
||||
return {str(k): float(v) for k, v in raw.items()}
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _write_disk_cache(cache: dict[str, float]) -> None:
|
||||
try:
|
||||
_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
_CACHE_PATH.write_text(
|
||||
json.dumps(cache, ensure_ascii=False, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to persist daily avg risk cache: %s", e)
|
||||
|
||||
|
||||
def _compute_mean_risk_1d(filepath: Path) -> float:
|
||||
"""Parse one risk GeoJSON and return mean risk_1d (0 if empty/missing)."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
geojson = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Failed to parse %s: %s", filepath, e)
|
||||
return 0.0
|
||||
|
||||
total = 0.0
|
||||
n = 0
|
||||
for feature in geojson.get("features", []):
|
||||
props = feature.get("properties") or {}
|
||||
r = props.get("risk_1d")
|
||||
if r is None:
|
||||
continue
|
||||
total += float(r)
|
||||
n += 1
|
||||
return round(total / n, 4) if n else 0.0
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def daily_avg_risk(date_yyyymmdd: str) -> float:
|
||||
"""Mean risk_1d for YYYYMMDD. Memory + disk cached."""
|
||||
disk = _read_disk_cache()
|
||||
if date_yyyymmdd in disk:
|
||||
return disk[date_yyyymmdd]
|
||||
|
||||
filepath = DATA_DIR / f"risk_{date_yyyymmdd}.geojson"
|
||||
if not filepath.exists():
|
||||
return 0.0
|
||||
|
||||
avg = _compute_mean_risk_1d(filepath)
|
||||
disk[date_yyyymmdd] = avg
|
||||
_write_disk_cache(disk)
|
||||
return avg
|
||||
|
||||
|
||||
def warm_daily_avg_risk(dates: list[str]) -> None:
|
||||
"""Precompute missing dates into the disk cache (blocking)."""
|
||||
for d in dates:
|
||||
daily_avg_risk(d)
|
||||
21
backend/utils/district_lookup.py
Normal file
21
backend/utils/district_lookup.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Map 100m grid_id (r{row}_c{col}) → Wuhan district name."""
|
||||
from functools import lru_cache
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import PROJECT_ROOT
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def grid_district_lookup() -> dict[str, str]:
|
||||
"""Loaded once from processed/grid_district_mapping.parquet."""
|
||||
path = PROJECT_ROOT / "processed" / "grid_district_mapping.parquet"
|
||||
if not path.exists():
|
||||
return {}
|
||||
df = pd.read_parquet(path)
|
||||
df = df.dropna(subset=["district_name"])
|
||||
return dict(zip(df["grid_id"].astype(str), df["district_name"].astype(str)))
|
||||
|
||||
|
||||
def district_for_grid(grid_id: str, default: str = "其他") -> str:
|
||||
return grid_district_lookup().get(grid_id, default)
|
||||
Reference in New Issue
Block a user