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
54 changes: 54 additions & 0 deletions .github/workflows/invariants.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: 🔬 Model Invariants (Linux)

# Complements the runff test in main.yml. That one compares ForeFire against
# frozen ForeFire output, so it cannot distinguish a physics fix from a
# physics regression. This one asserts properties that follow from the
# published spread equations and holds no reference data.
#
# It is a separate workflow because it needs the pyforefire extension, which
# install-forefire.sh does not build: outside a wheel build, CMakeLists
# defaults FOREFIRE_BUILD_PYTHON to OFF.

on:
push:
branches:
- "master"
- "dev"
pull_request:
branches: [ "master", "dev" ]
workflow_dispatch:

jobs:
invariants:
name: Dead fuel moisture invariants
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
# No `lfs: true` here, unlike main.yml. The suite builds its fuel, wind,
# temperature and moisture layers in memory and needs no fixtures.
- name: Checkout repository
uses: actions/checkout@v6

- name: Install Dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y --no-install-recommends \
build-essential cmake python3 python3-pip python3-venv
# NetCDF C++ legacy API for the build, C base at runtime
sudo apt-get install -y --no-install-recommends \
libnetcdf-dev libnetcdf-c++4-dev

- name: Build and install pyforefire
# A venv keeps this clear of PEP 668 externally-managed system Python.
# pip's build isolation pulls scikit-build-core and pybind11 from
# pyproject.toml, and numpy comes in as a runtime dependency.
run: |
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install .

- name: Check pyforefire imports
run: ./.venv/bin/python -c "import pyforefire; print(pyforefire.__file__)"

- name: Run dead fuel moisture invariants
run: ./.venv/bin/python tests/python/test_moisture_invariants.py
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<!-- Project Health & Status -->
[![linuxCI](https://github.com/forefireAPI/forefire/actions/workflows/main.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/main.yml)
[![macOSCI](https://github.com/forefireAPI/forefire/actions/workflows/macos.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/macos.yml)
[![Model Invariants](https://github.com/forefireAPI/forefire/actions/workflows/invariants.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/invariants.yml)
[![Docker CI/CD](https://github.com/forefireAPI/forefire/actions/workflows/docker.yml/badge.svg)](https://github.com/forefireAPI/forefire/actions/workflows/docker.yml)
[![Documentation Status](https://readthedocs.org/projects/forefire/badge/?version=latest)](https://forefire.readthedocs.io/en/latest/?badge=latest)
<!-- Distribution and Technical Stack -->
Expand Down
37 changes: 36 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,44 @@ The `ff-run.bash` script:
3. Uses Python scripts (`compare_kml.py`, `compare_nc.py`) to compare the generated KML and NetCDF files against reference files (`*.ref`) with numerical tolerance, accounting for minor floating-point variations.
4. Exits with status 0 on success, non-zero on failure.

## Running the Model Invariants (`test_moisture_invariants.py`)

The second test validated in CI, by the `invariants.yml` workflow. Where
`runff` compares ForeFire against frozen ForeFire output — and so cannot tell a
physics fix from a physics regression — this suite holds no reference data.
Every assertion follows from the published spread equations, so it stays valid
across recalibration.

It asserts, for each propagation model that consumes dead fuel moisture, that
the rate of spread stays finite for any moisture, decreases as moisture rises,
reaches zero at the moisture of extinction, responds to a dynamic
dead-moisture layer, and that `DataBroker` resolves every property the model
registers.

**To run it manually:**

1. Install the Python package, which builds the `pyforefire` extension:
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install .
```
2. Run the suite (add `-v` to print every probe's spread rate):
```bash
./.venv/bin/python tests/python/test_moisture_invariants.py
```

Restrict it while iterating with `--model NAME` and `--test NAME`, both
repeatable. It needs no fixtures — fuel, wind, temperature and moisture layers
are built in memory — and takes well under a minute.

Note that each probe runs in its own interpreter. The C++ core keeps mutable
global state, so a second `ForeFire()` in one process inherits the first one's
parameters and a parameter sweep silently returns one identical result. Keep
that in mind when writing any new Python test that varies parameters.

## Other Tests

The `tests/` directory contains other subdirectories (`mnh_*`, `python`, `runANN`) for potentially testing specific features like coupled simulations or Python bindings. A main `tests/run.bash` script exists but is not currently fully validated in CI. Refer to specific subdirectories for details if needed.
The `tests/` directory contains other subdirectories (`mnh_*`, `runANN`) for testing specific features like coupled simulations. A main `tests/run.bash` script exists but is not currently fully validated in CI. Refer to specific subdirectories for details if needed.

## Compiler Warnings

Expand Down
40 changes: 37 additions & 3 deletions src/DataBroker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,16 @@ namespace libforefire
}
}
else
if ((model->wantedProperties)[prop].substr(0, 5) == "moist")
// Only the "moist." group, i.e. the five NFDRS-style dead and
// live moistures FarsitePropagationModel registers as
// moist.ones / moist.tens / moist.hundreds / moist.liveh /
// moist.livew. getMoisturesProperties fills five slots, so it
// is only correct for a model that reserved five of them.
// The shorter prefix "moist" also caught the singular
// "moisture", which reserves one slot, so every property a
// model registered after it was written four slots too late
// -- and the last four ran off the end of the array.
if ((model->wantedProperties)[prop].substr(0, 6) == "moist.")
{
if (!moistAsked)
{
Expand Down Expand Up @@ -818,9 +827,33 @@ namespace libforefire
return 5;
}

/*! \brief moisture to use when a model asks for it but no layer supplies it

* Returns the moistures.ones parameter, which is the value such a model
* received before the "moist" prefix fix, so no simulation silently
* changes its numbers. Warns once, because a model that wants a moisture
* field and is given a constant is almost certainly misconfigured.
*/
double DataBroker::fallbackMoisture()
{
static bool warned = false;
double m = params->isValued("moistures.ones")
? params->getDouble("moistures.ones")
: 0.032;
if (!warned)
{
warned = true;
cout << "WARNING: a model requires the 'moisture' property but no "
"moisture layer was loaded; falling back to the constant "
"moistures.ones = " << m << endl;
}
return m;
}

int DataBroker::getMoisture(FireNode *fn, PropagationModel *model, int keynum)
{
(model->properties)[keynum] = moistureLayer->getValueAt(fn);
(model->properties)[keynum] =
moistureLayer ? moistureLayer->getValueAt(fn) : fallbackMoisture();
return 1;
}

Expand Down Expand Up @@ -933,7 +966,8 @@ namespace libforefire
int DataBroker::getMoisture(FFPoint loc, const double &t, FluxModel *model,
int keynum)
{
(model->properties)[keynum] = moistureLayer->getValueAt(loc, t);
(model->properties)[keynum] =
moistureLayer ? moistureLayer->getValueAt(loc, t) : fallbackMoisture();
return 1;
}

Expand Down
2 changes: 2 additions & 0 deletions src/DataBroker.h
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ class DataBroker {
static int getDummy(FireNode*, PropagationModel*, int);
/*! \brief predefined function for getting the fuel parameters at firenode location */
static int getFuelProperties(FireNode*, PropagationModel*, int);
/*! \brief constant moisture used when no moisture layer is loaded */
static double fallbackMoisture();
/*! \brief predefined function for getting the moisture at firenode location */

static int getMoisturesProperties(FireNode *, PropagationModel *, int );
Expand Down
1 change: 0 additions & 1 deletion src/flux/BurnupHeatFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ BurnupHeatFluxModel::BurnupHeatFluxModel(

/* destructor (shoudn't be modified) */
BurnupHeatFluxModel::~BurnupHeatFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/CraterHeatFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ CraterHeatFluxModel::CraterHeatFluxModel(

/* destructor (shoudn't be modified) */
CraterHeatFluxModel::~CraterHeatFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/CraterVaporFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ CraterVaporFluxModel::CraterVaporFluxModel(

/* destructor (shoudn't be modified) */
CraterVaporFluxModel::~CraterVaporFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/FactorChemFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ FactorChemFluxModel::FactorChemFluxModel(

/* destructor (shoudn't be modified) */
FactorChemFluxModel::~FactorChemFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/ForeFireV1HeatFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ ForeFireV1HeatFluxModel::ForeFireV1HeatFluxModel(

/* destructor (shoudn't be modified) */
ForeFireV1HeatFluxModel::~ForeFireV1HeatFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/ForeFireV1VaporFluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,6 @@ ForeFireV1VaporFluxModel::ForeFireV1VaporFluxModel(

/* destructor (shoudn't be modified) */
ForeFireV1VaporFluxModel::~ForeFireV1VaporFluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/HeatFluxBasicModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ HeatFluxBasicModel::HeatFluxBasicModel(

/* destructor (shoudn't be modified) */
HeatFluxBasicModel::~HeatFluxBasicModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/HeatFluxFromObsModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ HeatFluxFromObsModel::HeatFluxFromObsModel(

/* destructor (shoudn't be modified) */
HeatFluxFromObsModel::~HeatFluxFromObsModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/HeatFluxNominalModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ HeatFluxNominalModel::HeatFluxNominalModel(

/* destructor (shoudn't be modified) */
HeatFluxNominalModel::~HeatFluxNominalModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/LavaSO2FluxModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ LavaSO2FluxModel::LavaSO2FluxModel(

/* destructor (shoudn't be modified) */
LavaSO2FluxModel::~LavaSO2FluxModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/ScalarFluxNominalModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ ScalarFluxNominalModel::ScalarFluxNominalModel(

/* destructor (shoudn't be modified) */
ScalarFluxNominalModel::~ScalarFluxNominalModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/ScalarFromObsModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ ScalarFromObsModel::ScalarFromObsModel(

/* destructor (shoudn't be modified) */
ScalarFromObsModel::~ScalarFromObsModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/SpottingFluxBasicModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ SpottingFluxBasicModel::SpottingFluxBasicModel(

/* destructor (shoudn't be modified) */
SpottingFluxBasicModel::~SpottingFluxBasicModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/VaporFluxBasicModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ VaporFluxBasicModel::VaporFluxBasicModel(

/* destructor (shoudn't be modified) */
VaporFluxBasicModel::~VaporFluxBasicModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/VaporFluxFromObsModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ VaporFluxFromObsModel::VaporFluxFromObsModel(

/* destructor (shoudn't be modified) */
VaporFluxFromObsModel::~VaporFluxFromObsModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
1 change: 0 additions & 1 deletion src/flux/VaporFluxNominalModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ VaporFluxNominalModel::VaporFluxNominalModel(

/* destructor (shoudn't be modified) */
VaporFluxNominalModel::~VaporFluxNominalModel() {
if ( properties != 0 ) delete properties;
}

/* accessor to the name of the model */
Expand Down
3 changes: 0 additions & 3 deletions src/propagation/ANNPropagationModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,6 @@ ANNPropagationModel::ANNPropagationModel(const int & mindex, DataBroker* db)


ANNPropagationModel::~ANNPropagationModel() {
if (properties) {
delete[] properties;
}
}

/* accessor to the name of the model */
Expand Down
3 changes: 0 additions & 3 deletions src/propagation/BMapLoggerForANNTraining.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,6 @@ BMapLoggerForANNTraining::BMapLoggerForANNTraining(const int & mindex, DataBroke


BMapLoggerForANNTraining::~BMapLoggerForANNTraining() {
if (properties) {
delete[] properties;
}
if (csvfile.is_open()) {
csvfile.close();
}
Expand Down
4 changes: 4 additions & 0 deletions src/propagation/Balbi2015.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ double Balbi2015::getSpeed(double* valueOf){
double r0 = lsd*lr00;
double A0 = (lX0*lDeltaH)/(4*lCp*(lTi-lTa));
double xsi = ((lMl-lMd)*((lSigmal/lSigmad)*(lDeltah/lDeltaH)));
// See BalbiNov2011.cpp: a dead fuel wetter than the live fuel drives xsi
// negative, amplifying the radiant term and the flame temperature (R00 ~ T^4)
// instead of damping them. Wetter dead fuel must never speed a fire up.
if (xsi < 0.) xsi = 0.;
double A = (nu*A0/(1+a*lMd))*(1-xsi);
double T = lTa + (lDeltaH*(1-lX0)*(1-xsi))/((lstoch+1)*Cpa);
double R00 = (B*T*T*T*T)/(lCp*(lTi-lTa));
Expand Down
7 changes: 7 additions & 0 deletions src/propagation/BalbiNov2011.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ double BalbiNov2011::getSpeed(double* valueOf){
double A0 = (lX0*lDeltaH)/(4*lCp*(lTi-lTa));
/* double xsi = ((lMl-lMd)*((lSigmal/lSigmad)*(lDeltah/lDeltaH))); */
double xsi = ((lMl-lMd)*((Sd/Sl)*(lDeltah/lDeltaH))); // cf. Santoni et al., 2011
// xsi is the share of the 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 both
// the radiant term A and the flame temperature T are amplified rather than
// damped -- and R00 goes as T^4. Wetter dead fuel must never speed a fire
// up; its own penalty is already carried by the 1/(1 + a*Md) factor.
if (xsi < 0.) xsi = 0.;
double A = cosCurv * (nu*A0 / (1 + a * lMd)) * (1-xsi);
double T = lTa + ( lDeltaH*(1-lX0)*(1-xsi) ) / ((lstoch+1)*Cpa);
double R00 = (B*T*T*T*T) / (lCp*(lTi-lTa));
Expand Down
4 changes: 4 additions & 0 deletions src/propagation/BalbiNov2011Curv.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ double BalbiNov2011Curv::getSpeed(double* valueOf){
double r0 = lsd * lr00;
double A0 = (lX0*lDeltaH)/(4*lCp*(lTi-lTa));
double xsi = ((lMl-lMd)*((lSigmal/lSigmad)*(lDeltah/lDeltaH)));
// See BalbiNov2011.cpp: a dead fuel wetter than the live fuel drives xsi
// negative, amplifying the radiant term and the flame temperature (R00 ~ T^4)
// instead of damping them. Wetter dead fuel must never speed a fire up.
if (xsi < 0.) xsi = 0.;
double A = (nu*A0 / (1 + a * lMd)) * (1-xsi);
double T = lTa + ( lDeltaH*(1-lX0)*(1-xsi) ) / ((lstoch+1)*Cpa);
double R00 = (B*T*T*T*T) / (lCp*(lTi-lTa));
Expand Down
4 changes: 4 additions & 0 deletions src/propagation/BalbiNov2011TMdMl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ double BalbiNov2011TMdMl::getSpeed(double* valueOf){
double A0 = (lX0*lDeltaH)/(4*lCp*(lTi-lTa));
/* double xsi = ((lMl-lMd)*((lSigmal/lSigmad)*(lDeltah/lDeltaH))); */
double xsi = ((lMl-lMd)*((Sd/Sl)*(lDeltah/lDeltaH))); // cf. Santoni et al., 2011
// See BalbiNov2011.cpp: a dead fuel wetter than the live fuel drives xsi
// negative, amplifying the radiant term and the flame temperature (R00 ~ T^4)
// instead of damping them. Wetter dead fuel must never speed a fire up.
if (xsi < 0.) xsi = 0.;
double A = cosCurv * (nu*A0 / (1 + a * lMd)) * (1-xsi);
double T = lTa + ( lDeltaH*(1-lX0)*(1-xsi) ) / ((lstoch+1)*Cpa);
double R00 = (B*T*T*T*T) / (lCp*(lTi-lTa));
Expand Down
4 changes: 4 additions & 0 deletions src/propagation/BalbiUnsteady.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ double BalbiUnsteady::getSpeed(double* valueOf){
double r0 = lsd*lr00;
double A0 = (lX0*lDeltaH)/(4*lCp*(lTi-lTa));
double xsi = ((lMl-lMd)*((lSigmal/lSigmad)*(lDeltah/lDeltaH)));
// See BalbiNov2011.cpp: a dead fuel wetter than the live fuel drives xsi
// negative, amplifying the radiant term and the flame temperature (R00 ~ T^4)
// instead of damping them. Wetter dead fuel must never speed a fire up.
if (xsi < 0.) xsi = 0.;
double A = (nu*A0/(1+a*lMd))*(1-xsi);
double T = lTa + (lDeltaH*(1-lX0)*(1-xsi))/((lstoch+1)*Cpa);
double R00 = (B*T*T*T*T)/(lCp*(lTi-lTa));
Expand Down
Loading
Loading