diff --git a/CHANGELOG.md b/CHANGELOG.md index 85963b1..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,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 @@ -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..78b0cf6 100644 --- a/src/propagation/FarsitePropagationModel.cpp +++ b/src/propagation/FarsitePropagationModel.cpp @@ -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]; diff --git a/tests/python/README.md b/tests/python/README.md index 045431a..17ec1af 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 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 diff --git a/tests/python/test_farsite_reference.py b/tests/python/test_farsite_reference.py index 6589729..fef3d1d 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,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: @@ -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