Source code for hensmith._heat_exchanger_network

# -*- 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.
"""
The `HeatExchangerNetwork` facility: a pinch analysis on the heat utilities
of a whole BioSTEAM system, the synthesis of a heat exchanger network at the
minimum energy requirement (`hensmith.hxn_synthesis.synthesize_network`;
unsplit by default, with stream splits where a side of the pinch needs them
if `stream_splitting`), and its convergence and costing as a `System`.

Created on Sat Aug 22 21:58:19 2020
@author: sarangbhagwat and yoelcp
"""
import heapq
import weakref
from collections import Counter
from collections.abc import Mapping
import biosteam as bst
import numpy as np
from .hxn_synthesis import (
    synthesize_network, StreamLifeCycle, plot_pinch_diagram, _first_inlet,
    _explicit_contributions, _held_owners, _with_auxiliaries,
)
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

#: The facilities whose `_replace_unit_heat_utilities` has written their
#: utilities over heat utilities of their units that
#: `_restore_unit_heat_utilities` has not given back yet (see
#: `_replacing_networks`).
_REPLACING = weakref.WeakSet()

def _replacing_networks(hus):
    """
    The facilities that hold heat utilities of `hus` written over by
    `HeatExchangerNetwork._replace_unit_heat_utilities` and not given back
    yet, by the ``id`` of each such heat utility (a unit simulated again
    since holds new heat utilities, which are not among them).
    """
    ids = set(map(id, hus))
    networks = {}
    for network in list(_REPLACING):
        for hu, _ in getattr(network, '_replaced_heat_utilities', None) or ():
            if id(hu) in ids: networks.setdefault(id(hu), network)
    return networks

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 _phase_flows(stream):
    """Return the molar flows of `stream` by phase, {phase: array}."""
    if isinstance(stream, bst.MultiStream):
        return {phase: row.to_array()
                for phase, row in zip(stream.phases, stream.imol.data.rows)}
    return {stream.phase: stream.mol.to_array()}

def _move_phase_flows(stream, inlets):
    """
    Give the multiphase `stream` the flows that `inlets` carry together,
    keeping its temperature, pressure and phase split, without a flash.

    Each chemical that `stream` carries keeps the fraction of its flow in
    each phase (its phase flows are scaled by its new total over its old
    one), so when every inlet scales by a common factor, every phase does,
    to rounding; a chemical that it does not carry enters in the phases in
    which it arrives (adding a phase that `stream` lacks). No float is
    compared for equality and nothing is flashed, so the split does not
    depend on the last bits of the flash that made it (a TP flash at the
    stream's own temperature and pressure can even miss its two phases).
    """
    before = _phase_flows(stream)
    carried = sum(before.values())
    total = sum([s.mol.to_array() for s in inlets])
    arriving = {}
    for s in inlets:
        for phase, flows in _phase_flows(s).items():
            arriving[phase] = (arriving[phase] + flows if phase in arriving
                               else flows)
    new_phases = [i for i in arriving if i not in stream.phases]
    if new_phases: stream.phases = (*stream.phases, *new_phases)
    held = carried > 0.
    scale = np.divide(total, carried, out=np.zeros_like(total), where=held)
    none = np.zeros_like(total)
    for phase in stream.phases:
        stream.imol[phase] = np.where(held, before.get(phase, none) * scale,
                                      arriving.get(phase, none))

def _move_flows(unit):
    """
    Give the outlets of `unit`, a unit of a cached network, the flows that
    its inlets carry now, so that the network's convergence starts from
    the new flows (`HeatExchangerNetwork._cost` moves them in path order).
    Nothing is flashed here (only the convergence, which the facility
    guards, runs the rigorous units), and every outlet keeps its
    temperature and pressure.

    - A splitter splits its feed at its fixed fractions (`Splitter._run`
      copies the feed's intensive state and flashes nothing).
    - A mixer's outlet takes the summed flows of its inlets (running the
      rigorous mixer would flash).
    - Any other unit's outlet ``k`` takes the flows of its inlet ``k``.

    A single-phase outlet takes the flows in its phase. A multiphase
    outlet keeps its phase split (`_move_phase_flows`): each chemical that
    it carries keeps the fraction of its flow in each phase, and a
    chemical new to it enters in the phase in which it arrives.
    """
    if isinstance(unit, bst.Splitter):
        unit._run()
    elif isinstance(unit, bst.Mixer):
        s_out, = unit.outs
        ins = unit.ins
        if isinstance(s_out, bst.MultiStream):
            _move_phase_flows(s_out, ins)
        else:
            s_out.mol[:] = sum([s.mol for s in ins])
    else:
        for s_in, s_out in zip(unit.ins, unit.outs):
            if isinstance(s_out, bst.MultiStream):
                _move_phase_flows(s_out, [s_in])
            else:
                s_out.mol[:] = s_in.mol

def _network_path(units, stream_life_cycles):
    """
    Return the simulation path of a synthesized network and its recycle
    (tear) streams.

    A stream's stages form a series-parallel chain (in series, except that
    the branches of a split run in parallel between its splitter chain and
    its mixer), so the network is a directed graph with an edge along every
    connection of every life cycle (`StreamLifeCycle.connections`; for an
    unsplit stream, from each stage to the next one). 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
    (possibly a splitter outlet). 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:
        for up, up_port, down, _ in life_cycle.connections():
            successors[up].append((down, up.outs[up_port]))
            N_waiting[down] += 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: the default approach temperature, required of every pair of streams unless `approach_contributions` are given. units : Iterable[Unit], optional All unit operations available to the heat exchanger network. Defaults to all unit operations in the system. stream_splitting : bool, optional Allow a process stream to be split into parallel branches that re-join. A side of the pinch that no unsplit network serves at the minimum energy requirement (a pinch-rule proof, or an unsplit search that leaves a utility penalty) is planned with splits instead, and then reaches the MER targets exactly on the planner's knots (see Notes, "Stream splitting"). Sides that an unsplit network serves are never split, so a problem that needs no split gets the same network as with the default. The branches are realized with `biosteam.Splitter` chains and rigorous `biosteam.Mixer` units, which are adiabatic and cost nothing; their structure is reported in ``info['splits']`` (`synthesize_network`) and ``synthesis_info['splits']`` (`HeatExchangerNetwork`). With `avoid_recycle`, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Defaults to False. approach_contributions : Mapping or callable, optional Approach temperature contribution of each process stream [K]: a match between hot stream h and cold stream c must keep ``c_h + c_c`` instead of `T_min_app`, and a stream without one contributes ``T_min_app / 2`` (see Notes, "Approach temperature contributions"). A mapping ``{key: value}`` whose keys name units (a unit, its ID if it has no owner, or ``(owner.ID, unit.ID)``; the nearest key along a unit's chain of owners applies), or a callable ``f(hu)`` returning the value of a heat utility. A value is None or nan (unspecified) or a finite number > 0. A sequence raises a TypeError: the order of the facility's streams is internal. May be changed between simulations. Defaults to None (every stream contributes ``T_min_app / 2``). Notes ----- Unless `stream_splitting`, 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 each pair's required approach (`T_min_app` by default) 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, or, with `stream_splitting`, one that splits streams to reach them. 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. *Stream splitting.* With `stream_splitting`, a side of the pinch that no unsplit network serves at MER is planned with stream splits and reaches the targets exactly on the planner's knots (see `synthesize_network`, Notes, for the guarantee and its limits). Each split is realized as a chain of `biosteam.Splitter` units, IDs ``Split_<stream>_<hs|cs>`` (with ``_<n>`` for the n-th split of the stream on that side, n >= 2, and ``_b<c>`` for the chain's element c >= 2), and a rigorous `biosteam.Mixer`, ID ``Mix_<stream>_<hs|cs>[_<n>]``, listed in `new_splitters` and `new_mixers` (both empty without a split) and simulated in `HXN_sys` with the exchangers. They are adiabatic and add no cost. ``synthesis_info['splits']`` holds the splits (:class:`~hensmith.hxn_synthesis.StreamSplit`: the branch fractions and the exchangers of every branch), and a split stream's life cycle lists its branch stages with their branch and fraction. *Approach temperature contributions.* With `approach_contributions`, each process stream contributes its own approach temperature ``c_j`` (``T_min_app / 2`` unless given), and every process exchanger keeps the sum of its two streams' contributions instead of `T_min_app` (see `synthesize_network`, Notes); each stream's utility exchanger gets its agent as usual. A key names a unit: the unit itself, the ID of a unit without an owner (e.g. ``'D1'``), or ``(owner.ID, unit.ID)`` for an auxiliary unit (e.g. ``('D1', 'condenser')``; a bare auxiliary ID names nothing). ``owner`` is the top owner, so the pair names every auxiliary of that ID under it: the heaters of two auxiliary flash drums of one unit, say, are told apart by keying the heater objects or the drums. A heat utility takes the value of the key nearest to its unit along the unit's chain of owners, so ``'D1'`` covers the condenser and the reboiler of column D1 and ``('D1', 'condenser')`` overrides it for the condenser; a None or nan value is an explicit default that wins at its level like any other, and two keys of the same level for a unit must agree. A unit's owner is its direct owner (the unit it was made an auxiliary of; `biosteam.Unit.owner` gives the top one) or, for an auxiliary that a unit available to the network holds without setting its owner, or an exchanger without one whose heat utilities a unit takes without holding it as an auxiliary (``self.heat_utilities = self.hx.heat_utilities``), that unit: a `biosteam.MultistageCompressor` K1 builds its compressors and intercoolers anew whenever `pr` or `n_stages` changes, without an owner, and ``'K1'`` still covers the intercoolers and ``('K1', 'K1_H1')`` names one in every simulation (the bare ``'K1_H1'`` names nothing, though it is the key of that intercooler outside the facility, see `synthesize_network`; user objects are not changed). ID keys are the robust form: a unit that rebuilds its auxiliary units at every simulation (e.g. `biosteam.HXutilities`) leaves a key holding an old auxiliary, which then names the auxiliary that took its place (the one of the same ID under owners of the same IDs), if any; an old auxiliary that never had an owner (an old intercooler of K1) names none, and raises (heat utility keys are rejected, since BioSTEAM replaces them at every simulation). The keys and values are checked against every unit available to the network, its nested auxiliary units and the exchangers they hold through their heat utilities only, before the heat utilities are taken: a key that names none of them, a bad value or two conflicting keys raise a ValueError, even while their unit is idle, but a valid key whose unit holds no heat utility in a simulation (no duty, `ignored`, or not available to a heat exchanger network) has no effect, so that, e.g., Monte Carlo samples do not fail. A key of an auxiliary that a unit may build but does not hold in a simulation is valid too, and has no effect: an old auxiliary object of the unit that none took the place of, or the ID that `biosteam.Unit.auxiliary` gives an auxiliary under a name of the `auxiliary_unit_names` of the owner or of one of its auxiliaries, unless that place holds a unit of another ID: ``(owner.ID, 'name[k]')`` where the attribute `name` holds a list of auxiliaries (e.g. ``('X1', 'heat_exchangers[2]')`` while a `biosteam.HXutilities` X1, which builds one exchanger per agent it needs, needs fewer), and ``(owner.ID, name)`` where it holds a single one (so ``('X1', 'heat_exchangers')`` raises); or the ID that a `biosteam.MultistageCompressor` K1 gives its stage n, for any n >= 1 (``('K1', 'K1_H3')`` while K1 has 2 stages). Not every unit gives its auxiliaries that ID: the heater of a `biosteam.EnzymeTreatment` T1 has the ID '', so ``('T1', 'heat_exchanger')`` raises, with a message that gives the heater's key, ``('T1', '')``. A callable is called once per heat utility at every simulation (also to check a cached network, and again if a failed cache synthesizes anew), in the order of the facility's heat utilities: it should be pure. After a simulation, `targets` takes the values that simulation resolved instead of calling it again. ``synthesis_info`` reports the contributions as used by stream ('approach_contributions', in the order of `original_heat_exchangers`) and the smallest margin over each exchanger's required approach ('min_approach_margin'), and `pinch_Ts` holds each stream's own pinch temperature. 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 (where it splits, its branches run in parallel from a splitter chain to a mixer); 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 multiset of heat exchangers (each as often as it gives a stream: biosteam lists the heat utility of a unit in parallel once per unit, by ``Unit.parallel``, which a reactor may set by its volume), `stream_splitting` and the explicit approach contributions (resolved per stream) are 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. The splitters keep their fractions, so a branch exchanger's limit is its branch's fraction of the whole stream's. 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> ]>] Two hot liquids that reach the pinch against one cold liquid break the number rule above it (case rtB05 of the test suite), so no unsplit network reaches the MER targets; with `stream_splitting`, the cold stream is split into two branches, one per hot stream: >>> bst.main_flowsheet.set_flowsheet('two_hot_one_cold') >>> bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) >>> def process_stream(ID, T_in, T_out, P, **kmol_hr): ... inlet = bst.Stream(ID + '_in', T=T_in, P=P, phase='l', ... units='kmol/hr', **kmol_hr) ... return bst.HXutility(ID, ins=inlet, T=T_out, rigorous=False) >>> units = [process_stream('H1', 400., 320., 5e5, Water=60.), ... process_stream('H2', 390., 330., 5e5, Ethanol=30.), ... process_stream('C1', 310., 410., 5e5, Water=150.), ... process_stream('H3', 360., 300., 101325., Water=200.)] >>> HXN = bst.HeatExchangerNetwork('HXN', T_min_app=10.) >>> sys = bst.System.from_units('sys', units=[*units, HXN]) >>> sys.simulate() >>> HXN.synthesis_info['status'] 'best_effort' >>> HXN.stream_splitting = True >>> sys.simulate() >>> HXN.synthesis_info['status'] 'mer' >>> [u.ID for u in HXN.new_splitters], [u.ID for u in HXN.new_mixers] (['Split_0_hs'], ['Mix_0_hs']) >>> [hx.ID for hx in HXN.new_HXs] ['HX_0_2_hs', 'HX_0_3_hs', 'HX_1_0_cs'] >>> HXN.synthesis_info['splits'] [<StreamSplit Split_0_hs: stream 0 above, 2 branches (0.5201, 0.4799)>] With approach temperature contributions of 2 K for C1 and H3 (the other streams keep ``T_min_app / 2 = 5`` K), C1 needs 7 K against H1 and H2 but only 4 K against H3, and each exchanger's `dT` guard is its pair's requirement less 1e-6 K: >>> HXN.stream_splitting = False >>> HXN.approach_contributions = {'C1': 2., 'H3': 2.} >>> sys.simulate() >>> HXN.synthesis_info['status'] 'best_effort' >>> [round(hx.dT, 6) for hx in HXN.new_HXs] [6.999999, 6.999999, 6.999999, 6.999999, 6.999999, 3.999999] """ ticket_name = 'HXN' acceptable_energy_balance_error = 0.02 raise_energy_balance_error = False # Defaults of an instance without them (e.g. one from before stream # splitting or approach contributions); `__init__` sets them on every # new facility. stream_splitting = False approach_contributions = None 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, stream_splitting=False, approach_contributions=None): 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 self.stream_splitting = stream_splitting self.approach_contributions = approach_contributions if acceptable_energy_balance_error is not None: self.acceptable_energy_balance_error = acceptable_energy_balance_error def _get_units(self): """The units whose heat utilities the network may take: the `units` option (called if callable), or else the system's units.""" sys = self.system if self.units: units = self.units if callable(units): units = units() else: units = sys.units return units def _candidate_units(self, units=None): """The units that approach contribution keys may name: each unit of `units` (defaults to `_get_units`) followed by its nested auxiliary units and the exchangers they hold through their heat utilities only, whether or not it holds a heat utility the network takes (see `hensmith.hxn_synthesis._with_auxiliaries`).""" if units is None: units = self._get_units() return _with_auxiliaries(units) def _network_contributions(self, hx_utils, units=None, held=None): """ The explicit approach contributions of `hx_utils` (aligned with it; None where unspecified), or None if none is given (see `hensmith.hxn_synthesis._explicit_contributions`). The keys are checked against every candidate unit (`_candidate_units` of `units`, the units `hx_utils` were taken from if given), so a bad key or value raises even while its unit holds no heat utility. `held`, the holders of auxiliaries held without an owner as taken earlier (see `hensmith.hxn_synthesis._held_owners`), comes before those of the units now. """ x = self.approach_contributions if x is None: return None if not (isinstance(x, Mapping) or callable(x)): raise TypeError('HeatExchangerNetwork.approach_contributions must ' 'be a mapping or a callable (the order of the ' "facility's streams is internal, so a sequence " f'has nothing to align with), not ' f'{type(x).__name__!r}') return _explicit_contributions(hx_utils, x, self.T_min_app, units=self._candidate_units(units), held=held) def _synthesis_holders(self, hx_utils, units): """ The holders of the auxiliaries held without an owner (see `hensmith.hxn_synthesis._held_owners`) at a synthesis of the heat utilities `hx_utils` of `units`, which `targets` keeps for the keys and labels of exchangers that a unit has rebuilt since. With approach contributions, they are those the keys were resolved by. Without (the default path), they serve labels only, and the synthesis never depends on them: they are taken only if an exchanger's owner chain ends outside `units` (an exchanger held without an owner, as the intercoolers of a `biosteam.MultistageCompressor`), so that none of the key machinery runs otherwise, and are None on any error (labels then go by the units as they are, plain unit IDs at worst). """ hxs = [hu.unit for hu in hx_utils] if self.approach_contributions is not None: return _held_owners(hxs + self._candidate_units(units)) try: at_hand = {id(unit) for unit in units} for hx in hxs: while getattr(hx, '_owner', None) is not None: hx = hx._owner if id(hx) not in at_hand: break else: return None return _held_owners(hxs + self._candidate_units(units)) except Exception: return None def _get_original_heat_utilties(self, units=None): if units is None: units = self._get_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.unit`: its life cycle's entry port, or its first stage) to the state in which its first process exchanger takes it, directly or, where the stream splits at its inlet, through the splitter chain (which keeps the intensive state): at equilibrium at its inlet enthalpy for a point-load stream (see `hensmith.hxn_synthesis._first_inlet`).""" if not isinstance(stage.unit, (bst.HXprocess, bst.Splitter)): 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 _REPLACING.add(self) 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) _REPLACING.discard(self) 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() # once per simulation, for both the heat utilities and the keys (a # callable `units` is called, an iterable read, once) units = list(self._get_units()) hx_utils = self._get_original_heat_utilties(units) # the explicit approach contributions by heat utility (resolved # once per simulation; None without any, and with # approach_contributions None, the default path, none of the key # machinery runs here or in `_synthesis_holders`) values = self._network_contributions(hx_utils, units) value_of = (None if values is None else {id(hu): v for hu, v in zip(hx_utils, values)}) use_cached_network = False # A network synthesized with other options (stream splitting on or # off), or by a facility from before stream splitting (no options # recorded; its life cycles have no entry port), is never reused. if (self.cache_network and hasattr(self, 'original_heat_utils') and hasattr(self, '_stage_fractions') and getattr(self, '_synthesis_options', None) == (self.stream_splitting,)): # Units are a stable key to compare whether system has changed # configuration, each as often as it gives a stream: biosteam # lists the heat utility of a unit in parallel once per unit # (Unit.parallel, which a reactor may set by its volume), so a # change of that number changes the streams; the explicit # contributions, resolved per stream, must be those the network # was synthesized with. hu_by_unit = {hu.unit: hu for hu in hx_utils} use_cached_network = ( Counter([hu.unit for hu in hx_utils]) == Counter(self.original_heat_exchangers) and getattr(self, '_resolved_contributions', None) == ( None if value_of is None else tuple( value_of[id(hu_by_unit[hx])] for hx in 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 stage_scales = self._stage_scales for i, life_cycle in enumerate(stream_life_cycles): hx = hxs[i] s_util_in = hx.ins[0] # at its entry port, as in a fresh synthesis entry = life_cycle.entry s_lc = entry.unit.ins[entry.index] s_lc.copy_like(s_util_in) self._enter_network(i, entry, 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. A branch stage (fraction f of the flow) takes # f times the whole stream's limit at its share (the # partner: f times the stream's outlet), since the # splits keep their fractions. index = lc.index key = unit.ID, index fraction = stage_fractions.get(key) if (index == 0 and fraction is not None and (unit.ID, 1) in stage_fractions): fraction = 1. if fraction is None: H_lim = None else: H_lim = H_in + fraction * (H_out - H_in) f = stage_scales.get(key, 1.) if f != 1.: H_lim *= f setattr(unit, f'H_lim{index}', H_lim) sys = self.HXN_sys for unit in sys.units: _move_flows(unit) 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. holders = self._synthesis_holders(hx_utils, units) 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, stream_splitting=self.stream_splitting, approach_contributions=( None if value_of is None else [value_of[id(hu)] for hu in hx_utils])) new_HXs = HXs_hot_side + HXs_cold_side # the realized splits (none without stream splitting) splits = synthesis_info.get('splits', ()) self.new_splitters = new_splitters = [ u for split in splits for u in split.splitters ] self.new_mixers = new_mixers = [split.mixer for split in splits] 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._holders = holders 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 + new_splitters + new_mixers IDs = set([i.ID for i in all_units]) assert len(all_units) == len(IDs) # Each stream enters at its entry port (its first stage, or # the splitter chain of a split at its inlet) as a copy of # its real inlet, and every connection of its life cycle # (for an unsplit stream, from each stage to the next) is # wired. for i, life_cycle in enumerate(stream_life_cycles): entry = life_cycle.entry s_util = hx_heat_utils_rearranged[i].unit.ins[0] s_lc = entry.unit.ins[entry.index] s_lc.copy_like(s_util) self._enter_network(i, entry, s_lc) for life_cycle in stream_life_cycles: for up, up_port, down, down_port in life_cycle.connections(): down.ins[down_port] = up.outs[up_port] # 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. A branch stage (fraction f # of the flow) has f times the whole stream's limit: its # share is that of the whole stream's limit (on the parent # basis), and f is kept in `_stage_scales`. The options the # network was synthesized with, and its explicit approach # contributions (unfilled, in the order of # `original_heat_exchangers`; None without any), are the # cache's key. self._stage_fractions = stage_fractions = {} self._stage_scales = stage_scales = {} self._synthesis_options = (self.stream_splitting,) self._resolved_contributions = ( None if value_of is None else tuple( value_of[id(hu)] for hu in hx_heat_utils_rearranged)) 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 f = lc.fraction if f != 1.: H_lim /= f stage_scales[unit.ID, lc.index] = f 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))] # each stream's own splits (``synthesis_info['splits']``); a stream # without any gets its life cycle exactly as without splitting splits = {} for split in self.synthesis_info.get('splits', ()): splits.setdefault(split.stream, []).append(split) SLCs = [StreamLifeCycle(index, index in cold_indices) for index in indices] for SLC in SLCs: if SLC.index in splits: SLC.get_life_cycle(new_HXs, new_HX_utils, splits=splits[SLC.index]) else: 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, )
[docs] def targets(self, T_min_app=None): """ The pinch analysis targets of the facility's process streams (see `hensmith.targeting.PinchTargets`): energy, utilities, area, units, capital and total annualized cost. Parameters ---------- T_min_app : float, optional Minimum approach temperature [K], finite and >= 0 (> 0 with approach contributions). Defaults to the facility's `T_min_app`. Returns ------- PinchTargets The targets, with the facility's `approach_contributions` and `force_ideal_thermo`, and its `system`, whose operating hours (if it has them, a TEA's for instance) `PinchTargets.summary` and `hensmith.targeting.supertarget` then use by default, as `hensmith.targeting.network_report` does. Their `key_units` are the units that the facility's approach contribution keys may name, so that a film coefficient key (``h=``) of a unit without a stream in a simulation has no effect, as such an approach contribution key has none. Raises ------ RuntimeError If the facility has neither `units` nor a system (it learns its system when the system is first simulated). Warns ----- RuntimeWarning After a simulation, if a stream of the network is that of an exchanger no longer among the facility's units and their nested auxiliaries (rebuilt since the facility's last simulation, see Notes): the targets are of its former state. RuntimeWarning After a simulation, if an exchanger gives more or fewer streams than the facility's units list its heat utility now: a unit in parallel whose number (``Unit.parallel``, which a reactor may set by its volume) has changed since, simulated alone (see Notes). The targets keep a stream per unit of the facility's last simulation. RuntimeWarning If the first inlet and outlet of a stream's exchanger differ in flow (``ins[0].mol`` and ``outs[0].mol``) by more than 1 % of the larger: its inlet has changed since its unit was simulated (see Notes), and the targets take the stream from that new inlet to the outlet of the former state, a state of no process. Notes ----- After a simulation, the streams are those of `original_heat_exchangers`, in the network's stream order, at the units' current states, each hot or cold by its current direction (a stream that has reversed since the synthesis, its unit simulated alone or the network reused by `cache_network`, changes sides; one without duty keeps its side): right after a synthesis, the energy targets at the facility's `T_min_app` equal ``synthesis_info['Q_hot_target']`` and ``synthesis_info['Q_cold_target']`` bit for bit. The targets' `heat_utilities` are `original_heat_utils`, which with `replace_unit_heat_utilities` hold the network's utilities, not the streams' (see `hensmith.targeting.PinchTargets`). A mapping of approach contributions is resolved again by the units of `original_heat_utils`, so a mapping changed since applies (an auxiliary that a unit held without an owner at the synthesis, such as an intercooler of a `biosteam.MultistageCompressor` K1, is still that unit's, as are the targets' film coefficient keys, even after K1 has rebuilt its stages: K1 simulated alone, or a later sample of stored targets; the targets keep that relation); a callable is not called again (with `replace_unit_heat_utilities`, the heat utilities it would read hold the network's utilities by then): its values are those the last simulation resolved, and a callable set since takes effect at the next simulation. Before any simulation, the streams are those of the heat utilities the next simulation would take, prepared as the synthesis prepares them, and a callable is called once per heat utility. Nothing is stored: with `cache_network`, the targets follow the units' current states, not those of the network's synthesis. A unit that rebuilds its auxiliaries when simulated alone (K1 above, after a change of its `pr`) leaves the streams on its former stages, which are no longer in the process and keep their former states, until the system is simulated again: the targets are then of those former states, not of the process now, with a RuntimeWarning (`hensmith.targeting.network_report` raises meanwhile). Each stream runs from its exchanger's inlet now to its outlet as its unit last left it: after a feed or another stream changes, simulate the system before targeting (or the units alone, which gives the targets of the process now, unless a unit in parallel changes its number of units: biosteam lists its heat utilities once per unit, and the streams stay one per unit at the facility's last simulation, with a RuntimeWarning; `hensmith.targeting.network_report` raises). A stream whose inlet has changed in flow meanwhile warns (see Warns); a change of temperature alone keeps the flows equal and warns of nothing, its targets being right only if its unit sets its outlet by temperature (as a `biosteam.HXutility` with ``T`` does). Examples -------- The liquids of the second example of the class at 10 K (whose unsplit network falls short of the energy targets): >>> import biosteam as bst >>> bst.main_flowsheet.set_flowsheet('HXN_targets_example') >>> bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) >>> def process_stream(ID, T_in, T_out, P, **kmol_hr): ... inlet = bst.Stream(ID + '_in', T=T_in, P=P, phase='l', ... units='kmol/hr', **kmol_hr) ... return bst.HXutility(ID, ins=inlet, T=T_out, rigorous=False) >>> units = [process_stream('H1', 400., 320., 5e5, Water=60.), ... process_stream('H2', 390., 330., 5e5, Ethanol=30.), ... process_stream('C1', 310., 410., 5e5, Water=150.), ... process_stream('H3', 360., 300., 101325., Water=200.)] >>> HXN = bst.HeatExchangerNetwork('HXN', T_min_app=10.) >>> sys = bst.System.from_units('sys', units=[*units, HXN]) >>> sys.simulate() >>> HXN.synthesis_info['status'] 'best_effort' The targets [kW] are the synthesis's own, bit for bit: >>> targets = HXN.targets() >>> targets.labels ['C1', 'H3', 'H1', 'H2'] >>> round(targets.Q_hot / 3600, 3), round(targets.Q_cold / 3600, 3) (102.821, 209.035) >>> info = HXN.synthesis_info >>> targets.Q_hot == info['Q_hot_target'], targets.Q_cold == info['Q_cold_target'] (True, True) At another minimum approach temperature: >>> HXN.targets(20.) <PinchTargets: 4 streams, T_min_app = 20 K, Q_hot = 4.834e+05 kJ/hr, Q_cold = 8.658e+05 kJ/hr> """ from .targeting import _warn_stale, _miscounted_names targets, rebuilt, miscounted, stale = self._pinch_targets(T_min_app) if rebuilt is not None: warn(f'the targets of {self.ID} are of the former state of ' f"{rebuilt}, which is no longer one of the facility's units " "nor an auxiliary of one (rebuilt since the facility's last " 'simulation?): simulate the system for those of the process ' 'now', RuntimeWarning, stacklevel=2) if miscounted: warn(f'the targets of {self.ID} take a stream per heat utility ' "the facility's units listed at its last simulation, not " 'as they list them now: ' f'{_miscounted_names(miscounted)} (Unit.parallel changed ' 'since, its unit simulated alone?): simulate the system for ' 'those of the process now', RuntimeWarning, stacklevel=2) _warn_stale(stale, f' of {self.ID}') return targets
def _pinch_targets(self, T_min_app=None): """`targets` (see there), the label of the first of their streams whose exchanger is no longer among the facility's units, their nested auxiliaries and the exchangers they hold through their heat utilities only (rebuilt since the last simulation), or None, the exchangers of the others that give more or fewer streams than the facility's units list their heat utility now (a unit in parallel whose number has changed since, see `hensmith.targeting._miscounted`), and the labels of the streams whose exchanger's inlet has changed in flow since its unit was simulated (see `hensmith.targeting._stale_labels`). They are None and empty before a simulation, whose targets, `PinchTargets.from_heat_utilities`, take the heat utilities as listed now and warn of the last themselves.""" from .targeting import (PinchTargets, _rebuilt_label, _stale_labels, _miscounted) if T_min_app is None: T_min_app = self.T_min_app if (hasattr(self, 'original_heat_exchangers') and hasattr(self, 'original_heat_utils')): hxs = self.original_heat_exchangers hus = self.original_heat_utils # in stream order cold = set(self.cold_indices) # hot or cold by the current direction of each stream (the heat # utility, which replace_unit_heat_utilities overwrites, cannot # tell): a stream that has reversed since the synthesis changes # sides, one without duty keeps its side is_hot = [] for i, hx in enumerate(hxs): dH = hx.outs[0].H - hx.ins[0].H is_hot.append(dH < 0. if dH else i not in cold) x = self.approach_contributions units = list(self._get_units()) key_units = self._candidate_units(units) # the holders of the auxiliaries held without an owner as at the # synthesis, which a holder that has rebuilt them since (a # MultistageCompressor simulated alone) no longer holds held = getattr(self, '_holders', None) if callable(x) and not isinstance(x, Mapping): # Never called again: with replace_unit_heat_utilities, the # heat utilities it would read now hold the network's # utilities (or nothing, for a stream the process exchangers # serve). Its values, as the last simulation resolved them, # are in stream order. contributions = getattr(self, '_resolved_contributions', None) else: # by unit only, which the network never changes contributions = self._network_contributions(hus, units, held) targets = PinchTargets._from_exchangers( hxs, is_hot, T_min_app, contributions, self.force_ideal_thermo, None, hus, _held_owners([*hxs, *key_units], held)) targets.key_units = key_units targets.system = self.system return (targets, _rebuilt_label(hxs, targets.labels, key_units), _miscounted(hxs, targets.labels, units, key_units), _stale_labels(hxs, targets.labels)) if self.system is None and not self.units: raise RuntimeError('simulate the system (or give the facility its ' 'units) before targeting') units = list(self._get_units()) hx_utils = self._get_original_heat_utilties(units) hx_utils.sort(key=lambda x: x.duty) # as `_cost` sorts them targets = PinchTargets.from_heat_utilities( hx_utils, T_min_app, self._network_contributions(hx_utils, units), self.force_ideal_thermo, self.sort_hus_by_T, key_units=units) targets.system = self.system return targets, None, [], [] 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): 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) inlet_Ts = self.inlet_Ts outlet_Ts = self.outlet_Ts with open(filename, 'w', newline='') as file: csvWriter = csv.writer(file, delimiter=',') csvWriter.writerow(['Stream', 'Type', 'Original unit', 'HXN unit', 'H_in (kJ/hr)', 'H_out (kJ/hr)', 'T_in (C)', 'T_out (C)']) for life_cycle in stream_life_cycles: stream = life_cycle.index streamtype = 'Cold' if life_cycle.cold else 'Hot' original_unit = original_hxs[stream][0].ID if original_hxs[stream][1]: original_unit += ' - ' + original_hxs[stream][1] # One row per stage, at its own enthalpies. A stream that # splits at its inlet enters its splitter chain first, which # gets a row at the whole stream's inlet enthalpy (its first # stage is then a branch: a fraction of the flow). rows = [(stage.unit.ID, stage.H_in, stage.H_out) for stage in life_cycle.life_cycle] entry = life_cycle.entry # None if assigned by hand if entry is not None and isinstance(entry.unit, bst.Splitter): H_in = life_cycle.H_in rows.insert(0, (entry.unit.ID, H_in, H_in)) last = len(rows) - 1 for n, (ID, H_in, H_out) in enumerate(rows): T_in = inlet_Ts[stream] - 273.15 if n == 0 else None T_out = outlet_Ts[stream] - 273.15 if n == last else None csvWriter.writerow([stream, streamtype, original_unit, ID, H_in, H_out, T_in, T_out])