Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions configs/sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"gfs": {
"base_url": "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod/",
"file_regex": "gfs\\.t\\d{2}z\\.pgrb2\\.0p25\\.f\\d{3}",
"cycle_subdir": "atmos",
"require_complete_file_count": 121,
"prefer_latest": true
},
"paths": {
"data_dir": "/home/youruser/wrf/data",
"wps_dir": "/home/youruser/wrf/WPS",
"wrf_dir": "/home/youruser/wrf/WRF/run",
"wps_namelist": "/home/youruser/wrf/WPS/namelist.wps",
"wrf_namelist": "/home/youruser/wrf/WRF/run/namelist.input",
"scripts_dir": "/home/youruser/wrf/scripts",
"output_dir": "/home/youruser/wrf/output",
"geogrid_exe": "/home/youruser/wrf/WPS/geogrid.exe",
"link_grib_script": "/home/youruser/wrf/WPS/link_grib.csh",
"ungrib_exe": "/home/youruser/wrf/WPS/ungrib.exe",
"metgrid_exe": "/home/youruser/wrf/WPS/metgrid.exe",
"wgrib2_exe": "/usr/local/bin/wgrib2",
"mpirun_path": "/usr/bin/mpirun",
"real_exe": "/home/youruser/wrf/WRF/run/real.exe",
"wrf_exe": "/home/youruser/wrf/WRF/run/wrf.exe",
"ncl_path": "/usr/local/bin/ncl",
"ffmpeg_path": "/usr/bin/ffmpeg"
},
"region": {
"upper_left_latlon": "41.044190,-96.911967",
"lower_right_latlon": "40.525328,-96.467021"
},
"run": {
"max_runs": 5,
"force_latest_gfs": true
},
"physics": [
{
"name": "baseline",
"parameters": {
"mp_physics": "2,3,4,5,6,7,8,9,10,11,13,14,16,17,18,19,21,28,30,32",
"ra_lw_physics": 7,
"ra_sw_physics": 3,
"radt": 30,
"sf_sfclay_physics": 1,
"sf_surface_physics": 2,
"bl_pbl_physics": 1,
"bldt": 0,
"cu_physics": 1,
"cudt": 5,
"isfflx": 1,
"ifsnow": 1,
"icloud": 1,
"surface_input_source": 1,
"num_soil_layers": 4,
"sf_urban_physics": 1
}
}
]
}
25 changes: 25 additions & 0 deletions wrfsharp_py/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# WrfSharp Python Driver (prototype)

This folder contains a Python-first rewrite of the WRF pipeline so you can drive runs via JSON.
It mirrors the older C# flow but skips database output. The current focus is:

- Download latest GFS assets.
- Update WPS/WRF namelists.
- Run WPS and WRF executables.
- Execute NCL scripts and render PNG sequences into MP4s with ffmpeg.

## Quick start

```bash
python -m wrfsharp_py.driver configs/sample.json --prep --compute
```

## Notes

- `download.py` targets the NOAA NOMADS GFS directory. Adjust `gfs.base_url`, `gfs.cycle_subdir`,
and `gfs.file_regex` to match the resolution/cycle you want.
- The namelist helpers perform string substitution. If your namelist formatting differs, you may
need to tweak `namelist.py`.
- `driver.py` uses `wgrib2 -s` to parse start/end dates from the first and last GRIB files.
- For web visualization alternatives, consider generating NetCDF or GeoTIFF layers and serving
them via raster tiles (e.g., `xarray` + `rasterio` + `rio-tiler`) instead of MP4.
1 change: 1 addition & 0 deletions wrfsharp_py/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Python helpers for running WRF workflows."""
128 changes: 128 additions & 0 deletions wrfsharp_py/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional


@dataclass
class GfsDownloadConfig:
base_url: str
file_regex: str
cycle_subdir: str
require_complete_file_count: Optional[int] = None
prefer_latest: bool = True


@dataclass
class PathsConfig:
data_dir: Path
wps_dir: Path
wrf_dir: Path
wps_namelist: Path
wrf_namelist: Path
scripts_dir: Path
output_dir: Path
geogrid_exe: Path
link_grib_script: Path
ungrib_exe: Path
metgrid_exe: Path
wgrib2_exe: Path
mpirun_path: Path
real_exe: Path
wrf_exe: Path
ncl_path: Path
ffmpeg_path: Path


@dataclass
class RegionConfig:
upper_left_latlon: str
lower_right_latlon: str


@dataclass
class RunConfig:
max_runs: int
force_latest_gfs: bool


@dataclass
class PhysicsConfig:
name: str
parameters: Dict[str, Any]


@dataclass
class WrfConfig:
paths: PathsConfig
gfs: GfsDownloadConfig
region: RegionConfig
run: RunConfig
physics: List[PhysicsConfig]


def _require(mapping: Mapping[str, Any], key: str) -> Any:
if key not in mapping:
raise KeyError(f"Missing required config key: {key}")
return mapping[key]


def _as_path(value: str) -> Path:
return Path(value).expanduser()


def load_config(config_path: Path) -> WrfConfig:
payload = json.loads(config_path.read_text())

gfs_payload = _require(payload, "gfs")
paths_payload = _require(payload, "paths")
region_payload = _require(payload, "region")
run_payload = _require(payload, "run")

gfs = GfsDownloadConfig(
base_url=_require(gfs_payload, "base_url"),
file_regex=_require(gfs_payload, "file_regex"),
cycle_subdir=gfs_payload.get("cycle_subdir", "atmos"),
require_complete_file_count=gfs_payload.get("require_complete_file_count"),
prefer_latest=gfs_payload.get("prefer_latest", True),
)

paths = PathsConfig(
data_dir=_as_path(_require(paths_payload, "data_dir")),
wps_dir=_as_path(_require(paths_payload, "wps_dir")),
wrf_dir=_as_path(_require(paths_payload, "wrf_dir")),
wps_namelist=_as_path(_require(paths_payload, "wps_namelist")),
wrf_namelist=_as_path(_require(paths_payload, "wrf_namelist")),
scripts_dir=_as_path(_require(paths_payload, "scripts_dir")),
output_dir=_as_path(_require(paths_payload, "output_dir")),
geogrid_exe=_as_path(_require(paths_payload, "geogrid_exe")),
link_grib_script=_as_path(_require(paths_payload, "link_grib_script")),
ungrib_exe=_as_path(_require(paths_payload, "ungrib_exe")),
metgrid_exe=_as_path(_require(paths_payload, "metgrid_exe")),
wgrib2_exe=_as_path(_require(paths_payload, "wgrib2_exe")),

Copilot AI Jan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON config includes wgrib2_exe in the paths configuration, but the driver.py code never uses this executable. Since the PR description mentions "file timestamps as a placeholder for GRIB date extraction until wgrib2 parsing is added," this path configuration is defined but unused. Consider either removing it from the config or documenting that it's reserved for future use.

Suggested change
wgrib2_exe=_as_path(_require(paths_payload, "wgrib2_exe")),
wgrib2_exe=_as_path(_require(paths_payload, "wgrib2_exe")), # currently unused; reserved for future GRIB parsing support

Copilot uses AI. Check for mistakes.
mpirun_path=_as_path(_require(paths_payload, "mpirun_path")),
real_exe=_as_path(_require(paths_payload, "real_exe")),
wrf_exe=_as_path(_require(paths_payload, "wrf_exe")),
ncl_path=_as_path(_require(paths_payload, "ncl_path")),
ffmpeg_path=_as_path(_require(paths_payload, "ffmpeg_path")),
)

region = RegionConfig(
upper_left_latlon=_require(region_payload, "upper_left_latlon"),
lower_right_latlon=_require(region_payload, "lower_right_latlon"),
Comment on lines +112 to +114

Copilot AI Jan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The region configuration loads upper_left_latlon and lower_right_latlon as strings but never validates or uses them. If these are intended for future use in configuring WRF domain bounds, they should either be used in the namelist updates or documented as reserved for future implementation.

Copilot uses AI. Check for mistakes.
)

run = RunConfig(
max_runs=int(_require(run_payload, "max_runs")),
force_latest_gfs=bool(run_payload.get("force_latest_gfs", True)),
)

physics_payload = payload.get("physics", [])
physics = [
PhysicsConfig(name=entry.get("name", f"physics_{idx}"), parameters=entry["parameters"])
for idx, entry in enumerate(physics_payload)
]

return WrfConfig(paths=paths, gfs=gfs, region=region, run=run, physics=physics)
86 changes: 86 additions & 0 deletions wrfsharp_py/download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from __future__ import annotations

import re
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Tuple


@dataclass(frozen=True)
class GfsListing:
cycle_dir: str
files: List[str]


def _read_url(url: str) -> str:
with urllib.request.urlopen(url) as response:
return response.read().decode("utf-8", errors="replace")


def _find_dirs(page: str) -> List[str]:
return sorted(set(re.findall(r"gfs\.\d{8}/\d{2}/", page)))


def _find_files(page: str, pattern: str) -> List[str]:
regex = re.compile(pattern)
return sorted(set(match.group(0) for match in regex.finditer(page)))


def list_latest_gfs(
base_url: str,
file_pattern: str,
cycle_subdir: str,
prefer_latest: bool = True,
) -> GfsListing:
page = _read_url(base_url)
dirs = _find_dirs(page)
if not dirs:
raise RuntimeError(f"No GFS directories found at {base_url}")

cycle_dir = dirs[-1] if prefer_latest else dirs[0]
cycle_url = f"{base_url.rstrip('/')}/{cycle_dir}{cycle_subdir.strip('/')}/"
cycle_page = _read_url(cycle_url)
files = _find_files(cycle_page, file_pattern)
if not files:
raise RuntimeError(f"No files matching {file_pattern} found at {cycle_url}")
return GfsListing(cycle_dir=cycle_dir, files=files)


def download_files(
base_url: str,
cycle_dir: str,
cycle_subdir: str,
files: Iterable[str],
target_dir: Path,
) -> List[Path]:
target_dir.mkdir(parents=True, exist_ok=True)
downloaded: List[Path] = []
for filename in files:
url = f"{base_url.rstrip('/')}/{cycle_dir}{cycle_subdir.strip('/')}/{filename}"
destination = target_dir / filename
if destination.exists():
downloaded.append(destination)
continue
with urllib.request.urlopen(url) as response:
destination.write_bytes(response.read())

Copilot AI Jan 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code lacks validation that downloaded files are complete and uncorrupted. GFS GRIB files can be partially downloaded or corrupted, which would cause WRF preprocessing to fail later. Consider adding file size validation or checksum verification after download.

Suggested change
destination.write_bytes(response.read())
data = response.read()
content_length = response.getheader("Content-Length")
if content_length is not None:
try:
expected_size = int(content_length)
except ValueError:
expected_size = None
else:
if len(data) != expected_size:
raise RuntimeError(
f"Incomplete download for {url}: expected {expected_size} bytes, got {len(data)}"
)
destination.write_bytes(data)

Copilot uses AI. Check for mistakes.
downloaded.append(destination)
return downloaded


def pick_files_for_latest_cycle(
base_url: str,
file_pattern: str,
cycle_subdir: str,
prefer_latest: bool,
required_count: int | None,
) -> Tuple[GfsListing, bool]:
listing = list_latest_gfs(base_url, file_pattern, cycle_subdir, prefer_latest=True)
is_complete = required_count is None or len(listing.files) >= required_count

if is_complete or prefer_latest:
return listing, is_complete

fallback = list_latest_gfs(base_url, file_pattern, cycle_subdir, prefer_latest=False)
fallback_complete = required_count is None or len(fallback.files) >= required_count
return fallback, fallback_complete
Loading