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:
@@ -9,7 +9,7 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
|
||||
return False
|
||||
|
||||
# MultiPolygon: check each polygon
|
||||
if isinstance(polygon_coords[0], list) and isinstance(polygon_coords[0][0], list):
|
||||
if isinstance(polygon_coords[0], list) and polygon_coords[0] and isinstance(polygon_coords[0][0], list):
|
||||
for polygon in polygon_coords:
|
||||
if polygon and isinstance(polygon[0], list):
|
||||
ring = polygon[0] if isinstance(polygon[0][0], list) else polygon
|
||||
@@ -25,6 +25,8 @@ def point_in_polygon(lat: float, lon: float, polygon_coords: list) -> bool:
|
||||
def point_in_ring(lat: float, lon: float, ring: list) -> bool:
|
||||
"""Ray casting algorithm for point-in-ring test."""
|
||||
n = len(ring)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
|
||||
x, y = lon, lat
|
||||
|
||||
@@ -13,7 +13,7 @@ from utils.risk import risk_value_to_level
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@lru_cache(maxsize=16)
|
||||
def parse_geojson_file(filepath: Path) -> list[dict[str, Any]]:
|
||||
"""Parse GeoJSON file and extract grid data with standard fields."""
|
||||
try:
|
||||
|
||||
375
backend/utils/risk_raster.py
Normal file
375
backend/utils/risk_raster.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Risk raster tile engine — renders the full-Wuhan 100m risk grid as XYZ map tiles.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The model emits risk at ~140k GCN nodes per day. The product needs to display this
|
||||
over the full Wuhan 100m grid (~1.5M in-boundary cells) with smooth LOD. Shipping
|
||||
that many cells to the browser as vectors is impossible, so we rasterize server-side:
|
||||
|
||||
1. Build a dense per-cell risk raster R[row, col] once per (date, forecast_day):
|
||||
scatter each node's risk onto its 100m cell (max per cell), then nearest-fill
|
||||
empty cells via a Euclidean distance transform (Voronoi over nodes, quantized
|
||||
to the 100m grid). Cells outside the Wuhan boundary are masked out.
|
||||
2. Build a max-pooled pyramid for clean LOD at low zoom.
|
||||
3. Render standard 256x256 web-mercator PNG tiles by sampling the pyramid level
|
||||
that matches the tile's zoom. Tiles are cached; the browser just loads images.
|
||||
|
||||
Coordinate conventions (calibrated from processed/grid_100m_index.parquet):
|
||||
lat = MIN_LAT + (row + 0.5) * LAT_STEP -> row 0 is SOUTH, row increases north
|
||||
lon = MIN_LON + (col + 0.5) * LON_STEP -> col 0 is WEST, col increases east
|
||||
Everything below maps lat/lon -> (row, col) the same way, so orientation is coherent
|
||||
end to end. Tile pixel py=0 is north (high lat -> high row); we build the RGBA array
|
||||
with py as the first axis so north ends up at the top of the PNG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
from config import DATA_DIR, WUHAN_BOUNDS, PROJECT_ROOT
|
||||
|
||||
# --- Grid definition (geographically-correct ~100m grid over the Wuhan bbox) ---
|
||||
MIN_LON = WUHAN_BOUNDS["min_lon"]
|
||||
MAX_LON = WUHAN_BOUNDS["max_lon"]
|
||||
MIN_LAT = WUHAN_BOUNDS["min_lat"]
|
||||
MAX_LAT = WUHAN_BOUNDS["max_lat"]
|
||||
|
||||
NROWS = 1550 # matches processed/grid_100m_with_dem_pop.parquet row extent
|
||||
NCOLS = 1336 # matches its col extent
|
||||
LAT_STEP = (MAX_LAT - MIN_LAT) / NROWS
|
||||
LON_STEP = (MAX_LON - MIN_LON) / NCOLS
|
||||
|
||||
BOUNDARY_GEOJSON = PROJECT_ROOT / "Datas" / "武汉市.geojson"
|
||||
|
||||
# Forecast-day -> property suffix on the risk geojson features.
|
||||
_DAY_TO_KEY = {1: "risk_1d", 3: "risk_3d", 7: "risk_7d"}
|
||||
|
||||
MAX_PYRAMID_LEVEL = 7 # full-res + 7 downsamples covers world zoom range
|
||||
TILE_PX = 256
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Affine helpers (lat/lon <-> grid row/col)
|
||||
# ----------------------------------------------------------------------------
|
||||
def latlon_to_rowcol(lat: float, lon: float) -> tuple[int, int]:
|
||||
row = int((lat - MIN_LAT) / LAT_STEP)
|
||||
col = int((lon - MIN_LON) / LON_STEP)
|
||||
row = max(0, min(NROWS - 1, row))
|
||||
col = max(0, min(NCOLS - 1, col))
|
||||
return row, col
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Boundary mask (rasterized once)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def _boundary_mask() -> np.ndarray:
|
||||
"""Boolean (NROWS, NCOLS) mask, True for cells inside the Wuhan boundary."""
|
||||
img = Image.new("1", (NCOLS, NROWS), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if not BOUNDARY_GEOJSON.exists():
|
||||
# No boundary file -> color the whole bbox rather than nothing.
|
||||
return np.ones((NROWS, NCOLS), dtype=bool)
|
||||
|
||||
with open(BOUNDARY_GEOJSON, "r", encoding="utf-8") as f:
|
||||
gj = json.load(f)
|
||||
|
||||
def _draw_ring(ring):
|
||||
pts = []
|
||||
for lon, lat in ring:
|
||||
col = (lon - MIN_LON) / LON_STEP
|
||||
row = (lat - MIN_LAT) / LAT_STEP
|
||||
pts.append((col, row))
|
||||
if len(pts) >= 3:
|
||||
draw.polygon(pts, fill=1)
|
||||
|
||||
def _walk(geom):
|
||||
gtype = geom.get("type")
|
||||
coords = geom.get("coordinates", [])
|
||||
if gtype == "Polygon":
|
||||
for ring in coords:
|
||||
_draw_ring(ring)
|
||||
elif gtype == "MultiPolygon":
|
||||
for poly in coords:
|
||||
for ring in poly:
|
||||
_draw_ring(ring)
|
||||
|
||||
if gj.get("type") == "FeatureCollection":
|
||||
for feat in gj.get("features", []):
|
||||
_walk(feat.get("geometry", {}))
|
||||
elif gj.get("type") == "Feature":
|
||||
_walk(gj.get("geometry", {}))
|
||||
else:
|
||||
_walk(gj)
|
||||
|
||||
return np.array(img, dtype=bool)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Node loading (cached per date)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=8)
|
||||
def _load_nodes(date: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Return (rows, cols, risks[3]) arrays for all nodes on a date.
|
||||
|
||||
risks is shape (n_nodes, 3) for [risk_1d, risk_3d, risk_7d].
|
||||
"""
|
||||
filepath = DATA_DIR / f"risk_{date}.geojson"
|
||||
if not filepath.exists():
|
||||
raise FileNotFoundError(f"No risk data for date {date}")
|
||||
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
gj = json.load(f)
|
||||
|
||||
feats = gj.get("features", [])
|
||||
n = len(feats)
|
||||
rows = np.empty(n, dtype=np.int32)
|
||||
cols = np.empty(n, dtype=np.int32)
|
||||
risks = np.zeros((n, 3), dtype=np.float32)
|
||||
|
||||
for i, feat in enumerate(feats):
|
||||
p = feat.get("properties", {})
|
||||
lat = p.get("lat", 0.0)
|
||||
lon = p.get("lon", 0.0)
|
||||
r, c = latlon_to_rowcol(lat, lon)
|
||||
rows[i] = r
|
||||
cols[i] = c
|
||||
risks[i, 0] = p.get("risk_1d", 0.0)
|
||||
risks[i, 1] = p.get("risk_3d", 0.0)
|
||||
risks[i, 2] = p.get("risk_7d", 0.0)
|
||||
|
||||
return rows, cols, risks
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Risk raster + pyramid (cached per date+day)
|
||||
# ----------------------------------------------------------------------------
|
||||
def _maxpool2(a: np.ndarray) -> np.ndarray:
|
||||
"""Downsample by 2 taking the NaN-aware max of each 2x2 block."""
|
||||
h, w = a.shape
|
||||
h2, w2 = (h + 1) // 2, (w + 1) // 2
|
||||
out = np.full((h2, w2), np.nan, dtype=np.float32)
|
||||
# Pad to even dims with NaN so the reshape is clean.
|
||||
ph, pw = h2 * 2, w2 * 2
|
||||
pad = np.full((ph, pw), np.nan, dtype=np.float32)
|
||||
pad[:h, :w] = a
|
||||
blocks = pad.reshape(h2, 2, w2, 2)
|
||||
# np.nanmax over the 2x2 block axes; suppress all-NaN warnings.
|
||||
with np.errstate(invalid="ignore"):
|
||||
out = np.nanmax(blocks, axis=(1, 3))
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
@lru_cache(maxsize=6)
|
||||
def _risk_pyramid(date: str, day: int) -> tuple[np.ndarray, ...]:
|
||||
"""Build the nearest-filled, boundary-masked risk raster and its LOD pyramid.
|
||||
|
||||
Returns a tuple of arrays, level 0 = full res (NROWS, NCOLS), each subsequent
|
||||
level downsampled 2x. NaN marks "no data / outside boundary".
|
||||
"""
|
||||
if day not in _DAY_TO_KEY:
|
||||
raise ValueError(f"invalid forecast day {day}")
|
||||
day_idx = {1: 0, 3: 1, 7: 2}[day]
|
||||
|
||||
rows, cols, risks = _load_nodes(date)
|
||||
vals = risks[:, day_idx]
|
||||
|
||||
# Scatter nodes onto the grid, taking the max risk per cell.
|
||||
R = np.full((NROWS, NCOLS), -np.inf, dtype=np.float32)
|
||||
np.maximum.at(R, (rows, cols), vals)
|
||||
known = np.isfinite(R)
|
||||
|
||||
# Nearest-fill empty cells (Voronoi over nodes, quantized to the 100m grid).
|
||||
if known.any():
|
||||
idx = distance_transform_edt(~known, return_distances=False, return_indices=True)
|
||||
R = R[tuple(idx)]
|
||||
R = R.astype(np.float32)
|
||||
|
||||
# Mask out everything outside the Wuhan boundary.
|
||||
mask = _boundary_mask()
|
||||
R[~mask] = np.nan
|
||||
|
||||
pyramid = [R]
|
||||
for _ in range(MAX_PYRAMID_LEVEL):
|
||||
nxt = _maxpool2(pyramid[-1])
|
||||
pyramid.append(nxt)
|
||||
if nxt.shape[0] <= 2 or nxt.shape[1] <= 2:
|
||||
break
|
||||
return tuple(pyramid)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Colormap (risk 0..1 -> RGBA), built once as a 256-entry LUT
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=1)
|
||||
def _color_lut() -> np.ndarray:
|
||||
"""256x4 uint8 LUT. Green -> yellow -> orange -> red, alpha grows with risk.
|
||||
|
||||
Risk below ~0.25 is rendered transparent to keep the map readable.
|
||||
"""
|
||||
lut = np.zeros((256, 4), dtype=np.uint8)
|
||||
# control points: (risk, R, G, B)
|
||||
stops = [
|
||||
(0.00, 56, 176, 0), # green (low)
|
||||
(0.40, 250, 204, 21), # yellow (medium)
|
||||
(0.60, 249, 115, 22), # orange (high)
|
||||
(0.80, 239, 68, 68), # red (critical)
|
||||
(1.00, 153, 27, 27), # dark red (extreme)
|
||||
]
|
||||
xs = [s[0] for s in stops]
|
||||
for i in range(256):
|
||||
t = i / 255.0
|
||||
# piecewise-linear RGB interpolation
|
||||
for k in range(len(stops) - 1):
|
||||
if xs[k] <= t <= xs[k + 1]:
|
||||
f = (t - xs[k]) / (xs[k + 1] - xs[k] + 1e-9)
|
||||
r = stops[k][1] + f * (stops[k + 1][1] - stops[k][1])
|
||||
g = stops[k][2] + f * (stops[k + 1][2] - stops[k][2])
|
||||
b = stops[k][3] + f * (stops[k + 1][3] - stops[k][3])
|
||||
break
|
||||
else:
|
||||
r, g, b = stops[-1][1:]
|
||||
# alpha: transparent below 0.25, then ramp 90 -> 235
|
||||
if t < 0.25:
|
||||
a = 0.0
|
||||
else:
|
||||
a = 90 + (t - 0.25) / 0.75 * (235 - 90)
|
||||
lut[i] = (int(r), int(g), int(b), int(a))
|
||||
return lut
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Tile rendering
|
||||
# ----------------------------------------------------------------------------
|
||||
def _tile_pixel_latlon(z: int, x: int, y: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Return (lat[256,256], lon[256,256]) for each pixel center of a tile."""
|
||||
n = 2.0 ** z
|
||||
px = (np.arange(TILE_PX) + 0.5) / TILE_PX
|
||||
# longitude is linear in tile-x
|
||||
X = (x + px) / n
|
||||
lon = X * 360.0 - 180.0 # shape (256,)
|
||||
# latitude via inverse web-mercator (nonlinear in tile-y)
|
||||
Y = (y + px) / n
|
||||
lat = np.degrees(np.arctan(np.sinh(np.pi * (1.0 - 2.0 * Y)))) # shape (256,)
|
||||
lon2d = np.broadcast_to(lon, (TILE_PX, TILE_PX)) # varies along axis 1 (px)
|
||||
lat2d = np.broadcast_to(lat[:, None], (TILE_PX, TILE_PX)) # varies along axis 0 (py)
|
||||
return lat2d, lon2d
|
||||
|
||||
|
||||
def _level_for_zoom(z: int) -> int:
|
||||
"""Pick the pyramid level so ~1 source cell maps to ~1 screen pixel."""
|
||||
# meters/pixel at lat ~30.6: 156543.03 * cos(lat) / 2^z ; /100m per cell
|
||||
cells_per_px = (156543.03 * math.cos(math.radians(30.6)) / (2.0 ** z)) / 100.0
|
||||
if cells_per_px <= 1.0:
|
||||
return 0
|
||||
return max(0, min(MAX_PYRAMID_LEVEL, int(math.floor(math.log2(cells_per_px)))))
|
||||
|
||||
|
||||
def render_tile(z: int, x: int, y: int, date: str, day: int = 1) -> bytes:
|
||||
"""Render a single XYZ tile to PNG bytes. Fully transparent tiles return a
|
||||
tiny cached blank PNG. Result is cached per (z,x,y,date,day)."""
|
||||
return _render_tile_cached(z, x, y, date, day)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _render_tile_cached(z: int, x: int, y: int, date: str, day: int) -> bytes:
|
||||
pyramid = _risk_pyramid(date, day)
|
||||
level = _level_for_zoom(z)
|
||||
level = min(level, len(pyramid) - 1)
|
||||
R = pyramid[level]
|
||||
factor = 2 ** level
|
||||
lh, lw = R.shape
|
||||
|
||||
lat2d, lon2d = _tile_pixel_latlon(z, x, y)
|
||||
|
||||
# lat/lon -> full-res row/col -> level row/col
|
||||
row = ((lat2d - MIN_LAT) / LAT_STEP).astype(np.int32) // factor
|
||||
col = ((lon2d - MIN_LON) / LON_STEP).astype(np.int32) // factor
|
||||
|
||||
inside = (row >= 0) & (row < lh) & (col >= 0) & (col < lw)
|
||||
rc = np.clip(row, 0, lh - 1)
|
||||
cc = np.clip(col, 0, lw - 1)
|
||||
sampled = R[rc, cc] # (256,256) float32, NaN where no data
|
||||
valid = inside & np.isfinite(sampled)
|
||||
|
||||
# Map risk -> LUT index (NaN cells become 0 then are zeroed-out below)
|
||||
lut = _color_lut()
|
||||
safe = np.nan_to_num(sampled, nan=0.0)
|
||||
idx = np.clip((safe * 255.0), 0, 255).astype(np.uint8)
|
||||
rgba = lut[idx] # (256,256,4)
|
||||
rgba[~valid] = (0, 0, 0, 0) # transparent outside data/boundary
|
||||
|
||||
img = Image.fromarray(rgba, mode="RGBA")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=False)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Point query (for click-to-inspect)
|
||||
# ----------------------------------------------------------------------------
|
||||
@lru_cache(maxsize=16)
|
||||
def grid_stats(date: str, day: int = 1) -> dict:
|
||||
"""Lightweight aggregate stats over the in-boundary 100m grid for a date/day.
|
||||
|
||||
Computed from the cached raster, so this is cheap after the first tile build.
|
||||
Replaces the old heavy per-viewport LOD fetch the overlay used to do.
|
||||
"""
|
||||
R = _risk_pyramid(date, day)[0]
|
||||
finite = np.isfinite(R)
|
||||
n = int(finite.sum())
|
||||
if n == 0:
|
||||
return {"cell_count": 0, "avg_risk": 0.0, "max_risk": 0.0,
|
||||
"high_risk_count": 0, "forecast_day": day, "date": date}
|
||||
vals = R[finite]
|
||||
return {
|
||||
"cell_count": n,
|
||||
"avg_risk": round(float(vals.mean()), 4),
|
||||
"max_risk": round(float(vals.max()), 4),
|
||||
"high_risk_count": int((vals >= 0.8).sum()),
|
||||
"forecast_day": day,
|
||||
"date": date,
|
||||
}
|
||||
|
||||
|
||||
def query_cell(lat: float, lon: float, date: str, day: int = 1) -> dict:
|
||||
"""Return the 100m cell risk at a lat/lon for the given date.
|
||||
|
||||
Includes all three forecast horizons (1d/3d/7d) so the click panel can show
|
||||
them without a separate heavy grid fetch. `risk_value` is the requested day.
|
||||
"""
|
||||
row, col = latlon_to_rowcol(lat, lon)
|
||||
|
||||
def _sample(d: int) -> tuple[float, bool]:
|
||||
v = _risk_pyramid(date, d)[0][row, col]
|
||||
ok = bool(np.isfinite(v))
|
||||
return (round(float(v), 4) if ok else 0.0), ok
|
||||
|
||||
r1, in_b = _sample(1)
|
||||
r3, _ = _sample(3)
|
||||
r7, _ = _sample(7)
|
||||
current = {1: r1, 3: r3, 7: r7}[day]
|
||||
return {
|
||||
"grid_id": f"r{row}_c{col}",
|
||||
"row": row,
|
||||
"col": col,
|
||||
"center_lat": round(MIN_LAT + (row + 0.5) * LAT_STEP, 6),
|
||||
"center_lon": round(MIN_LON + (col + 0.5) * LON_STEP, 6),
|
||||
"risk_value": current,
|
||||
"risk_1d": r1,
|
||||
"risk_3d": r3,
|
||||
"risk_7d": r7,
|
||||
"in_boundary": in_b,
|
||||
"forecast_day": day,
|
||||
"date": date,
|
||||
}
|
||||
Reference in New Issue
Block a user