Skip to content

migrate heat exchanger network to the hensmith package - #257

Open
sarangbhagwat wants to merge 18 commits into
masterfrom
migrate-hxn-to-hensmith
Open

migrate heat exchanger network to the hensmith package#257
sarangbhagwat wants to merge 18 commits into
masterfrom
migrate-hxn-to-hensmith

Conversation

@sarangbhagwat

Copy link
Copy Markdown
Member

What

Migrates the heat exchanger network facility (biosteam/facilities/hxn) and its tests out of biosteam into the new standalone hensmith package (Heat Exchanger Network Synthesis, Modeling, Integration, Thermodynamics, and Heuristics), which biosteam now depends on.

Stacked on #256 (hxn-regression-tests) — retarget to master after the #253#254#255#256 chain merges. hensmith HEAD was extracted at exactly this base (1d882c4), so this PR's diff is purely the migration.

History preservation

hensmith's master (146 commits) was extracted with git filter-repo from biosteam at 1d882c4, keeping every commit that touched biosteam/units/facilities/hxn (the pre-March-2023 path) or biosteam/facilities/hxn, back to biosteam's Oct-2020 root commit ("Reset commit history") — the earliest HXN history that exists in any repo. The migrated files are byte-identical to this branch's copies at the extraction point.

Circular dependency design

biosteam requires hensmith (setup.py), and hensmith requires biosteam (its classes subclass bst.Facility). The cycle is import-safe by direction-asymmetry:

  • hensmith imports biosteam eagerly at module import time.
  • biosteam re-exports HeatExchangerNetwork lazily via PEP 562 module __getattr__ in biosteam/__init__.py and biosteam/facilities/__init__.py, so import biosteam never initializes hensmith mid-import. bst.HeatExchangerNetwork, biosteam.facilities.HeatExchangerNetwork, from-imports, and from biosteam import * all keep working.
  • The name is deliberately absent from facilities.__all__: biosteam's __init__ star-imports facilities during initialization, which would resolve the name eagerly and re-create the cycle when hensmith is imported first.
  • import biosteam also works with hensmith absent — only accessing the name requires it.

Covered by the new tests/test_hensmith_integration.py (subprocess tests of both import orders, written to fail before the migration and pass after).

The synthesis helpers (synthesize_network, problem_table, plot_pinch_diagram, StreamLifeCycle, ProblemTable) are no longer exported from biosteam — no downstream biorefinery references them via biosteam (verified across Bioindustrial-Park). The HeatExchangerNetwork RTD page now documents hensmith.HeatExchangerNetwork and notes the move.

Validation

  • Full suite (CI invocation, hensmith on path): 74 failed, 465 passed, 62 skipped — failure list byte-identical to the pre-migration baseline at 1d882c4 (74/495/62); passed dropped by exactly the 33 migrated items (18 test_hxn functions, 12 test_hxn_regression items, 3 doctests) and gained the 3 new integration tests. All 74 failures are pre-existing (notebooks, missing optional deps, stack drift).
  • Slow set tests/test_biorefineries.py: test_sugarcane (HXN end-to-end through the lazy re-export) passes; only the pre-existing failures remain (oilcane O6/O8/O9, cornstover).
  • hensmith suite (its repo, same interpreter): 33 passed (tests + doctests).

Release ordering

setup.py now requires hensmith>=0.1.0, so hensmith must be published to PyPI before the next biosteam release / CI run / RTD build that pip-installs biosteam's dependencies (or CI temporarily installs it from git). hensmith README and RTD docs are follow-up work in that repo.

🤖 Generated with Claude Code

sarangbhagwat and others added 15 commits August 22, 2026 16:10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ut], point loads for isothermal streams

temperature_interval_pinch_analysis built the heat cascade from interval
enthalpies that were (1) evaluated by flashing hot streams at the *shifted*
temperature T - T_min_app, so a stream entering within T_min_app of its dew
point was already liquid and lost its latent heat, (2) never clipped to the
stream's actual [H_in, H_out], so non-equilibrium column outlets inflated
interval duties, and (3) assigned with the wrong sign to streams whose
outlet temperature moves against their duty. Targets were impossible
(hot target above the un-integrated heating load on the class doctest;
hot - cold != net demand on sugarcane).

The cascade is now built by problem_table(): each monotone stream is walked
once down the shifted grid with a warm-started copy flashed at the real
temperature, exact at its own end points and clipped in between, so its
contributions telescope exactly to its duty; isothermal and non-monotone
streams are point loads at their outlet temperature. Hence
hot_util_load - cold_util_load == sum(unit_duty) and both targets are
non-negative by construction. Signature and return value of
temperature_interval_pinch_analysis are unchanged.

Validated by tests/test_hxn.py: per-stream and net energy identities on the
doctest system and a 4-stream case at dT = 5/10/20 K, two-stream closed
forms, point-load handling, and the synthetic network reaching MER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… warning, clean restart after failed flash

Three review fixes to the problem_table helpers added on this branch:

1. _stream_H_at_boundaries compared grid temperatures to T_hi/T_lo with
   float equality to detect a stream's own end points; this only worked
   because Ts was built from the same floats. Assign Hs[0]/Hs[-1] by
   position instead (the caller always passes T_hi first, T_lo last, and
   monotone streams have T_hi > T_lo strictly so the slice has >= 2
   entries, asserted), and loop only over the interior boundaries.

2. The VLE-failure warning printed {stream!r} for an anonymous inlet
   copy, giving no way to trace which stream failed. Pass the inlet
   stream's own ID (set by the wrapper to s_<i>__Util_<i>) into the
   helper and name it in the warning.

3. On a failed flash, the warm-started copy was left in whatever state
   the failed VLE call put it in and reused for the next boundary. Re-copy
   stream_in in the except branch so the next boundary restarts clean.

Also rewrote the comment above the pinch_T_arr loop in
temperature_interval_pinch_analysis, which claimed non-monotone streams
get pinch = T_in "as before this fix" -- not true in general (e.g.
T_out < T_in <= pinch_cold_stream_T for a cold stream gave T_out/pinch
under the old rule). The comment now states the actual rule (streams
already on one side of the pinch, including non-monotone ones, are not
split; load_duties assigns their whole duty to one side) without
claiming equivalence with prior behavior.

Covering tests unchanged (tests/test_hxn.py + hxn doctests):
12 passed in 13.36s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re its point loads

The cascade residual[k] = sum(point_H[:, :k+1]) + sum(interval_H[:, :k])
is the heat *leaving* boundary Ts[k], after that boundary's point loads.
Testing only this for non-negativity lets a hot point load at Ts[k] (an
isothermal condenser, a phase-changing HXutility) mask a deficit in the
cold interval (Ts[k-1], Ts[k]) directly above it - but a source at Ts[k]
cannot serve a sink above Ts[k]. Example: a condensing stream at 400 K
(shifted 395 K) against a cold stream heated 392 -> 398 K returned a hot
utility target of zero; the correct target is the cold 395-398 K segment.

The target is now the minimum over both the arriving flow
(residual - point_total) and the leaving flow at every boundary, which is
the standard problem-table treatment of point loads. Also: cold_util_load
is clamped at zero in the threshold branch (residual[-1] could be negative
by a rounding-level amount, and the tests assert non-negativity);
problem_table/ProblemTable are exported in __all__ with an Examples
doctest (two-stream threshold case).

Note on synthesis behaviour (unchanged by this commit, introduced with the
wrapper rewrite): non-monotone streams (outlet temperature moving against
the duty) get pinch_T = T_in in pinch_T_arr, so load_duties places their
whole duty on the hot side. The old code gave a meaningless negative dH2
for such streams when T_in was on the wrong side of the pinch. The table
counts them as point loads at T_out; reconciling that with the synthesis
heuristic is a separate item.

Validation: regression test test_problem_table_point_load_cannot_heat_above_itself;
tests/test_hxn.py + hxn doctests 14 passed (class doctest unchanged);
canonical suite 71 failed / 476 passed / 62 skipped with the same
pre-existing failure set as the baseline; tests/test_biorefineries.py
1 failed (test_cornstover, baseline) / 4 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… utility negative

The threshold branch could leave cold_util_load = residual[-1] negative by
up to 1e-9 * scale; clamping it to zero alone would break
hot_util_load - cold_util_load == sum(unit_duty) in relative terms (the
identity the tests assert with rtol=1e-9). Absorb the rounding into
hot_util_load instead, so both loads are non-negative and the identity is
exact. Also round the problem_table doctest's cold utility to 10 kJ/hr so
it does not demand ten significant digits from a VLE enthalpy across
Python versions.

tests/test_hxn.py + hxn doctests: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HeatExchangerNetwork had no way to visualize the synthesized network. Add
hxn_synthesis.plot_pinch_diagram (pure function, returns fig/ax) and a
HeatExchangerNetwork.plot_pinch_diagram wrapper that draw the standard
diagram: cold streams (blue, left to right) above hot streams (red, right
to left) with inlet/outlet T [degC] and H [kJ/hr], one vertical connector
with its duty per HXprocess, a dashed pinch line separating cold-side from
hot-side exchangers, and circles marking utility exchangers whose duty
exceeds Qmin.

Design notes:
- The hot and cold stream of each exchanger are found from the stream
  life cycles by identity, not by parsing HX_<i>_<j>_<side> IDs.
- Which side of the pinch an exchanger belongs to comes from the new
  attributes new_HXs_hot_side / new_HXs_cold_side stored in _cost (only
  their concatenation new_HXs was kept before).
- Columns on each side are ordered by a topological sort of the
  exchangers under the constraint that every stream meets them in its
  flow direction (hot streams reversed); contradictory constraints fall
  back to synthesis order. This keeps the diagram readable and
  deterministic.
- Artists carry gids (HX:<ID>, Util:<ID>) so tests can check the drawing
  structurally.

Validation: tests/test_hxn.py gains an ordering unit test (including the
hot-stream reversal and the cyclic fallback) and a structural test on the
class doctest system; docstring example added. tests/test_hxn.py + hxn
doctests: 17 passed. Rendered the doctest system and the full sugarcane
network (HXN.units=None) and checked them against the reference figure.
Canonical suite: 74 failed / 476 passed / 62 skipped; the 3 failures
beyond the previous run (test_oilcane_O6/O8/O9, test_tire_modeling) also
fail on the parent commit bbea705 with this change stashed - they come
from the sibling biorefineries/thermosteam clone state, not from biosteam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…am ID

Stream indices alone do not say which process stream a row of the pinch
diagram is. Add a label next to each index, '<unit> - <auxiliary> (<ID>)',
with one toggle per part on plot_pinch_diagram: show_units (the unit that
owns the stream's original heat exchanger, i.e. the main unit for auxiliary
exchangers), show_auxiliary_units (the exchanger's name within that unit,
e.g. 'condenser' or a dotted path for nested auxiliaries), and
show_stream_IDs (the original exchanger's inlet stream ID; unnamed inlets
add nothing). All default to True.

The owner comes from Unit.owner and the auxiliary name from a recursive
search of get_auxiliary_units_with_names(), so no unit or stream IDs are
parsed (unlike get_original_hxs_associated_with_streams, which scans for
'.' in IDs and special-cases unit classes). plot_pinch_diagram takes the
per-stream original exchangers as a new original_hxs argument (required
when any label part is on); HeatExchangerNetwork.plot_pinch_diagram passes
original_heat_exchangers. Labels carry gid 'Label:<index>'.

Validation: new test_pinch_diagram_stream_labels (helper composition,
figure labels, toggles off -> no labels); tests/test_hxn.py + hxn doctests
18 passed. Rendered the full sugarcane network to check nested auxiliary
names. Canonical suite 74 failed / 477 passed / 62 skipped, same
pre-existing failure set as the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New show_legend toggle (default True) on plot_pinch_diagram draws a
three-column legend below the axes with proxy handles for the six symbols:
cold stream, hot stream, process heat exchange, hot utility, cold utility,
and the pinch line. Placed with bbox_to_anchor below the axes so it never
overlaps the stream rows; savefig already uses bbox_inches='tight'.

Validation: new test_pinch_diagram_legend (entries and order; toggle off
-> no legend); tests/test_hxn.py + hxn doctests 19 passed; sugarcane
network re-rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review fixes for the pinch diagram:

- Utility markers were colored by the stream they sit on, so hot utilities
  (on cold streams) were blue and cold utilities red - the opposite of the
  reference figure and of the diagram's own legend. Color them by utility
  type. test_pinch_diagram_legend now checks every Util marker's edge color
  against the legend handle for its type, so the legend and drawing cannot
  drift apart again.
- Stream labels were drawn at the same zorder as the connectors and ran
  through the hot-side columns; draw them above with a white box.
- Remove the dead empty-life-cycle branch (H = nan), which _format_H could
  not format anyway: every stream always has its utility stage.
- HeatExchangerNetwork.plot_pinch_diagram raises a clear RuntimeError when
  called before simulation instead of an AttributeError from
  _get_stream_life_cycles; regression test added.
- Close the figure in the docstring example; NumPy-style Returns section;
  comment documenting the gid contract used by the tests.

tests/test_hxn.py + hxn doctests: 20 passed; sugarcane network re-rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er circles

The unit/stream label sat lower than the bold index (both were anchored
with va='bottom' at different font sizes) and its white box could cover
exchanger and utility circles on the stream line. Put index and label on
a shared baseline (va='baseline') raised to yi + 0.25 so both sit in line
and clear of the circles.

tests/test_hxn.py + hxn doctests: 20 passed; sugarcane network re-rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With a shared baseline the smaller label still reads slightly low next to
the larger bold index; raise its baseline by 0.08 so the two appear
vertically centered on each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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>
biosteam/facilities/hxn and its tests now live in
github.com/BioSTEAMDevelopmentGroup/hensmith, extracted with full git
history (including the pre-March-2023 biosteam/units/facilities/hxn
paths) at this branch state (1d882c4).

biosteam gains hensmith as an install dependency and re-exports
HeatExchangerNetwork lazily via PEP 562 module __getattr__ in both
biosteam/__init__ and biosteam.facilities, so bst.HeatExchangerNetwork,
biosteam.facilities.HeatExchangerNetwork, from-imports, and star-imports
keep working. The laziness makes the biosteam <-> hensmith circular
dependency import-safe in both orders: importing biosteam never
initializes hensmith mid-import, and hensmith imports biosteam eagerly to
subclass bst.Facility. The name is intentionally absent from
facilities.__all__ because biosteam/__init__ star-imports facilities
during initialization, which would resolve it eagerly and re-create the
cycle. Covered by tests/test_hensmith_integration.py (subprocess tests of
both import orders); biosteam also imports cleanly with hensmith absent.

The synthesis helpers (synthesize_network, problem_table,
plot_pinch_diagram, StreamLifeCycle, ProblemTable) are no longer exported
from biosteam; no downstream biorefinery references them via biosteam
(verified across Bioindustrial-Park). Docs page for HeatExchangerNetwork
now documents hensmith.HeatExchangerNetwork and notes the move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sarangbhagwat sarangbhagwat self-assigned this Aug 24, 2026
…0, wire CI/docs

Implements the ten findings from the PR #257 code review of the HXN ->
hensmith migration.

Version (finding 1): bump biosteam 2.53.11 -> 2.54.0 and the setup.py
install_requires so hensmith can pin a floor (biosteam>=2.54.0) that the
pre-migration PyPI wheel does not satisfy. Otherwise `pip install hensmith`
onto released biosteam 2.53.11 leaves two distinct HeatExchangerNetwork
classes (the bundled biosteam.facilities.hxn copy winning
bst.HeatExchangerNetwork), silently breaking downstream isinstance checks.

Lazy re-export shim (findings 4, 8, 10): replace the two hand-copied
single-name __getattr__ shims with one shared helper in
facilities/__init__.py, delegated to from biosteam/__init__. It now
  - catches ImportError and re-raises AttributeError with a hensmith
    pointer, so hasattr()/getattr(default) no longer leak
    ModuleNotFoundError and create_all_facilities fails with a message
    that names the move;
  - caches resolved supported names into module globals (PEP 562
    memoization) so bst.HeatExchangerNetwork stops re-entering the import
    machinery on every access, and adds the companion __dir__;
  - forwards the five moved synthesis helpers (StreamLifeCycle,
    ProblemTable, problem_table, synthesize_network, plot_pinch_diagram)
    with a DeprecationWarning for one release cycle, rather than a bare
    AttributeError.

CI / docs (findings 2, 7): git-install hensmith from HEAD in build.yml
(--no-deps, since the biosteam<->hensmith pins are circular by design) so
CI validates the sibling repo instead of the frozen PyPI wheel; add
../hensmith to docs/conf.py's sys.path so autoclass resolves from a source
checkout; qualify the docs note (star-import of biosteam.facilities no
longer provides the name; all five helpers listed and marked deprecated).

Tests (findings 5, 6, 9): rework tests/test_hensmith_integration.py to
capture subprocess stderr, assert HeatExchangerNetwork stays out of
facilities.__all__ (the cycle guard), exercise the hensmith-first and
from-scratch star-import orderings, assert missing-hensmith degrades to
AttributeError, assert the deprecated helpers warn, and add a
create_all_facilities smoke test that constructs the network through the
lazy re-export.

Validation: full suite (CI invocation, local thermosteam+hensmith clones)
74 failed / 469 passed / 62 skipped -- identical documented pre-existing
failure set, passed up by the 4 new integration tests. hensmith suite 33
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yoelcortes

yoelcortes commented Aug 28, 2026

Copy link
Copy Markdown
Member

@sarangbhagwat,

Thank for putting this together!

I see you added lazy loading to avoid the circular dependency. It is a good feature, but I would like to avoid lazy loading because it adds a layer of complexity which may not be necessary since hensmith is quick to load (not to mention lazyloading adds an overhead for every attribute call and import that can stack up).

Instead, we can import HXN from hensmith in biosteam.__init__ at the very end (python does not mind this because everything from biosteam is already initialized when accessed by hensmith). Within the facility creation features, create_all_facilities, we can just access bst.HXN as we normally would (and import biosteam as bst outside the function scope).

Also, I can directly add hensmith's objects (like HXN) to the biosteam docs if I set it up as a submodule (similar to thermosteam's integrated docs). Would this be something you are interested in? Otherwise, I can always add a link to hensmith's separate documentation later.

Thank you!

sarangbhagwat and others added 2 commits September 3, 2026 19:09
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.
…review

The PR #257 review asked to drop the PEP 562 module __getattr__ shims that
re-exported HeatExchangerNetwork from hensmith: they add a layer of
indirection that is unnecessary because hensmith is quick to import.

biosteam/__init__.py now ends with a plain `import hensmith`, after every
biosteam subpackage hensmith needs (it subclasses Facility) is
initialized, and lists 'HeatExchangerNetwork' in __all__ only when that
import succeeds. Only a missing hensmith (ModuleNotFoundError naming
hensmith) is tolerated, so biosteam still imports in environments
without it; an error raised while hensmith itself initializes propagates
instead of being masked. The binding of the name is done by hensmith,
not here: in the hensmith-first import order biosteam's __init__ runs
from hensmith's own `import biosteam` while hensmith is still
initializing and HeatExchangerNetwork does not exist yet, so a
`from hensmith import HeatExchangerNetwork` in biosteam would raise
ImportError in that order. hensmith 0.1.2 therefore binds
HeatExchangerNetwork into biosteam and biosteam.facilities at the end of
its own __init__ -- whichever package finishes initializing second does
the binding -- and setup.py pins hensmith>=0.1.2 since 0.1.1 (on PyPI)
would leave the name unbound. The name stays out of facilities.__all__,
which biosteam star-imports before hensmith can bind it.

The __getattr__/__dir__ shims and the deprecated aliases for the
synthesis helpers (StreamLifeCycle, ProblemTable, problem_table,
synthesize_network, plot_pinch_diagram) are removed; those live in
hensmith only (no downstream user in biorefineries). create_all_facilities
already used bst.HeatExchangerNetwork with a module-scope import.

tests/test_hensmith_integration.py is rewritten for the eager contract:
both import orders, star-import from scratch, missing hensmith (name
absent from the namespace and from __all__), broken hensmith propagating,
the facilities.__all__ guard, and the create_all_facilities smoke test
(7 passed). hensmith suite: 33 passed. Full suite (CI invocation, with
thermosteam at f768d38 as master's submodule pointer requires): 82 failed,
462 passed, 62 skipped -- versus the recorded 74-failure baseline, 15
fewer notebook failures and 23 new doctest failures that are stack drift
in the merged master (21 single-component Stream.show representation
from thermosteam 68e89dc; BinaryDistillation and ShortcutColumn
platform-and-ladders labels not regenerated after #252), none involving
hensmith.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sarangbhagwat
sarangbhagwat changed the base branch from hxn-regression-tests to master September 4, 2026 02:28
@sarangbhagwat

sarangbhagwat commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Thanks, @yoelcortes!

Done, lazy loading removed. biosteam/__init__.py now just does import hensmith at the very end, and create_all_facilities was already calling bst.HeatExchangerNetwork with a module-level import, so nothing to change there.

One issue I ran into without lazy loading: from hensmith import HeatExchangerNetwork at the end of biosteam's __init__ only works if biosteam gets imported first. If someone runs import hensmith first (hensmith's own test suite does, for one), biosteam's __init__ ends up running from inside hensmith's import biosteam while hensmith is still half-initialized — the class doesn't exist yet, so the from-import blows up.

I fixed this issue by letting hensmith do the binding: its __init__ ends with bst.HeatExchangerNetwork = bst.facilities.HeatExchangerNetwork = HeatExchangerNetwork. Whichever package finishes initializing second does the binding, both import orders work, and there's no __getattr__ anywhere anymore. That's hensmith 0.1.2 (biosteam now pins >=0.1.2), which is now on PyPI too. I also dropped the deprecated aliases for the synthesis helpers — nothing downstream used them.

I merged your recent changes to master into this branch and retargeted the PR to master. BTW, #254#256 were merged into each other's branches a few minutes after #253 had already gone into master, so the problem-table fixes, the pinch diagram, and the regression tests aren't on master yet — they're still on fix-hxn-cache-network at f5fcbb0. This PR takes care of it: those commits are all in its history (it was branched from the HEAD of that stack), hensmith was extracted from the same HEAD so it carries all of that code, and the one bit that still needs to land in biosteam itself — the heat_transfer.py fix from 75f1402 and its tests — is in this diff. Your two changes (bc06684, 15f6c63) are ported over to hensmith too.

On docs API — yes, I'd love to have hensmith as a submodule like thermosteam (would be great if we could also add a link to the hensmith docs in the bst API and also credit hensmith appropriately elsewhere in the docs). Happy to set it up / feel free to set it up whenever you'd like!

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