diff --git a/.github/actions/lfs-runff/action.yml b/.github/actions/lfs-runff/action.yml new file mode 100644 index 00000000..6b73e0af --- /dev/null +++ b/.github/actions/lfs-runff/action.yml @@ -0,0 +1,22 @@ +name: Fetch runff LFS data +description: > + Pulls only the LFS files tests/runff reads (~4 MB of the ~61 MB in LFS), + cached by object id so a run downloads nothing unless they change. + Checkout must run with `lfs: false` first. + +runs: + using: composite + steps: + - name: LFS cache key + id: key + shell: bash + run: echo "key=lfs-runff-$(git lfs ls-files -l -I 'tests/runff/*' | cut -d' ' -f1,3 | git hash-object --stdin)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v6 + with: + path: .git/lfs + key: ${{ steps.key.outputs.key }} + + - name: Pull runff LFS data + shell: bash + run: git lfs pull --include="tests/runff/*" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 353b33ab..05259fed 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,12 +2,20 @@ name: ๐Ÿ“ฆ Build & Test Docker Image on: push: + paths-ignore: [ "docs/**", "**/*.md" ] branches: [ "master" ] tags: [ 'v*.*.*' ] pull_request: + paths-ignore: [ "docs/**", "**/*.md" ] branches: [ "master" ] workflow_dispatch: +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -23,10 +31,12 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 with: - lfs: true + lfs: false # only tests/runff is needed, fetched below + + - uses: ./.github/actions/lfs-runff - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -34,7 +44,7 @@ jobs: - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -45,7 +55,7 @@ jobs: - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . # Only push if the ref starts with 'refs/tags/' diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml new file mode 100644 index 00000000..da8c1f9f --- /dev/null +++ b/.github/workflows/invariants.yml @@ -0,0 +1,65 @@ +name: ๐Ÿ”ฌ Model Invariants (Linux) + +# Complements the runff test in main.yml. That one compares ForeFire against +# frozen ForeFire output, so it cannot distinguish a physics fix from a +# physics regression. This one asserts properties that follow from the +# published spread equations and holds no reference data. +# +# It is a separate workflow because it needs the pyforefire extension, which +# install-forefire.sh does not build: outside a wheel build, CMakeLists +# defaults FOREFIRE_BUILD_PYTHON to OFF. + +on: + push: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: + - "master" + - "dev" + pull_request: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: [ "master", "dev" ] + workflow_dispatch: + +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + invariants: + name: Dead fuel moisture invariants + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + # No `lfs: true` here, unlike main.yml. The suite builds its fuel, wind, + # temperature and moisture layers in memory and needs no fixtures. + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends \ + build-essential cmake python3 python3-pip python3-venv + # NetCDF C++ legacy API for the build, C base at runtime + sudo apt-get install -y --no-install-recommends \ + libnetcdf-dev libnetcdf-c++4-dev + + - name: Build and install pyforefire + # A venv keeps this clear of PEP 668 externally-managed system Python. + # pip's build isolation pulls scikit-build-core and pybind11 from + # pyproject.toml, and numpy comes in as a runtime dependency. + run: | + python3 -m venv .venv + ./.venv/bin/python -m pip install --upgrade pip + ./.venv/bin/python -m pip install . + + - name: Check pyforefire imports + run: ./.venv/bin/python -c "import pyforefire; print(pyforefire.__file__)" + + - name: Run dead fuel moisture invariants + run: ./.venv/bin/python tests/python/test_moisture_invariants.py + + - name: Run the worker process runner test + run: ./.venv/bin/python tests/python/test_runner.py diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 4f9bc61d..2d6fb4e5 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -2,12 +2,20 @@ name: ๐Ÿงช Test Native (macOS) on: push: + paths-ignore: [ "docs/**", "**/*.md" ] branches: - brew pull_request: - branches: [ "master" ] + paths-ignore: [ "docs/**", "**/*.md" ] + branches: [ "master", "dev" ] workflow_dispatch: +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: build-native-macos: runs-on: macos-latest @@ -15,17 +23,27 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 with: - lfs: true + lfs: false # only tests/runff is needed, fetched below + + - uses: ./.github/actions/lfs-runff - name: Make install script executable run: chmod +x ./install-forefire-osx.sh - name: Build ForeFire using macOS install script + # The LFS check would curl back the LFS files the step above skips. + env: + FOREFIRE_CHECK_LFS: "OFF" run: ./install-forefire-osx.sh - name: Check ForeFire version run: ./bin/forefire -v + # Also the check that the recorded rates of spread survive a different + # compiler and a different CPU. + - name: Run C++ unit tests + run: ctest --test-dir build --output-on-failure + - name: Install Python test dependencies run: pip3 install --break-system-packages lxml xarray netCDF4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fa1e2725..efcca169 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,13 +2,21 @@ name: ๐Ÿงช Test Native (Linux) on: push: + paths-ignore: [ "docs/**", "**/*.md" ] branches: - "master" - # - "dev" + - "dev" pull_request: - branches: [ "master" ] + paths-ignore: [ "docs/**", "**/*.md" ] + branches: [ "master", "dev" ] workflow_dispatch: +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + jobs: build-native: runs-on: ubuntu-latest @@ -16,7 +24,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 with: - lfs: true # we use LFS to store large files (e.g. data.nc) + lfs: false # only tests/runff is needed, fetched below + + - uses: ./.github/actions/lfs-runff - name: Verify LFS test asset (data.nc) run: | @@ -38,11 +48,24 @@ jobs: pip3 install --no-cache-dir lxml xarray netCDF4 - name: ForeFire build - run: sudo bash ./install-forefire.sh -y + # The LFS check would curl back the LFS files the step above skips. + run: sudo env FOREFIRE_CHECK_LFS=OFF bash ./install-forefire.sh -y - name: Check ForeFire version run: ./bin/forefire -v + # The unit tests are built by install-forefire.sh along with everything + # else (FOREFIRE_BUILD_TESTS defaults on outside wheel builds), so this + # only has to run them. They cover the propagation and flux models one + # call at a time, which is what runff below cannot reach: it exercises a + # single case through Rothermel. + - name: Run C++ unit tests + run: | + # The build step above runs under sudo, so build/ is root-owned and + # ctest cannot create build/Testing/Temporary as the runner user. + sudo chown -R "$(id -u):$(id -g)" build + ctest --test-dir build --output-on-failure + - name: Add Build/Runtime Diagnostics run: | echo "--- ForeFire Linkage ---" @@ -56,4 +79,13 @@ jobs: - name: Run 'runff' Test Script run: | cd tests/runff - bash ff-run.bash # Execute the complete test logic script \ No newline at end of file + bash ff-run.bash # Execute the complete test logic script + + # The only coverage for ANNPropagationModel and BMapLoggerForANNTraining: + # both read a .ffann network in their constructor and abort without one, + # so the unit suite cannot reach them. ANN_test is built by the step + # above, since FOREFIRE_BUILD_TOOLS defaults on outside wheel builds. + - name: Run 'runANN' Test Script + run: | + cd tests/runANN + bash run.bash \ No newline at end of file diff --git a/.github/workflows/sanitizers.yml b/.github/workflows/sanitizers.yml new file mode 100644 index 00000000..e5022ad2 --- /dev/null +++ b/.github/workflows/sanitizers.yml @@ -0,0 +1,96 @@ +name: ๐Ÿงผ Sanitizers (Linux) + +# AddressSanitizer over both test paths: the unit suite, which constructs and +# destroys every propagation and flux model one call at a time, and runff, +# which runs a full simulation with a reload and NetCDF and KML output. +# +# This is a blocking check from the day it lands, which is only possible +# because ForeFire reports zero ASan errors today โ€” no use-after-free, no +# overflow, no double free, on either path. There is no backlog to clear +# first, and the point of turning it on now is to keep it that way. See #162. +# +# Leaks are blocking for the unit suite. They were not when this job landed โ€” +# nothing owned a PropagationModel, so ASan reported every one as leaked, about +# 4.7 MB in all. #159 gave the models, the propagative layer and two orphaned +# constant-layer arrays an owner, which took the unit suite to zero, so the +# gate went on. runff still leaks ~200 kB on paths the unit suite does not +# reach, so its leak check stays informational until those are found too. +# +# A separate workflow rather than a job in main.yml because it needs its own +# build: install-forefire.sh builds with -O3 -flto -fomit-frame-pointer, which +# is the opposite of what a sanitizer wants. + +on: + push: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: + - "master" + - "dev" + pull_request: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: [ "master", "dev" ] + workflow_dispatch: + +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + address-sanitizer: + name: AddressSanitizer + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + lfs: false # only tests/runff is needed, fetched below + + - uses: ./.github/actions/lfs-runff + + - name: Install Dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y --no-install-recommends \ + build-essential cmake python3 python3-pip + sudo apt-get install -y --no-install-recommends \ + libnetcdf-dev libnetcdf-c++4-dev + pip3 install --no-cache-dir --break-system-packages lxml xarray netCDF4 + + - name: Build with AddressSanitizer + # FOREFIRE_SANITIZE also switches the compile flags to -g -O1 + # -fno-omit-frame-pointer, so the stack traces are readable. + run: | + cmake -S . -B build-asan -DFOREFIRE_SANITIZE=address -DFOREFIRE_CHECK_LFS=OFF + cmake --build build-asan -j"$(nproc)" + + # Leaks are blocking here: the unit suite reports none since the model + # and layer ownership fix in #159, and this is what keeps it that way. + - name: Unit tests under ASan + env: + ASAN_OPTIONS: detect_leaks=1 + run: ctest --test-dir build-asan --output-on-failure + + # runff still leaks about 200 kB over 783 allocations, on paths a single + # simulation reaches and the unit suite does not, so its leak check stays + # informational below. Errors are blocking here as they always were. + - name: runff under ASan + env: + ASAN_OPTIONS: detect_leaks=0 + run: | + cd tests/runff + bash ff-run.bash + + # Informational only, and only for runff now. Keeps the figure visible so + # the remaining ownership gaps can be found and this can join the gate. + - name: runff leak report (informational) + continue-on-error: true + env: + ASAN_OPTIONS: detect_leaks=1 + run: | + echo "::group::runff leaks" + cd tests/runff + bash ff-run.bash || true + echo "::endgroup::" diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml new file mode 100644 index 00000000..ac8afb83 --- /dev/null +++ b/.github/workflows/wasm.yml @@ -0,0 +1,111 @@ +name: ๐ŸŒ Build WASM + +# Builds the WebAssembly module (issue #171) and runs the Node smoke test. On +# dev it also publishes the browser demo to GitHub Pages, from the same build. +# The .wasm is built here rather than committed: it is a megabyte of generated +# output, and a stale binary next to live sources is worse than a two minute +# build. +# +# The github-pages environment has to allow dev as a deployment branch +# (Settings โ†’ Environments โ†’ github-pages). + +on: + push: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: + - "master" + - "dev" + pull_request: + paths-ignore: [ "docs/**", "**/*.md" ] + branches: [ "master", "dev" ] + workflow_dispatch: + +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-wasm: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + # No LFS: the WASM build reads no test data, and emcmake turns the + # integrity check off by default. + + - name: Cache Emscripten SDK + id: emsdk-cache + uses: actions/cache@v6 + with: + path: ~/emsdk + key: emsdk-${{ runner.os }}-6.0.6 + + - name: Install Emscripten + if: steps.emsdk-cache.outputs.cache-hit != 'true' + run: | + # Pinned to the version the module was developed and tested against. + git clone --depth 1 https://github.com/emscripten-core/emsdk.git ~/emsdk + ~/emsdk/emsdk install 6.0.6 + ~/emsdk/emsdk activate 6.0.6 + + - name: Build the WebAssembly module + run: | + source ~/emsdk/emsdk_env.sh + emcmake cmake -S . -B build-wasm -DCMAKE_BUILD_TYPE=Release + cmake --build build-wasm -j"$(nproc)" + + - name: Report size + run: | + { + echo "### WebAssembly build" + echo "| file | raw | gzip | brotli |" + echo "| --- | --- | --- | --- |" + for f in bin/forefire.wasm bin/forefire.mjs; do + printf '| `%s` | %s | %s | %s |\n' "$(basename "$f")" \ + "$(stat -c%s "$f" | numfmt --to=iec)" \ + "$(gzip -c "$f" | wc -c | numfmt --to=iec)" \ + "$(brotli -c "$f" | wc -c | numfmt --to=iec)" + done + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Smoke test under Node + run: node bindings/wasm/smoke.mjs bin/forefire.mjs + + # The demo is published from dev only. + - name: Assemble the site + if: github.ref == 'refs/heads/dev' && github.event_name != 'pull_request' + run: | + mkdir -p _site + cp bin/forefire.mjs bin/forefire.wasm _site/ + cp bindings/wasm/index.html bindings/wasm/worker.mjs \ + bindings/wasm/landscape.mjs bindings/wasm/terrain3d.mjs _site/ + + - name: Upload artifact + if: github.ref == 'refs/heads/dev' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 + with: + path: _site + + deploy: + needs: build-wasm + if: github.ref == 'refs/heads/dev' && github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + # One deployment at a time; queued runs supersede rather than race. + concurrency: + group: pages + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 35fb3218..be10018e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -2,19 +2,23 @@ name: ๐Ÿ Build Python wheels on: push: + paths-ignore: [ "docs/**", "**/*.md" ] branches: - "master" tags: - "v*" pull_request: + paths-ignore: [ "docs/**", "**/*.md" ] branches: [ "master" ] release: types: [ published ] workflow_dispatch: +# A new push to a PR cancels its older run. Pushes to branches, tags and +# releases each get their own group, so none is ever queued or dropped. concurrency: - group: wheels-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true jobs: build-wheels: diff --git a/.gitignore b/.gitignore index 307d470e..fdd1edb1 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,7 @@ tests/python/FF_FS4roslog.csv tests/python/ros_computation.py tests/python/ros_sampling.py tests/python/test_logdb.csv -tests/runff/altitude.png +tests/runff/*.png tests/runff/httpConnectInfo.txt tests/python/log.txt tests/python/runROS.bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 15093ac5..bae3a118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,33 +10,146 @@ Entries below v2.5.0 were reconstructed from the release notes and the commit history, so they summarise each release rather than list every change. The `Full Changelog` link on each version gives the complete commit range. -## [Unreleased] +## [v2.6.0] โ€” 2026-10-26 -Merged since v2.5.0, not yet released. +### Added + +- C++ unit tests covering every propagation and flux model, built by default + and registered with CTest. They exercise the models one call at a time, + which the `runff` regression test cannot reach. ([#156]) +- A dead fuel moisture invariant suite (`tests/python/test_moisture_invariants.py`) + and the `invariants.yml` workflow that runs it. Unlike `runff` it holds no + reference data: every assertion follows from the published spread equations, + so it stays valid across recalibration. ([#158]) +- A blocking AddressSanitizer job, and `-DFOREFIRE_SANITIZE=` to build + with `-fsanitize=` on the compile line, the executables and the shared + library. ([#180]) +- Characterisation tests pinning the HTTP command server's current behaviour. + ([#174]) +- A concurrency stress test for free-threaded CPython + (`tests/python/test_threading.py`). It does not pass yet: it is the failing + test for the shared-state work in [#175]. ([#176]) +- `-Wall -Wextra` on ForeFire's own sources, with `FOREFIRE_ENABLE_WARNINGS` + and `FOREFIRE_WARNINGS_AS_ERRORS` to control them. NetCDF's headers are + included as system headers so their warnings do not appear. ([#156]) +- `FOREFIRE_BUILD_TESTS` (default on outside wheel builds) to build the unit + tests. ([#156]) +- `TESTING.md` now documents every suite, how to run it, and which ones CI + validates. +- `pyforefire.ForeFireRunner`, a `ForeFire` that runs in its own worker + process, so several simulations in one Python process no longer share + state, and a crash in the kernel raises `pyforefire.WorkerDied` instead of + ending Python. `ForeFire()` itself is unchanged. ([#175]) +- `forefire-validate landscape.nc fuels.csv` checks a landscape against its + fuel table before a run: fuel indices missing from the table, a fuel and + elevation shape mismatch, and missing elevation or wind. ([#190]) +- `tools/preprocessing/genForeFireCase.py` is back as a general landscape + writer. Its 3-D and 4-D fields are written in the right axis order, it uses + `netCDF4` instead of the deprecated `scipy.io.netcdf`, and it checks the + required `parametersProperties` keys before writing. ([#179]) +- A User Guide page on driving a simulation from NumPy arrays, with no + landscape file and no GIS. ([#189]) +- `-DFOREFIRE_ENABLE_NETCDF=OFF` builds the engine without NetCDF or HDF5, + the first step towards a WebAssembly build and a slim wheel. Commands that + read or write NetCDF files report that the build has no NetCDF support. + The default stays on. ([#171]) +- An experimental WebAssembly build: `emcmake cmake` compiles the engine + without NetCDF into an ES module whose embind API mirrors `pyforefire`, with + typed arrays in place of NumPy arrays. `FOREFIRE_BUILD_CLI` and + `FOREFIRE_BUILD_WASM` control the two executables. See + `bindings/wasm/README.md`. ([#171]) +- A browser demo of the WebAssembly build (`bindings/wasm/index.html`): pick + a square of ground on a map, and the page builds fuel and elevation layers + from satellite and terrain tiles, runs the fire in a worker, and plays it + back in 2D or on 3D terrain. Explore mode lights a fire where you click; + Research mode exposes the model, its tuning factors, the numerics, and the + fuel moisture and classes. ([#171]) +- The browser demo colours the burnt area by arrival time, with labelled + isochrones, and the fire front by its rate of spread. ([#171]) + +### Fixed + +- `pyforefire` calls that read the domain (`ff[key]`, `addLayer`, + `addScalarLayer`, `addIndexLayer`, `getDoubleArray`) segfaulted Python when + run before `FireDomain[...]`. They now raise `RuntimeError`. ([#202]) +- `clear[]` after `loadData` freed the propagative layer twice and crashed. + The data broker lists that layer but no longer deletes it; the domain does. + ([#205]) +- Rate of spread stayed finite and decreasing at high dead fuel moisture, and + `DataBroker` no longer serves `moisture` through the five-slot getter, which + returned a neighbouring property. ([#158]) +- The model properties array was freed twice on destruction. ([#157]) +- `~ForeFireModel` freed an uninitialised pointer when construction had not + reached the allocation. ([#156]) +- `runANN` had failed on its second line since it was committed: it diffed + against `result.txt.ref`, a file that is not in the repository. It now checks + the root mean squared error that `ANN_test` already computes, which does not + depend on the last digit of a machine-specific reference, and it runs in CI. + ([#183]) +- The `ForeFireAtom` instance counter is now atomic and the `SimulationParameters` + singleton is initialised safely, so two threads no longer race for ids or + construct the singleton twice. ([#177]) +- `StringRepresentation` kept its output buffer, current level and GeoJSON + cursor in file-scope globals shared by every instance. They are now members. + ([#178]) +- A fire front reduced to two nodes kept a deleted node as its head, and the + next burning scan crashed the simulation. ([#198]) +- `clear[]` no longer crashes after a simulation whose fire front stopped + everywhere, for instance an ignition in non-burnable fuel. ([#194]) +- `Balbi2020` multiplied by the fuel surface-to-volume ratio instead of + dividing by it in eq. C7, which drove the radiant fraction to zero. It now + matches `Balbi2020.py` in `wildfire_ROS_models`, and its rate of spread is + floored at zero on a downslope with no wind. ([#196]) +- A fuel map loaded with `loadData` that does not overlap the domain is now + stretched over it, as the warning says and as the other layers already were. + It was left at its own extent, so the whole domain read the fuel of its first + cell. ([#220]) +- `Rothermel` wrote the wind exponent coefficient as `3.59*10E-4`, which is + 3.59e-3 in C++, ten times the 3.59e-4 of Rothermel (1972), so wind raised + the rate of spread too little. Results with wind + change: +6% on the `runff` fuel at 5 m/s, several times more in loose fuel + beds such as sparse shrub. The `runff` references and the unit test pin are + regenerated. ([#195]) ### Changed +- Release builds compile with `-ffp-contract=off`, so the compiler no longer + fuses multiply-adds (FMA) where the CPU allows it. `-march=native` and ARM + builds now give the same simulation results as x86 builds without it. - Linux wheels are built against a NetCDF without DAP and HDF4, roughly halving the wheel. ([#154]) - Free-threaded (`cp314t`) wheels are no longer published, because the core is not yet thread-safe; CPython 3.14 is declared supported. ([#155]) +- `import pyforefire` no longer loads matplotlib; it is imported only when + `printToPathe` or `plot_simulation` is called. `plt`, `cm`, `mpath`, + `mpatches` and `ListedColormap` are no longer reachable as `pyforefire` + attributes. ([#193]) - `listenHTTP[]` binds to `localhost` by default and to the host given in `listenHTTP[host:port]`; use `listenHTTP[0.0.0.0:8000]` for the network (and inside Docker), which prints a warning. `systemExec` is refused over HTTP, the static file server only serves the run directory and `FOREFIREHOME/tools/htdocs`, and commands from another site or host name are refused. Other commands can still read and write files, so the - server is meant for trusted local use. ([#199]) + server is meant for trusted local use. ([#199], [#203]) +- A safe-topology failure in a step no longer ends the process; the command + reports an error and the coupled run continues. ([#181]) +- `stb_image_write.h` moved to `third_party/`, with its version, origin and + MIT / public-domain licence recorded. ([#192]) +- One-line accessors called from the propagation loops (`FireNode`, `FFPoint`, + `FFEvent`, `FireDomain` corners) are defined in their headers so they can be + inlined, which makes simulations faster with identical output. ([#221]) ## [v2.5.0] โ€” 2026-08-11 ### Added -- **Pip-installable wheels** for Linux (x86_64, aarch64) and macOS (Apple - Silicon and Intel), CPython 3.9 and newer. `pip install forefire` gives both - the `forefire` command-line interpreter and the `pyforefire` module, with - NetCDF bundled inside the wheel. Wheels are built without MPI and without - `-march=native`. ([#151]) +- **Pip-installable wheels** for Linux (x86_64, aarch64) and macOS on Apple + Silicon, CPython 3.9 and newer. `pip install forefire` gives both the + `forefire` command-line interpreter and the `pyforefire` module, with NetCDF + bundled inside the wheel. Wheels are built without MPI and without + `-march=native`. There is no Intel macOS wheel โ€” the `macos-13` runner was + retired before the release, so Intel Macs build from the source + distribution. ([#151]) ### Changed @@ -120,7 +233,7 @@ The V2 release: the HTTP interface, and alignment with Meso-NH V4.7.2. Tagged before the sources were moved into `src/`, so that this point in the repository stays easy to return to. -[Unreleased]: https://github.com/forefireAPI/forefire/compare/v2.5.0...dev +[v2.6.0]: https://github.com/forefireAPI/forefire/releases/tag/v2.6.0 [v2.5.0]: https://github.com/forefireAPI/forefire/releases/tag/v2.5.0 [v2.4.2]: https://github.com/forefireAPI/forefire/releases/tag/v2.4.2 [v2.1.122]: https://github.com/forefireAPI/forefire/releases/tag/v2.1.122 @@ -141,4 +254,32 @@ repository stays easy to return to. [#152]: https://github.com/forefireAPI/forefire/pull/152 [#154]: https://github.com/forefireAPI/forefire/pull/154 [#155]: https://github.com/forefireAPI/forefire/pull/155 +[#156]: https://github.com/forefireAPI/forefire/pull/156 +[#157]: https://github.com/forefireAPI/forefire/pull/157 +[#158]: https://github.com/forefireAPI/forefire/pull/158 +[#174]: https://github.com/forefireAPI/forefire/pull/174 +[#175]: https://github.com/forefireAPI/forefire/issues/175 +[#176]: https://github.com/forefireAPI/forefire/pull/176 +[#177]: https://github.com/forefireAPI/forefire/pull/177 +[#178]: https://github.com/forefireAPI/forefire/pull/178 +[#180]: https://github.com/forefireAPI/forefire/pull/180 +[#183]: https://github.com/forefireAPI/forefire/pull/183 +[#193]: https://github.com/forefireAPI/forefire/pull/193 +[#198]: https://github.com/forefireAPI/forefire/pull/198 +[#197]: https://github.com/forefireAPI/forefire/pull/197 +[#194]: https://github.com/forefireAPI/forefire/pull/194 +[#196]: https://github.com/forefireAPI/forefire/issues/196 +[#220]: https://github.com/forefireAPI/forefire/pull/220 +[#195]: https://github.com/forefireAPI/forefire/pull/195 +[#202]: https://github.com/forefireAPI/forefire/pull/202 [#199]: https://github.com/forefireAPI/forefire/pull/199 +[#179]: https://github.com/forefireAPI/forefire/pull/179 +[#181]: https://github.com/forefireAPI/forefire/pull/181 +[#182]: https://github.com/forefireAPI/forefire/pull/182 +[#189]: https://github.com/forefireAPI/forefire/pull/189 +[#190]: https://github.com/forefireAPI/forefire/pull/190 +[#192]: https://github.com/forefireAPI/forefire/pull/192 +[#221]: https://github.com/forefireAPI/forefire/pull/221 +[#203]: https://github.com/forefireAPI/forefire/pull/203 +[#205]: https://github.com/forefireAPI/forefire/issues/205 +[#171]: https://github.com/forefireAPI/forefire/issues/171 diff --git a/CMakeLists.txt b/CMakeLists.txt index ab9cc4cf..d7ea5206 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ if(DEFINED SKBUILD) set(_ff_default_static ON) set(_ff_default_tools OFF) set(_ff_default_lfs OFF) + set(_ff_default_tests OFF) else() set(_ff_wheel_build OFF) set(_ff_default_mpi ON) @@ -39,14 +40,41 @@ else() set(_ff_default_static OFF) set(_ff_default_tools ON) set(_ff_default_lfs ON) + set(_ff_default_tests ON) +endif() + +# Emscripten has no NetCDF and no reason for one: a browser build reads its +# data from JS typed arrays. Detecting it here rather than making the caller +# pass the flags keeps `emcmake cmake` a one-liner. +if(EMSCRIPTEN) + set(_ff_default_mpi OFF) + set(_ff_default_native OFF) + set(_ff_default_python OFF) + set(_ff_default_static ON) + set(_ff_default_tools OFF) + set(_ff_default_lfs OFF) + set(_ff_default_netcdf OFF) + set(_ff_default_cli OFF) + set(_ff_default_tests OFF) +else() + set(_ff_default_netcdf ON) + set(_ff_default_cli ON) endif() option(FOREFIRE_ENABLE_MPI "Enable MPI coupling when MPI is available" ${_ff_default_mpi}) +option(FOREFIRE_ENABLE_NETCDF "Build the NetCDF-backed I/O paths" ${_ff_default_netcdf}) +option(FOREFIRE_BUILD_CLI "Build the interactive forefire command line shell" ${_ff_default_cli}) +option(FOREFIRE_BUILD_WASM "Build the Emscripten/embind module" ${EMSCRIPTEN}) option(FOREFIRE_NATIVE_ARCH "Optimise for the build machine's CPU (-march=native)" ${_ff_default_native}) option(FOREFIRE_BUILD_PYTHON "Build the pyforefire Python extension module" ${_ff_default_python}) option(FOREFIRE_STATIC_CORE "Build the ForeFire core as a static library" ${_ff_default_static}) option(FOREFIRE_BUILD_TOOLS "Build the ANN_test helper executable" ${_ff_default_tools}) option(FOREFIRE_CHECK_LFS "Run the Git LFS data integrity check at configure time" ${_ff_default_lfs}) +option(FOREFIRE_BUILD_TESTS "Build the C++ unit tests, registered with CTest" ${_ff_default_tests}) +option(FOREFIRE_ENABLE_WARNINGS "Compile ForeFire's own sources with -Wall -Wextra" ON) +option(FOREFIRE_WARNINGS_AS_ERRORS "Fail the build on a compiler warning" OFF) +set(FOREFIRE_SANITIZE "" CACHE STRING + "Sanitizers to build with, passed to -fsanitize= (e.g. address, undefined, address,undefined)") # ---------------------------------- # Set C++ Standard @@ -130,7 +158,12 @@ endif() # detection logic below lives in exactly one place. add_library(forefire_netcdf INTERFACE) -if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) +if(NOT FOREFIRE_ENABLE_NETCDF) + # FF_NO_NETCDF compiles out every NetCDF-backed path; each entry point keeps + # a stub that reports the missing support (see DataBroker.h). + message(STATUS "NetCDF support disabled (FOREFIRE_ENABLE_NETCDF=OFF)") + target_compile_definitions(forefire_netcdf INTERFACE FF_NO_NETCDF) +elseif(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) message(STATUS "MESONH and XYZ environment variables are set. FF_STATIC too, static NetCDF.") set(MESONH $ENV{SRC_MESONH}) set(XYZ $ENV{XYZ}) @@ -154,7 +187,7 @@ if(DEFINED ENV{SRC_MESONH} AND DEFINED ENV{XYZ} AND DEFINED ENV{FF_STATIC}) message(FATAL_ERROR "Required static library not found: ${lib}") endif() endforeach() - target_include_directories(forefire_netcdf INTERFACE "${NETCDF_HOME}/include") + target_include_directories(forefire_netcdf SYSTEM INTERFACE "${NETCDF_HOME}/include") target_link_libraries(forefire_netcdf INTERFACE ${NETCDF_LIB} ${NETCDF_CXX_LIB} @@ -200,7 +233,9 @@ else() message(STATUS "NetCDF C: ${NETCDF_LIBRARY} (headers: ${NETCDF_INCLUDE_DIR})") message(STATUS "NetCDF C++: ${NETCDF_CXX_LIBRARY} (headers: ${NETCDF_CXX_INCLUDE_DIR})") - target_include_directories(forefire_netcdf INTERFACE ${NETCDF_CXX_INCLUDE_DIR} ${NETCDF_INCLUDE_DIR}) + # SYSTEM: the NetCDF C++4 headers trip -Wunused-variable, and that is not a + # warning anyone here can act on. + target_include_directories(forefire_netcdf SYSTEM INTERFACE ${NETCDF_CXX_INCLUDE_DIR} ${NETCDF_INCLUDE_DIR}) target_link_libraries(forefire_netcdf INTERFACE ${NETCDF_CXX_LIBRARY} ${NETCDF_LIBRARY}) endif() @@ -213,15 +248,54 @@ endif() # ---------------------------------- if(MPI_FOUND) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0 -DMPI_COUPLING") +elseif(FOREFIRE_SANITIZE) + # A sanitizer build wants frame pointers and debug info; the release set + # below strips both, and -flto makes the reports harder to read besides. + # -O1 keeps the suites quick enough to run in CI. + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O1 -fno-omit-frame-pointer") else() # uncomment if want to debug # set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0") - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -flto -fomit-frame-pointer -finline-functions -funroll-loops -ftree-vectorize -fno-strict-aliasing") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -ffp-contract=off -flto -fomit-frame-pointer -finline-functions -funroll-loops -ftree-vectorize -fno-strict-aliasing") if(FOREFIRE_NATIVE_ARCH) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -march=native") endif() endif() +# Sanitizers have to reach the link line as well as the compile line, and the +# shared library too: the core is where the allocations happen. +if(FOREFIRE_SANITIZE) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=${FOREFIRE_SANITIZE}") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=${FOREFIRE_SANITIZE}") + set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -fsanitize=${FOREFIRE_SANITIZE}") + message(STATUS "Sanitizers enabled: -fsanitize=${FOREFIRE_SANITIZE}") +endif() + +# Warnings are a target property rather than a global flag on purpose: they +# should apply to code this project writes, not to pybind11's headers or to +# whatever a consumer compiles against the installed library. +set(_ff_warning_flags "") +if(FOREFIRE_ENABLE_WARNINGS) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + list(APPEND _ff_warning_flags -Wall -Wextra) + elseif(MSVC) + list(APPEND _ff_warning_flags /W4) + endif() +endif() +if(FOREFIRE_WARNINGS_AS_ERRORS AND _ff_warning_flags) + if(MSVC) + list(APPEND _ff_warning_flags /WX) + else() + list(APPEND _ff_warning_flags -Werror) + endif() +endif() + +if(EMSCRIPTEN) + # Emscripten defaults to -fno-exceptions; the command parser signals bad + # input by throwing, so every translation unit needs them enabled. + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fexceptions") +endif() + # ---------------------------------- # Output Directories # ---------------------------------- @@ -258,10 +332,18 @@ endif() target_include_directories(forefireL PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src" "${CMAKE_CURRENT_SOURCE_DIR}/src/include") +# SYSTEM and PRIVATE: vendored headers are ours to use but not ours to fix, so +# the warning flags below must not apply to them, and nothing outside this +# library includes them. +target_include_directories(forefireL SYSTEM PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/third_party") # Threads is explicit because a static core does not drag libpthread in for # its consumers the way the shared library does. find_package(Threads REQUIRED) target_link_libraries(forefireL PUBLIC forefire_netcdf Threads::Threads) +if(_ff_warning_flags) + target_compile_options(forefireL PRIVATE ${_ff_warning_flags}) +endif() # ---------------------------------- # Test Executable @@ -274,8 +356,61 @@ endif() # ---------------------------------- # Main Project Executable # ---------------------------------- -add_executable(forefire app/forefire/ForeFire.cpp app/forefire/AdvancedLineEditor.cpp) -target_link_libraries(forefire PRIVATE forefireL) +# The shell is termios-driven and has no meaning in a browser, so a WASM build +# skips it and ships the embind module below instead. +if(FOREFIRE_BUILD_CLI) + add_executable(forefire app/forefire/ForeFire.cpp app/forefire/AdvancedLineEditor.cpp) + target_link_libraries(forefire PRIVATE forefireL) + if(_ff_warning_flags) + target_compile_options(forefire PRIVATE ${_ff_warning_flags}) + endif() +endif() + +# ---------------------------------- +# Unit Tests +# ---------------------------------- +if(FOREFIRE_BUILD_TESTS) + enable_testing() + add_subdirectory(tests/unit) +endif() + +# ---------------------------------- +# WebAssembly Module +# ---------------------------------- +if(FOREFIRE_BUILD_WASM) + add_executable(forefire_wasm bindings/wasm/forefire_wasm.cpp) + target_link_libraries(forefire_wasm PRIVATE forefireL) + # Two things are non-obvious here. embind refuses to compile below C++17, + # so this one translation unit moves up while the core stays on C++11 โ€” + # both sit on the same libc++. And the output is an ES module, which Node + # only recognises from a .js file when a package.json says so, hence .mjs. + set_target_properties(forefire_wasm PROPERTIES + OUTPUT_NAME "forefire" + SUFFIX ".mjs" + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON) + target_link_options(forefire_wasm PRIVATE + "--bind" + # A single ES module plus its .wasm, so a static page can import it. + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sEXPORT_NAME=createForeFire" + "-sENVIRONMENT=web,worker,node" + # The domain allocates its grids up front and can grow well past the + # 16 MB default; the fire domain sizes are not known at build time. + "-sALLOW_MEMORY_GROWTH=1" + "-sINITIAL_MEMORY=64MB" + "-sSTACK_SIZE=4MB" + # C++ exceptions are load-bearing here: BadOption and friends are how + # the command parser reports bad input. + "-fexceptions" + # There is no main(): the module is driven entirely through embind. + # FS stays exported so callers can stage fuels.csv and read back what + # print[] writes. + "-sEXPORTED_RUNTIME_METHODS=['FS']" + "-sFORCE_FILESYSTEM=1") + target_compile_options(forefire_wasm PRIVATE "-fexceptions") +endif() # ---------------------------------- # Python Extension Module @@ -319,7 +454,9 @@ if(NOT _ff_wheel_build) set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") # Adjust as necessary # Specify installation directories - install(TARGETS forefire RUNTIME DESTINATION bin) + if(FOREFIRE_BUILD_CLI) + install(TARGETS forefire RUNTIME DESTINATION bin) + endif() if(NOT FOREFIRE_STATIC_CORE) # With a static core there is no libforefireL to install: it is linked # straight into the executables. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c8a0305..48a18128 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,9 +64,13 @@ We welcome code contributions, from bug fixes to new features. Implement your code changes, following existing coding style and conventions where possible. 6. **Add Tests (if applicable):** - For new features or significant bug fixes, please add corresponding tests or update existing ones. See [TESTING.md](TESTING.md) for what each suite covers and how to run it. Ensure all tests pass locally. + For new features or significant bug fixes, please add corresponding tests or update existing ones. See [TESTING.md](TESTING.md) for what each suite covers, how to run it, and which ones CI validates. Ensure all tests pass locally. ```bash - # Example command to run tests (adjust as needed) + # The C++ unit tests + cmake -S . -B build && cmake --build build -j + ctest --test-dir build --output-on-failure + + # The script-driven suites (runff, runANN) cd tests && bash run.bash ``` 7. **Record the Change:** diff --git a/Dockerfile b/Dockerfile index 4dc854d8..8858f716 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,14 +29,16 @@ COPY --link app/ ./app/ # tools/check_all_lfs.bash and references tools/runANN/ANNTest.cpp. COPY --link tools/ ./tools/ COPY --link src/ ./src/ +COPY --link third_party/ ./third_party/ # -march=native is off: this image is published and has to run on CPUs other -# than the builder's. +# than the builder's. The LFS check and the unit tests are off too: tests/ is +# not copied into this stage, so they could only fail. # # Only libforefireL.so is carried into the runtime stage, and the wheel build # below compiles the core again on its own terms, so building the `forefire` # and ANN_test executables here as well would just be a wasted compile. -RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DFOREFIRE_NATIVE_ARCH=OFF \ +RUN cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DFOREFIRE_NATIVE_ARCH=OFF -DFOREFIRE_CHECK_LFS=OFF -DFOREFIRE_BUILD_TESTS=OFF \ && cmake --build build -j"$(nproc)" --target forefireL COPY --link bindings/ ./bindings/ diff --git a/README.md b/README.md index 07d47ab9..fa31601c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![linuxCI](https://github.com/forefireAPI/forefire/actions/workflows/main.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/main.yml) [![macOSCI](https://github.com/forefireAPI/forefire/actions/workflows/macos.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/macos.yml) +[![Model Invariants](https://github.com/forefireAPI/forefire/actions/workflows/invariants.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/invariants.yml) [![Docker CI/CD](https://github.com/forefireAPI/forefire/actions/workflows/docker.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/docker.yml) [![Documentation Status](https://readthedocs.org/projects/forefire/badge/?version=latest)](https://forefire.readthedocs.io/en/latest/?badge=latest) @@ -24,9 +25,10 @@ **Key Links:** - ๐Ÿ“š **Full Documentation:** [forefire.readthedocs.io](https://forefire.readthedocs.io/en/latest/) - ๐Ÿš€ **Live Demo:** [forefire.univ-corse.fr/sim](http://forefire.univ-corse.fr/sim) +- ๐Ÿ’ป **In-browser Demo (WebAssembly):** [forefireapi.github.io/forefire](https://forefireapi.github.io/forefire/) - ๐ŸŒ **Website:** [forefire.univ-corse.fr](https://forefire.univ-corse.fr/) -- ๐Ÿ“ **Changelog:** [CHANGELOG.md](CHANGELOG.md) โ€” what each release changed -- ๐Ÿงช **Testing:** [TESTING.md](TESTING.md) โ€” how to run each test suite +- ๐Ÿ“ **Changelog:** [CHANGELOG.md](CHANGELOG.md) +- ๐Ÿงช **Testing:** [TESTING.md](TESTING.md) ## Features @@ -69,6 +71,11 @@ ff.execute("step[dt=1000]") print(ff.execute("print[]")) ``` +You can drive a whole simulation from NumPy arrays โ€” a fuel map and a wind +field, no NetCDF and no GIS โ€” using `addIndexLayer` and `addScalarLayer`. See +[Running from NumPy arrays](https://forefire.readthedocs.io/en/latest/user_guide/python_arrays.html) +for a complete example. + Published wheels are built without MPI support. For fire-atmosphere coupling with MesoNH, or to tune the build for your CPU, build from source instead (see [Build from source](#build-from-source)). @@ -160,9 +167,15 @@ The CMake build is option-driven. The defaults below are what a plain | `FOREFIRE_STATIC_CORE` | `OFF` | Build the core as a static library instead of `libforefireL`. | | `FOREFIRE_BUILD_TOOLS` | `ON` | Build the `ANN_test` helper executable. | | `FOREFIRE_CHECK_LFS` | `ON` | Run the Git LFS integrity check while configuring. | +| `FOREFIRE_BUILD_TESTS` | `ON` | Build the C++ unit tests and register them with CTest. | +| `FOREFIRE_ENABLE_WARNINGS` | `ON` | Compile ForeFire's own sources with `-Wall -Wextra`. | +| `FOREFIRE_WARNINGS_AS_ERRORS` | `OFF` | Fail the build on any compiler warning. Not yet usable repository-wide. | +| `FOREFIRE_SANITIZE` | `""` | Sanitizers to build with, passed to `-fsanitize=` (e.g. `address`). | Wheel builds (anything driven by `pip`) flip these to the portable defaults: -no MPI, no `-march=native`, static core, Python module on. +no MPI, no `-march=native`, static core, Python module on, no tools, no tests. + +`TESTING.md` covers the test suites and the sanitizer build in detail. ## Python Bindings ForeFire provides Python bindings for easier scripting and integration: diff --git a/TESTING.md b/TESTING.md index dabd6859..adffffdc 100644 --- a/TESTING.md +++ b/TESTING.md @@ -14,9 +14,25 @@ Install Python libraries via pip: pip3 install lxml xarray netCDF4 ``` +## Running the Unit Tests + +The C++ unit tests exercise propagation and flux models one call at a time, +without running a simulation. They are built alongside everything else and run +through CTest: + +```bash +cmake -S . -B build && cmake --build build -j +ctest --test-dir build --output-on-failure +``` + +They need no Python and no test data. Configure with +`-DFOREFIRE_BUILD_TESTS=OFF` to skip building them; wheel builds already do. + +`tests/unit/README.md` describes what they cover and how to add one. + ## Running the Core Test (`runff`) -The primary automated test, validated in our CI pipeline, is located in `tests/runff/`. This test verifies core simulation, save/reload functionality, and NetCDF/KML output generation against reference files. +The primary physics regression test, validated in CI by `main.yml` and `macos.yml`, is located in `tests/runff/`. This test verifies core simulation, save/reload functionality, and NetCDF/KML output generation against reference files. **To run this test manually:** @@ -32,9 +48,215 @@ The `ff-run.bash` script: 3. Uses Python scripts (`compare_kml.py`, `compare_nc.py`) to compare the generated KML and NetCDF files against reference files (`*.ref`) with numerical tolerance, accounting for minor floating-point variations. 4. Exits with status 0 on success, non-zero on failure. +## Running the Model Invariants (`test_moisture_invariants.py`) + +The second test validated in CI, by the `invariants.yml` workflow. Where +`runff` compares ForeFire against frozen ForeFire output โ€” and so cannot tell a +physics fix from a physics regression โ€” this suite holds no reference data. +Every assertion follows from the published spread equations, so it stays valid +across recalibration. + +It asserts, for each propagation model that consumes dead fuel moisture, that +the rate of spread stays finite for any moisture, decreases as moisture rises, +reaches zero at the moisture of extinction, responds to a dynamic +dead-moisture layer, and that `DataBroker` resolves every property the model +registers. + +**To run it manually:** + +1. Install the Python package, which builds the `pyforefire` extension: + ```bash + python3 -m venv .venv + ./.venv/bin/python -m pip install . + ``` +2. Run the suite (add `-v` to print every probe's spread rate): + ```bash + ./.venv/bin/python tests/python/test_moisture_invariants.py + ``` + +Restrict it while iterating with `--model NAME` and `--test NAME`, both +repeatable. It needs no fixtures โ€” fuel, wind, temperature and moisture layers +are built in memory โ€” and takes well under a minute. + +Note that each probe runs in its own interpreter. The C++ core keeps mutable +global state, so a second `ForeFire()` in one process inherits the first one's +parameters and a parameter sweep silently returns one identical result. Keep +that in mind when writing any new Python test that varies parameters. + +## Running the Concurrency Stress Test (`test_threading.py`) + +Not run in CI, deliberately. On an ordinary interpreter the GIL serialises +every call into the extension, so the test skips and would report a green +result that proves nothing. It is a reproduction tool, run by hand when +working on the shared-state problem in +[#175](https://github.com/forefireAPI/forefire/issues/175). + +It runs eight simulations in threads and requires each to reproduce the fire +node count it produces when run alone, plus a case that hammers object +construction to exercise the shared id counter. + +**To run it**, you need a free-threaded build of CPython (`python3.14t` or +later) and `PYTHON_GIL=0`. The variable is required: `_pyforefire` does not +declare `py::mod_gil_not_used()`, so importing it switches the GIL back on and +the test skips. + +```bash +python3.14t -m venv .venv +./.venv/bin/python -m pip install . +PYTHON_GIL=0 ./.venv/bin/python tests/python/test_threading.py +``` + +**It does not pass today.** On `dev` it segfaults or returns wrong node counts, +which is the point โ€” it is the failing test the work in #175 has to make pass. +Wiring it into CI belongs with the last step of that issue, once it can pass +for the right reason. + +## Running the Worker Process Test (`test_runner.py`) + +Checks `pyforefire.ForeFireRunner`, which runs each simulation in its own +worker process (#175). Eight runners driven from threads must each reproduce +the in-process fire node count; exceptions in the worker must reach the +caller, and a killed worker must raise `WorkerDied`. It needs no free-threaded +interpreter, because the runners are separate processes. CI runs it in the +Model Invariants workflow. + +```bash +./.venv/bin/python tests/python/test_runner.py +``` + +## Running the ANN Test (`runANN`) + +Validated in CI by `main.yml`. It is the only coverage for +`ANNPropagationModel` and `BMapLoggerForANNTraining`: both read a `.ffann` +network in their constructor and abort without one, so the C++ unit suite +cannot construct them. + +It runs the trained network in `Rothermel.ffann` over the 1424 inputs in +`modelrun.csv` and checks the root mean squared error against what the +propagation model produced. + +**To run it manually**, after building (`ANN_test` needs +`-DFOREFIRE_BUILD_TOOLS=ON`, which is the default outside wheel builds): + +```bash +cd tests/runANN +bash run.bash +``` + +Add `print` to `ANN_test` for a per-input dump: + +```bash +../../bin/ANN_test Rothermel.ffann modelrun.csv print +``` + +The tolerance is calibrated between a working network and a useless one: + +| | RMSE | +| --- | --- | +| trained network | 0.0235 | +| predicting the mean of every output | 0.0966 | +| normalisation weights scaled by 1% | 17817 | +| **tolerance** | **0.05** | + +Note the fixture is weak: the expected outputs take four distinct values +spanning 1.1 in 12412, which is why the gap between a working network and a +constant one is so narrow. Replacing `modelrun.csv` with inputs that produce a +real spread of rates of spread would make this a much stronger check. + +## Running the Landscape Generator Test (`test_genforefirecase.py`) + +Covers `tools/preprocessing/genForeFireCase.py`, which writes the NetCDF +landscape file a simulation runs on. It builds a landscape, hands it to +ForeFire, ignites it and steps, so it exercises the writer and the reader +together rather than the writer alone. It also pins the field axis order, +where the 3-D and 4-D paths were previously broken. + +**To run it manually**, after installing the Python package as above: + +```bash +./.venv/bin/python tests/python/test_genforefirecase.py +``` + +It needs `numpy` and `netCDF4`, writes only into a temporary directory, and +takes a few seconds. Without a built `pyforefire` the load-and-simulate case +skips and the rest still run, so it is usable while iterating on the writer. + +The test doubles as the worked example for the tool. + ## Other Tests -The `tests/` directory contains other subdirectories (`mnh_*`, `python`, `runANN`) for potentially testing specific features like coupled simulations or Python bindings. A main `tests/run.bash` script exists but is not currently fully validated in CI. Refer to specific subdirectories for details if needed. +`tests/run.bash` runs every suite that its environment allows: `runff` and +`runANN` always, the `mnh_*` coupled cases when `SRC_MESONH` is set, and +`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 +worked examples in the repository, so keeping them executable is worthwhile. + +## Sanitizers + +`-DFOREFIRE_SANITIZE=` builds with `-fsanitize=`, applied to the +compile line, the executables and the shared library. It also switches the +optimisation flags to `-g -O1 -fno-omit-frame-pointer`, since the default +release set (`-O3 -flto -fomit-frame-pointer`) makes sanitizer reports hard to +read. + +```bash +cmake -S . -B build-asan -DFOREFIRE_SANITIZE=address +cmake --build build-asan -j +ASAN_OPTIONS=detect_leaks=0 ctest --test-dir build-asan --output-on-failure +``` + +`address` is what CI runs, on both the unit suite and `runff`, as a blocking +check. Other values are passed straight through โ€” `undefined`, or +`address,undefined` for both โ€” but only `address` is currently verified clean. + +ForeFire reports zero ASan *errors* โ€” no use-after-free, no overflow, no double +free โ€” on either test path, which is what makes a blocking job possible. + +**Leaks are blocking for the unit suite and informational for `runff`.** The +unit suite reports none: #159 gave the propagation and flux models, the +propagative layer and two orphaned constant-layer arrays an owner, which took +it from about 4.7 MB to zero. Running the suite with leak detection on is +therefore a check rather than a report: + +```bash +ASAN_OPTIONS=detect_leaks=1 ctest --test-dir build-asan --output-on-failure +``` + +`runff` still leaks about 200 kB over 783 allocations, on paths a full +simulation reaches and the unit suite does not, so its leak check runs without +gating the build. Closing those is what would let it join the gate; until then +the figure stays visible in the job log. + +Note that the sanitizer build writes `bin/forefire` and `lib/libforefireL.so` +like any other build, so it replaces a release build in the source tree. + +## Compiler Warnings + +ForeFire's own sources compile with `-Wall -Wextra` by default. The warnings +are not yet clean, so they are informational rather than fatal; two options +control this: + +* `-DFOREFIRE_ENABLE_WARNINGS=OFF` โ€” build quietly. +* `-DFOREFIRE_WARNINGS_AS_ERRORS=ON` โ€” fail the build on any warning. Useful + on a subset of files while clearing them; not yet usable repository-wide. + +The flags apply to `libforefireL`, the `forefire` executable and the unit +tests. NetCDF's headers are included as system headers so their warnings do +not appear. ## Contributing diff --git a/app/forefire/ForeFire.cpp b/app/forefire/ForeFire.cpp index d9c72bfc..5a36a034 100644 --- a/app/forefire/ForeFire.cpp +++ b/app/forefire/ForeFire.cpp @@ -85,12 +85,14 @@ int ForeFire::startShell(int argc, char* argv[]) { string listenCommand = "listenHTTP[]"; executor.ExecuteCommand(listenCommand); - // Keep the main thread alive indefinitely + // Keep the main thread alive while the server runs. `quit[]` used to + // end the process from inside the library; now it asks, and this is + // the loop that answers, so a served quit[] still stops the server. cout << "(Press Ctrl+C to exit)" << endl; - while (true) { - sleep(3600); + while (!Command::quitRequested()) { + sleep(1); } - + return 0; } else { @@ -160,6 +162,10 @@ void ForeFire::FFShell(ifstream* inputStream) { if (line.empty()) continue; executor.ExecuteCommand(line); + // `quit[]` no longer ends the process from inside the library, so + // the shell is what leaves on request. + if (Command::quitRequested()) + break; } } } diff --git a/app/forefire/commands.md b/app/forefire/commands.md index 3cf6d191..3394b5e2 100644 --- a/app/forefire/commands.md +++ b/app/forefire/commands.md @@ -1,13 +1,15 @@ std::string commandHelp = R"( ## FireDomain - FireDomain[sw=(x,y,z);ne=(x,y,z);t=seconds|date=YYYY-MM-DDTHH:MM:SSZ;opt:BBoxWSEN=(8.36215875,41.711125,9.1366311,42.28667)] + FireDomain[sw=(x,y,z);ne=(x,y,z);t=seconds] Create the main FireDomain; fronts and nodes will be created within this FireDomain - Example: FireDomain[sw=(0,0,0);ne=(64000,64000,0);BBoxWSEN=(lonWest,latSouth,lonEast,latNorth);t=2400] + Example: FireDomain[sw=(0,0,0);ne=(64000,64000,0);t=2400] Arguments: - 'sw': Southwest point (x,y,z) in meters - 'ne': Northeast point (x,y,z) in meters - - 't': Time associated with the fire domain in seconds or as an absolute ISO GMT date - - 'opt:BBoxWSEN': Optional WGS coordinates for the north-oriented bounding box (lonWest,latSouth,lonEast,latNorth) + - 't': Time associated with the fire domain in seconds (no 'date=' here) + To georeference the domain, set BBoxWSEN before creating it, e.g. + setParameter[BBoxWSEN=8.36215875,41.711125,9.1366311,42.28667] + FireDomain does not read BBoxWSEN; without the parameter the domain is placed at DefaultSWLngLat (Corsica) ## FireFront @@ -45,12 +47,12 @@ std::string commandHelp = R"( - 'nowplus': Delta duration in seconds for the scheduled command to execute; ## startFire - startFire[loc=(x,y,z)|lonlat=(lon,lat);t=seconds|date=YYYY-MM-DDTHH:MM:SSZ] + startFire[loc=(x,y,z)|lonlat=(lon,lat,z);t=seconds|date=YYYY-MM-DDTHH:MM:SSZ] Creates the smallest possible triangular fire front at the specified location and time - Example: startFire[loc=(0.0,0.0,0.0),t=0.] + Example: startFire[loc=(0.0,0.0,0.0);t=0.] Arguments: - 'loc': Starting location of the fire (coordinates x,y,z) - - 'lonlat': WGS coordinates of the ignition point + - 'lonlat': WGS coordinates of the ignition point (lon,lat,z); all three values are required - 't': Time when the fire is started (in seconds or as an absolute ISO GMT date); ## step @@ -67,15 +69,15 @@ std::string commandHelp = R"( ## trigger - trigger[fuelIndice=;loc=(x,y,z);fuelType=;vel=(vx,vy,vz);t=