deepguess's picture
Update StormScope Fast Space (live)
a51e33c verified
Raw
History Blame Contribute Delete
23.4 kB
from __future__ import annotations
import base64
import importlib.util
import importlib.metadata
import html
import json
import os
import platform
import subprocess
import sys
import time
from functools import lru_cache
from pathlib import Path
import gradio as gr
try:
import spaces as hf_spaces
except Exception: # Local dev environments usually do not have HF ZeroGPU.
hf_spaces = None
HERE = Path(__file__).resolve().parent
DEMO_WEBMAP = HERE / "demo_webmap"
MANIFEST_PATH = DEMO_WEBMAP / "manifest.json"
LIVE_ROOT = Path(os.getenv("STORMSCOPE_LIVE_ROOT", "/tmp/stormscope-live"))
LIVE_TIMEOUT_SEC = int(os.getenv("STORMSCOPE_LIVE_TIMEOUT_SEC", "900"))
PYDEPS_ROOT = Path(os.getenv("STORMSCOPE_PYDEPS_ROOT", str(LIVE_ROOT / "pydeps")))
EARTH2GRID_REF = os.getenv("STORMSCOPE_EARTH2GRID_REF", "8fdff5a78d324f8d25afe224915301b3169bffe2")
def gpu_decorator(*args, **kwargs):
if hf_spaces is not None and hasattr(hf_spaces, "GPU"):
return hf_spaces.GPU(*args, **kwargs)
def passthrough(func):
return func
return passthrough
def data_uri(path: Path) -> str:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def embed_manifest(webmap_dir: Path, *, cached: bool = False) -> dict:
manifest_path = webmap_dir / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for product in manifest["products"].values():
for frame in product["frames"]:
frame_path = webmap_dir / frame["url"]
frame["url"] = data_uri(frame_path)
frame.pop("source_npz", None)
manifest["source"] = "bundled-demo" if cached else "live"
return manifest
@lru_cache(maxsize=1)
def embedded_demo_manifest() -> dict:
return embed_manifest(DEMO_WEBMAP, cached=True)
def map_html(manifest: dict | None = None) -> str:
manifest = manifest or embedded_demo_manifest()
manifest_json = json.dumps(manifest)
document = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<style>
html, body {{ margin: 0; width: 100%; height: 100%; }}
#stormscope-map-wrap {{
position: relative;
width: 100%;
height: min(74vh, 760px);
min-height: 560px;
border: 1px solid #cad4cf;
border-radius: 8px;
overflow: hidden;
background: #eef2ef;
}}
#stormscope-map {{ width: 100%; height: 100%; }}
#stormscope-panel {{
position: absolute;
z-index: 700;
left: 12px;
right: 12px;
top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: rgba(255,255,255,.93);
border: 1px solid #d5ddd7;
border-radius: 8px;
box-shadow: 0 8px 28px rgba(20,30,25,.12);
}}
#stormscope-panel h1 {{
margin: 0;
font-size: 16px;
line-height: 1.1;
letter-spacing: 0;
}}
#stormscope-meta {{ color: #59655f; font-size: 12px; margin-top: 3px; }}
#stormscope-controls {{ display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }}
#stormscope-controls button, #stormscope-controls select {{
min-height: 32px;
border: 1px solid #cbd5ce;
border-radius: 6px;
background: white;
color: #17201b;
font: inherit;
font-weight: 650;
padding: 0 10px;
}}
#stormscope-controls input[type="range"] {{ width: min(38vw, 420px); }}
#stormscope-stamp {{ min-width: 88px; font-weight: 750; text-align: center; }}
.leaflet-control-attribution {{ font-size: 10px; }}
@media (max-width: 760px) {{
#stormscope-map-wrap {{ min-height: 620px; }}
#stormscope-panel {{ display: grid; left: 8px; right: 8px; top: 8px; }}
#stormscope-controls {{ justify-content: start; }}
#stormscope-controls input[type="range"] {{ width: 100%; }}
}}
</style>
</head>
<body>
<div id="stormscope-map-wrap">
<div id="stormscope-map"></div>
<div id="stormscope-panel">
<div>
<h1 id="stormscope-title">StormScope Fast</h1>
<div id="stormscope-meta"></div>
</div>
<div id="stormscope-controls">
<select id="stormscope-product"></select>
<button id="stormscope-prev" type="button">Prev</button>
<input id="stormscope-frame" type="range" min="0" max="0" value="0">
<button id="stormscope-next" type="button">Next</button>
<button id="stormscope-play" type="button">Play</button>
<span id="stormscope-stamp"></span>
</div>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
(() => {{
const manifest = {manifest_json};
const existing = document.getElementById("stormscope-map");
if (!existing || existing.dataset.ready === "1") return;
existing.dataset.ready = "1";
const bounds = [[manifest.bounds.south, manifest.bounds.west], [manifest.bounds.north, manifest.bounds.east]];
const view = manifest.view_bounds || manifest.bounds;
const viewBounds = [[view.south, view.west], [view.north, view.east]];
const map = L.map("stormscope-map", {{ preferCanvas: true, zoomControl: false }});
L.tileLayer("https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png", {{
maxZoom: 12,
attribution: "&copy; OpenStreetMap contributors"
}}).addTo(map);
L.control.zoom({{ position: "bottomleft" }}).addTo(map);
map.fitBounds(viewBounds, {{ padding: [18, 18] }});
const productSelect = document.getElementById("stormscope-product");
const slider = document.getElementById("stormscope-frame");
const stamp = document.getElementById("stormscope-stamp");
const playButton = document.getElementById("stormscope-play");
document.getElementById("stormscope-title").textContent = `StormScope ${{manifest.mode}} ${{manifest.cycle}}`;
document.getElementById("stormscope-meta").textContent = `${{manifest.init_time}} | ${{manifest.resolution}} | ${{manifest.frame_count}} frames`;
Object.entries(manifest.products).forEach(([slug, product]) => {{
const option = document.createElement("option");
option.value = slug;
option.textContent = product.title;
productSelect.appendChild(option);
}});
let product = productSelect.value;
let frame = 0;
let timer = null;
let overlay = null;
function productData() {{ return manifest.products[product]; }}
function frameData() {{ return productData().frames[frame]; }}
function update() {{
const p = productData();
slider.max = String(p.frames.length - 1);
if (frame >= p.frames.length) frame = p.frames.length - 1;
slider.value = String(frame);
stamp.textContent = frameData().label;
if (!overlay) {{
overlay = L.imageOverlay(frameData().url, bounds, {{ opacity: p.opacity, interactive: false }}).addTo(map);
}} else {{
overlay.setUrl(frameData().url);
overlay.setOpacity(p.opacity);
}}
}}
productSelect.addEventListener("change", () => {{
product = productSelect.value;
frame = Math.min(frame, productData().frames.length - 1);
update();
}});
slider.addEventListener("input", () => {{ frame = Number(slider.value); update(); }});
document.getElementById("stormscope-prev").addEventListener("click", () => {{
frame = (frame + productData().frames.length - 1) % productData().frames.length;
update();
}});
document.getElementById("stormscope-next").addEventListener("click", () => {{
frame = (frame + 1) % productData().frames.length;
update();
}});
playButton.addEventListener("click", () => {{
if (timer) {{
clearInterval(timer);
timer = null;
playButton.textContent = "Play";
}} else {{
timer = setInterval(() => {{
frame = (frame + 1) % productData().frames.length;
update();
}}, 650);
playButton.textContent = "Pause";
}}
}});
update();
}})();
</script>
</body>
</html>
"""
return (
'<iframe title="StormScope OpenStreetMap preview" '
'style="width:100%;height:min(78vh,820px);min-height:620px;border:0;border-radius:8px;overflow:hidden;" '
f'srcdoc="{html.escape(document, quote=True)}"></iframe>'
)
def live_enabled() -> bool:
return os.getenv("STORMSCOPE_ENABLE_LIVE", "0").lower() in {"1", "true", "yes", "on"}
def package_available() -> bool:
return importlib.util.find_spec("rustwx_stormscope") is not None
def env_flag(name: str, default: str = "0") -> bool:
return os.getenv(name, default).lower() in {"1", "true", "yes", "on"}
def ensure_pydeps_path() -> None:
if PYDEPS_ROOT.exists():
path = str(PYDEPS_ROOT)
if path not in sys.path:
sys.path.insert(0, path)
def command_probe(cmd: list[str]) -> str:
try:
proc = subprocess.run(
cmd,
cwd=HERE,
text=True,
capture_output=True,
timeout=45,
)
except Exception as exc:
return "+ " + " ".join(cmd) + f"\nfailed: {type(exc).__name__}: {exc}"
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
lines = [
"+ " + " ".join(cmd),
f"exit code: {proc.returncode}",
]
if stdout:
lines.append(stdout[:1200])
if stderr:
lines.append(stderr[:1200])
return "\n".join(lines)
def bootstrap_earth2grid() -> str:
ensure_pydeps_path()
if importlib.util.find_spec("earth2grid") is not None:
return "earth2grid bootstrap: already importable"
if not env_flag("STORMSCOPE_BOOTSTRAP_EARTH2GRID"):
return "earth2grid bootstrap: disabled (set STORMSCOPE_BOOTSTRAP_EARTH2GRID=1)"
PYDEPS_ROOT.mkdir(parents=True, exist_ok=True)
requirement = f"earth2grid @ git+https://github.com/NVlabs/earth2grid.git@{EARTH2GRID_REF}"
env = os.environ.copy()
env["PYTHONPATH"] = str(PYDEPS_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
cmd = [
sys.executable,
"-m",
"pip",
"install",
"--no-cache-dir",
"--no-build-isolation",
"--target",
str(PYDEPS_ROOT),
"--upgrade",
requirement,
]
try:
log = run_command(cmd, cwd=HERE, env=env)
except Exception as exc:
return f"earth2grid bootstrap failed: {type(exc).__name__}: {exc}"
importlib.invalidate_caches()
ensure_pydeps_path()
if importlib.util.find_spec("earth2grid") is None:
return log + "\n\nearth2grid bootstrap finished but module is still not importable"
return log + "\n\nearth2grid bootstrap: ok"
def module_probe(module_name: str, package_name: str | None = None) -> list[str]:
package_name = package_name or module_name
lines = [f"[{module_name}]"]
spec = importlib.util.find_spec(module_name)
lines.append(f"spec: {spec is not None}")
try:
version = importlib.metadata.version(package_name)
lines.append(f"version: {version}")
except Exception as exc:
lines.append(f"version: unavailable ({type(exc).__name__})")
if spec is None:
return lines
started = time.monotonic()
try:
__import__(module_name)
lines.append(f"import: ok ({time.monotonic() - started:.2f}s)")
except Exception as exc:
lines.append(f"import: failed ({type(exc).__name__}: {exc})")
return lines
def package_probe() -> str:
lines = [
"StormScope package probe",
f"python: {platform.python_version()}",
f"rustwx_stormscope importable: {package_available()}",
]
if not package_available():
lines.append("")
lines.append("The Space payload does not include the release package source.")
lines.append("Push with `scripts/push-hf-space.py --profile package-probe` to test package import and CLI help.")
return "\n".join(lines)
lines.append("")
lines.append(command_probe([sys.executable, "-m", "rustwx_stormscope.run", "--help"]))
lines.append("")
lines.append(command_probe([sys.executable, "-m", "rustwx_stormscope.webmap_preview", "--help"]))
return "\n".join(lines)
@gpu_decorator(duration=120)
def weather_stack_probe() -> str:
lines = [
"Weather model live-inference stack probe",
f"python: {platform.python_version()}",
f"platform: {platform.platform()}",
f"STORMSCOPE_ENABLE_LIVE: {os.getenv('STORMSCOPE_ENABLE_LIVE', '0')}",
f"cwd: {HERE}",
"",
]
try:
import torch
lines.extend(
[
"[torch/cuda]",
f"torch: {torch.__version__}",
f"cuda available: {torch.cuda.is_available()}",
]
)
if torch.cuda.is_available():
lines.extend(
[
f"gpu: {torch.cuda.get_device_name(0)}",
f"capability: {torch.cuda.get_device_capability(0)}",
f"arch list: {torch.cuda.get_arch_list()}",
f"bf16 supported: {torch.cuda.is_bf16_supported()}",
f"device memory GB: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f}",
]
)
except Exception as exc:
lines.append(f"[torch/cuda]\nfailed: {type(exc).__name__}: {exc}")
lines.append("")
lines.append("[runtime bootstrap]")
lines.append(bootstrap_earth2grid())
lines.append("")
for module_name, package_name in [
("rustwx_stormscope", "rustwx-stormscope"),
("earth2studio", "earth2studio"),
("earth2grid", "earth2grid"),
("physicsnemo", "nvidia-physicsnemo"),
("natten", "natten"),
("torchao", "torchao"),
("h5netcdf", "h5netcdf"),
("xarray", "xarray"),
("scipy", "scipy"),
]:
lines.extend(module_probe(module_name, package_name))
lines.append("")
lines.append("[earth2studio StormScope import]")
started = time.monotonic()
try:
from earth2studio.models.px.stormscope import StormScopeBase, StormScopeGOES, StormScopeMRMS
lines.append(f"import: ok ({time.monotonic() - started:.2f}s)")
lines.append(f"classes: {StormScopeBase.__name__}, {StormScopeGOES.__name__}, {StormScopeMRMS.__name__}")
except Exception as exc:
lines.append(f"import: failed ({type(exc).__name__}: {exc})")
lines.append("")
lines.append("[nvidia-smi]")
lines.append(command_probe(["nvidia-smi"]))
return "\n".join(lines)
def live_status() -> str:
lines = [
"Live inference status",
f"STORMSCOPE_ENABLE_LIVE: {os.getenv('STORMSCOPE_ENABLE_LIVE', '0')}",
f"rustwx_stormscope importable: {package_available()}",
f"live root: {LIVE_ROOT}",
f"timeout seconds: {LIVE_TIMEOUT_SEC}",
]
if not live_enabled():
lines.append("")
lines.append("Live inference is disabled for this public demo deployment.")
lines.append("Set STORMSCOPE_ENABLE_LIVE=1 only after the Space image has the full StormScope dependency stack.")
elif not package_available():
lines.append("")
lines.append("Live inference is enabled, but rustwx_stormscope is not installed in this Space runtime.")
else:
lines.append("")
lines.append("Live inference is enabled and the package is importable.")
return "\n".join(lines)
def subprocess_env() -> dict:
env = os.environ.copy()
cache_root = Path(env.get("STORMSCOPE_CACHE_DIR", str(LIVE_ROOT / "cache")))
env.setdefault("RUSTWX_STORMSCOPE_CACHE_DIR", str(cache_root))
env.setdefault("EARTH2STUDIO_CACHE", str(cache_root / "earth2studio"))
env.setdefault("RUSTWX_STORMSCOPE_GOES_CACHE", str(cache_root / "goes_nc"))
env.setdefault("RUSTWX_STORMSCOPE_MRMS_CACHE", str(cache_root / "mrms_grib2"))
env.setdefault("TORCHINDUCTOR_CACHE_DIR", str(cache_root / "torchinductor"))
env.setdefault("TRITON_CACHE_DIR", str(cache_root / "triton"))
env["PYTHONPATH"] = str(PYDEPS_ROOT) + os.pathsep + env.get("PYTHONPATH", "")
return env
def run_command(cmd: list[str], *, cwd: Path, env: dict) -> str:
started = time.monotonic()
proc = subprocess.run(
cmd,
cwd=cwd,
env=env,
text=True,
capture_output=True,
timeout=LIVE_TIMEOUT_SEC,
)
elapsed = time.monotonic() - started
lines = [
"+ " + " ".join(cmd),
f"exit code: {proc.returncode}",
f"elapsed: {elapsed:.1f}s",
]
if proc.stdout.strip():
lines.append("")
lines.append(proc.stdout.strip()[-4000:])
if proc.stderr.strip():
lines.append("")
lines.append(proc.stderr.strip()[-4000:])
if proc.returncode != 0:
raise RuntimeError("\n".join(lines))
return "\n".join(lines)
def latest_meta(outdir: Path) -> Path:
metas = sorted(outdir.glob("stormscope_nowcast_6km_*_meta.json"), key=lambda path: path.stat().st_mtime)
if not metas:
raise FileNotFoundError(f"no StormScope metadata written in {outdir}")
return metas[-1]
def live_duration(init_time: str, frames: int, num_steps: int, backend: str, mxfp8: bool) -> int:
del init_time, backend, mxfp8
return min(240, max(90, int(frames) * max(30, int(num_steps) * 4)))
@gpu_decorator(duration=live_duration)
def run_live_nowcast(init_time: str, frames: int, num_steps: int, backend: str, mxfp8: bool) -> tuple[str, str]:
if not live_enabled():
return map_html(), live_status()
if not package_available():
return map_html(), live_status()
ensure_pydeps_path()
frames = max(1, min(int(frames), 3))
num_steps = max(4, min(int(num_steps), 24))
backend = backend if backend in {"earth2studio", "rust"} else "earth2studio"
init_time = (init_time or "").strip()
run_id = time.strftime("%Y%m%d_%H%M%S")
run_root = LIVE_ROOT / "runs" / run_id
outdir = run_root / "out"
webmap_dir = run_root / "webmap"
outdir.mkdir(parents=True, exist_ok=True)
webmap_dir.mkdir(parents=True, exist_ok=True)
env = subprocess_env()
for path in [
env["RUSTWX_STORMSCOPE_CACHE_DIR"],
env["EARTH2STUDIO_CACHE"],
env["RUSTWX_STORMSCOPE_GOES_CACHE"],
env["RUSTWX_STORMSCOPE_MRMS_CACHE"],
]:
Path(path).mkdir(parents=True, exist_ok=True)
run_cmd = [
sys.executable,
"-m",
"rustwx_stormscope.run",
"--mode",
"nowcast",
"--res",
"6km",
"--steps",
str(frames),
"--num-steps",
str(num_steps),
"--no-compile",
"--backend",
backend,
"--outdir",
str(outdir),
]
if init_time and init_time.lower() not in {"latest", "auto"}:
run_cmd.extend(["--date", init_time])
if mxfp8:
run_cmd.append("--mxfp8")
preview_cmd = [
sys.executable,
"-m",
"rustwx_stormscope.webmap_preview",
"--outdir",
str(outdir),
"--output-dir",
str(webmap_dir),
"--products",
"refc,ir",
"--view",
"central_plains",
"--max-frames",
str(frames),
"--force",
]
logs = [live_status(), ""]
try:
logs.append(bootstrap_earth2grid())
logs.append("")
logs.append(run_command(run_cmd, cwd=HERE, env=env))
meta = latest_meta(outdir)
preview_cmd.extend(["--meta", str(meta)])
logs.append("")
logs.append(run_command(preview_cmd, cwd=HERE, env=env))
manifest = embed_manifest(webmap_dir)
return map_html(manifest), "\n".join(logs)
except subprocess.TimeoutExpired as exc:
return map_html(), "\n".join(logs + [f"live inference timed out after {exc.timeout}s"])
except Exception as exc:
return map_html(), "\n".join(logs + [f"live inference failed: {type(exc).__name__}: {exc}"])
@gpu_decorator(duration=45)
def zero_gpu_probe() -> str:
lines = [
"ZeroGPU runtime probe",
f"python: {platform.python_version()}",
f"platform: {platform.platform()}",
f"STORMSCOPE_ENABLE_LIVE: {os.getenv('STORMSCOPE_ENABLE_LIVE', '0')}",
]
try:
import torch
lines.append(f"torch: {torch.__version__}")
lines.append(f"cuda available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
lines.append(f"gpu: {torch.cuda.get_device_name(0)}")
lines.append(f"capability: {torch.cuda.get_device_capability(0)}")
lines.append(f"arch list: {torch.cuda.get_arch_list()}")
except Exception as exc:
lines.append(f"torch probe failed: {type(exc).__name__}: {exc}")
lines.append("")
lines.append(f"Live StormScope inference enabled: {live_enabled()}")
lines.append("The production fast path targets a validated CUDA/PyTorch/NATTEN/torchao stack with MXFP8.")
lines.append("This probe reports the GPU Hugging Face assigns at launch time; hardware can vary.")
lines.append("This Space proves the public web-map product and hosted UX; live inference needs a separately validated HF dependency path.")
lines.append("")
lines.append(package_probe())
lines.append("")
lines.append(live_status())
return "\n".join(lines)
with gr.Blocks(title="StormScope Fast Demo") as demo:
gr.Markdown(
"""
# StormScope Fast Demo
StormScope 10-minute nowcast frames rendered as transparent overlays on an
OpenStreetMap basemap. This is the hosted visualization shape for the
`rustwx-stormscope` release package.
"""
)
map_output = gr.HTML(map_html())
gr.Markdown("## Live Server Probe")
with gr.Row():
probe = gr.Button("Check ZeroGPU Runtime", variant="secondary")
package_button = gr.Button("Check Package CLI", variant="secondary")
weather_button = gr.Button("Check Weather Stack", variant="secondary")
probe_output = gr.Textbox(label="Runtime probe", lines=12)
probe.click(zero_gpu_probe, outputs=probe_output)
package_button.click(package_probe, outputs=probe_output)
weather_button.click(weather_stack_probe, outputs=probe_output)
gr.Markdown("## Live Nowcast")
with gr.Row():
init_time = gr.Textbox(label="Init time", value="2026-05-30T22:40:00", scale=2)
frames = gr.Slider(label="Frames", minimum=1, maximum=3, step=1, value=1, scale=1)
sampler_steps = gr.Slider(label="Sampler steps", minimum=4, maximum=24, step=1, value=8, scale=1)
with gr.Row():
backend = gr.Dropdown(label="Data backend", choices=["earth2studio", "rust"], value="earth2studio")
mxfp8 = gr.Checkbox(label="MXFP8", value=False)
live_button = gr.Button("Run Live Nowcast", variant="primary")
live_output = gr.Textbox(label="Live inference log", value=live_status(), lines=16)
live_button.click(
run_live_nowcast,
inputs=[init_time, frames, sampler_steps, backend, mxfp8],
outputs=[map_output, live_output],
)
gr.Markdown(
"""
The bundled cycle is pre-rendered from StormScope output. Full live inference is
guarded by `STORMSCOPE_ENABLE_LIVE=1` and uses the packaged CLI when the full
CUDA/PyTorch/NATTEN/torchao stack, model weights, and live satellite fetch path
are available on ZeroGPU.
"""
)
if __name__ == "__main__":
demo.launch()