Skip to content

Keep rate of spread finite and damped at high fuel moisture - #158

Merged
antonio-leblanc merged 6 commits into
forefireAPI:fix/model-properties-double-deletefrom
HugoFara:fix/moisture-ros-guards
Aug 12, 2026
Merged

Keep rate of spread finite and damped at high fuel moisture#158
antonio-leblanc merged 6 commits into
forefireAPI:fix/model-properties-double-deletefrom
HugoFara:fix/moisture-ros-guards

Conversation

@HugoFara

Copy link
Copy Markdown
Collaborator

Keep rate of spread finite and damped at high fuel moisture

Stacked on #157 (which is stacked on #156) — targets fix/model-properties-double-delete, so the diff below is only this change. Merge the two below it and GitHub retargets this automatically.

Six commits: two physics fixes, two test suites, two CI wirings.

The bugs

xsi goes negative when the dead fuel is wetter than the live fuel.xsi is the share of combustion energy spent vaporising the moisture the live fuel carries in excess of the dead fuel. When Md > Ml it flips sign, so (1 - xsi) exceeds 1 and both the radiant term A and the flame temperature T are amplified rather than damped — and R00 goes as T⁴. Wetter dead fuel made fires spread faster. Its own penalty is already carried by the 1/(1 + a·Md) factor, so the fix is to clamp at zero:

if (xsi < 0.) xsi = 0.;

Applied to Balbi2015, BalbiNov2011, BalbiNov2011Curv, BalbiNov2011TMdMl and BalbiUnsteady.

moisture was served by the five-slot getter.DataBroker mis-sized the property fetch, so models registering moisture read a neighbouring slot. Fixed in DataBroker.cpp.

Tests

tests/python/test_moisture_invariants.py — 628 lines asserting properties that follow from the published spread equations rather than from frozen ForeFire output: finiteness, monotonicity in moisture, extinction above the moisture of extinction, responsiveness, and resolution-independence. It builds its fuel, wind, temperature and moisture layers in memory, so it needs no fixtures and no LFS.

It runs in its own workflow because it needs the pyforefire extension, which install-forefire.sh does not build — FOREFIRE_BUILD_PYTHON defaults OFF outside a wheel build.

What the C++ suite from #156 says about this

The pinned rates of spread did not move, which is the right outcome and worth stating explicitly: the clamp only engages when Md > Ml, and the pins are measured at Md = 0.1, Ml = 1.0. So this changes behaviour exactly where it was wrong and nowhere else. tests/runff is likewise unchanged.

One thing this does not fix — worth a maintainer's decision

The clamp is one-sided. xsi > 1 is still reachable, and BalbiNov2011 still inverts at the shipped fuel table values.

BalbiNov2011 uses xsi = (Ml - Md)·(Sd/Sl)·(Deltah/DeltaH), where Balbi2015 uses (Sigmal/Sigmad) for that coefficient. With Sd/Sl for fuel 1 of tests/runff/fuels.csv, xsi crosses 1 near Ml ≈ 0.8; beyond that (1 - xsi) < 0, so T drops below ambient and R00 ∝ T⁴ turns it positive and growing again.

Measured on this branch, standard fuel, still air, flat ground:

MlBalbi2015BalbiNov2011
0.10.02240.0227
0.50.01179.06e-4
0.80.00662.34e-8 ← minimum
1.00.00438.16e-5 ← shipped default
1.50.00110.0175
2.01.43e-40.1596

Balbi2015 is monotonic across the whole range. BalbiNov2011 bottoms out at Ml = 0.8 and then climbs to seven times its bone-dry rate at Ml = 2.0 — soaking-wet live fuel spreading faster than dry. The shipped fuel table sets Ml = 1.0, which is inside the broken region.

I did not add if (xsi > 1.) xsi = 1.; here, because unlike the negative clamp it would change results at the default fuel values — moving the BalbiNov2011 pin and possibly runff — and that is a physics call rather than a bug-fix call. It is a two-line change once someone decides. The C++ suite's drier live fuel spreads faster, over the physical range deliberately stops at Ml = 0.8 rather than asserting the inversion is correct.

CI

The invariants workflow now triggers on pull requests to dev, matching the change made to main.yml and macos.yml in #156.

Verification

Two rate-of-spread models misbehave once dead fuel moisture reaches or
exceeds the moisture of extinction. Neither is reachable today, because
Md is a static column of the fuel table that never gets that high, but
both become reachable the moment dead fuel moisture is driven by a
dynamic field.
RothermelAndrews2018 returned NaN. Its moisture damping cubic is only
defined below the moisture of extinction: it reaches zero at mf/me == 1
and turns negative past it, driving the reaction intensity negative,
then the wind limit `wv = 0.9 * RI` negative, then pow(wv, B) to NaN for
the non-integer B. The `R <= 0` exit test is false for NaN, so the value
escaped into FireNode::velocity. Return zero before the cubic is
evaluated, and negate the exit test so a NaN can no longer slip past it.
The Balbi family sped up instead of slowing down. xsi is the share of
combustion energy spent vaporising the moisture the live fuel carries in
excess of the dead fuel; when the dead fuel is the wetter of the two it
goes negative, so (1-xsi) exceeds 1 and amplifies both the radiant term
and the flame temperature, which R00 raises to the fourth power. At
Md=3.0 that put the flame temperature near 18000 K and made the fire
spread 33x faster than at Md=1.0. Floor xsi at zero: the dead fuel's own
penalty is already carried by the 1/(1 + a*Md) factor. Applied to all
five models that share the expression.
xsi is deliberately not capped above. With the current fuel tables it
reaches ~5.3, which makes the flame temperature negative, but that traces
back to Deltah equalling DeltaH in most rows of fuels.csv rather than to
the model, and capping would move every existing calibration.
Behaviour below the thresholds is unchanged to the last digit, and the
runff KML and NetCDF references still match.
The runff test compares ForeFire against frozen ForeFire output, so it
cannot tell a physics fix from a physics regression: any deliberate
change to a spread model breaks it, and any accidental one that leaves
real_case.ff untouched slips through. This suite holds no reference data.
Every assertion follows analytically from the published spread equations,
so it stays valid across recalibration and across the planned switch from
a static fuel.Md column to a dynamic dead-moisture field.
Five invariants, over Rothermel, RothermelAndrews2018 and BalbiNov2011:
finite ROS is a real number for every moisture in [0, 3]
monotonic ROS decreases as dead fuel moisture rises
extinction ROS reaches zero at Md >= me and stays there
responsive a dynamic dead-moisture layer reaches the model
resolved DataBroker finds an optimised getter for every property
`responsive` skips until dynamic dead moisture exists, then activates.
`resolved` exists because DataBroker does not raise on an unknown
property: it warns and silently falls back to an un-optimised path, so a
mistyped property name yields stale values rather than an error.
Two constraints the probe has to respect. Each probe runs in its own
interpreter, because the core keeps mutable global state and a second
ForeFire() in one process inherits the first one's parameters -- an
in-process sweep returns one identical displacement for every input.
And relax is forced to 1, because below that FireNode::update blends in
the ignition velocity, which decays geometrically but never reaches zero,
so an extinguished front still creeps and extinction is unobservable.
Each assertion was mutation-checked: flipping the sign of the moisture
damping term, applying the dead-moisture layer inverted, removing the
moisture-of-extinction cutoff and mistyping a property name each turn the
matching invariant red. Runs in about 16 seconds.
registerPropagationModel matched the prefix "moist" and routed anything
starting with it to getMoisturesProperties, which fills five slots from
the moistures.* scalar parameters. That getter exists for the NFDRS-style
group FarsitePropagationModel registers as moist.ones / moist.tens /
moist.hundreds / moist.liveh / moist.livew, where five reserved slots
match five written values.
The prefix also caught the singular "moisture", which reserves one slot.
Balbi2015, Balbi2020 and BalbiNov2011TMdMl each register it, so every
property they registered afterwards was written four slots too late and
the last four ran past the end of the properties array. Balbi2020 read
its temperature out of moistures.liveh, its Rhod out of moistures.tens
and its Md out of moistures.hundreds, and wrote 28 doubles into a
24-element allocation. Measured, it spread the same 42.43 m -- the
distance covered by the initial front alone -- at every fuel-table Md
from 0.02 to 0.40: the model could not see its own fuel table.
Match "moist." instead, so the group still reaches the five-slot getter
while "moisture" resolves through propPropertiesGetters to getMoisture,
which writes exactly one value.
That leaves getMoisture reachable for those three models for the first
time, and it dereferenced moistureLayer unconditionally. Guard both
overloads and fall back to the moistures.ones parameter, which is the
value these models were already receiving, so no configured simulation
changes its numbers; warn once, since a model asking for a moisture
field and getting a constant is almost certainly misconfigured.
The runff KML and NetCDF references are unaffected: Rothermel registers
no moisture property.
Balbi2020, Balbi2015 and BalbiNov2011TMdMl each register a "moisture"
property beside their fuel properties, which is what the property-shift
fix addresses. Supply a temperature and a moisture layer from the probe
so all three can run, and add them to the sweep. Reverting the "moist."
prefix narrowing turns Balbi2020's monotonicity red, so the fix is now
covered.
Run duration is calibrated per model rather than fixed. Spread rates
differ by more than two orders of magnitude across these six models, and
one duration either drives the fast ones into the domain edge, clipping
the dry end of the sweep, or leaves the slow ones so close to the initial
front that perimeterResolution quantises the displacement and adjacent
moistures collide. The calibration iterates, because a Balbi front
accelerates as it develops and a rate measured over a short trial
underestimates the eventual one. All six models now land between 350 and
715 m in a 2000 m domain, well clear of both failure modes, and the
sweep keeps resolving if any model is recalibrated later.
Runs in about 40 seconds.
Adds invariants.yml, which builds the pyforefire extension and runs
tests/python/test_moisture_invariants.py on pushes to master, pull
requests against it, and manual dispatch.
A separate workflow rather than a job in main.yml, because the suite
needs the Python extension and install-forefire.sh does not build it:
outside a wheel build CMakeLists defaults FOREFIRE_BUILD_PYTHON to OFF,
so main.yml produces only the native interpreter. The job installs the
package into a venv instead of system Python, which keeps it clear of
PEP 668, and pip's build isolation supplies scikit-build-core and
pybind11 from pyproject.toml.
Checkout skips LFS, unlike main.yml: the suite builds its fuel, wind,
temperature and moisture layers in memory and needs no fixtures. Build
and suite together take about a minute locally.
This gives the repository a check that can distinguish a physics fix
from a physics regression. The runff test compares ForeFire against
frozen ForeFire output, so a deliberate model change breaks it while an
accidental one that leaves real_case.ff untouched passes.
Also documents the suite in TESTING.md, including the requirement that
each probe run in its own interpreter, and corrects the claim there that
nothing under tests/python is validated in CI.
Matches the change made to main.yml and macos.yml: the workflow only
triggered on master, so nothing integrating into dev would run it.
@antonio-leblanc
antonio-leblanc merged commit 9f02b4d into forefireAPI:fix/model-properties-double-deleteAug 12, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@HugoFara@antonio-leblanc