Skip to content

Add a per-fluid equation-of-state selector - #1700

Open
fahnab666 wants to merge 12 commits into
MFlowCode:masterfrom
fahnab666:feature/eos-selector-v2
Open

Add a per-fluid equation-of-state selector#1700
fahnab666 wants to merge 12 commits into
MFlowCode:masterfrom
fahnab666:feature/eos-selector-v2

Conversation

@fahnab666

@fahnab666 fahnab666 commented Jul 31, 2026

Copy link
Copy Markdown

Adds fluid_pp(i)%eos, a stable enumeration for a fluid's equation of state, in place of the implicit backend selection. This is step 2 of the incremental EOS path tracked in #1638 ("add EOS selection and integrate the existing Pyrometheus path"). Note that step 1 — centralizing thermodynamic operations, attempted in #1663 — has not landed, so this selector arrives before the interface it will eventually dispatch through. That ordering is the reason nothing in src/ reads %eos yet; see Scope below.

Only the two backends we already support are accepted:

  • stiffened_gas (default)
  • ideal_gas_mixture (chemistry builds, via Pyrometheus)

The enum holds exactly these two. Values for backends that do not exist yet are added alongside their backend rather than reserved up front, so the auto-generated "Valid values" hint never advertises a selector that check_eos will reject.

ideal_gas_mixture is the Pyrometheus mixture backend and is only valid in a chemistry build. On a non-chemistry build it is rejected, not silently downgraded. A plain ideal gas without chemistry is already covered by stiffened_gas with pi_inf = 0, which reduces the stiffened-gas law to the ideal-gas law, so it does not need its own enum value.

The default resolves from the compile-time chemistry flag, so existing cases do not change and the golden suite stays bit-identical (616/616, no goldens regenerated). The selector is validated at input time only, so nothing new enters the per-cell hot path. Case files use the readable names while the integer stays internal. The Fortran and Python checkers both reject unsupported values, ideal_gas_mixture on a non-chemistry build, and mixing EOS families within one run.

Both checkers cover every fluid_pp slot up to num_fluids_max, not just num_fluids: each slot is default-assigned and MPI-broadcast through the _emit_fluid_pp member loop, so a selector left on an unused slot still reaches the solver.

Scope

Nothing in src/ reads %eos yet, and the selector is fully determined by the chemistry flag, so there is exactly one legal value per build. That is deliberate — this PR establishes the interface and its validation, and the first backend to consume it lands next. Reviewers should weigh that tradeoff explicitly rather than treat it as an oversight.

Tests

toolchain/mfc/params_tests/test_eos_selector.py covers the enum, the readable-name to integer resolution, and each check_eos branch. test_fortran_and_python_enums_agree compares the hand-written eos_* constants in m_constants.fpp against _EOS_NAMESgenerate_constants_fpp skips compound registry keys, so nothing else forces the two to match.


Handover notes

Branch

repo    MFlowCode/MFC, PR #1700
head    fahnab666/mfc-nabid : feature/eos-selector-v2

External contribution (Fahad Nabid). Maintainer commits have been pushed directly to the contributor's branch — maintainerCanModify is true. If you push, use --force-with-lease=<branch>:<sha> with an explicit SHA; a bare --force-with-lease fails with "stale info" unless you have fetched the ref in the current clone.

Commits added on top of the contributor's work:

c9252fd  Trim the eos enum to the implemented backends and check every fluid_pp slot
b3efac1  Add tests for the eos selector
73e2150  Merge master into feature/eos-selector-v2   (conflict resolution, see below)

Environment

./mfc.sh build -j 16
./mfc.sh test  -j 12
cd toolchain && python -m pytest mfc/params_tests -q

Merge conflict with #1713 — resolved

After #1713 (remove the four-equation model) merged, this PR went CONFLICTING in exactly one file, src/common/m_global_parameters_common.fpp. Both branches edit the same use m_constants line: this PR adds eos_stiffened_gas, eos_ideal_gas_mixture, master removed model_eqns_4eq. Resolution keeps both. Verified after the merge: build clean, full suite 627 passed, 0 failed.

If master moves again, that same use line is the likely collision point.

Plumbing that was verified, so it need not be re-derived

  • _r(f"{px}eos", INT, ...) in definitions.py registers the parameter, so _emit_fluid_pp in toolchain/mfc/params/generators/fortran_gen.py auto-generates the MPI broadcast as MPI_INTEGER for all three targets. It reads the datatype from the registry, so no hand-editing of m_mpi_proxy is needed.
  • chemistry is logical, parameter :: chemistry = .${chemistry}$. (m_global_parameters_common.fpp), i.e. a compile-time fypp parameter. That is what makes the pre-namelist-read default merge(eos_ideal_gas_mixture, eos_stiffened_gas, chemistry) correct and constant-folded.
  • Case.__init__ (toolchain/mfc/case.py) normalises readable names to integers before CaseValidator runs, so the validator sees ints. The name-resolution path works end to end.
  • fluid_pp has no GPU declare, so widening physical_parameters is free.

Design decisions taken during review

The enum holds only implemented backends. mie_gruneisen, jwl and table were removed. With them present, choices advertised all five values, so the auto-generated hint contradicted the error:

fluid_pp(1)%eos selects an equation of state that is not yet implemented; only ...
Valid values: 1 (stiffened-gas), 2 (ideal-gas mixture), 3 (Mie-Grueneisen), 4 (JWL), 5 (tabulated)

Note test_names_are_wellformed_and_cover_choices asserts set(names.values()) == set(choices), so names and choices must shrink together.

Membership is re-checked in check_eos. The choices constraint is enforced by validate_constraints, a different layer from CaseValidator — relying on it lets an out-of-enum integer through. This was found by a test, not by reading.

Both checkers loop to num_fluids_max. Every fluid_pp slot is default-assigned and MPI-broadcast up to num_fluids_max, so a selector on an unused slot still reaches the solver. Validation is input-time only, so the wider bound costs nothing.

Fortran/Python enum drift guard. generate_constants_fpp silently skips compound registry keys (fluid_pp(1)%eos would produce the invalid identifier fluid_pp(1)%eos_stiffened_gas), so the eos_* constants are hand-written in m_constants.fpp. test_eos_selector.py::test_fortran_and_python_enums_agree compares them against _EOS_NAMES using get_fortran_constants(), which parses m_constants.fpp. Without that test nothing forces the two to agree.

Open question for reviewers

Nothing in src/ reads %eos yet, and the selector is fully determined by the compile-time chemistry flag, so there is exactly one legal value per build. That is deliberate — this establishes the interface and its validation — but it is a config knob that cannot be turned until a backend consumes it. The next PR in the series should be the thing that reads %eos, not more interface.

Series context

Tracking issue #1638 records the plan from #1659:

  1. Centralize thermodynamic operations (not landed; Central thermodynamics interface #1663 was closed on performance grounds)
  2. Add EOS selection and integrate the existing Pyrometheus path — this PR
  3. Add Mie-Gruneisen, then JWL
  4. Add reaction models incrementally

Note this is step 2 landing before step 1, which is why nothing reads the selector yet.

Related: #1714 (eos_state refactor) gives the interface a dispatch seam; #1708 tracks the remaining EOS duplication. A constraint established while investigating #1714: m_thermochem.f90 is generated for every build, but non-chemistry builds get a dummy h2o2.yaml mechanism (toolchain/mfc/run/input.py), and num_species from it sizes rhoYks(1:num_species) throughout. So ideal_gas_mixture is a whole-build mode, not a per-fluid peer backend — the fypp chemistry split must stay, and any future runtime dispatch covers only the non-chemistry backends.


Purpose in the series

Tracking issue #1638 (JWL / multi-EOS). Plan from #1659:

  1. Centralize thermodynamic operations — not landed; Central thermodynamics interface #1663 closed on performance grounds
  2. Add EOS selection and integrate the Pyrometheus paththis PR
  3. Add Mie-Gruneisen, then JWL
  4. Add reaction models incrementally

This is step 2 arriving before step 1, which is why nothing in src/ reads %eos yet. It establishes the vocabulary — a stable per-fluid enum, validated at input time — that later backends dispatch on. #1714 supplies the dispatch seam.

Do not merge more interface after this. The next PR in the series should be the first thing that actually reads %eos; otherwise the selector accumulates as a knob nobody can turn.


Working conventions and hazards (shared across this series)

Collected from the work that produced #1705, #1709, #1712, #1713, #1714, #1716. Every one of these cost real time or produced a wrong result before being caught.

Testing

A regression test that cannot fail is worse than no test. Always verify the negative: revert the fix, rebuild, confirm the case fails, restore. Two ways this silently broke here:

  • git stash push -- <file> has nothing to stash once the fix is committed, so the "reverted" run tests the fixed binary and reports a pass identical to a real one. Use git checkout master -- <file>, rebuild, test, then git checkout HEAD -- <file>.
  • Picking the wrong case configuration. A probe case at model_eqns = 3 cannot detect a sound-speed defect, because the six-equation branch of s_compute_speed_of_sound touches neither H nor qv.

The golden packer discards data. toolchain/mfc/packer/pack.py treated every .dat under D/ as <x> [<y> <z>] <value> and kept only the last column of each row. Probe and integral output are multi-column time series, so most columns were never compared (#1711, fixed in #1712). Before asserting that a golden covers something, check it is actually in golden.txt.

Case labels are load-bearing. The golden UUID is crc32(sha1(str(trace))) — the label chain determines the directory name. Renaming a label renames the golden. Avoid ! in labels (history expansion in interactive bash).

Local suite runs are flaky at high -j. Non-reproducible failures appeared on several unrelated branches at -j 1216 (chemistry cases, probe cases) that passed individually and in clean reruns. Re-run before investigating.

Removing parameters or features

Deregistering a parameter breaks things that are not the source tree. Removing pref/rhoref from the registry broke the entire suite because BASE_CFG in toolchain/mfc/test/case.py set them for every case. Also check fp_stability.py, params_tests/mutation_tests.py, and lint fixtures that use real parameter names as examples.

Grep the generated artifacts, not just the sources. A stale TYPED_DECLS entry naming a deleted type survived removal and did not break the build only because the parameter had also left every target's namelist vars, so it was never emitted. Check generated_decls.fpp, generated_constants.fpp, SIM_GPU_DECL_VARS, and the MPI broadcast generators.

Dead-local tell: after removing a block, a local with exactly one remaining occurrence in its file is almost certainly its own declaration. Two occurrences often means declaration plus a private() entry.

Fortran is case-insensitive. A local pRef shadowed the module global pref in the hardcoded-IC files; the read site was spelled pref and looked like a reference to the global. It is not. Confirm scope before concluding a global is live.

GPU

A CPU test run cannot catch a missing private(). It is a silent device race. Audit by hand or by script when adding per-cell state.

Do not match GPU_PARALLEL_LOOP naivelyEND_GPU_PARALLEL_LOOP contains the same substring and will register as a loop start, producing false positives. Exclude it explicitly.

Derived-type components cannot have runtime extents. dimension(num_fluids) in a type fails to compile outside case-optimized builds, where num_fluids is a parameter.

Benchmarking

Run ./mfc.sh bench in the foreground on an idle machine. Running it in the background while a pre-commit precheck ran at -j 12 produced a bogus +50% regression on a case that executes none of the changed code. Baseline noise here is ±3.5%; run the baseline twice before trusting any delta, and sanity-check that the regressing cases actually execute the modified code.

GitHub mechanics

  • --force-with-lease needs an explicit SHA (--force-with-lease=<branch>:<sha>) when the ref has not been fetched in the current clone; the bare form fails with "stale info".
  • gh run view --log-failed can miss the real output entirely. On the Frontier jobs the failing step carried only a non-zero exit while the actual test output lived in a separate Print Logs step that succeeded. Fetch the full log.
  • gh run rerun <id> --failed refuses while the workflow is still running; retry later.
  • Frontier jobs fail spuriously with some regularity — uniform ~15-minute deaths across heterogeneous jobs, and shared-Lustre install races (file INSTALL cannot set modification time ... No such file or directory, exit 143). Check for a real error before assuming a code fault.

Introduce fluid_pp(:)%eos, a stable five-value enumeration
(stiffened_gas, ideal_gas_mixture, mie_gruneisen, jwl, table)
in place of implicit backend selection. Only stiffened_gas (default)
and ideal_gas_mixture (chemistry, Pyrometheus) are backed by an
adapter; the remaining values are reserved and rejected explicitly.

The default resolves from the compile-time chemistry flag, so every
existing case is unchanged and all goldens stay bit-identical, and no
runtime dispatch enters the hot per-cell path. Case files accept
readable names while the integer representation stays internal. The
Fortran and Python checkers reject unsupported values, ideal_gas_mixture
without a chemistry build, and intra-cell EOS mixing (every fluid must
share one family).

The five enum constants are hand-written in m_constants.fpp; the
constant generator skips compound registry keys so the per-fluid
CONSTRAINTS entries drive only readable-name resolution and validation.
State plainly that a plain ideal gas without chemistry is stiffened_gas
with pi_inf = 0, and that ideal_gas_mixture is the Pyrometheus mixture
backend valid only in a chemistry build. This keeps users from reaching
for ideal_gas_mixture on a non-chemistry build, where it is rejected.
@fahnab666
fahnab666 force-pushed the feature/eos-selector-v2 branch from 18786a8 to 7ee3cd3 Compare July 31, 2026 15:02
@fahnab666
fahnab666 marked this pull request as ready for review July 31, 2026 15:04
@fahnab666
fahnab666 requested a review from sbryngelson as a code owner July 31, 2026 15:04
Copilot AI review requested due to automatic review settings July 31, 2026 15:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.25%. Comparing base (7f69cb5) to head (8f96104).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1700      +/-   ##
==========================================
+ Coverage   61.24%   61.25%   +0.01%     
==========================================
  Files          83       83              
  Lines       20700    20708       +8     
  Branches     3072     3072              
==========================================
+ Hits        12677    12685       +8     
  Misses       5969     5969              
  Partials     2054     2054              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sbryngelson sbryngelson left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for this. The plumbing checks out — _r(f"{px}eos", INT, ...) registration means the MPI broadcast is auto-generated correctly as MPI_INTEGER for all three targets, the namelist binds fluid_pp as a derived type so fluid_pp(1)%eos resolves, and the compile-time chemistry parameter makes the pre-namelist-read default correct. The Fortran and Python checkers agree.

Four things to address before merge, mostly around the enum having several hand-synced copies. Details inline.

Separately, worth noting for context rather than as a change request: nothing in src/ currently reads %eos, and both checkers leave exactly one legal value per build since it is fully determined by chemistry. That is defensible as step-2 groundwork, but it is the part I would most want justified given the guidance against config knobs with one correct value.

Comment thread src/common/m_constants.fpp Outdated
Comment thread toolchain/mfc/case_validator.py Outdated
Comment thread toolchain/mfc/case_validator.py Outdated
Comment thread toolchain/mfc/params/definitions.py
than hand-sync the eos integer values, and cover the bubbles_euler slot

Records the fluid_pp(:)%eos hand-written constants as a "still manual"
case in common-pitfalls.md and notes it in the m_constants.fpp comment,
since generate_constants_fpp silently skips compound registry keys.

check_eos in case_validator.py now reads its two supported values from
CONSTRAINTS["fluid_pp(1)%eos"]["names"], the same idiom already used
for recon_type, instead of a third hand-synced copy of 1 and 2.

check_eos (Python) and s_check_eos (Fortran) both now cover the extra
fluid property slot that bubbles_euler uses, matching the existing
check_stiffened_eos pattern; previously fluid_pp(num_fluids+1)%eos
went unvalidated when bubbles_euler was set.

Adds eos to the physical_parameters member list comment in
definitions.py, the registry/type drift guard from MFlowCode#1553.
@fahnab666
fahnab666 requested a review from sbryngelson August 2, 2026 04:09
@sbryngelson
sbryngelson dismissed their stale review August 3, 2026 14:58

try again

@sbryngelson

Copy link
Copy Markdown
Member

Code review

Found 1 issue:

  1. Out-of-bounds access in s_check_eos: the loop bound num_fluids + merge(1, 0, bubbles_euler) reaches fluid_pp(11) when num_fluids = 10 and bubbles_euler = T, but fluid_pp is dimension(num_fluids_max) with num_fluids_max = 10. num_fluids is constrained to {"min": 1, "max": NF} with NF = 10 (toolchain/mfc/params/definitions.py), and check_bubbles_euler only forces num_fluids = 1 for model_eqns = 4 — so num_fluids = 10, bubbles_euler = T, model_eqns = 2 passes every validator and then indexes one past the end. Debug builds abort on bounds check; release builds read adjacent memory.

This came in with ff17a039, mirroring the bub_fac idiom from check_stiffened_eos. That idiom is safe in Python only because self.get(f"fluid_pp({i})%eos") returns None for an out-of-range key and the loop body skips it; Fortran dereferences a fixed-size array instead. No other Fortran code indexes fluid_pp past num_fluids. Clamping the bound (or dropping the merge term, since the defaults loop already initializes all num_fluids_max slots) resolves it.

do i = 1, num_fluids + merge(1, 0, bubbles_euler)
@:PROHIBIT(chemistry .and. fluid_pp(i)%eos /= eos_ideal_gas_mixture, &

integer, parameter :: fourier_rings = 5 !< Fourier filter ring limit
integer, parameter :: num_fluids_max = 10 !< Maximum number of fluids in the simulation
integer, parameter :: num_probes_max = 10 !< Maximum number of flow probes in the simulation

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@fahnab666
fahnab666 force-pushed the feature/eos-selector-v2 branch from 7a48930 to d401bba Compare August 3, 2026 21:30
sbryngelson
sbryngelson previously approved these changes Aug 4, 2026
@sbryngelson

Copy link
Copy Markdown
Member

I don't understand why we can't just do exactly what is done now, but use ideal gas as an option (leaving chemistry alone) for when \Pi_\infty = 0?

@fahnab666

Copy link
Copy Markdown
Author

I don't understand why we can't just do exactly what is done now, but use ideal gas as an option (leaving chemistry alone) for when \Pi_\infty = 0?

@sbryngelson

That's a valid approach. I kept the change small on purpose: eos defaults to stiffened_gas (eos=1) when it isn't set, and that path already covers both stiffened gas and ideal gas. With pi_inf = 0 the stiffened-gas EOS reduces to an ideal gas, with no chemistry involved. eos=2 (ideal_gas_mixture) is the Pyrometheus multi-species mixture specifically, which is the only reason it is tied to chemistry.

Happy to do it your way: require an explicit eos in the case file instead of defaulting it, and leave chemistry as its own independent switch. The ideal-gas case then stays exactly as you describe, stiffened_gas with pi_inf = 0, decoupled from chemistry.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@fahnab666
fahnab666 requested a review from sbryngelson August 8, 2026 19:05
@sbryngelson sbryngelson added the claude-full-review Trigger Claude Code review label Aug 8, 2026
…p slot

Two problems with the selector as it stood.

The enum reserved mie_gruneisen, jwl, and table, but 'choices' listed all five
values, so the auto-generated hint advertised them as valid directly beneath
the error saying they were not implemented:

    fluid_pp(1)%eos selects an equation of state that is not yet implemented;
    only 'stiffened_gas' and 'ideal_gas_mixture' are available
    Valid values: 1 (stiffened-gas), ..., 4 (JWL), 5 (tabulated)

Drop the three reserved values. They cost three hand-synced constants in
m_constants.fpp and bought nothing that adding them alongside their backend
would not; the hint is now consistent with what check_eos accepts.

Both checkers also stopped at num_fluids (+1 for bubbles_euler), but every
fluid_pp slot is default-assigned and MPI-broadcast up to num_fluids_max via
the member loop in _emit_fluid_pp, so a selector left on an unused slot reached
the solver unvalidated. With num_fluids = 2, fluid_pp(3)%eos was accepted
unchecked. Both loops now cover num_fluids_max; validation is input-time only,
so the wider bound costs nothing.

The membership check stays explicit in check_eos rather than deferring to the
'choices' constraint: choices is enforced by validate_constraints, a separate
layer from CaseValidator, so CaseValidator alone would have let an out-of-enum
integer through.
Nothing exercised fluid_pp(i)%eos: no case sets it, so 616/616 bit-identical
showed only that the selector changed no behaviour, not that it works.

Covers the enum, the readable-name to integer resolution done by Case, and each
check_eos branch. test_fortran_and_python_enums_agree is the one that matters
most: generate_constants_fpp skips compound registry keys, so the eos_*
constants are hand-written in m_constants.fpp and nothing else forced them to
match _EOS_NAMES. get_fortran_constants already parses m_constants.fpp, so the
guard is a direct comparison rather than a comment asserting the invariant.

test_unused_slot_is_validated pins the loop bound fixed in the previous commit.
test_value_outside_enum_rejected pins the membership check that CaseValidator
must not delegate to the 'choices' layer.
@sbryngelson

Copy link
Copy Markdown
Member

I pushed three follow-ups to this branch directly (c9252fd, b3efac1) rather than round-tripping them — all small, all in response to the second read-through. Happy to revert any of them if you disagree.

Reserved enum values removed. choices listed all five values, so the generated hint contradicted the error:

fluid_pp(1)%eos selects an equation of state that is not yet implemented;
only 'stiffened_gas' and 'ideal_gas_mixture' are available
Valid values: 1 (stiffened-gas), 2 (ideal-gas mixture), 3 (Mie-Grueneisen), 4 (JWL), 5 (tabulated)

A user reads the second line, sets jwl, and gets rejected again. Dropping mie_gruneisen/jwl/table fixes that and removes three of the five hand-synced constants in m_constants.fpp. Add each value when its backend lands.

Both checkers now cover every slot. They stopped at num_fluids + bub_fac, but _emit_fluid_pp broadcasts i = 1, num_fluids_max and the defaults are assigned over the same range, so fluid_pp(3)%eos with num_fluids = 2 was accepted unvalidated. Confirmed against the validator before the change. No consequence today, but it becomes a silent wrong-EOS path for whoever wires the first backend, which is the whole point of this PR.

Tests added. test_eos_selector.py — the enum, name resolution, and each check_eos branch. The one worth your attention is test_fortran_and_python_enums_agree: generate_constants_fpp skips compound keys, so the eos_* constants are hand-written and only a comment asserted they match _EOS_NAMES. get_fortran_constants() already parses m_constants.fpp, so the guard is a direct comparison.

Writing those tests caught a mistake in my own first pass: I had dropped the membership check on the assumption that the choices constraint would cover out-of-enum integers. It does not — choices is enforced by validate_constraints, a different layer from CaseValidator — so eos: 99 passed. The explicit check is restored with a comment saying why it is not redundant.

Verification: params_tests 181 passed; ruff check / ruff format --check clean; ffmt --check src/ clean. CI will confirm the rest.

On scope: I have left the "one legal value per build" point as a note in the description rather than a change request. It is a real cost — a case parameter that cannot be set to anything other than what it already is — and it is only worth paying if the first backend follows closely. I am fine merging on that basis, but the next PR in the series should be the thing that reads %eos, not more interface.

Resolves a conflict in src/common/m_global_parameters_common.fpp introduced by
MFlowCode#1713 (remove the four-equation model). Both branches edit the same
use m_constants line: this branch adds eos_stiffened_gas and
eos_ideal_gas_mixture, master removes model_eqns_4eq. The resolution keeps
both changes.

Verified after the merge: build clean, full suite 627 passed, 0 failed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-full-review Trigger Claude Code review

Development

Successfully merging this pull request may close these issues.

3 participants