import datetime as dt
import json
import math
import subprocess
import sys
from pathlib import Path
import h5py
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
OUT = DATA / "scenarios"
STEPS_PER_DAY = 50 FORMAT_VERSION = 2
FBFM40_GROUPS = [
(range(101, 110), "Grass", 1.2), (range(121, 125), "GrassShrub", 1.0), (range(141, 150), "Shrub", 0.9), (range(161, 166), "TimberUnder", 0.8), (range(181, 190), "TimberLitter", 0.5), (range(201, 205), "Slash", 0.7), ]
def git_hash() -> str:
try:
return subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, text=True
).strip()
except Exception:
return "unknown"
def wind_to_speed_from(u: float, v: float) -> tuple[float, float]:
speed = math.hypot(u, v)
from_deg = math.degrees(math.atan2(-u, -v)) % 360.0
return speed, from_deg
def convert_fire(f: h5py.File, name: str) -> None:
g = f[name]
h, w = int(g.attrs["height"]), int(g.attrs["width"])
fuel_codes = g["230FBFM40"][()]
elevation = g["ELEV2020"][()].astype(np.float32)
dates = sorted(g["fire"].keys()) t0 = dt.datetime.fromisoformat(dates[0])
hours = [ (dt.datetime.fromisoformat(d) - t0).total_seconds() / 3600.0 for d in dates ]
arrival = np.full((h, w), -1.0)
for d, hrs in zip(reversed(dates), reversed(hours)):
burned = g["fire"][d][()] > 0
arrival[burned] = hrs
names = np.full((h, w), "Inactive", dtype=object)
for rng, cls, _ in FBFM40_GROUPS:
names[np.isin(fuel_codes, list(rng))] = cls
day0 = g["fire"][dates[0]][()] > 0
names[day0] = "Burning"
wind = []
for d, hrs in zip(dates, hours):
u = float(np.nanmean(g["u_component_of_wind_10m"][d][()]))
v = float(np.nanmean(g["v_component_of_wind_10m"][d][()]))
speed, from_deg = wind_to_speed_from(u, v)
wind.append({"hours": hrs, "speed_ms": round(speed, 3), "from_deg": round(from_deg, 2)})
scenario = {
"format_version": FORMAT_VERSION,
"id": name,
"grid": {
"width": w, "height": h,
"cell_size_m": float(g.attrs["resolution"]),
"crs": str(g.attrs["crs"]),
"origin": [float(x) for x in g.attrs["bounds_in_3310"][:2]],
},
"t0_utc": t0.strftime("%Y-%m-%dT00:00:00Z"),
"provenance": {
"source": "PyTorchFire six-fire pack (dataset.hdf5)",
"source_url": "https://github.com/mzhen77/neural-ca-wildfire",
"license": "CC-BY-4.0",
"retrieved": "2026-08-14",
"converter": "validation/scripts/convert_pytorchfire.py",
"converter_git": git_hash(),
"simplifications": [
"wind = domain-mean ERA5 u/v per day (per-cell field discarded)",
"FBFM40 codes grouped into 6 named classes with first-guess veg_factors",
"canopy cover / LAI layers unused (density left uniform)",
"arrival quantized to daily observation times",
],
"weather": {
"source": "ERA5 reanalysis as packaged in the six-fire HDF5",
"variables": "u/v 10 m wind (m/s), 2 m temperature (K), total precipitation (m)",
"wind_height_m": 10.0,
"native_resolution": "~31 km grid, one value per day",
"averaging": "domain mean of u and v per day, then hypot -> speed "
"(vector mean; a swinging wind averages toward calm)",
"source_direction_convention": "u eastward / v northward components",
"conversion": "from_deg = degrees(atan2(-u, -v)) mod 360, clockwise from north",
"grid_orientation_check": "LANDFIRE aspect vs elevation gradient, cos +0.96 "
"for row 0 = north on all six fires (2026-09-01)",
},
},
"wind": wind,
"steps_per_hour": STEPS_PER_DAY / 24.0,
}
config = {
"dim": "2d",
"width": w,
"height": h,
"history_limit": 0,
"initial": names.ravel().tolist(),
"rule": {"subrules": []},
"model": {"wildfire": {
"params": {
"seed": 0, "p0": 0.58,
"fuels": [{"name": cls, "veg_factor": vf} for _, cls, vf in FBFM40_GROUPS],
"wind_speed": wind[0]["speed_ms"],
"wind_from_deg": wind[0]["from_deg"],
"c1": 0.045,
"c2": 0.131,
"slope_a": 0.078,
"cell_size": float(g.attrs["resolution"]),
"burn_duration": 5,
"spotting": None,
},
"env": {"density": [], "elevation": elevation.ravel().tolist()},
}},
}
truth = {
"format_version": FORMAT_VERSION,
"time_unit": "hours_since_t0",
"observed_at": hours,
"arrival_hours": arrival.ravel().tolist(),
"spatial_accuracy_m": 375.0,
"accuracy_note": "VIIRS-derived daily cumulative masks; arrival quantized to observation days",
}
out = OUT / name
out.mkdir(parents=True, exist_ok=True)
(out / "scenario.json").write_text(json.dumps(scenario, indent=1))
(out / "config.json").write_text(json.dumps(config))
(out / "truth.json").write_text(json.dumps(truth))
burned_final = int((arrival >= 0).sum())
print(f"{name}: {w}x{h}, {len(dates)} observations over {hours[-1]:.0f}h, "
f"ignition {int(day0.sum())} -> final {burned_final} burned cells")
def main() -> None:
with h5py.File(DATA / "dataset.hdf5", "r") as f:
for name in f.keys():
convert_fire(f, name)
if __name__ == "__main__":
main()