Skip to content

reach MER on unsplit-feasible problems and correct MER targets for phase change - #1

Merged
sarangbhagwat merged 6 commits into
masterfrom
dev
Sep 24, 2026
Merged

sarangbhagwat merged 6 commits into
masterfrom
dev

Conversation

@sarangbhagwat

@sarangbhagwat sarangbhagwat commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

synthesize_network now reaches the minimum-energy-requirement (MER) utility targets on every problem tested that can reach them without stream splitting. On split-needed problems it returns its best unsplit network, and that network never reports utilities below MER. The MER targets themselves are also corrected for phase change. Tests grow from 37 to 467, all green locally.

What was wrong

1. The synthesizer missed MER on problems that don't need splitting.

  • The old passes built each stream's matches starting from its inlet, not from the pinch outward.
  • On the classic Linnhoff 4-stream problem (targets 20 / 60 kW), it used 27.5 / 67.5 kW.
  • The first hot stream was matched to the cold stream's cold end. The next hot stream, which enters at the pinch, was then limited by T_min_app, so the cold stream never reached the pinch.
  • Some unsplit MER networks also need a match that covers neither stream completely, or the same pair matched twice on one side. The old greedy passes could not produce either.

2. MER targets were too low for streams that change phase.

  • The problem table only used stream end temperatures, and vle(T=T_sat) is ambiguous at a phase boundary.
  • A latent-heat segment or a mixture's boiling-range glide inside a stream's range could therefore hide the real pinch.
  • Targets came out too low, by up to 100% in the worst cases.

3. Exchangers could cross internally without anything catching it.

  • HXprocess checks the approach temperature only at its two ends.
  • Several old networks "met" the too-low targets with exchangers whose internal approach dropped below T_min_app, sometimes to a real temperature cross.

The fix

  • hensmith/_curves.py (new, private): one temperature–enthalpy curve per stream.
    • Latent heat comes from flashes at a fixed vapor fraction and is stored as isothermal segments.
    • Binary glides are traced along the bubble curve.
    • Sensible segments are sampled until the curvature error is ≤ 0.002 K.
    • Each curve is clipped exactly to the stream's own enthalpy range.
    • The problem table, the pinch, and the planner all read these same curves.
  • hensmith/_planner.py (new, private, pure numpy): a pinch-design-method planner (Linnhoff & Hindmarsh 1983).
    • It works each side of the pinch outward from the pinch.
    • The pinch number and CP rules account for isothermal segments, which can serve several partners at the pinch.
    • Duties are either tick-off or event-limited, and a pair may repeat.
    • A remaining-cascade feasibility check prunes the search, and a memo avoids repeated work.
    • The search runs on a fixed amount of work, not a timer, so results are deterministic.
    • Problems that need splitting fall back to a best-effort search.
  • synthesize_network is rebuilt on the planner.
    • Each match is an HXprocess with both outlet limits set to its planned outlets.
    • Each stream gets one rigorous HXutility at its far end.
    • Point loads (condensers, reboilers) get their equilibrium inlet state.
    • Every exchanger's internal approach is verified exactly.
    • The signature and the 13-tuple return value are unchanged.
  • Changes in HeatExchangerNetwork (hensmith/_heat_exchanger_network.py):
    • Stages are ordered by their position along each stream.
    • Loops are converged tightly (energy-balance error ≈ 1e-10 %).
    • The cached-network path rescales each stage to its fraction of the stream.
    • Streams already at their outlet pass through their utility exchanger unchanged.
    • replace_unit_heat_utilities=True works again. It was already broken before this PR, because it called a biosteam method that no longer exists.
    • The "replaced in registry" warnings (128 per synthesis) are gone.
  • New public surface (optional keywords only):
    • problem_table(..., curves=None)
    • pinch_state(..., side=None, curve=None)
    • synthesize_network(..., info=None)
    • HeatExchangerNetwork.synthesis_info, whose 'status' is 'mer' or 'best_effort'

Validation

  • Full suite: pytest . --disable-numba=1 gives 467 passed (≈ 4 min locally; it was 37 tests in 17 s).

  • New test sets (tests/test_hxn_mer.py, case data in tests/hxn_mer_cases.py):

    Set Cases Over 10 streams Proof of the no-split / split label Checks
    No split 40 (24 literature, 16 real-thermo) 11 constant-Cp cases carry an unsplit MER network, re-verified in the test targets match an independent cascade and published values; MER is reached; status == 'mer'
    Split needed 38 (25 literature, 13 real-thermo) 13 pinch number/CP rule, re-derived in the test targets correct; network never beats MER; status == 'best_effort'

    Both sets also check all balances: the network's energy balance, each exchanger's hot/cold duty and mass balance, each stream's outlet, and each exchanger's internal approach.

  • Other new tests: tests/test_hxn_planner.py and tests/test_hxn_targets.py. tests/test_hxn.py and tests/test_hxn_regression.py gain integration tests and an internal-approach check.

  • Outside the suite:

    • An independent MILP oracle (scipy HiGHS) certified the no-split problems used for the check.
    • The planner reached MER on all ~4,500 certified problems: literature, random, and held-out problems with up to 40 streams.
    • None of 761 split-needed problems was falsely reported as MER.
  • Real biorefineries:

    • The doctest system and the isobutanol biorefinery now reach MER.
    • Oilcane O1, which needs splitting, is now 2.0 MW above its target, down from 9.7 MW.
    • Synthesis takes 0.05–1.1 s per run.
  • Docs: rewritten for the new method. All generated outputs, figures, and the demo were regenerated, and sphinx -W builds clean.

  • CI fix (f914c4c):

    • The PR's first CI run (Linux, py3.12; py3.13 was cancelled by fail-fast) failed only on tests/test_hxn.py::test_non_monotone_stream_is_a_point_load, with assert 7366735.070962861 >= 7366735.07096287.
    • Cause: the network reaches MER there, so the achieved hot-utility load equals the problem-table target in exact arithmetic. The two are sums over different terms (utility exchanger duties vs. the cascade), so they differ by round-off (~1.2e-15 relative, about 10 ulps). A bare >= on a bound attained with equality is platform-dependent.
    • Fix: the test now compares against the target less 1e-9 of the total duty. This is the tolerance the sibling test test_internal_pinch_of_condenser_against_boiling_mixture and the regression suite (MER_RTOL = 1e-9) already use.
    • The only other bare >= against a target (test_synthesis_info) is a best_effort case with a ~33% strict gap, so it is left as is.
  • Against the upstream that CI installs (all local on Windows, Python 3.14):

    • Full suite: 467 passed against biosteam 3bacb0fe and thermosteam 3f943856 (0.54.2). These are the exact commits this PR's CI installs from git HEAD. The baseline against the previous biosteam 7ff69657 / thermosteam f768d381 was also 467 passed.
    • hensmith master: 37 passed against the same upstream.
    • No repin needed: biosteam 2.54.0 requires hensmith>=0.1.2 (on PyPI), and hensmith's biosteam>=2.54.0 floor still holds.

Changes to review

  • Regression baselines (tests/test_hxn_regression.py): cases 04 and 10 were raised. Their old documented loads could only be reached through the internal crosses above, and were below the corrected targets. Cases 05, 08 and 09 were lowered.
  • Streams already at their outlet: such a stream now passes through its utility exchanger with exactly zero duty. Leftover flash noise therefore no longer costs a minimum-size exchanger (about $1,304 each). biosteam's costing rules are untouched, but network totals drop those phantom exchangers.
  • Runtime: synthesis is 1–17× slower than before, at 0.05–1.1 s per run. A cached network does not re-plan.
  • Not proven complete: MER is guaranteed only empirically, on the problems tested, and the docs say so.
  • Known upstream issue, not filed: thermosteam flashes fail silently inside two-phase water/ethanol at 20–50 % ethanol. hensmith avoids those flashes where it can, and the tests don't use those mixtures.

Commits

  • 454d71b add a pinch-outward planner that reaches MER on unsplit-feasible problems
  • ce17233 correct MER targets for phase change and synthesize networks with the MER planner
  • 1f1db9f test MER identification and achievement on 78 no-split and split-needed problems
  • 25d0684 feed point loads their equilibrium inlet, stop registry spam, fix replace_unit_heat_utilities
  • f9931ca describe the MER planner and the corrected targets in the docs
  • f914c4c allow round-off in the point-load test's MER lower bound

🤖 Generated with Claude Code

sarangbhagwat and others added 5 commits September 23, 2026 11:34
…lems

The synthesizer misses the minimum energy requirement (MER) on problems
that need no stream splitting (e.g. the Linnhoff 4-stream problem: 27.5 /
67.5 kW instead of 20 / 60 kW). Both designs consume streams from their
inlets instead of from the pinch, pinch partners are chosen by a local
score, and tick-off duties alone are not enough: some no-split problems
need a duty that stops short of tick-off and the same pair matched twice
on one side (e.g. H1 220->50 CP4, C1 140->170, C2 100->120, C3 120->250,
dTmin 10, whose only unsplit MER network is H1-C3 160, H1-C1 30, H1-C2
20, H1-C3 20).

hensmith/_planner.py is a pure-numpy planner (no biosteam objects) that
works on piecewise-linear T-H curves with flat (isothermal) segments.
Each side of the pinch becomes the same problem on level curves: 'must'
streams (hot above, cold below) are covered from the pinch outward by
matches at the current frontiers, 'flex' streams take utility at their
far end. A deterministic depth-first search tries exact event duties
(tick-off, dT limit, flex saving, vertical switch, return, preemption,
lead sharing) with repeated pairs, and prunes only on necessary
conditions: the residual cascade (split-relaxed feasibility), dead must
streams, and flat-aware pinch number/CP rules at every tight level.
Sides with a pinch-rule proof, or an exhausted work budget, get a
best-effort network (greedy dives, then per-must gap bisection) whose
uncovered heat is moved to the pinch end. Budgets are counted in work
units, never wall-clock time, so results are reproducible.

The design came from a panel of three competing prototypes judged on a
certified benchmark: 1,937 constant-CP problems (86 verified literature
problems, 1,851 random) labelled by an independent oracle (stage-wise
no-split MILP certificates checked by plain arithmetic, pinch-rule
proofs). Validation of this module: all 2,855 no-split problems of the
benchmark plus 1,680 fresh held-out problems (2-40 streams) reach MER
and pass the certificate checker; none of 761 split-proven problems is
reported as MER; every network is feasible; reruns are bit-identical.
tests/test_hxn_planner.py pins the curve primitives against brute
force, the flat-aware rules, the r002 network, repeated-pair literature
cases, hard held-out cases, determinism and best-effort feasibility.

The module is not wired into synthesize_network yet.

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

Two defects kept the heat exchanger network from its minimum energy
requirement (MER).

Targets. problem_table evaluated the cascade only at stream end
temperatures and flashed streams at vle(T=T_sat), where thermosteam keeps
whatever phase split the previous flash left. A boiling point, a dew or
bubble point, a mixture glide or a curved heat capacity inside a grid
interval could therefore hide the pinch, always making the hot-utility
target too low (by up to 100 %: e.g. cold water 300 K -> saturated vapour
against hot water 400 -> 330 K at 5 bar, dT 5 K, gave 0 instead of
2,395,102 kJ/hr). The synthesizer then "met" those targets with exchangers
whose approach dropped below T_min_app inside the unit, because HXprocess
checks the approach only at its two terminals (regression case 04 relied
on 4.63 K, case 10 on 2.07 K, against 5 K). The new private module
hensmith/_curves.py builds one piecewise-linear T-H curve per stream from
a handful of flashes: pure-component latent heat as a flat from saturated
liquid/vapour enthalpies (V-specified flashes, never vle(T=T_sat)), binary
glides traced along the bubble curve, curved single-phase stretches and
glides sampled until the chord is within 0.002 K, clipped to the stream's
own enthalpy range. problem_table's grid is the union of all curve
breakpoints; its targets now agree with an independent dense calculator
to <= 2.3e-5 on 22 phase-change stream sets (constant-Cp problems are
bit-identical to before).

Synthesis. synthesize_network is rewritten on the pinch-outward planner of
hensmith/_planner.py: the curves are sampled onto the problem-table grid
so the planner's cascade equals the table, each side is planned from the
pinch outward (repeated pairs allowed; IDs get a _<n> suffix), each
exchanger is realised as an HXprocess from exact inlet states with both
enthalpy limits at the planned outlets and dT = T_min_app - 1e-6 K as a
guard, every approach is re-verified on exact states (with local knot
refinement), and each stream gets one utility exchanger at its far end.
The old four passes, which consumed streams from their inlets and chose
pinch partners by a local score (and inflated cold-stream CP by the steam
efficiency through hx.Q), are removed. HeatExchangerNetwork orders its
system by the stream stages, converges recycle loops to ~1e-10 % energy
balance, rescales the stages of a cached network by their fractional
position on each stream, and exposes synthesis_info (status 'mer' or
'best_effort', targets, penalty, per-side proofs). Additive keywords
only: problem_table(curves=), pinch_state(side=, curve=),
load_duties(curves=), synthesize_network(info=); the 13-tuple is
unchanged (C_flow_vector now from process enthalpies). A stream already at
its outlet passes through its utility exchanger unchanged, so flash noise
no longer leaves phantom 1e-9 kJ/hr utility duties.

Doctest outputs changed as a consequence (HeatExchangerNetwork life
cycles; problem_table prints Ts[[0, -1]] because the grid now holds
curvature breakpoints). Regression loads re-recorded deliberately
(heating/cooling, kJ/hr): case 04 raised to 2.40548e6/3.601e6 and case 10
raised to its corrected MER 1.44713e7/8.46199e6, because the old values
were only reachable through internal temperature crosses and lie below the
corrected targets; case 05 lowered to MER 0/4.59225e6; cases 08 and 09
(split needed) lowered to 3.01517e6/7.35711e6 and 1.40002e7/9.56801e6.
The regression harness now also checks the exact internal approach of
every exchanger.

Validated: full suite green (CI invocation); on real systems the doctest
system and the isobutanol biorefinery now reach MER (old: +0.0068 MW and
+0.77 MW), oilcane O1 (split needed) ends 2.0 MW above target (old: 9.7
MW), synthesis takes 0.05-1.1 s, energy balance <= 1e-10 %.

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

tests/hxn_mer_cases.py holds two sets of heat exchanger network problems
and tests/test_hxn_mer.py checks them through the public
HeatExchangerNetwork facility.

NO_SPLIT (40 cases, 11 with more than 10 streams): 24 verified literature
problems (Linnhoff 4-stream; classic 4SP-23SP benchmarks; Smith, Kemp,
Turton and NPTEL examples incl. ones whose MER network needs the same pair
twice; the 12-37 stream Furman-Sahinidis, Sargent, nitric acid, Sorak-
Kravanja and Bandar Imam aromatics problems) with constant heat capacity,
and 16 real-thermodynamics problems (water, ethanol, methanol and safe
binary glides; condensers and boilers at the pinch, desuperheat-condense-
subcool, glides). Each constant-Cp case carries an unsplit MER network
(found by an independent MILP oracle) that a short arithmetic verifier in
the test module re-checks, which proves that no split is needed. The
tests check that the MER targets are identified correctly (against an
independent closed-form cascade and the published values, or against a
dense-grid reference for real thermo), that the network reaches them
(synthesis_info status 'mer'), and that every balance holds: energy
balance, heat minus cool equal to the net duty, every exchanger's duty and
mass balance, every stream's outlet, and the exact internal approach of
every exchanger.

SPLIT (38 cases, 13 with more than 10 streams): 25 literature problems and
13 real-thermo problems whose need for stream splitting is proven by the
pinch number or CP rule, re-derived in the test module. The tests check
the targets, that the network never beats them, that it reports
'best_effort', and the same balance and feasibility checks.

Tolerances are derived from the realized accuracy (MER achieved within
1e-12 of total duty for constant Cp, 1e-10 for real thermo) and, for the
real-thermo references, from the reference's own grid error at the pinch;
the module docstring gives the reasoning.

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

Point loads. Under force_ideal_thermo a near-azeotropic condenser feed
(oilcane O1, D302: 91 % ethanol vapour at 364.02 K) is a vapour below its
ideal dew point whose quenched outlet (369.84 K) is hotter than the feed,
so its curve is a point load at 369.84 K. Its first exchanger still got
the real 364.02 K feed, because the equilibrium-inlet rule reused
_end_state, which rejects states outside [T_lo, T_hi], a single point for
a point load; only pure components passed. HXprocess then capped the
partner at the real feed temperature and delivered 1.38 of the planned
3.38 MW. _point_load_inlet now feeds a point-load stream its equilibrium
state at the feed enthalpy whenever that state lies on the plan's side of
T_out (a non-equilibrium feed relaxes there without exchanging heat), at
both call sites and in StreamCurve.state_at_H.

Registry warnings. Internal stream copies were made with a bare
stream.copy(), which names the copy from its source line ('-' when the
line assigns no plain variable), so every synthesis re-registered '-' and
thermosteam warned 128 times on the HeatExchangerNetwork doctest system
(282 on oilcane O1; 2-3 before this branch). Internal copies are now
anonymous ('.'-prefixed IDs); only the final units and streams are
registered. The test filters for these warnings are narrowed accordingly.

replace_unit_heat_utilities=True called a method biosteam no longer has
(unit.owner._load_utility_cost) and paired the original utilities (cold
streams first) with the new utility exchangers (hot streams first). Each
original utility now takes its own stream's utility exchanger (the last
stage of its life cycle), costs are reloaded with _load_operation_costs,
the replacement runs after the load totals and the energy-balance check,
and a repeated simulation first restores the original utility data.

Also from the final review: the legacy pinch helpers (pinch_state,
load_duties, temperature_interval_pinch_analysis) keep their public
signatures but no longer claim to be steps of synthesize_network; the
curves= keyword briefly added to load_duties is removed again (master's
signature); a duplicate exact-approach checker in _curves is removed in
favour of the synthesizer's own, which the target tests now exercise.

Full suite: 467 passed (CI invocation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The concepts page and the tutorials described the old four matching
passes, IDs matched at most once per side, a problem-table grid of stream
end temperatures only, and a network that was "heuristic, not
guaranteed to reach MER". They now describe the stream curves and the
breakpoint grid (latent heat, non-equilibrium ends and point-load streams
as point loads), the pinch-outward planner (must/flex streams, pinch
rules, repeated pairs with _<n> IDs, best effort when splitting is
needed, both enthalpy limits, exact approach verification,
synthesis_info), cache fractions, and the guarantees as they are: MER is
never beaten, and it is reached on every certified no-split problem
tested, which is empirical, not proven.

Every captured output, figure, hero GIF/still, the demo page and the
poster were regenerated by running the docs/_demo_src generators on the
new library (ch01/ch02 now print the synthesis status and grid size;
ch04 adds pinch, exchanger-count and status columns). The quickstart now
reaches MER with 4 exchangers; the ten-stream case reaches MER with 10.
sphinx -W --keep-going builds with no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@read-the-docs-community

read-the-docs-community Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

test_non_monotone_stream_is_a_point_load failed on the PR's CI (Linux,
Python 3.12) with `assert 7366735.070962861 >= 7366735.07096287`: the
network reaches MER there, so the achieved hot-utility load equals the
problem-table target in exact arithmetic, but the two are sums over
different terms (utility exchanger duties vs. the cascade), so they differ
by a few ulps (1.2e-15 relative) in whichever direction the platform's
arithmetic rounds. A bare `>=` on a bound that holds with equality is
platform-dependent; this compares against the target less 1e-9 of the
total duty, the tolerance the sibling phase-change test already uses.
The other bare `>=` against a target (test_synthesis_info) is a
best_effort case with a strictly positive penalty, so it keeps a real gap.

Validated: full suite 467 passed locally against biosteam 3bacb0fe and
thermosteam 3f943856 (the commits the PR's CI installs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sarangbhagwat
sarangbhagwat merged commit e3e8c76 into master Sep 24, 2026
3 checks passed
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.

1 participant