Source code for hensmith.targeting

# -*- coding: utf-8 -*-
# hensmith: Heat Exchanger Network Synthesis, Modeling, Integration,
# Thermodynamics, and Heuristics
# Copyright (C) 2020-, Sarang Bhagwat <sarangbhagwat.developer@gmail.com>
#
# This module is under the UIUC open-source license. See
# github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt
# for license details.
"""
Targeting: what a heat exchanger network of a set of process streams can
achieve, before (or instead of) synthesizing one (the energy targets bound
every network, the others are estimates). `PinchTargets` holds the
problem table of the streams and derives from it the composite and grand
composite curves, the multiple-utility targets priced by biosteam
(`PinchTargets.utility_targets`), the balanced composite curves, and the
area, units, capital cost and total annualized cost targets
(`PinchTargets.summary`); `supertarget` sweeps the minimum approach
temperature for the least total annualized cost, and `network_report`
compares a synthesized network with its targets. `plot_composite_curves`,
`plot_grand_composite_curve` and `plot_supertargets` draw them.
"""
import math
import os
from collections import namedtuple
from collections.abc import Mapping, Sequence
from contextlib import contextmanager
from numbers import Integral, Real
from warnings import catch_warnings, filterwarnings, warn
import numpy as np
from matplotlib.legend import Legend
from matplotlib.patches import Patch
from matplotlib.path import Path
import biosteam as bst
from ._curves import StreamCurve, _T_EQ, _copy
from ._planner import _approach_contributions, _given_contributions
from .hxn_synthesis import (_problem_table, _pinch_cut, _format_H,
    _arrange_heat_utilities, _process_streams, _explicit_contributions,
    _check_key_levels, _nearest_key_value, _key_universe, _unknown_keys,
    _unknown_key_hints, _held_owners, _parent, _top, _with_auxiliaries)

__all__ = ('PinchTargets', 'supertarget', 'Supertargets', 'network_report',
           'plot_composite_curves', 'plot_grand_composite_curve',
           'plot_supertargets')

#: Kinds of the constraint positions of the grand composite curve (see
#: `_positions`): the heat arriving at a grid temperature (before its point
#: loads), the heat leaving it (after them), and the heat just above and
#: just below a utility level strictly between two grid temperatures.
_ARRIVING, _LEAVING, _ABOVE, _BELOW = 0, 1, 2, 3

#: How far short of a grid temperature a utility level is placed to serve
#: none of that temperature's point loads [K]: beyond `_T_EQ`, so that the
#: level is not snapped back onto it.
_T_SHORT = 2 * _T_EQ

#: Square feet per square meter, biosteam's own factor in `HX._design`.
_FT2_PER_M2 = 10.763

#: Default film coefficient [kW/m²/K]. A pair gives U = 0.5, biosteam's
#: heuristic overall heat transfer coefficient (except condensing against
#: boiling).
_H_DEFAULT = 1.0

#: Btu per kJ, biosteam's factor in `HX._cost` (furnace).
_BTU_PER_KJ = 0.947817

#: Default economics: interest rate, years of annualization and operating
#: hours per year (a capital recovery factor of 0.16275).
_INTEREST, _YEARS, _OPERATING_HOURS = 0.10, 10, 8000.

#: Default minimum approach temperatures of `supertarget` [K].
_T_MIN_APPS = np.arange(1., 41.)

#: A vertical temperature difference at or below this is no driving force
#: [K].
_DT_ZERO = 1e-9

#: The options of `PinchTargets.summary` (and `supertarget`).
_SUMMARY_OPTIONS = ('heating_agents', 'cooling_agents', 'h', 'h_utilities',
                    'P', 'heat_exchanger_type', 'material', 'interest',
                    'years', 'operating_hours', 'tea')

#: The options of `PinchTargets.summary` whose agents `h_utilities` may key.
_OPTION_AGENTS = 'heating_agents or cooling_agents'

#: The frames that a warning of `_warn` skips: those of hensmith itself.
_PACKAGE_FRAMES = (os.path.dirname(os.path.abspath(__file__)) + os.sep,)

#: The difference between the flows of an exchanger's first inlet and
#: outlet, relative to the larger, beyond which its inlet has changed since
#: its unit was simulated (see `_stale_labels`): far above a legitimate one,
#: as of the total condenser of a `biosteam.MESHDistillation` (about 0.1 %).
_STALE_FLOW = 1e-2

UtilityTarget = namedtuple('UtilityTarget', (
    'ID', 'agent', 'heating', 'level', 'duty', 'utility_duty', 'flow', 'cost',
    'T_in', 'T_out', 'T_utility_in', 'T_utility_out', 'shift'),
    defaults=(None,))

UtilityTarget.__doc__ = """
One utility of `PinchTargets.utility_targets`: an agent placed on the grand
composite curve and priced by biosteam as a single heat utility over the
temperatures it serves.

Unlike biosteam's signed `HeatUtility.duty`, `duty` is a magnitude: whether
the utility heats or cools the process is the `heating` flag.

Attributes
----------
ID : str or None
    ID of the agent; None for a remainder that no agent can serve.
agent : UtilityAgent or None
    The agent; None for an unserved remainder.
heating : bool
    True for a heating utility (heat given to cold streams).
level : float
    Shifted temperature level of the utility on the grand composite curve
    [K]; +inf for fuel and for unserved heating, -inf for unserved cooling.
duty : float
    Process-side heat [kJ/hr] (a magnitude).
utility_duty : float
    Utility-side heat, ``duty`` divided by the agent's heat transfer
    efficiency [kJ/hr], from biosteam; nan when unserved.
flow : float
    Flow rate of the agent [kmol/hr], from biosteam; nan when unserved.
cost : float
    Cost of the utility [USD/hr], from biosteam; nan when unserved.
T_in, T_out : float
    Process temperatures of the stretch it serves [K]: the real temperatures
    of the streams it serves there, the coldest and the hottest (heating) or
    the hottest and the coldest (cooling). biosteam prices the utility from
    them, a sensible agent from the inlet that keeps its line ``dT`` from
    the process (see Notes of `PinchTargets`).
T_utility_in, T_utility_out : float
    Inlet and outlet temperature of the agent [K], from the priced
    `HeatUtility`; nan when unserved.
shift : tuple[float] or None
    `PinchTargets.shift` of the targets that placed it [K], in stream order:
    `level` is on their shifted temperatures, and only targets with the same
    shifts take it (their balanced curves and their area, units and capital
    targets raise a ValueError for any other). None, the default, for a
    utility built by hand, which is not checked.

"""

def _stream_knots(grid, j, shifted):
    """
    The knots of stream j on the table's grid, ascending in temperature, as
    ``(T, Hl, Hr)``: real temperatures (shifted ones if `shifted`), and the
    left (low-enthalpy) and right (high-enthalpy) enthalpy at each, measured
    from the stream's cold end.
    """
    k_hi, k_lo = grid['k_hi'][j], grid['k_lo'][j]
    sl = slice(k_lo, k_hi - 1 if k_hi > 0 else None, -1)
    T = grid['Ts'][sl] if shifted else grid['Ts'][sl] + grid['shift'][j]
    H0 = grid['Hl'][j, k_lo]
    return T, grid['Hl'][j][sl] - H0, grid['Hr'][j][sl] - H0

def _member_values(member, R):
    """
    Left and right enthalpy of a composite member ``(T, Hl, Hr)`` at each
    temperature of `R`: its knot values at a knot (within `_T_EQ`), else its
    chord from ``Hr[i]`` at ``T[i]`` to ``Hl[i+1]`` at ``T[i+1]``; 0 below its
    range and its total above it.
    """
    T, Hl, Hr = member
    n = T.size
    il = np.searchsorted(T, R - _T_EQ, 'left')
    ir = np.searchsorted(T, R + _T_EQ, 'right')
    if n > 1:
        i1 = np.clip(il, 1, n - 1)
        i0 = i1 - 1
        x0 = T[i0]
        y0 = Hr[i0]
        chord = y0 + (Hl[i1] - y0) * (R - x0) / (T[i1] - x0)
        outside = np.where(il == 0, Hl[0], np.where(il >= n, Hr[-1], chord))
    else:
        outside = np.where(il == 0, Hl[0], Hr[-1])
    match = ir > il
    hl = np.where(match, Hl[np.minimum(il, n - 1)], outside)
    hr = np.where(match, Hr[np.maximum(ir - 1, 0)], outside)
    return hl, hr

def _composite(members, inv_h=None):
    """
    Composite of `members` (each ``(T, Hl, Hr)``, ascending T, enthalpies
    from 0, see `_stream_knots`), as segments ``(Ha, Hb, Ta, Tb, w)`` in
    ascending temperature: slopes from ``(Ha, Ta)`` to ``(Hb, Tb)`` and flats
    (``Ta == Tb``) of positive heat. Each member is linear in H within a
    segment (on a flat in proportion to its own flat), so a stretch [x, y] of
    a segment carries ``sum(q_m * inv_h[m]) = (y - x) * w`` (`inv_h`: one
    weight per member, 1 by default).
    """
    if not members:
        empty = np.empty(0)
        return empty, empty, empty, empty, empty
    inv_h = np.ones(len(members)) if inv_h is None else np.asarray(inv_h, float)
    values = np.unique(np.concatenate([m[0] for m in members]))
    # temperatures closer than _T_EQ are one knot, represented by the
    # highest member of the cluster (as in `_problem_table`)
    cluster = np.concatenate([[0], np.cumsum(np.diff(values) > _T_EQ)])
    R = values[np.flatnonzero(np.append(np.diff(cluster) > 0, True))]
    n = R.size
    hl = np.empty((len(members), n))
    hr = np.empty((len(members), n))
    for m, member in enumerate(members):
        hl[m], hr[m] = _member_values(member, R)
    Cl = hl.sum(axis=0)
    Cr = hr.sum(axis=0)
    # interleaved: the flat at R[0], the slope to R[1], the flat at R[1], ...
    Ha = np.empty(2 * n - 1)
    Hb = np.empty(2 * n - 1)
    Ta = np.empty(2 * n - 1)
    Tb = np.empty(2 * n - 1)
    d = np.empty((len(members), 2 * n - 1))
    Ha[0::2], Hb[0::2], Ta[0::2], Tb[0::2] = Cl, Cr, R, R
    d[:, 0::2] = hr - hl
    Ha[1::2], Hb[1::2], Ta[1::2], Tb[1::2] = Cr[:-1], Cl[1:], R[:-1], R[1:]
    d[:, 1::2] = hl[:, 1:] - hr[:, :-1]
    keep = Hb > Ha
    Ha, Hb, Ta, Tb = Ha[keep], Hb[keep], Ta[keep], Tb[keep]
    w = (inv_h @ d[:, keep]) / (Hb - Ha)
    return Ha, Hb, Ta, Tb, w

def _curve(segments):
    """The ``(T, H)`` polyline through `segments` (see `_composite`)."""
    Ha, Hb, Ta, Tb, _ = segments
    if not Ha.size: return np.empty(0), np.empty(0)
    start = np.ones(Ha.size, dtype=bool)
    start[1:] = (Ha[1:] != Hb[:-1]) | (Ta[1:] != Tb[:-1])
    keep = np.column_stack([start, np.ones(Ha.size, dtype=bool)]).ravel()
    T = np.column_stack([Ta, Tb]).ravel()[keep]
    H = np.column_stack([Ha, Hb]).ravel()[keep]
    return T, H

def _snap(Ts, level):
    """`level`, or the first grid temperature within `_T_EQ` of it."""
    k = np.flatnonzero(np.abs(Ts - level) <= _T_EQ)
    return float(Ts[k[0]]) if k.size else level

def _positions(Ts, arr, lea, levels):
    """
    The constraint positions of the grand composite curve (`arr`, `lea`:
    the heat arriving at and leaving each grid temperature `Ts`) for the
    utility `levels`, top to bottom, as arrays ``(T, G, kind, k)``: per grid
    index k the arriving and then the leaving heat, and per level l strictly
    between ``Ts[k]`` and ``Ts[k+1]`` (in descending l, after the leaving
    heat at ``Ts[k]``) the heat just above and just below l, both the linear
    value of the curve at l (`_ABOVE`, `_BELOW`).
    """
    n = Ts.size
    T = np.repeat(Ts, 2)
    G = np.column_stack([arr, lea]).ravel()
    kind = np.tile([_ARRIVING, _LEAVING], n)
    k = np.repeat(np.arange(n), 2)
    inner = sorted({l for l in levels if Ts[-1] < l < Ts[0]
                    and not np.any(Ts == l)}, reverse=True)
    if inner:
        l = np.array(inner)
        ki = np.searchsorted(-Ts, -l, 'right') - 1 # Ts[ki] > l > Ts[ki + 1]
        Gl = lea[ki] + (arr[ki + 1] - lea[ki]) * (Ts[ki] - l) / (Ts[ki] - Ts[ki + 1])
        index = np.repeat(2 * (ki + 1), 2)
        T = np.insert(T, index, np.repeat(l, 2))
        G = np.insert(G, index, np.repeat(Gl, 2))
        kind = np.insert(kind, index, np.tile([_ABOVE, _BELOW], l.size))
        k = np.insert(k, index, np.repeat(ki, 2))
    return T, G, kind, k

def _gap(positions, level):
    """
    Index of the first position below the gap in which a utility at the
    (snapped) `level` acts: 0 above the grid, the number of positions below
    it, the leaving heat at a grid temperature, else the position just below
    the level.
    """
    T, _, kind, _ = positions
    if level > T[0]: return 0
    if level < T[-1]: return T.size
    i = np.flatnonzero((T == level) & ((kind == _LEAVING) | (kind == _BELOW)))
    return int(i[0])

def _balance(positions, heating, cooling):
    """
    The balanced heat flow at every position: the curve minus the heating
    utilities ``(level, q)`` acting below each position and the cooling ones
    acting above it.
    """
    B = positions[1].copy()
    for level, q in heating: B[:_gap(positions, level)] -= q
    for level, q in cooling: B[_gap(positions, level):] -= q
    return B

@contextmanager
def _isolated_pricing(agents):
    """
    Price heat utilities of `agents` without side effects: each agent's
    `utility_stream_dump` is replaced by a new empty list (so every pricing
    gets fresh utility streams, and the costs do not depend on what was
    priced before) and new streams are registered in a temporary flowsheet;
    the original dumps, and biosteam's registry of flowsheets, are restored
    on exit.
    """
    dumps = {id(a): (a, a.utility_stream_dump) for a in agents}
    flowsheets = bst.Flowsheet.flowsheet.__dict__
    ID = '_hensmith_targets'
    missing = object()
    previous = flowsheets.get(ID, missing)
    try:
        for a, _ in dumps.values(): a.utility_stream_dump = []
        with bst.Flowsheet(ID):
            yield
    finally:
        for a, dump in dumps.values(): a.utility_stream_dump = dump
        if previous is missing: flowsheets.pop(ID, None)
        else: flowsheets[ID] = previous

def _overcounted_units(hus, owners=None):
    """
    The units given more heat utilities with duty in `hus` than they hold,
    ``[(unit, given, listed), ...]`` in order of first occurrence: those
    whose heat utilities `hus` holds more often than any unit on their
    owner chain (`unit` and up along `hensmith.hxn_synthesis._parent` with
    `owners`) lists them in its `heat_utilities`. biosteam lists the heat
    utilities of a unit in parallel (``Unit.parallel``, as a
    `biosteam.AbstractStirredTankReactor` sets for its recirculation heat
    exchanger) once per unit in parallel in its owner's, each a stream of
    the synthesis; a heat utility given more often than that is given
    twice, as by selections from overlapping unit lists.
    """
    given = {}
    for hu in hus:
        if hu.duty: given.setdefault(id(hu.unit), [hu.unit, 0])[1] += 1
    over = []
    for unit, n in given.values():
        if n == 1: continue
        listed, above = 0, unit
        while above is not None:
            listed = max(listed, sum([hu.unit is unit and bool(hu.duty)
                                      for hu in above.heat_utilities]))
            above = _parent(above, owners)
        if n > listed: over.append((unit, n, listed))
    return over

def _duty_off_stream(hu):
    """Whether the duty of the heat exchanger's heat utility `hu`
    (``hu.unit_duty``) differs from that of its process stream (``outs[0].H
    - ins[0].H`` of its unit) beyond rounding, as for the heat utilities
    that a heat exchanger network simulated with
    ``replace_unit_heat_utilities`` writes its utilities over or empties
    (and, by 0.1 %, that of biosteam's `MESHDistillation` total
    condenser)."""
    unit = hu.unit
    dH = unit.outs[0].H - unit.ins[0].H
    return abs(dH - hu.unit_duty) > 1e-6 * max(abs(dH), abs(hu.unit_duty))

def _unit_label(unit, owners=None):
    """``unit.ID``, or ``'<owner.ID> - <unit.ID>'`` for an auxiliary unit,
    with its top owner as keys take it (along
    `hensmith.hxn_synthesis._parent` with `owners`, which may make a unit
    the owner of an auxiliary it holds without one), and ``owner.ID`` alone
    for an auxiliary whose ID is empty."""
    owner = _top(unit, owners)
    if owner is unit: return unit.ID
    return f'{owner.ID} - {unit.ID}' if unit.ID else owner.ID

def _utility_member(u):
    """The composite member ``(T, Hl, Hr)`` of a non-fuel `UtilityTarget`:
    isothermal at the agent's temperature for a latent agent, else linear
    between the agent's inlet and outlet temperatures."""
    T_a, T_b = sorted((u.T_utility_in, u.T_utility_out))
    if u.agent.T_limit is None or T_b - T_a <= _T_EQ:
        T = u.agent.T if u.agent.T_limit is None else T_b
        return np.array([T]), np.array([0.]), np.array([u.duty])
    return (np.array([T_a, T_b]), np.array([0., u.duty]),
            np.array([0., u.duty]))

def _sensible(agent):
    """Whether biosteam prices `agent` by its sensible heat: a non-fuel
    agent with a temperature limit."""
    return agent is not None and not agent.isfuel and bool(agent.T_limit)

def _euler(regions):
    """Euler's S - 1 per connected component of the members of `regions`
    (sets; members of one region are connected)."""
    components = []
    for members in regions:
        if not members: continue
        component = set(members)
        for other in [c for c in components if c & component]:
            component |= other
            components.remove(other)
        components.append(component)
    return sum(len(c) - 1 for c in components)

def _film_value(x, label):
    """A film coefficient [kW/m²/K]: ``float(x)`` for a finite real number
    > 0 (not a bool); a ValueError naming `label` otherwise."""
    if isinstance(x, Real) and not isinstance(x, (bool, np.bool_)):
        value = float(x)
        if math.isfinite(value) and value > 0.: return value
    raise ValueError(f'{label} must be a finite number > 0 kW/m2/K, not {x!r}')

def _utility_films(utilities, h_utilities, agents=(), where='agents'):
    """
    The film coefficient [kW/m²/K] of each of `utilities` from
    `h_utilities`: None (`_H_DEFAULT`), a number, or a mapping from agent
    IDs or agents to numbers (`_H_DEFAULT` for an agent it leaves out). An
    ID key must name an agent of `utilities`, of `agents` (the option
    `where` names in the error) or of biosteam's default lists.
    """
    return _agent_films([u.agent for u in utilities], h_utilities, agents,
                        where)

def _agent_films(of, h_utilities, agents=(), where='agents'):
    """`_utility_films` of the agents `of` (None for an unserved utility)."""
    if h_utilities is None: return [_H_DEFAULT] * len(of)
    if not isinstance(h_utilities, Mapping):
        return [_film_value(h_utilities, 'h_utilities')] * len(of)
    known = {a.ID for a in (*agents, *bst.HeatUtility.heating_agents,
                            *bst.HeatUtility.cooling_agents)}
    known.update(a.ID for a in of if a is not None)
    values = {} # agent ID -> (key, value)
    unknown = []
    for key, value in h_utilities.items():
        if isinstance(key, bst.UtilityAgent): ID = key.ID
        elif isinstance(key, str): ID = key
        else:
            raise TypeError('h_utilities keys must be agent IDs (str) or '
                            f'UtilityAgent objects, not {type(key).__name__!r} '
                            f'({key!r})')
        value = _film_value(value, f'h_utilities of key {key!r}')
        if ID not in known and not isinstance(key, bst.UtilityAgent):
            unknown.append(key)
        other, other_value = values.setdefault(ID, (key, value))
        if other_value != value:
            raise ValueError(f'h_utilities keys {other!r} and {key!r} give agent '
                             f'{ID!r} different values: {other_value!r} and '
                             f'{value!r}')
    if unknown:
        raise ValueError('h_utilities keys that name no agent of the '
                         f"utilities, of {where}, or of biosteam's lists: "
                         f"{', '.join(map(repr, unknown))} (key another agent "
                         'by the UtilityAgent itself)')
    return [values[a.ID][1] if a is not None and a.ID in values
            else _H_DEFAULT for a in of]

def _T_at(segments, i, H):
    """Temperature of the segments `i` (see `_composite`) at heat `H`."""
    Ha, Hb, Ta, Tb = (x[i] for x in segments[:4])
    with np.errstate(invalid='ignore'): # inf - inf on a furnace's flat
        T = Ta + (Tb - Ta) * (H - Ha) / (Hb - Ha)
    return np.where(Ta == Tb, Ta, T)

def _bath(hot, cold):
    """
    Area [m²] of vertical heat transfer between the `hot` and `cold`
    segments (`_composite`, weighted by 1/h [m²·K/kW]): Bath's formula,
    exact on the stretches between the breakpoints of both curves, where
    both and every member are linear. A hot stretch at infinite temperature
    (fuel) takes no area. inf with a RuntimeWarning at a driving force of
    `_DT_ZERO` or less (a zero or negative one).
    """
    if not (hot[0].size and cold[0].size): return 0.
    end = min(hot[1][-1], cold[1][-1])
    X = np.unique(np.concatenate([hot[0], hot[1], cold[0], cold[1]]))
    X = X[(X >= 0.) & (X <= end)]
    x, y = X[:-1], X[1:]
    keep = y - x > 1e-12 * end
    x, y = x[keep], y[keep]
    m = 0.5 * (x + y)
    i = np.searchsorted(hot[0], m, 'right') - 1
    j = np.searchsorted(cold[0], m, 'right') - 1
    Th_x, Th_y = _T_at(hot, i, x), _T_at(hot, i, y)
    finite = np.isfinite(Th_x) # not fuel
    x, y, i, j = x[finite], y[finite], i[finite], j[finite]
    d1 = Th_x[finite] - _T_at(cold, j, x)
    d2 = Th_y[finite] - _T_at(cold, j, y)
    zero = np.flatnonzero(np.minimum(d1, d2) <= _DT_ZERO)
    if zero.size:
        k = zero[0]
        _warn('vertical heat transfer at a non-positive driving force between '
              f'H = {x[k]:.6g} and {y[k]:.6g} kJ/hr')
        return math.inf
    LMTD = _lmtd(d1, d2)
    return float(np.sum((y - x) * (hot[4][i] + cold[4][j]) / 3600. / LMTD))

def _lmtd(d1, d2):
    """
    Log-mean temperature differences [K] of the positive driving forces `d1`
    and `d2` (arrays), to machine precision when they are nearly equal: the
    log of their ratio is ``log1p((d1 - d2) / d2)`` for a ratio between 0.5
    and 1.5 (where the difference is exact), and the LMTD is `d1` at
    equality.
    """
    with np.errstate(divide='ignore', invalid='ignore'):
        r = (d1 - d2) / d2
        log = np.where(np.abs(r) < .5, np.log1p(r), np.log(d1 / d2))
        return np.where(r == 0., d1, (d1 - d2) / log)

def _check_exchanger(heat_exchanger_type, material):
    """Raise a ValueError unless biosteam knows the shell and tube
    `heat_exchanger_type` and `material`."""
    from biosteam.units.heat_exchange import Cb_dict
    from biosteam.units.design_tools.specification_factors import (
        shell_and_tube_material_factor_coefficients as materials)
    types = [i for i in Cb_dict if i != 'Furnace']
    if heat_exchanger_type not in types:
        raise ValueError(f'heat_exchanger_type must be one of {types}, not '
                         f'{heat_exchanger_type!r}')
    if material not in materials:
        raise ValueError(f'material must be one of {list(materials)}, not '
                         f'{material!r}')

def _exchanger_costs(A, P=101325., heat_exchanger_type='Floating head',
                     material='Carbon steel/carbon steel'):
    """
    ``(purchase, installed, type)`` [USD] of one heat exchanger of area `A`
    [ft²] at pressure `P` [Pa], as biosteam's `HX._cost` and
    `Unit._load_costs` cost it: a double pipe below 150 ft², else a shell
    and tube of `heat_exchanger_type` and `material` with 20 ft tubes.
    ``(inf, inf, None)`` for an infinite area (or one beyond the
    correlations), ``(nan, nan, None)`` for nan.
    """
    from biosteam.units.heat_exchange import (
        Cb_dict, p2, compute_double_pipe_purchase_cost)
    from biosteam.units.design_tools.specification_factors import (
        compute_shell_and_tube_material_factor,
        shell_and_tube_material_factor_coefficients)
    _check_exchanger(heat_exchanger_type, material)
    P = _pressure(P)
    if math.isnan(A): return math.nan, math.nan, None
    if math.isinf(A): return math.inf, math.inf, None
    P_psi = P * 14.7 / 101325
    L = 20 # ft, as biosteam assumes
    try:
        if A < 150: # double pipe
            p = P_psi / 600
            F_p = 0.8510 + 0.1292 * p + 0.0198 * p**2
            F_m = 2
            if A < 2.1718:
                F_l = A / 2.1718
                A = 2.1718
            else:
                F_l = 1
            kind = 'Double pipe'
            C_b = compute_double_pipe_purchase_cost(A, bst.CE)
        else: # shell and tube
            F_m = compute_shell_and_tube_material_factor(
                A, *shell_and_tube_material_factor_coefficients[material])
            F_l = np.polyval(p2, L) # L == 20 is not > 20 in biosteam
            p = P_psi / 100
            F_p = 0.9803 + 0.018 * p + 0.0017 * p**2
            kind = heat_exchanger_type
            C_b = Cb_dict[kind](A, bst.CE)
    except OverflowError:
        return math.inf, math.inf, None
    F = F_l * F_p * F_m
    return C_b * F, C_b * (bst.HX._F_BM_default[kind] + F - 1.), kind

def _furnace_costs(Q):
    """``(purchase, installed)`` [USD] of a furnace of utility duty `Q`
    [kJ/hr], as biosteam's `HX._cost` costs it (500 psi)."""
    from biosteam.units.heat_exchange import compute_furnace_purchase_cost
    C_b = compute_furnace_purchase_cost(Q * _BTU_PER_KJ, bst.CE)
    S = 500 / 500
    F_P = 0.986 - 0.0035 * S + 0.0175 * S * S
    return C_b * F_P, C_b * (bst.HX._F_BM_default['Furnace'] + F_P - 1.)

def _shell_split(A, M, P=101325., heat_exchanger_type='Floating head',
                 material='Carbon steel/carbon steel'):
    """
    ``(purchase, installed, k, type)`` [USD] of the area `A` [m²] in `M` >= 1
    shells, k of them sharing it equally, each of biosteam's `type`
    (`_exchanger_costs`): Linnhoff and Ahmad's k = `M`, unless that puts the
    shells below 150 ft² where the area fills k = ``A // 150 ft²`` >= 1
    shells of at least 150 ft² and these, with the ``M - k`` others of no
    area (each costed as biosteam costs a vanishing double pipe), cost more
    to install. k = `M` for an infinite or nan `A`.
    """
    def shells(k): # k shells of A / k
        p, i, kind = _exchanger_costs(A / k * _FT2_PER_M2, P,
                                      heat_exchanger_type, material)
        return k * p, k * i, k, kind
    equal = shells(M)
    if not math.isfinite(A * _FT2_PER_M2): return equal
    k = min(int(A * _FT2_PER_M2 // 150.), M)
    while k and A / k * _FT2_PER_M2 < 150.: k -= 1 # rounding
    if not k or k == M: return equal
    p, i, k, kind = shells(k)
    p0, i0, _ = _exchanger_costs(0., P, heat_exchanger_type, material)
    p += (M - k) * p0
    i += (M - k) * i0
    return (p, i, k, kind) if i > equal[1] else equal

def _real(x, name):
    """``float(x)`` of a finite real number (not a bool); a ValueError
    naming `name` otherwise."""
    if isinstance(x, Real) and not isinstance(x, (bool, np.bool_)):
        value = float(x)
        if math.isfinite(value): return value
    raise ValueError(f'{name} must be a finite number, not {x!r}')

def _pressure(P):
    """``float(P)`` of the exchangers' pressure `P` [Pa], a finite number
    > 0 (not a bool); a ValueError naming P otherwise."""
    P = _real(P, 'P')
    if P <= 0.: raise ValueError(f'P must be > 0 Pa, not {P!r}')
    return P

def _check_T_min_app(T_min_app):
    """A ValueError naming `T_min_app` unless it is a finite number >= 0
    [K] (not a bool), the rule of `supertarget`; > 0 with approach
    contributions is checked with them (`_given_contributions`)."""
    if _real(T_min_app, 'T_min_app') < 0.:
        raise ValueError(f'T_min_app must be >= 0 K, not {T_min_app!r}')

def _agents(agents, heating):
    """`agents` as a list (None stays None: biosteam's lists, read when
    used), each ID resolved among ``bst.HeatUtility.heating_agents`` (or
    ``cooling_agents``) as they are now; a ValueError for an unknown ID or
    an agent of the other kind (a heating agent is ``isheating_agent`` or
    ``isfuel``, a cooling one ``iscooling_agent`` and not ``isfuel``)."""
    if agents is None: return None
    kind, other = ('heating', 'cooling') if heating else ('cooling', 'heating')
    if isinstance(agents, (str, bst.UtilityAgent)):
        raise TypeError(f'{kind}_agents must be a sequence of agents (or '
                        f'their IDs), not {type(agents).__name__!r}')
    known = bst.HeatUtility.heating_agents if heating else bst.HeatUtility.cooling_agents
    resolved = []
    for agent in agents:
        if isinstance(agent, str):
            match = [a for a in known if a.ID == agent]
            if not match:
                raise ValueError(
                    f'no {kind} agent {agent!r} in bst.HeatUtility.{kind}_'
                    f"agents ({', '.join(a.ID for a in known)}); pass the "
                    'UtilityAgent itself for another one')
            agent = match[0]
        if heating: right = agent.isfuel or agent.isheating_agent
        else: right = agent.iscooling_agent and not agent.isfuel
        if not right:
            raise ValueError(f'{agent.ID!r} is not a {kind} agent; pass it in '
                             f'{other}_agents')
        resolved.append(agent)
    return resolved

def _key_agents(agents):
    """The `agents` of `PinchTargets.area_target` as a list (None is
    none), each ID resolved among biosteam's heating and cooling agents as
    they are now; a ValueError for an unknown ID."""
    if agents is None: return []
    if isinstance(agents, (str, bst.UtilityAgent)):
        raise TypeError('agents must be a sequence of agents (or their IDs), '
                        f'not {type(agents).__name__!r}')
    known = [*bst.HeatUtility.heating_agents, *bst.HeatUtility.cooling_agents]
    resolved = []
    for agent in agents:
        if isinstance(agent, str):
            match = [a for a in known if a.ID == agent]
            if not match:
                raise ValueError(
                    f'no agent {agent!r} in bst.HeatUtility.heating_agents or '
                    'cooling_agents; pass the UtilityAgent itself for another '
                    'one')
            agent = match[0]
        elif not isinstance(agent, bst.UtilityAgent):
            raise TypeError('agents must be UtilityAgent objects or their IDs '
                            f'(str), not {type(agent).__name__!r} ({agent!r})')
        resolved.append(agent)
    return resolved

def _economics(interest=None, years=None, operating_hours=None, tea=None):
    """
    ``(crf, hours)``: the capital recovery factor [1/yr] and the operating
    hours per year. Each keyword takes precedence over `tea` (its `IRR`,
    ``duration[1] - duration[0]`` and `operating_hours`, unless None), which
    takes precedence over the defaults.
    """
    if tea is not None:
        if interest is None: interest = tea.IRR
        if years is None: years = tea.duration[1] - tea.duration[0]
        if operating_hours is None: operating_hours = tea.operating_hours
    interest = _real(_INTEREST if interest is None else interest, 'interest')
    years = _real(_YEARS if years is None else years, 'years')
    hours = _real(_OPERATING_HOURS if operating_hours is None
                  else operating_hours, 'operating_hours')
    if interest <= -1.:
        raise ValueError(f'interest must be > -1, not {interest!r}')
    if years <= 0.:
        raise ValueError(f'years must be > 0, not {years!r}')
    if hours < 0.:
        raise ValueError(f'operating_hours must be >= 0, not {hours!r}')
    if interest == 0.: return 1. / years, hours
    fn = (1. + interest) ** years
    return interest * fn / (fn - 1.), hours


[docs] class PinchTargets: """ Pinch analysis targets of a set of process streams: the problem table (`hensmith.hxn_synthesis.problem_table`) and what follows from it, the composite and grand composite curves, the targets of several utilities (`utility_targets`) priced by biosteam, the balanced composite curves, and the targets of area, units, capital cost and total annualized cost (`summary`). Build it from heat utilities with `from_heat_utilities` (as `hensmith.hxn_synthesis.synthesize_network` prepares them), from a facility with `HeatExchangerNetwork.targets`, or from streams as `problem_table` takes them. Parameters ---------- streams_inlet : list[Stream] Inlet of each process stream. streams_quenched : list[Stream] Corresponding outlets, re-flashed at their enthalpy. is_hot : Sequence[bool] True where the stream is cooled, False where it is heated (a ValueError is raised for a stream with duty marked otherwise). T_min_app : float, optional Minimum approach temperature [K], a finite number >= 0 (> 0 with approach contributions), or a ValueError is raised. Defaults to 5. approach_contributions : Sequence[float or None], optional Approach temperature contribution of each stream [K], in stream order, as `problem_table` takes them: None or nan (unspecified: ``T_min_app / 2``), or a finite number > 0 (`T_min_app` must then be finite and > 0 too). All unspecified is the same as None. labels : list[str], optional Display name of each stream, which the curves built here also take (to name their streams in their warnings). Defaults to the IDs of `streams_inlet`. curves : list[StreamCurve], optional Prebuilt temperature-enthalpy curves of the streams (built here if not given). Attributes ---------- T_min_app : float Minimum approach temperature [K], as given. approach_contributions : tuple[float or None] or None Approach temperature contribution of each stream [K] in stream order (None: ``T_min_app / 2``), or None if no stream has one. is_hot : numpy.ndarray (N,) bool, True for hot (cooled) streams. labels : list[str] Display name of each stream. streams_inlet, streams_quenched : list[Stream] The process streams. curves : list[StreamCurve] Temperature-enthalpy curve of each stream (independent of `T_min_app`). table : ProblemTable The problem table. grid : dict The table's grid: 'Ts', its shifted temperatures [K] (descending, n); 'shift', as `shift`; 'contributions', each stream's approach temperature contribution [K] (N); 'Hl' and 'Hr', (N, n) each stream's enthalpy [kJ/hr] at every grid temperature, the left (low enthalpy) and right (high enthalpy) limit, which differ only at a point load of the stream; 'k_hi' and 'k_lo', (N,) the grid indices of each stream's own upper and lower temperature. shift : numpy.ndarray (N,) shift of each stream [K]: its real temperature at shifted temperature T* is ``T* + shift[j]``. Q_hot, Q_cold : float Minimum hot and cold utility [kJ/hr], process side (the table's `hot_util_load` and `cold_util_load`). pinch_T : float Shifted pinch temperature [K]. cut : str 'below' or 'above': the side of the pinch to which the point loads at the pinch temperature belong. It is 'below' when the zero heat flow of the cascade is the flow arriving at `pinch_T` (before those point loads), 'above' when it is the flow leaving it (after them), and always 'below' for a threshold problem with no hot utility. heat_utilities : list[HeatUtility] or None The heat utilities the streams were taken from, in stream order; None when built from streams. For `HeatExchangerNetwork.targets` after a simulation they are the facility's `original_heat_utils`, which with `replace_unit_heat_utilities` hold the network's utilities (the streams are read from the units). units : list[Unit] or None The unit of each stream (``hu.unit``), in stream order; None when built from streams. key_units : list[Unit] or None Further units that keys of `h` may name (with their owners) without a stream of theirs, a key that names only such units then having no effect: for `HeatExchangerNetwork.targets`, every unit available to the network, its nested auxiliary units and the exchangers they hold through their heat utilities only, which approach contribution keys may name; for `from_heat_utilities`, its `key_units` and theirs; else None (settable). An auxiliary that one of them, or a unit of `units`, holds without an owner when the targets are made (for `HeatExchangerNetwork.targets`, at the network's synthesis) stays that unit's for keys and labels, as the facility takes it (see Notes of `HeatExchangerNetwork`), even once the unit has rebuilt its auxiliaries. force_ideal_thermo : bool Whether the stream copies use ideal thermodynamics (False when built from streams). system : System or None The system of the facility of `HeatExchangerNetwork.targets`, else None (settable). If it has operating hours, `summary` (and so `supertarget`) uses them unless `operating_hours` or `tea` is given. Notes ----- *Composite curves.* `composite_curves` adds up the streams on the table's own grid, whose knots hold every breakpoint of every stream, so the composites reproduce the table to rounding: the cold composite starts at the cold utility target and ends the hot utility target beyond the end of the hot one. On the shifted scale the composites touch at the pinch (if there is hot utility). On the real scale they are at least `T_min_app` apart, or, with approach contributions, at least the smallest hot and the smallest cold contribution apart (unspecified ones ``T_min_app / 2``); the shifted composites then carry the pinch. *Grand composite curve.* `grand_composite_curve` is the heat cascaded through each shifted grid temperature with the minimum hot utility, both as it arrives (before the point loads at that temperature) and as it leaves (after them). It is non-negative and zero at the pinch. *Multiple utilities.* `utility_targets` places biosteam's agents on the grand composite curve, heating and cooling separately. biosteam lets a heating agent serve real temperatures below ``R = agent.T - agent.dT - dT`` and a cooling agent real temperatures above ``R = agent.T + agent.dT + dT`` (``dT = bst.HeatUtility.dT``, read at call time). On the shifted scale a stream with shift s may take an agent's heat only up to ``R - s`` (down to it, for cooling); the agent's level is the highest single level, up to the highest of these limits, that serves no stream above its own limit (the lowest, down to the lowest limit, for cooling): ``R`` for heating and ``R - T_min_app`` for cooling without approach contributions, which is biosteam's rule. A level serves only what lies below it (above it, for cooling) and above the highest zero of the curve balanced with the agents already placed (below the lowest one): the pinch, a utility pinch or another process pinch, which its heat never passes. So a stream reaching beyond its limit bounds the level at that limit, one lying wholly beyond it only at its own end (just short of it, if it has a point load there), and one with no heat on the level's side of that zero not at all. With contributions the level can be conservative for streams with slack. Non-fuel agents are placed in list order (biosteam's first feasible agent: for the default lists the lowest steam and the warmest coolant first), each as much as the curve above its level (below it, for cooling) allows given the agents already placed; what remains goes to the first fuel agent, or is left unserved with a RuntimeWarning (nan cost). The order is biosteam's preference, not price: the utility cost target is the cost of the agents placed this way, not the least utility cost, and an agent later in the list can be cheaper per kJ (with biosteam's default prices, natural gas is cheaper than any steam for a furnace heating from below about 565 K), so a network, which serves each stream with one agent at its outlet, can cost less than the target as well as more (see Notes of `network_report`). A level equal to a grid temperature serves that temperature's point loads (biosteam's own rule is strict; they differ only at exact equality), except where a sensible agent's line would have no driving force left: at a point load at ``agent.T - dT`` (``agent.T + dT`` for cooling), a stream's limit when ``agent.dT`` is 0 (`UtilityAgent`'s default), the agent would leave at its inlet temperature at an unbounded flow, so its level stays just short of that point load, which the next agent serves, as in biosteam. For the same reason a stream that ends at that temperature bounds the level of such an agent at that end, as one reaching beyond its limit does: were another stream (of another approach contribution) to set the level beyond that end, the agent would have given heat above it (taken it below, for cooling) and its line would reach its inlet temperature there; at the level it has given none. Each agent is priced as one biosteam heat utility over the stretch it serves, from its level (or the end of the grid, if that is nearer) to the next zero of the balanced curve (the pinch, or a utility pinch). Its `T_in` and `T_out` are the real temperatures of the streams with heat in that stretch (the cold streams for heating, the hot ones for cooling): the coldest and the hottest for heating, the hottest and the coldest for cooling. A point load at an end of the stretch counts where the stretch holds it, as the balanced curve does: a boiler at the pinch with the cut 'above', or a condenser at it with the cut 'below', is served at its saturation temperature. They are the stretch's ends without approach contributions (where a stream reaches them), and `T_out` is within the agent's limit. biosteam prices a latent agent and fuel from them (the flue leaves at least ``dT`` above `T_in`), but checks a sensible agent's outlet only against `T_in`, while its line must keep ``dT`` from the process over the whole stretch (cooling water serving a condenser at the cold end of its stretch must leave ``dT`` below the condensing temperature). At each position p of the stretch, the agent takes at least ``x = q - B(p)`` of its duty q below p (gives it above p, for heating), B being the balanced heat flow at p. Its outlet is therefore at most ``agent.T + min_p (T(p) - dT - agent.T) * q / x`` for cooling and at least ``agent.T - min_p (agent.T - dT - T(p)) * q / x`` for heating, T(p) being the lowest real temperature of the served hot streams at p (the highest of the cold ones): its line stays inside the grand composite curve, as a non-isothermal utility's does in pinch analysis. biosteam is given the inlet that gives this outlet, and divides the duty by the agent's heat transfer efficiency. Pricing uses fresh utility streams in a temporary flowsheet: costs are deterministic, and the flowsheet's registry and the agents' stream dumps are untouched. *Balanced composite curves.* `balanced_composite_curves` adds the utilities to the real composites (heating ones to the hot composite, cooling ones to the cold one), so both end at the same heat: a latent agent as a flat at its temperature, a sensible one as a line between its inlet and outlet temperatures, and fuel as a furnace at infinite temperature. An unserved remainder is left out: unserved cooling, the coldest member of the cold composite, as a gap at its start (which keeps the curves aligned), unserved heating as one at the end of the hot composite. `balanced_grand_composite_curve` is the cascade net of the utilities, zero at the process pinch and at every utility pinch. *Area target.* `area_target` is Bath's formula [Townsend84]_ on the balanced composite curves: vertical heat transfer, counter-current (a correction factor of 1), each stretch between breakpoints of either curve at its log-mean temperature difference, ``A = sum_i (1 / LMTD_i) sum_j q_ij / h_j``, over the real temperatures (with approach contributions too) and with a film coefficient h per stream and per utility (1 kW/m²/K by default, so a pair has biosteam's heuristic overall coefficient of 0.5 kW/m²/K; condensing against boiling, where biosteam takes 1, is not reproduced). The sum is exact on the piecewise-linear curves, a stream's share of a stretch being linear in the heat. Fuel takes no area, and the area is inf (with a RuntimeWarning) at a zero or negative driving force, as at ``T_min_app = 0``. *Units target.* `units_target` applies Euler's N = S - 1 to each region of the balanced grand composite curve between its zeros (the process pinch and the utility pinches), S counting the streams with heat in the region and the utilities acting in it. Where the curve stays zero, each piece between grid temperatures is balanced on its own: on each side of the pinch such pieces count S - 1 per group of them linked by common streams, so a stream across several counts once. It is an estimate, not a bound: it assumes one component and no loop per region, so a balanced subset of streams needs fewer units and each loop of matches one more. *Capital and total annualized cost.* `capital_cost_target` takes as many shells as the units target (less one furnace for all fuel) and costs each with biosteam's correlations at one pressure (biosteam takes each exchanger's highest inlet pressure: its pressure factor is up to about 12 % higher for high pressure steam heaters): a double pipe below 150 ft² (for carbon steel at 1 atm and biosteam's default CE of 567.5, the purchase cost jumps from about 5,486 USD just below 150 ft² to 20,138 USD for a floating head at 150 ft², and the installed cost, the one used here, from about 8,055 to 64,658 USD; the floating head correlation, not monotone below about 126 ft², is not used there), a shell and tube of any area above. Linnhoff and Ahmad divide the area target equally over the shells [Linnhoff90]_: with a cost law of economies of scale, such as a + bA^c, that is the costliest way to divide it, and each added shell adds cost. biosteam's jump breaks this: the equal division can bring every shell below 150 ft² although the area would fill some shell and tubes, so that a utility of a few watts that adds units would cut the target by most of it, where a network just adds a small exchanger and keeps its large ones. So when the equal division puts the N shells below 150 ft² and the area fills k = A // 150 ft² >= 1 shells of at least 150 ft², the target takes the costlier to install of the N double pipes and of k equal shell and tubes with N - k shells of no area (each costed as biosteam costs a vanishing double pipe, about 1,305 USD installed). This is the least change to the equal division that never falls when a unit is added (or some area, except where biosteam's correlations do: for shells whose cost rises faster than their area, beyond about 35,000 ft² (3,300 m²) for the default floating head of carbon steel and from about 15,000 ft² by type and material, and for the 'Double pipe' `heat_exchanger_type`, cheaper at 150 ft² than below). Where it departs from the equal division it tends to be high, as networks keep only part of their area in shell and tubes (the equal division tends to be low there): an estimate, not a bound. The furnace is costed on the fuel's utility duty at biosteam's default furnace pressure of 500 psi (`P` sets the pressure of the shells only). `summary` adds the total annualized cost, the installed (bare module) cost times the capital recovery factor plus the utility cost times the operating hours: no Lang factor and no working capital. Examples -------- One hot stream cooled 400 -> 300 K and one cold stream heated 330 -> 440 K, both with a heat capacity flow rate of 1 kW/K (1000 kmol/hr of a pseudo-component with a molar heat capacity of 3.6 J/mol/K): >>> import biosteam as bst, thermosteam as tmo >>> from hensmith import PinchTargets >>> fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., ... Cn=3.6, default=True) >>> bst.settings.set_thermo([fluid]) >>> bst.main_flowsheet.set_flowsheet('targeting_example') >>> H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1000., T=400.), ... T=300., rigorous=False) >>> C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1000., T=330.), ... T=440., rigorous=False) >>> H1.simulate(); C1.simulate() >>> hus = [H1.heat_utilities[0], C1.heat_utilities[0]] >>> targets = PinchTargets.from_heat_utilities(hus, T_min_app=10.) >>> targets <PinchTargets: 2 streams, T_min_app = 10 K, Q_hot = 1.800e+05 kJ/hr, Q_cold = 1.440e+05 kJ/hr> >>> targets.labels, targets.pinch_T (['C1', 'H1'], 390.0) The composite curves [K, kW], on the knots of the table's grid (340 K is the cold inlet and 390 K the hot inlet, each shifted to the other side): the cold one starts at the cold utility target, 40 kW, and ends 50 kW (the hot utility target) to the right of the hot one. >>> hot_T, hot_H, cold_T, cold_H = targets.composite_curves() >>> hot_T, hot_H / 3600 (array([300., 340., 400.]), array([ 0., 40., 100.])) >>> cold_T, cold_H / 3600 (array([330., 390., 440.]), array([ 40., 100., 150.])) The utility targets with biosteam's default agents [kW]: low pressure steam serves the cold stream up to its level, 407.189 K, and medium pressure steam the rest; cooling water serves the hot stream down to its level (302.372 K shifted) and chilled water the rest. >>> utilities = targets.utility_targets() >>> [(u.ID, round(u.duty / 3600, 3)) for u in utilities] [('low_pressure_steam', 17.189), ('medium_pressure_steam', 32.811), ('cooling_water', 27.628), ('chilled_water', 12.372)] >>> [(round(u.T_in, 3), round(u.T_out, 3)) for u in utilities] [(390.0, 407.189), (407.189, 440.0), (340.0, 312.372), (312.372, 300.0)] >>> round(sum(u.cost for u in utilities), 4) # USD/hr 1.656 The balanced composite curves end at the same heat: >>> hot_T, hot_H, cold_T, cold_H = targets.balanced_composite_curves(utilities) >>> round(hot_H[-1] / 3600, 6), round(cold_H[-1] / 3600, 6) (150.0, 150.0) The area target [m²] with film coefficients of 1 kW/m²/K, the units target (the balanced grand composite curve is zero at the utility pinches of low pressure steam and cooling water and from the pinch down to 330 K, which leaves five regions of two streams or utilities each), and the capital cost target [USD]. The 24.14 m² (260 ft²) fill one floating head, which with four shells of no area costs more to install than five equal double pipes of 52 ft² (about 34,000 USD), so the target takes it: >>> round(targets.area_target(utilities), 2) 24.14 >>> targets.units_target(utilities) {'above': 2, 'below': 3, 'total': 5} >>> capital = targets.capital_cost_target(utilities) >>> capital['N_shells'], capital['N_area_shells'], capital['type'] (5, 1, 'Floating head') >>> round(capital['installed_cost']) 72819 All targets, with the total annualized cost [USD/yr] at 10 % interest over 10 years and 8000 operating hours a year: >>> summary = targets.summary() >>> summary['shell_type'] 'Floating head' >>> {key: round(value, 2) for key, value in summary.items() ... if key != 'shell_type'} {'T_min_app': 10.0, 'Q_hot': 180000.0, 'Q_cold': 144000.0, 'utility_cost': 1.66, 'area': 24.14, 'N_units': 5, 'N_shells': 5, 'N_area_shells': 1, 'shell_area': 24.14, 'purchase_cost': 21054.85, 'installed_cost': 72819.1, 'annualized_capital_cost': 11850.97, 'operating_cost': 13247.77, 'TAC': 25098.74} The same streams at another minimum approach temperature (the curves are reused): >>> targets.at(20.) <PinchTargets: 2 streams, T_min_app = 20 K, Q_hot = 2.160e+05 kJ/hr, Q_cold = 1.800e+05 kJ/hr> References ---------- .. [Townsend84] Townsend, D. W., & Linnhoff, B. (1984). Surface area targets for heat exchanger networks. IChemE 11th Annual Research Meeting, Bath, UK. .. [Linnhoff90] Linnhoff, B., & Ahmad, S. (1990). Cost optimum heat exchanger networks - 1. Minimum energy and capital using simple models for capital cost. Computers & Chemical Engineering, 14(7), 729-750. """ def __init__(self, streams_inlet, streams_quenched, is_hot, T_min_app=5., approach_contributions=None, labels=None, curves=None): N = len(streams_inlet) if not N: raise ValueError('PinchTargets needs at least one process stream') if len(streams_quenched) != N: raise ValueError(f'{len(streams_quenched)} outlets given for ' f'{N} inlets') is_hot = np.asarray(is_hot, dtype=bool) if is_hot.shape != (N,): raise ValueError(f'{is_hot.size} is_hot values given for {N} ' 'streams') _check_T_min_app(T_min_app) x = approach_contributions if x is not None: if (not isinstance(x, (Sequence, np.ndarray)) or isinstance(x, (str, bytes))): raise TypeError('approach_contributions must be a sequence ' 'with one value per stream, not ' f'{type(x).__name__!r}') # the length, each value, then T_min_app if a value is given; # every value unspecified is the default path (None) x = _given_contributions(N, T_min_app, x) if x is not None: x = tuple(x) if labels is None: labels = [s.ID for s in streams_inlet] elif len(labels) != N: raise ValueError(f'{len(labels)} labels given for {N} streams') if curves is None: # named by the labels in their warnings curves = [StreamCurve(si, so, hot, label=label) for si, so, hot, label in zip(streams_inlet, streams_quenched, is_hot, labels)] if x is None: table, curves, grid = _problem_table( streams_inlet, streams_quenched, is_hot, T_min_app, curves) else: table, curves, grid = _problem_table( streams_inlet, streams_quenched, is_hot, T_min_app, curves, approach_contributions=list(x)) dH = np.array([c.H_out - c.H_in for c in curves]) tol = 1e-9 * float(np.abs(dH).sum()) for j in np.flatnonzero(np.where(is_hot, dH > tol, dH < -tol)): error = ValueError(f'stream {labels[j]!r} is ' f"{'heated' if dH[j] > 0. else 'cooled'} " f'({dH[j]:+.6g} kJ/hr) but marked ' f"{'hot' if is_hot[j] else 'cold'}: is_hot " 'must be True for a cooled stream and False ' 'for a heated one') # which stream, for `from_heat_utilities` to say it in terms of # its heat utility, which gives its side error._hensmith_reversed = (int(j), float(dH[j])) raise error self.T_min_app = T_min_app self.approach_contributions = x self.is_hot = is_hot self.labels = list(labels) self.streams_inlet = streams_inlet self.streams_quenched = streams_quenched self.curves = curves self.table = table self.grid = grid self.shift = grid['shift'] self.Q_hot = table.hot_util_load self.Q_cold = table.cold_util_load self.pinch_T = table.pinch_T self.cut = _pinch_cut(table) self.heat_utilities = None self.units = None self.key_units = None self.force_ideal_thermo = False self.system = None #: The holders of auxiliaries held without an owner when the #: targets were made (see `hensmith.hxn_synthesis._held_owners`). self._holders = None #: Grid index of the pinch. self._k_pinch = int(np.flatnonzero(table.Ts == table.pinch_T)[0]) #: Threshold of every heat comparison [kJ/hr], the table's own. self._tol = tol
[docs] @classmethod def from_heat_utilities(cls, hus, T_min_app=5., approach_contributions=None, force_ideal_thermo=False, sort_hus_by_T=False, key_units=None): """ Targets of the process streams behind heat utilities, prepared exactly as `hensmith.hxn_synthesis.synthesize_network` prepares them (so the targets equal the synthesis's, bit for bit). Parameters ---------- hus : Iterable[HeatUtility] Heat utilities of heat exchangers (``hu.unit``, whose first inlet and outlet are the process stream), such as ``bst.process_tools.heat_exchanger_utilities_from_units(sys.units)`` (those a heat exchanger network may take, ``hu.hxn_ok``, as `HeatExchangerNetwork` selects them), not ``sys.heat_utilities`` (sums by agent, of no unit). A selection is valid until its units are simulated again: biosteam then gives each unit new heat utilities, emptying the former ones, so select them again after any simulation. Zero-duty ones are dropped. Each counts once per occurrence, a stream each, as in the synthesis: biosteam lists the heat utility of a unit in parallel (``Unit.parallel``, as the recirculation heat exchanger of a `biosteam.AbstractStirredTankReactor`, such as a fermentor, with ``parallel['self']`` reactors) once per unit in its owner's ``heat_utilities``, and so in this selection. After a simulation of a `HeatExchangerNetwork` with ``replace_unit_heat_utilities=True`` they hold the network's utilities, not the process duties, and those of the streams it serves completely are empty (left out of biosteam's selection): take `HeatExchangerNetwork.targets` then, or build the targets before that simulation. T_min_app : float, optional Minimum approach temperature [K], finite and >= 0 (> 0 with approach contributions). Defaults to 5. approach_contributions : Mapping, Sequence or callable, optional Approach temperature contributions [K] in any form `synthesize_network` takes (a sequence is aligned with `hus` as given, zero-duty heat utilities included). Defaults to None. force_ideal_thermo : bool, optional Copy the streams with ideal thermodynamics. Defaults to False. sort_hus_by_T : bool, optional Order the streams by inlet temperature, as the synthesis's option. Defaults to False. key_units : Iterable[Unit], optional Further units at hand, such as ``sys.units``: the keys of `approach_contributions` and of `h` (see `area_target`) may name them, their nested auxiliary units and the exchangers they hold through their heat utilities only (the targets' `key_units`), whether or not they hold a heat utility of `hus`, as the keys of a `HeatExchangerNetwork` with these units may. A `biosteam.MultistageCompressor` K1 among them owns the stages it holds without an owner, so ``'K1'`` covers its intercoolers and ``('K1', 'K1_H1')`` names one (as a unit owns an exchanger it holds through its heat utilities only). Without them, only the units of `hus` and their owners are at hand, and ``'K1_H1'`` is that intercooler's key. Defaults to None. Returns ------- PinchTargets Streams in stream order (heating utilities, i.e. cold streams, first), labeled by unit (``'<owner> - <unit>'`` for an auxiliary unit, with its top owner as keys take it). Raises ------ ValueError If a heat utility belongs to no unit, or is not (or no longer) among its unit's ``heat_utilities`` (of a selection made before its unit was simulated again), or one with duty is not a heat exchanger's (``hu.hxn_ok`` False, as for the heat utilities that reactors, mixers or compressors add with ``Unit.add_heat_utility``: their unit's first inlet and outlet need not be the stream they heat or cool). If the heat utilities with duty of a unit are given more often than its ``heat_utilities`` or those of any of its owners list them, as when selections from overlapping unit lists (such as a unit and an auxiliary unit of it) give the same heat utility twice: its stream would count once more per extra one. If the stream of a heat utility, from its unit's inlet now to its outlet as last simulated, runs against the heat utility (cooled for a heating one, or heated for a cooling one): its inlet has changed since its unit was simulated (as the second warning below, given before this error, may say too). Warns ----- RuntimeWarning If a heat utility is one that a `HeatExchangerNetwork` simulated with ``replace_unit_heat_utilities=True`` has written its utilities over (and not given back yet, as its next simulation does), with a duty (``hu.unit_duty``) other than its process stream's (``outs[0].H - ins[0].H`` of its unit, which the targets use) beyond rounding: the warning names that network, whose `HeatExchangerNetwork.targets` are the process's. A duty that differs from its stream's for another reason warns of nothing, the targets being the streams' (the total condenser of a `biosteam.MESHDistillation` with ``full_condenser=True`` reports a duty about 0.1 % off the enthalpy change of its own streams, for instance). RuntimeWarning If the first inlet and outlet of a heat utility's unit 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 (a feed changed, its unit not simulated since), and the targets take its stream from that new inlet to the outlet of the former state, a state of no process: simulate the system (or the units) first, then select the heat utilities again. A change of temperature alone keeps the flows equal and warns of nothing: the targets are then of the new inlet and of the outlet as last simulated, which is the outlet's state now if the unit sets it by temperature (as a `biosteam.HXutility` with ``T`` does). """ hus = list(hus) _check_T_min_app(T_min_app) selection = ('take bst.process_tools.heat_exchanger_utilities_from_' 'units(sys.units), the heat utilities a heat exchanger ' 'network may take (before any simulation of a ' 'HeatExchangerNetwork with replace_unit_heat_utilities=' 'True, which writes its utilities over them; take its ' 'targets() after one)') if any(hu.unit is None for hu in hus): raise ValueError('every heat utility must belong to a heat ' 'exchanger (hu.unit), not be one of ' 'System.heat_utilities (sums by agent, of no ' f'unit): {selection}') # biosteam gives a unit new heat utilities each time it is simulated, # emptying the former ones (no duty), which would drop out silently former = [hu for hu in hus if not any(i is hu for i in hu.unit.heat_utilities)] if former: names = ', '.join(dict.fromkeys(repr(_unit_label(hu.unit)) for hu in former)) raise ValueError(f'the heat utilities of {names} are not (or no ' "longer) among their units' heat_utilities: " 'biosteam gives a unit new heat utilities each ' 'time it is simulated, emptying the former ones, ' 'so a selection is valid until its units are ' 'simulated again; select them again after any ' 'simulation, e.g. bst.process_tools.heat_' 'exchanger_utilities_from_units(sys.units)') others = [hu for hu in hus if hu.duty and not hu.hxn_ok] if others: names = ', '.join(dict.fromkeys(repr(_unit_label(hu.unit)) for hu in others)) raise ValueError('every heat utility must be a heat exchanger\'s ' f'(hu.hxn_ok), not those of {names}, whose first ' 'inlet and outlet need not be the stream they ' f'heat or cool: {selection}') if key_units is not None: key_units = _with_auxiliaries(key_units) holders = _held_owners([hu.unit for hu in hus] + (key_units or [])) # a heat utility counts once per occurrence, as in the synthesis # (biosteam repeats those of a unit in parallel): only more # occurrences than its unit and owners list are given twice over = _overcounted_units(hus, holders) if over: names = ', '.join(f'{_unit_label(unit, holders)!r} ({n} given, ' f'{m} listed)' for unit, n, m in over) raise ValueError(f'the heat utilities of {names} are given more ' 'often than their units, or the owners of ' 'these, list them in their heat_utilities (which ' 'repeat those of a unit in parallel, by ' 'Unit.parallel, once per unit, each a stream): ' 'their streams would count once more per extra ' 'one, as when selections from overlapping unit ' 'lists (such as a unit and an auxiliary unit of ' 'it) give the same heat utility twice; give the ' 'heat utilities of units none of which owns ' 'another, such as bst.process_tools.heat_' 'exchanger_utilities_from_units(sys.units)') # only those that a network has written over: a duty may differ from # its stream's for another reason (biosteam's MESHDistillation total # condenser, 0.1 % off), which the targets, the streams', ignore replaced = [hu for hu in hus if hu.hxn_ok and _duty_off_stream(hu)] if replaced: from ._heat_exchanger_network import _replacing_networks networks = _replacing_networks(replaced) replaced = [hu for hu in replaced if id(hu) in networks] if replaced: names = ', '.join(dict.fromkeys(repr(_unit_label(hu.unit)) for hu in replaced)) IDs = list(dict.fromkeys(networks[id(hu)].ID for hu in replaced)) _warn(f'the heat utilities of {names} have duties (hu.unit_duty) ' 'other than their process streams\' (outs[0].H - ins[0].H), ' 'which the targets use: they hold the utilities of ' f"HeatExchangerNetwork {', '.join(map(repr, IDs))}, " 'simulated with replace_unit_heat_utilities=True, and the ' 'streams it serves completely have none and are left out; ' f"take {' or '.join(f'{ID}.targets()' for ID in IDs)} " 'instead') values = _explicit_contributions(hus, approach_contributions, T_min_app, key_units) arranged, N_heating = _arrange_heat_utilities(hus, sort_hus_by_T) if values is None: contributions = None else: # in stream order value_of = dict(zip(map(id, hus), values)) contributions = [value_of[id(hu)] for hu in arranged] is_hot = [False] * N_heating + [True] * (len(arranged) - N_heating) hxs = [hu.unit for hu in arranged] labels = [_unit_label(hx, holders) for hx in hxs] # before the targets, whose streams a changed inlet may reverse _warn_stale(_stale_labels(hxs, labels), reselect=True) try: self = cls._from_exchangers(hxs, is_hot, T_min_app, contributions, force_ideal_thermo, labels, arranged, holders) except ValueError as error: reversed_ = getattr(error, '_hensmith_reversed', None) if reversed_ is None: raise j, dH = reversed_ hu = arranged[j] raise ValueError( f'the heat utility of {labels[j]!r} ' f"{'heats' if hu.duty > 0. else 'cools'} (hu.duty = " f'{hu.duty:+.6g} kJ/hr), but its stream, from its unit\'s ' 'inlet now to its outlet as last simulated, is ' f"{'heated' if dH > 0. else 'cooled'} ({dH:+.6g} kJ/hr): its " 'inlet has changed since its unit was simulated (a feed ' 'changed, or a recycle converged loosely?); simulate the ' 'system (or its units) first, converging any recycle more ' 'tightly, then select the heat utilities again') from None self.key_units = key_units return self
@classmethod def _from_exchangers(cls, hxs, is_hot, T_min_app, approach_contributions, force_ideal_thermo, labels=None, heat_utilities=None, holders=None): """Targets of the process streams of the exchangers `hxs` (stream order, contributions resolved in stream order), labeled by unit by default, with the holders `holders` of auxiliaries held without an owner (see `hensmith.hxn_synthesis._held_owners`), as they were when the contributions were resolved.""" _check_T_min_app(T_min_app) streams_inlet, streams_quenched = _process_streams(hxs, force_ideal_thermo) if labels is None: labels = [_unit_label(hx, holders) for hx in hxs] self = cls(streams_inlet, streams_quenched, is_hot, T_min_app, approach_contributions, labels) self.units = list(hxs) self.heat_utilities = None if heat_utilities is None else list(heat_utilities) self.force_ideal_thermo = bool(force_ideal_thermo) self._holders = holders return self
[docs] def at(self, T_min_app, scale_contributions=False): """ The targets of the same streams at another minimum approach temperature, reusing their curves (no flash). Parameters ---------- T_min_app : float Minimum approach temperature [K], finite and >= 0 (> 0 with approach contributions). scale_contributions : bool, optional Scale the specified approach contributions by ``T_min_app / self.T_min_app`` (the contribution scaling of contribution-based supertargeting, Ahmad, Linnhoff & Smith 1990); by default they stay as they are. Unspecified ones are ``T_min_app / 2`` either way. Defaults to False. Returns ------- PinchTargets With the same streams, curves, labels, units, key units, heat utilities and system. """ c = self.approach_contributions if c is not None and scale_contributions: _approach_contributions(len(c), T_min_app, c) # checks T_min_app c = [None if x is None else x * T_min_app / self.T_min_app for x in c] new = type(self)(self.streams_inlet, self.streams_quenched, self.is_hot, T_min_app, c, self.labels, self.curves) new.heat_utilities = self.heat_utilities new.units = self.units new.key_units = self.key_units new.force_ideal_thermo = self.force_ideal_thermo new.system = self.system new._holders = self._holders return new
def __repr__(self): N = self.is_hot.size return (f'<{type(self).__name__}: {N} stream{"s" if N != 1 else ""}, ' f'T_min_app = {self.T_min_app:g} K, Q_hot = {self.Q_hot:.3e} ' f'kJ/hr, Q_cold = {self.Q_cold:.3e} kJ/hr>') # %% Curves def _members(self, hot, shifted=False): """The composite members of the hot (or cold) streams.""" return [_stream_knots(self.grid, j, shifted) for j in np.flatnonzero(self.is_hot == hot)]
[docs] def composite_curves(self, shifted=False): """ The hot and cold composite curves. Parameters ---------- shifted : bool, optional Return shifted temperatures (each stream's real temperature minus its shift; the scale of the problem table) instead of real ones. Defaults to False. Returns ------- hot_T, hot_H, cold_T, cold_H : numpy.ndarray Temperatures [K] and cumulative heat [kJ/hr] of each composite, ascending; the hot one starts at 0 and the cold one at the cold utility target. Empty if there is no stream of that kind. """ hot_T, hot_H = _curve(_composite(self._members(True, shifted))) cold_T, cold_H = _curve(_composite(self._members(False, shifted))) return hot_T, hot_H, cold_T, cold_H + self.Q_cold
def _gcc(self): """The grid temperatures and the heat cascaded with the hot utility target arriving at and leaving each, ``(Ts, arr, lea)``.""" table = self.table leaving = table.residual + table.hot_util_load arriving = leaving - table.point_H.sum(axis=0) return table.Ts, arriving, leaving
[docs] def grand_composite_curve(self): """ The grand composite curve. Returns ------- T : numpy.ndarray Shifted grid temperatures [K], descending, each twice. H : numpy.ndarray Heat cascaded with the hot utility target [kJ/hr]: at each grid temperature the heat arriving (before its point loads) and then the heat leaving it (after them). It starts at the hot utility target and ends at the cold one, to rounding, and is zero at the pinch. """ Ts, arr, lea = self._gcc() return np.repeat(Ts, 2), np.column_stack([arr, lea]).ravel()
# %% Utilities def _side_heat(self): """Each stream's heat above and below the pinch [kJ/hr] (all below for a threshold problem, whose pinch is at the grid's top with the cut 'below').""" table = self.table k = self._k_pinch interval_H = np.abs(table.interval_H) point_H = np.abs(table.point_H) above = interval_H[:, :k].sum(axis=1) + point_H[:, :k].sum(axis=1) below = interval_H[:, k:].sum(axis=1) + point_H[:, k+1:].sum(axis=1) if self.cut == 'above': above = above + point_H[:, k] else: below = below + point_H[:, k] return above, below def _level(self, agent, heating, j, reach): """ The shifted level of `agent` serving the streams `j` (`_served`) of which those of the mask `reach` (`_in_reach`) are in its reach: the highest (lowest for cooling), up to the highest of the shifted limits of `j` (down to the lowest), that serves no stream in reach beyond its own real limit nor, if the agent is sensible, one at ``agent.T - dT`` real (``agent.T + dT`` for cooling) anywhere but at the level (see Notes of the class). """ dT = bst.HeatUtility.dT grid = self.grid Ts = grid['Ts'] s = self.shift[j] top, bottom = Ts[grid['k_hi'][j]], Ts[grid['k_lo'][j]] # the point load at the end a level beyond the stream would reach # first: its bottom (heating) or its top (cooling) k = grid['k_lo' if heating else 'k_hi'][j] point = np.abs(self.table.point_H[j, k]) > self._tol # a stream in reach reaching beyond its limit caps the level at the # limit, or, lying wholly beyond it, at its own end (just short of it # if a point load is there): a level serves only what lies below it # (above it, for cooling). So does one ending at its limit, for a # sensible agent whose dT is 0 (the stream's end at agent.T - dT # real; agent.T + dT for cooling): the agent, having given heat above # that end (taken it below), would have to leave at its inlet # temperature there, at an unbounded flow (see `_line_limit`); at the # level, where it has given none, it keeps dT sensible = _sensible(agent) if heating: R = agent.T - agent.dT - dT limit = R - s beyond = top > limit if sensible: beyond |= top + s >= agent.T - dT - _T_EQ caps = np.maximum(limit, bottom - _T_SHORT * point)[reach & beyond] if j.size: L = caps.min(initial=limit.max()) else: L = R else: R = agent.T + agent.dT + dT limit = R - s beyond = bottom < limit if sensible: beyond |= bottom + s <= agent.T + dT + _T_EQ caps = np.minimum(limit, top + _T_SHORT * point)[reach & beyond] if j.size: L = caps.max(initial=limit.min()) else: L = R - self.T_min_app if reach.any() and sensible: L = self._short_of_stuck_loads(agent, heating, L, j[reach], s[reach]) return float(L) def _in_reach(self, j, positions, B, heating): """ Mask of the streams `j` (`_served`) that the heat of a next agent can reach given the balanced heat flow `B` at the `positions` of the agents already placed: those with heat above the highest zero of `B` above the pinch temperature (below the lowest one below it, for cooling), a utility pinch or another process pinch; all of them if there is no such zero. A level at or beyond that zero gets no duty, and the heat of a level short of it never passes it. """ T = positions[0] pinch_T = self.pinch_T if heating: z = np.flatnonzero((B <= self._tol) & (T > pinch_T)) else: z = np.flatnonzero((B <= self._tol) & (T < pinch_T)) if not z.size: return np.ones(j.size, dtype=bool) # the heat between the virtual top and the zero (heating), or the # zero and the virtual bottom (cooling): position p is column p + 1 H = self._position_H(positions)[j] if heating: heat = np.abs(H[:, z[0] + 1] - H[:, 0]) else: heat = np.abs(H[:, -1] - H[:, z[-1] + 1]) return heat > self._tol def _short_of_stuck_loads(self, agent, heating, L, j, s): """ The level `L` of the sensible `agent`, or just short of it (`_T_SHORT`) if it would serve a point load of the streams `j` (shifts `s`) at it that leaves the agent's line no driving force: at ``agent.T - dT`` real (``agent.T + dT`` for cooling), a stream's own limit when ``agent.dT`` is 0. The agent would leave at its inlet temperature, at an unbounded flow; biosteam's rule, strict there, does not take it either. """ Ts = self.grid['Ts'] k = np.flatnonzero(np.abs(Ts - L) <= _T_EQ) if not k.size: return L dT = bst.HeatUtility.dT T = Ts[k][None, :] + s[:, None] # real load = np.abs(self.table.point_H[np.ix_(j, k)]) > self._tol if heating: if (load & (T >= agent.T - dT - _T_EQ)).any(): return L - _T_SHORT elif (load & (T <= agent.T + dT + _T_EQ)).any(): return L + _T_SHORT return L def _allocate(self, agents, Q, heating): """ Greedy list-order allocation of `Q` to `agents` on the grand composite curve: ``([(level, q, agent)], remaining)``. Each agent's level (`_level`) is set by the streams in reach given the agents placed before it (`_in_reach`). """ Ts, arr, lea = self._gcc() tol = self._tol served = self._served(heating) allocated = [] remaining = Q for agent in agents: if remaining <= tol: break done = [(l, q) for l, q, _ in allocated] positions = _positions(Ts, arr, lea, [l for l, _ in done]) B = (_balance(positions, done, ()) if heating else _balance(positions, (), done)) reach = self._in_reach(served, positions, B, heating) level = _snap(Ts, self._level(agent, heating, served, reach)) positions = _positions(Ts, arr, lea, [level] + [l for l, _ in done]) if heating: B = _balance(positions, done, ()) bound = B[:_gap(positions, level)] else: B = _balance(positions, (), done) bound = B[_gap(positions, level):] q = min(remaining, bound.min()) if bound.size else remaining if q > tol: allocated.append((level, q, agent)) remaining -= q return allocated, remaining def _served(self, heating): """Indices of the streams a heating agent can serve (the cold streams with heat above the pinch) or a cooling one (the hot streams with heat below it).""" above, below = self._side_heat() if heating: return np.flatnonzero(~self.is_hot & (above > self._tol)) return np.flatnonzero(self.is_hot & (below > self._tol)) def _position_H(self, positions): """Each stream's enthalpy [kJ/hr] at the `positions` (`_positions`), between a virtual top and a virtual bottom one: (N, P + 2), position p in column p + 1. The heat of a stream between two columns is the heat it has between those positions, point loads included where the arriving and the leaving position of their grid temperature both lie between them.""" T, _, kind, k = positions Ts, Hl, Hr = self.grid['Ts'], self.grid['Hl'], self.grid['Hr'] H = np.where(kind == _ARRIVING, Hr[:, k], Hl[:, k]) inner = np.flatnonzero((kind == _ABOVE) | (kind == _BELOW)) if inner.size: ki = k[inner] f = (T[inner] - Ts[ki + 1]) / (Ts[ki] - Ts[ki + 1]) H[:, inner] = Hr[:, ki + 1] + (Hl[:, ki] - Hr[:, ki + 1]) * f return np.column_stack([Hr[:, 0], H, Hl[:, -1]]) def _pinch_position(self, positions): """Index of the process pinch among the `positions`: the heat arriving at the pinch temperature if the cut is 'below', the heat leaving it if 'above'.""" T, _, kind, _ = positions return int(np.flatnonzero( (T == self.grid['Ts'][self._k_pinch]) & (kind == (_ARRIVING if self.cut == 'below' else _LEAVING)))[0]) def _ends(self, H, a, b, lo, hi, heating): """ ``((T_in, T_out), (top, bottom, shift))`` of a utility serving the heat between columns `a` and `b` of `H` (`_position_H`), shifted [lo, hi]: the real temperatures [K] of the streams it serves there (the cold ones with heat there above the tolerance, or the hot ones for cooling; any heat if no stream has more), the coldest and the hottest (heating; the hottest and the coldest for cooling), and their shifted ends [K] and shifts [K]. A stream served there only at a point load at an end of the stretch counts at that point load's temperature. """ heat = np.abs(H[:, b] - H[:, a]) heat[self.is_hot if heating else ~self.is_hot] = 0. j = np.flatnonzero(heat > self._tol) if not j.size: j = np.flatnonzero(heat > 0.) if not j.size: raise RuntimeError(f'no stream has heat in the stretch [{lo:.6g}, ' f'{hi:.6g}] K (shifted) of a utility') grid = self.grid Ts, s = grid['Ts'], self.shift[j] top, bottom = Ts[grid['k_hi'][j]], Ts[grid['k_lo'][j]] cold = float(np.min(np.maximum(bottom, lo) + s)) hot = float(np.max(np.minimum(top, hi) + s)) return ((cold, hot) if heating else (hot, cold)), (top, bottom, s) def _line_limit(self, agent, heating, q, T, B, served): """ The limit [K] on the outlet of the sensible `agent` serving `q` from the positions of its stretch (shifted temperatures `T` and balanced heat flow `B`): the lowest outlet (heating; the highest for cooling) at which its line, linear in its heat, stays `dT` from the `served` streams present at each position (see Notes of the class); None if no position bounds it. """ top, bottom, s = served # the heat the agent has at least given above each position (heating) # or taken below it (cooling): the rest of the heat flowing there x = q - B present = (bottom - _T_EQ <= T[:, None]) & (T[:, None] <= top + _T_EQ) bound = (x > self._tol) & present.any(axis=1) if not bound.any(): return None dT = bst.HeatUtility.dT x, T, present = x[bound], T[bound], present[bound] if heating: T_process = T + np.where(present, s, -math.inf).max(axis=1) return agent.T - float(np.min((agent.T - dT - T_process) * q / x)) T_process = T + np.where(present, s, math.inf).min(axis=1) return agent.T + float(np.min((T_process - dT - agent.T) * q / x))
[docs] def utility_targets(self, heating_agents=None, cooling_agents=None): """ Multiple-utility targets: the agents placed on the grand composite curve and priced by biosteam (see Notes of the class). They are placed in biosteam's order of preference, not by price, so their total cost is not the least utility cost of a network. Parameters ---------- heating_agents : Sequence[UtilityAgent or str], optional Heating agents (``isheating_agent``) in order of preference, or the IDs of agents of ``bst.HeatUtility.heating_agents``; fuel agents (``isfuel``) take the remainder, the first one only. Defaults to ``bst.HeatUtility.heating_agents`` (read at call time). cooling_agents : Sequence[UtilityAgent or str], optional Cooling agents (``iscooling_agent``, not fuels) in order of preference, or the IDs of agents of ``bst.HeatUtility.cooling_agents``. Defaults to ``bst.HeatUtility.cooling_agents``. Returns ------- list[UtilityTarget] The heating utilities in allocation order (fuel, or an unserved remainder, last), then the cooling ones. Agents with no duty are left out. Raises ------ ValueError If an ID names no agent of biosteam's list, or an agent is not of the kind of its list. Warns ----- RuntimeWarning If no agent can serve part of the hot or cold utility target. """ heating_agents = _agents(heating_agents, True) cooling_agents = _agents(cooling_agents, False) if heating_agents is None: heating_agents = list(bst.HeatUtility.heating_agents) if cooling_agents is None: cooling_agents = list(bst.HeatUtility.cooling_agents) tol = self._tol heat, remaining = self._allocate( [a for a in heating_agents if not a.isfuel], self.Q_hot, True) if remaining > tol: fuels = [a for a in heating_agents if a.isfuel] heat.append((math.inf, remaining, fuels[0] if fuels else None)) if not fuels: _warn(f'no heating agent can serve {remaining:.6g} kJ/hr of ' 'the hot utility target; it is left unserved') cool, remaining = self._allocate(cooling_agents, self.Q_cold, False) if remaining > tol: cool.append((-math.inf, remaining, None)) _warn(f'no cooling agent can serve {remaining:.6g} kJ/hr of the ' 'cold utility target; it is left unserved') # the stretch each agent serves: from its level, clipped to the grid, # to the next zero of the balanced curve, the pinch or a utility pinch Ts, arr, lea = self._gcc() positions = _positions(Ts, arr, lea, [l for l, _, _ in heat + cool if math.isfinite(l)]) B = _balance(positions, [(l, q) for l, q, _ in heat], [(l, q) for l, q, _ in cool]) zero = B <= tol T = positions[0] # each stream's enthalpy at the positions: its heat in a stretch, the # point loads of a grid temperature at its end included only where # the stretch holds them (as the balanced curve does) H = self._position_H(positions) pinch = self._pinch_position(positions) dT = bst.HeatUtility.dT utilities = [] with _isolated_pricing([a for *_, a in heat + cool if a is not None]): for level, q, agent in heat: # positions g to end - 1 (columns g to end of H) g = _gap(positions, level) z = np.flatnonzero(zero[g:]) end = g + int(z[0]) + 1 if z.size else pinch + 1 lo = float(T[end - 1]) hi = min(level, float(Ts[0])) (T_in, T_out), served = self._ends(H, g, end, lo, hi, True) T_priced = T_in if _sensible(agent): limit = self._line_limit(agent, True, q, T[g:end], B[g:end], served) if limit is not None: T_priced = max(T_in, limit - dT) utilities.append(self._utility(agent, True, level, q, T_in, T_out, T_priced)) for level, q, agent in cool: # positions start to g - 1 (columns start + 1 to g + 1 of H) g = _gap(positions, level) z = np.flatnonzero(zero[:g]) start = int(z[-1]) if z.size else pinch hi = float(T[start]) lo = max(level, float(Ts[-1])) (T_in, T_out), served = self._ends(H, start + 1, g + 1, lo, hi, False) T_priced = T_in if _sensible(agent): limit = self._line_limit(agent, False, q, T[start:g], B[start:g], served) if limit is not None: T_priced = min(T_in, limit + dT) utilities.append(self._utility(agent, False, level, q, T_in, T_out, T_priced)) return utilities
def _utility(self, agent, heating, level, q, T_in, T_out, T_priced): """The `UtilityTarget` of `agent` serving `q` from `T_in` to `T_out` (process temperatures), priced as one biosteam heat utility from `T_priced` to `T_out` (inside `_isolated_pricing`).""" shift = tuple(self.shift.tolist()) if agent is None: nan = math.nan return UtilityTarget(None, None, heating, level, q, nan, nan, nan, T_in, T_out, nan, nan, shift) hu = bst.HeatUtility() hu(q if heating else -q, T_priced, T_out, agent=agent) return UtilityTarget(agent.ID, agent, heating, level, q, abs(hu.duty), abs(hu.flow), hu.cost, T_in, T_out, hu.inlet_utility_stream.T, hu.outlet_utility_stream.T, shift) # %% Balanced curves def _check_utilities(self, utilities): """Raise a ValueError unless the heating duties of `utilities` (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, as those of `utility_targets` do (to the remainders of at most `_tol` that it leaves out, and round-off), and every utility's `shift` is None or `shift`: the duties alone do not tell targets of another shift apart when they do not depend on it (streams of one kind only, or a threshold problem below its threshold), and their levels would then be read on the wrong shifted temperatures.""" for heating, Q, name in ((True, self.Q_hot, 'Q_hot'), (False, self.Q_cold, 'Q_cold')): total = math.fsum([u.duty for u in utilities if bool(u.heating) == heating]) if not abs(total - Q) <= 2. * self._tol + 1e-9 * abs(Q): kind = 'heating' if heating else 'cooling' raise ValueError( f'the {kind} duties of utilities add up to {total:.6g} ' f'kJ/hr, not {name} = {Q:.6g} kJ/hr: give the ' 'utility_targets() of these targets (those of other ' 'targets, e.g. of another T_min_app, or a part of them ' 'do not balance their curves)') for u in utilities: if u.shift is not None and not np.array_equal(u.shift, self.shift): raise ValueError( f'utility {u.ID!r} was placed on the shifted temperatures ' 'of other targets (its shift is not that of these ' 'targets): give the utility_targets() of these targets ' '(those of other targets, e.g. of another T_min_app, do ' 'not balance their curves)') def _balanced_segments(self, utilities, inv_h=None, inv_h_utilities=None): """ The hot and cold segments (`_composite`) of the balanced composite curves on real temperatures: the streams plus the served utilities, fuel as a last hot segment at infinite temperature (a weight of 0, no area). `inv_h` weighs the streams (N) and `inv_h_utilities` the `utilities` (1 by default). The `utilities` are checked (`_check_utilities`). """ self._check_utilities(utilities) if inv_h is None: inv_h = np.ones(self.is_hot.size) if inv_h_utilities is None: inv_h_utilities = [1.] * len(utilities) sides = [] for hot in (True, False): members = self._members(hot) weights = list(inv_h[self.is_hot == hot]) fuel = 0. for u, weight in zip(utilities, inv_h_utilities): if u.heating != hot or u.agent is None: continue if u.agent.isfuel: fuel += u.duty continue members.append(_utility_member(u)) weights.append(weight) segments = _composite(members, weights) if fuel: end = segments[1][-1] if segments[1].size else 0. segments = tuple(np.append(x, v) for x, v in zip( segments, (end, end + fuel, math.inf, math.inf, 0.))) sides.append(segments) return sides
[docs] def balanced_composite_curves(self, utilities=None): """ The balanced composite curves (see Notes of the class). Parameters ---------- utilities : list[UtilityTarget], optional `utility_targets` of these targets, with any agents: a ValueError is raised unless the heating duties (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, and the `shift` of each is None or these targets' `shift` (the utilities of other targets, e.g. of another `T_min_app`, or a part of them do not balance the curves). Defaults to `utility_targets` with the default agents. Returns ------- hot_T, hot_H, cold_T, cold_H : numpy.ndarray Real temperatures [K] and cumulative heat [kJ/hr] of each composite, ascending: the hot one from 0, with fuel as a last stretch at ``T = inf``, and the cold one from the unserved cooling (0 if none), the coldest of its members, which is left out. The curves are thus aligned for vertical heat transfer, and end at the same heat unless part of the heating target is unserved (the hot one then ends short of the cold one by it). """ if utilities is None: utilities = self.utility_targets() hot, cold = self._balanced_segments(utilities) hot_T, hot_H = _curve(hot) cold_T, cold_H = _curve(cold) unserved = sum([u.duty for u in utilities if u.agent is None and not u.heating], 0.) if unserved: cold_H = cold_H + unserved return hot_T, hot_H, cold_T, cold_H
def _balanced_positions(self, utilities): """The constraint positions with the levels of every allocated finite level of `utilities` (checked, see `_check_utilities`), and the balanced heat flow at each.""" self._check_utilities(utilities) Ts, arr, lea = self._gcc() positions = _positions(Ts, arr, lea, [u.level for u in utilities if math.isfinite(u.level)]) B = _balance(positions, [(u.level, u.duty) for u in utilities if u.heating], [(u.level, u.duty) for u in utilities if not u.heating]) return positions, B
[docs] def balanced_grand_composite_curve(self, utilities=None): """ The balanced grand composite curve: the grand composite curve net of the utilities. Parameters ---------- utilities : list[UtilityTarget], optional `utility_targets` of these targets, with any agents: a ValueError is raised unless the heating duties (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, and the `shift` of each is None or these targets' `shift` (the utilities of other targets, e.g. of another `T_min_app`, or a part of them do not balance the curves). Defaults to `utility_targets` with the default agents. Returns ------- T : numpy.ndarray Shifted temperatures [K], descending: the grid temperatures (twice each, arriving and leaving heat) and the utility levels between them (twice each, just above and just below). B : numpy.ndarray Heat cascaded net of the utilities [kJ/hr], non-negative: zero at the pinch and at every utility pinch, at the top the duty of the fuel and of levels above the grid, at the bottom that of cooling levels below it. """ if utilities is None: utilities = self.utility_targets() positions, B = self._balanced_positions(utilities) return positions[0].copy(), B
# %% Supertargets def _film_coefficients(self, h): """The film coefficient of each stream [kW/m²/K] from `h` (see `area_target`), an (N,) array.""" N = self.is_hot.size if h is None: return np.full(N, _H_DEFAULT) if isinstance(h, Mapping): return self._film_mapping(h) if isinstance(h, (Sequence, np.ndarray)) and not isinstance(h, (str, bytes)): values = list(h) if len(values) != N: raise ValueError(f'{len(values)} film coefficients h given for ' f'{N} streams') return np.array([_film_value(v, f'h[{j}]') for j, v in enumerate(values)]) return np.full(N, _film_value(h, 'h')) def _film_mapping(self, h): """ `_film_coefficients` of a mapping whose keys are stream indices or name units as approach contribution keys do (see `hensmith.hxn_synthesis._mapping_contributions`): the key of the lowest level on the owner chain of a stream's unit wins, and a stream index is below its unit. Keys of the same level for a unit must give it the same value. Unit keys may also name `key_units` (and their owners) and the auxiliaries that units may build but do not hold now, as approach contribution keys of the facility may, and then have no effect. An auxiliary that a unit at hand held without an owner when the targets were made, or holds so now, is that unit's (see `hensmith.hxn_synthesis._key_universe`). """ N = self.is_hot.size units = self.units or () universe, live, owners = _key_universe( [*units, *(self.key_units or ())], self._holders) items = [] unknown = [] for key, value in h.items(): if isinstance(key, bst.HeatUtility): raise TypeError('a heat utility cannot be a key of h (biosteam ' 'replaces the heat utilities of a unit at every ' "simulation): use a stream index, its unit " "(hu.unit), the unit's ID or (owner.ID, " 'unit.ID)') if isinstance(key, Integral) and not isinstance(key, (bool, np.bool_)): if not 0 <= key < N: unknown.append(key) elif not (isinstance(key, (bst.Unit, str)) or isinstance(key, tuple) and len(key) == 2 and all(isinstance(i, str) for i in key)): raise TypeError('h keys must be stream indices (int), units, ' 'unit IDs (str) or (owner.ID, unit.ID) tuples, ' f'not {type(key).__name__!r} ({key!r})') elif _unknown_keys([key], universe, owners): unknown.append(key) items.append((key, _film_value(value, f'h of key {key!r}'))) if unknown: raise ValueError('h keys that name no stream: ' f"{', '.join(map(repr, unknown))} (a key is a " 'stream index or, for targets of heat utilities, ' 'a unit, the ID of a unit without an owner, or ' '(owner.ID, unit.ID)' f'{_unknown_key_hints(unknown, universe, owners)}' ')') unit_items = [(k, v) for k, v in items if not isinstance(k, Integral)] _check_key_levels(unit_items, universe, live, owners, 'h') by_index = {k: v for k, v in items if isinstance(k, Integral)} values = np.full(N, _H_DEFAULT) for j in range(N): # a stream index is below its unit if j in by_index: values[j] = by_index[j] elif unit_items: # only targets of heat utilities have units values[j] = _nearest_key_value(unit_items, units[j], live, owners, _H_DEFAULT) return values def _area(self, utilities, h, h_utilities): """`area_target` with resolved film coefficients (`h`, an (N,) array; `h_utilities`, one per utility).""" self._check_utilities(utilities) if any(u.agent is None for u in utilities): return math.nan hot, cold = self._balanced_segments( utilities, 1. / h, [1. / x for x in h_utilities]) return _bath(hot, cold)
[docs] def area_target(self, utilities=None, h=None, h_utilities=None, agents=None): """ The area target [m²] of vertical heat transfer between the balanced composite curves: Bath's formula (see Notes of the class). Parameters ---------- utilities : list[UtilityTarget], optional `utility_targets` of these targets, with any agents: a ValueError is raised unless the heating duties (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, and the `shift` of each is None or these targets' `shift` (the utilities of other targets, e.g. of another `T_min_app`, or a part of them do not balance the curves). Defaults to `utility_targets` with the default agents. h : float, Sequence[float] or Mapping, optional Film coefficient of the streams [kW/m²/K]: one for all, one per stream in stream order, or a mapping from stream indices (int) or, for targets of heat utilities, keys that name units as approach contribution keys do (a unit, the ID of a unit without an owner, or ``(owner.ID, unit.ID)``; an auxiliary unit takes the value of its owner's key unless a key names it). The nearest key wins, a stream index first; streams left out take 1. Labels are not keys. A key that names no stream raises a ValueError, unless it names one of `key_units` (for `HeatExchangerNetwork.targets`, any unit available to the network, as for its approach contribution keys) or an auxiliary that a unit may build but does not hold now: it then has no effect, so that a key of a unit idle in a Monte Carlo sample does not fail. Defaults to 1 for every stream. h_utilities : float or Mapping, optional Film coefficient of the utilities [kW/m²/K]: one for all, or a mapping from agent IDs or agents (the others take 1). An ID must name an agent of `utilities`, of `agents` or of biosteam's lists, or a ValueError is raised: key another agent by the UtilityAgent itself, or pass it in `agents`. Defaults to 1. agents : Sequence[UtilityAgent or str], optional Agents whose IDs `h_utilities` may key as well, or the IDs of agents of biosteam's lists (which it may key anyway): the `heating_agents` and `cooling_agents` that `utilities` were priced with, so that one `h_utilities` serves at every `T_min_app`, an agent that serves nothing at one included, as for `summary`. They change nothing else. Defaults to none. Returns ------- float The area [m²]: inf (with a RuntimeWarning) at a zero or negative driving force, nan if part of a utility target is unserved. """ if utilities is None: utilities = self.utility_targets() return self._area(utilities, self._film_coefficients(h), _utility_films(utilities, h_utilities, _key_agents(agents)))
[docs] def units_target(self, utilities=None): """ An estimate of the number of units: Euler's S - 1 per region of the balanced grand composite curve between its zeros (see Notes of the class). Parameters ---------- utilities : list[UtilityTarget], optional `utility_targets` of these targets, with any agents: a ValueError is raised unless the heating duties (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, and the `shift` of each is None or these targets' `shift` (the utilities of other targets, e.g. of another `T_min_app`, or a part of them do not balance the curves). Defaults to `utility_targets` with the default agents. Returns ------- dict ``{'above': int, 'below': int, 'total': int}``: the units of the regions above the process pinch, below it, and both. """ if utilities is None: utilities = self.utility_targets() positions, B = self._balanced_positions(utilities) P = positions[0].size tol = self._tol # each stream's cumulative enthalpy at every position, between the # virtual top and bottom ones H = self._position_H(positions) cuts = [-1, *np.flatnonzero(B <= tol).tolist(), P] gaps = [_gap(positions, u.level) for u in utilities if u.duty > tol] pinch = self._pinch_position(positions) # the members of each region (streams j, utilities -1 - i), grouped: # consecutive regions of one gap between two zeros (where the curve # stays zero, each piece balanced on its own) on one side of the # pinch form one group groups = [] # [above, [members of each region], zero] for c0, c1 in zip(cuts[:-1], cuts[1:]): heat = np.abs(H[:, c1 + 1] - H[:, c0 + 1]) members = {*np.flatnonzero(heat > tol).tolist(), *(-1 - i for i, g in enumerate(gaps) if c0 < g <= c1)} side = c1 <= pinch zero = 0 <= c0 and c1 == c0 + 1 < P if zero and groups and groups[-1][2] and groups[-1][0] == side: groups[-1][1].append(members) else: groups.append([side, [members], zero]) above = below = 0 for side, regions, _ in groups: N = _euler(regions) if side: above += N else: below += N return {'above': above, 'below': below, 'total': above + below}
def _capital(self, utilities, h, h_utilities, P, heat_exchanger_type, material): """`capital_cost_target` with resolved film coefficients (see `_area`), and the units target.""" _check_exchanger(heat_exchanger_type, material) P = _pressure(P) A = self._area(utilities, h, h_utilities) N = self.units_target(utilities)['total'] fuel = [u for u in utilities if u.agent is not None and u.agent.isfuel] N_shells = N - (1 if fuel else 0) # one furnace for all fuel if A > 0. and N_shells < 1: N_shells = 1 elif A == 0.: N_shells = 0 if N_shells: purchase, installed, N_area_shells, kind = _shell_split( A, N_shells, P, heat_exchanger_type, material) shell_area = A / N_area_shells else: # no area, or nan shell_area = purchase = installed = A N_area_shells = 0 kind = None furnace_duty = sum((u.utility_duty for u in fuel), 0.) if fuel: furnace_purchase, furnace_installed = _furnace_costs(furnace_duty) purchase += furnace_purchase installed += furnace_installed return {'area': A, 'N_shells': N_shells, 'N_area_shells': N_area_shells, 'shell_area': shell_area, 'type': kind, 'furnace_duty': furnace_duty, 'purchase_cost': purchase, 'installed_cost': installed}, N
[docs] def capital_cost_target(self, utilities=None, h=None, h_utilities=None, P=101325., heat_exchanger_type='Floating head', material='Carbon steel/carbon steel', agents=None): """ The capital cost target: the area target in as many shells as the units target (less a furnace), split equally over all of them or over fewer if that costs more (the others then have no area), costed with biosteam's correlations, plus a furnace for the fuel (see Notes of the class). Parameters ---------- utilities : list[UtilityTarget], optional `utility_targets` of these targets, with any agents: a ValueError is raised unless the heating duties (unserved ones included) add up to `Q_hot` and the cooling ones to `Q_cold`, and the `shift` of each is None or these targets' `shift` (the utilities of other targets, e.g. of another `T_min_app`, or a part of them do not balance the curves). Defaults to `utility_targets` with the default agents. h, h_utilities : optional Film coefficients, as `area_target` takes them. P : float, optional Pressure of every shell [Pa], > 0. Defaults to 101325. heat_exchanger_type : str, optional biosteam's type of the shell and tube exchangers ('Floating head', 'Fixed head', 'U tube', 'Kettle vaporizer' or 'Double pipe'). Defaults to 'Floating head'. material : str, optional biosteam's shell and tube material. Defaults to 'Carbon steel/carbon steel'. agents : Sequence[UtilityAgent or str], optional Agents whose IDs `h_utilities` may key as well, as `area_target` takes them. Returns ------- dict 'area' [m²]; 'N_shells', the number of shells; 'N_area_shells', how many of them share the area equally (the others have none); 'shell_area', the area of each of those [m²]; 'type', biosteam's type of each ('Double pipe' below 150 ft², None without shells or with an infinite or nan area); 'furnace_duty', the utility duty of the fuel [kJ/hr]; 'purchase_cost' and 'installed_cost' (bare module) [USD]. inf with an infinite area, nan with an unserved utility. """ if utilities is None: utilities = self.utility_targets() return self._capital(utilities, self._film_coefficients(h), _utility_films(utilities, h_utilities, _key_agents(agents)), P, heat_exchanger_type, material)[0]
def _check_options(self, options): """The options of `summary` (agents as lists of agents, the operating hours of `system` filled in if it has them and neither `operating_hours` nor `tea` is given), after raising for any that `summary` would reject, before pricing anything.""" unknown = [i for i in options if i not in _SUMMARY_OPTIONS] if unknown: raise TypeError(f"unexpected options: {', '.join(map(repr, unknown))} " f'(the options are {", ".join(_SUMMARY_OPTIONS)})') options = dict(options) for key in ('heating_agents', 'cooling_agents'): if options.get(key) is not None: options[key] = _agents(options[key], key == 'heating_agents') if options.get('operating_hours') is None and options.get('tea') is None: hours = getattr(self.system, 'operating_hours', None) if hours is not None: options['operating_hours'] = hours get = options.get _economics(get('interest'), get('years'), get('operating_hours'), get('tea')) _check_exchanger(get('heat_exchanger_type', 'Floating head'), get('material', 'Carbon steel/carbon steel')) _pressure(get('P', 101325.)) self._film_coefficients(get('h')) _utility_films((), get('h_utilities'), [*(get('heating_agents') or ()), *(get('cooling_agents') or ())], _OPTION_AGENTS) return options
[docs] def summary(self, **options): """ The targets of energy, area, units, capital and total annualized cost (TAC), pricing the utilities once. Parameters ---------- **options `heating_agents` and `cooling_agents` (as `utility_targets` takes them); `h` and `h_utilities` (as `area_target` takes them); `P`, `heat_exchanger_type` and `material` (as `capital_cost_target` takes them); `interest` (default 0.10), `years` (default 10) and `operating_hours` (per year, default 8000), each taken from `tea` when not given and `tea` is (its `IRR`, ``duration[1] - duration[0]`` and `operating_hours` unless None). Without `operating_hours` and `tea`, the operating hours are those of `system` if it has them, as for targets of `HeatExchangerNetwork.targets` in a system with operating hours (a TEA's, for instance), so that they match `network_report`. Returns ------- dict 'T_min_app' [K]; 'Q_hot' and 'Q_cold' [kJ/hr], process side; 'utility_cost' [USD/hr]; 'area' [m²]; 'N_units', the units target; 'N_shells', 'N_area_shells', 'shell_area' [m²] and 'shell_type', the 'type' of `capital_cost_target`, as it gives them; 'purchase_cost' and 'installed_cost' [USD]; 'annualized_capital_cost', the installed cost times the capital recovery factor, 'operating_cost', the utility cost times the operating hours, and their sum 'TAC' [USD/yr]. """ options = self._check_options(options) get = options.get heating_agents = get('heating_agents') cooling_agents = get('cooling_agents') crf, hours = _economics(get('interest'), get('years'), get('operating_hours'), get('tea')) utilities = self.utility_targets(heating_agents, cooling_agents) h_utilities = _utility_films( utilities, get('h_utilities'), [*(heating_agents or ()), *(cooling_agents or ())], _OPTION_AGENTS) capital, N = self._capital( utilities, self._film_coefficients(get('h')), h_utilities, get('P', 101325.), get('heat_exchanger_type', 'Floating head'), get('material', 'Carbon steel/carbon steel')) utility_cost = sum((u.cost for u in utilities), 0.) annualized = crf * capital['installed_cost'] operating = hours * utility_cost return {'T_min_app': self.T_min_app, 'Q_hot': self.Q_hot, 'Q_cold': self.Q_cold, 'utility_cost': utility_cost, 'area': capital['area'], 'N_units': N, 'N_shells': capital['N_shells'], 'N_area_shells': capital['N_area_shells'], 'shell_area': capital['shell_area'], 'shell_type': capital['type'], 'purchase_cost': capital['purchase_cost'], 'installed_cost': capital['installed_cost'], 'annualized_capital_cost': annualized, 'operating_cost': operating, 'TAC': annualized + operating}
def _least_TAC(records): """Index of the record of least TAC (nan counts as inf).""" TAC = np.array([r['TAC'] for r in records], dtype=float) return int(np.argmin(np.where(np.isnan(TAC), math.inf, TAC))) def _constant_TAC(records, where_finite=False): """Whether the records are at least two, each with a finite TAC (or, if `where_finite`, have at least two finite TACs, the others left out), and their TACs the same to within round-off (a relative 1e-12).""" TAC = np.array([r['TAC'] for r in records], dtype=float) finite = np.isfinite(TAC) if where_finite: TAC = TAC[finite] elif not finite.all(): return False return (TAC.size > 1 and float(np.ptp(TAC)) <= 1e-12 * float(np.abs(TAC).max()))
[docs] def supertarget(targets, T_min_apps=None, refine=3, n_refine=8, scale_contributions=False, **options): """ Sweep the minimum approach temperature for the least total annualized cost (TAC) target: supertargeting [Ahmad90]_. Parameters ---------- targets : PinchTargets The streams (their curves are reused: no flash). T_min_apps : Sequence[float], optional Minimum approach temperatures to evaluate [K], finite and >= 0 (> 0 with approach contributions); sorted and made unique. Defaults to 1, 2, ..., 40 K. refine : int, optional Rounds of refinement around the least TAC. Defaults to 3. n_refine : int, optional New temperatures evaluated per round of refinement. Defaults to 8. scale_contributions : bool, optional Scale the specified approach contributions of `targets` with the minimum approach temperature (see `PinchTargets.at`). Defaults to False. **options Options of `PinchTargets.summary`. Returns ------- Supertargets Warns ----- RuntimeWarning Once for the sweep, if some minimum approach temperatures leave part of a utility target unserved (no agent given can serve it: their TAC is nan), instead of the warning of `PinchTargets.utility_targets` at each. RuntimeWarning If no minimum approach temperature of the sweep has a finite TAC (each has an unserved utility or an infinite area): the sweep is then not refined, and its optimum is the smallest one, meaningless. Notes ----- Each minimum approach temperature is evaluated with ``targets.at(T_min_app, scale_contributions).summary(**options)``. Only the streams without an approach contribution follow the minimum approach temperature (``T_min_app / 2`` each), unless `scale_contributions` scales the specified ones by ``T_min_app / targets.T_min_app`` too, the contribution scaling of [Ahmad90]_; with every stream specified, the sweep needs it. The streams' curves are reused, but each point builds its own problem table (about that of a synthesis, which reads every stream's enthalpy on its grid), so on a system of many streams with rigorous thermodynamics the default sweep (40 temperatures and three refinements) can take longer than a synthesis: a coarser `T_min_apps` or a smaller `refine` keeps it short. The TAC is not smooth (the units target jumps, so do biosteam's correlations at 150 ft², and the pinch can move), so the sweep is a grid search: each round of refinement evaluates `n_refine` new temperatures between the neighbors of the least TAC so far, never on it: with it, they cut that bracket into ``n_refine + 2`` gaps, shared by its two sides in proportion to their widths, each side evenly spaced (all on its one side at a bound of the sweep). On an evenly spaced grid, after r rounds the spacing near the optimum is then about the grid's times ``(2 / (n_refine + 2))**r``, (1/5)**r for the defaults. A least TAC at the smallest or largest temperature evaluated is flagged (`Supertargets.at_bound`). A sweep whose TACs are all finite and the same (to a relative 1e-12) is not refined: its targets do not depend on the minimum approach temperature over the sweep (for streams that recover no heat, only heaters, say), its optimum is merely the smallest temperature, and `Supertargets.constant` flags it. Nor is one whose finite TACs (at least two) are the same, the others not (an unserved utility or an infinite area): its optimum is merely the smallest temperature of that flat stretch, so its repr says 'TAC constant where finite' rather than 'at the bound'. The capital target drops by about a shell and tube wherever the shrinking area fills one fewer of 150 ft² (see Notes of `PinchTargets`), so the least TAC often lies just past such a step, at the first temperature evaluated beyond it, rather than at a smooth trade-off of energy and capital. The records' 'N_area_shells' and 'shell_type' show the steps (`Supertargets.to_frame`). A network has such steps too, but where its own exchangers cross 150 ft², which the target, with its equal shells, places only roughly. Where the targets do not depend on the minimum approach temperature (below the threshold of a threshold problem, where neither the utilities nor the balanced composite curves change), the TACs differ only by the discretization of the streams' curves on the problem table's grid, by a tiny relative amount, and where the optimum lies in that flat stretch means nothing physically. Where the discretization trend is steady, it lies at an end of the stretch (for the quickstart system of the tutorial, the warmest point evaluated, just short of the threshold): compare the TACs of its neighbors before reading anything into its position. Unless `operating_hours` or `tea` is given, the operating hours are those of ``targets.system`` if it has them (see `PinchTargets.summary`). Examples -------- One hot stream cooled 400 -> 300 K and one cold stream heated 290 -> 390 K, both 1 kW/K. Up to 10 K they balance with no utility in a single exchanger of 20 m² (215 ft², a shell and tube); above it the utilities add two units, but the area still fills one shell and tube until it falls below 150 ft² (13.94 m²) just below 15 K, where three double pipes take it. So on a coarse grid the least total annualized cost (TAC) is at 15 K: >>> import biosteam as bst, thermosteam as tmo >>> from hensmith import PinchTargets, supertarget >>> fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., ... Cn=3.6, default=True) >>> bst.settings.set_thermo([fluid]) >>> bst.main_flowsheet.set_flowsheet('supertargeting_example') >>> H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1000., T=400.), ... T=300., rigorous=False) >>> C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1000., T=290.), ... T=390., rigorous=False) >>> H1.simulate(); C1.simulate() >>> targets = PinchTargets.from_heat_utilities( ... [H1.heat_utilities[0], C1.heat_utilities[0]], T_min_app=10.) >>> grid = [5., 10., 15., 20., 25., 30.] >>> sweep = supertarget(targets, grid, refine=0) >>> sweep <Supertargets: 6 points, T_min_app 5-30 K, optimum 15 K (TAC 4.949e+03 USD/yr)> >>> sweep['N_units'], sweep['area'].round(2) (array([1, 1, 3, 3, 4, 4]), array([20. , 20. , 13.88, 11.07, 9.73, 9.17])) >>> sweep['N_area_shells'], [r['shell_type'] for r in sweep.records[1:3]] (array([1, 1, 3, 3, 4, 4]), ['Floating head', 'Double pipe']) Refining around it finds the least TAC at the first temperature evaluated past that step, where the TAC falls to under half that of its neighbor below: the step decides the optimum, not a trade-off of energy and capital. >>> sweep = supertarget(targets, grid) >>> sweep <Supertargets: 30 points, T_min_app 5-30 K, optimum 14.96 K (TAC 4.936e+03 USD/yr)> >>> i = sweep.records.index(sweep.optimum) >>> [(r['shell_type'], round(r['area'], 2), round(r['TAC'])) ... for r in sweep.records[i - 1:i + 1]] [('Floating head', 13.95, 12573), ('Double pipe', 13.91, 4936)] References ---------- .. [Ahmad90] Ahmad, S., Linnhoff, B., & Smith, R. (1990). Cost optimum heat exchanger networks - 2. Targets and design for detailed capital cost models. Computers & Chemical Engineering, 14(7), 751-767. """ if T_min_apps is None: T_min_apps = _T_MIN_APPS try: values = list(T_min_apps) except TypeError: values = [] if not values or any(np.ndim(x) for x in values): raise ValueError('T_min_apps must be a non-empty sequence of ' 'temperatures [K]') # the rule of PinchTargets.at: finite numbers, not bools or strings T = np.array([_real(x, 'each of T_min_apps') for x in values]) contributions = targets.approach_contributions if contributions is None: if np.any(T < 0.): raise ValueError(f'T_min_apps must be >= 0 K, not {T_min_apps!r}') elif np.any(T <= 0.): raise ValueError('T_min_apps must be > 0 K with approach ' f'contributions, not {T_min_apps!r}') for value, name, low in ((refine, 'refine', 0), (n_refine, 'n_refine', 1)): if (not isinstance(value, Integral) or isinstance(value, (bool, np.bool_)) or value < low): raise ValueError(f'{name} must be an int >= {low}, not {value!r}') if (contributions is not None and not scale_contributions and all(c is not None for c in contributions)): raise ValueError('every stream has an approach contribution, so ' 'T_min_app changes nothing: pass ' 'scale_contributions=True to scale them with it') options = targets._check_options(options) def evaluate(T_min_app): with catch_warnings(): # counted once for the sweep instead filterwarnings('ignore', r'no (heating|cooling) agent can serve', RuntimeWarning) return targets.at(T_min_app, scale_contributions).summary(**options) records = [evaluate(float(x)) for x in np.unique(T)] for _ in range(refine): if not any(math.isfinite(r['TAC']) for r in records): break # nothing to refine around if _constant_TAC(records, where_finite=True): break # a flat line (where finite): nothing to refine either records.sort(key=lambda r: r['T_min_app']) i = _least_TAC(records) lo = records[max(i - 1, 0)]['T_min_app'] hi = records[min(i + 1, len(records) - 1)]['T_min_app'] if hi - lo <= 1e-6: break # the new temperatures go on both sides of the least TAC, never on # it (a bracket's midpoint can be it): with it they cut the bracket # into n_refine + 2 gaps, shared by its sides in proportion to their # widths (all on its one side at a bound of the sweep), so each side # is spaced evenly and the two about alike x = records[i]['T_min_app'] k = math.floor((n_refine + 2) * (x - lo) / (hi - lo) + 0.5) - 1 k = min(max(k, 0), n_refine) # how many go below it candidates = np.concatenate(( np.linspace(lo, x, k + 2)[1:-1], np.linspace(x, hi, n_refine - k + 2)[1:-1])) evaluated = np.array([r['T_min_app'] for r in records]) for T_new in candidates: if np.abs(evaluated - T_new).min() > 1e-12: records.append(evaluate(float(T_new))) sweep = Supertargets(targets, records, options, scale_contributions) unserved = int(np.isnan(sweep['utility_cost']).sum()) if unserved: _warn(f'{unserved} of the {len(records)} minimum approach ' 'temperatures evaluated leave part of a utility target ' 'unserved (no agent given can serve it), so their TAC is nan') if not np.isfinite(sweep['TAC']).any(): _warn('no minimum approach temperature of the sweep has a finite TAC ' '(each has an unserved utility or an infinite area), so it is ' 'not refined and its optimum, the smallest, is meaningless') return sweep
[docs] class Supertargets: """ The targets of a sweep of the minimum approach temperature, as `supertarget` returns them. Parameters ---------- targets : PinchTargets The streams swept. records : list[dict] `PinchTargets.summary` at each minimum approach temperature. options : dict, optional Options of `PinchTargets.summary` of the records. scale_contributions : bool, optional Whether the approach contributions were scaled (see `PinchTargets.at`). Defaults to False. Attributes ---------- targets : PinchTargets The streams swept. options : dict Options of `PinchTargets.summary` of the records. scale_contributions : bool Whether the approach contributions were scaled. records : list[dict] `PinchTargets.summary` at each minimum approach temperature, ascending. optimum : dict The record of least TAC (the first of equal ones; nan counts as inf). T_min_app : float Minimum approach temperature of `optimum` [K]. at_bound : bool Whether `optimum` is at the smallest or largest temperature evaluated: the least TAC may lie beyond the sweep. constant : bool Whether the TAC is the same at every temperature evaluated (at least two, each finite, to a relative 1e-12): the targets do not depend on the minimum approach temperature over the sweep, and `optimum` is merely the first record. Notes ----- ``sweep[key]`` is the array of a key of the records over the sweep (for instance ``sweep['TAC']``), and `to_frame` a pandas DataFrame of them indexed by 'T_min_app'. """ def __init__(self, targets, records, options=None, scale_contributions=False): if not records: raise ValueError('Supertargets needs at least one record') self.targets = targets self.options = dict(options or {}) self.scale_contributions = scale_contributions self.records = sorted(records, key=lambda r: r['T_min_app']) i = _least_TAC(self.records) self.optimum = self.records[i] self.T_min_app = self.optimum['T_min_app'] self.at_bound = i in (0, len(self.records) - 1) self.constant = _constant_TAC(self.records) def __getitem__(self, key): return np.array([r[key] for r in self.records])
[docs] def to_frame(self): """The records as a pandas DataFrame indexed by 'T_min_app'.""" import pandas as pd return pd.DataFrame(self.records).set_index('T_min_app')
def __repr__(self): n = len(self.records) lo, hi = self.records[0]['T_min_app'], self.records[-1]['T_min_app'] if self.constant: bound = ', TAC constant over the sweep' elif _constant_TAC(self.records, where_finite=True): bound = ', TAC constant where finite' elif self.at_bound: bound = ', at the bound' else: bound = '' return (f'<{type(self).__name__}: {n} point{"s" if n != 1 else ""}, ' f'T_min_app {lo:g}-{hi:g} K, optimum {self.T_min_app:.4g} K ' f'(TAC {self.optimum["TAC"]:.3e} USD/yr){bound}>')
def _profile_area(x, dT, inv_U, ID, cause=''): """ Area [m²] of counter-current heat transfer over the duties `x` [kJ/hr] (ascending) at the driving forces `dT` [K] there, linear in between (each stretch at its log-mean temperature difference), with an overall 1/U of `inv_U` [m²·K/kW]; inf with a RuntimeWarning naming the exchanger `ID` (followed by `cause`) at a driving force of `_DT_ZERO` or less. """ if dT.min() <= _DT_ZERO: _warn(f'{ID} transfers heat at a driving force of {dT.min():.6g} K' f'{cause}') return math.inf LMTD = _lmtd(dT[:-1], dT[1:]) return float(np.sum(np.diff(x) / LMTD)) * inv_U / 3600. def _stage_profile(curve, H0, sign, f, Q): """ ``(knots, T)`` of a stream of `curve` through an exchanger whose duty x runs from 0 to `Q` [kJ/hr] while the stream's enthalpy on the stage (fraction `f` of the whole flow) is ``H0 + sign * x``: the duties in (0, Q) at the breakpoints of the curve, and its temperature [K] at duties. """ knots = sign * (f * curve.H - H0) return (knots[(knots > 0.) & (knots < Q)], lambda x: np.interp((H0 + sign * x) / f, curve.H, curve.T)) def _flows(stream): """Molar flow rate of each chemical of `stream` [kmol/hr], an array.""" mol = stream.mol return mol.to_array() if hasattr(mol, 'to_array') else np.asarray(mol) def _same_ends(curve, s_in, s_out): """Whether the streams `s_in` and `s_out` are at the end states of `curve` (on the curve's thermodynamic basis): the same enthalpies, at the outlet to the larger of 1e-6 of the duty and the heat of a micro-kelvin of the outlet stream (a flash reaches the outlet only to its tolerance, which is more than 1e-6 of the duty of a stream that changes temperature by micro-kelvins); and the same pressure and flows at the inlet (an inlet at the enthalpy reference has the same enthalpy at any flow).""" span = abs(curve.H_out - curve.H_in) a, b = _flows(curve.stream_in), _flows(s_in) dH_out = abs(curve.H_out - s_out.H) return (abs(curve.H_in - s_in.H) <= 1e-9 * (abs(curve.H_in) + span) and (dH_out <= 1e-6 * span or dH_out <= 1e-6 * abs(s_out.C)) and abs(curve.P - s_in.P) <= 1e-9 * curve.P and a.shape == b.shape and np.abs(a - b).max(initial=0.) <= 1e-9 * np.abs(a).max(initial=0.)) def _check_targets(HXN, targets): """Raise a ValueError unless `targets` are those of the process streams of `HXN`'s network, in its stream order and current states.""" hxs = HXN.original_heat_exchangers units = targets.units if (units is None or len(units) != len(hxs) or any(u is not hx for u, hx in zip(units, hxs))): raise ValueError("targets must be of the network's process streams " "(the units of HXN.original_heat_exchangers, in that " 'order), as HXN.targets() makes them') ideal = targets.force_ideal_thermo if ideal != bool(HXN.force_ideal_thermo): raise ValueError(f'targets have force_ideal_thermo={ideal}, the ' f'network {bool(HXN.force_ideal_thermo)}') for c, hx in zip(targets.curves, hxs): s_in, s_out = hx.ins[0], hx.outs[0] if ideal: # the curves' basis: copies with ideal thermodynamics s_in = _copy(s_in, s_in.thermo.ideal()) s_out = _copy(s_out, s_out.thermo.ideal()) if not _same_ends(c, s_in, s_out): raise ValueError(f'targets are not of the current states of ' f'{_unit_label(hx, targets._holders)} ' '(simulated since?): make ' 'them again with HXN.targets()') def _warn(message): """Warn `message` (a RuntimeWarning) at the line that called hensmith, the first frame outside the package, not at a line of the package.""" warn(message, RuntimeWarning, skip_file_prefixes=_PACKAGE_FRAMES) def _stale_labels(hxs, labels): """The labels (of `labels`, aligned with `hxs`) of the exchangers `hxs` whose first inlet and outlet differ in flow (``ins[0].mol`` and ``outs[0].mol``) by more than `_STALE_FLOW` of the larger: an inlet changed since its unit was simulated, whose stream the targets would take from that new inlet to the outlet of the former state.""" stale = [] for hx, label in zip(hxs, labels): a, b = hx.ins[0].mol, hx.outs[0].mol if a.size == b.size and (np.abs(a - b).sum() > _STALE_FLOW * max(a.sum(), b.sum())): stale.append(label) return stale def _warn_stale(labels, of='', reselect=False): """Warn (see `_warn`) of the streams of `labels` (see `_stale_labels`), if any, in the targets `of` names (e.g. ``' of HXN'``); with `reselect` (targets of a selection of heat utilities, which a simulation of their units leaves behind), saying to select them again after simulating.""" if not labels: return names = ', '.join(map(repr, labels)) advice = 'simulate the system (or its units) first' if reselect: advice += ', then select the heat utilities again' if len(labels) == 1: _warn(f'the inlet and outlet of {names} differ in flow (its inlet ' 'changed since its unit was simulated?): the targets' f'{of} take its stream from its inlet now to its former ' f'outlet, a state of no process; {advice}') else: _warn(f'the inlets and outlets of {names} differ in flow (their ' 'inlets changed since their units were simulated?): the ' f'targets{of} take their streams from their inlets now to ' f'their former outlets, states of no process; {advice}') def _rebuilt_label(hxs, labels, units): """The label (of `labels`, aligned with `hxs`) of the first of the exchangers `hxs` that is not one of `units` (a facility's units, their nested auxiliaries and the exchangers they hold through their heat utilities only, see `hensmith.hxn_synthesis._with_auxiliaries`: a unit has rebuilt it since), or None.""" at_hand = {id(unit) for unit in units} for hx, label in zip(hxs, labels): if id(hx) not in at_hand: return label return None def _miscounted(hxs, labels, units, at_hand): """ ``[(label, n, listed), ...]``, in order of first occurrence in `hxs` (`labels` aligned with it): the exchangers of `hxs` that are among `at_hand` (not rebuilt since, see `_rebuilt_label`) and give `n` of its streams, one per time their heat utility was listed at the synthesis, while the `heat_utilities` of `units` (a facility's units, from which its synthesis takes the heat utilities) list a heat exchanger's heat utility of theirs `listed` times now, with or without duty. biosteam lists the heat utilities of a unit in parallel once per unit (``Unit.parallel``, which a `biosteam.AbstractStirredTankReactor` sets by its volume), so their number changes with the unit's size when the unit is simulated alone; the streams stay one per unit at the facility's last simulation. """ given = {} for hx, label in zip(hxs, labels): given.setdefault(id(hx), [label, 0])[1] += 1 listed = {} for unit in units: for hu in unit.heat_utilities: if hu.hxn_ok: listed[id(hu.unit)] = listed.get(id(hu.unit), 0) + 1 at_hand = {id(unit) for unit in at_hand} return [(label, n, listed.get(key, 0)) for key, (label, n) in given.items() if key in at_hand and listed.get(key, 0) != n] def _miscounted_names(miscounted): """The streams of `miscounted` (see `_miscounted`) for a message, e.g. ``"'R1 - heat_exchanger' 5 times, listed 2 times now"``.""" times = lambda n: f'{n} time{"" if n == 1 else "s"}' return '; '.join(f'{label!r} {times(n)}, listed {times(m)} now' for label, n, m in miscounted) def _check_network(HXN, targets): """Raise a ValueError unless every stream of `HXN`'s network is that of an original exchanger still among the facility's units, their nested auxiliaries and the exchangers they hold through their heat utilities only (see `_rebuilt_label`; not one that its unit has rebuilt since, as a `biosteam.MultistageCompressor` simulated alone after a change of `pr` rebuilds its intercoolers, whose former objects keep their former, consistent states), every such exchanger gives as many streams as the facility's units list its heat utility now (see `_miscounted`: not a unit in parallel whose number has changed since), and every stream enters and leaves the network at the end states of its curve in `targets` (which are of the units' current states): its copy of the real inlet where it enters the network, and the outlet of its last stage (the network's copies share the curves' thermodynamic basis).""" hxs = HXN.original_heat_exchangers units = list(HXN._get_units()) at_hand = HXN._candidate_units(units) label = _rebuilt_label(hxs, targets.labels, at_hand) if label is not None: raise ValueError(f'the network of {HXN.ID} is of {label}, 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') miscounted = _miscounted(hxs, targets.labels, units, at_hand) if miscounted: raise ValueError(f'the network of {HXN.ID} has a stream per heat ' 'utility its 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 life_cycle, c, label in zip(HXN.stream_life_cycles, targets.curves, targets.labels): entry = life_cycle.entry if entry is None: s_in = life_cycle.life_cycle[0].s_in else: s_in = entry.unit.ins[entry.index] if not _same_ends(c, s_in, life_cycle.life_cycle[-1].s_out): raise ValueError(f'the network of {HXN.ID} is not of the current ' f'states of {label} (changed since the ' "facility's last simulation?): simulate the " 'system') def _network_areas(HXN, targets, h, h_utilities, agents): """ ``{ID: (area, area_biosteam)}`` [m²] of the process exchangers and then the utility exchangers of `HXN` with duty (see `network_report`), with the film coefficients `h` (an (N,) array) and `h_utilities` (as `PinchTargets.area_target` takes them; `agents`, more agents its keys may name). A furnace takes no area. """ curves = targets.curves is_hot = targets.is_hot stages = {} # unit -> [(stream index, life stage)] for lc in HXN.stream_life_cycles: for st in lc.life_cycle: stages.setdefault(st.unit, []).append((lc.index, st)) process = [hx for hx in HXN.new_HXs if hx.total_heat_transfer > 1e-12] utility = [hx for hx in HXN.new_HX_utils if hx.heat_utilities and hx.total_heat_transfer > 1e-12] of = [hx.heat_utilities[0].agent for hx in utility] areas = {} for hx in process: (i, si), (j, sj) = stages[hx] if not is_hot[i]: (i, si), (j, sj) = (j, sj), (i, si) # x from the hot inlet (the cold outlet) end Q = si.H_in - si.H_out knots_h, T_h = _stage_profile(curves[i], si.H_in, -1., si.fraction, Q) knots_c, T_c = _stage_profile(curves[j], sj.H_out, -1., sj.fraction, Q) x = np.unique(np.concatenate([[0., Q], knots_h, knots_c])) areas[hx.ID] = ( _profile_area(x, T_h(x) - T_c(x), 1. / h[i] + 1. / h[j], hx.ID), hx.design_results.get('Area', 0.) / _FT2_PER_M2) for hx, agent, h_agent in zip(utility, of, _agent_films(of, h_utilities, agents, _OPTION_AGENTS)): if agent.isfuel: areas[hx.ID] = (0., 0.) continue (i, si), = stages[hx] hu = hx.heat_utilities[0] # x from the process outlet (the utility inlet) end H_in, H_out = si.H_in, si.H_out heating = H_out > H_in Q = abs(H_out - H_in) knots, T_p = _stage_profile(curves[i], H_out, -1. if heating else 1., si.fraction, Q) x = np.unique(np.concatenate([[0., Q], knots])) T_a, T_b = hu.inlet_utility_stream.T, hu.outlet_utility_stream.T T_u = T_a + (T_b - T_a) * x / Q dT = T_u - T_p(x) if heating else T_p(x) - T_u span = (f'at {T_a:.2f} K' if T_a == T_b else f'from {T_a:.2f} to {T_b:.2f} K') cause = (f': its {agent.ID} {span}, as biosteam specifies it, meets ' f'or crosses the profile of {targets.labels[i]}') areas[hx.ID] = ( _profile_area(x, dT, 1. / h[i] + 1. / h_agent, hx.ID, cause), hx.design_results.get('Area', 0.) / _FT2_PER_M2) return areas
[docs] def network_report(HXN, targets=None, **options): """ Compare the heat exchanger network that a `HeatExchangerNetwork` facility synthesized with the targets of its process streams: energy, utilities, number of units, area, capital cost and total annualized cost (TAC) of both, side by side. Parameters ---------- HXN : HeatExchangerNetwork A simulated facility, whose network is of the units' current states (simulate the system again after changing a unit or a stream). targets : PinchTargets, optional Targets of the facility's process streams, as `HeatExchangerNetwork.targets` makes them (the same units in the same order, the same thermodynamic basis and the units' current states). Defaults to ``HXN.targets()``. **options Options of `PinchTargets.summary`. The film coefficients `h` and `h_utilities` and the economics `interest`, `years`, `operating_hours` and `tea` apply to the network and the targets alike (the operating hours default to the system's, if it has them and neither `operating_hours` nor `tea` is given); the agents, `P`, `heat_exchanger_type` and `material` to the targets only (the network keeps biosteam's agents and costs). Returns ------- dict 'T_min_app' and 'targets_T_min_app' [K], of the facility (its attribute as it is now: a reused cached network keeps the one it was synthesized at) and of the targets; 'status' (``HXN.synthesis_info['status']``); 'applied', whether the network has a process exchanger; 'Q_hot' and 'Q_cold', the heat of the network's heating and cooling utilities [kJ/hr], process side (`HXN.actual_heat_util_load` is utility side); 'utilities', ``{agent ID: {'duty', 'utility_duty', 'cost'}}`` [kJ/hr, kJ/hr, USD/hr] (duty process side, a magnitude); 'utility_cost' [USD/hr]; 'N_units', the exchangers with duty (a furnace included), of which 'N_process_units' and 'N_utility_units'; 'area', the network's area like for like with the area target [m²], inf if an exchanger's is (see Notes); 'area_biosteam', the area biosteam sized on terminal temperature differences [m²]; 'exchanger_areas', ``{ID: (area, area_biosteam)}`` of each exchanger with duty [m²]; 'purchase_cost' and 'installed_cost' of all the network's exchangers [USD]; 'annualized_capital_cost', 'operating_cost' and 'TAC' [USD/yr], as `PinchTargets.summary` computes them; 'targets', `PinchTargets.summary` of the targets. Raises ------ RuntimeError If the facility was not simulated. ValueError If `targets` are not those of the facility's process streams in their current states (enthalpies at both ends, and pressure and flows at the inlet), if the network's streams do not enter and leave it at those states, are of exchangers no longer among the facility's units and their auxiliaries or are not as many as the units list their heat utilities now (a unit or a stream changed since the facility's last simulation: a `biosteam.MultistageCompressor` simulated alone after a change of `pr`, say, rebuilds its intercoolers, and a unit in parallel simulated alone may change its number of units, ``Unit.parallel``, whose heat utilities biosteam lists once per unit), or for an invalid option. TypeError For an unknown option. Notes ----- The network's 'area' is sized as the area target is: each exchanger with the film coefficients of its two sides (1 kW/m²/K by default), counter-current (a correction factor of 1), and integrated against the process duty along the temperature profiles of its streams as their curves in the targets model them, like for like with the area target: a point-load stream at its outlet temperature and a non-equilibrium end state (such as a vapour entering below its dew point) as its jump, where the stream itself may glide a little in the exchanger. A utility's temperature is linear in the duty between its inlet and outlet, and a furnace takes no area (as fuel in the targets). 'area_biosteam' is biosteam's own sizing: heuristic overall coefficients (0.5 kW/m²/K, but 1 for condensing against boiling, which no film coefficients reproduce), a correction factor for two shells, the log-mean of the terminal temperature differences (which overstates the mean difference along a glide) and the utility duty (the process duty divided by the heat transfer efficiency). An exchanger's 'area' is inf, and so the network's 'area', with a RuntimeWarning naming the exchanger, where its two temperature profiles meet or cross. biosteam specifies a utility from the temperatures at the ends of its exchanger only: it takes a sensible agent to its limit whenever the process inlet allows it (by ``bst.HeatUtility.dT``), as cooling water to 324.82 K and chilled water to 300.37 K, so against a stream that condenses a little below that limit (a vacuum condenser of steam condensing at 320 K, say) the utility's line crosses the condensing stretch inside the exchanger: as specified, the exchanger cannot be built. Its 'area_biosteam' stays finite, since biosteam sizes on the terminal temperature differences, and so do the costs and the TAC, which are biosteam's. The targets keep a sensible agent's line inside the grand composite curve, so the area target of such streams is finite. The report keeps biosteam's specification rather than size the exchanger for more of the utility, leaving where its line stays inside the stream's profile: that would be another network at another utility cost. 'exchanger_areas' gives the areas of the other exchangers. The capital and utility costs are those of the whole network (`HXN.new_HXs` and `HXN.new_HX_utils`), not the facility's costs, which are the differences from the original exchangers (and zero for a network without process exchangers: the facility then keeps the original exchangers, to which the utility exchangers of the network, reported here, are equivalent). Only the energy targets ('Q_hot' and 'Q_cold') bound the network; the others are estimates, and the network may beat some of them: - the network serves each stream with one agent at its outlet (biosteam's rule: the first agent of the list that can), while the targets place the agents on the grand composite curve in list order (fuel last), so the network may use other agents than the targets, costlier or cheaper ones: with Linnhoff & Hindmarsh's problem at 10 K, medium pressure steam and chilled water where the targets need only low pressure steam and cooling water, but a stream heated above the hottest steam takes fuel over its whole utility stretch, and with biosteam's default prices natural gas costs less per kJ than the steam the targets place below the fuel (a stream of 1 kW/K heated from 330 to 520 K against one cooled from 400 to 300 K, at 10 K: 3.14 USD/hr at MER against a target of 4.56 USD/hr). The utility cost target is the cost of biosteam's placement of its agents, not a minimum: neither it nor the network's utility cost bounds the other; - the area target is a minimum only for equal film coefficients and the same utilities: that problem's network takes 54.06 m² against a target of 59.68 m², with hotter steam and colder coolant; - the units target counts S - 1 per region between zeros of the balanced grand composite curve, as if each region's matches formed no loop: a network needs fewer units where a subset of streams balances (6 against 7 in that problem) or where one agent serves a stream the targets split between levels, and one more for each loop of its matches, such as a repeated match between two streams (two branches of a split stream meeting the same stream make one too) or two streams linked both through process matches and through a shared utility. Examples -------- The liquids of the second example of `HeatExchangerNetwork` at 10 K, with stream splitting, so that the network reaches the energy targets: >>> import biosteam as bst >>> from hensmith import network_report >>> bst.main_flowsheet.set_flowsheet('network_report_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., ... stream_splitting=True) >>> sys = bst.System.from_units('sys', units=[*units, HXN]) >>> sys.simulate() >>> report = network_report(HXN) >>> targets = report['targets'] >>> report['status'], report['applied'] ('mer', True) The network reaches the hot utility target [kW], but serves each stream with one agent at its outlet: medium pressure steam and chilled water, where the targets need mostly low pressure steam and cooling water [USD/hr]: >>> round(report['Q_hot'] / 3600, 3), round(targets['Q_hot'] / 3600, 3) (102.821, 102.821) >>> sorted(report['utilities']) ['chilled_water', 'cooling_water', 'medium_pressure_steam'] >>> round(report['utility_cost'], 3), round(targets['utility_cost'], 3) (5.496, 3.58) With hotter steam and colder coolant, its area [m²] is below the area target (biosteam's own sizing in between), and it needs fewer units than the estimate: >>> round(report['area'], 2), round(report['area_biosteam'], 2), round(targets['area'], 2) (50.62, 66.33, 70.3) >>> report['N_units'], targets['N_units'] (7, 9) Its total annualized cost [USD/yr] is below the target's, which puts the area target in five shell and tubes, where the network has one: >>> round(report['TAC']), round(targets['TAC']) (62041, 82120) >>> targets['N_area_shells'], targets['shell_type'] (5, 'Floating head') """ if not hasattr(HXN, 'new_HXs_hot_side'): raise RuntimeError('simulate the heat exchanger network before ' 'reporting on it') # (without the warnings of stages rebuilt since and of inlets changed # in flow since: _check_network raises) if targets is None: targets = HXN._pinch_targets()[0] else: _check_targets(HXN, targets) _check_network(HXN, targets) options = dict(options) if options.get('operating_hours') is None and options.get('tea') is None: hours = getattr(getattr(HXN, 'system', None), 'operating_hours', None) if hours is not None: options['operating_hours'] = hours options = targets._check_options(options) get = options.get crf, hours = _economics(get('interest'), get('years'), get('operating_hours'), get('tea')) areas = _network_areas( HXN, targets, targets._film_coefficients(get('h')), get('h_utilities'), [*(get('heating_agents') or ()), *(get('cooling_agents') or ())]) process = [hx for hx in HXN.new_HXs if hx.ID in areas] utility = [hx for hx in HXN.new_HX_utils if hx.ID in areas] hus = [hu for hx in utility for hu in hx.heat_utilities if hu.agent] utilities = {} for ID, group in bst.HeatUtility.heat_utilities_by_agent(hus).items(): utilities[ID] = {'duty': abs(sum([hu.unit_duty for hu in group])), 'utility_duty': abs(sum([hu.duty for hu in group])), 'cost': sum([hu.cost for hu in group])} utility_cost = sum([hu.cost for hu in hus], 0.) exchangers = HXN.new_HXs + HXN.new_HX_utils installed = sum([hx.installed_cost for hx in exchangers], 0.) annualized = crf * installed operating = hours * utility_cost return { 'T_min_app': HXN.T_min_app, 'targets_T_min_app': targets.T_min_app, 'status': HXN.synthesis_info['status'], 'applied': bool(HXN.new_HXs), 'Q_hot': sum([hu.unit_duty for hu in hus if hu.unit_duty > 0.], 0.), 'Q_cold': -sum([hu.unit_duty for hu in hus if hu.unit_duty < 0.], 0.), 'utilities': utilities, 'utility_cost': utility_cost, 'N_units': len(process) + len(utility), 'N_process_units': len(process), 'N_utility_units': len(utility), 'area': sum([a for a, _ in areas.values()], 0.), 'area_biosteam': sum([a for _, a in areas.values()], 0.), 'exchanger_areas': areas, 'purchase_cost': sum([hx.purchase_cost for hx in exchangers], 0.), 'installed_cost': installed, 'annualized_capital_cost': annualized, 'operating_cost': operating, 'TAC': annualized + operating, 'targets': targets.summary(**options), }
def _format_duty(H): """A duty `H` [kJ/hr] for a plot label: '0', or as `_format_H` (three significant digits, e.g. '1.18E5').""" return '0' if H == 0. else _format_H(H) def _legend(label, H): """`label` for the legend if a curve of heats `H` has points, else none (a label starting with '_' stays out of the legend).""" return label if H.size else '_' + label def _sci_ticks(axis): """Scientific tick labels from 10⁴ on the matplotlib `axis` (six-digit heats run together on a narrow figure); left alone unless it has a plain scalar formatter.""" from matplotlib.ticker import ScalarFormatter formatter = axis.get_major_formatter() if type(formatter) is ScalarFormatter: formatter.set_powerlimits((-3, 4)) def _reach(curves, a, b, top): """The top of the polylines `curves` (each an (n, 2) array of display points) over the stretch `a` <= x <= `b` if `top`, else their bottom; None if none of them runs over it.""" ys = [] for xy in curves: if len(xy) < 2: continue (x0, y0), (x1, y1) = xy[:-1].T, xy[1:].T left, right = np.minimum(x0, x1), np.maximum(x0, x1) keep = (right >= a) & (left <= b) if not keep.any(): continue x0, y0, x1, y1, left, right = (v[keep] for v in (x0, y0, x1, y1, left, right)) dx = x1 - x0 steep = dx == 0. # vertical: both ends slope = (y1 - y0) / np.where(steep, 1., dx) ys += [np.where(steep, y0, y0 + slope * (np.clip(a, left, right) - x0)), np.where(steep, y1, y0 + slope * (np.clip(b, left, right) - x0))] if not ys: return None ys = np.concatenate(ys) return ys.max() if top else ys.min() class _Labels: """ Where the annotations of `ax` added to it go, decided when drawn: at the final layout of the figure, which places the axes. All of them are placed in one pass (`_place`), kept for the renderer, the box and the view limits of the axes it was made for, so that drawing or measuring a label (as constrained layout, a legend's 'best' location and a tight bounding box each do) places none of them again. Each label's `textcoords` is an `_At` of it: a class rather than a closure, as is the `_Leader` of one, so that the figure can still be pickled. The labels keep off the polylines `curves` ((x, y) in data, their points that are not finite left out). """ def __init__(self, ax, curves=()): self.ax, self.labels, self.options = ax, [], [] self.curves = [] for x, y in curves: xy = np.column_stack((x, y)).astype(float).reshape(-1, 2) self.curves.append(xy[np.isfinite(xy).all(axis=1)]) self._key = self._spots = None def __getstate__(self): return {**self.__dict__, '_key': None, '_spots': None} def add(self, label, **options): """Place the annotation `label` (after those added before it).""" label.set_anncoords(_At(self, len(self.labels))) self.labels.append(label) self.options.append(options) def spots(self, renderer): """The display (x, y) of the text of each label, and the ends of its leader (or None).""" ax = self.ax key = (id(renderer), tuple(ax.viewLim.bounds), tuple(ax.bbox.bounds), tuple([(label.get_text(), label.get_fontsize()) for label in self.labels])) if key != self._key: self._spots, self._key = self._place(renderer), key return self._spots def _sizes(self, renderer): """The display (width, height) of the text of each label (not where it is, which is not placed yet).""" from matplotlib.text import Text boxes = [Text.get_window_extent(label, renderer) for label in self.labels] return [(box.width, box.height) for box in boxes] def _curves(self): """The `curves` in display.""" return [self.ax.transData.transform(xy) for xy in self.curves] class _At: """The `textcoords` of the label `index` of the `_Labels` `labels`.""" def __init__(self, labels, index): self.labels, self.index = labels, index def __call__(self, renderer): from matplotlib.transforms import Affine2D return Affine2D().translate(*self.labels.spots(renderer)[self.index][0]) class _Under: """ The y `xycoords` of a note under the axes `ax`: the display box of the axes and of the visible decorations of its x axis (tick labels, label and offset text) as drawn, so that the note keeps below them at any size (a class, so that the figure can still be pickled). """ def __init__(self, ax): self.ax = ax def __call__(self, renderer): from matplotlib.transforms import Bbox ax = self.ax box = ax.xaxis.get_tightbbox(renderer) return ax.bbox if box is None else Bbox.union([ax.bbox, box]) class _Leader(Patch): """ A thin line from the anchor of the label `index` of the `_Labels` `labels` to its text, drawn where the label sits away from its anchor (its path is that of the last draw, empty without a line). """ def __init__(self, labels, index, **kwargs): from matplotlib.transforms import IdentityTransform super().__init__(fill=False, transform=IdentityTransform(), **kwargs) self.set_in_layout(False) self.labels, self.index = labels, index self._path = Path(np.zeros((0, 2))) def get_path(self): return self._path def path(self, renderer): """The path of the leader where the labels are placed for the `renderer` (in display; empty without a leader).""" ends = self.labels.spots(renderer)[self.index][1] return Path(np.zeros((0, 2)) if ends is None else np.array(ends)) def draw(self, renderer): if not self.get_visible(): return self._path = self.path(renderer) if len(self._path.vertices): super().draw(renderer) class _Stacked(_Labels): """ Labels of the balanced composite curves (the `curves`), added by `add(label, offset)` in turn: each heating agent's by ascending level, then the fuel's, then each cooling agent's by descending level. A label sits `dx`, `dy` points (the `offset`) off its anchor: above left of it (right- and bottom-aligned) if `dy` > 0, a heating one, else below right of it (left- and top-aligned), a cooling one. One that would run past the left or right edge of the axes is moved to keep 2 points inside them, and each is kept `abs(dy)` points above (below) the curves under its span. The labels of a side are then stacked in a column: each at least as high (low) as the one before it, which keeps the order of the levels, and moved on, up (down), past each label placed before it whose box it would overlap or come within 2 points of. Where that would run past the top (bottom) of the axes, they are set in rows instead, from the other end: each where it sits, moved sideways, left (right), past the labels of the levels beyond its own (whose rows are beyond its own, so that its leader crosses none of them), if that keeps the order and all of them inside the axes; else in the column, moved sideways past the label they would run into at the edge. One left more than 1.5 times its height from its anchor gets a leader to it. """ def add(self, label, offset, leader=None): """Place the annotation `label` `offset` points off its anchor, with a leader (a `_Leader` added to the axes, with the gid `leader`) if it is away from it.""" super().add(label, offset=offset) if leader is not None: self.ax.add_patch(_Leader(self, len(self.labels) - 1, color=label.get_color(), lw=0.6, gid=leader)) def make_room(self, x=0.): """Extend the data limits of the axes in y (at `x`), so that the labels fit in their columns at the layout of the figure now (where a renderer is at hand).""" ax = self.ax renderer = getattr(ax.figure.canvas, 'get_renderer', lambda: None)() if renderer is None or not np.isfinite(ax.dataLim.intervaly).all(): return for _ in range(4): # the scale changes with the limits y0, y1 = ax.get_ylim() # autoscaled first below, above = self._place(renderer, bound=False) if below <= 0. and above <= 0.: break scale = (y1 - y0) / ax.bbox.height # data per pixel lo, hi = ax.dataLim.intervaly ax.update_datalim([(x, lo - 1.05 * below * scale), (x, hi + 1.05 * above * scale)], updatex=False) ax.autoscale_view(scalex=False) def _place(self, renderer, bound=True): """The `spots`, or how far (in pixels) the columns of labels run past the bottom and the top of the axes if not `bound` by them.""" ax = self.ax pt = ax.figure.dpi / 72. # pixels per point gap = 2. * pt frame = ax.bbox lo, hi = frame.x0 + gap, frame.x1 - gap bottom, top = frame.y0 + gap, frame.y1 - gap if not bound: bottom, top = -math.inf, math.inf curves = self._curves() sizes = self._sizes(renderer) anchors = [ax.transData.transform(label.xy) for label in self.labels] offsets = [options['offset'] for options in self.options] def clear(i, u, v): """`v` kept abs(dy) points off the curves under the span of label `i` from `u`.""" (w, h), dy = sizes[i], offsets[i][1] reach = _reach(curves, u, u + w, dy > 0) if reach is None: return v if dy > 0: return max(v, reach + dy * pt) return min(v, reach + dy * pt - h) def leader(i, u, v): """The ends of a leader from the anchor of label `i` to the nearest point of its box from `u`, `v`, if that is more than 1.5 times its height away, else None.""" (w, h), (x, y) = sizes[i], anchors[i] near = (min(max(x, u), u + w), min(max(y, v), v + h)) span = math.hypot(near[0] - x, near[1] - y) if span <= 1.5 * h: return None trim = 1.5 * pt / span return [(x + trim * (near[0] - x), y + trim * (near[1] - y)), (near[0] - trim * (near[0] - x), near[1] - trim * (near[1] - y))] def stack(order, up, column, placed): """The lower left corners of the labels `order` of a side (up for heating), placed past those `placed` and each other (and, in rows, the leaders before them): in a column, else in rows; and whether that kept them inside the axes and in order, without moving any the other way.""" placed = list(placed) corners = {} last = None clean = True for i in order: (w, h), (dx, dy) = sizes[i], offsets[i] x, y = anchors[i] + (dx * pt, dy * pt) u, v = (x - w, y) if up else (x, y - h) u = max(min(u, hi - w), lo) # inside the axes, left to right v = clear(i, u, v) if column and last is not None: # in order v = max(v, last[0]) if up else min(v, last[1] - h) for _ in range(2 * len(placed) + 1): hits = [box for box in placed if box[0] < u + w + gap and u < box[2] + gap and box[1] < v + h + gap and v < box[3] + gap] if not hits: break # past the nearest one, on (up or down) or sideways # (left or right) if up: on = min(hits, key=lambda box: box[1])[3] + gap side = max(hits, key=lambda box: box[0])[0] - gap - w moves = [('v', on, on + h <= top), ('u', side, side >= lo)] else: on = max(hits, key=lambda box: box[3])[1] - gap - h side = min(hits, key=lambda box: box[2])[2] + gap moves = [('v', on, on >= bottom), ('u', side, side + w <= hi)] if not column: moves.reverse() for n, (move, value, fits) in enumerate(moves): if fits: break else: clean = False break if n: clean = False if move == 'v': v = value else: u, v = value, clear(i, value, v) if not bottom <= v <= top - h: clean = False v = max(min(v, top - h), bottom) # inside the axes if not column and last is not None and ( v > last[0] + 0.5 * h if up else v < last[0] - 0.5 * h): clean = False # out of order corners[i] = (u, v) placed.append((u, v, u + w, v + h)) ends = None if column else leader(i, u, v) if ends is not None: (x0, y0), (x1, y1) = ends placed.append((min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))) last = (v, v + h) return corners, clean corners = {} placed = [] for up in (True, False): order = [i for i, (dx, dy) in enumerate(offsets) if (dy > 0) == up] if not order: continue side, clean = stack(order, up, True, placed) if not clean and bound: rows, clean = stack(order[::-1], up, False, placed) if clean: side = rows corners.update(side) placed += [(u, v, u + sizes[i][0], v + sizes[i][1]) for i, (u, v) in side.items()] if not bound: return (max([frame.y0 + gap - v for u, v in corners.values()], default=0.), max([v + sizes[i][1] - frame.y1 + gap for i, (u, v) in corners.items()], default=0.)) spots = [] for i, (w, h) in enumerate(sizes): u, v = corners[i] spots.append(((u + w, v) if offsets[i][1] > 0 else (u, v + h), leader(i, u, v))) return spots class _Beside(_Labels): """ Labels of the steps of the grand composite curve, added by `add(label, heating)` in turn, each anchored at the end of its step. A label sits beside the end, 3 points right or left of it and 2 points above or below its level: on the side with more room in the axes, first, toward the pinch (below a heating level, above a cooling one) right of the end and beyond the level left of it, then the other way; then likewise on the other side. It takes the first of these whose box is inside the axes and crosses none of the `curves` (the grand composite curve, the first, and the steps) nor a label placed before it; else the nearest such box on along its level, away from the end (by steps of its height); else the first of the four inside the axes that keeps off the grand composite curve, else the first inside the axes, else the first. """ def add(self, label, heating): """Place the annotation `label` (left- and bottom-aligned) of a `heating` step or not.""" super().add(label, heating=heating) def _place(self, renderer): from matplotlib.transforms import Bbox ax = self.ax pt = ax.figure.dpi / 72. frame = ax.bbox gcc, *steps = [Path(xy.reshape(-1, 2)) for xy in self._curves()] placed = [] spots = [] for label, options, (w, h) in zip(self.labels, self.options, self._sizes(renderer)): x, y = ax.transData.transform(label.xy) toward = -1. if options['heating'] else 1. # the pinch right = frame.x1 - x >= x - frame.x0 sides = [(side, vertical) for side in ((1., -1.) if right else (-1., 1.)) for vertical in ((toward, -toward) if side > 0 else (-toward, toward))] def score(side, vertical, slide=0.): """Whether the box `slide` pixels on from its place beside the end is outside the axes, on the curve, and on the steps or a label placed before it; and the box.""" u = x + 3. * pt + slide if side > 0 else x - 3. * pt - w - slide v = y + 2. * pt if vertical > 0 else y - 2. * pt - h box = Bbox([[u, v], [u + w, v + h]]) inside = (frame.x0 <= u and u + w <= frame.x1 and frame.y0 <= v and v + h <= frame.y1) return ((not inside, gcc.intersects_bbox(box, filled=False), any(p.intersects_bbox(box, filled=False) for p in steps) or any(box.overlaps(other) for other in placed)), box) candidates = [score(*side) for side in sides] if all(any(flags) for flags, box in candidates): # none clear beside the end: the nearest clear one on along # the level, away from the end slides = [score(*side, slide=k * h) for k in range(1, 41) for side in sides] clear = [item for item in slides if not any(item[0])] candidates = clear[:1] + candidates box = min(candidates, key=lambda item: item[0])[1] placed.append(box) spots.append(((box.x0, box.y0), None)) return spots class _ClearLegend(Legend): """ A legend (of loc 'best') at matplotlib's 'best' location in its axes among the spots that cover none of their texts (the labels), decided when drawn, as the labels are placed: of the spots matplotlib tries in the axes, the first, in its order, of those clear of the texts that covers the least of the rest of the axes, by matplotlib's count (of the points of their lines, patches and collections in it, of their crossings of it, and of the rectangles, such as a shaded band, that it overlaps; the leaders where they are drawn), which is matplotlib's location wherever that is clear of the texts. Else, where each of them covers a text, it goes above the axes (and any title of them), left-aligned with them, where a constrained layout or a tight bounding box makes room for it. """ # the anchors of the locations 1 to 10 ('upper right' to 'center') _anchors = ('NE', 'NW', 'SW', 'SE', 'E', 'W', 'E', 'S', 'N', 'C') def _find_best_position(self, width, height, renderer): # matplotlib's hook of the 'best' location (which the legend would # fall back to without it): the display (x, y) of the lower left # corner of the legend of `width` and `height` from matplotlib.collections import PolyCollection from matplotlib.patches import Rectangle from matplotlib.text import Text from matplotlib.transforms import Bbox ax = self.parent texts = [text.get_window_extent(renderer) for text in ax.texts if text.get_visible() and text.get_text()] paths = [line.get_transform().transform_path(line.get_path()) for line in ax.lines] boxes, points = [], [] for patch in ax.patches: if isinstance(patch, _Leader): paths.append(patch.path(renderer)) elif isinstance(patch, Rectangle): boxes.append(patch.get_bbox().transformed(patch.get_data_transform())) else: paths.append(patch.get_transform().transform_path(patch.get_path())) for collection in ax.collections: if isinstance(collection, PolyCollection): transform = collection.get_transform() paths += [transform.transform_path(path) for path in collection.get_paths()] elif len(collection.get_offsets()): points.append(collection.get_offset_transform().transform( collection.get_offsets())) points = np.concatenate(points) if points else np.zeros((0, 2)) pad = self.borderaxespad * renderer.points_to_pixels( self.prop.get_size_in_points()) frame = self.get_bbox_to_anchor() size = Bbox.from_bounds(0., 0., width, height) best = None for anchor in self._anchors: box = size.anchored(anchor, container=frame.padded(-pad)) if any(box.overlaps(text) for text in texts): continue badness = (sum(box.count_contains(path.vertices) + path.intersects_bbox(box, filled=False) for path in paths) + box.count_contains(points) + box.count_overlaps(boxes)) if best is None or badness < best[0]: best = (badness, box.p0) if not badness: break if best is not None: return best[1] # the titles are the texts of the axes besides `texts` titles = [title.get_window_extent(renderer).y1 for title in ax.get_children() if isinstance(title, Text) and title not in ax.texts and title.get_visible() and title.get_text()] return frame.x0, max([frame.y1, *titles]) + pad
[docs] def plot_composite_curves(targets, kind='real', utilities=None, ax=None, file=None, dpi=300): """ Draw the composite curves of `targets`: real, shifted, or balanced with the utilities. Parameters ---------- targets : PinchTargets kind : str, optional 'real' (the default) or 'shifted' for `PinchTargets.composite_curves` with the heat recovered shaded and the utility targets labeled (shifted ones with the pinch marked), or 'balanced' for `PinchTargets.balanced_composite_curves` with each utility labeled. utilities : list[UtilityTarget], optional Utilities of the balanced curves, as `PinchTargets.balanced_composite_curves` takes them (those of `targets`), with kind 'balanced' only. Defaults to `PinchTargets.utility_targets` with the default agents. ax : matplotlib.axes.Axes, optional Axes to draw on; a new figure (with a constrained layout, which keeps the axis labels and a note under the x axis in it) is created if not given. file : str, optional If given, the figure is saved to this path. dpi : int, optional Resolution used when saving. Defaults to 300. Returns ------- fig : matplotlib.figure.Figure ax : matplotlib.axes.Axes Raises ------ ValueError For another `kind`, for `utilities` with kind 'real' or 'shifted' (whose curves take none), or for ``utilities=False`` (the balanced curves are those of their utilities: kind 'real' draws the composites without them). Notes ----- Temperatures are shown in degC. Artists carry stable gids: 'Hot composite' and 'Cold composite' (or 'Hot balanced composite' and 'Cold balanced composite'), 'Heat recovered', 'Hot utility', 'Cold utility', 'Pinch', 'Fuel', 'Utility:<agent ID>' and 'Leader:<agent ID>'. The pinch is marked where the shifted composites touch: not for a threshold problem whose composites end apart, nor without streams on one side (whose empty composite stays out of the legend). Fuel, at infinite temperature on the balanced curves, is labeled rather than drawn, above the hot end of the cold balanced composite (at the top of both), whose top its heat closes, and so is an unserved remainder, in a note under the x axis ('Utility:unserved', clear of the curves, of their labels and of the legend): the cold balanced composite starts at the unserved cooling, and the hot one ends short of the cold one by the unserved heating. A label of a latent agent sits by its flat, above left of the warm end of a heating one and below right of the cold end of a cooling one, and a sensible agent's off the middle of its line, above left for heating and below right for cooling. When drawn (at the final layout of the figure, all of them in one pass), a label that would run past the left or right edge of the axes is moved just inside them, and off the curves. The labels of each side keep the order of the levels: each one that would run into the label of a lower heating (higher cooling) agent is moved on past it, up for heating and down for cooling (the fuel's past those of the heating agents), in a column for which the limits of the y axis leave room (as the axes are laid out when drawn on). Where the column would still run past the top (bottom) of the axes, the labels go in rows instead, each beside its flat and moved sideways past the labels of the levels beyond its own; or, if those would not keep the order inside the axes, those that would run past the edge are moved sideways past the label in their way. Labels can then still overlap, on axes too small for them all (many agents on small axes without a layout engine, say, or on short axes that a legend above them leaves shorter): a larger figure, or one with ``layout='constrained'``, gives them room. A label left more than 1.5 times its height away from its anchor gets a thin leader from it ('Leader:<agent ID>', an empty path otherwise), and each label a halo, so that a leader passing behind it strikes none of its letters. The legend, of any kind, covers none of the labels: when drawn, it goes to the spot of matplotlib's 'best' location in the axes that covers the least of the curves, the leaders and the band among those clear of the labels (matplotlib's own spot wherever that is clear of them: no fixed corner is clear of the composites of every problem); or, where each of those spots would cover a label (on small axes, say), above the axes and any title of them, left-aligned with them, where a constrained layout or a tight bounding box makes room for it. Examples -------- The streams of `PinchTargets`: >>> import biosteam as bst, thermosteam as tmo >>> from hensmith import PinchTargets, plot_composite_curves >>> fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., ... Cn=3.6, default=True) >>> bst.settings.set_thermo([fluid]) >>> bst.main_flowsheet.set_flowsheet('targeting_example') >>> H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1000., T=400.), ... T=300., rigorous=False) >>> C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1000., T=330.), ... T=440., rigorous=False) >>> H1.simulate(); C1.simulate() >>> targets = PinchTargets.from_heat_utilities( ... [H1.heat_utilities[0], C1.heat_utilities[0]], T_min_app=10.) >>> fig, ax = plot_composite_curves(targets, kind='shifted') >>> [line.get_gid() for line in ax.get_lines()] ['Hot composite', 'Cold composite', 'Pinch'] >>> import matplotlib.pyplot as plt >>> plt.close(fig) """ import matplotlib.pyplot as plt if kind not in ('real', 'shifted', 'balanced'): raise ValueError("kind must be 'real', 'shifted' or 'balanced', not " f'{kind!r}') if utilities is not None and kind != 'balanced': raise ValueError(f'the {kind} composite curves take no utilities ' "(those of kind='balanced')") if utilities is False: raise ValueError('the balanced composite curves are those of their ' "utilities, not False (kind='real' draws the " 'composites without them)') if ax is None: fig, ax = plt.subplots(figsize=(7.5, 4.2), layout='constrained') else: fig = ax.figure hot_color, cold_color = '#d62728', '#2e6db4' text_kwargs = dict(fontsize=8) if kind == 'balanced': if utilities is None: utilities = targets.utility_targets() hot_T, hot_H, cold_T, cold_H = targets.balanced_composite_curves(utilities) finite = np.isfinite(hot_T) hot_T, hot_H = hot_T[finite], hot_H[finite] # a halo, so that a leader passing behind a label strikes none of # its letters from matplotlib.patheffects import withStroke label_kwargs = dict(text_kwargs, path_effects=[ withStroke(linewidth=2.5, foreground=ax.get_facecolor())]) ax.plot(hot_H, hot_T - 273.15, color=hot_color, lw=2, label=_legend('hot balanced composite', hot_H), gid='Hot balanced composite') ax.plot(cold_H, cold_T - 273.15, color=cold_color, lw=2, label=_legend('cold balanced composite', cold_H), gid='Cold balanced composite') labels = [] # (annotation, offset, ID) of each agent's but fuel's fuels = [] unserved = [] for u in utilities: if u.agent is None: unserved.append(f"unserved {'heating' if u.heating else 'cooling'} " f'{_format_duty(u.duty)} kJ/hr (not drawn)') continue if u.agent.isfuel: fuels.append(u) continue label = f'{u.ID} {_format_duty(u.duty)} kJ/hr' T, H = (hot_T, hot_H) if u.heating else (cold_T, cold_H) if u.agent.T_limit is None: # above a heating flat, ending at its warm end (left of it the # hot curve is below the flat), or below a cooling flat from # its cold end (right of it both curves are above the flat) T_u = u.agent.T if u.heating: i = np.searchsorted(T, T_u + 1e-6) - 1 offset, ha, va = (-2, 2), 'right', 'bottom' else: i = np.searchsorted(T, T_u - 1e-6) offset, ha, va = (2, -2), 'left', 'top' x = H[min(max(i, 0), H.size - 1)] else: # at the middle of a sensible agent's line, off the rising # composite: above left of it for heating, below right of it # for cooling (the other composite is on the far side) T_u = 0.5 * (u.T_utility_in + u.T_utility_out) x = np.interp(T_u, T, H) if u.heating: offset, ha, va = (-6, 4), 'right', 'bottom' else: offset, ha, va = (6, -4), 'left', 'top' annotation = ax.annotate( label, xy=(x, T_u - 273.15), xytext=(0, 0), ha=ha, va=va, color=hot_color if u.heating else cold_color, gid='Utility:' + u.ID, **label_kwargs) labels.append((annotation, offset, u.ID)) # each moved inside the axes if it would run out of them, off the # curves, in the order of the levels, and on past the labels placed # before it that it would run into (up for heating, down for # cooling, or sideways at the edge of the axes); with a leader to # its anchor if that leaves it away from it placement = _Stacked(ax, [(hot_H, hot_T - 273.15), (cold_H, cold_T - 273.15)]) heating = sorted([item for item in labels if item[1][1] > 0], key=lambda item: item[0].xy[1]) cooling = sorted([item for item in labels if item[1][1] < 0], key=lambda item: -item[0].xy[1]) for annotation, offset, ID in heating: placement.add(annotation, offset, leader='Leader:' + ID) # the fuel's heat closes the cold curve: above its hot end (at the # top of both curves), over the labels of the heating agents it # would overlap there, and of any fuel before it curves_T = np.concatenate((hot_T, cold_T)) if curves_T.size: T_top, span = curves_T.max(), np.ptp(curves_T) x = max(H[-1] for H in (hot_H, cold_H) if H.size) else: T_top, span, x = targets.grid['Ts'][0], 0., 0. # headroom for two labels where a heating agent's is near the top stacked = bool(fuels) and any(T_top - 273.15 - a.xy[1] <= 0.15 * span for a, *_ in heating) for u in fuels: annotation = ax.annotate( f'{u.ID} (furnace) {_format_duty(u.duty)} kJ/hr', xy=(x, T_top - 273.15), xytext=(0, 0), ha='right', va='bottom', color=hot_color, gid='Fuel', **label_kwargs) placement.add(annotation, (-2, 4), leader='Leader:' + u.ID) for annotation, offset, ID in cooling: placement.add(annotation, offset, leader='Leader:' + ID) if unserved: # the curves end apart: say why, in a note under the x axis, # clear of the curves, of their labels and of the legend ax.annotate('\n'.join(unserved), xy=(0., 0.), xycoords=('axes fraction', _Under(ax)), xytext=(0, -4), textcoords='offset points', ha='left', va='top', color='0.45', gid='Utility:unserved', **text_kwargs) # headroom for the labels above the top of the curves, and more # where they need it to stack in columns ax.margins(y=0.16 if stacked else 0.1) placement.make_room(x) ax.set_ylabel('T [°C]') else: shifted = kind == 'shifted' hot_T, hot_H, cold_T, cold_H = targets.composite_curves(shifted) if cold_H.size and hot_H.size and cold_H[0] < hot_H[-1]: ax.axvspan(cold_H[0], hot_H[-1], color='0.92', lw=0, zorder=0, label='heat recovered', gid='Heat recovered') ax.plot(hot_H, hot_T - 273.15, color=hot_color, lw=2, label=_legend('hot composite', hot_H), gid='Hot composite') ax.plot(cold_H, cold_T - 273.15, color=cold_color, lw=2, label=_legend('cold composite', cold_H), gid='Cold composite') T_all = np.concatenate([hot_T, cold_T]) - 273.15 H_hot_end = hot_H[-1] if hot_H.size else 0. H_cold_end = cold_H[-1] if cold_H.size else targets.Q_cold ax.margins(y=0.12) # headroom for the utility labels # above the top of the curves, ending at the right one of their hot # ends, and below their bottom from the middle of the cold utility: # inside the axes, clear of the curves by a few points ax.annotate(f'hot utility {_format_duty(targets.Q_hot)} kJ/hr', xy=(max(H_hot_end, H_cold_end), T_all.max()), xytext=(-2, 4), textcoords='offset points', ha='right', va='bottom', gid='Hot utility', **text_kwargs) ax.annotate(f'cold utility {_format_duty(targets.Q_cold)} kJ/hr', xy=(0.5 * targets.Q_cold, T_all.min()), xytext=(0, -4), textcoords='offset points', ha='left', va='top', gid='Cold utility', **text_kwargs) pinch_T = targets.pinch_T tol = 1e-9 * pinch_T if shifted and all(T.size and T.min() - tol <= pinch_T <= T.max() + tol for T in (hot_T, cold_T)): # where the composites touch: the cold utility plus the cold # streams' heat below the pinch, if both composites reach the # pinch temperature (not for streams of one side, nor for a # threshold problem whose composites end apart) below = targets._side_heat()[1] x = targets.Q_cold + below[~targets.is_hot].sum() ax.plot([x], [pinch_T - 273.15], 'o', mfc='w', mec='k', ms=7, zorder=4, label='pinch', gid='Pinch') ax.set_ylabel('shifted T [°C]' if shifted else 'T [°C]') _sci_ticks(ax.xaxis) ax.set_xlabel('H [kJ·h$^{-1}$]') # at matplotlib's 'best' location among the spots clear of the labels, # or above the axes if none is (added as `Axes.legend` adds one) ax.legend_ = _ClearLegend(ax, *ax.get_legend_handles_labels(), loc='best', fontsize=8) ax.legend_._remove_method = ax._remove_legend ax.grid(alpha=0.3) if file: fig.savefig(file, dpi=dpi, bbox_inches='tight') return fig, ax
[docs] def plot_grand_composite_curve(targets, utilities=None, ax=None, file=None, dpi=300): """ Draw the grand composite curve of `targets` with the utility targets as a staircase of levels. Parameters ---------- targets : PinchTargets utilities : list[UtilityTarget] or False, optional Utilities to draw. Defaults to `PinchTargets.utility_targets` with the default agents; False draws the curve only. ax : matplotlib.axes.Axes, optional Axes to draw on; a new figure (with a constrained layout, which keeps the axis labels in it) is created if not given. file : str, optional If given, the figure is saved to this path. dpi : int, optional Resolution used when saving. Defaults to 300. Returns ------- fig : matplotlib.figure.Figure ax : matplotlib.axes.Axes Notes ----- Shifted temperatures are shown in degC. Heating utilities are drawn by ascending level from the temperature axis, each a horizontal segment at its level as long as its duty, joined by dotted verticals; cooling utilities likewise by descending level. Fuel is drawn dashed at the top of the grid and an unserved remainder in gray. Each label sits beside the end of its segment, placed when drawn: on the side with more room in the axes, right of the end toward the pinch (below the level for heating, above it for cooling) or left of it beyond the level (above it for heating, below it for cooling, where the heat cascaded is at least the end of the step), if that keeps it inside the axes and off the curve, the steps and the labels placed before it; else at the first other side of the end and of the level that does (a pocket of the curve, or a curve that leaves the end at a shallow angle, can run toward the pinch); else on along its level, away from the end, where it first does. Artists carry stable gids: 'GCC', 'Pinch', 'Utility:<agent ID>' (the segments, 'Utility:unserved' for a remainder) and 'Label:<agent ID>'. Examples -------- The streams of `PinchTargets`: >>> import biosteam as bst, thermosteam as tmo >>> from hensmith import PinchTargets, plot_grand_composite_curve >>> fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., ... Cn=3.6, default=True) >>> bst.settings.set_thermo([fluid]) >>> bst.main_flowsheet.set_flowsheet('targeting_example') >>> H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1000., T=400.), ... T=300., rigorous=False) >>> C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1000., T=330.), ... T=440., rigorous=False) >>> H1.simulate(); C1.simulate() >>> targets = PinchTargets.from_heat_utilities( ... [H1.heat_utilities[0], C1.heat_utilities[0]], T_min_app=10.) >>> fig, ax = plot_grand_composite_curve(targets) >>> sorted(line.get_gid() for line in ax.get_lines() ... if (line.get_gid() or '').startswith('Utility:')) ['Utility:chilled_water', 'Utility:cooling_water', 'Utility:low_pressure_steam', 'Utility:medium_pressure_steam'] >>> import matplotlib.pyplot as plt >>> plt.close(fig) """ import matplotlib.pyplot as plt if ax is None: fig, ax = plt.subplots(figsize=(5, 4.2), layout='constrained') else: fig = ax.figure hot_color, cold_color = '#d62728', '#2e6db4' T, H = targets.grand_composite_curve() ax.plot(H, T - 273.15, color='k', lw=2, label='grand composite curve', gid='GCC') ax.axvline(0, color='k', ls='--', lw=0.8) if targets.Q_hot > 0.: x, y = 0., targets.pinch_T else: # threshold problem i = int(np.argmin(H)) x, y = H[i], T[i] ax.plot([x], [y - 273.15], 'o', mfc='w', mec='k', ms=7, zorder=4, gid='Pinch') if utilities is None: utilities = targets.utility_targets() if utilities is not False: Ts = targets.grid['Ts'] heating = sorted((u for u in utilities if u.heating), key=lambda u: u.level) cooling = sorted((u for u in utilities if not u.heating), key=lambda u: -u.level) ends = [] # (ID, x, y, heating, color) at the end of each step segments = [] # (x, y) of the steps and the dotted lines between for steps, color, end in ((heating, hot_color, Ts[0]), (cooling, cold_color, Ts[-1])): x = 0. previous = None for u in steps: y = (u.level if math.isfinite(u.level) else end) - 273.15 ID = 'unserved' if u.ID is None else u.ID if u.agent is None: style = dict(color='0.5', ls='-') elif u.agent.isfuel: style = dict(color=color, ls='--') else: style = dict(color=color, ls='-') if previous is not None: ax.plot([x, x], [previous, y], color=style['color'], ls=':', lw=0.8) segments.append(([x, x], [previous, y])) ax.plot([x, x + u.duty], [y, y], lw=1.5, gid='Utility:' + ID, **style) segments.append(([x, x + u.duty], [y, y])) x += u.duty previous = y ends.append((ID, x, y, u.heating, style['color'])) # each label beside the end of its step, placed when drawn: on the # side with more room in the axes, toward the pinch right of the # end, beyond the level left of it (where the curve never is: the # heat cascaded there is at least the end of the step), or where # else it keeps inside the axes and off the curve, the steps and # the labels before it (a pocket of the curve, or a curve that # leaves the end of a step at a shallow angle, can run toward the # pinch) placement = _Beside(ax, [(H, T - 273.15), *segments]) for ID, x, y, is_heating, color in ends: label = ax.annotate(ID, xy=(x, y), xytext=(0, 0), ha='left', va='bottom', fontsize=7, color=color, gid='Label:' + ID) placement.add(label, is_heating) ax.margins(y=0.08) # headroom for a label beyond the outermost level _sci_ticks(ax.xaxis) ax.set_xlabel('heat cascaded [kJ·h$^{-1}$]') ax.set_ylabel('shifted T [°C]') ax.grid(alpha=0.3) if file: fig.savefig(file, dpi=dpi, bbox_inches='tight') return fig, ax
[docs] def plot_supertargets(sweep, ax=None, file=None, dpi=300): """ Draw the total annualized cost (TAC) of a sweep of the minimum approach temperature, its annualized capital and operating parts, and the least TAC. Parameters ---------- sweep : Supertargets ax : matplotlib.axes.Axes, optional Axes to draw on; a new figure (with a constrained layout, which keeps the axis labels in it) is created if not given. file : str, optional If given, the figure is saved to this path. dpi : int, optional Resolution used when saving. Defaults to 300. Returns ------- fig : matplotlib.figure.Figure ax : matplotlib.axes.Axes Notes ----- Artists carry stable gids: 'TAC', 'Capital' (the annualized capital cost), 'Operating', 'Optimum' and 'Nonfinite'. The optimum is labeled '(TAC constant)' if the TAC is the same over the sweep (`Supertargets.constant`), '(TAC constant where finite)' if it is the same at each temperature with a finite TAC (at least two), else '(at the bound)' if it is at an end of the sweep (`Supertargets.at_bound`), and is not marked without a finite TAC. The lines leave out the temperatures without a finite TAC (an unserved utility or an infinite area): each stretch of them is shaded ('Nonfinite', halfway to its finite neighbors), so the axis keeps the whole sweep. Examples -------- The streams of `PinchTargets`: >>> import biosteam as bst, thermosteam as tmo >>> from hensmith import PinchTargets, supertarget, plot_supertargets >>> fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., ... Cn=3.6, default=True) >>> bst.settings.set_thermo([fluid]) >>> bst.main_flowsheet.set_flowsheet('targeting_example') >>> H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1000., T=400.), ... T=300., rigorous=False) >>> C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1000., T=330.), ... T=440., rigorous=False) >>> H1.simulate(); C1.simulate() >>> targets = PinchTargets.from_heat_utilities( ... [H1.heat_utilities[0], C1.heat_utilities[0]], T_min_app=10.) >>> sweep = supertarget(targets) >>> sweep <Supertargets: 64 points, T_min_app 1-40 K, optimum 7.816 K (TAC 2.490e+04 USD/yr)> >>> fig, ax = plot_supertargets(sweep) >>> [line.get_gid() for line in ax.get_lines() if line.get_gid()] ['TAC', 'Capital', 'Operating', 'Optimum'] >>> import matplotlib.pyplot as plt >>> plt.close(fig) """ import matplotlib.pyplot as plt if ax is None: fig, ax = plt.subplots(figsize=(6, 4), layout='constrained') else: fig = ax.figure T = sweep['T_min_app'] TAC = sweep['TAC'] ax.plot(T, TAC, color='k', lw=2, label='total annualized cost', gid='TAC') ax.plot(T, sweep['annualized_capital_cost'], color='#2e6db4', lw=1.5, label='annualized capital cost', gid='Capital') ax.plot(T, sweep['operating_cost'], color='#d62728', lw=1.5, label='operating cost', gid='Operating') optimum = sweep.optimum if math.isfinite(optimum['TAC']): if sweep.constant: bound = ' (TAC constant)' elif _constant_TAC(sweep.records, where_finite=True): bound = ' (TAC constant where finite)' elif sweep.at_bound: bound = ' (at the bound)' else: bound = '' ax.axvline(optimum['T_min_app'], color='k', ls='--', lw=0.8) ax.plot([optimum['T_min_app']], [optimum['TAC']], 'o', mfc='w', mec='k', ms=7, zorder=4, label=f"optimum {optimum['T_min_app']:.3g} K{bound}", gid='Optimum') bad = ~np.isfinite(TAC) if bad.any(): # the stretches without a finite TAC, which the lines leave out: # shaded behind them, halfway to their finite neighbors n = T.size starts = np.flatnonzero(bad & ~np.r_[False, bad[:-1]]) ends = np.flatnonzero(bad & ~np.r_[bad[1:], False]) for k, (a, b) in enumerate(zip(starts, ends)): left = T[a] if a == 0 else (T[a - 1] + T[a]) / 2 right = T[b] if b == n - 1 else (T[b] + T[b + 1]) / 2 ax.axvspan(left, right, color='0.85', lw=0, zorder=0, label='no finite TAC' if k == 0 else '_nolegend_', gid='Nonfinite') ax.set_xlabel('$T_{min,app}$ [K]') ax.set_ylabel('cost [USD·yr$^{-1}$]') ax.legend(loc='best', fontsize=8) ax.grid(alpha=0.3) if file: fig.savefig(file, dpi=dpi, bbox_inches='tight') return fig, ax