Skip to content

Give structure models a read-only view of the fire (StructureStep::fire) - #5018

Open
hgopalan wants to merge 2 commits into
erf-model:developmentfrom
hgopalan:structure-fire-view
Open

hgopalan wants to merge 2 commits into
erf-model:developmentfrom
hgopalan:structure-fire-view

Conversation

@hgopalan

@hgopalan hgopalan commented Nov 9, 2026

Copy link
Copy Markdown
Contributor

Related to #5009

Summary

A structure model (erf.structures.model, added in #5009) can read the flow but not the fire. To heat power-line conductors and towers from a fire, a model needs the fire's heat release and burned area at its own points.

This PR adds an optional, read-only view of the fire to the structure seam: StructureStep::fire. It holds the fire grid's geometry, the level the fire grid refines, the time of the fire's fields, and the engine's existing getters heat_release() and level_set(). It is nullptr when the run has no fire. ERF itself still contains no structure model, and no fire physics is added here. The models that use the view live outside ERF, as the fire engines do.

The fire and the model may sit on different levels. The view states which fire step its fields belong to, and three new CTests check this for the three layouts.

Runs without a structure model are bit-identical. No existing gold file or test result changes. Structures_ExampleModel gains one check (no fire view without a fire).

Theory

Which fire step a model sees. Within one step of a level, ERF advances the structure model first, then the dycore, then the fire (ERF_Advance.cpp). A finer level takes its substeps after the coarse step. With $t$ the start of the model's step, $t_f$ the time the fire's fields belong to and $\Delta t_c$ the step of the fire's level when it is coarser:

$$t_f = \begin{cases} t, & \text{fire on the model's level or a finer one},\\\ t_0 + \Delta t_c, & \text{fire on a coarser level, } t \in [t_0,\ t_0 + \Delta t_c). \end{cases}$$

What the tests compare. The example fire engine burns a disk of radius $r(t) = r_0 + v,t$ that releases $q_0$ inside it. With one substep per fire step of length $\Delta t_f$, the heat released $Q$ and the burned area $A$ at $t_f$ are

$$Q = q_0\,\pi\, r\!\left(t_f - \tfrac{1}{2}\Delta t_f\right)^2, \qquad A = \pi\, r(t_f)^2 ,$$

with $r_0$ = 150 m, $v$ = 50 m/s and $q_0$ = 5e4 W/m². $Q = 0$ before the fire's first step.

Why a 3 % tolerance. Fire cells of 10 m resolve these disks to 1.4 % or better wherever the disk sits on the grid. Fields one fire step late or early miss by 11 % or more.

Changes

1. The fire view (Source/MovingBodies/Coupling/ERF_StructureModel.H:98, :128)

A new struct StructureFire and a new last member StructureStep::fire. The struct uses AMReX types only, so the seam header still includes no fire header.

Member Meaning
geom Geometry of the fire grid [m], one cell in z
lev The atmospheric level the fire grid refines (erf.fire.anchor_level)
time End of the fire's last step [s]; before its first step in this run, the time ERF started or restarted from
heat_release FireEngine::heat_release() [W/m²], or nullptr
level_set FireEngine::level_set(), negative where burned, or nullptr

2. Building the view (Source/MovingBodies/Coupling/ERF_StructureCoupling.cpp:87)

ERF::advance_structure_model fills the view under ERF_USE_FIRE when a fire coupler exists, and passes nullptr otherwise.

3. The fire step's time (Source/FireCoupling/ERF_FireCoupler.H:206, ERF_FireCoupler.cpp:189)

FireCoupler::stepped() and step_end_time() record when the engine's last advance() ended. advance() now takes ERF's double time and passes it to the engine as Real, as before. In single precision the view's time then matches the model's step time exactly.

4. Example engine (Tests/FireEngineExample/ERF_FireEngineExample.cpp:126, :139)

heat_release() returns the whole flux of the last step, before the sensible/latent split. The engine now writes its last step's start and length to its checkpoint and rebuilds that step's fluxes on a restart, so heat_release() after a restart equals the straight run's.

5. Example model (Tests/StructureModelExample/ERF_StructureModelExample.cpp:42)

When step.fire is set, the model prints each step the fire's level and time, the heat released (MultiFab::sum) and the burned area (ReduceSum over the level set). It is a free function, because nvcc does not accept a device lambda inside a private member function.

6. Tests (Tests/CTestList.cmake:5049) and docs (Docs/sphinx_doc/theory/StructureCoupling.rst:79)

Three CTests, built only when both example directories are compiled in (the particles = ON leg of ci.yml). A new section "Reading the fire" describes the view, its timing, its restart behaviour and its lifetime.

No new input keys.

Results

check_fire_view.awk compares each printed line with the disk. 2 ranks, fire cells 10 m (5 m on level 1).

Test Fire level Model level $t_f$ Largest error vs the disk
Structures_ExampleModel_FireView 0 0 $t$ 1.29 %
— restart from chk00002 (t = 1 s) 0 0 $t$; first line $t_f$ = 1 s, $Q$ = straight run's same as straight run
Structures_ExampleModel_FireViewFinerFire 1 (patch) 0 $t$ 0.25 %
Structures_ExampleModel_FireViewFinerModel 0 1 (patch) end of the coarse step 1.15 %

Mutants, each one line, each run against the four structure tests above plus Fire_ExampleEngine_Restart:

Mutant Caught by
step_end_time = start of the step all three fire-view tests
nullptr passed instead of the view all three
view time ignores the fire step FireViewFinerModel
heat_release() returns the sensible part only all three
restart fallback time 0 instead of the step's time FireView (restart leg)
engine does not rebuild its fluxes on restart FireView (restart leg)

Test(s)

  • CMake, Release, MPI, ERF_ENABLE_FIRE=ON, ERF_ENABLE_MOVING_BODIES=ON, both example directories compiled in: ctest -R 'Structures|Fire_|FireCoupl|StructureRegistry' 38/38 passed.
  • The three fire-view runs also pass with amrex.the_arena_init_size=0 amrex.init_snan=1 fab.do_initval=1 and FPE traps (invalid, zero, overflow), with one box per rank (fabarray.mfiter_tile_size = 1024 1024 1024) and with tiling.
  • Debug build with ERF_ENABLE_FIRE=OFF and ERF_ENABLE_MOVING_BODIES=ON compiles with no warnings.
  • Sphinx builds with no warnings. codespell and git diff --check are clean.
  • Not built here: CUDA, HIP, SYCL and single precision. The GPU code is one ReduceSum in the example model.

Not in this PR

  • The model that uses the view (conductor temperature from IEEE 738, thermal sag, tower steel temperature). It goes to erf-model/FLARE, behind this seam.
  • A fire grid that follows a regrid of its level. This was already the case before this PR.

🤖 Generated with Claude Code

hgopalan and others added 2 commits November 8, 2026 21:36
A structure model could read the flow but not the fire. StructureStep now
carries an optional StructureFire: the fire grid's geometry, the level it
refines, the time of the fire's fields, and the engine's heat_release() and
level_set(). It is nullptr without a fire. FireCoupler records when its
last step ended, so the view states which fire step the fields belong to.

The example fire engine returns its whole flux as heat_release(); the
example structure model prints what the view holds each step. Three CTests
check it against the engine's growing disk: both on level 0; the fire on a
level-1 patch with the model on level 0; the fire on level 0 with the model
on a level-1 patch. Structures_ExampleModel also checks that a run without
a fire gets no view.

No change to runs without a structure model; FireCoupler's two new members
are written in advance() only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- report_fire is a free function (nvcc rejects a device lambda in a private member)
- FireCoupler keeps the fire step's end in double, as ERF's time
- the example engine checkpoints its last step and restores its fluxes, so
  heat_release() after a restart matches the straight run; FireView gains a
  restart leg that checks the view's time and heat right after the restart
- docs and comments: restart wording, lifetime rule, erf.fire.engine, the
  11 % / 1.4 % numbers, the checker's limits; heat_flux pinned in the decks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@asalmgren asalmgren left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review comments below. No crash-level bugs found. The main point is that the fire view's time doesn't exactly match the model's step time in a single-precision build, or when the fire is on a finer level (first two comments).

z_phys_cc, T_atm_k0, RH_atm_k0, precip_accum_k0.get()};
m_engine->advance(forcing);
m_stepped = true;
m_step_end_time = time_d + static_cast<double>(dt);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In single precision dt here is the Real (float) dt, while ERF advances time as t_new = time + dt[lev] with dt[lev] in double. With a dt that float can't represent exactly (e.g. 0.1), step_end_time comes out as t + 0.100000001490116 while the model's step.time is t + 0.1, so fire->time == step.time is false even though the PR description says they match exactly. The CTests don't catch this because dt = 0.5 and 0.25 are exact in float. Suggest passing ERF's double dt_lev into advance() (or making the dt parameter double) and computing the end time from that.

post_dycore ? yvel_new : yvel_old,
z_phys_cc, T_atm_k0, RH_atm_k0, precip_accum_k0.get()};
m_engine->advance(forcing);
m_stepped = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the fire sits on a finer level than the structure model, this end time is built by adding up the fine substeps, while the coarse model's time is time + dt[lev] in one addition (and ERF then resets t_new[lev+1] to that). Even in double they can differ by round-off: with dt_ref_ratio=3 and dt0 = 0.1, three adds of 0.0333... give t + 0.30000000000000004 vs t + 0.3. Storing the time ERF assigns to the level (t_new[m_lev]) instead of summing would avoid the drift.

#ifdef ERF_USE_FIRE
if (m_fire_coupler) {
const FireEngine& engine = m_fire_coupler->engine();
const double fire_time = m_fire_coupler->stepped() ? m_fire_coupler->step_end_time() : time;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before the fire's first advance(), this falls back to the model's current step time time, not the time the fire's fields belong to. That's only right if the fire's level has been advanced every step since start/restart. If it hasn't stepped yet (e.g. right after a regrid or restart, or a level-0 model several coarse steps in before the fire level advances), fire->time reports t while heat_release/level_set still hold the state from t0, so the model treats stale fields as current.

FireGrid m_fg;
int m_lev = 0;
amrex::Geometry m_geom_atm; ///< geometry of the fire grid's level
bool m_stepped = false; ///< advance() was called in this run

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible simplification: instead of m_stepped plus the stepped() ? step_end_time() : time fallback in each consumer, set m_step_end_time in FireCoupler::initialize (and on restart) to the start/restart time (t_new[fire_lev]). Then it always means "time of the engine's fields", m_stepped/stepped() go away, and the fallback in advance_structure_model (and the issue noted there) goes with them.

std::istringstream is(std::string(buf.dataPtr()));
is >> m_time;
is >> m_time >> m_step_start >> m_step_dt;
if (!is) { Abort("[FIRE] fire.example: cannot read " + restart_chkfile + "/FireEngineExample"); }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now requires m_step_start and m_step_dt and aborts if they're missing, so checkpoints written by the example engine before this PR (which hold only m_time) can no longer be restarted from. Reading the two new fields as optional (defaulting m_step_dt to 0 if absent) would keep old checkpoints working.

Real heat = Real(-1.0), burned = Real(-1.0);
if (f.heat_release) { heat = f.heat_release->sum(0) * dA; }
if (f.level_set) {
burned = dA * amrex::ReduceSum(*f.level_set, 0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hand-rolls a ReduceSum plus ParallelDescriptor::ReduceRealSum to count burned cells, but FireEngine::burned_area() already computes the burned area across ranks. Since this file is the reference example, out-of-tree models will copy this pattern (including the device-lambda workaround). Exposing burned_area in StructureFire, or calling the engine's version, would avoid the duplication.

}

private:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: stray blank line added after private:, unrelated to the change.

Sign up for free to 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