Configuring the network and a larger system#

The three chapters before this one held everything fixed: a minimum approach temperature of 5 K, every unit of the system in scope, and one small five-stream flowsheet. This chapter varies all three. It sweeps T_min_app over the quickstart system to expose the trade-off between recovered heat and added area, narrows the analysis with ignored=, goes through the remaining constructor options of HeatExchangerNetwork one by one, and finishes by synthesizing a ten-stream network with ten process exchangers.

Every number and figure below is output of the code shown on this page. The quickstart system built here is chapter 1’s build repeated verbatim, so this page runs on its own.

Imports and the system#

import matplotlib.pyplot as plt
import biosteam as bst
from hensmith import HeatExchangerNetwork
from hensmith.hxn_synthesis import problem_table

Two module-level helpers are defined as well. utility_hx builds one bst.Stream at a given temperature, pressure and phase, puts a bst.HXutility on it that brings it to T_out, and simulates that exchanger; the result is a single unit carrying exactly one heat utility, which is to say exactly one process stream as far as the network is concerned. boiling_hx is the same helper specialized to a liquid stream heated with rigorous VLE, so that it may cross its bubble point. Both are used only by the ten-stream system at the end of this page.

def utility_hx(ID, T, P, phase, T_out, rigorous=None, **flow):
    """A simulated HXutility acting as one process stream (kmol/hr flows)."""
    s = bst.Stream(ID + '_in', T=T, P=P, phase=phase, units='kmol/hr', **flow)
    if rigorous is None: rigorous = phase == 'g'
    hx = bst.HXutility(ID, ins=s, T=T_out, rigorous=rigorous)
    hx.simulate()
    return hx


def boiling_hx(ID, T, P, T_out, **flow):
    """A cold liquid stream heated past its bubble point (rigorous VLE)."""
    return utility_hx(ID, T, P, 'l', T_out, rigorous=True, **flow)

The system itself is the one from Quickstart: a divided shortcut column with its auxiliary condenser and reboiler, a cooler on each of its two products, and a flash whose feed is heated by an auxiliary exchanger – five heating and cooling requirements in total.

import biosteam as bst
from hensmith import HeatExchangerNetwork

bst.settings.set_thermo(['Water', 'Methanol', 'Glycerol'], cache=True)
bst.main_flowsheet.set_flowsheet('quickstart')
feed1 = bst.Stream('feed1', flow=(8000, 100, 25))
feed2 = bst.Stream('feed2', flow=(10000, 1000, 10))
D1 = bst.ShortcutColumn('D1', ins=feed1,
                        outs=('distillate', 'bottoms_product'),
                        LHK=('Methanol', 'Water'),
                        y_top=0.99, x_bot=0.01, k=2,
                        is_divided=True)
D1_H1 = bst.HXutility('D1_H1', ins=D1.outs[1], T=300)
D1_H2 = bst.HXutility('D1_H2', ins=D1.outs[0], T=300)
F1 = bst.Flash('F1', ins=feed2, outs=('vapor', 'liquid'), V=0.9, P=101325)
HXN = HeatExchangerNetwork('HXN', T_min_app=5.)
sys = bst.System.from_units('sys', units=[D1, D1_H1, D1_H2, F1, HXN])
sys.simulate()

The minimum approach temperature#

T_min_app is a plain attribute of the facility, and it is live: assigning to it and simulating again is all that is needed to see a different network. Nothing is memoized by default – cache_network is False, so each simulation runs the pinch analysis and the synthesis again from the current duties – which makes a sweep a loop over six assignments.

rows = []
for T_min_app in (2., 5., 10., 15., 20., 30.):
    HXN.T_min_app = T_min_app
    sys.simulate()
    rows.append((T_min_app, HXN.actual_heat_util_load, HXN.actual_cool_util_load,
                 HXN.installed_costs['Heat exchangers'], len(HXN.new_HXs),
                 dict(HXN.synthesis_info)))
print('T_min_app  heating    cooling    added installed  pinch     process      status')
print('[K]        [kJ/hr]    [kJ/hr]    cost [USD]       [K]       exchangers')
for T, heat, cool, cost, n, info in rows:
    pinch_T = info['plan_targets']['pinch_T']  # shifted scale
    status = info['status']
    if status != 'mer':  # process-side hot utility above the MER target
        status += f" (+{info['Q_hot'] - info['Q_hot_target']:.3g} kJ/hr)"
    print(f'{T:<9.0f}  {heat:<9.4g}  {cool:<9.4g}  {cost:<15.4g}  {pinch_T:<8.2f}  {n:<11d}  {status}')
T_min_app  heating    cooling    added installed  pinch     process      status
[K]        [kJ/hr]    [kJ/hr]    cost [USD]       [K]       exchangers
2          2.957e+08  9.219e+04  2.02e+06         298.15    3            mer
5          2.977e+08  1.936e+06  1.112e+06        298.15    4            mer
10         3.009e+08  5.009e+06  6.004e+05        298.15    4            mer
15         3.053e+08  9.144e+06  6.684e+05        323.53    8            best_effort (+6.63e+03 kJ/hr)
20         3.131e+08  1.663e+07  4.131e+05        318.53    6            best_effort (+4.7e+03 kJ/hr)
30         3.289e+08  3.157e+07  2.496e+05        308.53    5            best_effort (+837 kJ/hr)

Both utility loads rise monotonically with the approach temperature: heating goes from 2.957e+08 kJ/hr at 2 K to 3.289e+08 kJ/hr at 30 K, and cooling from 9.219e+04 to 3.157e+07 kJ/hr. The added installed cost mostly falls, from 2.02e+06 USD at 2 K to 2.496e+05 USD at 30 K. That is the classic pinch trade-off. T_min_app enters the calculation in two places, and both push the same way. In the problem table it is the amount by which hot streams are shifted down before the cascade, so a larger value moves the hot streams further from the cold ones and raises both utility targets. In the synthesis it is the approach every match must keep along its whole length – checked at every breakpoint of both streams’ temperature-enthalpy curves, and verified on their exact states. A smaller approach therefore admits more matches and lets each one transfer more heat, but the exchangers that do so work across a smaller temperature difference and need more area for the same duty – which is what the right-hand panel below prices.

The cost does not fall monotonically, though: it rises from 6.004e+05 USD at 10 K to 6.684e+05 USD at 15 K, and the last three columns say why. Up to 10 K the pinch stays at 298.15 K on the shifted scale, and the network reaches the minimum energy requirement (MER) targets – status mer – with three or four exchangers. From 15 K on, the pinch moves to the column condenser’s vapor inlet, 65.4 °C on the real scale (323.53, 318.53 and 308.53 K shifted: the same real temperature less 15, 20 and 30 K). Below that pinch the pinch design rules fail: the cold streams that reach it cannot each be paired with a hot stream whose heat capacity flow rate is at least as large, which proves that the targets need a stream split. hensmith does not split streams, so the network is a best-effort one, and the status column shows how close it comes: 6.63e+03, 4.7e+03 and 837 kJ/hr of process-side heating above the target, at most a few thousandths of a percent of the heating load. At 15 K it takes eight exchangers to get that close, among them repeated matches between the same two streams, which emulate the missing split – and which cost more than the four exchangers at 10 K. HXN.synthesis_info records each outcome, including the pinch-rule proof, under 'sides'.

T = [r[0] for r in rows]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.6))
ax1.plot(T, [r[1] / 1e6 for r in rows], 'o-', color='#d62728', label='heating utility')
ax1.plot(T, [r[2] / 1e6 for r in rows], 's-', color='#2e6db4', label='cooling utility')
ax1.set_xlabel('T_min_app [K]'); ax1.set_ylabel('utility load [GJ/hr]')
ax1.legend(fontsize=8); ax1.grid(alpha=0.3)
ax2.plot(T, [r[3] / 1e6 for r in rows], 'o-', color='k')
ax2.set_xlabel('T_min_app [K]'); ax2.set_ylabel('added installed cost [MUSD]'); ax2.grid(alpha=0.3)
fig.tight_layout()
Two-panel line plot of a minimum approach temperature sweep on the quickstart system. Left panel: utility load in GJ/hr against T_min_app in K, with the heating utility (red circles) rising gently across the top of the panel and the cooling utility (blue squares) rising from near zero along the bottom as T_min_app goes from 2 to 30 K. Right panel: added installed cost in MUSD against T_min_app, falling steeply from 2.02e+06 USD at 2 K to 6.004e+05 USD at 10 K, rising slightly to 6.684e+05 USD at 15 K, and falling again to 2.496e+05 USD at 30 K.

The T_min_app trade-off on the quickstart system. Left: the heating utility load rises from 2.957e+08 kJ/hr at 2 K to 3.289e+08 kJ/hr at 30 K and the cooling utility load from 9.219e+04 to 3.157e+07 kJ/hr, so less heat is recovered as the approach widens. Right: the added installed cost of the network falls over the same range, from 2.02e+06 USD at 2 K to 2.496e+05 USD at 30 K, most of the drop happening between 2 and 10 K; the bump at 15 K is the best-effort network that needs more exchangers once the pinch has moved. The default 5 K used throughout this tutorial sits on the steep part of the cost curve: 2.977e+08 kJ/hr of heating, 1.936e+06 kJ/hr of cooling and 1.112e+06 USD of added installed cost. Choosing T_min_app is choosing a point on these two curves; the economically sensible one depends on utility prices and on the cost of exchanger area, neither of which the network optimizes for you.#

Scoping the analysis#

By default the network analyzes every unit of the system it belongs to. Two constructor options change that. units restricts the analysis to a given set of unit operations – either an iterable of units, or a callable returning one, which is evaluated at simulation time so that the set may depend on the converged flowsheet; leaving it as None (the default) means all units of the system. ignored works the other way round: it names units whose heat utilities are dropped from the analysis, again as an iterable or a callable. Whatever survives both filters is collected with bst.process_tools.heat_exchanger_utilities_from_units, and utilities with a zero duty are discarded, leaving one process stream per remaining utility.

Excluding a unit is not only an exclusion from the synthesis: it is an exclusion from the accounting as well. original_heat_util_load and original_cool_util_load are sums over the utilities that were analyzed, so the “before” figures move too. Ignoring D1_H1, the cooler on the column’s bottoms product, makes the point:

HXN.T_min_app = 5.
HXN.ignored = [D1_H1]        # leave the bottoms cooler out of the analysis
sys.simulate()
print(f'streams in the network: {len(HXN.stream_life_cycles)}')
print(f'heating utility: {HXN.original_heat_util_load:.4g} -> {HXN.actual_heat_util_load:.4g} kJ/hr')
print(f'cooling utility: {HXN.original_cool_util_load:.4g} -> {HXN.actual_cool_util_load:.4g} kJ/hr')
HXN.ignored = None
streams in the network: 4
heating utility: 3.609e+08 -> 3.42e+08 kJ/hr
cooling utility: 1.797e+07 -> 0 kJ/hr

Four streams are analyzed instead of the five of Quickstart, and the one that left carried most of the recoverable heat of this system. On the cooling side the pool itself shrinks: chapter 1 reported 6.201e+07 -> 1.936e+06 kJ/hr, and here the same two numbers read 1.797e+07 -> 0 kJ/hr. The remaining cooling demand is recovered completely – the process exchangers bring every remaining hot stream to its outlet, and a stream served that way passes through its utility exchanger untouched, so no cooling utility is left at all – but it is a much smaller demand, and the cooling actually saved falls with it. The heating side shows the loss directly, since its pool is unchanged: the same 3.609e+08 kJ/hr of heating is only reduced to 3.42e+08 kJ/hr, against 2.977e+08 kJ/hr when D1_H1 was in scope. The bottoms cooler is a large, high-temperature hot stream, and without it there is far less heat available to serve the column’s reboiler. Setting ignored back to None restores the full analysis on the next simulation.

Other options#

The remaining keyword arguments of HeatExchangerNetwork are listed in the API Reference reference; what each of them does to the synthesis is described below. Four of them – Qmin, force_ideal_thermo, avoid_recycle and sort_hus_by_T – are passed straight through to synthesize_network(), which can also be called directly on a list of heat utilities.

Qmin is a duty floor, in kJ/hr, defaulting to 1e-3. A planned exchanger whose duty is below it (Q < Qmin) is dropped from the plan and its duty left to the utilities, which keeps numerically negligible matches out of the network; the streams simply carry that heat on to their next match or to their utility exchangers. Dropping a match never makes another one infeasible, but a large Qmin can cost MER, and HXN.synthesis_info['qmin_dropped'] lists what it removed. The same value is passed to the pinch diagram, where a utility exchanger whose enthalpy change is at or below it (<=, rather than the strict < of the synthesis) is not marked with a circle – the reason a stream that finishes on process heat alone carries no utility symbol.

cache_network (default False) reuses a synthesized topology across simulations, which is worth doing when the same system is simulated many times with slightly different inputs, as in a Monte Carlo or a sensitivity analysis. When it is on and a network has already been synthesized, the units owning the current heat utilities are compared with the exchangers behind the cached one; if the sets are identical the cached network is kept and only the stream states and the exchanger specifications are updated. Each life cycle’s first inlet is copied from the current stream. Each process exchanger keeps, as the enthalpy limit of the stream its side of the pinch must serve completely (the hot stream above the pinch, the cold stream below it), the same fraction of that stream’s duty as at synthesis, so a changed feed rescales every stage instead of letting the first one take the whole duty; its partner transfers what that limit sets, but never past its own outlet, and takes the rest to its utility exchanger, which brings every stream to its new outlet enthalpy. A reused network is not planned again, so it need not be at MER for the new duties, and synthesis_info still describes the synthesis that produced it. The reused network is then re-converged and re-checked stream by stream: the outlet of each life cycle must reproduce the original exchanger’s outlet in composition, pressure and enthalpy, and each stream’s original exchanger must have a finite installed cost. If any of those checks fails, hensmith warns with a RuntimeWarning saying that the cache algorithm failed and the cached network was ignored, discards the cache and synthesizes a fresh network. The energy balance is treated the same way but silently: a cached network whose energy balance error exceeds the tolerance is discarded and re-synthesized without a warning, and a warning is issued only if the freshly synthesized network also fails.

replace_unit_heat_utilities (default False) changes where the savings are reported. By default the facility carries the difference between the new and the original utilities as its own heat_utilities, which is why the utility rows of its results table are negative (Quickstart), and the original units keep the utilities they had before integration. With the option on, each synthesized utility exchanger’s heat utility is copied onto the corresponding original heat utility instead and the owner’s utility cost is reloaded, so the process units themselves report the reduced duties, and the facility is left with no heat_utilities of its own. The option applies only when at least one process exchanger was synthesized; if the synthesis produced no matches, the facility reports no utilities and no added cost either way.

avoid_recycle (default False) forbids matching the same hot/cold stream pair more than once anywhere – neither twice on one side of the pinch nor once on each side. Without it, the planner may match a pair repeatedly: alternating two partners in series emulates a stream split, and some unsplit MER networks need it (the repeated exchangers carry a suffix, HX_3_2_cs_2 for the second one, say). Two exchangers between the same two streams can close a loop in the network, which its System tears at a recycle stream and converges by fixed-point iteration. Turning the option on trades those networks – and possibly MER with them – for a network in which no two exchangers connect the same pair of streams.

force_ideal_thermo (default False) runs the analysis on copies of the streams made with ideal thermodynamics (i.thermo.ideal()), and the synthesized exchangers inherit that property package. It is an escape hatch for systems whose rigorous VLE is expensive or fragile; the network it produces is only as accurate as that assumption.

sort_hus_by_T (default False) reorders the streams before the analysis: heating utilities are sorted by inlet temperature in descending order and cooling utilities in ascending order. Heating utilities always precede cooling ones in the rearranged list regardless, and it is that list that fixes the stream indices used by every per-stream array, by the stream life cycles and by the pinch diagram. The order does not change the targets; it breaks ties in the planner’s search, so sorting can change the network that comes out. The default order is by signed duty, which is how the facility hands the utilities to the synthesis: cold streams from the smallest heating duty up, hot streams from the largest cooling duty down.

acceptable_energy_balance_error overrides, for one instance, the class attribute of the same name. It is a fraction, not a percentage: the default 0.02 means 2 %. After convergence the network’s heat flows are compared with the original ones, and the absolute deviation from unity of that ratio is tested against this tolerance; energy_balance_percent_error reports the same quantity multiplied by 100. Exceeding it warns with a RuntimeWarning, or raises a RuntimeError if the class attribute raise_energy_balance_error is set to True.

A larger system#

The quickstart system has five streams and produces four process exchangers. The system below has ten streams and produces ten, on both sides of the pinch, which is enough for the structure of a synthesized network to be visible. It is the ten-stream case of the regression suite, tests/test_hxn_regression.py::case_10_ten_streams, inlined here rather than imported from tests: five hot streams – two condensers (H1, H2), one partial condensation (H5) and two liquid coolers, at two different pressures – and five cold streams, two of which are taken past their bubble point with rigorous VLE (C2, C3).

bst.settings.set_thermo(['Water', 'Ethanol'], cache=True)
bst.main_flowsheet.set_flowsheet('ten_streams')
units = [utility_hx('H1', 355., 101325., 'g', 320., Ethanol=300.),
         utility_hx('H2', 420., 5e5, 'g', 340., Water=150.),
         utility_hx('H3', 395., 5e5, 'l', 330., Water=1000.),
         utility_hx('H4', 370., 101325., 'l', 310., Ethanol=700.),
         utility_hx('H5', 380., 101325., 'g', 372.5, Water=100.),   # partial condensation
         utility_hx('C1', 300., 101325., 'l', 360., Water=2000.),
         boiling_hx('C2', 330., 101325., 380., Water=200.),
         boiling_hx('C3', 320., 101325., 352., Ethanol=400.),
         utility_hx('C4', 310., 101325., 'l', 345., Water=400., Ethanol=400.),
         utility_hx('C5', 340., 101325., 'l', 390., Water=600.)]
HXN10 = HeatExchangerNetwork('HXN', T_min_app=5.)
sys10 = bst.System.from_units('sys10', units=[*units, HXN10])
sys10.simulate()
hus = HXN10.original_heat_utils
streams_inlet = [hu.unit.ins[0].copy() for hu in hus]
streams_quenched = [hu.unit.outs[0].copy() for hu in hus]
for s in streams_quenched: s.vle(H=s.H, P=s.P)
table = problem_table(streams_inlet, streams_quenched, [hu.duty < 0 for hu in hus], 5.)
print(f'process exchangers: {len(HXN10.new_HXs)}')
print(f'hot utility:  target {table.hot_util_load:.4g}, network {HXN10.actual_heat_util_load:.4g} kJ/hr')
print(f'cold utility: target {table.cold_util_load:.4g}, network {HXN10.actual_cool_util_load:.4g} kJ/hr')
# Those loads are utility-side (duty = unit_duty / heat transfer
# efficiency); the targets are process-side enthalpy differences, so
# sum the process-side duties of the network's utility exchangers too.
new_hus = [hu for hx in HXN10.new_HX_utils for hu in hx.heat_utilities]
heat = sum(hu.unit_duty for hu in new_hus if hu.unit_duty > 0)
cool = -sum(hu.unit_duty for hu in new_hus if hu.unit_duty < 0)
print(f'hot utility,  process side: target {table.hot_util_load:.4g}, network {heat:.4g} kJ/hr')
print(f'cold utility, process side: target {table.cold_util_load:.4g}, network {cool:.4g} kJ/hr')
print(f'energy balance error: {HXN10.energy_balance_percent_error:.2g} %')
print(f"synthesis status: {HXN10.synthesis_info['status']}")
fig, ax = HXN10.plot_pinch_diagram()
process exchangers: 10
hot utility:  target 1.447e+07, network 1.523e+07 kJ/hr
cold utility: target 8.462e+06, network 8.462e+06 kJ/hr
hot utility,  process side: target 1.447e+07, network 1.447e+07 kJ/hr
cold utility, process side: target 8.462e+06, network 8.462e+06 kJ/hr
energy balance error: 2e-12 %
synthesis status: mer

The targets come from problem_table() applied to the same streams the facility synthesized from, exactly as in Pinch analysis and targets. The comparison is printed twice because there are two conventions in play. The facility’s actual_heat_util_load and actual_cool_util_load are utility-side loads: the duty an agent must supply, which includes that agent’s heat transfer efficiency. The problem table knows nothing about agents – its targets are process-side enthalpy differences. Summing the unit_duty of the new utility exchangers instead, as tests/test_hxn_regression.py does, gives the process-side loads that are comparable with the targets. On the hot side the two differ: 1.523e+07 kJ/hr utility-side against 1.447e+07 kJ/hr process-side, for a target of 1.447e+07 kJ/hr. On the cold side the cooling agents used here need no such correction and both read 8.462e+06 kJ/hr, against a target of 8.462e+06 kJ/hr. The network reaches both targets – the synthesis reports mer – and those process-side numbers, 1.447e+07 and 8.462e+06 kJ/hr, are the values the regression suite records for this case. Its energy balance error is 2e-12 %.

The phase changes of these streams are what make this case demanding. Two condensers, a partial condensation and two streams boiled past their bubble points put flat and steep stretches into the stream curves, and a network checked only at the terminals of its exchangers could let two streams cross inside one of them. hensmith keeps T_min_app everywhere inside every exchanger, on the exact stream states, so its targets and its network hold for the real streams.

Pinch diagram of the synthesized ten-stream network: five blue cold streams labelled C5, C4, C2, C1 and C3 drawn left to right above five red hot streams labelled H1, H2, H4, H3 and H5 drawn right to left, joined by ten vertical process-exchanger connectors, five of them to the left of the dashed pinch line and five to the right, with hot utility circles at the outlets of three cold streams and cold utility circles at the outlets of four hot streams.

The synthesized ten-stream network. The five cold streams (blue, indices 0 to 4) run left to right and the five hot streams (red, indices 5 to 9) right to left, each annotated with its inlet and outlet temperature and enthalpy flow, the outlets being the quenched end states the analysis works from. The ten vertical connectors are the process exchangers, each labelled with its duty in kJ/hr; five of them lie to the left of the dashed pinch line, in the cold-side design, and five to the right, in the hot-side design. Streams 0 (C5) and 5 (H1) are matched with each other on both sides of the pinch, which closes a loop in the network that its System tears and converges. Three cold streams still need a hot utility at their outlet (open red circles on the right) and four hot streams a cold utility (open blue circles on the left); the others are brought to their outlets by process heat exchange alone. The shaded background marks the two sides of the pinch.#

Where to next#

  • Key Concepts – the pinch concepts, the MER planner and the validation behind everything shown in this tutorial.

  • API Reference – the full API reference, including a table of every constructor option and every attribute the facility sets.