Skip to content

Commit f4b3fd8

Browse files
Merge pull request #179 from forefireAPI/fix/restore-genforefirecase
Restore genForeFireCase.py as a general landscape writer
2 parents 0cdc9ac + c5b243c commit f4b3fd8

6 files changed

Lines changed: 427 additions & 7 deletions

File tree

‎TESTING.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ spanning 1.1 in 12412, which is why the gap between a working network and a
150150
constant one is so narrow. Replacing `modelrun.csv` with inputs that produce a
151151
real spread of rates of spread would make this a much stronger check.
152152
153+
## Running the Landscape Generator Test (`test_genforefirecase.py`)
154+
155+
Covers `tools/preprocessing/genForeFireCase.py`, which writes the NetCDF
156+
landscape file a simulation runs on. It builds a landscape, hands it to
157+
ForeFire, ignites it and steps, so it exercises the writer and the reader
158+
together rather than the writer alone. It also pins the field axis order,
159+
where the 3-D and 4-D paths were previously broken.
160+
161+
**To run it manually**, after installing the Python package as above:
162+
163+
```bash
164+
./.venv/bin/python tests/python/test_genforefirecase.py
165+
```
166+
167+
It needs `numpy` and `netCDF4`, writes only into a temporary directory, and
168+
takes a few seconds. Without a built `pyforefire` the load-and-simulate case
169+
skips and the rest still run, so it is usable while iterating on the writer.
170+
171+
The test doubles as the worked example for the tool.
172+
153173
## Other Tests
154174
155175
`tests/run.bash` runs every suite that its environment allows: `runff` and

‎docs/source/user_guide/landscape_file.rst‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,26 @@ Generating a suitable ``landscape.nc`` file typically involves standard Geograph
8181
* **Clip** the layers to your desired simulation domain boundaries.
8282
* **Convert** the final processed layers into a single NetCDF file with appropriate variable names.
8383

84-
3. **ForeFire Helpers (Potentially):** Previous versions of ForeFire included Python helper scripts (e.g., `genForeFireCase.py`). While the status of V2 helpers is pending, tools might exist or be developed to assist in this NetCDF creation process. Consult the documentation sections on available tools once updated.
84+
3. **ForeFire Helpers:** ``tools/preprocessing/genForeFireCase.py`` writes the file for you from numpy arrays, which is usually easier than assembling the NetCDF by hand:
85+
86+
.. code-block:: python
87+
88+
import numpy as np
89+
from genForeFireCase import FiretoNC
90+
91+
FiretoNC("landscape.nc",
92+
domainProperties={'SWx': 0., 'SWy': 0., 'SWz': 0.,
93+
'Lx': 1000., 'Ly': 1000., 'Lz': 0.,
94+
't0': 0., 'Lt': np.inf},
95+
parametersProperties={'date': "2026-08-12T12:00:00Z",
96+
'duration': 3600,
97+
'refYear': 2026, 'refDay': 224,
98+
'year': 2026, 'month': 8, 'day': 12},
99+
fuelModelMap=fuel, # (NY, NX) integer fuel indices
100+
elevation=elevation, # (NY, NX) metres
101+
wind={"zonal": windU, "meridian": windV})
102+
103+
Field arrays are indexed outermost axis first: ``(NY, NX)``, or ``(NZ, NY, NX)`` and ``(NT, NZ, NY, NX)`` when they vary with height or time. Every key shown under ``parametersProperties`` is required.
85104

86105
Loading in ForeFire
87106
-------------------
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Smoke test for tools/preprocessing/genForeFireCase.py.
2+
3+
The landscape file is the thing standing between a new user and their first
4+
simulation, so this checks the whole path rather than the writer alone: build
5+
a landscape, hand it to ForeFire, ignite it, and step.
6+
7+
It also pins the axis order. The 3-D and 4-D paths used to declare their
8+
dimensions in one order and assign in another: a 4-D field only broadcast when
9+
NY == NT and NX == NZ, and a 3-D field never got its NT dimension created.
10+
11+
python3 tests/python/test_genforefirecase.py
12+
13+
Needs numpy, netCDF4 and a built pyforefire. Skips rather than fails when
14+
pyforefire is missing, so it stays runnable on a machine with no build.
15+
"""
16+
17+
import os
18+
import sys
19+
import tempfile
20+
import traceback
21+
22+
sys.path.insert(0, os.path.join(
23+
os.path.dirname(os.path.abspath(__file__)), "..", "..",
24+
"tools", "preprocessing"))
25+
26+
import numpy as np # noqa: E402
27+
from netCDF4 import Dataset # noqa: E402
28+
29+
from genForeFireCase import FiretoNC, REQUIRED_PARAMETERS # noqa: E402
30+
31+
NX = 100
32+
NY = 100
33+
34+
35+
def domain_properties(nx=NX, ny=NY, resolution=10.0):
36+
return {'SWx': 0., 'SWy': 0., 'SWz': 0.,
37+
'Lx': nx * resolution, 'Ly': ny * resolution, 'Lz': 0.,
38+
't0': 0., 'Lt': np.inf}
39+
40+
41+
def parameters_properties():
42+
return {'date': "2026-08-12T12:00:00Z", 'duration': 3600,
43+
'refYear': 2026, 'refDay': 224,
44+
'year': 2026, 'month': 8, 'day': 12}
45+
46+
47+
def test_writes_a_loadable_landscape():
48+
"""The generated file loads in ForeFire, ignites and spreads."""
49+
try:
50+
import pyforefire
51+
except ImportError:
52+
return ["SKIP: pyforefire not importable"]
53+
54+
failures = []
55+
with tempfile.TemporaryDirectory() as workdir:
56+
path = os.path.join(workdir, "landscape.nc")
57+
58+
fuel = np.full((NY, NX), 1, dtype=np.int32)
59+
elevation = np.zeros((NY, NX))
60+
wind = {"zonal": np.full((NY, NX), 2.0),
61+
"meridian": np.zeros((NY, NX))}
62+
63+
FiretoNC(path, domain_properties(), parameters_properties(),
64+
fuel, elevation=elevation, wind=wind)
65+
66+
with Dataset(path) as ds:
67+
for name in ('fuel', 'altitude', 'windU', 'windV',
68+
'domain', 'parameters'):
69+
if name not in ds.variables:
70+
failures.append("landscape is missing variable %s" % name)
71+
if failures:
72+
return failures
73+
74+
ff = pyforefire.ForeFire()
75+
ff.setString("fuelsTableFile",
76+
os.path.join(os.path.dirname(os.path.abspath(__file__)),
77+
"..", "runff", "fuels.csv"))
78+
ff.setString("NetCDFfile", path)
79+
ff.execute("FireDomain[sw=(0,0,0);ne=(1000,1000,0);t=0]")
80+
ff.addLayer("propagation", "Rothermel", "propagationModel")
81+
ff.execute("startFire[loc=(500,500,0);t=0]")
82+
for _ in range(5):
83+
ff.execute("step[dt=100]")
84+
85+
nodes = ff.execute("print[]").count("FireNode")
86+
if nodes == 0:
87+
failures.append("no fire nodes after stepping a loaded landscape")
88+
89+
return failures
90+
91+
92+
def test_four_dimensional_field():
93+
"""A 4-D field keeps its shape and its values.
94+
95+
Deliberately uses four different lengths: with NY == NT and NX == NZ the
96+
old code's broadcast succeeded by accident.
97+
"""
98+
failures = []
99+
nt, nz, ny, nx = 5, 2, 3, 4
100+
101+
with tempfile.TemporaryDirectory() as workdir:
102+
path = os.path.join(workdir, "landscape.nc")
103+
104+
fuel = np.full((ny, nx), 1, dtype=np.int32)
105+
# Every element distinct, so a reordering cannot go unnoticed.
106+
zonal = np.arange(nt * nz * ny * nx, dtype=float).reshape(
107+
(nt, nz, ny, nx))
108+
meridian = np.zeros((nt, nz, ny, nx))
109+
110+
FiretoNC(path, domain_properties(nx, ny), parameters_properties(),
111+
fuel, wind={"zonal": zonal, "meridian": meridian})
112+
113+
with Dataset(path) as ds:
114+
got = np.array(ds.variables['windU'][:])
115+
116+
if got.shape != (nt, nz, ny, nx):
117+
failures.append("windU has shape %s, expected %s"
118+
% (got.shape, (nt, nz, ny, nx)))
119+
elif not np.array_equal(got, zonal):
120+
failures.append("windU values do not match the input")
121+
122+
return failures
123+
124+
125+
def test_three_dimensional_field():
126+
"""A 3-D field gains a leading NT of 1 and keeps its values.
127+
128+
The old code never created the NT dimension on this path, so writing a
129+
3-D field failed outright.
130+
"""
131+
failures = []
132+
nz, ny, nx = 2, 3, 4
133+
134+
with tempfile.TemporaryDirectory() as workdir:
135+
path = os.path.join(workdir, "landscape.nc")
136+
137+
fuel = np.full((ny, nx), 1, dtype=np.int32)
138+
zonal = np.arange(nz * ny * nx, dtype=float).reshape((nz, ny, nx))
139+
140+
FiretoNC(path, domain_properties(nx, ny), parameters_properties(),
141+
fuel, wind={"zonal": zonal,
142+
"meridian": np.zeros((nz, ny, nx))})
143+
144+
with Dataset(path) as ds:
145+
got = np.array(ds.variables['windU'][:])
146+
147+
if got.shape != (1, nz, ny, nx):
148+
failures.append("windU has shape %s, expected %s"
149+
% (got.shape, (1, nz, ny, nx)))
150+
elif not np.array_equal(got[0], zonal):
151+
failures.append("3-D windU values do not match the input")
152+
153+
return failures
154+
155+
156+
def test_missing_parameter_is_reported_before_writing():
157+
"""A missing key names itself, and leaves no half-written file behind."""
158+
failures = []
159+
incomplete = parameters_properties()
160+
del incomplete['refDay']
161+
162+
with tempfile.TemporaryDirectory() as workdir:
163+
path = os.path.join(workdir, "landscape.nc")
164+
try:
165+
FiretoNC(path, domain_properties(), incomplete,
166+
np.full((NY, NX), 1, dtype=np.int32))
167+
failures.append("a missing parameter key did not raise")
168+
except KeyError as error:
169+
if 'refDay' not in str(error):
170+
failures.append("the error does not name the missing key: %s"
171+
% error)
172+
if os.path.exists(path):
173+
failures.append("a file was written despite the missing key")
174+
175+
return failures
176+
177+
178+
def main():
179+
tests = [test_writes_a_loadable_landscape,
180+
test_four_dimensional_field,
181+
test_three_dimensional_field,
182+
test_missing_parameter_is_reported_before_writing]
183+
184+
failures = []
185+
for test in tests:
186+
try:
187+
result = test()
188+
except Exception:
189+
result = ["%s raised:\n%s" % (test.__name__, traceback.format_exc())]
190+
for line in result:
191+
if line.startswith("SKIP:"):
192+
print("%s: %s" % (test.__name__, line))
193+
else:
194+
failures.append("%s: %s" % (test.__name__, line))
195+
if not result:
196+
print("%s: ok" % test.__name__)
197+
198+
if failures:
199+
print("\nFAILED with %d problem(s):" % len(failures))
200+
for failure in failures:
201+
print(" - %s" % failure)
202+
return 1
203+
print("\nOK")
204+
return 0
205+
206+
207+
if __name__ == "__main__":
208+
sys.exit(main())

‎tools/README.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ to couple the simulation with MesoNH you should also edit the &NAM_FOREFIRE name
99

1010
List of preprocessing scripts:
1111
- ***clickImageToLocation.py*** sets the coordinates for the Init.ff file
12-
- ***genForeFireCase.py*** contains routines for addFieldToNcFile
12+
- ***genForeFireCase.py*** writes the **NetCDFfile** landscape (fuel, elevation, wind, flux models) from numpy arrays
1313
- ***genPrepIdeal.py*** creates the .nam for mesonh ideal case ???
1414
- ***kmlDomain.py*** extracts kml files from a netcdf file
1515
- ***PGD2Init.py*** creates the **InitFile**=Init.ff in the **ForeFireDataDirectory** directory
@@ -19,16 +19,25 @@ List of preprocessing scripts:
1919

2020
### genForeFireCase
2121

22-
Contains the preprocessing routine *FiretoNC*:
23-
to be used in order to generate a packed data landscape data in cdf format for ForeFire.
22+
Lives in `preprocessing/genForeFireCase.py` and contains the preprocessing
23+
routine *FiretoNC*, used to generate a packed data landscape in cdf format for
24+
ForeFire.
25+
2426
**Usage:** FiretoNC(filename, file name
2527
domainProperties, the domain extension (map matching forefire parameters SWx, SWy, SWz, Lx, Ly, Lz, t0, Lt)
26-
parametersProperties, the other optional properties you may want to put in the list
28+
parametersProperties, the simulation date and duration. All of date, duration, refYear, refDay, year, month and day are required
2729
fuelModelMap, a numpy integer array containing the indexes of fuel type
2830
elevation=None, a numpy real array with the elevation
2931
wind=None, a map with a "zonal" and "meridian" numpy real array values
3032
fluxModelMap=None): a map with a ("table" and "name" ) and fMap "data" numpy int array values containing indices to the corresponding flux model
3133

34+
Field arrays are indexed outermost axis first: `(NY, NX)`, or `(NZ, NY, NX)`
35+
and `(NT, NZ, NY, NX)` when they vary with height or time — the same
36+
convention `prealCF2Case.py` uses.
37+
38+
`tests/python/test_genforefirecase.py` builds a landscape, loads it and runs a
39+
short simulation; it doubles as a worked example.
40+
3241

3342
### PGD2Init
3443

‎tools/preprocessing/READMEscripts.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ to couple the simulation with MesoNH you should also edit the &NAM_FOREFIRE name
2323

2424
List of scripts:
2525
- ***clickImageToLocation.py *** sets the coordinates for the Init.ff file
26-
- ***genForeFireCase.py*** contains routines for addFieldToNcFile
26+
- ***genForeFireCase.py*** writes the **NetCDFfile** landscape (fuel, elevation, wind, flux models) from numpy arrays
2727
- ***genPrepIdeal.py*** creates the .nam for mesonh ideal case ???
2828
- ***prealCF2Case.py*** creates the **NetCDFfile**=data.nc file in the **ForeFireDataDirectory** directory
2929
- ***pngs2bmap.py*** creates the burning map file **BMapFiles** starting from kml contours
@@ -35,7 +35,7 @@ Contains the preprocessing routine *FiretoNC*:
3535
to be used in order to generate a packed data landscape data in cdf format for ForeFire.
3636
Usage: FiretoNC(filename, file name
3737
domainProperties, the domain extension (map matching forefire parameters SWx, SWy, SWz, Lx, Ly, Lz, t0, Lt)
38-
parametersProperties, the other optional properties you may want to put in the list
38+
parametersProperties, the simulation date and duration. All of date, duration, refYear, refDay, year, month and day are required
3939
fuelModelMap, a numpy integer array containing the indexes of fuel type
4040
elevation=None, a numpy real array with the elevation
4141
wind=None, a map with a "zonal" and "meridian" numpy real array values

0 commit comments

Comments
 (0)