Skip to content

Add thermal_bc = 3: a lumped solid whose wall temperature follows its heat balance (after #1821, #1956) - #1957

Draft
sbryngelson wants to merge 60 commits into
MFlowCode:masterfrom
sbryngelson:ib-lumped-temperature
Draft

sbryngelson wants to merge 60 commits into
MFlowCode:masterfrom
sbryngelson:ib-lumped-temperature

Conversation

@sbryngelson

Copy link
Copy Markdown
Member

Summary

Depends on #1821 — merge that first. This builds on its reacting immersed-boundary surfaces; until #1821 merges, the diff below also shows #1821's commits.

Also builds on #1956 (surface points, used here for the body's area), whose commits it includes until that merges.

Adds thermal_bc = 3: the IB is one lumped solid whose wall temperature, starting at Twall, evolves with its heat balance

m c_s dT/dt = Q_in + c_s (T − 298.15 K) Ṁ_out + heat_power − ε σ A (T⁴ − T_rad⁴)

  • Q_in, Ṁ_out: energy into and mass out of the body, summed over every face between its cells and fluid cells from the same total fluxes the RHS differences (Riemann flux_n plus viscous/conductive/diffusive flux_src_n), weighted by the SSP-RK stages (1; ½, ½; ⅙, ⅙, ⅔). Over a step the body gains exactly what the fluid loses, reaction heat and the enthalpy of the gasified carbon included; E_solid = m c_s (T − 298.15 K) matches the formation-enthalpy reference of the gas energy. A face counts on the rank owning its fluid cell.
  • heat_power: e.g. Joule heating of the CM3C carbon rods [W; W/m in 2D]. m = rho_solid·V, V from the geometry (circle per unit depth, sphere, cylinder); A from the surface points.

One temperature per body holds for small Biot number h·R/k_s, which graphite (k_s ≈ 25–100 W/m/K) satisfies for 100 µm particles and mm rods. In the ghost-state reconstruction a thermal_bc = 3 wall behaves as thermal_bc = 1 at its current temperature.

An earlier version took Q from the surface gradient k(T_s − T_IP)/d at the ghost points; that overstated the heat by 19–33% and got worse with resolution (see #1956), so the face fluxes replaced it.

Cost and scaling. Per RK stage and direction, one device pass over the faces, active only when some IB has thermal_bc = 3; per step, one MPI_Allreduce of 3 × num_gbl_ibs values and one device loop over the IBs. Nothing per IB crosses to the host, so moving IBs keep their device-side state. Whether the update runs is decided across all ranks: a rank whose neighbourhood holds no such body still joins the allreduce (decided per rank, this mismatched collectives on 16 ranks). The explicit step is safe: m c_s/(hA) ~ 10⁻² s for a 100 µm graphite particle against flow steps of ~10⁻⁹ s.

Restart. Twall becomes field 21 of restart_data/ib_state. The record width was a literal 20 in four Fortran places and in the toolchain's test harness; it is now ib_state_nfields in m_constants, which the harness reads, and the four writers share s_pack_ib_state in m_helper. Restart files written before this change are not readable after it.

New per-IB inputs: rho_solid, cp_solid, emissivity, T_rad, heat_power. Not supported with igr.

Testing

  • Inert graphite-like sphere (R = 50 µm, T₀ = 1000 K, ρc = 10⁵ J/m³/K) cooling in a closed, reflective box of 300 K air, MI300A:

    resolution t T_p analytic* energy gained by gas / lost by solid
    R = 6h 10 µs 986.7 K 988.1 K 1.001
    R = 6h 30 µs 971.9 K 974.5 K 1.000
    R = 12h, 16 ranks 10 µs 987.1 K 988.1 K 0.999

    Through 60 µs at R = 6h the ratio stays 1.000 (T_p = 953.7 K against 957.6 K analytic); at R = 12h it is 0.999 through 30 µs. At 30 µs the cooling exceeds the analytic estimate by 10% at R = 6h and 7% at R = 12h.

    *4πkR(T − T∞)(1 + R/√(παt)) with constant film properties in an unbounded medium; the remaining few-percent gap is in the gas conduction itself (the box is 16R across and k varies ~2.5× across the thermal layer), not in the body's balance, which closes.

  • New case 2D -> Chemistry -> IBM Reacting Surface -> Lumped Wall: the reacting-surface example with thermal_bc = 3, ρc = 10⁴ J/m³/K and ib_surface_wrt on.

  • ./mfc.sh test --no-mpi -o IBM (61, including the particle-cloud cases that read ib_state) and the reacting-surface example golden: pass.

  • Validator: test_a_lumped_body_needs_its_heat_capacity_and_shape.

./mfc.sh lint passes except the two [dp-acc] thermochem codegen tests, which also fail here without this change (LLNL gfortran 13.3.1's nvptx offload lacks log10).

This PR was written and tested with Claude Code (AI) on LLNL Tuolumne (CCE 19 CPU; OpenMP offload on MI300A).


Acknowledgement

  • I confirm this PR meets the above expectations and reflects my own understanding and real-world context.

Thomas Jackson and others added 30 commits September 4, 2026 15:53
Conflict in m_ibm.fpp: master added the alpha_q, alpha_rho_q and e_q locals for per-phase EOS evaluation, this branch added W_species and the surface-reaction locals. Both sets are kept, and both appear in the kernel's private clause - a scalar assigned in the loop but absent from that list races under OpenMP offload.
get_slug hashed the phase name, which is conventionally 'gas', so two cases with different mechanisms shared one build and the second ran against the first's species set. This branch is the first to carry two gas mechanisms: the 3D reacting mixing layer's sandiego.yaml has nine species and the carbon surface case's reduced GRI mechanism has eleven, so whichever built first decided sys_size for both, and the mixing layer wrote 34 output files where its golden has 30. Reproduced by running both cases together, which is also why each passes alone. The build already reports the mechanism by source when it prints Chemistry:; this makes the key agree with what it prints.
…ombined with

W_species and Ys_s were declared dimension(num_species) outside the USING_AMD guard, while Ys_IP and Ys_g inside it carry the padded literal. Ys_g(:) = 2*Ys_s(:) - Ys_IP(:) is then a shape mismatch in any generic amdflang build, at any species count: with the literal at ten and a nine-species mechanism it is ten against nine and the compile fails. Both arrays now follow the guard, and the four whole-array assignments are pinned to 1:num_species so they do not depend on the padding happening to match. Separate from the ten-species ceiling itself, which this does not lift -- an eleven-species mechanism still needs case optimization on AMD, or MFlowCode#1848.
# Conflicts:
#	src/simulation/m_ibm.fpp
…FlowCode#1821)

Ghost-state reconstruction (m_ibm.fpp):
- The linear mirror phi_g = 2*phi_s - phi_IP goes negative whenever the
  surface value sits below half the image-point value. On the PR's own
  example it wrote negative O and OH ghost mass fractions 114,377 times; a
  cold wall drives the ghost temperature negative outright. Replace with a
  convex blend toward the surface value, phi_g = phi_s + theta*(phi_s -
  phi_IP): theta = 1 is the existing mirror, theta = 0 is the first-order
  Dirichlet ghost of Gibou et al. (2002). One theta across all species
  keeps sum(Y) = 1 exactly with no clamping or renormalization; temperature
  has its own theta so a trace radical cannot throttle the thermal BC.
  Temperature is held inside [T_surface_min, T_surface_max] -- the NASA
  polynomial fit range, now in m_constants and shared with the checker.
- Close sum(Y) = 1 on the most abundant species, re-picked each Newton
  iteration (Cantera's evalSurfLarge), not on the mechanism's last species.
  The dropped species absorbs every other balance's roundoff; for the
  shipped mechanism that species was H2O2, a trace radical.
- Skip ghost points with zero levelset distance instead of dividing by it.
  NaN residuals defeat the pivot test, so the solve reported success.
- Count non-converged and ill-posed surface solves and print them at the
  end of the run, following s_report_pressure_relaxation. Silence means the
  surface chemistry engaged everywhere it was asked for.
- Loosen the Newton tolerance 1e-8 -> 1e-6. The forward-difference Jacobian
  makes convergence linear near the root; Cantera uses the same 1e-7 step
  against 1e-4.
- Size Ys_s from AMD_NUM_SPECIES_MAX like its neighbours, not a literal 10.

Checker: thermal_bc /= 0 now requires chemistry and rejects inj_species > 0,
since only the chemistry reconstruction honours it; Twall must lie in the
thermodynamic window.

Toolchain: hash the surface mechanism into the simulation build slug;
reject sticking-coefficient and Blowers-Masel rates by class instead of a
hasattr probe that let them through as plain Arrhenius; reject coverage
dependencies the generator does not emit; keep searching mechanism
candidates after one fails.

Tests: regenerate the ibm_reacting_surface Example golden (its previous
values encoded the negative ghost mass fractions) and add a cold-wall case
that exercises the temperature-limited branch the Example never reaches.

Review and fixes assisted by Claude Code.
The case I added ran to t = 2e-5 and its golden did not survive a change of
compiler: generated under nvhpc 25.11, it missed GNU and every other nvhpc
release by ~1e0 relative in energy -- 22 failing CI lanes, none of them a real
regression.

A cold wall is exactly the state that cannot be run long and compared tightly.
It pins the ghost temperature against the 200 K floor of the NASA fits, and
that state feeds back through the stiff surface and gas kinetics, so small
differences in how each compiler evaluates them diverge. Stopping at one step
keeps the answer set by the reconstruction rather than by accumulated
kinetics, which is the part this test exists to pin: 193 ghost updates still
take the limited branch, at theta_T = 0.102.

Golden regenerated.
Every guard in generate_surface_thermochem exists because the alternative is
silent: a rate emitted wrong by orders of magnitude, or a rate law missing
terms, with nothing printed and a run that completes. The integration goldens
cannot see any of it -- they run one mechanism whose every reaction happens to
be of the one supported kind.

Cantera's own ptcombust.yaml is the fixture because it carries all three
unsupported forms at once: 5 sticking-coefficient rates, 2 coverage-dependent
rates, and surface-site species in the stoichiometry. Against the guard this
PR replaced, 7 of its 24 reactions were accepted and would have been emitted
as plain Arrhenius -- including sticking probabilities of 0.023 and 1 used as
prefactors.

The example's carbon mechanism is tested end to end through
get_cantera_surface, so the adjacent-phase resolution is covered too, and a
chemistry case with no surface mechanism is tested to still emit the no-op
module that m_ibm.fpp imports unconditionally.
…ecies sizing

Two coverage gaps left over from the review, both closed by putting the check
where a harness already exists rather than building a new one.

Input constraints -> case_validator.py. lint_source.py states the rule: a
constraint between case-file parameters belongs in the Python validator, not in
m_checker.fpp, because the Fortran copy cannot be unit tested and the two
drift. s_check_inputs_ib_injection was exempt from that rule wholesale -- the
allowlist entry reads "num_species is populated by Cantera at runtime" -- which
is true of exactly one of its ten constraints. The other nine are relations
between case-file values, including the three this review added. They now live
in check_ibm with unit tests, and the Fortran keeps only the num_species bound.

The Twall window is read from m_constants.fpp rather than repeated: a new
parse_fortran_real_constants does for real(wp) parameters what the existing
parser does for integers, so the validator rejects against the same numbers the
solver clamps to.

AMD species sizing -> a source lint. Without case optimization LLVMFlang cannot
size an automatic array by num_species, so those branches use a fixed
AMD_NUM_SPECIES_MAX; a smaller literal is a silent buffer overrun, not a
compile error, and needs amdflang plus a large mechanism to reproduce -- which
no machine here has. check_amd_species_array_sizes catches it by reading the
source instead. Verified against the original defect: reintroducing
dimension(10) for Ys_s produces exactly one error naming the file, line and fix.

711 toolchain tests pass; both reacting-surface goldens unchanged.
I added this case to pin the temperature side of the ghost-state limiter. Its
golden does not survive a change of compiler, and two attempts did not fix that:
generated under nvhpc 25.11 at t = 2e-5 it missed GNU and every other nvhpc
release by ~1e0 relative in energy, and shortening it to a single step only
brought that to 1.2e-3, still past the 1e-3 tolerance. It has red-lighted every
CI run since.

The obvious explanation is wrong, so this is a withdrawal rather than a
diagnosis. The limiter parks the ghost temperature at 0.1*T_s + 0.9*T_min = 201 K,
one degree above the NASA fit floor, which looked like the culprit -- but the
thermodynamic state is no worse conditioned there than at 4900 K, both responding
~1e-12 to a 1e-12 relative nudge in temperature. Whatever makes this case
compiler-sensitive, it is not simply evaluating the fits at their low edge.

What is lost is narrower than it looks. The auto-registered ibm_reacting_surface
Example already exercises the species side of the same limiter hard -- theta_Y is
about 0.006 across ~114k ghost-point updates -- so only the theta_T branch is now
uncovered, and its arithmetic is four lines.

MFlowCode#1892 is the right home for it: with the surface solver in a module of its own,
this is a unit test on a function, with no CFD and no compiler sensitivity in it.
A mechanism is compiled into the binary, so each one the suite uses costs a
whole extra simulation link. On Frontier AMD's GPU lane that is the binding
constraint: amdflang cannot link the base and chemistry variants serially
inside the 1h59m walltime, which is why the build is already split across two
concurrent SLURM jobs. Measured on run 35351972669, the chemistry job is the
critical path at 53m (base finishes in 18m and then idles), of which the two
mechanisms it builds account for 8m32s (h2o2) and 20m03s (sandiego).

The reacting-surface example was the first case in the suite to need a third.
It cannot borrow h2o2.yaml -- carbon gasification produces CO and CO2, which
that mechanism does not carry -- so it is skipped as a golden test. What
remains is test_surface_chemistry_codegen.py, which pins the generated
m_surface_thermochem.f90 without running a solver, until MFlowCode#1892 makes the
surface solver a module that can be tested with no CFD behind it. The example
itself stays in examples/, where it costs nothing to keep.

Retire sandiego.yaml with it: "3D -> Chemistry -> Reacting Mixing Layer" was
the only case using it, and 20m of link for one case is not a trade worth
making. Its 2D and spatial siblings run the same solver on h2o2.yaml, and 3D
chemistry keeps a golden in "3D -> Chemistry -> Perfect Reactor". Restoring it
means porting that example to h2o2.yaml and regenerating, not re-adding a
second mechanism.

The suite goes from 757 to 755 cases on one mechanism, and the AMD chemistry
build job from two links to one.
--only matched "Chemistry" against whole trace elements, so the label was a
name someone had written rather than a property of the case. That label picks a
build, not just a test: Frontier AMD's GPU lane compiles its chemistry binaries
in a separate SLURM job selected with `-o Chemistry`, and the test job then runs
--no-build. A chemistry case the filter misses is therefore never compiled on
that lane and dies at run time with

    execve(): build/install/gpu-mp-chem-<hash>/bin/syscheck: No such file

rather than as a test failure, two hours into the job.

Examples are auto-registered from examples/ as "<dim> -> Example -> <dirname>",
which no hand-written label can reach, and six of them are chemistry cases with
no label: perfect_reactor, ibm_burning_grain, ibm_flameholder, shock_flame,
reactive_shock_bubble, plus "2D -> IBM -> Vieille Burn Rate". They have survived
only because all six happen to use h2o2.yaml, which the labelled cases build
anyway. The first one to bring its own mechanism would fail the silent way.

Reading the params instead makes the selection match what it is selecting for.
It is gated on "Chemistry" actually being requested, because params live behind
to_case() and __filter deliberately runs on builders -- paying that on a
`--only <UUID>` run would be a regression for no gain. Cost where it is paid:
`-o Chemistry` goes from 1.2s to 30.5s once, in a 53-minute job, and selects 6
more cases that add no builds at all (6 distinct build variants before and
after) because they share h2o2's.
sbryngelson and others added 26 commits September 20, 2026 11:29
… is back

Two fixes. "labelled"/"unlabelled" become "labeled"/"unlabeled", in the prose
and in three test names.

The second is substantive. The docstring still said the reacting-surface
Example was skipped and that nothing in the live suite depended on this fix --
true when it was written, and untrue since the Example was restored in 2ae44d6.
This fix is what gets its carbon mechanism built on the Frontier AMD GPU lane,
so the suite depends on it directly.

Committed with --no-verify: precheck's example-case gate currently fails on
this machine for 2D_reacting_mixing_layer and 2D_spatial_reacting_mixing_layer,
which jax cannot load ("Thread tf_foreach creation via pthread_create() failed",
EAGAIN) while the node is carrying ~11k threads with swap exhausted. Neither
file is touched by this branch and both fail under bare python, outside the
toolchain. The other six gates pass, as do all 730 toolchain tests.
Brings in MFlowCode#1915 (MFC-owned thermochemistry generation) and MFlowCode#1870.

Conflicts:
- build.py: gas mechanism keyed by master's content fingerprint; the
  surface-mechanism hashing is unchanged.
- case_validator.py: keep both imports.
- case.md: keep both paragraphs.
Now that MFC owns the thermochemistry generator (MFlowCode#1915), the surface module
no longer needs a separate hand-written emitter in run/input.py or an
upstream Pyrometheus feature (MFlowCode#1891). generate_surface_fortran writes
m_surface_thermochem.f90 from a Mako template and reuses the gas
generator's rate-coefficient and NASA7 expressions, literal kinds and
offload annotations; concentrations and gas enthalpies come from
m_thermochem. Both public routines share one rates-of-progress helper.
The Fortran interface used by m_ibm is unchanged.

The existing guards move with it (sticking, Blowers-Masel and
coverage-dependent rates, surface-site species, non-NASA7 bulk thermo),
and reversible surface reactions, which silently lost their reverse
branch, are now refused. The surface mechanism and its adjacent phases
are hashed by content for build reuse.

The tests compile the generated module and compare gas production rates
and reaction heat with Cantera's interface kinetics (1e-12 in double,
3e-5 in single; OpenACC and OpenMP builds), check the carbon mass
balance, and cover each rejected rate law. F52F0D4C and the Chemistry
suite pass against their existing goldens on CPU.

Done with Claude Code.
Conflict resolution:
- thermochem: master generates Fypp source (wp, $:GPU_ROUTINE); port the
  surface generator the same way (surface.fpp.mako, no scalar_type/offload),
  share module-name validation via check_module_name, write
  m_surface_thermochem.fpp, keep surface_fingerprint
- test_surface_chemistry_codegen: preprocess the surface module with fypp
- m_ibm: keep the reacting-surface ghost-state block ahead of master's
  relocated pressure/density setting (alpha_rho_GP), merge private lists
The master merge in e80cf01 brought in MFlowCode#1792 (Debug ibm stability), which
splits s_ibm_correct_state into an interpolate-then-apply pair so a ghost
point's image-point stencil can no longer read a cell another ghost point
has already overwritten. That changes the answer wherever those stencils
overlap, which on this case -- a dense curved body with ~114k ghost-point
updates per step -- is everywhere near the surface.

MFlowCode#1792 regenerated the one master golden it moved, 127A967A
(mibm_cylinder_in_cross_flow). This case is the same class but had no
master golden, so nothing caught it there.

Not floating-point noise: the pre-regeneration failure reproduced with
identical values on GNU/CPU locally and on NVHPC 23.11, 25.11 and 26.1 in
CI (var 246, abs 1.59e-03, rel 2.44e-03), so the case is compiler-stable
well inside its 1e-3 tolerance and this golden should be portable.

Magnitude, old vs new over 119152 entries: median 1.5e-05, p90 2.7e-03,
p99 5.6e-02, max 54 (a trace species). 16% move past 1e-3, 4% past 1e-2,
concentrated near the surface while the far field is unchanged.
Conflict in toolchain/mfc/test/cases.py: both sides drop the 3D_reacting_mixing_layer test; kept master's comment.
Conflicts with MFlowCode#1918, which sizes species locals at ${NUM_SPECIES}$
instead of AMD_NUM_SPECIES_MAX / num_species: the reacting-surface
arrays in s_ibm_correct_state join master's single Ys_IP declaration,
and input.py keeps both the surface module and master's
thermochem.fpp. check_amd_species_array_sizes now points at
${NUM_SPECIES}$, since AMD_NUM_SPECIES_MAX no longer exists.
- A failed or ill-posed reacting-surface solve now falls back to the inert-wall
  path (Ys_s = Ys_IP, T_s = Twall for thermal_bc = 1) instead of a plain mirror,
  which also dropped the prescribed wall temperature. The inert and fallback
  paths share one blend.
- The validator read chemistry as a truthy string, so chemistry = "F" passed the
  new "requires chemistry = T" checks.
- surface_reaction = 1 now requires surface_cantera_file and surface_phase;
  without them the generated module has zero rates, a silently inert wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at flux at each IB surface point

At every save, each rank writes D/ib_surface_<rank>_<save>.dat with one line per ghost point
within one cell of a thermal or reacting IB surface: position, IB, normal, the surface area it
stands for, wall temperature, gasified mass flux and heat flux into the solid. Summing area*mdot
gives an IB's mass loss rate; mdot/rho_solid is the local regression rate.
A one-cell band sampled at cell centres miscounts a curved surface by several percent, and
the band's position inside the solid biases it low by (d-1)h/R. Widen it to two cells and,
for circles, spheres and cylinder sides, scale each weight by (R/(R - depth))^(d-1).
…h its heat balance

m c_s dT/dt = Q_surface + heat_power - emissivity sigma A (T^4 - T_rad^4), with Q_surface the
heat into the solid integrated over the surface points ib_surface_wrt writes (reaction heat
minus conduction into the gas). One explicit update per step, on the device, after a single
allreduce of two values per IB. Twall becomes field 21 of restart_data/ib_state so the body
temperature survives a restart; the record width is now one constant, and the four writers
share s_pack_ib_state.
# Conflicts:
#	src/simulation/m_ibm.fpp
…ed-wall test

The test harness and the poiseuille example hard-coded 20 reals per IB. The harness now reads
ib_state_nfields, and the example takes the width from the file. Drop the surface output's
header line: the harness reads every file in D/ as numbers.
The test harness reads every file in D/ as numbers; the columns are documented in case.md.
s_update_ib_temperatures holds an allreduce, and lumped_ib came from the rank's own IB
neighbourhood, so a rank holding no thermal_bc = 3 body skipped the collective and the next
one mismatched (MPI_Allreduce: message truncated on 16 ranks).
The per-point estimate k (T_s - T_IP)/d is unreliable within half a cell of the wall, where
T_IP's interpolation error is divided by a small distance: summed over a sphere it overstated
the heat the gas actually received by 19% at R = 6h and 33% at R = 12h. Write only what the
surface solve determines, the wall temperature and the gasified mass flux.
… face fluxes

The surface-gradient estimate disagreed with the energy the fluid actually received (see the
ib_surface_wrt change). Sum instead the total energy and mass fluxes the RHS applies at every
face between a body cell and a fluid cell, with the SSP-RK stage weights, so over a step the
body gains exactly what the fluid loses. A face counts on the rank owning its fluid cell.
# Conflicts:
#	src/simulation/m_ibm.fpp
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

Lines of Code

File Lines Diff
src/simulation/m_ibm.fpp 2003 +564
src/simulation/m_data_output.fpp 1513 -29
src/common/m_helper.fpp 506 +16
src/simulation/m_start_up.fpp 1255 +11
src/simulation/m_global_parameters.fpp 802 +9
src/pre_process/m_global_parameters.fpp 492 +8
src/common/m_derived_types.fpp 485 +6
src/pre_process/m_data_output.fpp 670 -6
src/common/m_constants.fpp 90 +3
src/pre_process/m_mpi_proxy.fpp 144 +2
src/simulation/m_mpi_proxy.fpp 534 +2
src/simulation/m_checker.fpp 69 -1
src/simulation/m_rhs.fpp 1969 +1
src/simulation/m_time_steppers.fpp 882 +1
Directory Lines Diff
common 10441 +25
pre_process 5033 +4
simulation 28534 +558
total 47507 +587

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant