# /// script # requires-python = ">=3.11" # dependencies = ["shapely>=2.0"] # /// """Build src/static/flood-zones-p1.geojson from CMU Water Center's flood map. CMU's Chiang Mai flood hazard map (watercenter.cmu.ac.th, CMFlood, built from the October 2024 flood) publishes the flooded area at five P.1 levels as KML: /data/KML5Layer/L1.kml (P.1 = 4.30 m) to L5.kml (5.30 m, the 2024 record). Each file is the whole extent at that level. The dashboard wants one ring per stage instead: zone k is what floods first when P.1 reaches stage k's level (the extent at level k minus everything flooded at a lower level). uv run scripts/build_flood_zones.py # fetch the KML from CMU uv run scripts/build_flood_zones.py --kml-dir D # or read L1..L5.kml from D The levels must match P1_FLOOD_STAGES in src/ml/features.py. """ import argparse import json import re import urllib.request from pathlib import Path import shapely from shapely import make_valid from shapely.geometry import MultiPolygon, Polygon, mapping from shapely.ops import unary_union REPO = Path(__file__).resolve().parents[1] OUT = REPO / "src" / "static" / "flood-zones-p1.geojson" BASE = "https://watercenter.cmu.ac.th/data/KML5Layer" LEVELS = [4.30, 4.50, 4.70, 5.00, 5.30] # L1..L5, P.1 gauge level in m # ~3 m at Chiang Mai: invisible at the zoom levels the map allows, and it # keeps the file small. Slivers below MIN_AREA (about 300 m²) come from the # layers' borders not lining up exactly and are dropped. SIMPLIFY_DEG = 3e-5 MIN_AREA_DEG2 = 2.5e-8 GRID = 1e-5 # coordinates snapped to 5 decimals (~1 m) with valid geometry SOURCE = ( "CMU Water Center (ศูนย์วิชาการสนับสนุนการบริหารจัดการน้ำ มหาวิทยาลัยเชียงใหม่), " "Chiang Mai flood hazard map from the October 2024 flood, " "https://watercenter.cmu.ac.th/chiangmai/cmflood/warning/floodmap" ) def fetch(i: int, kml_dir: Path | None) -> str: if kml_dir: return (kml_dir / f"L{i}.kml").read_text(encoding="utf-8") req = urllib.request.Request( f"{BASE}/L{i}.kml", headers={"User-Agent": "Mozilla/5.0"} ) with urllib.request.urlopen(req, timeout=60) as r: return r.read().decode("utf-8") def ring(text: str) -> list: pts = [] for tok in text.split(): x, y, *_ = tok.split(",") pts.append((float(x), float(y))) return pts def kml_extent(text: str): polys = [] for body in re.findall(r"(.*?)", text, re.S): outer = re.search( r".*?(.*?)", body, re.S ) inner = re.findall( r".*?(.*?)", body, re.S ) polys.append( make_valid(Polygon(ring(outer.group(1)), [ring(s) for s in inner])) ) return unary_union(polys) def polygons(geom) -> list: if isinstance(geom, Polygon): return [geom] return [ g for g in getattr(geom, "geoms", []) if isinstance(g, (Polygon, MultiPolygon)) ] def clean(geom): parts = [] for g in polygons(geom): for p in polygons(g): if p.area >= MIN_AREA_DEG2: parts.append(p) return unary_union(parts) def rounded(coords): # the geometry is already on the grid; this only drops float noise if isinstance(coords, (list, tuple)) and coords and isinstance(coords[0], float): return [round(coords[0], 5), round(coords[1], 5)] return [rounded(c) for c in coords] def main() -> None: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--kml-dir", type=Path, help="read L1..L5.kml from here") args = ap.parse_args() flooded = None features = [] for i, level in enumerate(LEVELS, 1): extent = kml_extent(fetch(i, args.kml_dir)) extent = shapely.set_precision( make_valid(extent.simplify(SIMPLIFY_DEG, preserve_topology=True)), GRID ) if flooded is None: new, flooded = extent, extent else: new = shapely.difference(extent, flooded, grid_size=GRID) flooded = shapely.union(flooded, extent, grid_size=GRID) zone = shapely.set_precision(clean(new), GRID) assert zone.is_valid, f"zone {i} is not a valid geometry" geo = mapping(zone) features.append( { "type": "Feature", "properties": {"zone": i, "level": level}, "geometry": { "type": geo["type"], "coordinates": rounded(geo["coordinates"]), }, } ) print(f"zone {i} (P.1 {level:.2f} m): {len(polygons(zone))} polygons") doc = { "type": "FeatureCollection", "source": SOURCE, "note": "Zone k floods first when P.1 reaches its level; zones are disjoint.", # highest zone first, so the zones that flood first are drawn on top "features": features[::-1], } OUT.write_text( json.dumps(doc, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8", ) print(f"wrote {OUT.relative_to(REPO)}: {OUT.stat().st_size / 1024:.0f} KB") if __name__ == "__main__": main()