|
| 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()) |
0 commit comments