Skip to content

Add synthetic HXN regression tests; fix synthesis for non-equilibrium inlets, stale network ordering, and H_lim at the bubble point - #256

Merged
yoelcortes merged 3 commits into
hxn-pinch-diagramfrom
hxn-regression-tests
Aug 28, 2026
Merged

Add synthetic HXN regression tests; fix synthesis for non-equilibrium inlets, stale network ordering, and H_lim at the bubble point#256
yoelcortes merged 3 commits into
hxn-pinch-diagramfrom
hxn-regression-tests

Conversation

@sarangbhagwat

Copy link
Copy Markdown
Member

Stacked on #255 (hxn-pinch-diagram); shows only the three commits from this work.

Summary

Regression suitetests/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:

  1. energy balance closes to |error| < 1e-6 % with no numerical RuntimeWarning;
  2. hot/cold utility loads never beat the problem-table MER targets, and heat − cool == net duty;
  3. hot/cold utility loads are no worse than the values documented in CASES (run the file directly to print current values), so a future hxn change 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:

  • Phantom pinch loads for non-equilibrium inlets (hxn_synthesis.py): load_duties and the transient pinch-state setup flashed each stream at the pinch temperature and trusted that enthalpy. A superheated liquid from a non-rigorous HXutility (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. New pinch_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 in 08e873c5).
  • Stale exchangers from a synthesis-order path (_heat_exchanger_network.py): HXN_sys used new_HXs + new_HX_utils as its path, not a topological order of the rewired network, and with no recycle declared ran once. Now built via tmo.Network.from_units(all_units, interaction=False) + System._from_network (ordered by connections, real loops detected for the already-configured fixed-point convergence). interaction=False is required: HXprocess is 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_lim ignored 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_condition set the saturated phase and skipped the enthalpy limit, so a cold stream overshot its target and its "heating" utility cooled it back.
  • Plus a typo in both no-exchange early exits of 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_lim at the bubble point for the function and HXprocess; inlet immutability.
  • tests/test_hxn.py: load_duties/pinch_state energy conservation for a non-equilibrium inlet; exact end-point pinch states (superheated water, mislabelled N2); the HXN-specific ordering warning.
  • All new tests were written failing first; each of the three fixes was ablated individually and re-breaks case 10.

Validation

  • Cases 1–4 and 6–10 are bit-identical to their baselines; case 5 improved (hot 5.84e6 → 3.22e6 kJ/hr) because its 420 K / 5 bar vapor feed is a non-equilibrium inlet now handled consistently — baseline lowered accordingly.
  • Full CI-style suite (pytest . --disable-numba=1 -m "not slow"): 74 failed / 495 passed / 62 skipped — the same 74 pre-existing failures (notebooks, missing qsdsan/SALib, thermosteam/biorefineries stack drift) as before this branch; nothing new fails.

🤖 Generated with Claude Code

sarangbhagwat and others added 3 commits August 23, 2026 00:00
…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 yoelcortes 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.

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.

@yoelcortes
yoelcortes merged commit 91ae655 into hxn-pinch-diagram Aug 28, 2026
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.
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