| """ |
| Run NVIDIA StormScope (regional nowcasting) on RTX 5090. |
| |
| StormScope predicts CONUS satellite (GOES) and radar (MRMS) imagery |
| at 6km or 3km resolution with 10-min or 60-min timesteps. |
| |
| Operational modes: |
| nowcast - 10-min steps, pure-obs (no GFS), uses latest GOES/MRMS |
| forecast - 60-min steps, GFS-conditioned, longer range |
| |
| Usage: |
| # Operational: use latest observations |
| python run_stormscope.py --mode nowcast --steps 6 |
| python run_stormscope.py --mode forecast --steps 6 |
| |
| # Historical: specify init time |
| python run_stormscope.py --mode nowcast --steps 6 --date 2023-12-05T12:00:00 |
| python run_stormscope.py --mode nowcast --res 3km --steps 3 # 3km satellite only |
| |
| Output: |
| Each step -> stormscope_{YYYYMMDD}_{HH}z_f{NN}.npz |
| Metadata JSON -> stormscope_{YYYYMMDD}_{HH}z_meta.json |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import sys |
| import time |
| from datetime import datetime, timedelta, timezone |
|
|
| import numpy as np |
| import torch |
|
|
| |
| |
| RUST_BACKEND = False |
|
|
|
|
| def _env(name: str, legacy_name: str | None = None, default: str = "") -> str: |
| if name in os.environ: |
| return os.environ[name] |
| if legacy_name and legacy_name in os.environ: |
| return os.environ[legacy_name] |
| return default |
|
|
|
|
| |
| GOES_CHANNEL_MAP = { |
| "abi01c": "ch_blue_047", |
| "abi02c": "ch_red_064", |
| "abi03c": "ch_veggie_086", |
| "abi07c": "ch_swir_390", |
| "abi08c": "ch_wv_upper_619", |
| "abi09c": "ch_wv_mid_695", |
| "abi10c": "ch_wv_lower_734", |
| "abi13c": "ch_ir_1035", |
| } |
|
|
| |
| CHANNEL_META = { |
| "ch_blue_047": {"units": "reflectance", "range": [0.0, 1.0], "type": "visible"}, |
| "ch_red_064": {"units": "reflectance", "range": [0.0, 1.0], "type": "visible"}, |
| "ch_veggie_086": {"units": "reflectance", "range": [0.0, 1.0], "type": "visible"}, |
| "ch_swir_390": {"units": "K", "range": [180.0, 310.0], "type": "ir"}, |
| "ch_wv_upper_619": {"units": "K", "range": [190.0, 260.0], "type": "wv"}, |
| "ch_wv_mid_695": {"units": "K", "range": [190.0, 270.0], "type": "wv"}, |
| "ch_wv_lower_734": {"units": "K", "range": [190.0, 275.0], "type": "wv"}, |
| "ch_ir_1035": {"units": "K", "range": [180.0, 310.0], "type": "ir"}, |
| "refc": {"units": "dBZ", "range": [-20.0, 75.0], "type": "radar"}, |
| } |
|
|
| |
| |
| |
| |
| |
| MODEL_CONFIGS = { |
| ("nowcast", "6km"): { |
| "goes": "6km_10min_natten_pure_obs_zenith_6steps", |
| "mrms": "6km_10min_natten_pure_obs_mrms_obs_6steps", |
| "step_minutes": 10, |
| "needs_gfs": False, |
| }, |
| ("nowcast", "3km"): { |
| "goes": "3km_10min_natten_pure_obs_cos_zenith_input_eoe", |
| "mrms": None, |
| "step_minutes": 10, |
| "needs_gfs": False, |
| }, |
| ("forecast", "6km"): { |
| "goes": "6km_60min_natten_cos_zenith_input_eoe_v2", |
| "mrms": "6km_60min_natten_cos_zenith_input_mrms_eoe", |
| "step_minutes": 60, |
| "needs_gfs": True, |
| "conditioning": "gfs", |
| }, |
| } |
|
|
| STORMSCOPE_VERSION = "0.12.1" |
|
|
|
|
| def forecast_conditioning_kind(cfg: dict | None = None) -> str: |
| """Return the forecast conditioner kind. |
| |
| Default is GFS, matching Earth2Studio's StormScopeGOES.load_model default. |
| RUSTWX_STORMSCOPE_FORECAST_CONDITIONING=hrrr is kept as an explicit experiment switch. |
| """ |
| default = (cfg or {}).get("conditioning", "gfs") |
| kind = _env("RUSTWX_STORMSCOPE_FORECAST_CONDITIONING", "SSFAST_FORECAST_CONDITIONING", default).strip().lower() |
| if kind not in {"gfs", "hrrr"}: |
| raise ValueError("RUSTWX_STORMSCOPE_FORECAST_CONDITIONING must be 'gfs' or 'hrrr'") |
| return kind |
|
|
|
|
| def get_latest_init_time(step_minutes: int, needs_history: bool, |
| gfs_aligned: bool = False) -> str: |
| """Get the best init time for operational runs using latest observations. |
| |
| Rounds down to the nearest step_minutes boundary and subtracts a small |
| buffer to ensure GOES/MRMS data is available on NOAA S3. |
| |
| If gfs_aligned=True, rounds to the nearest past 6-hour GFS cycle that |
| has had time to post on AWS (~4 hours after init). |
| """ |
| now = datetime.now(timezone.utc) |
|
|
| if gfs_aligned: |
| |
| |
| gfs_buffer_hours = int(_env("RUSTWX_STORMSCOPE_GFS_BUFFER_HOURS", "SSFAST_GFS_BUFFER_HOURS", "4")) |
| cutoff = now - timedelta(hours=gfs_buffer_hours) |
| cutoff = cutoff.replace(minute=0, second=0, microsecond=0) |
| cutoff = cutoff - timedelta(hours=cutoff.hour % 6) |
| return cutoff.strftime("%Y-%m-%dT%H:%M:%S") |
|
|
| |
| |
| |
| buffer_min = int(_env("RUSTWX_STORMSCOPE_OBS_BUFFER_MIN", "SSFAST_OBS_BUFFER_MIN", "10")) |
| init = now - timedelta(minutes=buffer_min) |
| |
| init = init.replace(second=0, microsecond=0) |
| init = init - timedelta(minutes=init.minute % step_minutes) |
| return init.strftime("%Y-%m-%dT%H:%M:%S") |
|
|
|
|
| def save_step(outdir, init_time_str, step_idx, step_minutes, mode, res, |
| goes_pred, goes_vars, mrms_pred, lats, lons, valid_mask, |
| mrms_valid_mask, dtype_name, goes_model_name, mrms_model_name): |
| """Save one forecast step as a self-describing .npz file.""" |
| init_dt = datetime.fromisoformat(init_time_str) |
| valid_dt = init_dt + timedelta(minutes=step_minutes * (step_idx + 1)) |
| fhr = step_minutes * (step_idx + 1) / 60.0 |
|
|
| cycle_str = init_dt.strftime("%Y%m%d_%H%Mz") |
| fname = f"stormscope_{mode}_{res}_{cycle_str}_f{step_idx + 1:02d}.npz" |
|
|
| fields = {} |
|
|
| |
| for i, var_name in enumerate(goes_vars): |
| field = goes_pred[0, 0, 0, i].detach().cpu().to(torch.float32).numpy() |
| field = np.where(valid_mask[0, 0, 0, i].cpu().numpy(), field, np.nan) |
| out_name = GOES_CHANNEL_MAP.get(var_name, var_name) |
| fields[out_name] = field |
|
|
| |
| if mrms_pred is not None: |
| refc = mrms_pred[0, 0, 0, 0].detach().cpu().to(torch.float32).numpy() |
| refc = np.where(mrms_valid_mask[0, 0, 0, 0].cpu().numpy(), refc, np.nan) |
| fields["refc"] = refc |
|
|
| |
| lons_fixed = lons.copy().astype(np.float32) |
| lons_fixed[lons_fixed > 180] -= 360 |
| fields["lats"] = lats.astype(np.float32) |
| fields["lons"] = lons_fixed |
|
|
| |
| fields["init_time"] = init_dt.isoformat() |
| fields["valid_time"] = valid_dt.isoformat() |
| fields["step_minutes"] = step_minutes |
| fields["forecast_hour"] = fhr |
| fields["mode"] = mode |
| fields["resolution"] = res |
| fields["dtype"] = dtype_name |
| fields["goes_model"] = goes_model_name |
| fields["mrms_model"] = mrms_model_name or "none" |
| fields["stormscope_version"] = STORMSCOPE_VERSION |
|
|
| outpath = os.path.join(outdir, fname) |
| np.savez_compressed(outpath, **fields) |
|
|
| |
| label = f"T+{int(fhr)}h" if fhr == int(fhr) else f"T+{fhr:.1f}h" |
| print(f" Saved {fname} ({label}, valid {valid_dt.strftime('%Y-%m-%d %H:%M')}Z)") |
| for name, arr in fields.items(): |
| if isinstance(arr, np.ndarray) and arr.ndim == 2: |
| valid = arr[~np.isnan(arr)] |
| if len(valid) > 0: |
| print(f" {name}: [{valid.min():.1f}, {valid.max():.1f}] mean={valid.mean():.1f}") |
|
|
| return outpath |
|
|
|
|
| def save_cycle_metadata(outdir, init_time_str, mode, res, cfg, saved_files, |
| goes_model_name, mrms_model_name, grid_shape, dtype_name, |
| total_elapsed, complete=True): |
| """Write a JSON manifest for the entire cycle (for unified_dashboard ingestion).""" |
| init_dt = datetime.fromisoformat(init_time_str) |
| cycle_str = init_dt.strftime("%Y%m%d_%H%Mz") |
| step_minutes = cfg["step_minutes"] |
|
|
| fields_available = list(GOES_CHANNEL_MAP.values()) |
| if cfg.get("mrms"): |
| fields_available.append("refc") |
|
|
| meta = { |
| "model": "stormscope", |
| "cycle": cycle_str, |
| "init_time": init_dt.isoformat() + "Z", |
| "mode": mode, |
| "complete": complete, |
| "resolution": res, |
| "step_minutes": step_minutes, |
| "n_steps": len(saved_files), |
| "valid_times": [ |
| (init_dt + timedelta(minutes=step_minutes * (i + 1))).isoformat() + "Z" |
| for i in range(len(saved_files)) |
| ], |
| "fields": fields_available, |
| "field_metadata": {k: v for k, v in CHANNEL_META.items() if k in fields_available}, |
| "grid": { |
| "shape": list(grid_shape), |
| "projection": "hrrr_lcc", |
| }, |
| "models": { |
| "goes": goes_model_name, |
| "mrms": mrms_model_name or None, |
| }, |
| "inference": { |
| "dtype": dtype_name, |
| "device": torch.cuda.get_device_name() if torch.cuda.is_available() else "cpu", |
| "stormscope_version": STORMSCOPE_VERSION, |
| "conditioning_source": forecast_conditioning_kind(cfg) if cfg.get("needs_gfs") else None, |
| "total_seconds": round(total_elapsed, 1), |
| }, |
| "files": [os.path.basename(f) for f in saved_files], |
| } |
|
|
| meta_path = os.path.join(outdir, f"stormscope_{mode}_{res}_{cycle_str}_meta.json") |
| with open(meta_path, "w") as f: |
| json.dump(meta, f, indent=2) |
| if complete: |
| print(f"\nMetadata: {os.path.basename(meta_path)}") |
| return meta_path |
|
|
|
|
| def run_stormscope(date: str, nsteps: int, outdir: str, mode: str, res: str, |
| use_bf16: bool = True): |
| """Run StormScope in the specified mode.""" |
| from earth2studio.data import GFS_FX, HRRR_FX, GOES, MRMS, fetch_data |
| from earth2studio.models.px.stormscope import ( |
| StormScopeBase, |
| StormScopeGOES, |
| StormScopeMRMS, |
| ) |
|
|
| config_key = (mode, res) |
| if config_key not in MODEL_CONFIGS: |
| raise ValueError(f"No model config for mode={mode}, res={res}. " |
| f"Available: {list(MODEL_CONFIGS.keys())}") |
|
|
| cfg = MODEL_CONFIGS[config_key] |
| step_minutes = cfg["step_minutes"] |
| conditioning_kind = forecast_conditioning_kind(cfg) if cfg["needs_gfs"] else None |
|
|
| |
| if use_bf16 and torch.cuda.is_bf16_supported(): |
| compute_dtype = torch.bfloat16 |
| dtype_name = "bfloat16" |
| else: |
| compute_dtype = torch.float32 |
| dtype_name = "float32" |
| if use_bf16: |
| print("WARNING: bf16 requested but not supported, falling back to fp32") |
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| os.makedirs(outdir, exist_ok=True) |
|
|
| print(f"Mode: {mode} ({res}, {step_minutes}-min steps)") |
| print(f"Precision: {dtype_name}") |
| if conditioning_kind: |
| print(f"Forecast conditioning: {conditioning_kind.upper()}_FX") |
| else: |
| print("Forecast conditioning: no (pure obs)") |
| print(f"MRMS model: {'yes' if cfg.get('mrms') else 'no'}") |
|
|
| run_start = time.time() |
|
|
| |
| print("\nLoading StormScope models...") |
| t0 = time.time() |
| package = StormScopeBase.load_default_package() |
|
|
| |
| goes_model_name = cfg["goes"] |
| if cfg["needs_gfs"]: |
| if conditioning_kind == "gfs": |
| conditioning_source = GFS_FX() |
| elif conditioning_kind == "hrrr": |
| conditioning_source = HRRR_FX() |
| else: |
| raise ValueError(f"Unsupported forecast conditioning source: {conditioning_kind}") |
| model = StormScopeGOES.load_model( |
| package=package, |
| conditioning_data_source=conditioning_source, |
| model_name=goes_model_name, |
| ) |
| else: |
| model = StormScopeGOES.load_model( |
| package=package, |
| model_name=goes_model_name, |
| ) |
| model = model.to(device=device) |
| model.eval() |
| print(f" GOES model ({goes_model_name}) loaded in {time.time() - t0:.1f}s") |
|
|
| |
| init_dt = datetime.fromisoformat(date) |
| goes_cutover = datetime(2025, 4, 7) |
| goes_satellite = "goes19" if init_dt >= goes_cutover else "goes16" |
| scan_mode = "C" |
| print(f" Satellite: {goes_satellite.upper()} ({scan_mode})") |
|
|
| |
| model_mrms = None |
| mrms_model_name = cfg.get("mrms") |
| if mrms_model_name: |
| t0 = time.time() |
| model_mrms = StormScopeMRMS.load_model( |
| package=package, |
| conditioning_data_source=GOES(satellite=goes_satellite, scan_mode=scan_mode), |
| model_name=mrms_model_name, |
| ) |
| model_mrms = model_mrms.to(device=device) |
| model_mrms.eval() |
| print(f" MRMS model ({mrms_model_name}) loaded in {time.time() - t0:.1f}s") |
|
|
| start_date = [np.datetime64(datetime.fromisoformat(date))] |
| print(f"\nInit time: {start_date[0]}") |
|
|
| goes_vars = list(model.input_coords()["variable"]) |
| lat_out = model.latitudes.detach().cpu().to(torch.float32).numpy() |
| lon_out = model.longitudes.detach().cpu().to(torch.float32).numpy() |
| grid_shape = lat_out.shape |
| print(f"Output grid: {grid_shape}, lat [{lat_out.min():.1f}, {lat_out.max():.1f}], " |
| f"lon [{lon_out.min():.1f}, {lon_out.max():.1f}]") |
|
|
| goes = GOES(satellite=goes_satellite, scan_mode=scan_mode) |
| goes_lat, goes_lon = GOES.grid(satellite=goes_satellite, scan_mode=scan_mode) |
|
|
| |
| print("Building interpolators...") |
| model.build_input_interpolator(goes_lat, goes_lon) |
| if cfg["needs_gfs"]: |
| if conditioning_kind == "gfs": |
| gfs_lon, gfs_lat = np.meshgrid(GFS_FX.GFS_LON, GFS_FX.GFS_LAT) |
| model.build_conditioning_interpolator( |
| gfs_lat.astype(np.float32), |
| gfs_lon.astype(np.float32), |
| ) |
| elif conditioning_kind == "hrrr": |
| hrrr_lat, hrrr_lon = HRRR_FX.grid() |
| model.build_conditioning_interpolator(hrrr_lat, hrrr_lon) |
|
|
| in_coords = model.input_coords() |
|
|
| |
| print("Fetching GOES data...") |
| t0 = time.time() |
| if RUST_BACKEND: |
| from rustwx_stormscope import rust_datasources as _rds |
| x, x_coords = _rds.goes_input(goes_satellite, scan_mode, start_date, goes_vars, |
| in_coords["lead_time"], device, goes_lat.shape) |
| else: |
| x, x_coords = fetch_data(goes, time=start_date, variable=np.array(goes_vars), |
| lead_time=in_coords["lead_time"], device=device) |
| print(f" GOES data fetched in {time.time() - t0:.1f}s, shape={x.shape}") |
|
|
| |
| x_mrms = None |
| x_coords_mrms = None |
| if model_mrms: |
| print("Fetching MRMS data...") |
| t0 = time.time() |
| mrms_in_coords = model_mrms.input_coords() |
| if RUST_BACKEND: |
| from rustwx_stormscope import rust_datasources as _rds |
| x_mrms, x_coords_mrms, _mlat, _mlon = _rds.mrms_input( |
| start_date, mrms_in_coords["lead_time"], device) |
| else: |
| mrms = MRMS() |
| x_mrms, x_coords_mrms = fetch_data(mrms, time=start_date, variable=np.array(["refc"]), |
| lead_time=mrms_in_coords["lead_time"], device=device) |
| print(f" MRMS data fetched in {time.time() - t0:.1f}s, shape={x_mrms.shape}") |
|
|
| model_mrms.build_input_interpolator(x_coords_mrms["lat"], x_coords_mrms["lon"]) |
| model_mrms.build_conditioning_interpolator(goes_lat, goes_lon) |
|
|
| |
| batch_size = 1 |
| if x.dim() == 5: |
| x = x.unsqueeze(0).repeat(batch_size, 1, 1, 1, 1, 1) |
| x_coords["batch"] = np.arange(batch_size) |
| x_coords.move_to_end("batch", last=False) |
| if x_mrms is not None and x_mrms.dim() == 5: |
| x_mrms = x_mrms.unsqueeze(0).repeat(batch_size, 1, 1, 1, 1, 1) |
| x_coords_mrms["batch"] = np.arange(batch_size) |
| x_coords_mrms.move_to_end("batch", last=False) |
|
|
| x = x.to(dtype=torch.float32) |
| if x_mrms is not None: |
| x_mrms = x_mrms.to(dtype=torch.float32) |
|
|
| |
| use_autocast = (compute_dtype == torch.bfloat16) |
| if use_autocast: |
| print(f"Using torch.autocast(bf16) for inference") |
|
|
| |
| y, y_coords = x, x_coords |
| y_mrms, y_coords_mrms = x_mrms, x_coords_mrms |
| saved_files = [] |
|
|
| total_minutes = nsteps * step_minutes |
| print(f"\nRunning {nsteps}-step {mode} ({total_minutes} min = {total_minutes/60:.1f}h)...") |
| for step_idx in range(nsteps): |
| t0 = time.time() |
|
|
| |
| with torch.autocast("cuda", dtype=compute_dtype, enabled=use_autocast): |
| y_pred, y_pred_coords = model(y, y_coords) |
|
|
| |
| y_mrms_pred = None |
| y_coords_mrms_pred = None |
| if model_mrms and y_mrms is not None: |
| with torch.autocast("cuda", dtype=compute_dtype, enabled=use_autocast): |
| y_mrms_pred, y_coords_mrms_pred = model_mrms.call_with_conditioning( |
| y_mrms, y_coords_mrms, conditioning=y, conditioning_coords=y_coords |
| ) |
|
|
| elapsed = time.time() - t0 |
| print(f" Step {step_idx + 1}/{nsteps}: inference {elapsed:.1f}s") |
|
|
| |
| mrms_valid_mask = None |
| if model_mrms and y_mrms_pred is not None: |
| mrms_valid_mask = model_mrms.valid_mask.expand_as(y_mrms_pred) |
|
|
| outpath = save_step( |
| outdir, date, step_idx, step_minutes, mode, res, |
| y_pred, goes_vars, y_mrms_pred, |
| lat_out, lon_out, |
| model.valid_mask.expand_as(y_pred), |
| mrms_valid_mask, |
| dtype_name, goes_model_name, mrms_model_name, |
| ) |
| saved_files.append(outpath) |
|
|
| |
| |
| save_cycle_metadata( |
| outdir, date, mode, res, cfg, saved_files, |
| goes_model_name, mrms_model_name, grid_shape, dtype_name, |
| time.time() - run_start, complete=False, |
| ) |
|
|
| |
| y_pred, y_pred_coords = model.next_input(y_pred, y_pred_coords, y, y_coords) |
| if model_mrms and y_mrms_pred is not None: |
| y_mrms_pred, y_coords_mrms_pred = model_mrms.next_input( |
| y_mrms_pred, y_coords_mrms_pred, y_mrms, y_coords_mrms |
| ) |
|
|
| y = y_pred |
| y_coords = y_pred_coords |
| if y_mrms_pred is not None: |
| y_mrms = y_mrms_pred |
| y_coords_mrms = y_coords_mrms_pred |
|
|
| total_elapsed = time.time() - run_start |
|
|
| |
| meta_path = save_cycle_metadata( |
| outdir, date, mode, res, cfg, saved_files, |
| goes_model_name, mrms_model_name, grid_shape, dtype_name, total_elapsed, |
| complete=True, |
| ) |
|
|
| print(f"\n=== StormScope {mode} complete ===") |
| print(f" {len(saved_files)} steps, {total_elapsed:.0f}s total") |
| print(f" Output: {outdir}/") |
| for f in saved_files: |
| print(f" {os.path.basename(f)}") |
|
|
| return saved_files |
|
|
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser(description="Run NVIDIA StormScope") |
| parser.add_argument("--mode", type=str, default="nowcast", |
| choices=["nowcast", "forecast"], |
| help="nowcast=10min pure-obs, forecast=60min GFS-conditioned") |
| parser.add_argument("--res", type=str, default="6km", |
| choices=["6km", "3km"], |
| help="Grid resolution (3km only for nowcast mode)") |
| parser.add_argument("--steps", type=int, default=6, |
| help="Forecast steps (default: 6)") |
| parser.add_argument("--date", type=str, default=None, |
| help="Init datetime ISO format (default: latest available)") |
| parser.add_argument("--outdir", type=str, default="outputs/stormscope", |
| help="Output directory") |
| parser.add_argument("--fp32", action="store_true", |
| help="Force fp32 instead of bf16") |
| args = parser.parse_args() |
|
|
| print(f"StormScope on {torch.cuda.get_device_name()}") |
| print(f"VRAM: {torch.cuda.get_device_properties(0).total_memory / 1e9:.1f} GB") |
| print() |
|
|
| |
| cfg = MODEL_CONFIGS[(args.mode, args.res)] |
| if args.date is None: |
| needs_history = "6steps" in cfg["goes"] |
| date = get_latest_init_time( |
| cfg["step_minutes"], |
| needs_history, |
| gfs_aligned=cfg.get("needs_gfs", False) |
| and forecast_conditioning_kind(cfg) == "gfs", |
| ) |
| print(f"Using latest init time: {date}Z") |
| else: |
| date = args.date |
|
|
| run_stormscope(date, args.steps, args.outdir, args.mode, args.res, |
| use_bf16=not args.fp32) |
|
|