Skip to content

Recover the double-delete and moisture fixes that missed dev - #173

Merged
HugoFara merged 9 commits into
devfrom
recover/stacked-fixes
Aug 12, 2026
Merged

Recover the double-delete and moisture fixes that missed dev#173
HugoFara merged 9 commits into
devfrom
recover/stacked-fixes

Conversation

@HugoFara

Copy link
Copy Markdown
Collaborator

#157 and #158 are marked merged, but their changes are not on dev. The stack was merged bottom-first, so the upper PRs landed on branches that had already been consumed.

TimePRMerged intoReached dev?
16:26:59#156dev
16:28:59#157test/model-unit-suite❌ — already merged 2 min earlier
16:45:51#158fix/model-properties-double-delete

So dev currently has the unit test suite, but not the double-delete fix and not the moisture guards.

Verified on origin/dev at 8b8f5b1:

unit tests: present
double-delete: STILL PRESENT <- #157 missing
moisture clamp: MISSING <- #158 missing

What this PR does

Merges fix/model-properties-double-delete (which carries both #157 and #158) into current dev.

This is a merge, not a fast-forward, deliberately: dev has four commits the stack never saw — including Merge branch 'master' into dev and the docs/_legacy reorganisation. Fast-forwarding would have reverted tools/devops/install-netcdf-manylinux.sh. The merge preserves both sides and applied with no conflicts.

Verification

  • Build: 0 errors.
  • Unit tests: 22/22 cases, 2115/2115 assertions.
  • runff: KML and NetCDF both within tolerance.
  • Confirmed dev's newer work is untouched — no diff against tools/devops/ or docs/.

For next time

When merging a stack, merge top-down#158 into #157's branch, then #157 into #156's branch, then #156 into dev — or retarget each PR to dev as the one below it lands. GitHub retargets automatically on merge, but only if the lower PR merges first and the upper one has not already been merged elsewhere.


Drafted by Claude Opus 5. Reviewed by a maintainer before filing.

HugoFaraand others added 9 commits August 12, 2026 17:27
`properties` is allocated by each model's own constructor with
`new double[numProperties]` and freed by ~ForeFireModel. Sixteen flux
models and two propagation models freed it again in their own
destructor, so destroying any model that registers at least one property
was a double free. The flux ones also used scalar `delete` on an array
allocated with `new[]`.
Removing the derived deletes and leaving it to the base class is the
whole fix; no ownership changes.
This has never been hit in production because FireDomain keeps its
models in propModelsTable and fluxModelsTable and frees neither, so
nothing destroys a model today. It is reachable from anything that does,
including a test -- which is why the test that covers it comes with it:
`every model can be destroyed` now runs all 33 models rather than the
six that register no properties, and `destroying a model does not
disturb the next one` allocates across a destruction, since a double
free often surfaces as the next allocation coming back wrong rather than
as an immediate abort.
Verified by reintroducing a single delete: the suite aborts with
"double free or corruption (!prev)" and doctest reports the case as
CRASHED - SIGABRT.
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.
Keep rate of spread finite and damped at high fuel moisture
@HugoFara
HugoFara merged commit 823292d into devAug 12, 2026
3 checks passed
@antonio-leblanc
antonio-leblanc deleted the recover/stacked-fixes branch August 13, 2026 02:30
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