From d70ff74d04dbaf90e14b04c8b93487afd31ae2b6 Mon Sep 17 00:00:00 2001 From: Antonio Leblanc Date: Tue, 10 Nov 2026 15:08:10 -0300 Subject: [PATCH 1/3] farsite: convert the sine slope and tilted wind back to the reference inputs --- CHANGELOG.md | 6 +++ TESTING.md | 4 +- src/propagation/FarsitePropagationModel.cpp | 12 +++++- tests/python/README.md | 7 ++-- tests/python/test_farsite_reference.py | 43 +++++++++++++-------- 5 files changed, 49 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85963b1..c46b2c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ history, so they summarise each release rather than list every change. The 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]) ## [v2.6.0] — 2026-10-26 @@ -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 diff --git a/TESTING.md b/TESTING.md index 9655416..30e259a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/src/propagation/FarsitePropagationModel.cpp b/src/propagation/FarsitePropagationModel.cpp index 78897ee..c4e93b2 100644 --- a/src/propagation/FarsitePropagationModel.cpp +++ b/src/propagation/FarsitePropagationModel.cpp @@ -184,7 +184,17 @@ 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 slope = valueOf[idx_slope]; + // Except with splines, the normal carries the slope in z, so ForeFire + // hands over sin(slope) and the wind times cos(slope). FARSITE wants the + // tangent and the horizontal wind. + if (FireNode::nmlScheme != FireNode::spline) + { + 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]; diff --git a/tests/python/README.md b/tests/python/README.md index 045431a..2db4689 100644 --- a/tests/python/README.md +++ b/tests/python/README.md @@ -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 still differs (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 diff --git a/tests/python/test_farsite_reference.py b/tests/python/test_farsite_reference.py index 6589729..13bbd3a 100644 --- a/tests/python/test_farsite_reference.py +++ b/tests/python/test_farsite_reference.py @@ -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). @@ -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 @@ -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 @@ -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 @@ -218,8 +227,9 @@ 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) @@ -227,7 +237,8 @@ def main(argv=None): 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)) @@ -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"], @@ -255,9 +267,7 @@ 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)") + bad = [k for k in errs if errs[k] > REL_TOL] if bad: failures.append((c, bad)) if args.verbose or bad: @@ -276,8 +286,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 From 68fda3aa095213dbe697887c35b8ed01329bbf41 Mon Sep 17 00:00:00 2001 From: Antonio Leblanc Date: Tue, 10 Nov 2026 16:34:55 -0300 Subject: [PATCH 2/3] changelog: Farsite result changes first under Fixed --- CHANGELOG.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c46b2c3..be2751e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -53,18 +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]) -- 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]) ## [v2.6.0] — 2026-10-26 From 00c9e76ea14f6779ff092ea75d84e66b26ce3e9c Mon Sep 17 00:00:00 2001 From: Antonio Leblanc Date: Tue, 10 Nov 2026 17:38:26 -0300 Subject: [PATCH 3/3] farsite: keep NaN slope as NaN, note when to drop the conversion --- src/propagation/FarsitePropagationModel.cpp | 12 +++++++----- tests/python/README.md | 2 +- tests/python/test_farsite_reference.py | 5 ++++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/propagation/FarsitePropagationModel.cpp b/src/propagation/FarsitePropagationModel.cpp index c4e93b2..78b0cf6 100644 --- a/src/propagation/FarsitePropagationModel.cpp +++ b/src/propagation/FarsitePropagationModel.cpp @@ -183,12 +183,14 @@ namespace libforefire //----------------------------------------------------------------------------- // 2) Read actual values from valueOf //----------------------------------------------------------------------------- - double windspd = adjustementWind * valueOf[idx_wind]; // wind along the normal, m/s + double windspd = adjustementWind * valueOf[idx_wind]; // wind times cos(slope) here, m/s double slope = valueOf[idx_slope]; - // Except with splines, the normal carries the slope in z, so ForeFire - // hands over sin(slope) and the wind times cos(slope). FARSITE wants the - // tangent and the horizontal wind. - if (FireNode::nmlScheme != FireNode::spline) + // 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); diff --git a/tests/python/README.md b/tests/python/README.md index 2db4689..17ec1af 100644 --- a/tests/python/README.md +++ b/tests/python/README.md @@ -63,7 +63,7 @@ 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 and back are printed, not asserted. `--scheme splines` runs it with that normal scheme, -where the head still differs (the spline normals do not point at the head). +where the head gap is only reported (the spline normals do not point at the head). Needs a C++ compiler besides `pyforefire`. Manually: ```bash diff --git a/tests/python/test_farsite_reference.py b/tests/python/test_farsite_reference.py index 13bbd3a..fef3d1d 100644 --- a/tests/python/test_farsite_reference.py +++ b/tests/python/test_farsite_reference.py @@ -267,7 +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 errs if errs[k] > REL_TOL] + # 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: