diff --git a/TESTING.md b/TESTING.md index b6b0268..0b178ea 100644 --- a/TESTING.md +++ b/TESTING.md @@ -157,6 +157,16 @@ real spread of rates of spread would make this a much stronger check. `tests/python/` when `PYTHONEXE` is set. It is not itself invoked by CI, which runs the suites individually. +`tests/python/test_validate.py` covers the `forefire-validate` landscape +checker. It loads the checker straight from source and exercises its pure +decision logic, so it needs neither the compiled `_pyforefire` extension nor +`netCDF4`; the one test that reads a real `.nc` is skipped when `netCDF4` is +absent. `run.bash` runs it after the examples. + +```bash +python3 tests/python/test_validate.py +``` + `tests/python/` also holds `idealizedwind.py`, `farsite_flat.py` and `percolation.py`. They are examples rather than tests — they produce plots and assert nothing — and are not run anywhere. They are the closest thing to diff --git a/bindings/python/README.md b/bindings/python/README.md index 6b5a04d..a55725b 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -133,6 +133,22 @@ For examples that use real-world data (fuel, topography, wind), see the scripts in the [`tests/python/`](https://github.com/forefireAPI/forefire/tree/master/tests/python) directory of the main repository. +### Validating a landscape file + +A landscape `.nc` and its fuel table have to agree: every fuel index in the +raster must be defined in the table, or the simulation fails or produces wrong +results without saying why. The `forefire-validate` command checks this up +front: + +```bash +forefire-validate landscape.nc fuels.csv +``` + +It reports any fuel index present in the raster but missing from the table, +flags a fuel/elevation shape mismatch, and warns when elevation or wind is +absent. Reading the `.nc` needs the `netCDF4` package (`pip install netCDF4`); +it is not pulled in by the wheel automatically. + --- ## Development diff --git a/bindings/python/src/pyforefire/_validate.py b/bindings/python/src/pyforefire/_validate.py new file mode 100644 index 0000000..7229b06 --- /dev/null +++ b/bindings/python/src/pyforefire/_validate.py @@ -0,0 +1,246 @@ +"""Validate a ForeFire landscape file against a fuel table. + +A landscape ``.nc`` and the fuel table used with it have to agree: every fuel +index painted into the raster must exist in the table, or the simulation "will +likely fail or produce incorrect results". Nothing in the engine reports which +index is missing, so this command does it up front. + +The logic that decides what is wrong is kept pure (:func:`build_report`) and is +tested without any NetCDF file; :func:`read_landscape` is the thin adapter that +pulls the same information out of a real ``.nc`` and needs ``netCDF4``. + +Run it as ``forefire-validate landscape.nc fuels.csv``. +""" + +from __future__ import annotations + +import argparse +import sys +from collections import namedtuple + +# Variable names ForeFire and its documentation accept for each role, in the +# order they are searched. Kept here so the report can name what it looked for. +FUEL_NAMES = ("fuel", "fuel_index", "land_cover") +ELEVATION_NAMES = ("altitude", "elevation", "dem", "hgt") +WIND_U_NAMES = ("windU", "wind_u", "U") +WIND_V_NAMES = ("windV", "wind_v", "V") + +#: A single line of the report. ``level`` is "error", "warning" or "info". +Finding = namedtuple("Finding", ("level", "message")) + +#: What :func:`read_landscape` extracts and :func:`build_report` consumes. +#: ``fuel_name``/``elevation_name`` are the matched variable name or ``None``; +#: ``raster_indices`` is the set of fuel indices found in the raster; +#: ``fuel_shape``/``elevation_shape`` are the spatial shapes or ``None``; +#: ``has_wind_u``/``has_wind_v`` say whether a wind component is present. +Landscape = namedtuple( + "Landscape", + ( + "variables", + "fuel_name", + "raster_indices", + "fuel_shape", + "elevation_name", + "elevation_shape", + "has_wind_u", + "has_wind_v", + ), +) + + +def parse_fuel_indices(text): + """Return the set of ``Index`` values declared in a fuel table. + + Accepts the ``;``-separated ``fuels.csv`` layout (a ``,`` separator is + tolerated as a fallback). The header row is the first non-empty line and is + identified by its first column being ``Index``. + """ + indices = set() + header_seen = False + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + sep = ";" if ";" in line else "," + first = line.split(sep, 1)[0].strip() + if not header_seen: + header_seen = True + if first.lower() == "index": + continue # skip the header row + # No header: fall through and treat this line as data. + try: + indices.add(int(float(first))) + except ValueError: + # A non-numeric first column that is not the header is not an index. + continue + return indices + + +def _find(names, present): + """Return the first of ``names`` present in ``present``, else ``None``.""" + for name in names: + if name in present: + return name + return None + + +def build_report(landscape, table_indices): + """Compare a :class:`Landscape` against the fuel indices ``table_indices``. + + Returns ``(findings, ok)`` where ``findings`` is a list of :class:`Finding` + and ``ok`` is ``False`` when any finding is an error. This function does no + I/O, so it can be tested with hand-built inputs. + """ + findings = [] + + if landscape.fuel_name is None: + findings.append( + Finding( + "error", + "no fuel index variable found (looked for %s)" + % ", ".join(FUEL_NAMES), + ) + ) + else: + findings.append( + Finding("info", "fuel variable: %r" % landscape.fuel_name) + ) + missing = sorted(landscape.raster_indices - table_indices) + if missing: + findings.append( + Finding( + "error", + "fuel indices in the raster but absent from the table: %s" + % ", ".join(str(i) for i in missing), + ) + ) + else: + findings.append( + Finding( + "info", + "all %d fuel indices are defined in the table" + % len(landscape.raster_indices), + ) + ) + + if landscape.elevation_name is None: + findings.append( + Finding( + "warning", + "no elevation variable (looked for %s); slope will not be " + "computed" % ", ".join(ELEVATION_NAMES), + ) + ) + elif ( + landscape.fuel_shape is not None + and landscape.elevation_shape is not None + and landscape.fuel_shape != landscape.elevation_shape + ): + findings.append( + Finding( + "error", + "fuel %s and elevation %s have different shapes" + % (landscape.fuel_shape, landscape.elevation_shape), + ) + ) + + if not landscape.has_wind_u or not landscape.has_wind_v: + findings.append( + Finding( + "warning", + "no wind field in the file; supply wind via parameters or the " + "trigger command", + ) + ) + + ok = not any(f.level == "error" for f in findings) + return findings, ok + + +def _unique_indices(array): + """Return the set of integer fuel indices in a raster array.""" + import numpy as np + + values = np.ma.compressed(array) if np.ma.isMaskedArray(array) else np.asarray(array) + return {int(v) for v in np.unique(values)} + + +def read_landscape(nc_path): + """Read the fields :func:`build_report` needs from a NetCDF landscape file. + + Requires ``netCDF4``. Raises :class:`SystemExit` with an actionable message + if it is not installed, since it is an optional dependency of the wheel. + """ + try: + import netCDF4 + except ImportError: # pragma: no cover - depends on the environment + raise SystemExit( + "reading a landscape file needs the netCDF4 package: " + "pip install netCDF4" + ) + + with netCDF4.Dataset(nc_path) as ds: + variables = set(ds.variables) + fuel_name = _find(FUEL_NAMES, variables) + elevation_name = _find(ELEVATION_NAMES, variables) + + raster_indices = set() + fuel_shape = None + if fuel_name is not None: + fuel_var = ds.variables[fuel_name] + raster_indices = _unique_indices(fuel_var[:]) + fuel_shape = tuple(int(n) for n in fuel_var.shape if n > 1) + + elevation_shape = None + if elevation_name is not None: + elevation_shape = tuple( + int(n) for n in ds.variables[elevation_name].shape if n > 1 + ) + + return Landscape( + variables=variables, + fuel_name=fuel_name, + raster_indices=raster_indices, + fuel_shape=fuel_shape, + elevation_name=elevation_name, + elevation_shape=elevation_shape, + has_wind_u=_find(WIND_U_NAMES, variables) is not None, + has_wind_v=_find(WIND_V_NAMES, variables) is not None, + ) + + +def format_report(findings): + """Render findings as aligned ``LEVEL: message`` lines.""" + marks = {"error": "ERROR", "warning": "WARN ", "info": "ok "} + return "\n".join("%s %s" % (marks[f.level], f.message) for f in findings) + + +def validate_files(nc_path, fuels_path): + """Validate ``nc_path`` against ``fuels_path``; return ``(findings, ok)``.""" + with open(fuels_path, "r") as handle: + table_indices = parse_fuel_indices(handle.read()) + landscape = read_landscape(nc_path) + return build_report(landscape, table_indices) + + +def main(argv=None): + """Console entry point for ``forefire-validate``.""" + parser = argparse.ArgumentParser( + prog="forefire-validate", + description="Check a ForeFire landscape .nc against a fuel table.", + ) + parser.add_argument("landscape", help="path to the landscape NetCDF file") + parser.add_argument("fuels", help="path to the fuel table (e.g. fuels.csv)") + args = parser.parse_args(argv) + + findings, ok = validate_files(args.landscape, args.fuels) + print(format_report(findings)) + if ok: + print("\nlandscape is consistent with the fuel table") + else: + print("\nlandscape has errors that will break the simulation") + return 0 if ok else 1 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index be4350e..8ec5bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,10 @@ dependencies = [ # See the install rule for `forefire` in CMakeLists.txt for why the binary is # not shipped as a wheel script directly. forefire = "pyforefire._cli:main" +# Check a landscape .nc against a fuel table. Reading the .nc needs netCDF4, +# which is intentionally not a hard dependency; the command asks for it if the +# file has to be read. +forefire-validate = "pyforefire._validate:main" [project.urls] Homepage = "https://forefire.univ-corse.fr/" diff --git a/tests/python/run.bash b/tests/python/run.bash index a4a7ff5..783b851 100755 --- a/tests/python/run.bash +++ b/tests/python/run.bash @@ -1,6 +1,9 @@ $PYTHONEXE percolation.py $PYTHONEXE idealizedwind.py +# Pure-logic checker for the landscape validator; needs no built extension. +$PYTHONEXE test_validate.py || { echo "test_validate.py failed."; exit 1; } + # Basic sanity checks on generated output # Check that 360wind.png exists and is not empty if [ ! -s 360wind.png ]; then diff --git a/tests/python/test_validate.py b/tests/python/test_validate.py new file mode 100644 index 0000000..45371bf --- /dev/null +++ b/tests/python/test_validate.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Tests for the ``forefire-validate`` landscape checker. + +The decision logic in ``pyforefire._validate`` is pure, so it is tested here +with hand-built inputs and no NetCDF file — and the module is loaded straight +from its source path, so this suite does not need the compiled ``_pyforefire`` +extension either. A real ``.nc`` round-trip runs too, but only when ``netCDF4`` +is installed; it is skipped otherwise rather than failing. +""" + +import importlib.util +import os +import sys +import tempfile + +_MODULE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + "bindings", "python", "src", "pyforefire", "_validate.py", +) + + +def _load_validate(): + spec = importlib.util.spec_from_file_location("_ff_validate", _MODULE_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +V = _load_validate() + + +def _landscape(**overrides): + """A consistent Landscape (fuel + elevation + wind, matching shapes).""" + fields = dict( + variables={"fuel", "altitude", "windU", "windV"}, + fuel_name="fuel", + raster_indices={0, 1, 2}, + fuel_shape=(100, 100), + elevation_name="altitude", + elevation_shape=(100, 100), + has_wind_u=True, + has_wind_v=True, + ) + fields.update(overrides) + return V.Landscape(**fields) + + +def _errors(findings): + return [f.message for f in findings if f.level == "error"] + + +def _levels(findings): + return {f.level for f in findings} + + +# --- parse_fuel_indices ----------------------------------------------------- + +def test_parse_semicolon_table(): + text = "Index;Rhod;Ml\n0;563;1.0\n1;563;1.0\n2;614;1.0\n" + got = V.parse_fuel_indices(text) + return [] if got == {0, 1, 2} else [f"parsed {got}, expected {{0,1,2}}"] + + +def test_parse_skips_header_and_blanks(): + text = "\nIndex;a\n1;x\n\n# comment\n7;y\n" + got = V.parse_fuel_indices(text) + return [] if got == {1, 7} else [f"parsed {got}, expected {{1,7}}"] + + +def test_parse_comma_fallback_and_no_header(): + got = V.parse_fuel_indices("3,foo\n4,bar\n") + return [] if got == {3, 4} else [f"parsed {got}, expected {{3,4}}"] + + +# --- build_report ----------------------------------------------------------- + +def test_consistent_case_is_ok(): + findings, ok = V.build_report(_landscape(), {0, 1, 2, 3}) + if not ok: + return [f"expected ok, got errors {_errors(findings)}"] + if "error" in _levels(findings) or "warning" in _levels(findings): + return [f"expected only info, got {_levels(findings)}"] + return [] + + +def test_missing_index_is_error(): + findings, ok = V.build_report(_landscape(raster_indices={0, 1, 9}), {0, 1, 2}) + errs = _errors(findings) + if ok: + return ["index 9 missing from table but case reported ok"] + if not any("9" in m for m in errs): + return [f"error should name index 9, got {errs}"] + return [] + + +def test_absent_fuel_variable_is_error(): + findings, ok = V.build_report( + _landscape(fuel_name=None, raster_indices=set(), fuel_shape=None), + {0, 1}, + ) + if ok or not any("fuel" in m for m in _errors(findings)): + return [f"absent fuel var should be an error, got {_errors(findings)}"] + return [] + + +def test_shape_mismatch_is_error(): + findings, ok = V.build_report( + _landscape(elevation_shape=(50, 50)), {0, 1, 2} + ) + if ok or not any("shape" in m for m in _errors(findings)): + return [f"shape mismatch should be an error, got {_errors(findings)}"] + return [] + + +def test_missing_elevation_is_warning_not_error(): + findings, ok = V.build_report( + _landscape( + variables={"fuel", "windU", "windV"}, + elevation_name=None, + elevation_shape=None, + ), + {0, 1, 2}, + ) + if not ok: + return [f"missing elevation should not be fatal, got {_errors(findings)}"] + if "warning" not in _levels(findings): + return ["missing elevation should produce a warning"] + return [] + + +def test_missing_wind_is_warning_not_error(): + findings, ok = V.build_report( + _landscape( + variables={"fuel", "altitude"}, + has_wind_u=False, + has_wind_v=False, + ), + {0, 1, 2}, + ) + if not ok: + return [f"missing wind should not be fatal, got {_errors(findings)}"] + if "warning" not in _levels(findings): + return ["missing wind should produce a warning"] + return [] + + +# --- read_landscape (only if netCDF4 is available) -------------------------- + +def test_read_real_netcdf(): + try: + import netCDF4 + import numpy as np + except ImportError: + print(" (skipped: netCDF4 not installed)") + return [] + + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "case.nc") + with netCDF4.Dataset(path, "w") as ds: + ds.createDimension("y", 4) + ds.createDimension("x", 5) + fuel = ds.createVariable("fuel", "i4", ("y", "x")) + fuel[:] = np.array([[0, 1, 2, 1, 0]] * 4) + alt = ds.createVariable("altitude", "f4", ("y", "x")) + alt[:] = 0.0 + + land = V.read_landscape(path) + problems = [] + if land.fuel_name != "fuel": + problems.append(f"fuel_name={land.fuel_name!r}") + if land.raster_indices != {0, 1, 2}: + problems.append(f"raster_indices={land.raster_indices}") + if land.fuel_shape != (4, 5): + problems.append(f"fuel_shape={land.fuel_shape}") + if land.has_wind_u or land.has_wind_v: + problems.append("wind reported present in a file with none") + + _, ok = V.build_report(land, {0, 1, 2}) + if not ok: + problems.append("consistent real file reported as broken") + return problems + + +def main(): + tests = [ + test_parse_semicolon_table, + test_parse_skips_header_and_blanks, + test_parse_comma_fallback_and_no_header, + test_consistent_case_is_ok, + test_missing_index_is_error, + test_absent_fuel_variable_is_error, + test_shape_mismatch_is_error, + test_missing_elevation_is_warning_not_error, + test_missing_wind_is_warning_not_error, + test_read_real_netcdf, + ] + total = 0 + for fn in tests: + print(f" {fn.__name__}") + try: + failures = fn() + except Exception as exc: + failures = [f"raised {type(exc).__name__}: {exc}"] + if failures: + total += len(failures) + for f in failures: + print(f" FAIL: {f}") + else: + print(" ok") + + print() + if total: + print(f"FAILED: {total} failure(s)") + return 1 + print("All validate tests pass.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())