#!/usr/bin/env python """Make a lightweight Leaflet web-map preview from StormScope .npz outputs.""" from __future__ import annotations import argparse import json from dataclasses import dataclass from datetime import datetime from pathlib import Path import numpy as np from PIL import Image from .paths import default_outdir, default_webmap_dir @dataclass(frozen=True) class Product: slug: str field: str title: str default_opacity: float PRODUCTS = { "refc": Product("refc", "refc", "Composite Reflectivity", 0.82), "ir": Product("ir", "ch_ir_1035", "IR Brightness Temperature", 0.64), } VIEWS = { "data": None, "conus": {"west": -126.0, "east": -66.0, "south": 23.0, "north": 50.0}, "central_plains": {"west": -106.5, "east": -90.0, "south": 31.5, "north": 47.2}, } REFC_LEVELS = np.array([5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75], dtype=np.float32) REFC_COLORS = np.array( [ (236, 243, 252, 0), (205, 222, 246, 125), (160, 194, 232, 155), (103, 157, 212, 185), (32, 102, 172, 205), (44, 162, 95, 215), (166, 217, 106, 220), (255, 240, 102, 225), (253, 174, 55, 232), (244, 109, 32, 238), (215, 48, 31, 242), (165, 0, 38, 246), (122, 50, 148, 248), (84, 39, 136, 250), (68, 68, 68, 252), ], dtype=np.uint8, ) IR_STOPS = np.array([190, 205, 220, 235, 250, 265, 280, 300], dtype=np.float32) IR_COLORS = np.array( [ (62, 20, 115, 220), (44, 76, 160, 205), (32, 143, 195, 190), (99, 194, 174, 168), (226, 237, 154, 135), (250, 180, 92, 105), (126, 126, 126, 70), (255, 255, 255, 0), ], dtype=np.float32, ) def parse_args() -> argparse.Namespace: ap = argparse.ArgumentParser(description="Build a web-map overlay preview from StormScope .npz") ap.add_argument("--meta", default="", help="cycle metadata JSON; defaults to latest completed cycle") ap.add_argument("--outdir", default=str(default_outdir())) ap.add_argument("--output-dir", default=str(default_webmap_dir())) ap.add_argument("--products", default="refc,ir", help="comma-separated: refc,ir") ap.add_argument("--view", default="central_plains", choices=sorted(VIEWS), help="initial web-map view") ap.add_argument("--max-frames", type=int, default=12) ap.add_argument("--force", action="store_true") return ap.parse_args() def read_json(path: Path) -> dict: return json.loads(path.read_text()) def parse_dt(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) def latest_meta(outdir: Path) -> Path: candidates: list[tuple[datetime, Path]] = [] for path in outdir.glob("stormscope_*_*_*_meta.json"): try: meta = read_json(path) if meta.get("complete", True): candidates.append((parse_dt(meta["init_time"]), path)) except Exception: continue if not candidates: raise FileNotFoundError(f"no completed StormScope cycle metadata found in {outdir}") return sorted(candidates)[-1][1] def normalize_lon(values: np.ndarray) -> np.ndarray: return np.where(values > 180.0, values - 360.0, values) def bounds_from_npz(npz_path: Path) -> dict: with np.load(npz_path) as data: lats = data["lats"].astype(np.float32) lons = normalize_lon(data["lons"].astype(np.float32)) finite = np.isfinite(lats) & np.isfinite(lons) return { "west": float(np.nanmin(lons[finite])), "east": float(np.nanmax(lons[finite])), "south": float(np.nanmin(lats[finite])), "north": float(np.nanmax(lats[finite])), } def rgba_refc(values: np.ndarray) -> np.ndarray: rgba = np.zeros((*values.shape, 4), dtype=np.uint8) mask = np.isfinite(values) & (values >= REFC_LEVELS[0]) if not np.any(mask): return rgba indices = np.searchsorted(REFC_LEVELS, values[mask], side="right") - 1 indices = np.clip(indices, 0, len(REFC_COLORS) - 1) rgba[mask] = REFC_COLORS[indices] return rgba def rgba_ir(values: np.ndarray) -> np.ndarray: rgba = np.zeros((*values.shape, 4), dtype=np.uint8) mask = np.isfinite(values) if not np.any(mask): return rgba clipped = np.clip(values[mask], IR_STOPS[0], IR_STOPS[-1]) channels = [] for channel in range(4): channels.append(np.interp(clipped, IR_STOPS, IR_COLORS[:, channel])) rgba[mask] = np.stack(channels, axis=1).clip(0, 255).astype(np.uint8) return rgba def field_rgba(product: Product, values: np.ndarray) -> np.ndarray: if product.slug == "refc": return rgba_refc(values) if product.slug == "ir": return rgba_ir(values) raise ValueError(f"unknown product {product.slug}") def frame_label(meta: dict, index: int) -> str: step = int(meta.get("step_minutes") or 0) if step: lead = step * (index + 1) if lead % 60 == 0: return f"+{lead // 60}h" return f"+{lead}m" return f"f{index + 1:02d}" def render_product_frame(npz_path: Path, product: Product, output: Path, force: bool) -> dict: if output.exists() and output.stat().st_size > 0 and not force and output.stat().st_mtime >= npz_path.stat().st_mtime: return {"path": output} output.parent.mkdir(parents=True, exist_ok=True) with np.load(npz_path) as data: values = data[product.field].astype(np.float32) rgba = field_rgba(product, values) Image.fromarray(rgba, "RGBA").save(output, optimize=False) return {"path": output} def write_html(output_dir: Path) -> None: (output_dir / "index.html").write_text( """ StormScope Web Map Preview

StormScope

""", encoding="utf-8", ) def build_preview(meta_path: Path, outdir: Path, output_dir: Path, product_slugs: list[str], view: str, max_frames: int, force: bool) -> dict: meta = read_json(meta_path) files = list(meta.get("files", [])) if max_frames > 0: files = files[:max_frames] if not files: raise ValueError(f"metadata has no files: {meta_path}") output_dir.mkdir(parents=True, exist_ok=True) assets = output_dir / "assets" / meta.get("cycle", "cycle") bounds = bounds_from_npz(outdir / files[0]) manifest = { "schema": "rustwx-stormscope.webmap_preview.v1", "source_meta": str(meta_path), "cycle": meta.get("cycle"), "mode": "nearcast" if meta.get("mode") == "forecast" else meta.get("mode"), "resolution": meta.get("resolution"), "init_time": meta.get("init_time"), "bounds": bounds, "view": view, "view_bounds": VIEWS[view] or bounds, "frame_count": len(files), "products": {}, } for slug in product_slugs: if slug not in PRODUCTS: raise ValueError(f"unknown product {slug!r}; available: {', '.join(PRODUCTS)}") product = PRODUCTS[slug] frames = [] for index, filename in enumerate(files): npz_path = outdir / filename png_name = f"{Path(filename).stem}_{product.slug}.png" png_path = assets / product.slug / png_name render_product_frame(npz_path, product, png_path, force) valid_times = meta.get("valid_times") or [] frames.append( { "index": index, "label": frame_label(meta, index), "valid_time": valid_times[index] if index < len(valid_times) else None, "url": png_path.relative_to(output_dir).as_posix(), "source_npz": str(npz_path), } ) manifest["products"][slug] = { "field": product.field, "title": product.title, "opacity": product.default_opacity, "frames": frames, } (output_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (output_dir / "manifest.js").write_text( "window.STORMSCOPE_WEBMAP = " + json.dumps(manifest, indent=2) + ";\n", encoding="utf-8", ) write_html(output_dir) return manifest def main() -> int: args = parse_args() outdir = Path(args.outdir) meta = Path(args.meta) if args.meta else latest_meta(outdir) products = [slug.strip() for slug in args.products.split(",") if slug.strip()] manifest = build_preview(meta, outdir, Path(args.output_dir), products, args.view, args.max_frames, args.force) print(json.dumps({"ok": True, "index": str(Path(args.output_dir) / "index.html"), "cycle": manifest["cycle"]}, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())