# -*- coding: utf-8 -*-
# hensmith: Heat Exchanger Network Synthesis, Modeling, Integration,
# Thermodynamics, and Heuristics
# Copyright (C) 2020-, Sarang Bhagwat <sarangb2@illinois.edu>, Yoel Cortes-Pena <yoelcortes@gmail.com>
#
# This module is under the UIUC open-source license. See
# github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt
# for license details.
"""
Created on Sat Aug 22 21:58:19 2020
@author: sarangbhagwat and yoelcp
"""
import heapq
import biosteam as bst
import numpy as np
from .hxn_synthesis import (
synthesize_network, StreamLifeCycle, plot_pinch_diagram, _first_inlet,
)
from warnings import warn
__all__ = ('HeatExchangerNetwork',)
#: A stream whose utility exchanger would transfer at most this fraction of
#: the stream's duty is already at its outlet: the plan leaves it no utility
#: duty (the planner resolves duties to 1e-9 of the total), and what is left
#: is the residual of the enthalpy flashes that realize the plan (at most
#: 1e-10 of the duty over the test suite, where the smallest utility duty a
#: network does leave is 3e-7 of the stream's duty).
_SERVED_RTOL = 1e-9
def _pass_through_served_streams(stream_life_cycles, original_units):
"""
Let every stream that the process exchangers bring to its outlet (to
within `_SERVED_RTOL` of its duty) leave its utility exchanger in the
state it enters it.
A rigorous `HXutility` re-flashes its feed at the outlet enthalpy even
when the feed already has it; the flash converges from its own previous
state, so the phase split can move in the last digits and, with the
enthalpies of formation, leave a spurious net duty (~1e-9 kJ/hr) that
biosteam designs and costs as a minimum-size exchanger, depending on
flash noise (e.g. present in a cached network but not in the fresh one).
Passing the feed through gives the exchanger exactly no duty, which
biosteam neither designs nor costs; an exchanger with any real duty is
left as it is (and costed as always). Utility outlets feed nothing else
in the network, so the converged network needs no further pass.
"""
for life_cycle, unit in zip(stream_life_cycles, original_units):
util = life_cycle.life_cycle[-1].unit
if not isinstance(util, bst.HXutility): continue
feed, product = util.ins[0], util.outs[0]
duty = abs(unit.outs[0].H - unit.ins[0].H)
if abs(product.H - feed.H) <= _SERVED_RTOL * duty:
product.copy_like(feed)
def _load_utility_costs(unit):
"""Recompute the utility cost of `unit` and of its owner (the unit whose
heat utilities include those of `unit`, e.g. a column for its
condenser) from their heat utilities."""
unit._load_operation_costs()
owner = unit.owner
if owner is not unit: owner._load_operation_costs()
def _network_path(units, stream_life_cycles):
"""
Return the simulation path of a synthesized network and its recycle
(tear) streams.
Every stream passes its stages in series, so the network is a directed
graph with an edge from each stage to the next one of the same stream.
The path is a topological order of that graph (Kahn's algorithm, ties
broken by the order of `units`); where the graph has a cycle (e.g. a
pair of streams matched both above and below the pinch, or repeated
matches in alternating order), the unit with the fewest unplaced
predecessors comes next and its inlets from later units become recycle
streams. Every unit then runs after all its feeders except across a
declared recycle, and the fixed-point iteration of the resulting
`System` converges the loops. (Deterministic, unlike a general network
sort, which need not settle on intertwined loops.)
"""
position = {u: i for i, u in enumerate(units)}
successors = {u: [] for u in units}
N_waiting = {u: 0 for u in units}
for life_cycle in stream_life_cycles:
stages = life_cycle.life_cycle
for a, b in zip(stages, stages[1:]):
successors[a.unit].append((b.unit, a.unit.outs[a.index]))
N_waiting[b.unit] += 1
ready = [position[u] for u in units if not N_waiting[u]]
heapq.heapify(ready)
placed = {}
while len(placed) < len(units):
if ready:
unit = units[heapq.heappop(ready)]
if unit in placed: continue
else: # a cycle: break it where the fewest feeders are missing
unit = min((u for u in units if u not in placed),
key=lambda u: (N_waiting[u], position[u]))
placed[unit] = len(placed)
for other, _ in successors[unit]:
N_waiting[other] -= 1
if not N_waiting[other] and other not in placed:
heapq.heappush(ready, position[other])
path = sorted(units, key=placed.__getitem__)
recycles = [s for unit in units for other, s in successors[unit]
if placed[other] <= placed[unit]]
return path, recycles
[docs]
class HeatExchangerNetwork(bst.Facility):
"""
Create a HeatExchangerNetwork object that will perform a pinch analysis
on the entire system's heating and cooling utility objects. The heat
exchanger network reduces the heating and cooling utility requirements
of the system and may add additional capital cost.
Parameters
----------
ID : str
Unique name for the facility.
T_min_app : float
Minimum approach temperature observed during synthesis of heat exchanger network.
units : Iterable[Unit], optional
All unit operations available to the heat exchanger network. Defaults
to all unit operations in the system.
Notes
-----
The network is synthesized without stream splits by
:func:`~hensmith.hxn_synthesis.synthesize_network`: a problem table on
the streams' temperature-enthalpy curves gives the minimum energy
requirement (MER) targets, and a planner builds each side of the pinch
from the pinch outward [1]_ [2]_, keeping `T_min_app` everywhere inside
every exchanger on the exact stream states. It reaches the targets
whenever its search finds an unsplit network that does; the same pair
of streams may then be matched more than once (IDs with a suffix
``_<n>``, e.g. ``HX_3_2_cs_2``), since series alternation can replace a
split. Where the pinch design rules prove that MER needs stream
splitting, the network is a best-effort one close to the targets. The
outcome is recorded in `synthesis_info` (a dict; see the `info` keyword
of `synthesize_network`): 'status' is 'mer' when the network's utilities
equal the targets and 'best_effort' otherwise, with the targets, the
planned utilities and, per side of the pinch, any proof that a split is
needed.
Original system stream and heat exchanger objects are preserved. All
stream copies and new HX objects can be found in a newly created
flowsheet '<sys>_HXN' where <sys> is the name of the system associated
to the HeatExchangerNetwork object. Each stream passes its exchangers in
series; the network is simulated as a `System` (`HXN_sys`) whose path
follows the streams, with the loops that repeated matches can form torn
and converged to a tight tolerance (every exchanger starts at its
planned state, so the loops are at their fixed point after one pass).
With `cache_network`, a network is reused while the set of heat
exchangers is the same: each process exchanger keeps, as its enthalpy
limit, the share of the stream's duty it had at synthesis (on the
stream that the plan serves completely on that side of the pinch; its
partner transfers that share, but never past its own outlet, and takes
the rest to its utility), and the utility exchangers
bring every stream to its new outlet. If the cached network does not
reproduce the outlets, or its energy balance is off, the network is
synthesized again.
Every utility exchanger is designed and costed by biosteam as usual. A
stream that its process exchangers bring to its outlet (within 1e-9 of
its duty: the residual of the enthalpy flashes) leaves its utility
exchanger in the state it enters it, so that exchanger has exactly no
duty and no cost instead of a spurious duty from re-flashing the
stream.
The facility's heat utilities are the new utilities less the original
ones, summed by agent (a negative utility cost is a saving). With
`replace_unit_heat_utilities`, each original heat utility takes the heat
utility of its own stream's utility exchanger instead, the utility costs
of its unit and of that unit's owner are reloaded, and the facility
carries no heat utilities. The original data are given back before the
network is costed again, so that the network is synthesized from the
units' own utilities whether or not the units were simulated again.
References
----------
.. [1] Linnhoff, B., & Hindmarsh, E. (1983). The pinch design method for
heat exchanger networks. Chemical Engineering Science, 38(5),
745-763.
.. [2] Seider, W. D., Lewin, D. R., Seader, J. D., Widagdo, S., Gani, R.,
& Ng, M. K. (2017). Product and Process Design Principles. Wiley.
Heat Exchanger Networks (Chapter 9)
Examples
--------
>>> import biosteam as bst
>>> bst.settings.set_thermo(['Water', 'Methanol', 'Glycerol'])
>>> 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 = bst.HeatExchangerNetwork('HXN', T_min_app = 5.)
>>> sys = bst.System.from_units('sys', units=[D1, D1_H1, D1_H2, F1, HXN])
>>> sys.simulate()
>>> # See all results
>>> round(HXN.actual_heat_util_load/HXN.original_heat_util_load, 2)
0.82
>>> abs(HXN.energy_balance_percent_error) < 0.01
True
>>> HXN.synthesis_info['status'] # the utilities equal the MER targets
'mer'
>>> HXN.stream_life_cycles
[<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>
]>]
"""
ticket_name = 'HXN'
acceptable_energy_balance_error = 0.02
raise_energy_balance_error = False
network_priority = -2
_N_ins = 0
_N_outs = 0
_units= {'Flow rate': 'kg/hr',
'Work': 'kW'}
def __init__(self, ID='', T_min_app=5., units=None, ignored=None, Qmin=1e-3,
force_ideal_thermo=False, cache_network=False, avoid_recycle=False,
acceptable_energy_balance_error=None, replace_unit_heat_utilities=False,
sort_hus_by_T=False):
bst.Facility.__init__(self, ID, None, None)
self.T_min_app = T_min_app
self.units = units
self.ignored = ignored
self.Qmin = Qmin
self.force_ideal_thermo = force_ideal_thermo
self.cache_network = cache_network
self.avoid_recycle = avoid_recycle
self.replace_unit_heat_utilities = replace_unit_heat_utilities
self.sort_hus_by_T = sort_hus_by_T
if acceptable_energy_balance_error is not None:
self.acceptable_energy_balance_error = acceptable_energy_balance_error
def _get_original_heat_utilties(self):
sys = self.system
if self.units:
units = self.units
if callable(units): units = units()
else:
units = sys.units
ignored = self.ignored
if ignored:
if callable(ignored): ignored = ignored()
ignored_hx_utils = sum([i.heat_utilities for i in ignored], [])
else:
ignored_hx_utils = ()
hx_utils = bst.process_tools.heat_exchanger_utilities_from_units(units)
return [i for i in hx_utils if i.duty and i not in ignored_hx_utils]
def _run(self): pass
def _enter_network(self, index, stage, stream):
"""Bring `stream` (the network copy of stream `index`'s real inlet,
which enters the network at `stage`) to the state in which its first
process exchanger takes it: at equilibrium at its inlet enthalpy for
a point-load stream (see `hensmith.hxn_synthesis._first_inlet`)."""
if not isinstance(stage.unit, bst.HXprocess): return
point_load = index in self.synthesis_info.get('point_loads', ())
_first_inlet(stream, point_load, self.outlet_Ts[index],
index not in self.cold_indices)
def _replace_unit_heat_utilities(self, heat_utilities, stream_life_cycles):
"""
Overwrite each original heat utility with the heat utility of its own
stream's utility exchanger, the last stage of the stream's life
cycle (`heat_utilities` and `stream_life_cycles` are both in stream
order; `new_HX_utils` is not: it lists the hot streams first), and
reload the utility costs of its unit and of the unit's owner. The
original data are kept for `_restore_unit_heat_utilities`.
"""
replaced = []
for hu, life_cycle in zip(heat_utilities, stream_life_cycles):
new = life_cycle.life_cycle[-1].unit.heat_utilities[0]
replaced.append((hu, hu.copy()))
if new.agent: hu.copy_like(new)
else: hu.empty() # a stream the process exchangers serve
_load_utility_costs(hu.unit)
self._replaced_heat_utilities = replaced
def _restore_unit_heat_utilities(self):
"""
Copy their original data back into the heat utilities that
`_replace_unit_heat_utilities` last overwrote, and reload their
units' utility costs, wherever the unit still holds that heat
utility. A unit that
is simulated again replaces its heat utilities with new ones (as
every unit is before the facility in a system simulation), but one
that is not (e.g. when the network alone is simulated again, or
once per ignored utility in `_energy_balance_error_contributions`)
would otherwise hand the network its own utilities as the process
duties, and a stream that it serves completely, with no utility
left, would drop out of the next network.
"""
replaced = getattr(self, '_replaced_heat_utilities', None)
if not replaced: return
self._replaced_heat_utilities = None
for hu, original in replaced:
unit = hu.unit
if any(i is hu for i in unit.heat_utilities):
hu.copy_like(original)
_load_utility_costs(unit)
def _design(self): pass
def _load_capital_costs(self): pass # Do not replace installed costs
def _cost(self):
sys = self.system
flowsheet = bst.Flowsheet(sys.ID + '_HXN')
with flowsheet.temporary():
self._restore_unit_heat_utilities()
hx_utils = self._get_original_heat_utilties()
use_cached_network = False
if (self.cache_network and hasattr(self, 'original_heat_utils')
and hasattr(self, '_stage_fractions')):
# Units are a stable key to compare whether system has changed configuration.
hu_by_unit = {hu.unit: hu for hu in hx_utils}
use_cached_network = (
hu_by_unit.keys() == set(self.original_heat_exchangers)
)
with flowsheet.temporary(), bst.IgnoreDockingWarnings():
if use_cached_network:
# Keep the same configuration, but update stream life cycles and heat exchanger specifications.
# Note that stream_life_cycles are aligned with original_heat_exchangers (from synthesize_network).
# Heat utils are rearranged so that they align with stream_life_cycles too.
hxs = self.original_heat_exchangers
hx_heat_utils_rearranged = [hu_by_unit[hx] for hx in hxs]
stream_life_cycles = self.stream_life_cycles
new_HXs = self.new_HXs
new_HX_utils = self.new_HX_utils
stage_fractions = self._stage_fractions
for i, life_cycle in enumerate(stream_life_cycles):
hx = hxs[i]
s_util_in = hx.ins[0]
stage = life_cycle.life_cycle[0]
s_lc = stage.unit.ins[stage.index]
s_lc.copy_like(s_util_in)
self._enter_network(i, stage, s_lc)
H_in = s_util_in.H
H_out = hx.outs[0].H
for lc in life_cycle.life_cycle:
unit = lc.unit
if isinstance(unit, bst.HXutility):
unit.H = H_out
continue
# Each exchanger keeps the share of its "must"
# stream's duty (port 1: the hot stream above the
# pinch, the cold stream below it), which the plan
# serves completely; the partner (port 0), which
# leaves its remainder to its utility, takes what
# that transfers up to its own outlet (its planned
# share would cut the must short when only the
# must's duty changed; no limit at all would let a
# grown must pull it past its outlet, so that its
# utility runs backwards). A port without a limit
# in the synthesis (see `synthesize_network`) gets
# none.
index = lc.index
fraction = stage_fractions.get((unit.ID, index))
if (index == 0 and fraction is not None
and (unit.ID, 1) in stage_fractions):
fraction = 1.
setattr(unit, f'H_lim{index}', None if fraction is None
else H_in + fraction * (H_out - H_in))
sys = self.HXN_sys
for unit in sys.units:
for s_in, s_out in zip(unit.ins, unit.outs):
if isinstance(s_out, bst.MultiStream):
s_out.F_mol = s_in.F_mol
if not s_out.mol.sparse_equal(s_in.mol):
s_out.copy_flow(s_in)
s_out.vle(T=s_out.T, P=s_out.P)
else:
s_out.mol[:] = s_in.mol
else:
# Signed-duty order is the default matching priority of the
# synthesis passes: smallest heating duty first among the cold
# streams, largest cooling duty first among the hot streams.
hx_utils.sort(key = lambda x: x.duty)
self.HXN_flowsheet = HXN_F = flowsheet
for i in HXN_F.registries: i.clear()
self.synthesis_info = synthesis_info = {}
HXs_hot_side, HXs_cold_side, new_HX_utils, hxs, T_in_arr,\
T_out_arr, pinch_T_arr, C_flow_vector, hx_heat_utils_rearranged, streams_inlet, stream_HXs_dict,\
hot_indices, cold_indices = \
synthesize_network(hx_utils, self.T_min_app, self.Qmin,
self.force_ideal_thermo, self.avoid_recycle,
self.sort_hus_by_T, info=synthesis_info)
new_HXs = HXs_hot_side + HXs_cold_side
self.new_HXs_hot_side = HXs_hot_side
self.new_HXs_cold_side = HXs_cold_side
self.cold_indices = cold_indices
self.original_heat_exchangers = hxs
self.new_HXs = new_HXs
self.new_HX_utils = new_HX_utils
self.streams_inlet = streams_inlet
stream_life_cycles = self._get_stream_life_cycles()
self.stream_HXs_dict = stream_HXs_dict
self.pinch_Ts = pinch_T_arr
self.inlet_Ts = T_in_arr
self.outlet_Ts = T_out_arr
all_units = new_HXs + new_HX_utils
IDs = set([i.ID for i in all_units])
assert len(all_units) == len(IDs)
for i, life_cycle in enumerate(stream_life_cycles):
stage = life_cycle.life_cycle[0]
s_util = hx_heat_utils_rearranged[i].unit.ins[0]
s_lc = stage.unit.ins[stage.index]
s_lc.copy_like(s_util)
self._enter_network(i, stage, s_lc)
for life_cycle in stream_life_cycles:
s_out = None
for i in life_cycle.life_cycle:
unit = i.unit
if s_out: unit.ins[i.index] = s_out
s_out = unit.outs[i.index]
# For the cached network: the share of its stream's duty at
# which each process stage's enthalpy limit sits, so that a
# changed feed rescales every stage instead of letting the
# first one take the whole duty.
self._stage_fractions = stage_fractions = {}
for i, life_cycle in enumerate(stream_life_cycles):
hx = hx_heat_utils_rearranged[i].unit
H_in = hx.ins[0].H
span = hx.outs[0].H - H_in
for lc in life_cycle.life_cycle:
unit = lc.unit
if isinstance(unit, bst.HXutility): continue
H_lim = getattr(unit, f'H_lim{lc.index}')
if H_lim is None: continue
stage_fractions[unit.ID, lc.index] = (
(H_lim - H_in) / span if span else 0.
)
path, recycles = _network_path(all_units, stream_life_cycles)
self.HXN_sys = sys = bst.System(HXN_F.ID, path,
recycle=recycles or None)
# Every stage starts at its planned state (synthesize_network
# runs each exchanger once), so recycle loops (e.g. a pair
# matched above and below the pinch) are at their fixed point
# after the first pass; tight tolerances close the energy
# balance to ~1e-10 % (molar flows never change: the
# temperature criterion governs; 1e-8 K sits above the flash
# noise).
sys.set_tolerance(method='fixedpoint', subsystems=True,
mol=1e-9, rmol=1e-12, T=1e-8, rT=1e-12,
maxiter=200)
original_purchase_costs = [hx.purchase_cost for hx in hxs]
original_installed_costs = [hx.installed_cost for hx in hxs]
sys._setup()
try:
sys.converge()
except:
for i in sys.units: i._run()
warn('heat exchanger network was not able to converge', RuntimeWarning)
_pass_through_served_streams(
stream_life_cycles, [hu.unit for hu in hx_heat_utils_rearranged]
)
for i in sys.units: i._summary()
for i in range(len(stream_life_cycles)):
hx = hx_heat_utils_rearranged[i].unit
P = hx.ins[0].P
s_util = hx.outs[0]
lc = stream_life_cycles[i].life_cycle[-1]
s_lc = lc.unit.outs[lc.index]
IDs = tuple([i.ID for i in s_util.available_chemicals])
if use_cached_network:
try:
assert np.isfinite(hx.installed_cost)
np.testing.assert_allclose(s_util.imol[IDs], s_lc.imol[IDs])
np.testing.assert_allclose(P, s_lc.P, rtol=1e-3, atol=0.1)
np.testing.assert_allclose(s_util.H, s_lc.H, rtol=1e-3, atol=1.)
except AssertionError as e:
msg = ("heat exchanger network cache algorithm failed, "
f"cached network ignored: {e}")
warn(msg, RuntimeWarning, stacklevel=2)
del self.original_heat_utils
self._cost()
return
else:
np.testing.assert_allclose(s_util.imol[IDs], s_lc.imol[IDs], rtol=1e-3, atol=0.1)
np.testing.assert_allclose(P, s_lc.P, rtol=1e-3, atol=0.1)
np.testing.assert_allclose(s_util.H, s_lc.H, rtol=1e-3, atol=1.)
new_purchase_costs_HXp = []
new_purchase_costs_HXu = []
new_installed_costs_HXp = []
new_installed_costs_HXu = []
new_utility_costs = []
for hx in new_HX_utils:
new_installed_costs_HXu.append(hx.installed_cost)
new_purchase_costs_HXu.append(hx.purchase_cost)
new_utility_costs.append(hx.utility_cost)
for new_HX in new_HXs:
new_purchase_costs_HXp.append(new_HX.purchase_cost)
new_installed_costs_HXp.append(new_HX.installed_cost)
hu_sums1 = bst.HeatUtility.sum_by_agent(hx_heat_utils_rearranged)
new_heat_utils = sum([hx.heat_utilities for hx in new_HX_utils], [])
hu_sums2 = bst.HeatUtility.sum_by_agent(new_heat_utils)
# to change sign on duty without switching heat/cool (i.e. negative costs):
for hu in hu_sums1: hu.reverse()
hus_final = bst.HeatUtility.sum_by_agent(hu_sums1 + hu_sums2)
Q_bal = (
(2.*sum([abs(i.Q) for i in new_HXs])
+ sum([abs(i.duty * i.agent.heat_transfer_efficiency) for i in hu_sums2]))
/ sum([abs(i.duty * i.agent.heat_transfer_efficiency) for i in hu_sums1])
)
energy_balance_error = Q_bal - 1
self.energy_balance_percent_error = 100 * energy_balance_error
if new_HXs:
self.installed_costs['Heat exchangers'] = max(0, (
sum(new_installed_costs_HXp)
+ sum(new_installed_costs_HXu)
- sum(original_installed_costs)
))
self.purchase_costs['Heat exchangers'] = self.baseline_purchase_costs['Heat exchangers'] = max(0, (
sum(new_purchase_costs_HXp)
+ sum(new_purchase_costs_HXu)
- sum(original_purchase_costs)
))
# with replace_unit_heat_utilities, the units carry the new
# utilities (replaced below, once the network is final)
self.heat_utilities = (
[] if self.replace_unit_heat_utilities else hus_final
)
else: # if no matches were made, retain all original HXutilities (i.e., don't add the -- relatively minor -- differences between new and original HXutilities)
self.installed_costs['Heat exchangers'] = 0.
self.baseline_purchase_costs['Heat exchangers'] = self.purchase_costs['Heat exchangers'] = 0.
self.heat_utilities = []
self.original_heat_utils = hx_heat_utils_rearranged
self.original_purchase_costs = original_purchase_costs
self.original_utility_costs = hu_sums1
self.new_purchase_costs_HXp = new_purchase_costs_HXp
self.new_purchase_costs_HXu = new_purchase_costs_HXu
self.new_utility_costs = hu_sums2
new_hus = bst.process_tools.heat_exchanger_utilities_from_units(new_HX_utils)
hus_heating = [hu for hu in hx_utils if hu.duty > 0]
hus_cooling = [hu for hu in hx_utils if hu.duty < 0]
self.original_heat_util_load = sum([hu.duty for hu in hus_heating])
self.original_cool_util_load = sum([abs(hu.duty) for hu in hus_cooling])
self.actual_heat_util_load = sum([hu.duty for hu in new_hus if hu.duty>0])
self.actual_cool_util_load = sum([abs(hu.duty) for hu in new_hus if hu.duty<0])
if abs(energy_balance_error) > self.acceptable_energy_balance_error:
if use_cached_network:
del self.original_heat_utils
self._cost()
return
msg = ("heat exchanger network energy balance is off by "
f"{energy_balance_error:.2%} (an absolute error greater "
f"than {self.acceptable_energy_balance_error:.2%})")
if self.raise_energy_balance_error:
raise RuntimeError(msg)
else:
warn(msg, RuntimeWarning, stacklevel=2)
# Last, so that nothing above (the loads, a re-synthesis) reads
# the original heat utilities after they are overwritten
if new_HXs and self.replace_unit_heat_utilities:
self._replace_unit_heat_utilities(hx_heat_utils_rearranged,
stream_life_cycles)
def _energy_balance_error_contributions(self):
original_ignored = ignored = self.ignored
if ignored and callable(ignored): ignored = ignored()
energy_balance_errors = {}
for hu in self._get_original_heat_utilties():
self.ignored = list(ignored or ()) + [hu.unit]
if hasattr(hu.unit, 'owner'):
ID = hu.unit.owner.ID, hu.unit.ID
else:
ID = hu.unit.ID
try:
self.simulate()
except:
energy_balance_errors[ID] = (hu, None)
else:
energy_balance_errors[ID] = (hu, self.energy_balance_percent_error)
self.ignored = original_ignored
return energy_balance_errors
def _get_stream_life_cycles(self):
cold_indices = self.cold_indices
new_HXs = self.new_HXs
new_HX_utils = self.new_HX_utils
streams = self.streams_inlet
indices = [i for i in range(len(streams))]
SLCs = [StreamLifeCycle(index, index in cold_indices) for index in indices]
for SLC in SLCs:
SLC.get_life_cycle(new_HXs, new_HX_utils)
stream_life_cycles = SLCs
self.stream_life_cycles = stream_life_cycles
return stream_life_cycles
[docs]
def plot_pinch_diagram(self, file=None, **kwargs):
"""
Draw the pinch diagram of the synthesized network; see
:func:`~hensmith.hxn_synthesis.plot_pinch_diagram`
for the keyword arguments. Returns the matplotlib figure and axes.
"""
if not hasattr(self, 'new_HXs_hot_side'):
raise RuntimeError('simulate the heat exchanger network before '
'plotting its pinch diagram')
return plot_pinch_diagram(
self.stream_life_cycles, self.inlet_Ts, self.outlet_Ts,
self.new_HXs_hot_side, self.new_HXs_cold_side,
Qmin=self.Qmin, original_hxs=self.original_heat_exchangers,
file=file, **kwargs,
)
def get_original_hxs_associated_with_streams(self): # pragma: no cover
original_units = self.system.units
original_heat_utils = self.original_heat_utils
original_hx_utils = [i.unit for i in original_heat_utils]
original_hxs = {}
stream_index = 0
for hx in original_hx_utils:
if '.' in hx.ID: # Names like 'U.1', i.e. non-explicitly named unit (e.g. auxillary HX)
for unit in original_units:
if isinstance(unit, bst.units.MultiEffectEvaporator):
for key, component in unit.components.items():
if isinstance(component, list):
for subcomponent in component:
if subcomponent is hx:
original_hxs[stream_index] = (unit, key)
elif component is hx:
original_hxs[stream_index] = (unit, key)
elif isinstance(unit, bst.units.BinaryDistillation)\
or isinstance(unit, bst.units.ShortcutColumn):
if unit.boiler is hx:
original_hxs[stream_index] = (unit, 'boiler')
elif unit.condenser is hx:
original_hxs[stream_index] = (unit, 'condenser')
elif hasattr(unit, 'heat_exchanger'):
if unit.heat_exchanger is hx:
original_hxs[stream_index] = (unit, 'heat exchanger')
else: # Explicitly named unit
original_hxs[stream_index] = (hx, '')
stream_index += 1
self.original_hxs = original_hxs
return original_hxs
def save_stream_life_cycles_as_csv(self): # pragma: no cover
if not hasattr(self, 'stream_life_cycles'):
self.stream_life_cycles = self._get_stream_life_cycles()
stream_life_cycles = self.stream_life_cycles
if not hasattr(self, 'original_hxs'):
self.original_hxs = self.get_original_hxs_associated_with_streams()
original_hxs = self.original_hxs
import csv
from datetime import datetime
dateTimeObj = datetime.now()
filename = 'HXN-%s_%s.%s.%s.%s.%s.csv'%(self.system.ID, dateTimeObj.year,
dateTimeObj.month, dateTimeObj.day,
dateTimeObj.hour, dateTimeObj.minute)
csvWriter = csv.writer(open(filename, 'w'), delimiter=',')
csvWriter.writerow(['Stream', 'Type', 'Original unit', 'HXN unit', 'H_in (kJ/hr)',
'H_out (kJ/hr)', 'T_in (C)', 'T_out (C)'])
stream, streamtype, original_unit, hxn_unit, H_in, H_out, T_in, T_out =\
0, 0, 0, 0, 0, 0, 0, 0
inlet_Ts = self.inlet_Ts
outlet_Ts = self.outlet_Ts
for life_cycle in stream_life_cycles:
stream = life_cycle.index
streamtype = 'Cold' if life_cycle.cold else 'Hot'
stage_no = 0
stages = life_cycle.life_cycle
len_stages = len(stages)
for stage in stages:
original_unit = original_hxs[stream][0].ID
if original_hxs[stream][1]:
original_unit+= ' - ' + original_hxs[stream][1]
hxn_unit = stage.unit
hxn_unit_ID = hxn_unit.ID
H_in = stage.H_in
H_out = stage.H_out
T_in, T_out = None, None
if stage_no == 0:
T_in = inlet_Ts[stream] - 273.15
if stage_no == len_stages - 1:
T_out = outlet_Ts[stream] - 273.15
row = [stream, streamtype, original_unit, hxn_unit_ID,
H_in, H_out, T_in, T_out]
csvWriter.writerow(row)
stage_no += 1