-
Notifications
You must be signed in to change notification settings - Fork 0
Add Python driver prototype for WRF workflow #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| ] | ||
| } |
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Python helpers for running WRF workflows.""" |
| 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")), | ||
| 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
|
||
| ) | ||
|
|
||
| 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) | ||
| 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()) | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| 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) |
There was a problem hiding this comment.
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.