Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ history, so they summarise each release rather than list every change. The

### Fixed

- The Farsite model reads the wind in m/s and the slope as a tangent, like the
other Rothermel models, and slope only speeds up spread uphill. It used to
read the wind as if it were in mph and double the slope, so downhill spread
was boosted as much as uphill, and a headwind gave NaN and dropped the slope
term. This changes Farsite results: about 5 times more wind effect and 4
times less slope effect for fuel model 1, so settings tuned on the old
behaviour need tuning again. ([#242])
- On a slope, the Farsite model now matches the FARSITE reference. ForeFire
hands every model the sine of the slope and the wind times its cosine,
because the front normal carries the slope; the Farsite model converts them
back to the tangent and the horizontal wind. Uphill spread rises by up to a
third at 30°. Flat ground and `normalScheme=splines` are unchanged. ([#265])
- Commands that need a domain print an error when there is none, instead of
segfaulting: `FireNode`, `FireFront`, `startFire`, `goTo`, `save[]`,
`trigger[fuel]`, `trigger[fuelIndice]`, any command scheduled with `@t` or
Expand All @@ -53,13 +65,6 @@ history, so they summarise each release rather than list every change. The
- `plot[...;filename=*.asc]` writes a valid ESRI ASCII grid: one row per y,
north first, at full precision. It used to write one row per x and round
values to six significant digits.
- The Farsite model reads the wind in m/s and the slope as a tangent, like the
other Rothermel models, and slope only speeds up spread uphill. It used to
read the wind as if it were in mph and double the slope, so downhill spread
was boosted as much as uphill, and a headwind gave NaN and dropped the slope
term. This changes Farsite results: about 5 times more wind effect and 4
times less slope effect for fuel model 1, so settings tuned on the old
behaviour need tuning again. ([#242])

## [v2.6.0] — 2026-10-26

Expand Down Expand Up @@ -336,4 +341,5 @@ repository stays easy to return to.
[#205]: https://github.com/forefireAPI/forefire/issues/205
[#171]: https://github.com/forefireAPI/forefire/issues/171
[#172]: https://github.com/forefireAPI/forefire/issues/172
[#265]: https://github.com/forefireAPI/forefire/issues/265
[#242]: https://github.com/forefireAPI/forefire/pull/242
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,8 @@ python3 tests/python/test_validate.py

`tests/python/test_farsite_reference.py` compares the Farsite model with the
FARSITE reference code (`mbedward/farsite`) over the 13 fuel models. It needs
a C++ compiler and is not run by CI. Gaps on slope and at flank/back are
reported; `--strict` makes the slope gap fail.
a C++ compiler and is not run by CI. Flank and back are reported, not
asserted. `--scheme splines` runs it with that normal scheme.

```bash
./.venv/bin/python tests/python/test_farsite_reference.py
Expand Down
16 changes: 14 additions & 2 deletions src/propagation/FarsitePropagationModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,20 @@ namespace libforefire
//-----------------------------------------------------------------------------
// 2) Read actual values from valueOf
//-----------------------------------------------------------------------------
double windspd = adjustementWind * valueOf[idx_wind]; // wind along the normal, m/s
double slope = valueOf[idx_slope]; // tangent of the slope along the normal
double windspd = adjustementWind * valueOf[idx_wind]; // wind times cos(slope) here, m/s
double slope = valueOf[idx_slope];
// Except with splines, ForeFire puts the slope in the normal's z, so
// models get sin(slope) and the wind times cos(slope). FARSITE wants the
// tangent and the horizontal wind. If the core stops doing that, remove
// this conversion. computeSpeed[] calls getSpeed directly, so its inputs
// go through it too.
if (FireNode::nmlScheme != FireNode::spline && !std::isnan(slope))
{
double s = std::max(-0.99, std::min(0.99, slope));
double c = std::sqrt(1.0 - s * s);
slope = s / c;
windspd /= c;
}
// moinstures variables
double m_ones = valueOf[idx_m_ones];
double m_liveh = valueOf[idx_m_liveh];
Expand Down
7 changes: 4 additions & 3 deletions tests/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ Compares the `Farsite` model with FARSITE's own code for the 13 standard fuel
models, four midflame winds, three slopes and three moisture sets. The
reference is `farsite_reference.cpp`, FARSITE's spread rate and ellipse copied
from mbedward/farsite and compiled by the test with the system C++ compiler.
Fails when the head rate of spread differs from the reference; flank, back and
the gap on a slope are printed, not asserted (`--strict` asserts the slope
too). Needs a C++ compiler besides `pyforefire`. Manually:
Fails when the head rate of spread differs from the reference; flank and back
are printed, not asserted. `--scheme splines` runs it with that normal scheme,
where the head gap is only reported (the spline normals do not point at the head).
Needs a C++ compiler besides `pyforefire`. Manually:

```bash
python tests/python/test_farsite_reference.py # -v for every case
Expand Down
46 changes: 29 additions & 17 deletions tests/python/test_farsite_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,16 @@
Two comparisons, both against the reference, never against ForeFire itself:

model The reference is fed the wind and slope ForeFire handed to the
model at the head node. Any gap is in FarsitePropagationModel.cpp
or in the fuel table and moistures ForeFire feeds it.
model at the head node, converted as the model does (sine to
tangent, wind back to horizontal, except with splines). Any gap is
in FarsitePropagationModel.cpp or in the fuel table and moistures.
pipeline The reference is fed the case as FARSITE sees it: the midflame wind
and the tangent of the terrain slope. Any gap beyond the model one
is in how ForeFire derives the normal wind and slope it hands over.

The test fails when the head rate of spread differs beyond REL_TOL in the
model comparison, or in the pipeline comparison on flat ground. On a slope the
pipeline gap is known and only reported, unless ``--strict``. Flank, back and
length-to-breadth are only reported: ForeFire has no ellipse, so a node not
facing the wind spreads at the no-wind rate.
The test fails when the head rate of spread differs beyond REL_TOL in either
comparison. Flank, back and length-to-breadth are only reported: ForeFire has
no ellipse, so a node not facing the wind spreads at the no-wind rate.

Every case runs in its own interpreter, because a second ``ForeFire()`` in a
process inherits the first one's parameters (see test_moisture_invariants.py).
Expand All @@ -34,7 +33,7 @@
-----
python3 tests/python/test_farsite_reference.py # summary
python3 tests/python/test_farsite_reference.py -v # every case
python3 tests/python/test_farsite_reference.py --strict # sloped pipeline too
python3 tests/python/test_farsite_reference.py --scheme splines
"""

import argparse
Expand Down Expand Up @@ -137,6 +136,7 @@ def _probe_in_process(case):
log = os.path.join(tmp, "farsite.csv")
ff = forefire.ForeFire()
ff["propagationModel"] = "Farsite"
ff["normalScheme"] = case["scheme"]
ff["fuelsTable"] = "STDfarsiteFuelsTable"
ff["FarsiteLoggerCSVPath"] = log
ff["moistures.ones"] = ones
Expand Down Expand Up @@ -208,6 +208,15 @@ def facing(r): # +1 north (head), -1 south (back), 0 east/west (flank)
return min(rows, key=lambda r: abs(r["normalWind"]) + abs(r["slope"]))


def model_inputs(h, scheme):
"""The wind and slope getSpeed uses, from the raw values it logged."""
if scheme == "splines":
return h["normalWind"], h["slope"]
s = max(-0.99, min(0.99, h["slope"]))
c = math.sqrt(1.0 - s * s)
return h["normalWind"] / c, s / c


def rel(a, b):
if a == b:
return 0.0
Expand All @@ -218,16 +227,18 @@ def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("-v", "--verbose", action="store_true",
help="print every case, not only the mismatches")
parser.add_argument("--strict", action="store_true",
help="also fail on the pipeline gap on a slope")
parser.add_argument("--scheme", default="medians",
choices=("medians", "weightedMedians", "splines"),
help="ForeFire normalScheme")
parser.add_argument("--probe", help=argparse.SUPPRESS)
args = parser.parse_args(argv)

if args.probe:
print(_SENTINEL + json.dumps(_probe_in_process(json.loads(args.probe))))
return 0

cases = [{"fuel": f, "moisture": m, "wind": w, "slope": s}
cases = [{"fuel": f, "moisture": m, "wind": w, "slope": s,
"scheme": args.scheme}
for f in FUELS for m in MOISTURES for w in WINDS for s in SLOPES]
with ThreadPoolExecutor(max_workers=min(8, os.cpu_count() or 2)) as pool:
logs = list(pool.map(probe, cases))
Expand All @@ -236,7 +247,8 @@ def main(argv=None):
with tempfile.TemporaryDirectory() as tmp:
exe = build_reference(tmp)
model = run_reference(exe, [
(c["fuel"], MOISTURES[c["moisture"]], h["normalWind"], h["slope"])
(c["fuel"], MOISTURES[c["moisture"]])
+ model_inputs(h, args.scheme)
for c, h in zip(cases, heads)])
pipeline = run_reference(exe, [
(c["fuel"], MOISTURES[c["moisture"]], c["wind"],
Expand All @@ -255,9 +267,10 @@ def main(argv=None):
"head(model)": rel(h["ROS"], mo["head"]),
"head(pipeline)": rel(h["ROS"], pi["head"]),
}
bad = [k for k in ("R0", "phiew", "head(model)") if errs[k] > REL_TOL]
if errs["head(pipeline)"] > REL_TOL and (args.strict or c["slope"] == 0):
bad.append("head(pipeline)")
# The spline normals do not point at the head: report that gap only.
checked = [k for k in errs
if not (k == "head(pipeline)" and args.scheme == "splines")]
bad = [k for k in checked if errs[k] > REL_TOL]
if bad:
failures.append((c, bad))
if args.verbose or bad:
Expand All @@ -276,8 +289,7 @@ def main(argv=None):
ratios = [h["ROS"] / pi["head"] for c, h, pi in sloped
if c["slope"] == deg]
print(f"slope {deg:.0f} deg, ForeFire head / FARSITE head: "
f"{min(ratios):.3f} to {max(ratios):.3f}"
+ ("" if args.strict else " (reported, not asserted)"))
f"{min(ratios):.3f} to {max(ratios):.3f}")
return 1 if failures else 0


Expand Down
Loading