Anatomy of a synthesized network#

Quickstart reported what the network saved and Pinch analysis and targets where those targets come from. Neither looked at the network itself. This chapter opens it: the flowsheet and System the facility builds for its own exchangers, the naming that makes them readable, the per-stream life cycles that record which exchangers each stream passes through and in what order, the per-stream pinch temperatures that split the design into its two sides, the options of the pinch diagram, and the cost and utility accounting the facility reports.

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

import matplotlib.pyplot as plt
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 network flowsheet#

By default, the facility never touches the streams or exchangers of the system it integrates. Everything it synthesizes – stream copies, process exchangers, utility exchangers – is created in a flowsheet of its own, and gathered into a System of its own.

print(HXN.HXN_flowsheet)
print(HXN.HXN_sys)
print([unit.ID for unit in HXN.HXN_sys.units])
sys_HXN
sys_HXN
['HX_0_2_hs', 'HX_1_2_hs', 'HX_1_4_hs', 'HX_1_3_hs', 'Util_2_cs', 'Util_3_cs', 'Util_4_cs', 'Util_0_hs', 'Util_1_hs']

HXN.HXN_flowsheet is that flowsheet, a bst.Flowsheet named after the system it belongs to, sys.ID + '_HXN' – here sys_HXN. It is created each time the facility synthesizes a network, and its registries are cleared before synthesis, so the network’s IDs neither collide with the original flowsheet’s nor accumulate across repeated simulations.

HXN.HXN_sys is the bst.System built from the synthesized units. It is named after the flowsheet, sys_HXN, in whose system registry it is registered – so HXN.HXN_flowsheet.system.sys_HXN resolves to it, just as the exchangers resolve through HXN.HXN_flowsheet.unit. It is an ordinary System holding the nine units listed on the third line. They are listed in the order the system simulates them, which follows the streams: after synthesis every stream’s stages are rewired in series, each stage feeding the next, and the path is a topological order of those connections, ties broken by the order in which the synthesis returned the exchangers – the process exchangers in plan order, then the utility exchangers, hot streams first. HX_0_2_hs therefore runs first: it is the first stage of both of its streams, while HX_1_2_hs has to wait for stream 2 to leave it. Where the stages of a network form a loop – a pair of streams matched on both sides of the pinch, or two streams matched repeatedly in alternation – the loop is torn at a recycle stream and converged by the system’s own fixed-point solver.

The IDs carry the whole topology. A process exchanger is an HXprocess named HX_<cold>_<hot>_hs when it lies above the pinch, in the hot-side design, and HX_<hot>_<cold>_cs when it lies below the pinch, in the cold-side design – note that the two orders differ: a cold-side exchanger names its hot stream first, a hot-side one its cold stream first, and in both the first number is the stream at port 0. When the same two streams are matched more than once on the same side, the second and later exchangers carry a suffix, _2, _3, …, in the order the hot stream meets them (HX_3_2_cs_2, say). A utility exchanger is an HXutility named Util_<index>_hs for a cold stream, which is finished by a hot utility above the pinch, and Util_<index>_cs for a hot stream, which is finished by a cold utility below it. The indices are stream indices: positions in the rearranged utility list of synthesize_network(), cold streams first and then hot ones, as described in Pinch analysis and targets. The stream copies are named after the exchanger they touch, s_<index>__<exchanger> on the way in and <exchanger>__s_<index> on the way out.

All four process exchangers of this network end in _hs: every match lies above the pinch, which is the same fact as the pinch diagram of Quickstart showing all four connectors to the right of the pinch line. Below the pinch there is nothing to design: no cold stream needs heat there (stream 1 enters exactly at the pinch temperature and stream 0 above it, as the pinch temperatures below show), so the only stream below it, hot stream 2, is finished by its cooler.

HXN.HXN_sys.diagram()
Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_0_2_hs, HX_1_2_hs, HX_1_4_hs and HX_1_3_hs drawn as two-inlet nodes in a chain feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs.

The synthesized network as its own flowsheet, sys_HXN. The four two-inlet nodes are the process exchangers; each takes one cold and one hot stream copy and passes both on. The five single-inlet nodes are the utility exchangers that finish each stream: Util_0_hs and Util_1_hs are heating, Util_2_cs is cooling, and Util_3_cs and Util_4_cs are drawn grey because they carry no utility at all – their inlet and outlet enthalpies are equal, 2.47e+06 and 7.18e+05 kJ/hr, so streams 3 and 4 are brought to their outlet states by process heat exchange alone. The stream names show the wiring: s_1__HX_1_2_hs enters HX_1_2_hs carrying stream 1, and HX_1_2_hs__s_1 leaves it and enters HX_1_4_hs.#

Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_0_2_hs, HX_1_2_hs, HX_1_4_hs and HX_1_3_hs drawn as two-inlet nodes in a chain feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs.

The synthesized network as its own flowsheet, sys_HXN. The four two-inlet nodes are the process exchangers; each takes one cold and one hot stream copy and passes both on. The five single-inlet nodes are the utility exchangers that finish each stream: Util_0_hs and Util_1_hs are heating, Util_2_cs is cooling, and Util_3_cs and Util_4_cs are drawn grey because they carry no utility at all – their inlet and outlet enthalpies are equal, 2.47e+06 and 7.18e+05 kJ/hr, so streams 3 and 4 are brought to their outlet states by process heat exchange alone. The stream names show the wiring: s_1__HX_1_2_hs enters HX_1_2_hs carrying stream 1, and HX_1_2_hs__s_1 leaves it and enters HX_1_4_hs.#

Stream life cycles#

A flowsheet of nine units says what exists; it does not say, for one stream, what happens to it. That is what a StreamLifeCycle records. The facility builds one per stream after synthesis, aligned with HXN.original_heat_exchangers, and stores them in HXN.stream_life_cycles.

for life_cycle in HXN.stream_life_cycles:
    life_cycle.show()
StreamLifeCycle: Stream_0, cold
	life_cycle: 
		<LifeStage: <HXprocess: HX_0_2_hs>, H_in = 5.38e+06 kJ/hr, H_out = 4.24e+07 kJ/hr>
		<LifeStage: <HXutility: Util_0_hs>, H_in = 4.24e+07 kJ/hr, H_out = 6.92e+07 kJ/hr>
	
StreamLifeCycle: Stream_1, cold
	life_cycle: 
		<LifeStage: <HXprocess: HX_1_2_hs>, H_in = 0 kJ/hr, H_out = 5.05e+06 kJ/hr>
		<LifeStage: <HXprocess: HX_1_4_hs>, H_in = 5.05e+06 kJ/hr, H_out = 5.08e+06 kJ/hr>
		<LifeStage: <HXprocess: HX_1_3_hs>, H_in = 5.08e+06 kJ/hr, H_out = 2.3e+07 kJ/hr>
		<LifeStage: <HXutility: Util_1_hs>, H_in = 2.3e+07 kJ/hr, H_out = 2.79e+08 kJ/hr>
	
StreamLifeCycle: Stream_2, hot
	life_cycle: 
		<LifeStage: <HXprocess: HX_0_2_hs>, H_in = 4.52e+07 kJ/hr, H_out = 8.12e+06 kJ/hr>
		<LifeStage: <HXprocess: HX_1_2_hs>, H_in = 8.12e+06 kJ/hr, H_out = 3.07e+06 kJ/hr>
		<LifeStage: <HXutility: Util_2_cs>, H_in = 3.07e+06 kJ/hr, H_out = 1.14e+06 kJ/hr>
	
StreamLifeCycle: Stream_3, hot
	life_cycle: 
		<LifeStage: <HXprocess: HX_1_3_hs>, H_in = 2.04e+07 kJ/hr, H_out = 2.47e+06 kJ/hr>
		<LifeStage: <HXutility: Util_3_cs>, H_in = 2.47e+06 kJ/hr, H_out = 2.47e+06 kJ/hr>
	
StreamLifeCycle: Stream_4, hot
	life_cycle: 
		<LifeStage: <HXprocess: HX_1_4_hs>, H_in = 7.51e+05 kJ/hr, H_out = 7.18e+05 kJ/hr>
		<LifeStage: <HXutility: Util_4_cs>, H_in = 7.18e+05 kJ/hr, H_out = 7.18e+05 kJ/hr>
	

A life cycle has the attributes index, the stream’s index; name, s_<index>; cold, True for a heated stream and False for a cooled one; and life_cycle, the list of stages. It is recovered from IDs alone: each exchanger ID is parsed – HX_<a>_<b>_<hs|cs>, with an optional _<n> suffix, or Util_<a>_<hs|cs> – the first number being the stream at port 0 and the second the stream at port 1, so a stream index is matched exactly and never as a substring of another index. The stages are then sorted by inlet enthalpy, ascending for a cold stream and descending for a hot one, which is flow direction in both cases since a cold stream gains enthalpy as it goes and a hot stream loses it (ties, which only stages without duty can produce, put the stream’s first side of the pinch first and its utility last).

Read stream 1, the longest life cycle here: it passes HX_1_2_hs, HX_1_4_hs and HX_1_3_hs and then its utility exchanger Util_1_hs, its enthalpy rising 0, 5.05e+06, 5.08e+06, 2.3e+07 and finally 2.79e+08 kJ/hr. Its first exchanger is the match the pinch design method asks for: stream 1 enters exactly at the pinch temperature, and HX_1_2_hs pairs it there with stream 2, the only hot stream that reaches the pinch, from which the planner builds the hot-side design outward. Stream 2 runs the other way, 4.52e+07 to 8.12e+06 to 3.07e+06 kJ/hr through two process exchangers and then to 1.14e+06 kJ/hr through Util_2_cs. Each stage’s outlet enthalpy is the next stage’s inlet enthalpy because the facility rewires the units after synthesis, making each stage’s outlet stream the inlet of the following stage. Streams 3 and 4 end on a stage whose inlet and outlet enthalpies are equal, 2.47e+06 and 7.18e+05 kJ/hr: their utility exchangers have nothing left to do.

One stage on its own:

stage = HXN.stream_life_cycles[1].life_cycle[0]
print(stage.unit, '| stream position', stage.index)
print(stage.s_in.ID, '->', stage.s_out.ID)
print(f'{stage.H_in:.4g} -> {stage.H_out:.4g} kJ/hr')
HX_1_2_hs | stream position 0
s_1__HX_1_2_hs -> HX_1_2_hs__s_1
0 -> 5.051e+06 kJ/hr

A LifeStage holds only two things, unit and index; everything else is a property read from the unit when accessed. index is the position of this stream in the exchanger’s ins and outs – 0 or 1 for an HXprocess, always 0 for an HXutility. Here it is 0, because a hot-side process exchanger is constructed with its cold stream first. s_in and s_out are unit.ins[index] and unit.outs[index], and H_in and H_out are their enthalpies, so a life cycle always reflects the current state of the network rather than a snapshot taken at synthesis. This stage takes stream 1 from 0 to 5.051e+06 kJ/hr.

Per-stream pinch temperatures#

The pinch analysis produces three arrays indexed like the life cycles, which the facility stores as HXN.inlet_Ts, HXN.outlet_Ts and HXN.pinch_Ts. The first two are each stream’s inlet temperature and its quenched outlet temperature (Pinch analysis and targets). The third is the temperature at which a stream crosses the process pinch on its own scale, where it passes from the cold-side design into the hot-side design. It is reported for information: the synthesizer cuts every stream at the pinch on the stream’s temperature-enthalpy curve itself, and pinch_Ts summarizes where that cut lies.

for i, life_cycle in enumerate(HXN.stream_life_cycles):
    kind = 'cold' if life_cycle.cold else 'hot '
    print(f'stream {i} ({kind}): in {HXN.inlet_Ts[i] - 273.15:5.1f} °C, '
          f'pinch {HXN.pinch_Ts[i] - 273.15:5.1f} °C, out {HXN.outlet_Ts[i] - 273.15:5.1f} °C')
stream 0 (cold): in  33.2 °C, pinch  33.2 °C, out  99.5 °C
stream 1 (cold): in  25.0 °C, pinch  25.0 °C, out  95.9 °C
stream 2 (hot ): in  98.2 °C, pinch  30.0 °C, out  26.8 °C
stream 3 (hot ): in  65.4 °C, pinch  64.9 °C, out  64.9 °C
stream 4 (hot ): in  64.9 °C, pinch  64.8 °C, out  64.8 °C

The process pinch of this system is a single shifted temperature, 298.15 K (Pinch analysis and targets), which stands for two real ones: 25.0 °C for cold streams and, T_min_app higher, 30.0 °C for hot streams. Each stream is then classified against the pinch temperature of its own kind.

  • A stream that reaches the pinch is cut there, and its pinch_T is the pinch temperature of its kind. Stream 2 crosses it, 98.2 to 26.8 °C, and stream 1 enters exactly at it, 25.0 °C; they are cut at 30.0 and 25.0 °C respectively.

  • A stream whose outlet stops short of the pinch never reaches it, and its pinch_T is its own outlet temperature: it lies wholly on one side, and the cut is a formality at its far end. Streams 3 and 4 are hot streams that cool only to 64.9 and 64.8 °C, far above the 30.0 °C hot-stream pinch, and those outlet temperatures are exactly what pinch_Ts reports for them.

  • A stream whose inlet is already past the pinch is likewise not cut, and its pinch_T is its inlet temperature. Stream 0 is a cold stream entering at 33.2 °C, above the 25.0 °C cold-stream pinch, so its pinch_T is 33.2 °C.

That last clause also catches isothermal and non-monotone streams – a stream whose outlet lies on the wrong side of its inlet for the sign of its duty, such as a cold stream whose equilibrium outlet ends up cooler than it entered. These get pinch_T = T_in too, but only as a label. The synthesis treats them as the problem table does, as a point load: their whole duty sits at their outlet temperature, on whichever side of the pinch that temperature lies (and, exactly at the pinch, on the side the problem table’s cascade assigns the point loads there), and such a stream enters its first process exchanger in the equilibrium state at its inlet enthalpy.

Reading the pinch diagram#

plot_pinch_diagram() draws the life cycles above. Called as HXN.plot_pinch_diagram, the facility supplies the life cycles, the inlet and outlet temperatures, the hot-side and cold-side exchanger lists, its Qmin and the original exchangers, and forwards file and every other keyword argument. Those remaining arguments are:

show_units, show_auxiliary_units and show_stream_IDs

The three parts of each stream’s label, built from its original heat exchanger and joined as <unit> - <auxiliary> (<inlet stream ID>): respectively the ID of the unit owning that exchanger, the name of the exchanger within its owner when it is an auxiliary one (condenser and the like), and the ID of that exchanger’s inlet stream. All three default to True; with all three off no label is drawn and the original exchangers are not needed, while turning any of them on without those exchangers raises ValueError.

show_legend

Adds a legend of the six symbols – cold stream, hot stream, process heat exchange, hot utility, cold utility, pinch – below the axes.

ax

Draws into a matplotlib axes provided by the caller instead of creating a figure; the figure returned is then the one that axes belongs to.

file and dpi

Save the figure to a path, at the given resolution.

Turning off the labels and the legend leaves the quantitative skeleton of the diagram:

fig, ax = plt.subplots(figsize=(9, 4.4))
HXN.plot_pinch_diagram(show_units=False, show_auxiliary_units=False,
                       show_stream_IDs=False, show_legend=False, ax=ax)
Minimal pinch diagram of the synthesized quickstart network, drawn into a caller-provided axes with the stream labels and the legend suppressed: two blue cold streams indexed 0 and 1 running left to right above three red hot streams indexed 2, 3 and 4 running right to left, T and H columns on both sides reading 33.2 °C and 5.38E6 kJ/hr to 99.5 °C and 6.92E7 kJ/hr for stream 0, four vertical process-exchanger connectors with boxed duties between the streams, a dashed pinch line with all four connectors on its hot side, and the Cold side and Hot side captions along the bottom.

The same network as the pinch diagram of Quickstart, with show_stream_IDs=False and show_legend=False – and the two unit-label options off as well – drawn into an axes created by the caller. Only the stream annotations and the legend are gone: the T and H columns on both sides remain, and so do the boxed exchanger duties in the ΔH row, the bold stream index at the inlet of each stream, the dashed pinch line and the Cold side and Hot side captions. The columns read off the life cycles above: stream 1 enters at 25.0 °C with 0.00E0 kJ/hr and leaves at 95.9 °C with 2.79E8 kJ/hr, and the first connector it meets carries the 5.05E6 kJ/hr of its first stage. The four duties are the same four as in Quickstart.#

Exchanger columns are ordered independently on each side of the pinch, by _order_exchanger_columns. Every stream’s stage order is a chain of precedence constraints between the exchangers it meets – reversed for hot streams, which are drawn right to left – and a topological sort of that graph (Kahn’s algorithm, ties broken by the order in which the synthesis returned the exchangers, the plan order from the pinch outward) lays them out so that every stream meets its exchangers in flow direction. That is why stream 1 reads its three connectors left to right in exactly the order of its life cycle. Constraints that contradict each other, which would require some stream to flow backwards, cannot be satisfied by any ordering; the given order is then used unchanged.

Energy balance and cost accounting#

What the facility reports about itself is one consistency check and a set of differences.

print(f'energy balance error: {HXN.energy_balance_percent_error:.2g} % '
      f'(warns above {100 * HXN.acceptable_energy_balance_error:.0f} %)')
print(f'original exchangers, purchase cost: {sum(HXN.original_purchase_costs):.4g} USD')
print(f'new process exchangers, purchase:   {sum(HXN.new_purchase_costs_HXp):.4g} USD')
print(f'new utility exchangers, purchase:   {sum(HXN.new_purchase_costs_HXu):.4g} USD')
print(f'facility purchase cost (added):     {HXN.purchase_costs["Heat exchangers"]:.4g} USD')
print(f'facility installed cost (added):    {HXN.installed_costs["Heat exchangers"]:.4g} USD')
print('facility heat utilities (new minus original):')
for hu in HXN.heat_utilities:
    print(f'  {hu.ID:<20} duty {hu.duty:>11.4g} kJ/hr   cost {hu.cost:>8.4g} USD/hr')
energy balance error: -1.8e-11 % (warns above 2 %)
original exchangers, purchase cost: 3.365e+05 USD
new process exchangers, purchase:   4.734e+05 USD
new utility exchangers, purchase:   2.095e+05 USD
facility purchase cost (added):     3.464e+05 USD
facility installed cost (added):    1.112e+06 USD
facility heat utilities (new minus original):
  low_pressure_steam   duty  -6.324e+07 kJ/hr   cost   -388.8 USD/hr
  chilled_water        duty   4.214e+07 kJ/hr   cost   -210.7 USD/hr
  cooling_water        duty   1.794e+07 kJ/hr   cost   -5.976 USD/hr

energy_balance_percent_error compares the heat the synthesized network moves with the heat the original exchangers moved. The numerator is twice the absolute duty of every new process exchanger – twice, because each process match takes that heat off one stream and puts it onto another – plus the process-side duty of the new utility exchangers, each agent’s duty multiplied by its heat_transfer_efficiency. The denominator is the same product summed over the original utilities. The ratio less one, times 100, is the reported percentage: -1.8e-11 % here, which is round-off.

The tolerance it is checked against, acceptable_energy_balance_error, is a fraction, not a percentage: the class attribute is 0.02, that is 2 %, and the constructor argument of the same name defaults to None, meaning that the class value is kept. The check is on the absolute error. Exceeding it warns with a RuntimeWarning naming the error and the tolerance, or raises a RuntimeError instead if the class attribute raise_energy_balance_error is set to True.

The costs are differences, clipped at zero. original_purchase_costs is the purchase cost of each original exchanger, one entry per stream, 3.365e+05 USD in total here; new_purchase_costs_HXp and new_purchase_costs_HXu are the same for the synthesized process and utility exchangers, 4.734e+05 and 2.095e+05 USD. The facility’s own purchase_costs['Heat exchangers'] – and its identical baseline_purchase_costs entry – is max(0, new - original) over those three sums, 4.734e+05 + 2.095e+05 - 3.365e+05 = 3.464e+05 USD. Its installed_costs['Heat exchangers'] is formed exactly the same way from the installed costs of the same exchangers rather than their purchase costs, and is the larger figure here, 1.112e+06 USD. Clipping at zero means a network whose exchangers happen to be cheaper than the ones they replace is reported as adding nothing rather than as a capital credit; and if the synthesis produced no process exchangers at all, both entries are set to zero and the facility reports no utilities either.

The utilities are differences too. original_utility_costs holds the original heat utilities summed by agent – reversed in sign, since they are the very objects that were negated to form the difference – and new_utility_costs holds the new utility exchangers’ utilities summed by agent. HXN.heat_utilities is the sum of the two, that is new - original, which is why every cost printed above is negative: -388.8 USD/hr of low pressure steam, -210.7 USD/hr of chilled water and -5.976 USD/hr of cooling water are savings. The duties carry the sign convention of their agent, so the steam duty is negative, -6.324e+07 kJ/hr, while the chilled and cooling water duties are positive, 4.214e+07 and 1.794e+07 kJ/hr, because cooling duties are negative to begin with and a positive difference again means less of them. Setting replace_unit_heat_utilities=True moves this reporting onto the process units instead, as Configuring the network and a larger system describes.

Where to next#