| |
| """Build the StormScope operational web view from cycle .npz outputs. |
| |
| Rendering is delegated to rust/stormscope_render, which uses rustwx-render for |
| the basemap, projection, palettes, and PNG writing. This script only watches |
| cycle manifests, invokes the renderer, assembles GIFs, and writes the HTML shell. |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import time |
| import traceback |
| from dataclasses import dataclass |
| from datetime import datetime, timedelta, timezone |
| from pathlib import Path |
|
|
| from PIL import Image |
|
|
| from .paths import default_exports_dir, default_outdir, default_site_dir, default_spatial_root, repo_root |
| from .wxstore_export import export_cycle as export_wxstore_cycle |
|
|
|
|
| @dataclass(frozen=True) |
| class Product: |
| slug: str |
| renderer_key: str |
| field: str |
| title: str |
| duration_ms: int |
|
|
|
|
| @dataclass(frozen=True) |
| class Region: |
| slug: str |
| title: str |
| west: float |
| east: float |
| south: float |
| north: float |
| pad_frac: float = 0.0 |
|
|
|
|
| PRODUCTS = [ |
| Product("ir", "ir", "ch_ir_1035", "IR Brightness Temperature", 650), |
| Product("refc", "refc", "refc", "Composite Reflectivity", 650), |
| ] |
|
|
| REGIONS = { |
| "conus": Region("conus", "CONUS", -126.0, -66.0, 23.0, 50.0, 0.0), |
| "central_plains": Region("central_plains", "Central Plains", -106.5, -90.0, 31.5, 47.2, 0.0), |
| } |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| root = repo_root() |
| renderer_name = "stormscope_render.exe" if os.name == "nt" else "stormscope_render" |
| wxstore_name = "stormscope_wxstore.exe" if os.name == "nt" else "stormscope_wxstore" |
| ap = argparse.ArgumentParser(description="Render StormScope operational site") |
| ap.add_argument("--outdir", default=str(default_outdir())) |
| ap.add_argument("--site-dir", default=str(default_site_dir())) |
| ap.add_argument("--renderer", default=str(root / "rust" / "stormscope_render" / "target" / "release" / renderer_name)) |
| ap.add_argument("--wxstore-exporter", default=str(root / "rust" / "stormscope_wxstore" / "target" / "release" / wxstore_name)) |
| ap.add_argument("--wxstore-spatial-root", default=str(default_spatial_root())) |
| ap.add_argument("--wxstore-fields", default="", |
| help="comma-separated WXA field allowlist; default exports all StormScope 2D fields") |
| ap.add_argument("--no-wxstore-export", action="store_true", |
| help="skip native rustwx WXA export") |
| ap.add_argument("--visual-dir", default=str(default_exports_dir())) |
| ap.add_argument("--export-dir", default="", |
| help="alias for --visual-dir; copies latest exported GIFs/PNGs there") |
| ap.add_argument("--regions", default="conus,central_plains", |
| help="comma-separated region slugs; available: conus,central_plains") |
| ap.add_argument("--loop", action="store_true") |
| ap.add_argument("--interval", type=int, default=60, help="seconds between render scans") |
| ap.add_argument("--width", type=int, default=1200) |
| ap.add_argument("--height", type=int, default=820) |
| ap.add_argument("--max-cycles-per-mode", type=int, default=2, |
| help="render only this many latest cycles per mode; 0 renders all") |
| ap.add_argument("--force", action="store_true") |
| args = ap.parse_args() |
| if args.export_dir and not args.visual_dir: |
| args.visual_dir = args.export_dir |
| args.region_list = parse_regions(args.regions) |
| return args |
|
|
|
|
| def parse_regions(value: str) -> list[Region]: |
| regions = [] |
| for raw in value.split(","): |
| slug = raw.strip() |
| if not slug: |
| continue |
| if slug not in REGIONS: |
| raise ValueError(f"unknown region {slug!r}; available: {', '.join(REGIONS)}") |
| regions.append(REGIONS[slug]) |
| return regions or [REGIONS["conus"]] |
|
|
|
|
| def read_json(path: Path) -> dict: |
| return json.loads(path.read_text()) |
|
|
|
|
| def display_mode(mode: str) -> str: |
| return "nearcast" if mode == "forecast" else mode |
|
|
|
|
| def mode_title(mode: str) -> str: |
| return display_mode(mode).capitalize() |
|
|
|
|
| def parse_dt(value: str) -> datetime: |
| return datetime.fromisoformat(value.replace("Z", "+00:00")) |
|
|
|
|
| def dt_label(value: str) -> str: |
| return parse_dt(value).strftime("%Y-%m-%d %H:%MZ") |
|
|
|
|
| def cycle_sort_key(meta: dict) -> datetime: |
| return parse_dt(meta["init_time"]) |
|
|
|
|
| def relpath(path: Path, root: Path) -> str: |
| return path.relative_to(root).as_posix() |
|
|
|
|
| def should_update(target: Path, sources: list[Path], force: bool) -> bool: |
| if force or not target.exists(): |
| return True |
| try: |
| if target.stat().st_size == 0: |
| return True |
| except OSError: |
| return True |
| target_mtime = target.stat().st_mtime |
| return any(src.exists() and src.stat().st_mtime > target_mtime for src in sources) |
|
|
|
|
| def frame_number(filename: str, index: int) -> str: |
| stem = Path(filename).stem |
| tail = stem.rsplit("_", 1)[-1] |
| if tail.startswith("f") and tail[1:].isdigit(): |
| return tail.upper() |
| return f"F{index + 1:02d}" |
|
|
|
|
| def visible_frame_indices(meta: dict) -> list[int]: |
| files = meta.get("files", []) |
| if display_mode(meta["mode"]) != "nearcast": |
| return list(range(len(files))) |
|
|
| valid_times = meta.get("valid_times", []) |
| cutoff = datetime.now(timezone.utc) - timedelta(minutes=15) |
| selected = [ |
| i for i in range(len(files)) |
| if i < len(valid_times) and parse_dt(valid_times[i]) >= cutoff |
| ] |
| return selected or list(range(len(files))) |
|
|
|
|
| def render_png(renderer: Path, npz: Path, output: Path, product: Product, region: Region, meta: dict, |
| index: int, width: int, height: int, force: bool) -> None: |
| if not should_update(output, [npz, renderer], force): |
| return |
| valid = meta["valid_times"][index] if index < len(meta.get("valid_times", [])) else "" |
| step_minutes = int(meta.get("step_minutes", 0)) |
| lead_minutes = step_minutes * (index + 1) |
| if lead_minutes and lead_minutes % 60 == 0: |
| lead = f"+{lead_minutes // 60} h" |
| elif lead_minutes: |
| lead = f"+{lead_minutes} min" |
| else: |
| lead = frame_number(npz.name, index) |
| title = f"StormScope {mode_title(meta['mode'])} {region.title} {product.title}" |
| subtitle_left = f"Init {dt_label(meta['init_time'])} {frame_number(npz.name, index)} ({lead})" |
| if valid: |
| subtitle_left += f" valid {dt_label(valid)}" |
| subtitle_right = "rustwx-render / rustwx-stormscope" |
|
|
| cmd = [ |
| str(renderer), |
| "--input", str(npz), |
| "--output", str(output), |
| "--product", product.renderer_key, |
| "--title", title, |
| "--subtitle-left", subtitle_left, |
| "--subtitle-right", subtitle_right, |
| "--width", str(width), |
| "--height", str(height), |
| f"--west={region.west}", |
| f"--east={region.east}", |
| f"--south={region.south}", |
| f"--north={region.north}", |
| f"--bounds-pad-frac={region.pad_frac}", |
| ] |
| env = os.environ.copy() |
| vendor_assets = repo_root() / "rust" / "vendor" / "rustwx" / "assets" |
| if vendor_assets.exists(): |
| env.setdefault("RUSTWX_ASSETS_DIR", str(vendor_assets)) |
| subprocess.run(cmd, check=True, env=env) |
|
|
|
|
| def make_gif(frames: list[Path], gif_path: Path, duration_ms: int, force: bool) -> None: |
| if not frames: |
| return |
| if not should_update(gif_path, frames, force): |
| return |
| gif_path.parent.mkdir(parents=True, exist_ok=True) |
| images = [] |
| for frame in frames: |
| try: |
| if not frame.exists() or frame.stat().st_size == 0: |
| frame.unlink(missing_ok=True) |
| continue |
| image = Image.open(frame) |
| image.load() |
| images.append(image.convert("RGB")) |
| image.close() |
| except Exception: |
| frame.unlink(missing_ok=True) |
| if not images: |
| return |
| try: |
| images[0].save( |
| gif_path, |
| save_all=True, |
| append_images=images[1:], |
| duration=duration_ms, |
| loop=0, |
| optimize=False, |
| ) |
| finally: |
| for image in images: |
| image.close() |
|
|
|
|
| def latest_meta_paths(meta_paths: list[Path], max_per_mode: int) -> list[Path]: |
| if max_per_mode <= 0: |
| return meta_paths |
| buckets: dict[str, list[tuple[datetime, Path]]] = {} |
| for path in meta_paths: |
| try: |
| meta = read_json(path) |
| key = display_mode(meta["mode"]) |
| init = parse_dt(meta["init_time"]) |
| except Exception: |
| continue |
| buckets.setdefault(key, []).append((init, path)) |
| selected = [] |
| for entries in buckets.values(): |
| selected.extend(path for _, path in sorted(entries, reverse=True)[:max_per_mode]) |
| return sorted(set(selected)) |
|
|
|
|
| def render_cycle(meta_path: Path, outdir: Path, site_dir: Path, renderer: Path, |
| regions: list[Region], |
| width: int, height: int, force: bool) -> dict: |
| meta = read_json(meta_path) |
| mode = display_mode(meta["mode"]) |
| cycle = meta["cycle"] |
| products_out = [] |
| fields = set(meta.get("fields", [])) |
|
|
| for region in regions: |
| cycle_dir = site_dir / "assets" / region.slug / mode / cycle |
| for product in PRODUCTS: |
| if product.field not in fields: |
| continue |
| frame_paths = [] |
| png_dir = cycle_dir / product.slug / "frames" |
| files = meta.get("files", []) |
| frame_indices = visible_frame_indices(meta) |
| for index in frame_indices: |
| filename = files[index] |
| npz = outdir / filename |
| if not npz.exists(): |
| continue |
| png = png_dir / f"{Path(filename).stem}_{region.slug}_{product.slug}.png" |
| render_png(renderer, npz, png, product, region, meta, index, width, height, force) |
| frame_paths.append(png) |
| gif_path = cycle_dir / f"{product.slug}.gif" |
| make_gif(frame_paths, gif_path, product.duration_ms, force) |
| products_out.append({ |
| "slug": product.slug, |
| "title": product.title, |
| "region_slug": region.slug, |
| "region_title": region.title, |
| "gif": gif_path, |
| "frames": frame_paths, |
| "n_frames": len(frame_paths), |
| }) |
|
|
| return { |
| "mode": mode, |
| "source_mode": meta["mode"], |
| "cycle": cycle, |
| "init_time": meta["init_time"], |
| "valid_times": [ |
| meta.get("valid_times", [])[i] |
| for i in visible_frame_indices(meta) |
| if i < len(meta.get("valid_times", [])) |
| ], |
| "step_minutes": meta.get("step_minutes"), |
| "resolution": meta.get("resolution"), |
| "products": products_out, |
| "meta_path": meta_path, |
| } |
|
|
|
|
| def latest_by_mode(cycles: list[dict]) -> list[dict]: |
| latest = {} |
| for cycle in cycles: |
| key = cycle["mode"] |
| if key not in latest or parse_dt(cycle["init_time"]) > parse_dt(latest[key]["init_time"]): |
| latest[key] = cycle |
| return [latest[key] for key in ("nowcast", "nearcast") if key in latest] |
|
|
|
|
| def write_site(site_dir: Path, outdir: Path, cycles: list[dict]) -> None: |
| site_dir.mkdir(parents=True, exist_ok=True) |
| now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") |
| service_status = {} |
| status_path = outdir / "status.json" |
| if status_path.exists(): |
| try: |
| service_status = read_json(status_path) |
| except Exception: |
| service_status = {"state": "status unreadable"} |
|
|
| manifest_cycles = [] |
| for cycle in cycles: |
| manifest_products = [] |
| for product in cycle["products"]: |
| manifest_products.append({ |
| "slug": product["slug"], |
| "title": product["title"], |
| "region_slug": product["region_slug"], |
| "region_title": product["region_title"], |
| "gif": relpath(product["gif"], site_dir) if product["gif"].exists() else None, |
| "frames": [relpath(frame, site_dir) for frame in product["frames"] if frame.exists()], |
| "n_frames": product["n_frames"], |
| }) |
| manifest_cycles.append({**{k: cycle[k] for k in ( |
| "mode", "source_mode", "cycle", "init_time", "valid_times", "step_minutes", "resolution" |
| )}, "products": manifest_products}) |
|
|
| (site_dir / "manifest.json").write_text(json.dumps({ |
| "updated": now, |
| "service_status": service_status, |
| "cycles": manifest_cycles, |
| }, indent=2)) |
|
|
| cards = [] |
| for cycle in latest_by_mode(cycles): |
| product_cards = [] |
| for product in cycle["products"]: |
| if not product["gif"].exists(): |
| continue |
| gif_rel = relpath(product["gif"], site_dir) |
| download_name = f"stormscope_{cycle['mode']}_{cycle['cycle']}_{product['region_slug']}_{product['slug']}.gif" |
| product_cards.append(f""" |
| <article class="product"> |
| <div class="product-head"> |
| <div> |
| <h3>{html_escape(product["title"])}</h3> |
| <span>{html_escape(product["region_title"])} | {product["n_frames"]} frames</span> |
| </div> |
| <a class="export" href="{gif_rel}" download="{html_escape(download_name)}">Export GIF</a> |
| </div> |
| <img src="{gif_rel}" alt="{html_escape(cycle["mode"])} {html_escape(product["region_title"])} {html_escape(product["title"])} animation"> |
| </article>""") |
| valid_times = cycle.get("valid_times") or [] |
| valid_span = "" |
| if valid_times: |
| valid_span = f"{dt_label(valid_times[0])} to {dt_label(valid_times[-1])}" |
| cards.append(f""" |
| <section class="cycle {cycle["mode"]}"> |
| <div class="cycle-head"> |
| <div> |
| <p class="kicker">{html_escape(cycle["mode"])}</p> |
| <h2>{html_escape(cycle["resolution"])} cycle {html_escape(cycle["cycle"])}</h2> |
| </div> |
| <dl> |
| <div><dt>Init</dt><dd>{dt_label(cycle["init_time"])}</dd></div> |
| <div><dt>Valid</dt><dd>{html_escape(valid_span)}</dd></div> |
| <div><dt>Step</dt><dd>{cycle["step_minutes"]} min</dd></div> |
| </dl> |
| </div> |
| <div class="products"> |
| {"".join(product_cards) if product_cards else "<p>No rendered products yet.</p>"} |
| </div> |
| </section>""") |
|
|
| state = service_status.get("state", "unknown") |
| last_success = service_status.get("last_success", "") |
| next_run = service_status.get("next_run", "") |
| active_modes = {cycle["mode"] for cycle in latest_by_mode(cycles)} |
| if active_modes == {"nowcast"}: |
| page_subtitle = "Nowcast, refreshed from the latest completed cycle." |
| elif active_modes == {"nearcast"}: |
| page_subtitle = "GFS-conditioned nearcast, refreshed from the latest completed cycle." |
| else: |
| page_subtitle = "Nowcast and GFS-conditioned nearcast, refreshed from the latest completed cycle." |
| body = "".join(cards) if cards else """ |
| <section class="empty"> |
| <h2>Waiting for StormScope cycles</h2> |
| <p>The renderer is ready; the first products will appear after the service writes a cycle manifest.</p> |
| </section>""" |
|
|
| (site_dir / "index.html").write_text(f"""<!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <meta http-equiv="refresh" content="60"> |
| <title>StormScope Operational</title> |
| <style> |
| :root {{ |
| color-scheme: light; |
| --bg: #f6f7f4; |
| --ink: #1d211f; |
| --muted: #657069; |
| --line: #d8ddd7; |
| --surface: #ffffff; |
| --green: #0f766e; |
| --orange: #b45309; |
| --red: #b91c1c; |
| }} |
| * {{ box-sizing: border-box; }} |
| body {{ |
| margin: 0; |
| background: var(--bg); |
| color: var(--ink); |
| font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| }} |
| header {{ |
| padding: 18px 22px 12px; |
| border-bottom: 1px solid var(--line); |
| background: var(--surface); |
| display: flex; |
| align-items: end; |
| justify-content: space-between; |
| gap: 18px; |
| flex-wrap: wrap; |
| }} |
| h1, h2, h3, p {{ margin: 0; }} |
| h1 {{ font-size: clamp(24px, 3vw, 34px); letter-spacing: 0; }} |
| .status {{ |
| display: grid; |
| grid-template-columns: repeat(3, max-content); |
| gap: 8px 14px; |
| color: var(--muted); |
| font-size: 13px; |
| }} |
| .status strong {{ color: var(--ink); }} |
| main {{ padding: 18px 22px 28px; display: grid; gap: 18px; }} |
| .cycle {{ |
| background: var(--surface); |
| border: 1px solid var(--line); |
| border-top: 4px solid var(--green); |
| border-radius: 8px; |
| overflow: hidden; |
| }} |
| .cycle.nearcast {{ border-top-color: var(--orange); }} |
| .cycle-head {{ |
| padding: 14px 16px; |
| display: flex; |
| align-items: start; |
| justify-content: space-between; |
| gap: 16px; |
| border-bottom: 1px solid var(--line); |
| }} |
| .kicker {{ |
| text-transform: uppercase; |
| color: var(--muted); |
| font-size: 12px; |
| font-weight: 700; |
| }} |
| h2 {{ font-size: 20px; letter-spacing: 0; }} |
| dl {{ |
| margin: 0; |
| display: grid; |
| grid-template-columns: repeat(3, max-content); |
| gap: 10px 16px; |
| font-size: 13px; |
| color: var(--muted); |
| }} |
| dt {{ font-weight: 700; color: var(--ink); }} |
| dd {{ margin: 2px 0 0; }} |
| .products {{ |
| padding: 14px; |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(min(100%, 520px), 1fr)); |
| gap: 14px; |
| }} |
| .product {{ |
| border: 1px solid var(--line); |
| border-radius: 8px; |
| background: #fbfcfa; |
| overflow: hidden; |
| }} |
| .product-head {{ |
| min-height: 46px; |
| padding: 10px 12px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 10px; |
| border-bottom: 1px solid var(--line); |
| }} |
| h3 {{ font-size: 15px; letter-spacing: 0; }} |
| .product-head span {{ color: var(--muted); font-size: 12px; white-space: nowrap; }} |
| .export {{ |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| min-height: 32px; |
| padding: 0 11px; |
| border: 1px solid var(--line); |
| border-radius: 6px; |
| color: var(--ink); |
| background: #fff; |
| font-size: 13px; |
| font-weight: 700; |
| text-decoration: none; |
| white-space: nowrap; |
| }} |
| .export:hover {{ border-color: var(--green); color: var(--green); }} |
| img {{ width: 100%; height: auto; display: block; background: #fff; }} |
| .empty {{ |
| background: var(--surface); |
| border: 1px solid var(--line); |
| border-radius: 8px; |
| padding: 18px; |
| }} |
| @media (max-width: 800px) {{ |
| header, main {{ padding-left: 12px; padding-right: 12px; }} |
| .status, dl {{ grid-template-columns: 1fr; }} |
| .cycle-head {{ display: grid; }} |
| }} |
| </style> |
| </head> |
| <body> |
| <header> |
| <div> |
| <h1>StormScope Operational</h1> |
| <p>{html_escape(page_subtitle)}</p> |
| </div> |
| <div class="status"> |
| <span>Service <strong>{html_escape(str(state))}</strong></span> |
| <span>Updated <strong>{html_escape(now)}</strong></span> |
| <span>Next <strong>{html_escape(str(next_run or last_success or "pending"))}</strong></span> |
| </div> |
| </header> |
| <main> |
| {body} |
| </main> |
| </body> |
| </html> |
| """) |
|
|
|
|
| def html_escape(value: str) -> str: |
| return ( |
| value.replace("&", "&") |
| .replace("<", "<") |
| .replace(">", ">") |
| .replace('"', """) |
| ) |
|
|
|
|
| def mirror_latest_visuals(site_dir: Path, cycles: list[dict], visual_dir: Path | None) -> None: |
| if not visual_dir: |
| return |
| visual_dir.mkdir(parents=True, exist_ok=True) |
| for cycle in latest_by_mode(cycles): |
| for product in cycle["products"]: |
| if product["gif"].exists(): |
| shutil.copy2( |
| product["gif"], |
| visual_dir / f"latest_{cycle['mode']}_{product['region_slug']}_{product['slug']}.gif", |
| ) |
| frames = [frame for frame in product["frames"] if frame.exists()] |
| if frames: |
| shutil.copy2( |
| frames[-1], |
| visual_dir / f"latest_{cycle['mode']}_{product['region_slug']}_{product['slug']}_last.png", |
| ) |
| index = site_dir / "index.html" |
| if index.exists(): |
| shutil.copy2(index, visual_dir / "index.html") |
|
|
|
|
| def export_wxstore_cycles(cycles: list[dict], outdir: Path, args: argparse.Namespace) -> None: |
| if args.no_wxstore_export: |
| return |
| spatial_root = Path(args.wxstore_spatial_root) |
| fields = args.wxstore_fields or None |
| for cycle in cycles: |
| meta_path = Path(cycle["meta_path"]) |
| try: |
| result = export_wxstore_cycle( |
| meta_path, |
| outdir, |
| spatial_root, |
| binary=args.wxstore_exporter, |
| fields=fields, |
| force=args.force, |
| ) |
| if result.get("skipped_current"): |
| continue |
| print( |
| f"[ops_render] WXA exported {result.get('model')} {result.get('cycle')} " |
| f"products={result.get('products', 'unknown')}", |
| flush=True, |
| ) |
| except FileNotFoundError as exc: |
| print(f"[ops_render] WXA export unavailable: {exc}", flush=True) |
| return |
| except Exception as exc: |
| print(f"[ops_render] WXA export failed for {meta_path.name}: {exc}", flush=True) |
|
|
|
|
| def render_once(args: argparse.Namespace) -> list[dict]: |
| outdir = Path(args.outdir) |
| site_dir = Path(args.site_dir) |
| renderer = Path(args.renderer) |
| if not renderer.exists(): |
| raise FileNotFoundError(f"renderer binary not found: {renderer}") |
| all_meta_paths = sorted(outdir.glob("stormscope_*_*_*_meta.json")) |
| complete_meta_paths = [] |
| for path in all_meta_paths: |
| try: |
| if read_json(path).get("complete", True): |
| complete_meta_paths.append(path) |
| except Exception: |
| continue |
| meta_paths = latest_meta_paths( |
| complete_meta_paths, |
| args.max_cycles_per_mode, |
| ) |
| cycles = [ |
| render_cycle(path, outdir, site_dir, renderer, args.region_list, args.width, args.height, args.force) |
| for path in meta_paths |
| ] |
| export_wxstore_cycles(cycles, outdir, args) |
| write_site(site_dir, outdir, cycles) |
| visual_dir = Path(args.visual_dir) if args.visual_dir else None |
| mirror_latest_visuals(site_dir, cycles, visual_dir) |
| return cycles |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| while True: |
| try: |
| cycles = render_once(args) |
| print(f"[ops_render] rendered/checked {len(cycles)} cycles at {datetime.now(timezone.utc).isoformat()}", flush=True) |
| except Exception: |
| traceback.print_exc() |
| if not args.loop: |
| return 1 |
| if not args.loop: |
| return 0 |
| time.sleep(max(5, args.interval)) |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|