Files
CA/backend/utils/risk_raster.py
Akiba So e95e2f1338 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
2026-06-21 17:35:03 +08:00

376 lines
14 KiB
Python

"""
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,
}