Add synthetic HXN regression tests; fix synthesis for non-equilibrium inlets, stale network ordering, and H_lim at the bubble point - #256
Merged
Conversation
…ts, and documented heat recovery
Ten synthetic systems of increasing complexity (2 to 10 process streams,
built from simulated HXutility units; every case from the third on has
phase-changing streams: total and partial condensers, boiling and
partially boiling cold streams, mixed pressures, a threshold problem and a
near-degenerate cascade). For each, the synthesized network must
(i) close its energy balance to |error| < 0.1 % with no numerical
RuntimeWarning escaping simulate();
(ii) never beat the problem-table MER targets computed on the same
streams (a network below MER is a bookkeeping error), and satisfy
actual_heat - actual_cool == net process duty;
(iii) use no more hot or cold utility than documented in CASES
(recorded at 1ab689f by running the file directly), so a future
change to hxn cannot silently make the synthesizer recover less heat.
The recorded baselines also document where the greedy heuristic currently
falls short of MER: cases 1-4 and 7 hit the target exactly; cases 8 and 9
are within 1 %; case 5 (boiling cold stream) uses 5.8e6 kJ/hr of hot
utility where MER is 0, and case 6 uses 7.1e6 against an MER of 2.1e6.
Case 10 is a strict xfail: synthesis emits a zero-duty exchanger
(HX_8_2_cs, hot 355 K against cold 365 K, Q = 0) whose cold inlet is hotter
than its hot inlet, and compute_LMTD then raises FloatingPointError on the
log of a negative ratio. Fixing that flips the xfail into a failure that
prompts recording the case's baseline.
Validation: tests/test_hxn_regression.py -> 9 passed, 1 xfailed (5.7 s,
not marked slow). Full CI-style suite -> 74 failed, 488 passed, 62 skipped,
1 xfailed; the 4 failures beyond the documented 70 (test_oilcane_O6/O8/O9:
AttributeError inside the sibling biorefineries clone; test_tire: value
drift, fails identically on unmodified master) are pre-existing stack drift
since the baseline was measured against the pip thermosteam wheel.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and H_lim at the bubble point Regression case 10 (tests/test_hxn_regression.py) crashed in compute_LMTD with a zero-duty exchanger whose cold inlet was hotter than its hot inlet. Tracing it back exposed three independent defects, each fixed at its cause: 1. Phantom pinch loads for non-equilibrium inlets (hxn_synthesis.py). load_duties() and the transient pinch-state setup in synthesize_network() flashed each stream at its pinch temperature and took that equilibrium enthalpy at face value. A non-rigorous HXutility can carry a superheated liquid (ethanol at 370 K, 1 atm, H = 6.4e6 kJ/hr); flashing it at the 355 K pinch yields vapor with H = 32.0e6, and abs(H_pinch - H_in) turned that into a 25.6e6 kJ/hr "cooling load" above the pinch for a stream whose whole duty is 5.45e6. Exchangers were then synthesized against a transient stream carrying latent heat the real stream does not have; rewired with the real inlet they behaved differently, overheating a cold stream and leaving a downstream match with crossed, zero-duty inlets. New pinch_state() flashes at the pinch T and, when the result lies outside [min(H_in, H_out), max(H_in, H_out)], re-flashes at the clipped enthalpy, so the hot-side and cold-side loads split |H_in - H_out| exactly by construction (the abs() is gone). This is the synthesizer's counterpart of the clipping the problem table got in 08e873c. 2. HXN_sys path in synthesis order (_heat_exchanger_network.py). _cost built bst.System(path=new_HXs + new_HX_utils). Hot-side exchangers are synthesized before the cold-side exchangers that feed them after the life-cycle rewiring, and with no recycle declared the system ran the path once, leaving those exchangers with stale inlets (an energy-balance residual equal to the upstream duty change). The path is now built with tmo.Network.from_units(all_units, interaction=False) and System._from_network, which orders units by their connections and detects the genuine loops in larger networks for the fixed-point convergence that was already configured. interaction=False is required: HXprocess is an interaction unit and the default strips those out and disconnects them. 3. heat_exchange_to_condition ignored H_lim at the bubble point (heat_transfer.py). When the temperature limit lands within 1e-3 K of the stream's bubble point (e.g. T_hot_in - dT = 373.124 K for water at 1 atm) the outlet was set to the saturated phase and the enthalpy limit was never applied, so a cold stream's apparent capacity became its full vaporization and it overshot its enthalpy target; its "heating" utility then had to cool it back. The limit is now applied in that branch too, by VLE since the clipped state is two-phase. Also in counter_current_heat_exchange, both no-exchange early exits did s1_in.copy_like(s1_out), overwriting an inlet with the already modified outlet (observed rewriting a superheated-liquid inlet to vapor); corrected to s1_out.copy_like(s1_in). Tests: tests/test_heat_exchange.py (new) covers H_lim at the bubble point for heat_exchange_to_condition and HXprocess, and inlet immutability; tests/test_hxn.py gains a load_duties/pinch_state energy-conservation test for a non-equilibrium inlet. Case 10 loses its xfail and records its baseline (hot 1.40742e7, cold 8.06488e6 kJ/hr, both above MER, energy balance error 0.0000 %); the H5 input is corrected to 1 atm (it was a subcooled vapor at 2 bar). Cases 1-9 are bit-identical to their baselines. Validation: targeted HX/HXN tests and doctests -> 37 passed. Full CI-style suite -> 74 failed, 493 passed, 62 skipped: exactly the same 74 pre-existing failures as the previous run, +5 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…path warning, tighter regression tolerances Follow-up to 75f1402 from a code review of the branch. pinch_state (hxn_synthesis.py) now takes the stream's real end states and, when the pinch temperature coincides with an end temperature or the flash at the pinch leaves the stream's enthalpy range, returns the *equilibrium state at that end enthalpy* (new helper _end_state). Two defects closed: - A non-equilibrium inlet with pinch_T == T_in still had its duty split across the pinch: flashing a superheated liquid at its own temperature does not reproduce H_in, and the result can lie inside the range so the clip never fired (superheated liquid water 380 -> 400 K put 98 % of the duty on the side the stream is "unavailable" on). Now H_pinch == H_in exactly, so the whole duty lands on one side as the pinch analysis promises. - A phase-mislabelled non-condensable (N2 as 'l' at 400 K) re-flashed at the clipped enthalpy came back as gas at ~2500 K and drove matching. _end_state falls back to the stream as given when the equilibrium state leaves the stream's own [T_out, T_in] range. The review suggested returning the stream as given at end points; that was tried and rejected: it lets the synthesizer offer a non-equilibrium inlet's heat at its fictitious temperature (liquid ethanol "at 370 K") and case 10 then beat the problem-table MER by 0.3 %. The equilibrium state at the end enthalpy keeps synthesis consistent with the table, which is the invariant the regression suite enforces. _heat_exchanger_network.py: thermosteam's Network.sort can warn 'network path could not be determined' when its ordering heuristic does not settle; since 75f1402 that could surface from HXN with no context. It is caught and re-issued as an HXN-specific RuntimeWarning (tested by monkeypatching Network.from_units). tests/test_hxn_regression.py: converged networks close their energy balance to ~1e-10 %, so the 0.1 % tolerance could not catch the stale-ordering defect (ablating it gives 0.049 %). EB_TOLERANCE is now 1e-6 % and the heat - cool == net duty identity uses rtol 1e-8. Case 5's documented baseline is lowered (hot 5.8433e6 -> 3.2224e6, cold 1.04356e7 -> 7.81466e6): its 420 K, 5 bar vapor feed is below water's boiling point there, a non-equilibrium inlet now handled consistently. Cases 1-4 and 6-10 are unchanged. Validation: HX/HXN tests and doctests -> 37 passed. Full CI-style suite -> 74 failed, 495 passed, 62 skipped; the same 74 pre-existing failures as the previous two runs, +2 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
yoelcortes
approved these changes
Aug 28, 2026
yoelcortes
left a comment
Member
There was a problem hiding this comment.
Great catch in the heat exchange algorithm; for the simplest test case of a single component in equilibrium, there would be an error with partial vaporization.
Every other update + test is in the correct direction and lead to more confidence in the tool.
We may need to see later on if any system created by the heat exchanger network fails to converge with BioSTEAM's partitioning algorithm. If it ever happens, we can try to improve it by studying the failed system.
sarangbhagwat
added a commit
that referenced
this pull request
Sep 4, 2026
Upstream merged the HXN PR stack (#253-#256) into master and added two follow-up commits touching files this branch deleted when the heat exchanger network moved to hensmith: - bc06684 reworded the cache_network comments in biosteam/facilities/hxn/_heat_exchanger_network.py - 15f6c63 loosened the cache_network test tolerance for reproducibility in tests/test_hxn.py (rtol 1e-6 -> 2e-3, feed perturbation 1.01 -> 1.000001) Both modify/delete conflicts are resolved by keeping the deletions; the two changes are ported to hensmith (hensmith/_heat_exchanger_network.py and tests/test_hxn.py) on its port-upstream-hxn-review-tweaks branch.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #255 (
hxn-pinch-diagram); shows only the three commits from this work.Summary
Regression suite —
tests/test_hxn_regression.py: ten synthetic systems (2 → 10 process streams; condensers, partial condensers, boiling and partially boiling cold streams, mixed pressures, a threshold problem, a near-degenerate cascade). For each synthesized network:|error| < 1e-6 %with no numericalRuntimeWarning;heat − cool == net duty;CASES(run the file directly to print current values), so a futurehxnchange cannot silently recover less heat.The baselines also document where the greedy heuristic falls short of MER today (case 6: 7.1e6 vs 2.1e6 kJ/hr hot utility) — targets for future work.
Fixes — case 10 crashed in
compute_LMTD(zero-duty exchanger with crossed inlets). Tracing it back found three independent defects:hxn_synthesis.py):load_dutiesand the transient pinch-state setup flashed each stream at the pinch temperature and trusted that enthalpy. A superheated liquid from a non-rigorousHXutility(ethanol "at 370 K", 1 atm, H = 6.4e6) flashed at 355 K becomes vapor with H = 32e6;abs(H_pinch − H_in)minted a 25.6e6 kJ/hr cooling load for a 5.45e6 stream. Newpinch_state()returns a state whose enthalpy lies within the stream's real range — the equilibrium state at the nearer end enthalpy when the flash leaves it or the pinch sits on an end temperature — so hot-side + cold-side loads equal|H_in − H_out|by construction (synthesizer counterpart of the problem-table clipping in08e873c5)._heat_exchanger_network.py):HXN_sysusednew_HXs + new_HX_utilsas its path, not a topological order of the rewired network, and with no recycle declared ran once. Now built viatmo.Network.from_units(all_units, interaction=False)+System._from_network(ordered by connections, real loops detected for the already-configured fixed-point convergence).interaction=Falseis required:HXprocessis an interaction unit that the default strips out and disconnects. thermosteam's "path could not be determined" warning is re-issued with HXN context.H_limignored at the bubble point (heat_transfer.py): when the temperature limit lands within 1e-3 K of the bubble point (373.124 K for water; hot inlet was exactly 378.124 K)heat_exchange_to_conditionset the saturated phase and skipped the enthalpy limit, so a cold stream overshot its target and its "heating" utility cooled it back.counter_current_heat_exchange:s1_in.copy_like(s1_out)overwrote an inlet with the modified outlet →s1_out.copy_like(s1_in).Tests
tests/test_heat_exchange.py(new):H_limat the bubble point for the function andHXprocess; inlet immutability.tests/test_hxn.py:load_duties/pinch_stateenergy conservation for a non-equilibrium inlet; exact end-point pinch states (superheated water, mislabelled N2); the HXN-specific ordering warning.Validation
pytest . --disable-numba=1 -m "not slow"): 74 failed / 495 passed / 62 skipped — the same 74 pre-existing failures (notebooks, missingqsdsan/SALib, thermosteam/biorefineries stack drift) as before this branch; nothing new fails.🤖 Generated with Claude Code