diff --git a/README.md b/README.md index fcff57f..efe5de8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,15 @@ split, the network is a best-effort one close to the targets. With `HeatExchangerNetwork(..., stream_splitting=True)`, streams are instead split into parallel branches there (BioSTEAM splitters and rigorous mixers), and the network reaches the MER targets (not guaranteed with `avoid_recycle`; -see the documentation for the limits with real thermodynamics). +see the documentation for the limits with real thermodynamics). Each stream +can also carry its own approach temperature contribution +(`HeatExchangerNetwork(..., approach_contributions={unit: dT})`): a match +must then keep the sum of its two streams' contributions instead of +`T_min_app`. `hensmith.targeting` targets what a network of the streams +can achieve before one is synthesized (several utilities priced by BioSTEAM, +area, units, capital and total annualized cost: `HXN.targets()`), finds the +`T_min_app` of the least targeted total annualized cost (`supertarget`), and +compares a synthesized network with its targets (`network_report`). ```python import biosteam as bst # hensmith units plug into BioSTEAM systems diff --git a/docs/_demo_src/README.md b/docs/_demo_src/README.md index 0674c02..9ea7a3b 100644 --- a/docs/_demo_src/README.md +++ b/docs/_demo_src/README.md @@ -25,7 +25,8 @@ directories themselves. | `examples/ch01_quickstart.py` | `_static/images/examples/tutorial_01_quickstart_flowsheet_light.png`, `…_flowsheet_dark.png`, `_static/images/examples/tutorial_01_quickstart_pinch_diagram.png`; `_generated/ch01_results.txt`, `ch01_loads.txt`, `ch01_life_cycles.txt`, `ch01_summary.txt` | | `examples/ch02_pinch_analysis.py` | `_static/images/examples/tutorial_02_composite_curves.png`, `tutorial_02_grand_composite.png`; `_generated/ch02_threshold.txt`, `ch02_table.txt`, `ch02_compare.txt` | | `examples/ch03_network_anatomy.py` | `_static/images/examples/tutorial_03_hxn_flowsheet_light.png`, `…_hxn_flowsheet_dark.png`, `tutorial_03_pinch_diagram_minimal.png`; `_generated/ch03_flowsheet.txt`, `ch03_life_cycles.txt`, `ch03_stage.txt`, `ch03_pinch_Ts.txt`, `ch03_accounting.txt` | -| `examples/ch04_configuring.py` | `_static/images/examples/tutorial_04_T_min_app_sweep.png`, `tutorial_04_ten_streams_pinch_diagram.png`; `_generated/ch04_sweep.txt`, `ch04_splitting.txt`, `ch04_ignored.txt`, `ch04_ten_streams.txt` | +| `examples/ch04_configuring.py` | `_static/images/examples/tutorial_04_T_min_app_sweep.png`, `tutorial_04_ten_streams_pinch_diagram.png`; `_generated/ch04_sweep.txt`, `ch04_splitting.txt`, `ch04_contributions.txt`, `ch04_ignored.txt`, `ch04_ten_streams.txt` | +| `examples/ch05_targeting.py` | `_static/images/examples/tutorial_05_composite_curves.png`, `tutorial_05_balanced_composites.png`, `tutorial_05_grand_composite.png`, `tutorial_05_supertargets.png`, `tutorial_05_trade_off.png`; `_generated/ch05_targets.txt`, `ch05_utilities.txt`, `ch05_summary.txt`, `ch05_sweep.txt`, `ch05_trade_off.txt`, `ch05_report.txt` | | `make_hero_gif.py` | `_static/images/demo/hero_light.gif`, `hero_dark.gif` (8 s loop, 20 fps, 2000 × 720), `hero_light_still.png`, `hero_dark_still.png` (the frame-0 stills served under `prefers-reduced-motion`) | | `build_demo.py` | `_static/quickstart_demo.html` — the interactive quickstart demo, filled in from `quickstart_demo_template.html` | | `make_poster.py` | `_static/images/examples/quickstart_demo_poster.png` — the README poster that links to the demo (2400 × 1260) | @@ -82,9 +83,9 @@ Three mechanisms, all enforced at regeneration time: - **Regions.** Each chapter script marks the code the page shows with `# [start:]` / `# [end:]` comments, and the `.rst` page pulls it in with `literalinclude` + `:start-after:` / `:end-before:`. The reader - therefore sees code that *was executed*, not a retyped copy. Chapters 02–04 + therefore sees code that *was executed*, not a retyped copy. Chapters 02–05 each build the quickstart system in a `system` region whose body is exactly - chapter 01's `build` + `network` + `simulate` bodies, so all four chapters + chapter 01's `build` + `network` + `simulate` bodies, so all five chapters provably describe one system. - **Captures.** `_common.capturing(name)` redirects everything printed inside a block into `_generated/.txt` (and echoes it), and diff --git a/docs/_demo_src/build_all.py b/docs/_demo_src/build_all.py index efd8883..fb5fa58 100644 --- a/docs/_demo_src/build_all.py +++ b/docs/_demo_src/build_all.py @@ -30,6 +30,7 @@ 'examples/ch02_pinch_analysis.py', 'examples/ch03_network_anatomy.py', 'examples/ch04_configuring.py', + 'examples/ch05_targeting.py', 'make_hero_gif.py', 'build_demo.py', # consumes the chapter-01 captures and figures, and the logo 'make_poster.py', # consumes the chapter-01 pinch diagram and the logo diff --git a/docs/_demo_src/examples/ch04_configuring.py b/docs/_demo_src/examples/ch04_configuring.py index dd2a18b..fea4643 100644 --- a/docs/_demo_src/examples/ch04_configuring.py +++ b/docs/_demo_src/examples/ch04_configuring.py @@ -9,7 +9,9 @@ """Tutorial chapter 04 (docs/source/tutorial/04_configuring.rst): configuring a HeatExchangerNetwork -- sweeping the minimum approach temperature to expose the utility/capital trade-off, splitting streams where the targets need it -(``stream_splitting=True``), scoping the analysis with ``ignored=``, and +(``stream_splitting=True``), giving streams their own approach temperature +contributions (``approach_contributions=``), scoping the analysis with +``ignored=``, and synthesizing a larger ten-stream system (the ten-stream case of the regression suite, inlined here rather than imported from ``tests``). Regions between ``# [start:x]`` / ``# [end:x]`` are literalinclude'd by the page; everything @@ -128,6 +130,32 @@ def main(): HXN.stream_splitting = False HXN.T_min_app = 5. # [end:splitting] + with capturing('ch04_contributions'): + # [start:contributions] + HXN.approach_contributions = { + 'D1': 1., # the column's condenser and reboiler: phase change + F1: 1., # the flash's heater boils its feed + } # the two product coolers keep T_min_app / 2 = 2.5 K + sys.simulate() + info = HXN.synthesis_info + print(f"status: {info['status']}, {len(HXN.new_HXs)} process exchangers, " + f"{HXN.installed_costs['Heat exchangers']:.4g} USD added installed cost") + print(f'heating {HXN.actual_heat_util_load:.4g} kJ/hr, ' + f'cooling {HXN.actual_cool_util_load:.4g} kJ/hr') + contributions = info['approach_contributions'] # by stream, as used + print('contributions by stream [K]:', contributions) + required = {} # each match: its two streams' sum + for c, life_cycle in zip(contributions, HXN.stream_life_cycles): + for stage in life_cycle.life_cycle: + if stage.unit in HXN.new_HXs: + required[stage.unit] = required.get(stage.unit, 0.) + c + for hx in HXN.new_HXs: + print(f'{hx.ID:<10} requires {required[hx]:.1f} K, Q = {hx.Q:.4g} kJ/hr') + print(f"smallest approach {info['min_approach']:.3f} K, " + f"margin over the requirement {info['min_approach_margin']:.2g} K") + HXN.approach_contributions = None + # [end:contributions] + assert info['min_approach_margin'] >= -1e-6 - 1e-9 with capturing('ch04_ten_streams'): # [start:ten_streams] bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) diff --git a/docs/_demo_src/examples/ch05_targeting.py b/docs/_demo_src/examples/ch05_targeting.py new file mode 100644 index 0000000..1a02612 --- /dev/null +++ b/docs/_demo_src/examples/ch05_targeting.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- +# hensmith: Heat Exchanger Network Synthesis, Modeling, Integration, +# Thermodynamics, and Heuristics +# Copyright (C) 2020-, Sarang Bhagwat +# +# This module is under the UIUC open-source license. See +# github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt +# for license details. +"""Tutorial chapter 05 (docs/source/tutorial/05_targeting.rst): targeting and +supertargeting with ``hensmith.targeting`` -- the facility's targets +(``HXN.targets()``), the multiple-utility targets priced by biosteam, the +composite, balanced composite and grand composite curves drawn by the +library, the area, units, capital and total annualized cost targets, the +``T_min_app`` sweep for the least total annualized cost (on the quickstart +system and on a two-stream problem with an interior optimum), and the report +that compares the synthesized network with its targets. Regions between +``# [start:x]`` / ``# [end:x]`` are literalinclude'd by the page; everything +else is plumbing. + + python docs/_demo_src/examples/ch05_targeting.py +""" +import sys +import pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +import _common # noqa: E402 (sets NUMBA_DISABLE_JIT / DISABLE_PREFERENCES, Agg) +from _common import capturing, save + + +# [start:imports] +import numpy as np +import matplotlib.pyplot as plt +from hensmith import (supertarget, network_report, plot_composite_curves, + plot_grand_composite_curve, plot_supertargets) +# [end:imports] + + +def knots(T, H): + """Knots [K, kJ/hr] of a polyline without its zero-length steps and + without the points inside a straight run, so that two polylines tracing + the same curve on different grids compare knot by knot.""" + T, H = np.asarray(T, float), np.asarray(H, float) + step = np.r_[True, (np.abs(np.diff(T)) > 1e-9) | (np.abs(np.diff(H)) > 1e-6)] + T, H = T[step], H[step] + if T.size < 3: return T, H + a, b, c = slice(None, -2), slice(1, -1), slice(2, None) + cross = (T[b] - T[a]) * (H[c] - H[a]) - (H[b] - H[a]) * (T[c] - T[a]) + bend = np.abs(cross) > 1e-9 * np.abs(T[c] - T[a]) * np.abs(H[c] - H[a]) + keep = np.r_[True, bend, True] + return T[keep], H[keep] + + +def main(): + # [start:system] + import biosteam as bst + from hensmith import HeatExchangerNetwork + + bst.settings.set_thermo(['Water', 'Methanol', 'Glycerol'], cache=True) + bst.main_flowsheet.set_flowsheet('quickstart') + feed1 = bst.Stream('feed1', flow=(8000, 100, 25)) + feed2 = bst.Stream('feed2', flow=(10000, 1000, 10)) + D1 = bst.ShortcutColumn('D1', ins=feed1, + outs=('distillate', 'bottoms_product'), + LHK=('Methanol', 'Water'), + y_top=0.99, x_bot=0.01, k=2, + is_divided=True) + D1_H1 = bst.HXutility('D1_H1', ins=D1.outs[1], T=300) + D1_H2 = bst.HXutility('D1_H2', ins=D1.outs[0], T=300) + F1 = bst.Flash('F1', ins=feed2, outs=('vapor', 'liquid'), V=0.9, P=101325) + HXN = HeatExchangerNetwork('HXN', T_min_app=5.) + sys = bst.System.from_units('sys', units=[D1, D1_H1, D1_H2, F1, HXN]) + sys.simulate() + # [end:system] + with capturing('ch05_targets'): + # [start:targets] + targets = HXN.targets() # the facility's streams at its T_min_app + print(targets) + print('streams:', targets.labels) + print(f'shifted pinch: {targets.pinch_T:.2f} K') + info = HXN.synthesis_info + print('equal to the synthesis targets:', + targets.Q_hot == info['Q_hot_target'], targets.Q_cold == info['Q_cold_target']) + print(f'heating utility of the network: {HXN.actual_heat_util_load:.4g} kJ/hr') + # [end:targets] + with capturing('ch05_utilities'): + # [start:utilities] + utilities = targets.utility_targets() # biosteam's default agents + print('agent duty utility duty level process T [K] cost') + print(' [kJ/hr] [kJ/hr] [K] in -> out [USD/hr]') + for u in utilities: + print(f'{u.ID:<18} {u.duty:<10.4g} {u.utility_duty:<12.4g} {u.level:<6.2f} ' + f'{u.T_in:.2f} -> {u.T_out:.2f} {u.cost:.2f}') + print(f'utility cost target: {sum(u.cost for u in utilities):.2f} USD/hr') + for u in utilities: + print(f'{u.ID}: {u.flow:.4g} kmol/hr, from {u.T_utility_in:.2f} to {u.T_utility_out:.2f} K') + # [end:utilities] + # [start:composites] + fig, ax = plot_composite_curves(targets) + fig_balanced, ax_balanced = plot_composite_curves(targets, kind='balanced', + utilities=utilities) + # [end:composites] + save(fig, 'tutorial_05_composite_curves.png') + save(fig_balanced, 'tutorial_05_balanced_composites.png') + plt.close(fig); plt.close(fig_balanced) + # [start:gcc] + fig, ax = plot_grand_composite_curve(targets, utilities=utilities) + # [end:gcc] + save(fig, 'tutorial_05_grand_composite.png') + plt.close(fig) + with capturing('ch05_summary'): + # [start:supertargets] + print('units:', targets.units_target(utilities)) + capital = targets.capital_cost_target(utilities) + print(f"{capital['N_area_shells']} of {capital['N_shells']} shells share the area: " + f"{capital['shell_area']:.4g} m2 each, {capital['type']}") + summary = targets.summary() # 10 % over 10 years; 8000 h/yr (no system hours) + for key, value in summary.items(): # numbers, and the shell type + print(f'{key:<24} {value}' if isinstance(value, str) else f'{key:<24} {value:.6g}') + # [end:supertargets] + with capturing('ch05_sweep'): + # [start:sweep] + sweep = supertarget(targets, np.arange(1., 31.)) + print(sweep) + print('T_min_app Q_hot Q_cold area units installed capital operating TAC') + print('[K] [kJ/hr] [kJ/hr] [m2] [USD] [USD/yr] [USD/yr] [USD/yr]') + optimum = sweep.optimum + after = sweep.records[sweep.records.index(optimum) + 1] # the next point evaluated + for r in sweep.records: + if r['T_min_app'] in (1., 2., 5., 10., 15., 20., 25., 30.) or r is optimum or r is after: + print(f"{r['T_min_app']:<9.4g} {r['Q_hot']:<9.4g} {r['Q_cold']:<9.4g} " + f"{r['area']:<6.0f} {r['N_units']:<5d} {r['installed_cost']:<9.4g} " + f"{r['annualized_capital_cost']:<9.4g} {r['operating_cost']:<9.4g} {r['TAC']:.0f}") + print(f'optimum {sweep.T_min_app:.4g} K, at the bound of the sweep: {sweep.at_bound}') + fig, ax = plot_supertargets(sweep) + # [end:sweep] + save(fig, 'tutorial_05_supertargets.png') + plt.close(fig) + with capturing('ch05_trade_off'): + # [start:trade_off] + import thermosteam as tmo + from hensmith import PinchTargets + fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., + Cn=3.6, default=True) # 1000 kmol/hr: 1 kW/K + bst.settings.set_thermo([fluid]) + bst.main_flowsheet.set_flowsheet('trade_off') + H1 = bst.HXutility('H1', ins=bst.Stream('H1_in', Fluid=1e5, T=400.), + T=320., rigorous=False) # 100 kW/K, 400 -> 320 K + C1 = bst.HXutility('C1', ins=bst.Stream('C1_in', Fluid=1e5, T=310.), + T=390., rigorous=False) # 100 kW/K, 310 -> 390 K + H1.simulate(); C1.simulate() + pair = PinchTargets.from_heat_utilities([H1.heat_utilities[0], C1.heat_utilities[0]]) + water = bst.HeatUtility.get_heating_agent('low_pressure_steam').thermo + steam = bst.UtilityAgent('steam_450K', Water=1, T=450., P=1e6, phase='g', + heat_transfer_price=4e-7, thermo=water) + refrigerant = bst.UtilityAgent('water_280K', Water=1, T=280., P=1e3, phase='l', + heat_transfer_price=2e-7, thermo=water) + pair_sweep = supertarget(pair, np.arange(11., 61.), heating_agents=[steam], + cooling_agents=[refrigerant]) + print(pair_sweep) + for key in ('Q_hot', 'area', 'N_units', 'installed_cost', 'annualized_capital_cost', + 'operating_cost', 'TAC'): + print(f'{key:<24} {pair_sweep.optimum[key]:.6g}') + fig, ax = plot_supertargets(pair_sweep) + # [end:trade_off] + save(fig, 'tutorial_05_trade_off.png') + plt.close(fig) + with capturing('ch05_report'): + # [start:report] + report = network_report(HXN) + target = report['targets'] # targets.summary() with the same options + print(' network target') + print(f"utility cost [USD/hr] {report['utility_cost']:<10.2f} {target['utility_cost']:.2f}") + print(f"units {report['N_units']:<10d} {target['N_units']}") + print(f"area [m2] {report['area']:<10.0f} {target['area']:.0f}") + print(f"area, biosteam [m2] {report['area_biosteam']:.0f}") + print(f"installed cost [USD] {report['installed_cost']:<10.4g} {target['installed_cost']:.4g}") + print(f"TAC [USD/yr] {report['TAC']:<10.0f} {target['TAC']:.0f}") + print('exchanger area biosteam [m2]') + for ID, (area, area_biosteam) in report['exchanger_areas'].items(): + print(f'{ID:<11} {area:<5.0f} {area_biosteam:.0f}') + # [end:report] + # plumbing checks: the library's curves are chapter 2's + from examples.ch02_pinch_analysis import composite_curves, grand_composite + ours = targets.composite_curves() + theirs = composite_curves(targets.table, T_min_app=5.) + for (T, H), (T2, H2) in zip((ours[:2], ours[2:]), (theirs[:2], theirs[2:])): + (T, H), (T2, H2) = knots(T, H), knots(T2, H2) + assert T.size == T2.size, (T.size, T2.size) + assert np.allclose(T, T2, rtol=0, atol=1e-6) and np.allclose(H, H2, rtol=1e-9, atol=1e-3) + T, H = targets.grand_composite_curve() + H2, T2 = grand_composite(targets.table) + assert np.array_equal(T, T2) and np.array_equal(H, H2) + assert targets.Q_hot == info['Q_hot_target'] and targets.Q_cold == info['Q_cold_target'] + assert report['Q_hot'] >= targets.Q_hot * (1 - 1e-9) and report['status'] == 'mer' + assert abs(report['utility_cost'] - target['utility_cost']) < 1e-6 * target['utility_cost'] + assert abs(pair_sweep.T_min_app - 21.595) <= 0.02 and not pair_sweep.at_bound + + +if __name__ == '__main__': + main() diff --git a/docs/source/API/api.rst b/docs/source/API/api.rst index f3803ee..d7d2be5 100644 --- a/docs/source/API/api.rst +++ b/docs/source/API/api.rst @@ -3,10 +3,13 @@ API Reference .. currentmodule:: hensmith -hensmith exports six names. :class:`HeatExchangerNetwork` is the BioSTEAM -facility most users need; the functions and classes of +hensmith exports thirteen names. :class:`HeatExchangerNetwork` is the +BioSTEAM facility most users need; the functions and classes of ``hensmith.hxn_synthesis`` are the pinch analysis and synthesis machinery -behind it, usable on their own. +behind it, usable on their own; and those of ``hensmith.targeting`` target +utilities, area, units, capital and total annualized cost, find the +``T_min_app`` of the least targeted total annualized cost, and compare a +network with its targets. .. autosummary:: @@ -16,12 +19,20 @@ behind it, usable on their own. synthesize_network StreamLifeCycle plot_pinch_diagram + PinchTargets + supertarget + Supertargets + network_report + plot_composite_curves + plot_grand_composite_curve + plot_supertargets .. toctree:: :maxdepth: 2 heat_exchanger_network hxn_synthesis + targeting .. note:: diff --git a/docs/source/API/heat_exchanger_network.rst b/docs/source/API/heat_exchanger_network.rst index 8804657..1993a9b 100644 --- a/docs/source/API/heat_exchanger_network.rst +++ b/docs/source/API/heat_exchanger_network.rst @@ -13,7 +13,11 @@ reports the utility loads and capital cost that result. The original units, streams and heat exchangers are left untouched: the stream copies and synthesized exchangers live in a separate flowsheet named ``_HXN``. See :doc:`../tutorial/index` for a worked example and :doc:`../concepts` for the -method. +method. :meth:`~HeatExchangerNetwork.targets` returns the targets of the +facility's streams (a :class:`PinchTargets`: utilities, area, units, capital +and total annualized cost) without synthesizing anything, and +:func:`network_report` compares the synthesized network with them +(:doc:`targeting`). .. autoclass:: HeatExchangerNetwork :no-members: @@ -21,12 +25,16 @@ method. .. automethod:: HeatExchangerNetwork.plot_pinch_diagram +.. automethod:: HeatExchangerNetwork.targets + Constructor options ------------------- -In addition to ``ID``, ``T_min_app`` and ``units`` documented above, the -constructor accepts the following options; :doc:`../tutorial/04_configuring` -shows what each of them changes. +In addition to ``ID``, ``T_min_app`` (the default pair requirement: each +stream contributes ``T_min_app / 2`` unless given its own contribution with +``approach_contributions``) and ``units`` documented above, the constructor +accepts the following options; :doc:`../tutorial/04_configuring` shows what +each of them changes. .. list-table:: :header-rows: 1 @@ -46,7 +54,7 @@ shows what each of them changes. - Run the analysis on stream copies with ideal thermodynamics; the synthesized exchangers inherit that thermo. Defaults to False. * - ``cache_network`` - bool - - Reuse the network configuration of the previous simulation when the set of units contributing heat utilities and ``stream_splitting`` are unchanged, updating only stream states and exchanger specifications: each process exchanger keeps the fraction of its stream's duty at which its enthalpy limit sat at synthesis (the splitters keep their branch fractions), and the utility exchangers bring every stream to its new outlet. The reused network is not planned again, so it need not be at MER for the new duties. Defaults to False. + - Reuse the network configuration of the previous simulation when the units contributing heat utilities (each as often as it gives a stream, as a unit in parallel does once per unit), ``stream_splitting`` and the explicit approach contributions (resolved per stream) are unchanged, updating only stream states and exchanger specifications: each process exchanger keeps the fraction of its stream's duty at which its enthalpy limit sat at synthesis (the splitters keep their branch fractions), and the utility exchangers bring every stream to its new outlet. The reused network is not planned again, so it need not be at MER for the new duties. Defaults to False. * - ``avoid_recycle`` - bool - Never match the same hot/cold stream pair twice anywhere (on one side of the pinch or across the two), so that no two exchangers connect the same pair and form a recycle loop; this forbids the repeated matches some unsplit MER networks need. Defaults to False. @@ -62,6 +70,9 @@ shows what each of them changes. * - ``stream_splitting`` - bool - Allow a process stream to be split into parallel branches that re-join. A side of the pinch that no unsplit network serves at MER is planned with splits and reaches the targets exactly on the planner's knots; sides that an unsplit network serves are never split, so a problem that needs no split gets the same network as with the default. Each split is a chain of ``Splitter`` units and a rigorous ``Mixer`` (adiabatic, no cost), listed in ``new_splitters`` and ``new_mixers``. With ``avoid_recycle``, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Stored as an attribute of the same name, and part of the ``cache_network`` key. Defaults to False. + * - ``approach_contributions`` + - Mapping or callable + - Approach temperature contribution of each process stream, in K: a match between hot stream *h* and cold stream *c* must keep :math:`c_h + c_c` instead of ``T_min_app`` everywhere inside it, and a stream without a contribution contributes ``T_min_app / 2``; the utility exchangers keep BioSTEAM's own approach rule. A mapping's keys name units -- a unit, the ID of a unit without an owner (``'D1'``), or ``(owner.ID, unit.ID)`` for an auxiliary unit (``('D1', 'condenser')``; with the top owner, so it names every auxiliary of that ID under it) -- and a heat utility takes the value of the key nearest to its unit along the unit's chain of owners (two keys of the same level must agree); ID keys stay valid when a unit rebuilds its auxiliaries, and heat utility keys are rejected (``TypeError``). A callable ``f(hu)`` is called once per heat utility at every simulation and should be pure; a sequence raises a ``TypeError``. A value is None or nan (unspecified, an explicit default at its level) or a finite number > 0. Keys and values are checked against every available unit and its auxiliaries before any filtering, so an unknown key, a bad value or a conflict raises a ``ValueError`` even while its unit is idle, but a valid key whose unit holds no heat utility in a simulation has no effect, as does a key of an auxiliary that its unit may build but does not build in that simulation (``('X1', 'heat_exchangers[2]')`` of a ``biosteam.HXutilities`` that needs fewer exchangers, say, or ``('K1', 'K1_H3')`` of a ``biosteam.MultistageCompressor`` K1 with 2 stages). The resolved values are part of the ``cache_network`` key. May be changed between simulations. Defaults to None (every stream contributes ``T_min_app / 2``, so every pair requires ``T_min_app``). Class attributes ---------------- @@ -123,7 +134,7 @@ the cached network. - Percent deviation from one of the ratio (twice the duty of each process exchanger, plus the new utility duties weighted by their agents' heat-transfer efficiency) / (the original utility duties weighted the same way), as computed in ``_cost``. * - ``synthesis_info`` - dict - - The synthesis report (see the ``info`` keyword of :func:`synthesize_network`): ``'status'`` is ``'mer'`` when the network's utilities equal the MER targets and ``'best_effort'`` otherwise; next to it the targets, the planned and realized utilities, the penalty, per side of the pinch any proof that a split is needed, and the smallest approach inside any process exchanger. With ``stream_splitting``, also ``'stream_splitting'``, ``'splits'`` (the realized splits, :class:`~hensmith.hxn_synthesis.StreamSplit`), ``'split_deviations'`` and, per side, the split candidate chosen (``'split'``). Kept from the synthesis that produced a cached network. + - The synthesis report (see the ``info`` keyword of :func:`synthesize_network`): ``'status'`` is ``'mer'`` when the network's utilities equal the MER targets and ``'best_effort'`` otherwise; next to it the targets, the planned and realized utilities, the penalty, per side of the pinch any proof that a split is needed, the smallest approach inside any process exchanger (``'min_approach'``) and the smallest margin of an exchanger's approach over its pair's required approach (``'min_approach_margin'``, no less than -1e-6 K, the exact check's rounding guard), and each stream's approach contribution as used (``'approach_contributions'``, ``T_min_app / 2`` where none was given, in stream order). With ``stream_splitting``, also ``'stream_splitting'``, ``'splits'`` (the realized splits, :class:`~hensmith.hxn_synthesis.StreamSplit`), ``'split_deviations'`` and, per side, the split candidate chosen (``'split'``). Kept from the synthesis that produced a cached network. * - ``stream_life_cycles`` - list[StreamLifeCycle] - Ordered sequence of exchangers each stream passes through, aligned with ``original_heat_exchangers``; a split stream's life cycle also lists its splits and marks each branch stage with its branch and flow fraction. @@ -159,7 +170,7 @@ the cached network. - The flowsheet ``_HXN`` holding the network's stream copies and exchangers. * - ``pinch_Ts`` - ndarray, K - - Per-stream pinch temperature (informational): the process pinch on the stream's own scale when the stream crosses it, else its inlet temperature (inlet already past the pinch, or an isothermal or non-monotone stream) or its outlet temperature (stream ending before the pinch). + - Per-stream pinch temperature (informational): the shifted pinch plus the stream's shift (``T_min_app`` for a hot stream and 0 for a cold one by default; ``T_min_app / 2 + c_j`` for a hot and ``T_min_app / 2 - c_j`` for a cold stream with approach contributions) when the stream crosses it, else its inlet temperature (inlet already past the pinch, or an isothermal or non-monotone stream) or its outlet temperature (stream ending before the pinch). * - ``inlet_Ts`` - ndarray, K - Inlet temperature of each stream. diff --git a/docs/source/API/targeting.rst b/docs/source/API/targeting.rst new file mode 100644 index 0000000..61bfa45 --- /dev/null +++ b/docs/source/API/targeting.rst @@ -0,0 +1,71 @@ +Targeting and supertargeting (hensmith.targeting) +================================================= + +.. currentmodule:: hensmith + +``hensmith.targeting`` says 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. +:class:`PinchTargets` holds the problem table of the streams and derives from +it the composite and grand composite curves, the targets of several utilities +priced by biosteam (:class:`~hensmith.targeting.UtilityTarget`), the balanced +composite curves, and the targets of area, units, capital cost and total +annualized cost. :func:`supertarget` sweeps the minimum approach temperature +for the least total annualized cost and returns :class:`Supertargets`; +:func:`network_report` compares a synthesized network with its targets; and +:func:`plot_composite_curves`, :func:`plot_grand_composite_curve` and +:func:`plot_supertargets` draw them. A facility's targets come from +:meth:`HeatExchangerNetwork.targets`. Nothing here changes the synthesis. +:doc:`../tutorial/05_targeting` shows every function on the quickstart system, +and :doc:`../concepts` explains the methods and their limits. + +.. autoclass:: PinchTargets + :no-members: + +.. automethod:: PinchTargets.from_heat_utilities + +.. automethod:: PinchTargets.at + +.. automethod:: PinchTargets.composite_curves + +.. automethod:: PinchTargets.grand_composite_curve + +.. automethod:: PinchTargets.utility_targets + +.. automethod:: PinchTargets.balanced_composite_curves + +.. automethod:: PinchTargets.balanced_grand_composite_curve + +.. automethod:: PinchTargets.area_target + +.. automethod:: PinchTargets.units_target + +.. automethod:: PinchTargets.capital_cost_target + +.. automethod:: PinchTargets.summary + +.. autoclass:: hensmith.targeting.UtilityTarget + :no-members: + +.. autofunction:: supertarget + +.. autoclass:: Supertargets + :no-members: + +.. automethod:: Supertargets.to_frame + +.. autofunction:: network_report + +.. autofunction:: plot_composite_curves + +.. autofunction:: plot_grand_composite_curve + +.. autofunction:: plot_supertargets + +.. note:: + + :class:`~hensmith.targeting.UtilityTarget` is not exported by + ``hensmith``; it is documented because + :meth:`PinchTargets.utility_targets` returns it. The private helpers of + ``hensmith.targeting`` (those whose names start with an underscore) are not + part of the supported API. diff --git a/docs/source/_generated/ch04_contributions.txt b/docs/source/_generated/ch04_contributions.txt new file mode 100644 index 0000000..b5eb048 --- /dev/null +++ b/docs/source/_generated/ch04_contributions.txt @@ -0,0 +1,7 @@ +status: mer, 3 process exchangers, 1.449e+06 USD added installed cost +heating 2.967e+08 kJ/hr, cooling 1.014e+06 kJ/hr +contributions by stream [K]: [1. 1. 2.5 1. 2.5] +HX_1_2_hs requires 3.5 K, Q = 4.303e+07 kJ/hr +HX_0_4_hs requires 3.5 K, Q = 3.338e+04 kJ/hr +HX_0_3_hs requires 2.0 K, Q = 1.794e+07 kJ/hr +smallest approach 3.500 K, margin over the requirement -1.3e-12 K diff --git a/docs/source/_generated/ch05_report.txt b/docs/source/_generated/ch05_report.txt new file mode 100644 index 0000000..7ee3b63 --- /dev/null +++ b/docs/source/_generated/ch05_report.txt @@ -0,0 +1,15 @@ + network target +utility cost [USD/hr] 1839.61 1839.61 +units 7 6 +area [m2] 7462 5946 +area, biosteam [m2] 5536 +installed cost [USD] 2.184e+06 2.282e+06 +TAC [USD/yr] 15072295 15088200 +exchanger area biosteam [m2] +HX_1_2_hs 466 495 +HX_0_2_hs 3097 3101 +HX_1_4_hs 1 1 +HX_1_3_hs 460 459 +Util_2_cs 101 103 +Util_0_hs 356 177 +Util_1_hs 2983 1200 diff --git a/docs/source/_generated/ch05_summary.txt b/docs/source/_generated/ch05_summary.txt new file mode 100644 index 0000000..8b28166 --- /dev/null +++ b/docs/source/_generated/ch05_summary.txt @@ -0,0 +1,17 @@ +units: {'above': 5, 'below': 1, 'total': 6} +6 of 6 shells share the area: 991 m2 each, Floating head +T_min_app 5 +Q_hot 2.82781e+08 +Q_cold 1.93588e+06 +utility_cost 1839.61 +area 5946.13 +N_units 6 +N_shells 6 +N_area_shells 6 +shell_area 991.022 +shell_type Floating head +purchase_cost 710660 +installed_cost 2.28172e+06 +annualized_capital_cost 371339 +operating_cost 1.47169e+07 +TAC 1.50882e+07 diff --git a/docs/source/_generated/ch05_sweep.txt b/docs/source/_generated/ch05_sweep.txt new file mode 100644 index 0000000..bce0eca --- /dev/null +++ b/docs/source/_generated/ch05_sweep.txt @@ -0,0 +1,14 @@ + +T_min_app Q_hot Q_cold area units installed capital operating TAC +[K] [kJ/hr] [kJ/hr] [m2] [USD] [USD/yr] [USD/yr] [USD/yr] +1 2.808e+08 0 6836 5 2.482e+06 4.04e+05 1.454e+07 14943217 +1.849 2.808e+08 0 6836 5 2.482e+06 4.04e+05 1.454e+07 14943211 +1.853 2.808e+08 2049 6835 6 2.555e+06 4.158e+05 1.454e+07 14955188 +2 2.809e+08 9.219e+04 6759 6 2.532e+06 4.12e+05 1.455e+07 14959665 +5 2.828e+08 1.936e+06 5946 6 2.282e+06 3.713e+05 1.472e+07 15088200 +10 2.859e+08 5.009e+06 5440 6 2.127e+06 3.461e+05 1.5e+07 15344973 +15 2.9e+08 9.138e+06 5158 9 2.269e+06 3.693e+05 1.533e+07 15694680 +20 2.975e+08 1.662e+07 5073 9 2.243e+06 3.651e+05 1.573e+07 16093036 +25 3.049e+08 2.41e+07 5042 9 2.234e+06 3.635e+05 1.613e+07 16498447 +30 3.124e+08 3.157e+07 5042 9 2.234e+06 3.635e+05 1.654e+07 16904956 +optimum 1.849 K, at the bound of the sweep: False diff --git a/docs/source/_generated/ch05_targets.txt b/docs/source/_generated/ch05_targets.txt new file mode 100644 index 0000000..69a1df1 --- /dev/null +++ b/docs/source/_generated/ch05_targets.txt @@ -0,0 +1,5 @@ + +streams: ['D1 - reboiler', 'F1 - heat_exchanger', 'D1_H1', 'D1 - condenser', 'D1_H2'] +shifted pinch: 298.15 K +equal to the synthesis targets: True True +heating utility of the network: 2.977e+08 kJ/hr diff --git a/docs/source/_generated/ch05_trade_off.txt b/docs/source/_generated/ch05_trade_off.txt new file mode 100644 index 0000000..107b63d --- /dev/null +++ b/docs/source/_generated/ch05_trade_off.txt @@ -0,0 +1,8 @@ + +Q_hot 4.17312e+06 +area 719.867 +N_units 3 +installed_cost 439992 +annualized_capital_cost 71606.7 +operating_cost 20031 +TAC 91637.6 diff --git a/docs/source/_generated/ch05_utilities.txt b/docs/source/_generated/ch05_utilities.txt new file mode 100644 index 0000000..f862e20 --- /dev/null +++ b/docs/source/_generated/ch05_utilities.txt @@ -0,0 +1,7 @@ +agent duty utility duty level process T [K] cost + [kJ/hr] [kJ/hr] [K] in -> out [USD/hr] +low_pressure_steam 2.828e+08 2.977e+08 407.19 298.15 -> 372.60 1829.93 +chilled_water 1.936e+06 1.936e+06 282.37 303.15 -> 300.00 9.68 +utility cost target: 1839.61 USD/hr +low_pressure_steam: 7695 kmol/hr, from 412.19 to 412.19 K +chilled_water: 1443 kmol/hr, from 280.37 to 298.15 K diff --git a/docs/source/_static/images/examples/tutorial_05_balanced_composites.png b/docs/source/_static/images/examples/tutorial_05_balanced_composites.png new file mode 100644 index 0000000..d139382 Binary files /dev/null and b/docs/source/_static/images/examples/tutorial_05_balanced_composites.png differ diff --git a/docs/source/_static/images/examples/tutorial_05_composite_curves.png b/docs/source/_static/images/examples/tutorial_05_composite_curves.png new file mode 100644 index 0000000..5f02702 Binary files /dev/null and b/docs/source/_static/images/examples/tutorial_05_composite_curves.png differ diff --git a/docs/source/_static/images/examples/tutorial_05_grand_composite.png b/docs/source/_static/images/examples/tutorial_05_grand_composite.png new file mode 100644 index 0000000..9997d4d Binary files /dev/null and b/docs/source/_static/images/examples/tutorial_05_grand_composite.png differ diff --git a/docs/source/_static/images/examples/tutorial_05_supertargets.png b/docs/source/_static/images/examples/tutorial_05_supertargets.png new file mode 100644 index 0000000..70100cb Binary files /dev/null and b/docs/source/_static/images/examples/tutorial_05_supertargets.png differ diff --git a/docs/source/_static/images/examples/tutorial_05_trade_off.png b/docs/source/_static/images/examples/tutorial_05_trade_off.png new file mode 100644 index 0000000..e0db014 Binary files /dev/null and b/docs/source/_static/images/examples/tutorial_05_trade_off.png differ diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst index 0d24b7a..f8fb788 100644 --- a/docs/source/concepts.rst +++ b/docs/source/concepts.rst @@ -5,13 +5,15 @@ Key Concepts This page collects the ideas hensmith implements: what a minimum approach temperature is, how the problem table turns a set of process streams into -utility targets and a pinch, how :func:`~hensmith.synthesize_network` turns -those targets into a network of exchangers, and what the result is and is not +utility targets and a pinch, how those targets extend to several utilities, +area, units and cost, how :func:`~hensmith.synthesize_network` turns the +targets into a network of exchangers, and what the result is and is not guaranteed to be. Everything below describes the behavior of the code in -``hensmith/hxn_synthesis.py`` and ``hensmith/_heat_exchanger_network.py``, and -of their two private helpers, ``hensmith/_curves.py`` (the stream curves) and -``hensmith/_planner.py`` (the network planner); the :doc:`tutorial/index` -shows the same concepts on a running system. +``hensmith/hxn_synthesis.py``, ``hensmith/_heat_exchanger_network.py`` and +``hensmith/targeting.py``, and of two private helpers, +``hensmith/_curves.py`` (the stream curves) and ``hensmith/_planner.py`` (the +network planner); the :doc:`tutorial/index` shows the same concepts on a +running system. Heat integration and the minimum approach temperature ----------------------------------------------------- @@ -33,7 +35,8 @@ used to shift hot streams in the problem table and required between the streams everywhere inside every synthesized exchanger: - in :func:`~hensmith.problem_table`, where every hot stream's temperature is - shifted *down* by ``T_min_app`` before the streams are compared, so that two + shifted *down* by ``T_min_app`` (with approach contributions, every stream + by its own shift; see below) before the streams are compared, so that two streams which meet on the shifted scale are really ``T_min_app`` apart; - in the planner of :func:`~hensmith.synthesize_network`, which admits a match only if the two streams stay at least ``T_min_app`` apart at every @@ -41,8 +44,8 @@ streams everywhere inside every synthesized exchanger: not only at its two ends; and - on every synthesized process exchanger, whose approach is verified on the exact states of its two streams, again along its whole length. The - exchanger itself is a ``biosteam.HXprocess`` constructed with - ``dT=T_min_app - 1e-6``, a guard against rounding only. + exchanger itself is a ``biosteam.HXprocess`` constructed with ``dT`` equal + to its pair's required approach minus 1e-6, a guard against rounding only. Lowering ``T_min_app`` lowers the utility targets and raises exchanger area; raising it does the reverse. It is the parameter in hensmith that sets the @@ -76,7 +79,8 @@ outlet, for instance); what a non-equilibrium end state departs from equilibrium at its own end temperature becomes a flat there. **The shifted grid.** Hot streams are shifted down by ``T_min_app``; cold -streams are not. The grid ``Ts`` is the sorted (descending) union of the +streams are not (with approach contributions, every stream by its own shift; +see below). The grid ``Ts`` is the sorted (descending) union of the shifted breakpoints of all curves, temperatures closer than 1e-9 K being one grid point. Every temperature at which any stream's curve bends or jumps is therefore a grid point, and between two consecutive grid points every stream @@ -174,6 +178,180 @@ demand of the whole set of streams. That identity is the table's own consistency check -- whatever the algorithm does with the cascade, it can neither invent nor destroy energy. +Approach temperature contributions +---------------------------------- + +A single ``T_min_app`` holds every match to the same driving-force floor, +whether its streams condense and boil with high film coefficients or are +gases with low ones. Each stream :math:`j` may therefore carry its own +*approach temperature contribution* :math:`c_j`, in K, as described by Kemp +(2007): a process match between hot stream :math:`h` and cold stream :math:`c` +must keep :math:`c_h + c_c` everywhere inside it, on the exact states, and a +stream without a contribution contributes ``T_min_app / 2``, so that without +contributions every pair requires ``T_min_app`` exactly as before. A small +contribution suits a condensing or boiling stream, a large one a gas. +:func:`~hensmith.problem_table` takes the contributions as a sequence aligned +with its streams, :func:`~hensmith.synthesize_network` as a sequence aligned +with its heat utilities, a mapping or a callable, and +:class:`~hensmith.HeatExchangerNetwork` as a mapping or a callable (see +below). + +**The shift.** Writing :math:`\Delta T_{min}` for ``T_min_app``, every stream +is shifted by its own amount, :math:`T^* = T - \mathrm{shift}_j`, with + +.. math:: + + \mathrm{shift}_h = \tfrac{1}{2} \Delta T_{min} + c_h \quad \text{(hot)}, + \qquad + \mathrm{shift}_c = \tfrac{1}{2} \Delta T_{min} - c_c \quad \text{(cold)}, + +so that + +.. math:: + + T^*_h \ge T^*_c \iff T_h - T_c \ge \mathrm{shift}_h - \mathrm{shift}_c + = c_h + c_c. + +Two streams that meet on the shifted scale are therefore exactly their pair's +required approach apart, and the problem table above is built on that scale +with no other change. A contribution is None or nan (unspecified) or a finite +number greater than zero. Zero is excluded so that every pair keeps a +positive driving force: a match allowed to touch would have none at its pinch +and need an infinite area. (The ``HXprocess`` guard is the requirement less +1e-6 K, so a pair whose contributions sum to 1e-6 K or less still gets a +guard of zero or less, as ``T_min_app <= 1e-6`` K does by default.) Whenever +a contribution is given, ``T_min_app`` must be finite and positive as well, +since it sets the default ``T_min_app / 2``; without contributions, or with +every one unspecified, ``T_min_app = 0`` is accepted as before. + +**The default is unchanged, bit for bit.** An unspecified stream gets +exactly the default shifts, ``T_min_app`` if hot and 0 if cold +(:math:`x/2 + x/2 = x` and :math:`x/2 - x/2 = 0` hold exactly in floating +point), and with nothing specified the code takes the old expression +literally. A run without contributions, with every contribution unspecified +or with every one equal to ``T_min_app / 2`` gives the same targets and the +same network, number for number. + +**One shifted scale for the planner.** The planner's search sees one curve +per stream on the shifted scale, not the shifts themselves, and on that scale +a match is feasible exactly when its real streams keep their pair's +requirement. The MER targets, the pinch design rules, the remaining problem +analysis and the guarantee of `Stream splitting`_ below therefore carry over +unchanged; a branch is its parent's curve with every heat scaled by its +fraction, so it keeps its parent's shift. + +**Per pair on the real scale.** What works on real temperatures uses the +pair's own value. A pair's required approach is +:math:`\mathrm{shift}_h - \mathrm{shift}_c` -- equal to :math:`c_h + c_c` up +to round-off, and to ``T_min_app`` exactly without contributions. The exact +approach verification and the repair described below enforce it, each +``HXprocess`` gets it less 1e-6 K as its ``dT``, and +``synthesis_info['min_approach_margin']`` reports the smallest margin of any +exchanger's exact approach over its own pair's requirement (no less than +-1e-6 K, the check's rounding guard), next to ``'min_approach'``, the +smallest approach itself. Wherever this page says that a match keeps +``T_min_app``, read its pair's required approach. + +**Per-stream pinch temperatures.** The pinch of the problem table, and the +``'pinch_T'`` of ``synthesis_info['plan_targets']``, stay on the shifted +scale. Stream :math:`j`'s own pinch temperature is that pinch plus +:math:`\mathrm{shift}_j` -- ``T_min_app`` above it for a hot stream and at +it for a cold one by default -- and that is what the facility's ``pinch_Ts`` +reports. The real composite curves no longer keep one fixed distance: they +can come as close as the smallest hot plus the smallest cold contribution, +and only the shifted curves carry the pinch. + +**Utilities are not affected.** The contributions apply to the process +matches only: each stream's utility exchanger gets its agent from BioSTEAM's +own rule (``HeatUtility.dT`` plus the agent's ``dT``), whatever the stream's +contribution. + +**Keys in the facility.** :class:`~hensmith.HeatExchangerNetwork` takes a +mapping or a callable, since the order of its streams is internal. A mapping's +keys name units: a unit object (matched by identity), the ID of a unit +without an owner (``'D1'``), or ``(owner.ID, unit.ID)`` for an auxiliary +unit (``('D1', 'condenser')``; a bare auxiliary ID such as ``'condenser'`` +names nothing). The ``owner`` of a pair is the top owner, so a pair names +every auxiliary of that ID under it, at any depth: if a unit holds two +auxiliary flash drums, each with its own ``heat_exchanger``, the pair +``('U', 'heat_exchanger')`` names both heaters, and the heater objects +or the drums (``('U', 'flash_a')``) tell them apart. A heat utility takes +the value of the key nearest to its unit along the unit's chain of owners, +so ``'D1'`` covers a column's condenser and reboiler and +``('D1', 'condenser')`` overrides it for the condenser. A None or nan value +is an explicit default that wins at its level like any value, and two keys +of the same level for a unit must agree. A unit's owner is its direct owner +(the unit it was made an auxiliary of; BioSTEAM's ``Unit.owner`` gives the +top one), or, for an auxiliary that a unit available to the network holds +without setting its owner, or an exchanger without one whose heat utilities +a unit takes without holding it as an auxiliary (a custom unit's +``self.heat_utilities = self.hx.heat_utilities``), that unit (the user's +objects are left as they are): ``biosteam.MultistageCompressor`` builds its +compressors and intercoolers anew, without an owner, whenever ``pr`` or +``n_stages`` changes, and ``'K1'`` still covers the intercoolers of +compressor K1 and ``('K1', 'K1_H1')`` names one in every sample, while +``'K1_H1'`` names nothing. Targets keep this relation as it was when they +were made (those of ``HXN.targets()`` as it was at the network's synthesis), +for their contributions, film coefficient keys and labels +(``'K1 - K1_H1'``): ``'K1'`` +still covers the intercoolers of targets stored in one sample after K1 has +rebuilt them in the next, or has been simulated alone. (Outside the +facility -- ``synthesize_network``, ``temperature_interval_pinch_analysis``, +``PinchTargets.from_heat_utilities`` without ``key_units=sys.units`` -- only +the heat utilities' units and their owners are at hand, so such an +intercooler has no owner there and ``'K1_H1'`` is its key.) ID +keys are the robust form: a unit that rebuilds its auxiliary units at every +simulation (``biosteam.HXutilities``, for one) leaves a key holding an old +auxiliary object, which then names the auxiliary that took its place: the +one of the same ID under owners of the same IDs, if any (an old intercooler +of K1, which never had an owner, names none and raises). Heat utilities +cannot be keys, +because BioSTEAM replaces them at every simulation; a value per heat +utility is given by a callable +``f(hu)`` instead, which is called once per heat utility at every simulation +and should therefore be pure (after a simulation, ``HXN.targets()`` takes the +values that simulation resolved instead of calling it again, since +``replace_unit_heat_utilities`` overwrites the heat utilities by then). The +keys and values are checked against every unit available to the network +and its auxiliary units before any heat utility is filtered out: a key that +names none of them, a bad value or two +conflicting keys raise a ``ValueError`` even while their unit is idle, but a +valid key whose unit holds no heat utility in a simulation (no duty, +``ignored``, ``hxn_ok=False``) has no effect, so that a Monte Carlo sample in +which a unit falls idle does not fail. The same holds for an auxiliary that a +unit may build but does not hold in a sample: ``biosteam.HXutilities``, for +one, builds one exchanger per agent it needs, so a lower outlet temperature +drops ``('X1', 'heat_exchangers[2]')``, and a ``biosteam.MultistageCompressor`` +K1 with two stages drops ``('K1', 'K1_H3')``: a key ``('K1', +'K1_')`` of the ID that K1 gives its stage n (ticket ``K`` for a +compressor, ``H`` for an intercooler) is valid for any stage number n >= 1, +and has no effect while K1 has fewer stages. A key of the ID that BioSTEAM's +``Unit.auxiliary`` gives an auxiliary under a name of the +``auxiliary_unit_names`` of the owner or of one of its auxiliaries is valid +whether or not that auxiliary is built, as is an old auxiliary object of a +unit at hand that none took the place of; while the auxiliary is missing, +the key has no effect. That ID is ``(owner.ID, 'name[k]')`` where the +attribute ``name`` holds a list of auxiliaries (as ``heat_exchangers`` of +``HXutilities`` does) and ``(owner.ID, name)`` where it holds a single one, +so ``('X1', 'heat_exchangers')`` or ``('D1', 'condenser[0]')`` still raise. +Not every unit gives its auxiliaries that ID, and where the attribute holds +a unit of another ID, the key raises: the heater of a +``biosteam.EnzymeTreatment`` T1 has the ID ``''``, so ``('T1', +'heat_exchanger')`` raises, with a message that gives the heater's key, +``('T1', '')`` (``'T1'`` covers it too). + +**The cache.** With ``cache_network=True``, a cached network is reused only +while the explicit contributions resolve to the same value for every stream +(see *Reusing a network* below). ``T_min_app`` is never part of the reuse +test, with or without explicit contributions, so changing it alone keeps a +cached network, and a reused network keeps the streams without a +contribution at the ``T_min_app / 2`` it was synthesized with (as does +``synthesis_info['approach_contributions']``). + +**Additive requirements only.** A requirement that belongs to a pair but is +not the sum of two stream terms cannot be written as one shift per stream, +so it has no problem table and is not supported. + Targets: MER, pinch, composite and grand composite curves --------------------------------------------------------- @@ -190,7 +368,9 @@ overlap horizontally, heat can pass from hot to cold: that overlap is the heat recovered by process exchange. The overhang of the cold curve at its warm end is the hot utility, and the overhang of the hot curve at its cold end is the cold utility. The place where the curves come closest vertically is the pinch, -and by construction they approach no closer than ``T_min_app``. +and by construction they approach no closer than ``T_min_app`` (with approach +contributions, no closer than the smallest hot plus the smallest cold +contribution; the shifted curves carry the pinch). .. figure:: /_static/images/examples/tutorial_02_composite_curves.png :class: white-bg @@ -203,7 +383,8 @@ and by construction they approach no closer than ``T_min_app``. utility of 1.936e+06 kJ/hr. The curves come closest at the cold end, where the pinch of this system lies -- 298.15 K on the shifted scale. :doc:`tutorial/02_pinch_analysis` builds this figure from a - :class:`~hensmith.ProblemTable`. + :class:`~hensmith.ProblemTable`; :doc:`tutorial/05_targeting` draws it + with the library. The **grand composite curve** plots the same cascade differently: the heat carried through each shifted grid temperature once the minimum hot utility is @@ -213,11 +394,285 @@ curve's minimum may be strictly positive and the pinch is placed at the top of the grid, ``Ts[0]``, by convention. Its shape shows where in the temperature range utility has to be added or removed. +Both curves are library functions: :class:`~hensmith.PinchTargets` returns +them (:meth:`~hensmith.PinchTargets.composite_curves`, on the real or the +shifted scale, and :meth:`~hensmith.PinchTargets.grand_composite_curve`), on +the knots of the table's own grid, so they reproduce the table to rounding; +:func:`~hensmith.plot_composite_curves` and +:func:`~hensmith.plot_grand_composite_curve` draw them. + Both pictures express the three rules of pinch design: **no heat may cross the pinch**, **no cold utility may be used above it**, and **no hot utility below it**. Violating any one of them makes the network use more of both utilities than the targets require, by the amount transferred across the pinch. +Multiple utilities on the grand composite curve +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The MER targets say how much heat the utilities must supply and remove, not +which utilities. :meth:`~hensmith.PinchTargets.utility_targets` places +BioSTEAM's heating and cooling agents on the grand composite curve, heating +and cooling separately, and prices each one with BioSTEAM. + +**Levels.** BioSTEAM lets a heating agent serve a stream only below +``agent.T - agent.dT - dT``, and a cooling agent only above ``agent.T + +agent.dT + dT``, where ``dT`` is ``bst.HeatUtility.dT``, read at call time. +On the shifted scale that limit is the agent's **level**: the limit itself for +a heating agent and the limit less ``T_min_app`` for a cooling agent, which is +BioSTEAM's rule exactly. With approach contributions the streams have +different shifts, and an agent's level is the highest single shifted +temperature (the lowest, for cooling), up to the highest of the streams' own +limits, at which it serves no stream beyond that stream's own real limit. A +level serves only what lies below it (above it, for cooling), and its heat +goes no further than the next zero of the curve balanced with the agents +placed before it (see below): the pinch, a utility pinch or another process +pinch. So a stream lying wholly beyond its limit bounds the level only at the +stream's own end, and a stream lying wholly past that zero does not bound it; +streams with slack are then served conservatively. + +**List order and fuel.** Non-fuel agents are placed in the order of +BioSTEAM's lists, BioSTEAM's own "first feasible agent" rule: for the default +lists, the lowest pressure steam first, and the warmest coolant first. Each +takes as much of the remaining duty as the curve above its level (below it, +for cooling) allows, given the agents already placed. What remains of the +heating goes to the first fuel agent, a furnace at the top of the curve; if +there is none, or if some cooling remains, it is left unserved, with a +``RuntimeWarning`` and a nan cost. A level equal to a grid temperature +serves that temperature's point loads; BioSTEAM's own rule is strict, and the +two differ only at exact equality. The exception is a sensible agent whose +line would have no driving force left there: with ``agent.dT`` of 0 +(``UtilityAgent``'s default), it could serve a point load at its limit only +at its own 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, with approach contributions, a stream that ends at +such an agent's limit bounds its level at that end, as a stream reaching +beyond its limit does: were another stream to set the level beyond that +end, the agent, having given heat above it (taken it below, for cooling), +would have to leave at its inlet temperature there. +The order is BioSTEAM's preference, not price: the utility cost target is +what the agents cost 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 can cost less than the target as well as +more (see the comparison of a network with its targets in `Supertargeting: +area, units, capital and total cost`_ below). + +**Served intervals and pricing.** Each agent is priced as one BioSTEAM heat +utility over the stretch it serves: from its level, or from the end of the +grid if that is nearer, to the next zero of the **balanced grand composite +curve** (the cascade net of the utilities), which is the pinch or a *utility +pinch* where a lower agent is used to its limit. +A target's ``T_in`` and ``T_out`` are the real temperatures of the streams it +serves in that stretch: 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: a boiler at the pinch, whose latent heat the cut +puts above it, is served at its boiling temperature. Without approach contributions +``T_in`` and ``T_out`` are the stretch's ends where streams reach them, and in +every case ``T_out`` is within the agent's limit. BioSTEAM prices a latent +agent and fuel from them. + +A **sensible** agent, such as cooling water, warms (or cools) along a line as +it takes its duty, and BioSTEAM checks that line only at the process inlet: a +coolant leaves at ``T_in - dT`` or at its limit, whichever is lower. Inside +the stretch the line must keep ``dT`` from the process too. Cooling water that +serves a condenser at the cold end of its stretch, say, has taken most of its +duty by the time it passes the condensing temperature, so it must leave well +below ``T_in - dT``. At each point of the stretch the agent has taken at least +the duty less the balanced heat flowing there (on the grand composite curve: +its line stays inside the curve, as a non-isothermal utility's does in pinch +analysis), and that bounds its outlet. hensmith prices the agent at the +highest outlet the bound allows (the lowest, for heating), by giving BioSTEAM +the matching inlet. Its flow, and therefore its cost, is then the least a +single stream of the agent needs. With approach contributions the bound takes, +at each point, the lowest real temperature of the hot streams there (the +highest of the cold ones), which is conservative. + +BioSTEAM divides each duty by the agent's heat transfer efficiency, so a +target's ``utility_duty`` is utility side while its ``duty``, like the MER +targets, is process side. +Each pricing uses fresh utility streams in a temporary flowsheet: the costs +are deterministic, and the user's flowsheet, the agents and their later costs +are untouched. + +Supertargeting: area, units, capital and total cost +--------------------------------------------------- + +Energy is one part of the cost of a network; its exchangers are the other. +Supertargeting targets the exchangers too -- their area, their number and +their capital cost -- before design, like the MER targets, and with them the +total annualized cost, which a sweep of ``T_min_app`` then minimizes +(Linnhoff & Ahmad, 1990; Ahmad, Linnhoff & Smith, 1990). +:meth:`~hensmith.PinchTargets.summary` returns every target at one +``T_min_app`` and :func:`~hensmith.supertarget` sweeps it. + +**Balanced composite curves.** Adding the utility targets to the composite +curves -- the heating utilities to the hot curve, the cooling ones to the cold +curve -- gives two curves that span the same heat +(:meth:`~hensmith.PinchTargets.balanced_composite_curves`): a latent agent is a +flat at its temperature, a sensible one a line between its inlet and outlet +temperatures, and fuel a furnace that takes no area. If some utility is +unserved, the curves cannot balance, and the area, capital and total cost +targets are nan; the unserved cooling, the coldest part of the cold curve, is +left out as a gap at its start, which keeps the two curves aligned. + +**Area.** The area target is Bath's formula (Townsend & Linnhoff, 1984) on +the balanced composite curves: heat is transferred vertically, from the hot +curve to the cold curve below it, in counter-current (a correction factor of +1). Split the heat axis at every breakpoint of either curve into intervals +:math:`i`; in each, stream or utility :math:`j` carries heat :math:`q_{ij}`, +and + +.. math:: + + A = \sum_i \frac{1}{\Delta T_{LM,i}} \sum_j \frac{q_{ij}}{h_j}, + +where :math:`\Delta T_{LM,i}` is the log-mean temperature difference of +interval :math:`i` on the real scale and :math:`h_j` the film coefficient of +:math:`j`. Every curve and every stream's share of it is linear in the heat +inside an interval, so the sum is exact. hensmith takes :math:`h_j` = 1 +kW/m²/K for every stream and utility by default, which gives any pair +BioSTEAM's heuristic overall coefficient of 0.5 kW/m²/K (condensing against +boiling, where BioSTEAM takes 1.0, is not reproduced); ``h=`` and +``h_utilities=`` set others. A zero or negative driving force anywhere +makes the area infinite, with a ``RuntimeWarning``, as at ``T_min_app = 0``. + +**Units.** A network of :math:`S` streams and utilities that forms one +connected component needs at least :math:`S - 1` units (Euler's rule). The +units target applies it to each region between the zeros of the balanced +grand composite curve -- the process pinch and every utility pinch --, +:math:`S` counting the streams with heat in the region and the utilities +acting in it. It is an estimate, **S - 1 per region, assuming one component +and no loop**: a subset of streams that balances on its own needs fewer +units, and each loop of matches one more. + +**Capital.** The area is put in as many shells as the units target, +:math:`N`, less one furnace for all the fuel, and every shell is costed with +BioSTEAM's own correlations; the furnace is costed on the fuel's utility duty, +at BioSTEAM's default furnace pressure of 500 psi. +Linnhoff & Ahmad (1990) divide the area equally, :math:`N \cdot C(A / N)`: +with economies of scale (:math:`C = a + b A^c`) that is the costliest way to +divide it, and every unit added adds cost. BioSTEAM's correlations are not of +that form: a double pipe exchanger below 150 ft² costs at most about 8,100 +USD installed, a shell and tube at 150 ft² about 64,700 USD. An equal division +can then put every shell below 150 ft², so that a utility of a few watts that +adds units would cut the capital target by most of it (the four-stream problem +of Linnhoff & Hindmarsh at 5.56 K: seven double pipes instead of four floating +heads, a fifth of the cost), although a network just adds a small exchanger +and keeps its large ones. So when the +equal division puts the shells below 150 ft² and the area fills +:math:`k = \lfloor A / 150\ \mathrm{ft^2} \rfloor \geq 1` shells of at least +150 ft², the target is the costlier of the :math:`N` double pipes and of +:math:`k` equal shell and tubes with :math:`N - k` shells of no area (each +costed as BioSTEAM costs a vanishing double pipe, about 1,300 USD installed). +This is the least change to the equal division that never falls when a unit +is added, except for shells beyond about 35,000 ft² (3,300 m²) with the +default floating head of carbon steel (from about 15,000 ft² by type and +material), whose cost rises faster than their area; where it departs from it, +it tends to be high (networks keep only part of their area in shell and tubes) +as the equal division tends to be low. +The summary's ``'N_area_shells'``, ``'shell_area'`` and ``'shell_type'`` say +which shells carry the area. BioSTEAM's other specifics carry over: the +floating head correlation is not monotone below about 126 ft², which the +double pipe hides, and there is no largest shell area. All shells are costed +at one pressure, ``P``, whereas BioSTEAM takes each exchanger's highest inlet +pressure; for high pressure steam heaters its pressure factor is up to about +12 % higher. + +**Total annualized cost.** With a capital recovery factor over :math:`n` +years at interest :math:`i`, + +.. math:: + + \mathrm{TAC} = \mathrm{CRF} \, C_{installed} + t_{op} \, C_{utility}, + \qquad \mathrm{CRF} = \frac{i (1 + i)^n}{(1 + i)^n - 1}, + +with :math:`i` = 10 %, :math:`n` = 10 years and :math:`t_{op}` = 8000 h/yr +by default, or taken from a BioSTEAM TEA (``tea=``). The targets of a facility +(``HXN.targets()``) take :math:`t_{op}` from its system when the system has +operating hours (a TEA sets them), as the network report does. It counts the +bare module cost of the exchangers only: no Lang factor and no working capital. + +**The sweep.** :func:`~hensmith.supertarget` evaluates the targets over a grid +of ``T_min_app`` values, reusing the streams' curves (nothing is flashed +again, though each point builds its own problem table, about the table of a +synthesis), and then refines the grid around the least total annualized cost. The +cost is not smooth -- the units target jumps, the double pipe switches to a +shell and tube, the pinch moves -- so the refinement only narrows a bracket, +and ``at_bound`` flags an optimum at an end of the sweep. The capital target +drops by about a shell and tube wherever the shrinking area fills one fewer of +150 ft², so the least cost often lies just past such a step rather than at a +smooth trade-off of energy and capital: compare the ``'N_area_shells'`` and +``'shell_type'`` of its neighbors (``sweep.to_frame()``). A network has such +steps too, but where its own exchangers cross 150 ft², which the target's +equal shells place only roughly. Where the targets do +not depend on ``T_min_app`` -- below the threshold of a threshold problem, the +utilities and the balanced composite curves stay the same -- the costs differ +only by the discretization of the curves, by a tiny relative amount (4e-7 +of the total cost for the quickstart system), so 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, the +warmest point evaluated, just short of the threshold). A sweep whose costs +are all finite and the same to round-off (streams that recover no heat, say) +is not refined, and ``constant`` flags it. A sweep with no finite +total cost (an unserved utility everywhere, say) is not refined and warns that +its optimum is meaningless; :func:`~hensmith.plot_supertargets` shades the +stretches of a sweep without a finite cost. With approach +contributions, only the unspecified streams follow ``T_min_app``; +``scale_contributions=True`` scales the specified ones with it, the +contribution scaling of Ahmad, Linnhoff & Smith (1990). + +**A network against its targets.** :func:`~hensmith.network_report` compares a +synthesized network with its targets: utility cost, units, area, capital and +total annualized cost. Its ``'area'`` re-sizes the network's exchangers like +the target sizes them (the same film coefficients, a correction factor of 1, +the integrated driving force along the streams' profiles as the targets' +curves model them and the process side duty), so it is comparable with the +target; ``'area_biosteam'`` is BioSTEAM's own sizing, which includes its +correction factor, the terminal log-mean temperature difference and the +utility side duty. An exchanger whose profiles meet or cross has an infinite +``'area'``, and so has the network, with a ``RuntimeWarning`` naming it, while +its ``'area_biosteam'`` stays finite: BioSTEAM specifies a utility from the +ends of its exchanger only, taking a sensible agent to its limit (cooling +water to 324.82 K) whenever the process inlet allows it, so against a stream +that condenses a little below that limit (a vacuum condenser, say) the +utility's line crosses the condensing stretch inside the exchanger. The +report keeps BioSTEAM's specification of the utility, whose cost it reports; +the target, which keeps a sensible agent's line inside the grand composite +curve, stays finite. The report checks that its targets and the network's +streams are both of the units' current states: after a stream or a unit +changes, it raises a ``ValueError`` until the system is simulated again +(``HXN.targets()`` warns meanwhile of a stream whose inlet has changed in flow +since its unit was simulated, whose targets would run from that new inlet to +its former outlet). The comparison has limits: + +- the Bath target bounds the area only with equal film coefficients and the + same utilities; +- the network gives each stream one agent at its outlet, BioSTEAM's list + rule, while the target allocates the agents on the grand composite curve + in list order, fuel last, so the network can use other agents than the + target, costlier or cheaper ones. On the four-stream problem of Linnhoff & + Hindmarsh at 10 K, the target is low pressure steam and cooling water, + the network medium pressure steam and chilled water, which cost more. 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 target places 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, + takes natural gas from 390 K at 5.2e-6 USD/kJ, where the target places + low, medium and high pressure steam (6.5e-6 to 1.2e-5 USD/kJ) and fuel + only above 504 K, so the network at MER costs 3.14 USD/hr 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, and neither it nor the network's + utility cost bounds the other; +- the units target is an estimate in both directions, since Euler's S - 1 + assumes one component and no loop per region: a balanced subset of + streams, or one agent serving a stream whose heat the target splits + between two utility levels, needs fewer units; each loop of matches needs + one more, whether 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 (for such a + loop, the quickstart network at 5 K has 7 units against 6, see + :doc:`tutorial/05_targeting`). + From targets to a network: the pinch-outward MER planner -------------------------------------------------------- @@ -537,8 +992,12 @@ own utilities. If no process match was made at all, the facility reports zero capital and no utilities. **Reusing a network.** With ``cache_network=True`` a later simulation checks -whether the set of units behind the heat utilities is unchanged; if it is, the -same network configuration is reused instead of being synthesized again. Each +whether the units behind the heat utilities, each as often as it gives a +stream (biosteam lists the heat utility of a unit in parallel once per unit, +so a reactor whose ``parallel['self']`` changes with its volume changes the +streams), and the explicit approach contributions they resolve to, are +unchanged; if they are, the same network configuration is reused instead of +being synthesized again. Each life cycle's first inlet is copied from the current stream, and each process exchanger keeps, as the enthalpy limit of its *must* stream (the hot stream above the pinch, the cold stream below it), the fraction of that stream's @@ -588,8 +1047,10 @@ checked the same way, against the quenched outlet enthalpy and temperature. **The approach inside every exchanger.** The synthesis verifies every process exchanger on the exact states of its streams (see *Exact approach verification* above) and records the smallest approach it found in -``synthesis_info['min_approach']``; the tests re-check it with temperatures -computed independently of the code under test. +``synthesis_info['min_approach']``, and the smallest margin of an +exchanger's approach over its pair's required approach in +``synthesis_info['min_approach_margin']``; the tests re-check both with +temperatures computed independently of the code under test. **MER identification and achievement.** ``tests/test_hxn_mer.py`` synthesizes a corpus of 78 problems through the public facility. For 40 of them -- 24 @@ -619,15 +1080,59 @@ systems of increasing complexity -- all with phase-changing streams from the third on -- and for each one requires that the synthesized network (i) closes its energy balance without raising ``RuntimeWarning``, (ii) never uses less hot or cold utility than the MER targets computed on the same streams, and -reports ``'mer'`` exactly when it reaches them, (iii) keeps ``T_min_app`` -inside every process exchanger on exact states, (iv) is planned on the -problem table's own cascade, and (v) recovers at least as much heat as a load -recorded in the test file. A network that improves leaves slack in (v); those +reports ``'mer'`` exactly when it reaches them, (iii) keeps each pair's +required approach inside every process exchanger on exact states, (iv) is +planned on the problem table's own cascade, and (v) recovers at least as +much heat as a load recorded in the test file. A network that improves leaves slack in (v); those recorded numbers are lowered deliberately by a maintainer, never raised to make a failing test pass. Every case is synthesized again with ``stream_splitting=True`` and held to the same checks, and the three cases that need stream splits for MER must then reach their targets. +**Approach contributions.** ``tests/test_hxn_contributions.py`` checks the +contributions against references that do not use them. Without +contributions -- and with every contribution unspecified or equal to +``T_min_app / 2`` -- plans, problem tables and facility networks must equal +the default ones bit for bit, compared in-process. Every stream at +:math:`x` must plan the network of ``T_min_app`` :math:`= 2x`, and on +real-thermodynamics cases all hot streams at :math:`a` and all cold ones at +:math:`b` must give the default targets at ``T_min_app`` :math:`= a + b`. +With mixed contributions, the targets of 40 random problems, of the 24 +constant heat capacity no-split problems of the MER corpus and of five split +ones (which must reach MER with ``stream_splitting=True``) must equal a +closed-form constant heat capacity cascade with the classic symmetric shifts, +and every planned match must keep its pair's requirement on recomputed +temperatures. The facility, with and without splits, on constant heat +capacity and real-thermodynamics cases, must reach the contribution-aware +targets, keep each pair's requirement on exact states recomputed +independently and give each exchanger its pair's ``dT``; the exact check and +the repair must act on each pair's own requirement. The key rules, the +validation of keys and values (for idle units too, and before any stream is +flashed) and the ``cache_network`` rule are tested as well. + +**Targeting.** ``tests/test_targeting.py`` checks ``hensmith.targeting`` +against references computed independently of it: the composite curves of +the four-stream problem of Linnhoff & Hindmarsh in closed form, and the +composite and grand composite curves of constant heat capacity and +real-thermodynamics cases against their problem tables; the multiple-utility +allocations and served intervals against duties computed from the agents' +own attributes, each priced again in isolation; the area target against a +midpoint integration of hand-built composite curves (with approach +contributions and per-stream film coefficients too) and against closed +forms; the units target on eight reference problems, utility pinches and +balanced subsets among them; the exchanger costs against BioSTEAM's own; the +capital recovery factor against BioSTEAM's; and the swept optimum against +the closed-form total annualized cost of a two-stream problem. The +facility's targets must equal the synthesis's bit for bit, with and without +approach contributions; the network report's areas must equal independent +log-mean areas integrated along the network's own streams where these are at +equilibrium (the report follows each stream as the targets' curves model it, +like for like with the area target, so it may differ slightly for a +point-load stream, held at its outlet temperature, or a non-equilibrium end +state, such as a vapour entering below its dew point); and targeting must +leave the flowsheet's streams, the agents, later utility costs and a later +synthesis unchanged. + **Doctests.** The examples in the docstrings are executed as part of the test suite, so the numbers printed in the API reference are numbers the code currently produces. @@ -640,9 +1145,10 @@ What the synthesized network is guaranteed to be: - **Never better than MER.** Its utilities are never below the targets of the problem table, and ``'mer'`` is reported only if the realized network reaches them. -- **Feasible everywhere inside.** Every process exchanger keeps - ``T_min_app - 1e-6`` K on the exact stream states at its ends and at every - checked position inside it, and the heat balance closes on every stream. +- **Feasible everywhere inside.** Every process exchanger keeps its pair's + required approach (``T_min_app``, or ``c_h + c_c``) minus 1e-6 K on the + exact stream states at its ends and at every checked position inside it, + and the heat balance closes on every stream. - **Deterministic.** The search is budgeted in work units, not seconds, so the same streams give the same network however fast the machine is. @@ -669,7 +1175,11 @@ What it is not: - **Energy first, then units; no cost optimization.** MER always takes precedence over the number of exchangers, and an unsplit MER network can need many of them. The branch and bound reduces the number of exchangers - among MER plans, but nothing optimizes area, capital or total cost. + among MER plans, but the synthesis does not optimize area, capital or total + cost; ``hensmith.targeting`` targets them, finds the ``T_min_app`` of the + least targeted total annualized cost (see `Supertargeting: area, units, + capital and total cost`_), and :func:`~hensmith.network_report` compares a + network with the targets. - **Some networks cannot be represented.** Networks whose match order is cyclic are outside the planner's model. A side that needs a split without a pinch-rule proof spends its whole MER search budget before the best-effort @@ -685,11 +1195,25 @@ What it is not: utilities flagged as not usable for HXN integration (``hxn_ok=False``) or with non-positive flow, and streams excluded with ``ignored``, are removed from the analysis entirely. -- **One approach temperature for everything.** A single ``T_min_app`` shifts - the problem table and constrains every synthesized exchanger. There is no - per-stream or per-match approach temperature, so a match whose exchange is - cheap and one whose exchange is expensive are held to the same driving-force - floor. +- **Additive approach temperatures only.** Per-stream approach temperature + contributions are supported, so a match's requirement is ``T_min_app`` or + the sum of its two streams' contributions; a requirement that belongs to a + pair and is not such a sum is not (see + `Approach temperature contributions`_). The utility exchangers use + BioSTEAM's approach rule whatever a stream's contribution. +- **Targets are estimates.** Beyond the MER targets, the targets of + ``hensmith.targeting`` are estimates: the utility cost target is the cost + of BioSTEAM's placement of its agents, which a network at MER can exceed + or undercut, the area target bounds a network's area only with equal film + coefficients and the same utilities, the units target can be wrong in + both directions, the capital target assumes shells + of equal area at one pressure (keeping the shell and tubes the area fills + when an equal division would drop below 150 ft²), and with approach + contributions the utility levels are conservative and their pricing + temperatures approximate. The optimum ``T_min_app`` is the optimum of these + estimates, and often sits just past a step of the capital target where the + area fills one shell and tube fewer: a step a network shares, though not + at the same ``T_min_app``. References ---------- @@ -703,6 +1227,14 @@ References - Seider, W. D., Lewin, D. R., Seader, J. D., Widagdo, S., Gani, R., & Ng, M. K. (2017). *Product and Process Design Principles*. Wiley. Heat Exchanger Networks (Chapter 9). +- Townsend, D. W., & Linnhoff, B. (1984). Surface area targets for heat + exchanger networks. *IChemE 11th Annual Research Meeting*, Bath, UK. +- 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. +- 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. - Cortes-Pena, Y., Kumar, D., Singh, V., & Guest, J. S. (2020). BioSTEAM: A fast and flexible platform for the design, simulation, and techno-economic analysis of biorefineries under uncertainty. *ACS Sustainable Chemistry & diff --git a/docs/source/contributing/contributing.rst b/docs/source/contributing/contributing.rst index 7fb1201..9228163 100644 --- a/docs/source/contributing/contributing.rst +++ b/docs/source/contributing/contributing.rst @@ -9,8 +9,8 @@ change has to respect. Where the code lives -------------------- -The library is two public modules under ``hensmith/``, and three private ones -behind them: +The library is three public modules under ``hensmith/``, and three private +ones behind them: ``hensmith/_heat_exchanger_network.py`` The ``HeatExchangerNetwork`` facility unit: its ``_run``, ``_design`` and @@ -24,6 +24,12 @@ behind them: splitters and mixers, and verifying it on exact stream states), the per-stream bookkeeping of ``StreamLifeCycle``, and ``plot_pinch_diagram``. +``hensmith/targeting.py`` + Targeting and supertargeting on top of the problem table: composite and + grand composite curves, multiple-utility targets priced by BioSTEAM, + area, units, capital and total annualized cost targets, the + ``T_min_app`` sweep (``supertarget``), the network report and the plots. + ``hensmith/_curves.py`` (private) The piecewise-linear temperature-enthalpy curve of each process stream, built once from a handful of flashes, on which both the problem table and @@ -42,8 +48,8 @@ behind them: lemmas, the constructions and the proof that a split side always has an MER candidate. Nothing in it runs when the option is off. -``hensmith/__init__.py`` re-exports the ``__all__`` of the two public modules -and holds the biosteam registration block described in `The import +``hensmith/__init__.py`` re-exports the ``__all__`` of the three public +modules and holds the biosteam registration block described in `The import contract`_ below. The public API is documented under :doc:`../API/api`. Tests live in ``tests/``: @@ -82,6 +88,21 @@ Tests live in ``tests/``: system is synthesized again with ``stream_splitting=True``, and the three that need stream splits must then reach their targets. +``tests/test_hxn_contributions.py`` + Approach temperature contributions: default bit-for-bit identity + in-process, uniform contributions against a doubled ``T_min_app`` and + against uniform real-thermodynamics runs, closed-form targets and + per-pair exact approaches on corpus and real-thermodynamics cases, key + resolution and validation, and the ``cache_network`` rule. + +``tests/test_targeting.py`` + ``hensmith.targeting`` against independent references: closed-form + composite curves, utility allocations from the agents' attributes, + independently integrated areas, units and cost references, the + closed-form optimum ``T_min_app`` of a two-stream problem, the facility's + targets against the synthesis's, the network report, and the absence of + side effects on the flowsheet and the agents. + Development environment ----------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index f1f5bf0..53de057 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -42,7 +42,11 @@ synthesizes a network of process exchangers that reaches the minimum energy requirement (MER) whenever it finds one without stream splits -- or, with ``stream_splitting=True``, splits streams where MER needs it -- and reports the utility savings and added capital cost as part of the system's -techno-economic analysis. +techno-economic analysis. Its targeting tools go beyond MER: they target +several utilities, exchanger area, units, capital and total annualized cost +before any network exists, find the minimum approach temperature of the +least total annualized cost, and compare a synthesized network with its +targets. Watch it run in the `Quickstart`_ demo below. Quickstart diff --git a/docs/source/tutorial/01_quickstart.rst b/docs/source/tutorial/01_quickstart.rst index 134c0bd..391f473 100644 --- a/docs/source/tutorial/01_quickstart.rst +++ b/docs/source/tutorial/01_quickstart.rst @@ -208,5 +208,7 @@ Where to next the diagram, unit by unit. - :doc:`04_configuring` -- the constructor options of the facility, and applying it to a larger system. +- :doc:`05_targeting` -- utility, area, units and cost targets, and the + ``T_min_app`` of the least total annualized cost. - :doc:`../concepts` -- the pinch concepts and terminology used throughout. - :doc:`../API/api` -- the full API reference. diff --git a/docs/source/tutorial/02_pinch_analysis.rst b/docs/source/tutorial/02_pinch_analysis.rst index 8fd3970..487a76a 100644 --- a/docs/source/tutorial/02_pinch_analysis.rst +++ b/docs/source/tutorial/02_pinch_analysis.rst @@ -149,11 +149,13 @@ designs, and the dashed line drawn on the pinch diagram of chapter 1. Composite curves ---------------- -hensmith itself ships one plot, the pinch diagram of a synthesized network. The -two curves below are not library functions: they are computed in this -tutorial's script from the fields of the :class:`~hensmith.ProblemTable`, and -they are shown here because they are the standard way to read what the table -says before any network exists. +The two curves below are computed in this tutorial's script from the fields +of the :class:`~hensmith.ProblemTable`, to show how they follow from the +table; they are the standard way to read what the table says before any +network exists. The library returns the same curves +(:class:`~hensmith.PinchTargets`) and draws them +(:func:`~hensmith.plot_composite_curves`, +:func:`~hensmith.plot_grand_composite_curve`), as :doc:`05_targeting` shows. .. literalinclude:: /../_demo_src/examples/ch02_pinch_analysis.py :language: python @@ -306,4 +308,6 @@ Where to next temperatures behind the diagram, unit by unit. - :doc:`04_configuring` -- what changing ``T_min_app`` does to the targets and to the cost of reaching them. +- :doc:`05_targeting` -- the composite and grand composite curves drawn by the + library, several utilities on them, and area, units and cost targets. - :doc:`../concepts` -- the pinch concepts and terminology used throughout. diff --git a/docs/source/tutorial/03_network_anatomy.rst b/docs/source/tutorial/03_network_anatomy.rst index 2ec1886..1794270 100644 --- a/docs/source/tutorial/03_network_anatomy.rst +++ b/docs/source/tutorial/03_network_anatomy.rst @@ -391,6 +391,8 @@ Where to next - :doc:`04_configuring` -- the constructor options of the facility, what ``T_min_app`` and ``ignored`` change, and a ten-stream network. +- :doc:`05_targeting` -- how this network compares with the targets of + utility cost, area, units and total annualized cost. - :doc:`../concepts` -- the pinch concepts, the MER planner, and what a synthesized network is and is not guaranteed to be. - :doc:`../API/api` -- the full API reference. diff --git a/docs/source/tutorial/04_configuring.rst b/docs/source/tutorial/04_configuring.rst index caa26d8..9556207 100644 --- a/docs/source/tutorial/04_configuring.rst +++ b/docs/source/tutorial/04_configuring.rst @@ -6,7 +6,9 @@ temperature of 5 K, every unit of the system in scope, and one small five-stream flowsheet. This chapter varies all three. It sweeps ``T_min_app`` over the quickstart system to expose the trade-off between recovered heat and added area, lets hensmith split streams where the targets need it -(``stream_splitting=True``), narrows the analysis with ``ignored=``, goes +(``stream_splitting=True``), gives some streams their own approach +temperature contributions (``approach_contributions=``), narrows the +analysis with ``ignored=``, goes through the remaining constructor options of :class:`~hensmith.HeatExchangerNetwork` one by one, and finishes by synthesizing a ten-stream network with ten process exchangers. @@ -80,7 +82,9 @@ every breakpoint of both streams' temperature-enthalpy curves, and verified on their exact states. A smaller approach therefore admits more matches and lets each one transfer more heat, but the exchangers that do so work across a smaller temperature difference and need more area for the same duty -- which -is what the right-hand panel below prices. +is what the right-hand panel below prices. ``T_min_app`` holds every match to +the same floor; `Approach temperature contributions`_ below relaxes it for +the streams that need less. The cost does not fall monotonically, though: it rises from 6.004e+05 USD at 10 K to 6.684e+05 USD at 15 K, and the last three columns say why. Up to 10 K @@ -124,8 +128,9 @@ makes the split instead. part of the cost curve: 2.977e+08 kJ/hr of heating, 1.936e+06 kJ/hr of cooling and 1.112e+06 USD of added installed cost. Choosing ``T_min_app`` is choosing a point on these two curves; the economically sensible one depends - on utility prices and on the cost of exchanger area, neither of which the - network optimizes for you. + on utility prices and on the cost of exchanger area; synthesis does not + optimize them, but :doc:`05_targeting` targets both and finds the + ``T_min_app`` that minimizes their sum. Splitting streams ----------------- @@ -167,6 +172,62 @@ splits serves is planned exactly as without the option, so the MER networks of the sweep at 2, 5 and 10 K would come out the same. :doc:`../concepts` describes the method and its guarantee. +Approach temperature contributions +---------------------------------- + +``T_min_app`` is one number for every match, but the film coefficients behind +it are not: a condensing or boiling stream transfers heat far more readily +than a liquid that is only cooled, and can work across a smaller temperature +difference for the same area. ``approach_contributions`` gives streams their +own approach temperature contribution, in K. A match between a hot and a cold +stream must then keep the sum of their two contributions instead of +``T_min_app``, everywhere inside it, and a stream without a contribution +contributes ``T_min_app / 2``, so that two such streams still need +``T_min_app``. Like ``stream_splitting`` it is a plain attribute of the +facility. Its keys name units: ``'D1'`` covers both auxiliary exchangers of +the column, its condenser and its reboiler, and ``F1``, the flash itself, +covers the auxiliary heater of its feed. + +.. literalinclude:: /../_demo_src/examples/ch04_configuring.py + :language: python + :start-after: # [start:contributions] + :end-before: # [end:contributions] + :dedent: + +.. literalinclude:: /_generated/ch04_contributions.txt + :language: text + +``synthesis_info['approach_contributions']`` lists the contributions as used, +in the stream order of ``original_heat_exchangers``: the reboiler (stream 0), +the flash's heater (1) and the condenser (3) contribute 1 K each, and the two +product coolers (2 and 4) the default 2.5 K. Each match requires the sum over +its two streams, read off the stream life cycles: 3.5 K for ``HX_1_2_hs`` and +``HX_0_4_hs``, which each pair a cooler with a phase-changing stream, and +2.0 K for ``HX_0_3_hs``, which pairs the condenser with the reboiler. Each of +these exchangers is a ``HXprocess`` whose ``dT`` is its own requirement less +1e-6 K. + +The network reaches its targets -- status ``mer`` -- with three process +exchangers. Compared with the 5 K network of the sweep (four exchangers, +2.977e+08 kJ/hr of heating, 1.936e+06 kJ/hr of cooling and 1.112e+06 USD of +added installed cost), it uses 2.967e+08 kJ/hr of heating and 1.014e+06 kJ/hr +of cooling, for 1.449e+06 USD. All three numbers lie between those of the +5 K network and of the 2 K one (2.957e+08 and 9.219e+04 kJ/hr, 2.02e+06 USD): +more heat is recovered than at 5 K, at the price of more area, but every +match with a cooler still needs 3.5 K rather than the 2 K that the sweep's +first point allows every match. The smallest approach inside any of the +three exchangers is 3.500 K, so the 2.0 K match stays well clear of its +requirement; the margin over the requirement, -1.3e-12 K, says that a 3.5 K +match is held at exactly its requirement, to round-off (the exact check +allows 1e-6 K below it). Setting ``approach_contributions`` back to ``None`` +restores the default on the next simulation. :doc:`../concepts` gives the +shifted scale that makes the targets exact for any contributions, and the +full key rules: +``(owner.ID, unit.ID)`` keys for the auxiliary units of that ID under the +top owner, the nearest key +along a unit's owners winning, and keys that are checked even while their +unit is idle. + Scoping the analysis -------------------- @@ -218,10 +279,12 @@ Other options The remaining keyword arguments of :class:`~hensmith.HeatExchangerNetwork` are listed in the :doc:`../API/api` reference; what each of them does to the synthesis is described below (``stream_splitting`` in `Splitting streams`_ +and ``approach_contributions`` in `Approach temperature contributions`_ above). Five of them -- ``Qmin``, ``force_ideal_thermo``, ``avoid_recycle``, ``sort_hus_by_T`` and ``stream_splitting`` -- are passed straight through to :func:`~hensmith.synthesize_network`, which can also be called directly on a -list of heat utilities. +list of heat utilities; ``approach_contributions`` is first resolved by the +facility to one value per heat utility (keys naming idle units ignored). ``Qmin`` is a duty floor, in kJ/hr, defaulting to 1e-3. A planned exchanger whose duty is below it (``Q < Qmin``) is dropped from the plan and its duty @@ -238,8 +301,11 @@ stream that finishes on process heat alone carries no utility symbol. simulations, which is worth doing when the same system is simulated many times with slightly different inputs, as in a Monte Carlo or a sensitivity analysis. When it is on and a network has already been synthesized, the units owning the -current heat utilities are compared with the exchangers behind the cached one; -if the sets are identical the cached network is kept and only the stream states +current heat utilities are compared with the exchangers behind the cached one +(each as often as it gives a stream, as a unit in parallel does once per unit), +and the explicit approach contributions, resolved to one value per stream, +with those the cached network was synthesized with; if both match, the cached +network is kept and only the stream states and the exchanger specifications are updated. Each life cycle's first inlet is copied from the current stream. Each process exchanger keeps, as the enthalpy limit of the stream its side of the pinch must serve completely (the hot @@ -380,6 +446,9 @@ the real streams. Where to next ------------- +- :doc:`05_targeting` -- targeting the area, units and cost that this + chapter's sweep traded against the utilities, and the ``T_min_app`` that + minimizes the total annualized cost. - :doc:`../concepts` -- the pinch concepts, the MER planner and the validation behind everything shown in this tutorial. - :doc:`../API/api` -- the full API reference, including a table of every diff --git a/docs/source/tutorial/05_targeting.rst b/docs/source/tutorial/05_targeting.rst new file mode 100644 index 0000000..52fac40 --- /dev/null +++ b/docs/source/tutorial/05_targeting.rst @@ -0,0 +1,425 @@ +Targeting and supertargeting +============================ + +The chapters before this one judged a network by one pair of numbers, the +minimum energy requirement (MER) targets of its problem table. Pinch analysis +can say much more before a single exchanger exists: how the heating and +cooling targets split between several utilities and what they cost, how much +exchanger area and how many units a network of these streams needs, what +that area costs, and therefore which ``T_min_app`` minimizes the total +annualized cost. ``hensmith.targeting`` computes all of these +(:class:`~hensmith.PinchTargets`), sweeps ``T_min_app`` for the least total +annualized cost (:func:`~hensmith.supertarget`), draws the composite curves, +the grand composite curve and the sweep, and compares a synthesized network +with its targets (:func:`~hensmith.network_report`). + +Every number and figure below is output of the code shown on this page. The +quickstart system built here is chapter 1's build repeated verbatim, so this +page runs on its own. + +Imports and the system +---------------------- + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:imports] + :end-before: # [end:imports] + :dedent: + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:system] + :end-before: # [end:system] + :dedent: + +The facility's targets +---------------------- + +:meth:`HXN.targets() ` returns the +targets of the streams the facility synthesized from, in its stream order and +at its ``T_min_app`` (or at another one, ``HXN.targets(10.)``, without +synthesizing anything). + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:targets] + :end-before: # [end:targets] + :dedent: + +.. literalinclude:: /_generated/ch05_targets.txt + :language: text + +The streams are labeled by their units, an auxiliary exchanger by its owner +and its own ID. The targets are the synthesis's own, bit for bit: the +facility prepares the streams for targeting exactly as the synthesis prepares +them. They are **process side**: 2.828e+08 kJ/hr is the heat the cold +streams need from a hot utility. The network's heating load, 2.977e+08 kJ/hr +here and in :doc:`04_configuring`, is **utility side**: biosteam divides each +duty by its agent's heat transfer efficiency, 0.95 for steam, and 2.828e+08 / +0.95 is 2.977e+08. + +Nothing is stored on the facility, and nothing is re-synthesized: the targets +are those of the units' current states, which, with ``cache_network=True``, +can differ from the cached network's. Each stream runs from its unit's inlet +now to its outlet as the unit was last simulated, so after a feed changes, +simulate the system (or the units) before targeting: a stream whose inlet has +changed in flow since warns that its targets mix the new inlet with the former +outlet, a state of no process. The streams themselves are those of the +facility's last simulation, one per heat utility its units listed then: a unit +in parallel simulated alone that changes its number of units (a fermentor's +``parallel['self']``, set by its volume) warns, as biosteam lists its heat +utilities once per unit, until the system is simulated. The same object can be +built without a facility, from the heat utilities of heat exchangers with +:meth:`PinchTargets.from_heat_utilities ` +(as the trade-off example below does) or from streams as +:func:`~hensmith.problem_table` takes them. The facility's selection, +``bst.process_tools.heat_exchanger_utilities_from_units(sys.units)``, is the +one to give it: a heat utility that a unit adds for itself (a reactor's or a +heated mixer's, ``hu.hxn_ok`` False) raises a ``ValueError``, as its unit's +first inlet and outlet need not be the stream it heats or cools. 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 (a former one, no longer among its unit's ``heat_utilities``, +raises a ``ValueError`` rather than drop out as a heat utility without duty). +A heat utility counts as a stream each time it is given, as in the synthesis: +biosteam lists the heat utility of a unit in parallel once per unit (a +fermentor's recirculation cooler, for one, once per reactor of its +``parallel['self']``), and so does the selection. One given more often than +its unit and owners list it, as selections from overlapping unit lists (a +unit and an auxiliary unit of it) give it, raises a ``ValueError``: its +stream would count once more per extra one. Given +``key_units=sys.units`` too, the keys of ``approach_contributions`` and of +``h`` may name any unit, as the facility's may: ``'K1'`` then covers the +intercoolers of a ``biosteam.MultistageCompressor`` K1, which builds them +without an owner. After a simulation with ``replace_unit_heat_utilities=True``, +though, the units' heat utilities hold the network's utilities, not the +process duties, and those of the streams that the process exchangers serve +completely are empty: the selection then gives the wrong streams (with a +``RuntimeWarning``), and ``HXN.targets()`` is the one to take. + +Several utilities on the grand composite curve +---------------------------------------------- + +The MER targets say how much heating and cooling the streams need, not which +utilities supply them. :meth:`~hensmith.PinchTargets.utility_targets` places +biosteam's heating and cooling agents on the grand composite curve, +following biosteam's own rules for which agent may serve which temperature, +and prices each with biosteam. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:utilities] + :end-before: # [end:utilities] + :dedent: + +.. literalinclude:: /_generated/ch05_utilities.txt + :language: text + +Each agent has a **level** on the shifted scale: the warmest temperature it +can serve, or the coldest, for a coolant. A heating agent's level is its +temperature less biosteam's approach, the agent's own ``dT`` plus +``HeatUtility.dT``: 412.19 - 0 - 5 = 407.19 K for low pressure steam. A +coolant's level is its temperature plus that approach, moved onto the shifted +scale by subtracting ``T_min_app``, the hot streams' shift: +280.37 + 2 + 5 - 5 = 282.37 K for chilled water. Low pressure steam, first in +biosteam's list of heating agents, is above the top of the cold streams, so it +takes the whole hot utility target; a hotter steam would only be used for what +it cannot serve. +Every cold stream above the pinch is heated by it, from 298.15 K (the pinch) +to 372.60 K, and biosteam prices that duty at 1829.93 USD/hr, with the same +division by the heat transfer efficiency as in the network: the utility duty +is 2.977e+08 kJ/hr. On the cold side, cooling water, the first coolant in the +list, gets nothing: all the cold utility is needed below 303.15 K (30 °C, the +pinch on the hot streams' scale), colder than cooling water can serve. Chilled +water serves it. A sensible agent, it enters at 280.37 K and leaves at 298.15 +K, 5 K below the warmest process temperature it serves, 303.15 K: biosteam +limits a sensible agent's outlet at that end, and hensmith also keeps its +line 5 K from the process across the whole stretch, which here allows the +same outlet (a condenser at the cold end of the stretch would hold it lower). +The utility cost target, 1839.61 USD/hr, is the cost of the MER utilities +with biosteam's agents placed this way. + +The grand composite curve shows the allocation: each agent is a horizontal +segment at its level, as wide as its duty, the heating ones from the lowest +level up, each starting where the one below ends, and the cooling ones +mirrored below the pinch. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:gcc] + :end-before: # [end:gcc] + :dedent: + +.. figure:: /_static/images/examples/tutorial_05_grand_composite.png + :class: white-bg + :width: 560 + :alt: Grand composite curve of the quickstart system: a black curve of shifted temperature in degrees Celsius against the heat cascaded in kJ/hr, starting near zero at the pinch, marked by an open circle at 25 degrees Celsius, and rising to the hot utility target at about 99 degrees Celsius, with a red horizontal segment labelled low_pressure_steam at about 134 degrees Celsius spanning the whole hot utility target, and a very short blue segment labelled chilled_water near 9 degrees Celsius. + + The grand composite curve of the quickstart system at ``T_min_app = 5`` K + (the same curve as chapter 2's), with the utility targets as a staircase: + low pressure steam at its level, 407.19 K (134 °C on the shifted scale), + serves all 2.828e+08 kJ/hr of the hot utility target, above every cold + stream; chilled water at 282.37 K serves the 1.936e+06 kJ/hr of cold + utility below the pinch. The open circle is the pinch, 298.15 K shifted. + +Composite curves, plain and balanced +------------------------------------ + +Chapter 2 drew the composite curves from the fields of a +:class:`~hensmith.ProblemTable` in its own script; +:func:`~hensmith.plot_composite_curves` draws them from the targets, and +:meth:`~hensmith.PinchTargets.composite_curves` returns their points. With +``kind='balanced'`` the utilities join the curves: the heating utilities the +hot composite and the cooling ones the cold composite, so that both curves end +at the same heat. These **balanced composite curves** are what the area +target below is computed on. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:composites] + :end-before: # [end:composites] + :dedent: + +.. figure:: /_static/images/examples/tutorial_05_composite_curves.png + :class: white-bg + :width: 720 + :alt: Composite curves of the quickstart system drawn by hensmith: a red hot composite curve above a blue cold composite curve on axes of temperature in degrees Celsius against enthalpy in kJ/hr, a shaded band marking the heat recovered, a cold utility label of 1.94E6 kJ/hr at the cold end and a hot utility label of 2.83E8 kJ/hr at the warm end. + + The composite curves of chapter 2, drawn by the library: the shaded band is + the heat recovered, the cold composite starts at the cold utility target, + 1.936e+06 kJ/hr, and ends the hot utility target, 2.828e+08 kJ/hr, beyond + the end of the hot one. The script checks that their knots are chapter 2's. + +.. figure:: /_static/images/examples/tutorial_05_balanced_composites.png + :class: white-bg + :width: 720 + :alt: Balanced composite curves of the quickstart system: the red hot balanced composite follows the hot composite and then rises vertically to a long flat at about 139 degrees Celsius labelled low_pressure_steam 2.83E8 kJ/hr, which runs to the end of the blue cold balanced composite; the blue curve starts with a short steep line from about 7 to 25 degrees Celsius labelled chilled_water 1.94E6 kJ/hr and then follows the cold composite. + + The balanced composite curves. The steam condenses at 412.19 K, so it is a + flat at that temperature (139 °C), 2.828e+08 kJ/hr long, at the warm end of + the hot curve; chilled water warms from 280.37 to 298.15 K, a line at the + cold end of the cold curve, 1.936e+06 kJ/hr long. Both curves now span the + same heat; the area target assumes that every bit of it is transferred + vertically, from the hot curve to the cold curve below it. + +Area, units, capital and total cost +----------------------------------- + +On the balanced composite curves, :meth:`~hensmith.PinchTargets.summary` +targets the rest of the cost of a network. The methods that take +``utilities`` need those of the same targets: a list from targets at another +``T_min_app`` (``targets.at(20.)``, say) or a part of one would not balance +the curves, so its duties are checked against ``Q_hot`` and ``Q_cold``, and +the shifted temperatures its levels are on (each utility's ``shift``) against +the targets' ``shift``; a mismatch raises a ``ValueError``. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:supertargets] + :end-before: # [end:supertargets] + :dedent: + +.. literalinclude:: /_generated/ch05_summary.txt + :language: text + +- **Area**, 5946.13 m²: Bath's formula, the area of an ideal network that + transfers heat vertically between the balanced composite curves, each + stretch between their breakpoints at its log-mean temperature difference + and each stream's share of it over its own film coefficient. By default + every stream and utility has a film coefficient of 1 kW/m²/K, so any pair + has biosteam's heuristic overall coefficient of 0.5 kW/m²/K; ``h=`` and + ``h_utilities=`` give streams and agents their own. +- **Units**, 6 (5 above the pinch and 1 below): Euler's rule, the number of + streams and utilities less one, in each region between the zeros of the + balanced grand composite curve. It is an estimate, not a bound: a subset of + streams that balances on its own needs fewer units. +- **Capital**: the area divided into as many shells of equal area as there + are units, here 6 floating head shells of 991 m², each costed with + biosteam's own correlations: 2.28172e+06 USD installed. When the equal + shells would fall below 150 ft² (14 m²), where biosteam's double pipe costs + a fraction of a shell and tube, the target keeps the shell and tubes the + area fills if they cost more (see :doc:`../concepts`); + ``'N_area_shells'``, ``'shell_area'`` and ``'shell_type'`` say which shells + carry the area. +- **Total annualized cost** (TAC), 1.50882e+07 USD/yr: the installed cost + times the capital recovery factor (371339 USD/yr: 10 % interest over 10 + years by default) plus the utility cost times the operating hours + (1.47169e+07 USD/yr: 8000 h/yr by default). It counts the bare module cost + of the exchangers only, no Lang factor and no working capital. + ``interest=``, ``years=`` and ``operating_hours=`` change the economics, or + ``tea=`` takes them from a biosteam TEA. The facility's targets know their + system: if it has operating hours (a TEA sets them), the summary and the + sweep below use them instead of 8000 h/yr. The quickstart system has none. + +Supertargeting: the least total annualized cost +----------------------------------------------- + +Chapter 4 swept ``T_min_app`` by synthesizing a network at each value. +:func:`~hensmith.supertarget` sweeps the targets instead: it reuses the +streams' curves, so no stream is flashed again, and evaluates the summary at +each ``T_min_app``, then refines the grid around the least TAC. Each point +still builds its own problem table, which reads every stream's enthalpy on the +grid; on a system of many streams with rigorous thermodynamics, a sweep of the +default 40 points and three refinements can take longer than a synthesis, and +a coarser ``T_min_apps`` or a smaller ``refine`` keeps it short. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:sweep] + :end-before: # [end:sweep] + :dedent: + +.. literalinclude:: /_generated/ch05_sweep.txt + :language: text + +The quickstart system is dominated by steam: its operating cost, 1.454e+07 to +1.654e+07 USD/yr over the sweep, is about forty times its annualized capital +cost, so the TAC follows the utility cost and falls as ``T_min_app`` does. Below +about 1.85 K the system is a threshold problem: it needs no cold utility at +all, so the hot utility, its steam and the balanced composite curves no longer +change with ``T_min_app``, and neither does any target. The TACs at 1 and at +1.849 K differ by less than 7 USD/yr, 4e-7 of the TAC, which is no physical +effect: the problem table's grid moves with ``T_min_app`` over the curved +(vapor-liquid) profiles of the streams, and the area follows it, too little +to show in the table (6836 m² at both). Every ``T_min_app`` of that flat +stretch is an optimum but for these few USD/yr. The area, and with it the TAC, +falls steadily across the stretch as the grid moves, so the least TAC is always +the warmest point evaluated below the threshold, here 1.849 K (inside the +sweep, not at its bound), 0.004 K short of the step. That step comes at the +next point, 1.853 K: the cold utility appears and needs a unit of its own (6 +units), which adds about 12000 USD/yr to the TAC. Between 10 and 15 K, where +chapter 4's sweep saw the pinch move to the column condenser's vapor inlet, +the units target jumps to 9. + +.. figure:: /_static/images/examples/tutorial_05_supertargets.png + :class: white-bg + :width: 640 + :alt: Total annualized cost against T_min_app from 1 to 30 K for the quickstart system: the black total annualized cost and the red operating cost both rise almost linearly from about 1.5e7 USD per year to about 1.7e7 USD per year, the blue annualized capital cost stays near 4e5 USD per year along the bottom, and an open circle with a dashed vertical line marks the optimum at 1.85 K. + + The sweep of the quickstart system. The total annualized cost (black) is + the operating cost (red) plus the annualized capital cost (blue); the + circle and the dashed line mark the least TAC, 1.494e+07 USD/yr at 1.849 K. + Capital is a small fraction of the TAC, so the optimum has the least + utility: it is on the flat stretch below 1.85 K, where no cold utility (and + no unit for it) is needed, and any point of that stretch would do. + +A trade-off between capital and energy needs a system whose capital +matters. The two streams below, a hot and a cold one of 100 kW/K each, are +served by steam at 450 K and by water evaporating at 280 K, two latent agents +made for the example with prices of 4e-7 and 2e-7 USD/kJ. Their targets come +straight from their heat utilities with +:meth:`PinchTargets.from_heat_utilities `, +and ``heating_agents=`` and ``cooling_agents=`` are passed on to every summary +of the sweep. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:trade_off] + :end-before: # [end:trade_off] + :dedent: + +.. literalinclude:: /_generated/ch05_trade_off.txt + :language: text + +Here the annualized capital cost (71606.7 USD/yr at the optimum) falls as +``T_min_app`` grows and the operating cost (20031 USD/yr) rises with it, and +the least TAC, 91637.6 USD/yr, is at 21.59 K, well inside the sweep: the +classic supertargeting curve. + +.. figure:: /_static/images/examples/tutorial_05_trade_off.png + :class: white-bg + :width: 640 + :alt: Total annualized cost against T_min_app from 11 to 60 K for a two-stream problem: the blue annualized capital cost falls steeply from about 110000 USD per year and flattens near 53000, the red operating cost rises linearly from near zero to about 87000 USD per year, and the black total annualized cost has a U shape with its minimum, marked by an open circle and a dashed vertical line, at 21.6 K. + + The sweep of the two-stream problem: the capital cost (blue) falls and the + operating cost (red) rises with ``T_min_app``, and their sum (black) has + its minimum, 9.164e+04 USD/yr, at 21.59 K. + +The shells of this pair are large, so the capital cost falls smoothly. With +small streams it falls in steps: about one shell and tube each time the +shrinking area fills one fewer of 150 ft², or when the last one goes and +double pipes take the area. The least TAC then often lies just past such a +step, at the first ``T_min_app`` evaluated beyond it, and says more about +biosteam's 150 ft² than about a trade-off of energy and capital; the sweep's +``'N_area_shells'`` and ``'shell_type'`` (``sweep.to_frame()``) show where the +steps are. A network has such steps too, but where its own exchangers cross +150 ft². + +The network against its targets +------------------------------- + +:func:`~hensmith.network_report` compares the network the facility +synthesized with the targets of its streams, on the same options. + +.. literalinclude:: /../_demo_src/examples/ch05_targeting.py + :language: python + :start-after: # [start:report] + :end-before: # [end:report] + :dedent: + +.. literalinclude:: /_generated/ch05_report.txt + :language: text + +The network reaches the utility cost target, 1839.61 USD/hr: it is at MER, +with the steam and chilled water that the targets allocate. That is not a +rule: the utility cost target is the cost of biosteam's agents placed on the +grand composite curve, not a minimum, and a network at MER, which serves +each stream with one agent at its outlet, can cost more (a costlier agent +over a whole stream) or less (natural gas, which with biosteam's default +prices costs less per kJ than steam, heating a whole stream that ends above +the hottest steam) than the target. The comparison of everything else needs +care too, because a target is an estimate of an ideal network: + +- **Area.** The report's ``'area'``, 7462 m², re-sizes every exchanger of the + network like the target sizes it: the same film coefficients, a correction + factor of 1 and the integrated temperature difference along the streams' + profiles as the targets' curves model them. On that basis the network needs + more area than the 5946 m² target, as it should with equal film + coefficients and the same utilities. + biosteam's own sizing, 5536 m² (``'area_biosteam'``), is lower still, + mostly because it takes an overall coefficient of 1.0 kW/m²/K for + condensing steam against a boiling stream (the steam heaters ``Util_0_hs`` + and ``Util_1_hs``: 177 and 1200 m² instead of 356 and 2983 m²), and because + its terminal log-mean temperature difference overstates the mean driving + force of a glide; it also applies a correction factor for its shells and + sizes utility exchangers on the utility side duty. Because biosteam sizes + on the terminal temperature differences only, an exchanger whose profiles + cross inside it keeps a finite ``'area_biosteam'``, while its ``'area'``, + and so the network's, is infinite, with a ``RuntimeWarning`` naming it. This + happens when biosteam heats cooling water to its limit, 324.82 K, against + a stream that condenses a little below that, as in a vacuum condenser: the + utility's line crosses the condensing stretch (see the Notes of + :func:`~hensmith.network_report`). +- **Units.** The network has 7 exchangers against an estimate of 6 because + it has a loop. Above the pinch, the bottoms cooler ``D1_H1`` heats both the + reboiler (``HX_0_2_hs``) and the flash's feed heater (``HX_1_2_hs``), and + each of these also has its own steam heater (``Util_0_hs`` and + ``Util_1_hs``), so the four exchangers close a loop: reboiler, ``D1_H1``, + feed heater, steam and back to the reboiler. Euler's S - 1 counts the steam + as one stream however many streams it heats, and assumes the matches of a + region form no loop. The estimate can be wrong both ways: a balanced subset + of streams needs fewer units, and each loop needs one more, whether a + repeated match between two streams (two branches of a split stream meeting + the same stream make one too) or, as here, two streams linked both through + process matches and through a shared utility. +- **Cost.** The installed cost and the TAC of the network (2.184e+06 USD and + 15072295 USD/yr) are biosteam's, on biosteam's areas, and here they are + below the targets' (2.282e+06 USD and 15088200 USD/yr), which divide a + larger area into equal shells. Neither is a bound on the other. + +The report's ``'targets'`` is :meth:`~hensmith.PinchTargets.summary` with the +same options, and the network and its targets are priced over the same +operating hours (the system's, if it has them, else 8000 h/yr); +``exchanger_areas`` gives both areas of each exchanger, which is +where a gap between the network and its target can be traced. The +synthesis itself does not optimize area, capital or total cost: it reaches +MER first and then keeps the number of exchangers low. Supertargeting is how +to choose the ``T_min_app`` it synthesizes at. + +Where to next +------------- + +- :doc:`../concepts` -- the multiple-utility rule, the area, units and cost + targets, the sweep, and the limits of each. +- :doc:`../API/targeting` -- every function, class and option of + ``hensmith.targeting``. diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst index 9817616..9e0f57c 100644 --- a/docs/source/tutorial/index.rst +++ b/docs/source/tutorial/index.rst @@ -3,7 +3,9 @@ Tutorial A staged, hands-on tour of hensmith: the quickstart system end to end, the pinch analysis behind its utility targets, the anatomy of the synthesized -network, and how to configure the facility and apply it to a larger system. +network, how to configure the facility and apply it to a larger system, and +targeting utilities, area, units and cost to choose the minimum approach +temperature of the least total annualized cost. Every code block on these pages is included from a script under ``docs/_demo_src/examples`` that also produced the figures and outputs shown, so what you read is what ran. @@ -15,3 +17,4 @@ so what you read is what ran. 02_pinch_analysis 03_network_anatomy 04_configuring + 05_targeting diff --git a/hensmith/__init__.py b/hensmith/__init__.py index b8729c2..af417e3 100644 --- a/hensmith/__init__.py +++ b/hensmith/__init__.py @@ -23,13 +23,16 @@ from ._heat_exchanger_network import * from .hxn_synthesis import * +from .targeting import * from . import _heat_exchanger_network from . import hxn_synthesis +from . import targeting __all__ = ( *_heat_exchanger_network.__all__, *hxn_synthesis.__all__, + *targeting.__all__, ) # %% Register with biosteam diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 6a257c0..a3a0e59 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -17,10 +17,14 @@ @author: sarangbhagwat and yoelcp """ import heapq +import weakref +from collections import Counter +from collections.abc import Mapping import biosteam as bst import numpy as np from .hxn_synthesis import ( synthesize_network, StreamLifeCycle, plot_pinch_diagram, _first_inlet, + _explicit_contributions, _held_owners, _with_auxiliaries, ) from warnings import warn @@ -34,6 +38,26 @@ #: network does leave is 3e-7 of the stream's duty). _SERVED_RTOL = 1e-9 +#: The facilities whose `_replace_unit_heat_utilities` has written their +#: utilities over heat utilities of their units that +#: `_restore_unit_heat_utilities` has not given back yet (see +#: `_replacing_networks`). +_REPLACING = weakref.WeakSet() + +def _replacing_networks(hus): + """ + The facilities that hold heat utilities of `hus` written over by + `HeatExchangerNetwork._replace_unit_heat_utilities` and not given back + yet, by the ``id`` of each such heat utility (a unit simulated again + since holds new heat utilities, which are not among them). + """ + ids = set(map(id, hus)) + networks = {} + for network in list(_REPLACING): + for hu, _ in getattr(network, '_replaced_heat_utilities', None) or (): + if id(hu) in ids: networks.setdefault(id(hu), network) + return networks + def _pass_through_served_streams(stream_life_cycles, original_units): """ Let every stream that the process exchangers bring to its outlet (to @@ -201,7 +225,9 @@ class HeatExchangerNetwork(bst.Facility): ID : str Unique name for the facility. T_min_app : float - Minimum approach temperature observed during synthesis of heat exchanger network. + Minimum approach temperature observed during synthesis of heat + exchanger network: the default approach temperature, required of + every pair of streams unless `approach_contributions` are given. units : Iterable[Unit], optional All unit operations available to the heat exchanger network. Defaults to all unit operations in the system. @@ -220,6 +246,19 @@ class HeatExchangerNetwork(bst.Facility): and ``synthesis_info['splits']`` (`HeatExchangerNetwork`). With `avoid_recycle`, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Defaults to False. + approach_contributions : Mapping or callable, optional + Approach temperature contribution of each process stream [K]: a + match between hot stream h and cold stream c must keep ``c_h + + c_c`` instead of `T_min_app`, and a stream without one contributes + ``T_min_app / 2`` (see Notes, "Approach temperature + contributions"). A mapping ``{key: value}`` whose keys name units + (a unit, its ID if it has no owner, or ``(owner.ID, unit.ID)``; + the nearest key along a unit's chain of owners applies), or a + callable ``f(hu)`` returning the value of a heat utility. A value + is None or nan (unspecified) or a finite number > 0. A sequence + raises a TypeError: the order of the facility's streams is + internal. May be changed between simulations. Defaults to None + (every stream contributes ``T_min_app / 2``). Notes ----- @@ -227,15 +266,16 @@ class HeatExchangerNetwork(bst.Facility): splits by :func:`~hensmith.hxn_synthesis.synthesize_network`: a problem table on the streams' temperature-enthalpy curves gives the minimum energy requirement (MER) targets, and a planner builds each side of the - pinch from the pinch outward [1]_ [2]_, keeping `T_min_app` everywhere - inside every exchanger on the exact stream states. It reaches the - targets whenever its search finds an unsplit network that does; the same - pair of streams may then be matched more than once (IDs with a suffix - ``_``, e.g. ``HX_3_2_cs_2``), since series alternation can replace a - split. Where the pinch design rules prove that MER needs stream - splitting, the network is a best-effort one close to the targets, or, - with `stream_splitting`, one that splits streams to reach them. The - outcome is recorded in `synthesis_info` (a dict; see the `info` keyword + pinch from the pinch outward [1]_ [2]_, keeping each pair's required + approach (`T_min_app` by default) everywhere inside every exchanger on + the exact stream states. It reaches the targets whenever its search + finds an unsplit network that does; the same pair of streams may then be + matched more than once (IDs with a suffix ``_``, e.g. + ``HX_3_2_cs_2``), since series alternation can replace a split. Where + the pinch design rules prove that MER needs stream splitting, the + network is a best-effort one close to the targets, or, with + `stream_splitting`, one that splits streams to reach them. The outcome + is recorded in `synthesis_info` (a dict; see the `info` keyword of `synthesize_network`): 'status' is 'mer' when the network's utilities equal the targets and 'best_effort' otherwise, with the targets, the planned utilities and, per side of the pinch, any proof that a split is @@ -257,6 +297,75 @@ class HeatExchangerNetwork(bst.Facility): the exchangers of every branch), and a split stream's life cycle lists its branch stages with their branch and fraction. + *Approach temperature contributions.* With `approach_contributions`, + each process stream contributes its own approach temperature ``c_j`` + (``T_min_app / 2`` unless given), and every process exchanger keeps + the sum of its two streams' contributions instead of `T_min_app` (see + `synthesize_network`, Notes); each stream's utility exchanger gets its + agent as usual. A key names a unit: the unit itself, the ID of a unit + without an owner (e.g. ``'D1'``), or ``(owner.ID, unit.ID)`` for an + auxiliary unit (e.g. ``('D1', 'condenser')``; a bare auxiliary ID names + nothing). ``owner`` is the top owner, so the pair names every auxiliary + of that ID under it: the heaters of two auxiliary flash drums of one + unit, say, are told apart by keying the heater objects or the drums. A + heat utility takes the value of the key nearest to its unit along the + unit's chain of owners, so ``'D1'`` covers the condenser and the + reboiler of column D1 and ``('D1', 'condenser')`` overrides it for the + condenser; a None or nan value is an explicit default that wins at its + level like any other, and two keys of the same level for a unit must + agree. A unit's owner is its direct owner (the unit it was made an + auxiliary of; `biosteam.Unit.owner` gives the top one) or, for an + auxiliary that a unit available to the network holds without setting + its owner, or an exchanger without one whose heat utilities a unit takes + without holding it as an auxiliary (``self.heat_utilities = + self.hx.heat_utilities``), that unit: a `biosteam.MultistageCompressor` + K1 builds its compressors and intercoolers anew whenever `pr` or + `n_stages` changes, without an owner, and ``'K1'`` still covers the + intercoolers and + ``('K1', 'K1_H1')`` names one in every simulation (the bare ``'K1_H1'`` + names nothing, though it is the key of that intercooler outside the + facility, see `synthesize_network`; user objects are not changed). ID + keys are the robust form: a + unit that rebuilds its auxiliary units at every simulation (e.g. + `biosteam.HXutilities`) leaves a key holding an old auxiliary, which + then names the auxiliary that took its place (the one of the same ID + under owners of the same IDs), if any; an old auxiliary that never had + an owner (an old intercooler of K1) names none, and raises (heat + utility keys are rejected, since BioSTEAM replaces them at every + simulation). The keys and values are checked against every unit + available to the network, its nested auxiliary units and the exchangers + they hold through their heat utilities only, before the + heat utilities are taken: a key that names none of them, a bad value + or two conflicting keys raise a ValueError, even while their unit is + idle, but a valid key whose unit holds no heat utility in a simulation + (no duty, `ignored`, or not available to a heat exchanger network) has + no effect, so that, e.g., Monte Carlo samples do not fail. A key of an + auxiliary that a unit may build but does not hold in a simulation is + valid too, and has no effect: an old auxiliary object of the unit that + none took the place of, or the ID that `biosteam.Unit.auxiliary` gives + an auxiliary under a name of the `auxiliary_unit_names` of the owner or + of one of its auxiliaries, unless that place holds a unit of another + ID: ``(owner.ID, 'name[k]')`` where the attribute `name` holds a list of + auxiliaries (e.g. ``('X1', 'heat_exchangers[2]')`` while a + `biosteam.HXutilities` X1, which builds one exchanger per agent it needs, + needs fewer), and ``(owner.ID, name)`` where it holds a single one (so + ``('X1', 'heat_exchangers')`` raises); or the ID that a + `biosteam.MultistageCompressor` K1 gives its stage n, for any n >= 1 + (``('K1', 'K1_H3')`` while K1 has 2 stages). Not every unit gives its + auxiliaries that ID: the heater of a `biosteam.EnzymeTreatment` T1 has + the ID '', so ``('T1', 'heat_exchanger')`` raises, with a message that + gives the heater's key, ``('T1', '')``. A callable is + called once per heat utility at every simulation (also to check a + cached network, and again if a failed cache synthesizes anew), in the + order of the facility's heat utilities: it should be pure. After a + simulation, `targets` takes the values that simulation resolved instead + of calling it again. + ``synthesis_info`` reports the contributions as used by stream + ('approach_contributions', in the order of `original_heat_exchangers`) + and the smallest margin over each exchanger's required approach + ('min_approach_margin'), and `pinch_Ts` holds each stream's own pinch + temperature. + Original system stream and heat exchanger objects are preserved. All stream copies and new HX objects can be found in a newly created flowsheet '_HXN' where is the name of the system associated @@ -268,13 +377,16 @@ class HeatExchangerNetwork(bst.Facility): its planned state, so the loops are at their fixed point after one pass). - With `cache_network`, a network is reused while the set of heat - exchangers and `stream_splitting` are the same: each process exchanger - keeps, as its enthalpy limit, the share of the stream's duty it had at - synthesis (on the stream that the plan serves completely on that side of - the pinch; its partner transfers that share, but never past its own - outlet, and takes the rest to its utility), and the utility exchangers - bring every stream to its new outlet. The splitters keep their + With `cache_network`, a network is reused while the multiset of heat + exchangers (each as often as it gives a stream: biosteam lists the heat + utility of a unit in parallel once per unit, by ``Unit.parallel``, which + a reactor may set by its volume), `stream_splitting` and the explicit + approach contributions (resolved per stream) are the same: each process + exchanger keeps, as its enthalpy limit, the share of the stream's duty + it had at synthesis (on the stream that the plan serves completely on + that side of the pinch; its partner transfers that share, but never + past its own outlet, and takes the rest to its utility), and the + utility exchangers bring every stream to its new outlet. The splitters keep their fractions, so a branch exchanger's limit is its branch's fraction of the whole stream's. If the cached network does not reproduce the outlets, or its energy balance is off, the network is synthesized again. @@ -386,13 +498,28 @@ class HeatExchangerNetwork(bst.Facility): >>> HXN.synthesis_info['splits'] [] + With approach temperature contributions of 2 K for C1 and H3 (the other + streams keep ``T_min_app / 2 = 5`` K), C1 needs 7 K against H1 and H2 + but only 4 K against H3, and each exchanger's `dT` guard is its pair's + requirement less 1e-6 K: + + >>> HXN.stream_splitting = False + >>> HXN.approach_contributions = {'C1': 2., 'H3': 2.} + >>> sys.simulate() + >>> HXN.synthesis_info['status'] + 'best_effort' + >>> [round(hx.dT, 6) for hx in HXN.new_HXs] + [6.999999, 6.999999, 6.999999, 6.999999, 6.999999, 3.999999] + """ ticket_name = 'HXN' acceptable_energy_balance_error = 0.02 raise_energy_balance_error = False - # Default of an instance without it (e.g. one from before stream - # splitting); `__init__` sets it on every new facility. + # Defaults of an instance without them (e.g. one from before stream + # splitting or approach contributions); `__init__` sets them on every + # new facility. stream_splitting = False + approach_contributions = None network_priority = -2 _N_ins = 0 _N_outs = 0 @@ -402,7 +529,8 @@ class HeatExchangerNetwork(bst.Facility): def __init__(self, ID='', T_min_app=5., units=None, ignored=None, Qmin=1e-3, force_ideal_thermo=False, cache_network=False, avoid_recycle=False, acceptable_energy_balance_error=None, replace_unit_heat_utilities=False, - sort_hus_by_T=False, stream_splitting=False): + sort_hus_by_T=False, stream_splitting=False, + approach_contributions=None): bst.Facility.__init__(self, ID, None, None) self.T_min_app = T_min_app self.units = units @@ -414,16 +542,85 @@ def __init__(self, ID='', T_min_app=5., units=None, ignored=None, Qmin=1e-3, self.replace_unit_heat_utilities = replace_unit_heat_utilities self.sort_hus_by_T = sort_hus_by_T self.stream_splitting = stream_splitting + self.approach_contributions = approach_contributions if acceptable_energy_balance_error is not None: self.acceptable_energy_balance_error = acceptable_energy_balance_error - def _get_original_heat_utilties(self): + def _get_units(self): + """The units whose heat utilities the network may take: the `units` + option (called if callable), or else the system's units.""" sys = self.system if self.units: units = self.units if callable(units): units = units() else: units = sys.units + return units + + def _candidate_units(self, units=None): + """The units that approach contribution keys may name: each unit of + `units` (defaults to `_get_units`) followed by its nested auxiliary + units and the exchangers they hold through their heat utilities + only, whether or not it holds a heat utility the network takes (see + `hensmith.hxn_synthesis._with_auxiliaries`).""" + if units is None: units = self._get_units() + return _with_auxiliaries(units) + + def _network_contributions(self, hx_utils, units=None, held=None): + """ + The explicit approach contributions of `hx_utils` (aligned with + it; None where unspecified), or None if none is given (see + `hensmith.hxn_synthesis._explicit_contributions`). The keys are + checked against every candidate unit (`_candidate_units` of `units`, + the units `hx_utils` were taken from if given), so a bad key or + value raises even while its unit holds no heat utility. `held`, the + holders of auxiliaries held without an owner as taken earlier (see + `hensmith.hxn_synthesis._held_owners`), comes before those of the + units now. + """ + x = self.approach_contributions + if x is None: return None + if not (isinstance(x, Mapping) or callable(x)): + raise TypeError('HeatExchangerNetwork.approach_contributions must ' + 'be a mapping or a callable (the order of the ' + "facility's streams is internal, so a sequence " + f'has nothing to align with), not ' + f'{type(x).__name__!r}') + return _explicit_contributions(hx_utils, x, self.T_min_app, + units=self._candidate_units(units), + held=held) + + def _synthesis_holders(self, hx_utils, units): + """ + The holders of the auxiliaries held without an owner (see + `hensmith.hxn_synthesis._held_owners`) at a synthesis of the heat + utilities `hx_utils` of `units`, which `targets` keeps for the keys + and labels of exchangers that a unit has rebuilt since. With + approach contributions, they are those the keys were resolved by. + Without (the default path), they serve labels only, and the + synthesis never depends on them: they are taken only if an + exchanger's owner chain ends outside `units` (an exchanger held + without an owner, as the intercoolers of a + `biosteam.MultistageCompressor`), so that none of the key machinery + runs otherwise, and are None on any error (labels then go by the + units as they are, plain unit IDs at worst). + """ + hxs = [hu.unit for hu in hx_utils] + if self.approach_contributions is not None: + return _held_owners(hxs + self._candidate_units(units)) + try: + at_hand = {id(unit) for unit in units} + for hx in hxs: + while getattr(hx, '_owner', None) is not None: hx = hx._owner + if id(hx) not in at_hand: break + else: + return None + return _held_owners(hxs + self._candidate_units(units)) + except Exception: + return None + + def _get_original_heat_utilties(self, units=None): + if units is None: units = self._get_units() ignored = self.ignored if ignored: if callable(ignored): ignored = ignored() @@ -465,6 +662,7 @@ def _replace_unit_heat_utilities(self, heat_utilities, stream_life_cycles): else: hu.empty() # a stream the process exchangers serve _load_utility_costs(hu.unit) self._replaced_heat_utilities = replaced + _REPLACING.add(self) def _restore_unit_heat_utilities(self): """ @@ -481,6 +679,7 @@ def _restore_unit_heat_utilities(self): left, would drop out of the next network. """ replaced = getattr(self, '_replaced_heat_utilities', None) + _REPLACING.discard(self) if not replaced: return self._replaced_heat_utilities = None for hu, original in replaced: @@ -497,7 +696,17 @@ def _cost(self): flowsheet = bst.Flowsheet(sys.ID + '_HXN') with flowsheet.temporary(): self._restore_unit_heat_utilities() - hx_utils = self._get_original_heat_utilties() + # once per simulation, for both the heat utilities and the keys (a + # callable `units` is called, an iterable read, once) + units = list(self._get_units()) + hx_utils = self._get_original_heat_utilties(units) + # the explicit approach contributions by heat utility (resolved + # once per simulation; None without any, and with + # approach_contributions None, the default path, none of the key + # machinery runs here or in `_synthesis_holders`) + values = self._network_contributions(hx_utils, units) + value_of = (None if values is None + else {id(hu): v for hu, v in zip(hx_utils, values)}) use_cached_network = False # A network synthesized with other options (stream splitting on or # off), or by a facility from before stream splitting (no options @@ -506,10 +715,21 @@ def _cost(self): and hasattr(self, '_stage_fractions') and getattr(self, '_synthesis_options', None) == (self.stream_splitting,)): - # Units are a stable key to compare whether system has changed configuration. + # Units are a stable key to compare whether system has changed + # configuration, each as often as it gives a stream: biosteam + # lists the heat utility of a unit in parallel once per unit + # (Unit.parallel, which a reactor may set by its volume), so a + # change of that number changes the streams; the explicit + # contributions, resolved per stream, must be those the network + # was synthesized with. hu_by_unit = {hu.unit: hu for hu in hx_utils} use_cached_network = ( - hu_by_unit.keys() == set(self.original_heat_exchangers) + Counter([hu.unit for hu in hx_utils]) + == Counter(self.original_heat_exchangers) + and getattr(self, '_resolved_contributions', None) == ( + None if value_of is None else tuple( + value_of[id(hu_by_unit[hx])] + for hx in self.original_heat_exchangers)) ) with flowsheet.temporary(), bst.IgnoreDockingWarnings(): if use_cached_network: @@ -572,6 +792,7 @@ def _cost(self): # Signed-duty order is the default matching priority of the # synthesis passes: smallest heating duty first among the cold # streams, largest cooling duty first among the hot streams. + holders = self._synthesis_holders(hx_utils, units) hx_utils.sort(key = lambda x: x.duty) self.HXN_flowsheet = HXN_F = flowsheet for i in HXN_F.registries: i.clear() @@ -582,7 +803,10 @@ def _cost(self): synthesize_network(hx_utils, self.T_min_app, self.Qmin, self.force_ideal_thermo, self.avoid_recycle, self.sort_hus_by_T, info=synthesis_info, - stream_splitting=self.stream_splitting) + stream_splitting=self.stream_splitting, + approach_contributions=( + None if value_of is None else + [value_of[id(hu)] for hu in hx_utils])) new_HXs = HXs_hot_side + HXs_cold_side # the realized splits (none without stream splitting) splits = synthesis_info.get('splits', ()) @@ -594,6 +818,7 @@ def _cost(self): self.new_HXs_cold_side = HXs_cold_side self.cold_indices = cold_indices self.original_heat_exchangers = hxs + self._holders = holders self.new_HXs = new_HXs self.new_HX_utils = new_HX_utils self.streams_inlet = streams_inlet @@ -626,10 +851,16 @@ def _cost(self): # of the flow) has f times the whole stream's limit: its # share is that of the whole stream's limit (on the parent # basis), and f is kept in `_stage_scales`. The options the - # network was synthesized with are the cache's key. + # network was synthesized with, and its explicit approach + # contributions (unfilled, in the order of + # `original_heat_exchangers`; None without any), are the + # cache's key. self._stage_fractions = stage_fractions = {} self._stage_scales = stage_scales = {} self._synthesis_options = (self.stream_splitting,) + self._resolved_contributions = ( + None if value_of is None else tuple( + value_of[id(hu)] for hu in hx_heat_utils_rearranged)) for i, life_cycle in enumerate(stream_life_cycles): hx = hx_heat_utils_rearranged[i].unit H_in = hx.ins[0].H @@ -830,6 +1061,231 @@ def plot_pinch_diagram(self, file=None, **kwargs): file=file, **kwargs, ) + def targets(self, T_min_app=None): + """ + The pinch analysis targets of the facility's process streams (see + `hensmith.targeting.PinchTargets`): energy, utilities, area, units, + capital and total annualized cost. + + Parameters + ---------- + T_min_app : float, optional + Minimum approach temperature [K], finite and >= 0 (> 0 with + approach contributions). Defaults to the facility's `T_min_app`. + + Returns + ------- + PinchTargets + The targets, with the facility's `approach_contributions` and + `force_ideal_thermo`, and its `system`, whose operating hours + (if it has them, a TEA's for instance) `PinchTargets.summary` + and `hensmith.targeting.supertarget` then use by default, as + `hensmith.targeting.network_report` does. Their `key_units` are + the units that the facility's approach contribution keys may + name, so that a film coefficient key (``h=``) of a unit without + a stream in a simulation has no effect, as such an approach + contribution key has none. + + Raises + ------ + RuntimeError + If the facility has neither `units` nor a system (it learns its + system when the system is first simulated). + + Warns + ----- + RuntimeWarning + After a simulation, if a stream of the network is that of an + exchanger no longer among the facility's units and their nested + auxiliaries (rebuilt since the facility's last simulation, see + Notes): the targets are of its former state. + RuntimeWarning + After a simulation, if an exchanger gives more or fewer streams + than the facility's units list its heat utility now: a unit in + parallel whose number (``Unit.parallel``, which a reactor may + set by its volume) has changed since, simulated alone (see + Notes). The targets keep a stream per unit of the facility's + last simulation. + RuntimeWarning + If the first inlet and outlet of a stream's exchanger differ in + flow (``ins[0].mol`` and ``outs[0].mol``) by more than 1 % of + the larger: its inlet has changed since its unit was simulated + (see Notes), and the targets take the stream from that new + inlet to the outlet of the former state, a state of no process. + + Notes + ----- + After a simulation, the streams are those of + `original_heat_exchangers`, in the network's stream order, at the + units' current states, each hot or cold by its current direction (a + stream that has reversed since the synthesis, its unit simulated + alone or the network reused by `cache_network`, changes sides; one + without duty keeps its side): right after a synthesis, the energy + targets at the facility's `T_min_app` equal + ``synthesis_info['Q_hot_target']`` and + ``synthesis_info['Q_cold_target']`` bit for bit. The targets' + `heat_utilities` are `original_heat_utils`, which with + `replace_unit_heat_utilities` hold the network's utilities, not the + streams' (see `hensmith.targeting.PinchTargets`). A mapping of + approach contributions is resolved again by the units of + `original_heat_utils`, so a mapping changed since applies (an + auxiliary that a unit held without an owner at the synthesis, such + as an intercooler of a `biosteam.MultistageCompressor` K1, is still + that unit's, as are the targets' film coefficient keys, even after + K1 has rebuilt its stages: K1 simulated alone, or a later sample of + stored targets; the targets keep that relation); a + callable is not called again (with `replace_unit_heat_utilities`, the + heat utilities it would read hold the network's utilities by then): + its values are those the last simulation resolved, and a callable set + since takes effect at the next simulation. Before any simulation, + the streams are those of the heat utilities the next simulation + would take, prepared as the synthesis prepares them, and a callable + is called once per heat utility. + Nothing is stored: with `cache_network`, the targets follow the + units' current states, not those of the network's synthesis. A + unit that rebuilds its auxiliaries when simulated alone (K1 above, + after a change of its `pr`) leaves the streams on its former + stages, which are no longer in the process and keep their former + states, until the system is simulated again: the targets are then + of those former states, not of the process now, with a + RuntimeWarning (`hensmith.targeting.network_report` raises + meanwhile). Each stream runs from its exchanger's inlet now to its + outlet as its unit last left it: after a feed or another stream + changes, simulate the system before targeting (or the units alone, + which gives the targets of the process now, unless a unit in + parallel changes its number of units: biosteam lists its heat + utilities once per unit, and the streams stay one per unit at the + facility's last simulation, with a RuntimeWarning; + `hensmith.targeting.network_report` raises). A stream whose inlet + has changed in flow meanwhile warns (see Warns); a change of + temperature alone keeps the flows equal and warns of nothing, its + targets being right only if its unit sets its outlet by + temperature (as a `biosteam.HXutility` with ``T`` does). + + Examples + -------- + The liquids of the second example of the class at 10 K (whose + unsplit network falls short of the energy targets): + + >>> import biosteam as bst + >>> bst.main_flowsheet.set_flowsheet('HXN_targets_example') + >>> bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + >>> def process_stream(ID, T_in, T_out, P, **kmol_hr): + ... inlet = bst.Stream(ID + '_in', T=T_in, P=P, phase='l', + ... units='kmol/hr', **kmol_hr) + ... return bst.HXutility(ID, ins=inlet, T=T_out, rigorous=False) + >>> units = [process_stream('H1', 400., 320., 5e5, Water=60.), + ... process_stream('H2', 390., 330., 5e5, Ethanol=30.), + ... process_stream('C1', 310., 410., 5e5, Water=150.), + ... process_stream('H3', 360., 300., 101325., Water=200.)] + >>> HXN = bst.HeatExchangerNetwork('HXN', T_min_app=10.) + >>> sys = bst.System.from_units('sys', units=[*units, HXN]) + >>> sys.simulate() + >>> HXN.synthesis_info['status'] + 'best_effort' + + The targets [kW] are the synthesis's own, bit for bit: + + >>> targets = HXN.targets() + >>> targets.labels + ['C1', 'H3', 'H1', 'H2'] + >>> round(targets.Q_hot / 3600, 3), round(targets.Q_cold / 3600, 3) + (102.821, 209.035) + >>> info = HXN.synthesis_info + >>> targets.Q_hot == info['Q_hot_target'], targets.Q_cold == info['Q_cold_target'] + (True, True) + + At another minimum approach temperature: + + >>> HXN.targets(20.) + + + """ + from .targeting import _warn_stale, _miscounted_names + targets, rebuilt, miscounted, stale = self._pinch_targets(T_min_app) + if rebuilt is not None: + warn(f'the targets of {self.ID} are of the former state of ' + f"{rebuilt}, which is no longer one of the facility's units " + "nor an auxiliary of one (rebuilt since the facility's last " + 'simulation?): simulate the system for those of the process ' + 'now', RuntimeWarning, stacklevel=2) + if miscounted: + warn(f'the targets of {self.ID} take a stream per heat utility ' + "the facility's units listed at its last simulation, not " + 'as they list them now: ' + f'{_miscounted_names(miscounted)} (Unit.parallel changed ' + 'since, its unit simulated alone?): simulate the system for ' + 'those of the process now', RuntimeWarning, stacklevel=2) + _warn_stale(stale, f' of {self.ID}') + return targets + + def _pinch_targets(self, T_min_app=None): + """`targets` (see there), the label of the first of their streams + whose exchanger is no longer among the facility's units, their + nested auxiliaries and the exchangers they hold through their heat + utilities only (rebuilt since the last simulation), or None, the + exchangers of the others that give more or fewer streams than the + facility's units list their heat utility now (a unit in parallel + whose number has changed since, see + `hensmith.targeting._miscounted`), and the labels of the streams + whose exchanger's inlet has changed in flow since its unit was + simulated (see `hensmith.targeting._stale_labels`). They are None + and empty before a simulation, whose targets, + `PinchTargets.from_heat_utilities`, take the heat utilities as + listed now and warn of the last themselves.""" + from .targeting import (PinchTargets, _rebuilt_label, _stale_labels, + _miscounted) + if T_min_app is None: T_min_app = self.T_min_app + if (hasattr(self, 'original_heat_exchangers') + and hasattr(self, 'original_heat_utils')): + hxs = self.original_heat_exchangers + hus = self.original_heat_utils # in stream order + cold = set(self.cold_indices) + # hot or cold by the current direction of each stream (the heat + # utility, which replace_unit_heat_utilities overwrites, cannot + # tell): a stream that has reversed since the synthesis changes + # sides, one without duty keeps its side + is_hot = [] + for i, hx in enumerate(hxs): + dH = hx.outs[0].H - hx.ins[0].H + is_hot.append(dH < 0. if dH else i not in cold) + x = self.approach_contributions + units = list(self._get_units()) + key_units = self._candidate_units(units) + # the holders of the auxiliaries held without an owner as at the + # synthesis, which a holder that has rebuilt them since (a + # MultistageCompressor simulated alone) no longer holds + held = getattr(self, '_holders', None) + if callable(x) and not isinstance(x, Mapping): + # Never called again: with replace_unit_heat_utilities, the + # heat utilities it would read now hold the network's + # utilities (or nothing, for a stream the process exchangers + # serve). Its values, as the last simulation resolved them, + # are in stream order. + contributions = getattr(self, '_resolved_contributions', None) + else: + # by unit only, which the network never changes + contributions = self._network_contributions(hus, units, held) + targets = PinchTargets._from_exchangers( + hxs, is_hot, T_min_app, contributions, self.force_ideal_thermo, + None, hus, _held_owners([*hxs, *key_units], held)) + targets.key_units = key_units + targets.system = self.system + return (targets, _rebuilt_label(hxs, targets.labels, key_units), + _miscounted(hxs, targets.labels, units, key_units), + _stale_labels(hxs, targets.labels)) + if self.system is None and not self.units: + raise RuntimeError('simulate the system (or give the facility its ' + 'units) before targeting') + units = list(self._get_units()) + hx_utils = self._get_original_heat_utilties(units) + hx_utils.sort(key=lambda x: x.duty) # as `_cost` sorts them + targets = PinchTargets.from_heat_utilities( + hx_utils, T_min_app, self._network_contributions(hx_utils, units), + self.force_ideal_thermo, self.sort_hus_by_T, key_units=units) + targets.system = self.system + return targets, None, [], [] + def get_original_hxs_associated_with_streams(self): # pragma: no cover original_units = self.system.units original_heat_utils = self.original_heat_utils diff --git a/hensmith/_planner.py b/hensmith/_planner.py index a4374bb..f00c86a 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -34,7 +34,10 @@ the highest for a cold one. Interior knots within ``_KNOT_TOL`` K of the chord through their neighbours are dropped, so a constant-CP stream becomes a single segment and the fast paths apply. On the *shifted* scale ``T*``, -hot streams are moved down by ``T_min_app`` and cold streams are not moved. +stream j is moved down by its shift ``s_j``: ``T_min_app`` for hot and 0 +for cold streams, or ``T_min_app/2 + c_j`` (hot) and ``T_min_app/2 - c_j`` +(cold) with approach contributions ``c_j``, so a pair feasible on the +shifted scale keeps ``c_h + c_c``. Shifted temperatures closer than ``_LEVEL_EQ`` K are merged, exactly as the problem table merges its grid. @@ -54,10 +57,10 @@ ====== ====== ===== ======================= side stream role level ``y`` ====== ====== ===== ======================= -above hot must ``T - T_min_app`` -above cold flex ``T`` -below cold must ``-T`` -below hot flex ``-(T - T_min_app)`` +above hot must ``T - s_j`` +above cold flex ``T - s_j`` +below cold must ``-(T - s_j)`` +below hot flex ``-(T - s_j)`` ====== ====== ===== ======================= A *must* stream has to be served completely by process matches on its side: @@ -176,7 +179,8 @@ Guarantees and limits --------------------- - The utilities are never below the targets. Every planned exchanger keeps - ``T_min_app`` on the knot curves at its ends and at every knot inside it. + its pair's required approach (``T_min_app``, or ``c_h + c_c``) on the + knot curves at its ends and at every knot inside it. The heat balance closes on every stream, and ``'mer'`` is never claimed falsely. Results are deterministic. - Every pruning test is a necessary condition. A missed MER network can @@ -229,6 +233,7 @@ import math from bisect import bisect_left, bisect_right from collections import defaultdict +from numbers import Real import numpy as np @@ -1862,11 +1867,81 @@ def _simplify(T, H, hot): return np.array(Tk), np.array(Hk) -def _stream_curves(knots, is_hot, T_min_app): - """Validate, shift, snap and simplify every stream's knots; None for a - stream without duty.""" +def _contribution_value(x, label): + """ + A stream's approach temperature contribution [K]: None for None or nan + (unspecified), ``float(x)`` for a finite real number > 0 (not a bool); + a ValueError naming `label` otherwise. + """ + if x is None: return None + if isinstance(x, Real) and not isinstance(x, (bool, np.bool_)): + value = float(x) + if math.isnan(value): return None + if math.isfinite(value) and value > 0.: return value + raise ValueError(f'the approach contribution of {label} must be a ' + f'finite number > 0 K, or None, not {x!r}') + + +def _given_contributions(N, T_min_app, contributions): + """ + The validated contributions of `N` streams (a list of `N` values, see + `_contribution_value`), or None if `contributions` is None or every + value is unspecified (the default path, for any `T_min_app`). The + length is checked first, then every value, then, only when a value is + given, that `T_min_app` is finite and > 0. + """ + if contributions is None: return None + contributions = list(contributions) + if len(contributions) != N: + raise ValueError(f'{len(contributions)} approach contributions given ' + f'for {N} streams') + values = [_contribution_value(x, f'stream {j}') + for j, x in enumerate(contributions)] + if all(x is None for x in values): return None + if not (math.isfinite(T_min_app) and T_min_app > 0.): + raise ValueError('T_min_app must be finite and > 0 with approach ' + f'contributions, not {T_min_app!r}') + return values + + +def _approach_contributions(N, T_min_app, contributions=None): + """ + The (N,) approach temperature contributions [K] of `N` streams: + `contributions` (one entry per stream, see `_given_contributions`) with + every unspecified one, or all of them if `contributions` is None, at + ``T_min_app / 2``. + """ + half = T_min_app / 2. + values = _given_contributions(N, T_min_app, contributions) + if values is None: return np.full(N, half) + return np.array([half if x is None else x for x in values], dtype=float) + + +def _approach_shifts(is_hot, T_min_app, contributions=None): + """ + Each stream's shift onto the shifted scale, ``T* = T - shift``: + ``T_min_app`` for hot and 0 for cold streams without `contributions` + (or with every value unspecified), else ``T_min_app/2 + c`` for hot and + ``T_min_app/2 - c`` for cold streams (`_approach_contributions`), so + that a hot and a cold stream that touch on the shifted scale are ``c_h + + c_c`` apart. Unspecified contributions next to given ones keep the + default shifts bit for bit (``x/2 + x/2 == x``, ``x/2 - x/2 == 0.``). + """ + is_hot = np.asarray(is_hot, dtype=bool) + values = _given_contributions(is_hot.size, T_min_app, contributions) + if values is None: + return np.where(is_hot, T_min_app, 0.) + half = T_min_app / 2. + c = np.array([half if x is None else x for x in values], dtype=float) + return np.where(is_hot, half + c, half - c) + + +def _stream_curves(knots, is_hot, T_min_app, shift=None): + """Validate, shift (by `shift`, default `_approach_shifts`), snap and + simplify every stream's knots; None for a stream without duty.""" + if shift is None: shift = _approach_shifts(is_hot, T_min_app) raw = [] - for kn, hot in zip(knots, is_hot): + for j, (kn, hot) in enumerate(zip(knots, is_hot)): T, H = kn T = np.asarray(T, float).ravel() H = np.asarray(H, float).ravel() @@ -1885,7 +1960,7 @@ def _stream_curves(knots, is_hot, T_min_app): 'non-decreasing') T = np.maximum.accumulate(T) H = np.maximum.accumulate(H) - raw.append(_simplify(T - (T_min_app if hot else 0.), H, hot)) + raw.append(_simplify(T - shift[j], H, hot)) if not raw: return [] u = np.unique(np.concatenate([r[0] for r in raw])) @@ -2042,7 +2117,8 @@ class Plan: Targets of the planner's cascade. pinch_T : float Pinch on the shifted scale (hot streams shifted down by - ``T_min_app``). + ``T_min_app`` by default): stream j's real pinch is at ``pinch_T + + shift_j`` (``plan.info['shift']`` with approach contributions). cut : {'above', 'below'} Side of the pinch the point loads at `pinch_T` belong to. exchangers : list[Exchanger] @@ -2077,8 +2153,12 @@ class Plan: ``candidate`` and ``signature`` None), ``qmin_dropped`` (list of (side, hot, cold, Q)), ``dropped`` (matches removed by the safety net; always empty unless there is a bug), - ``min_approach`` (on the knot curves), ``work``, ``scale``, - ``tolQ``, ``cascade`` (the planner's problem table). + ``min_approach`` (the smallest real approach on the knot curves), + ``work``, ``scale``, ``tolQ``, ``cascade`` (the planner's problem + table); with `approach_contributions` also ``shift`` (each stream's + shift, ``T* = T - shift``) and ``min_approach_margin`` (the smallest + approach less its pair's required approach, on the shifted scale; + None without exchangers). """ __slots__ = ('status', 'Q_hot_target', 'Q_cold_target', 'pinch_T', 'cut', 'exchangers', 'stages', 'utility', 'Q_hot', 'Q_cold', @@ -2140,7 +2220,8 @@ def _walk(curves, recs, N): def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., - work_scale=1., stream_splitting=False, _split_exclude=None, + work_scale=1., stream_splitting=False, + approach_contributions=None, _split_exclude=None, _split_prefer=None): """ Plan a heat exchanger network at minimum energy requirement, unsplit @@ -2158,7 +2239,8 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., is_hot : sequence[bool] True for streams that are cooled. T_min_app : float - Minimum approach temperature. + Minimum approach temperature: the required approach of every pair + unless `approach_contributions` are given. avoid_recycle : bool, optional Never match the same (hot, cold) pair twice anywhere. Qmin : float, optional @@ -2183,6 +2265,14 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., each side's chosen candidate; `synthesize_network` realizes them. With `avoid_recycle`, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Defaults to False. + approach_contributions : sequence[float or None], optional + Approach temperature contribution of each stream [K], in the order + of `knots`: None or nan (unspecified: ``T_min_app / 2``), or a + finite number > 0 (then `T_min_app` must be finite and > 0). A + match between hot stream h and cold stream c keeps ``c_h + c_c``: + stream j is planned on the shifted scale ``T - s_j``, with ``s_j = + T_min_app/2 + c_j`` for a hot and ``T_min_app/2 - c_j`` for a cold + stream. Defaults to None (every pair keeps `T_min_app`). _split_exclude : dict[str, set[tuple]], optional Private (the refine loop): per side, network signatures a split candidate may not have unless it is the side's last candidate. @@ -2262,7 +2352,8 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., dT = float(T_min_app) if not math.isfinite(dT): raise ValueError('T_min_app must be finite') - curves = _stream_curves(knots, is_hot, dT) + shift = _approach_shifts(is_hot, dT, approach_contributions) + curves = _stream_curves(knots, is_hot, dT, shift) act = [c for c in curves if c is not None] scale = float(sum(c.duty for c in act)) tolQ = _REL_Q * max(scale, 1.) @@ -2365,11 +2456,26 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., if g > 0.}) if stream_splitting: side_info[name]['split'] = p.split + if approach_contributions is None: + min_approach = (dT + min_dT) if recs else None + else: + # each exchanger's real approach: its shifted one plus its pair's + # required approach (the difference of the two streams' shifts) + if split_mode: + from ._splitting import _approach_violation_split as violation + else: + violation = _approach_violation + min_approach = min( + (violation(curves[e.hot], curves[e.cold], e) + + float(shift[e.hot] - shift[e.cold]) for e in recs), + default=None) plan.info = dict(sides=side_info, qmin_dropped=qmin_dropped, - dropped=dropped, - min_approach=(dT + min_dT) if recs else None, + dropped=dropped, min_approach=min_approach, work=sum(p.work for p in plans.values()), scale=scale, tolQ=tolQ, cascade=table) + if approach_contributions is not None: + plan.info.update(shift=shift, + min_approach_margin=min_dT if recs else None) return plan @@ -2380,7 +2486,8 @@ def _plan_numeric(streams, dTmin, knots=1, **kwargs): Plan a constant-CP problem given as dicts ``{name, kind ('hot' | 'cold'), T_in, T_out, CP}``; `knots` > 1 splits every stream into that many collinear pieces (discretisation tests). Keywords go to - :func:`plan_network`. + :func:`plan_network`; ``approach_contributions`` is in the order of + `streams`. Returns a dict in the certificate format of the scratch oracle: ``matches`` (side, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index fb3907e..cb04e5e 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -15,6 +15,7 @@ cycles (`StreamLifeCycle`) and pinch diagrams (`plot_pinch_diagram`). """ from collections import namedtuple +from collections.abc import Iterable, Mapping, Sequence import heapq import math import re @@ -23,7 +24,8 @@ from warnings import warn from ._curves import (StreamCurve, stream_curves, _end_state, _T_EQ, _T_SIDE, _copy, _point_load_inlet) -from ._planner import plan_network +from ._planner import (plan_network, _approach_shifts, + _approach_contributions, _contribution_value) from ._splitting import Split, _ISO_TOL, _same_network __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', @@ -472,7 +474,8 @@ def show(self): ProblemTable.__doc__ = """ Result of `problem_table`: the temperature-interval heat cascade of a set of process streams on the *shifted* temperature scale (hot streams shifted down -by the minimum approach temperature, cold streams unshifted). +by the minimum approach temperature, cold streams unshifted; with approach +contributions every stream by its own shift, see `problem_table`). Attributes ---------- @@ -508,8 +511,10 @@ def show(self): Minimum cold utility target [kJ/hr]. pinch_T : float Shifted grid temperature of the pinch [K] (``Ts[0]`` for a threshold - problem); the hot-stream pinch temperature is `pinch_T + T_min_app`, - the cold-stream one is `pinch_T`. + problem). Stream j's pinch temperature is ``pinch_T + s_j``, its shift: + ``pinch_T + T_min_app`` (hot) and ``pinch_T`` (cold) by default, + ``pinch_T + T_min_app/2 + c_j`` (hot) and ``pinch_T + T_min_app/2 - + c_j`` (cold) with approach contributions. See Also -------- @@ -518,7 +523,7 @@ def show(self): """ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, - curves=None): + curves=None, approach_contributions=None): """ Energy-consistent problem table (temperature-interval heat cascade). @@ -531,11 +536,19 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, is_hot : Sequence[bool] True where the stream is cooled. T_min_app : float - Minimum approach temperature [K]. + Minimum approach temperature [K]: the required approach of every + pair of streams unless `approach_contributions` are given. curves : list, optional Prebuilt temperature-enthalpy curves of the same streams, in the same order (e.g. shared with the network synthesis); built here if not given. + approach_contributions : Sequence[float or None], optional + Approach temperature contribution of each stream [K], in the order + of `streams_inlet`: None or nan (unspecified: ``T_min_app / 2``), or + a finite number > 0 (`T_min_app` must then be finite and > 0 too). + A hot and a cold stream then need the sum of their contributions + between them (see Notes). Defaults to None (every pair needs + `T_min_app`). Returns ------- @@ -560,7 +573,8 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, within 0.002 K of the true curve. Single-phase stretches are evaluated with their phases fixed (no flash), so a grid point exactly at a saturation temperature is never ambiguous. Hot streams are shifted down - by `T_min_app`; cold streams are not. The grid is the union of all + by `T_min_app`; cold streams are not (see below for approach + contributions). The grid is the union of all shifted breakpoints, so every point at which any stream's curve bends or jumps is a grid point, every stream is within 0.002 K of linear between grid points, and the grid minimum of the cascade is the true @@ -589,6 +603,14 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, first location the pinch. With the per-stream identity above, hot_util_load - cold_util_load equals the net heating demand. + With `approach_contributions` ``c_j`` (Kemp 2007), hot streams are + shifted down by ``T_min_app/2 + c_j`` and cold streams by + ``T_min_app/2 - c_j``, so that a hot and a cold stream at the same + shifted temperature are ``c_h + c_c`` apart: every match must keep the + sum of its two streams' contributions. An unspecified contribution is + ``T_min_app/2``; with none specified, the table is the default one, bit + for bit. + Examples -------- A threshold problem: 1000 kmol/hr of water cooled 400 -> 300 K supplies @@ -614,12 +636,27 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, 1445550.0 >>> table.pinch_T 395.0 + + If the cold stream contributes 10 K and the hot one the default 2.5 K, + the cold stream needs 12.5 K against the hot one, which a 400 K inlet + cannot give against a 390 K outlet, so hot utility appears. The hot + stream's pinch temperature is ``395 + 5 = 400`` K and the cold one's + ``395 - 7.5 = 387.5`` K: + + >>> table = problem_table([hot_in, cold_in], [hot_out, cold_out], + ... [True, False], 5., approach_contributions=[None, 10.]) + >>> table.Ts[[0, -1]] + array([397.5, 295. ]) + >>> round(table.hot_util_load, 3) + 171724.348 + >>> table.pinch_T + 395.0 """ return _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, - curves)[0] + curves, approach_contributions)[0] def _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, - curves=None): + curves=None, approach_contributions=None): """ Return the `ProblemTable` of `problem_table` together with the stream curves it was built from and its grid enthalpies, as ``(table, curves, @@ -629,8 +666,11 @@ def _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, * 'Ts': the table's shifted grid temperatures (descending, n); * 'shift': each stream's shift (`T_min_app` for hot streams, 0 for - cold ones; N), so stream j's real temperature at grid index k is - ``Ts[k] + shift[j]``; + cold ones by default; ``T_min_app/2 + c_j`` and ``T_min_app/2 - c_j`` + with `approach_contributions`; N), so stream j's real temperature at + grid index k is ``Ts[k] + shift[j]``; + * 'contributions': each stream's approach temperature contribution + [K], unspecified ones (all by default) at ``T_min_app / 2`` (N); * 'Hl', 'Hr': (N x n) each stream's enthalpy at every grid temperature, left (low-enthalpy) and right (high-enthalpy) limit; they differ only where the stream's curve has a flat (point load) at that grid @@ -647,7 +687,7 @@ def _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, N = len(streams_inlet) is_hot = np.asarray(is_hot, dtype=bool) sign = np.where(is_hot, 1., -1.) - shift = np.where(is_hot, T_min_app, 0.) + shift = _approach_shifts(is_hot, T_min_app, approach_contributions) if curves is None: curves = stream_curves(streams_inlet, streams_quenched, is_hot) elif len(curves) != N: @@ -702,7 +742,9 @@ def _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, cold_util_load = 0. table = ProblemTable(Ts, interval_H, point_H, residual, hot_util_load, cold_util_load, Ts[k_pinch]) - grid = dict(Ts=Ts, shift=shift, Hl=Hl, Hr=Hr, k_hi=k_hi, k_lo=k_lo) + grid = dict(Ts=Ts, shift=shift, Hl=Hl, Hr=Hr, k_hi=k_hi, k_lo=k_lo, + contributions=_approach_contributions( + N, T_min_app, approach_contributions)) return table, curves, grid def _pinch_cut(table): @@ -726,26 +768,571 @@ def _pinch_cut(table): arriving = table.residual - point_total return 'below' if arriving[k] <= table.residual[k] else 'above' -def _pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, - sort_hus_by_T=False): - """ - The first step of `synthesize_network`: prepare the process streams - behind `hus` and run the problem table on them. Returns the 12 values - of `temperature_interval_pinch_analysis` (which wraps this function) - and then the table's ``table, curves, grid`` (see `_problem_table`), - on which the network is planned, so that the curves are built only - once. - """ - hx_utils = hus - hus_heating = [hu for hu in hx_utils if hu.duty > 0] - hus_cooling = [hu for hu in hx_utils if hu.duty < 0] +def _parent(unit, owners=None): + """The unit one step up the owner chain of `unit`: ``unit._owner`` or, + without one, the unit that holds `unit` by `owners` (see + `_held_owners`), or None for a top unit.""" + owner = getattr(unit, '_owner', None) + if owner is None and owners: + held = owners.get(id(unit)) + if held is not None and held[0] is unit: owner = held[1] + return owner + +def _top(unit, owners=None): + """The top owner of `unit` along `_parent` (`unit` itself without an + owner).""" + while (owner := _parent(unit, owners)) is not None: unit = owner + return unit + +def _held_owners(units, held=None): + """ + The owners that the units at hand give the auxiliary units they hold + without setting their owner: a dict from the id of such an auxiliary to + ``(auxiliary, holder)``, for each `biosteam.Unit` without an ``_owner`` + among the `auxiliary_units` of a unit of `units` or of one on their + owner chains (the first holder wins; none that would close a loop). + `biosteam.System.from_units` sets the owners of the auxiliaries that + exist when it is called, but some units build theirs anew without one: + `biosteam.MultistageCompressor` rebuilds its compressors and + intercoolers whenever `pr` or `n_stages` changes, so the keys of its + ID cover them only by this relation (see `_parent`). `held`, such a + dict taken earlier (a snapshot, which keeps its objects alive), comes + first: its auxiliaries keep their holders even once a holder has + rebuilt them (no longer holds them), and the units at hand add the + others, along its owners. Then a unit without an ``_owner`` that none + holds as an auxiliary, but whose heat utility is among the + `heat_utilities` of units on those chains (an exchanger kept outside + the `auxiliary_unit_names`, see `_heat_utility_units`), is held by the + lowest of them (an owner lists the heat utilities of its auxiliaries + too), none that would close a loop. Nothing is mutated. + """ + owners = dict(held) if held else {} + chains = _owner_chains(units, owners) + + def closes_loop(aux, unit): + above = unit + while above is not None and above is not aux: + above = _parent(above, owners) + return above is not None + + for unit in chains: + for aux in getattr(unit, 'auxiliary_units', ()): + if (not isinstance(aux, bst.Unit) + or getattr(aux, '_owner', None) is not None + or id(aux) in owners): + continue + if not closes_loop(aux, unit): owners[id(aux)] = (aux, unit) + by_heat_utilities = {} + for unit in chains: + for hx in _heat_utility_units([unit]): + if getattr(hx, '_owner', None) is None and id(hx) not in owners: + by_heat_utilities.setdefault(id(hx), (hx, []))[1].append(unit) + for hx, holders in by_heat_utilities.values(): + holders = [unit for unit in holders if not closes_loop(hx, unit)] + # the lowest holder: one on the chain of no other holder + lowest = [unit for unit in holders + if not any(above is unit for other in holders + if other is not unit + for above in _owner_chains([other], owners)[1:])] + if lowest: owners[id(hx)] = (hx, lowest[0]) + return owners + +def _unit_name(unit, owners=None): + """``(owner.ID, unit.ID)``, with the top owner (``(ID, ID)`` for a unit + without an owner; owners along `_parent`): the unit's tuple key.""" + return _top(unit, owners).ID, unit.ID + +def _id_path(unit, owners=None): + """The IDs of `unit` and of its owners in turn (along `_parent`), + ``(unit.ID, owner.ID, ..., top.ID)``, which tell apart auxiliary units + of the same ID under different owners.""" + path = [] + while unit is not None: + path.append(unit.ID) + unit = _parent(unit, owners) + return tuple(path) + +def _unit_message_name(unit, owners=None): + """How messages name a unit: its tuple key (`_unit_name`), followed by + its owners below the top one when there are any (the tuple key of an + auxiliary of an auxiliary is shared by its namesakes).""" + path = _id_path(unit, owners) + label = repr(_unit_name(unit, owners)) + if len(path) <= 2: return label + return f"{label} (of {' of '.join(map(repr, path[1:-1]))})" + +def _names_unit(key, unit, live=(), owners=None): + """ + Whether the approach contribution key `key` (of a valid type, see + `_check_contribution_key`) names `unit`, with owners along `_parent` + (`owners`, see `_held_owners`). A unit key names itself; a unit key + that is not a unit at hand (its id not in `live`, the ids of the units + at hand: a stale auxiliary that its owner rebuilt) names the unit of + the same `_id_path` instead. A tuple key names every unit of that + `_unit_name` (every auxiliary of that ID under that top owner, at any + depth) and a str key the unit of that ID without an owner. + """ + if isinstance(key, bst.Unit): + if key is unit: return True + return (id(key) not in live + and _id_path(key, owners) == _id_path(unit, owners)) + if isinstance(key, tuple): return key == _unit_name(unit, owners) + return key == unit.ID and _parent(unit, owners) is None + +def _contribution_key_level(key, unit, live=(), owners=None): + """The number of steps along the owner chain of `unit` (`unit`, its + owner, ..., along `_parent` with `owners`) to the first unit that `key` + names (see `_names_unit`, which takes `live`), or None.""" + level = 0 + while unit is not None: + if _names_unit(key, unit, live, owners): return level + unit = _parent(unit, owners) + level += 1 + return None + +def _check_contribution_key(key): + """Raise a TypeError unless `key` is a unit, a str or a pair of str.""" + if isinstance(key, bst.HeatUtility): + raise TypeError('a heat utility cannot be an approach_contributions ' + 'key (biosteam replaces the heat utilities of a unit ' + 'at every simulation): use its unit (hu.unit), the ' + "unit's ID or (owner.ID, unit.ID), or a callable (or, " + 'outside a HeatExchangerNetwork, a sequence aligned ' + 'with the heat utilities)') + if isinstance(key, (bst.Unit, str)): return + if (isinstance(key, tuple) and len(key) == 2 + and all(isinstance(i, str) for i in key)): return + raise TypeError('approach_contributions keys must be units, unit IDs ' + '(str) or (owner.ID, unit.ID) tuples, not ' + f'{type(key).__name__!r} ({key!r})') + +def _owner_chains(units, owners=None): + """Every unit on the owner chains of `units` (along `_parent` with + `owners`), once each.""" + found = {} + for unit in units: + while unit is not None and id(unit) not in found: + found[id(unit)] = unit + unit = _parent(unit, owners) + return list(found.values()) + +def _nested_auxiliaries(unit): + """ + The `biosteam.Unit` objects that `unit` holds as auxiliary units, at + any depth, in the order of `biosteam.Unit.nested_auxiliary_units`. + Unlike that property, this reads the auxiliaries of units only (an + object that is not one, such as the `biosteam.VacuumSystem` of a flash + drum under vacuum, has none, and the property fails on it one level + down), and it takes each unit once, never `unit` itself, and walks + into each unit at most once (so units that hold each other end it). + """ + nested = [] + found = {id(unit)} # the units listed (and `unit`) + walked = set() # the units `walk` went into + + def add(aux): + if isinstance(aux, bst.Unit) and id(aux) not in found: + found.add(id(aux)) + nested.append(aux) + + def walk(holder): + # as `nested_auxiliary_units`: each attribute of `holder` (a unit or + # a list of them) before the auxiliaries of its units, and the + # auxiliaries of those that are units each before its own + walked.add(id(holder)) + for name in getattr(holder, 'auxiliary_unit_names', ()): + held = getattr(holder, name, None) + if held is None: continue + group = list(held) if isinstance(held, Iterable) else [held] + for aux in group: add(aux) + for aux in group: + if not isinstance(aux, bst.Unit): continue + for sub in getattr(aux, 'auxiliary_units', ()): + add(sub) + if isinstance(sub, bst.Unit) and id(sub) not in walked: + walk(sub) + + walk(unit) + return nested + +def _heat_utility_units(units): + """ + The `biosteam.Unit` objects that the heat utilities of `units` belong + to (``hu.unit``), each once, other than the units of `units`: the + exchangers that a unit holds through its `heat_utilities` only, outside + its `auxiliary_unit_names`, as a unit may keep an exchanger as a plain + attribute and take its heat utilities in its `_design` + (``self.heat_utilities = self.hx.heat_utilities``; biosteam rejects + only the heat utilities of auxiliaries added again). A + `HeatExchangerNetwork` takes those heat utilities as any other + (`biosteam.process_tools.heat_exchanger_utilities_from_units` reads + `unit.heat_utilities`), so such exchangers are among its original + heat exchangers. An owner lists the heat utilities of its auxiliaries + too, whose units are its nested auxiliaries. + """ + found = {id(unit) for unit in units} + held = [] + for unit in units: + for hu in getattr(unit, 'heat_utilities', None) or (): + hx = getattr(hu, 'unit', None) + if isinstance(hx, bst.Unit) and id(hx) not in found: + found.add(id(hx)) + held.append(hx) + return held + +def _with_auxiliaries(units): + """Each unit of `units` followed by its nested auxiliary units (those + that are `biosteam.Unit` objects, see `_nested_auxiliaries`) and by the + exchangers that it or they hold through their heat utilities only (see + `_heat_utility_units`): the units that keys may name, as + `HeatExchangerNetwork` takes those available to it, whether or not they + hold a heat utility, and among which its original exchangers are found + while no unit has rebuilt them (see + `hensmith.targeting._rebuilt_label`).""" + candidates = [] + for unit in units: + group = [unit, *_nested_auxiliaries(unit)] + candidates.extend(group) + candidates.extend(_heat_utility_units(group)) + return candidates + +def _key_universe(units, held=None): + """The units that keys may name, given the units at hand `units`: + ``(universe, live, owners)``, the owner chains of `units` (see + `_owner_chains`) along the owners they give the auxiliaries they hold + (`owners`, see `_held_owners`, after those of the snapshot `held`), and + the ids of the universe (`live`).""" + owners = _held_owners(units, held) + universe = _owner_chains(units, owners) + return universe, {id(u) for u in universe}, owners + +def _auxiliary_attributes(key, universe, owners=None): + """ + The attributes that the tuple key `key`, of the ID that + `biosteam.Unit.auxiliary` gives an auxiliary, may name: ``(owner.ID, + 'name[k]')`` the k-th unit of a list under the attribute `name`, and + ``(owner.ID, name)`` a single unit there, with `name` one of the + `auxiliary_unit_names` of a unit under the top owner ``owner.ID`` + (along `_parent` with `owners`). Yields ``(unit, name, k, held)`` for + each such unit, with k None for the single form and `held` what the + unit holds there (None if missing). + """ + owner_ID, ID = key + match = re.fullmatch(r'(.+)\[(0|[1-9]\d*)\]', ID) + name, k = (match[1], int(match[2])) if match else (ID, None) + for unit in universe: + if (_top(unit, owners).ID == owner_ID + and name in getattr(unit, 'auxiliary_unit_names', ())): + yield unit, name, k, getattr(unit, name, None) + +def _auxiliary_slots(key, universe, owners=None): + """ + Where the units of `universe` hold the auxiliary unit of the tuple key + `key` by the ID that `biosteam.Unit.auxiliary` gives an auxiliary (see + `_auxiliary_attributes`). Yields ``(unit, held)`` for each such unit + whose attribute takes that form (a list for ``'name[k]'``) or holds + nothing (None or missing), with `held` the `biosteam.Unit` there, or + None if there is none (nothing, an object that is not a unit, or a + list too short). + """ + for unit, name, k, held in _auxiliary_attributes(key, universe, owners): + if held is not None: + if isinstance(held, list) != (k is not None): continue + if k is not None: held = held[k] if k < len(held) else None + yield unit, held if isinstance(held, bst.Unit) else None + +def _other_form_hints(key, universe, owners=None): + """ + Hints for the tuple key `key` of the ID that `biosteam.Unit.auxiliary` + gives an auxiliary in the other form than the attribute it names takes + (see `_auxiliary_slots`, which skips such attributes): for ``(owner.ID, + name)`` where a unit under that top owner holds a list there, the keys + of its units (``"E1.evaporators is a list: ('E1', 'evaporators[0]'), + ..."``), and for ``(owner.ID, 'name[k]')`` where it holds one unit, + that unit's key (``"D1.condenser is ('D1', 'condenser')"``). + """ + hints = [] + for unit, name, k, held in _auxiliary_attributes(key, universe, owners): + if held is None or isinstance(held, list) == (k is not None): continue + if k is not None: + if isinstance(held, bst.Unit): + hints.append(f'; {unit.ID}.{name} is ' + f'{_unit_name(held, owners)!r}') + continue + keys = [repr(_unit_name(aux, owners)) for aux in held + if isinstance(aux, bst.Unit)] + if keys: + shown = ', '.join(keys[:3]) + (', ...' if len(keys) > 3 else '') + hints.append(f'; {unit.ID}.{name} is a list: {shown}') + return hints + +def _stage_tickets(compressor=None): + """The ticket names that a `biosteam.MultistageCompressor` gives its + stages in their IDs (``f'{K.ID}_{ticket}{n}'``): those of the + `biosteam.IsentropicCompressor` and `biosteam.HXutility` stages it + builds from `pr` and `n_stages`, and those of the stages that + `compressor` holds now (given as `compressors` and `hxs`).""" + tickets = {getattr(getattr(bst, 'IsentropicCompressor', None), + 'ticket_name', None), bst.HXutility.ticket_name} + if compressor is not None: + for stages in (getattr(compressor, 'compressors', None), + getattr(compressor, 'hxs', None)): + tickets.update(getattr(stage, 'ticket_name', None) + for stage in stages or ()) + return {ticket for ticket in tickets if isinstance(ticket, str) and ticket} + +def _is_stage_ID(ID, compressor_ID, tickets): + """Whether `ID` is ``f'{compressor_ID}_{ticket}{n}'`` for a ticket name + of `tickets` and a stage number n >= 1 (see `_stage_tickets`).""" + prefix = f'{compressor_ID}_' + return ID.startswith(prefix) and any( + re.fullmatch(re.escape(ticket) + r'[1-9]\d*', ID[len(prefix):]) + for ticket in tickets) + +def _names_unbuilt_stage(key, universe, owners=None): + """ + Whether the tuple key `key` gives the ID of a stage that a + `biosteam.MultistageCompressor` K of `universe` under the top owner + ``key[0]`` (along `_parent` with `owners`) may build: + ``f'{K.ID}_{ticket}{n}'`` for any stage number n >= 1 (see + `_stage_tickets`). K sets its `auxiliary_unit_names` to the IDs of the + stages it builds whenever `pr` or `n_stages` changes, so ``('K1', + 'K1_H3')`` names an intercooler of K1 with 3 stages or more and no unit + with fewer. + """ + MultistageCompressor = getattr(bst, 'MultistageCompressor', None) + if MultistageCompressor is None: return False + owner_ID, ID = key + return any(isinstance(unit, MultistageCompressor) + and _is_stage_ID(ID, unit.ID, _stage_tickets(unit)) + and _top(unit, owners).ID == owner_ID + for unit in universe) + +def _names_unbuilt_auxiliary(key, universe, live, owners=None): + """ + Whether the key `key` (of a valid type, see `_check_contribution_key`), + which names no unit of `universe` (see `_names_unit`; `live` holds the + ids of `universe`, `owners` the owners of `_parent`), names an + auxiliary unit that a unit of `universe` may build but does not hold + now, as a `biosteam.HXutilities` builds one exchanger per agent it + needs: an old auxiliary object whose top owner is in `universe` + (rebuilt with fewer auxiliaries), or a tuple key of the ID that biosteam + gives an auxiliary (see `_auxiliary_slots`) where a unit of `universe` + holds no unit, or holds the unit of that ID: ``('X1', + 'heat_exchangers[2]')`` while the list ``X1.heat_exchangers`` is + shorter, say. Or a tuple key of the ID that a + `biosteam.MultistageCompressor` of `universe` gives a stage, of any + stage number (see `_names_unbuilt_stage`): ``('K1', 'K1_H3')`` while + K1 has 2 stages. Such a key is valid but names nothing. A slot that + holds a unit of another ID (``T1.heat_exchanger`` of a + `biosteam.EnzymeTreatment` T1 is ``('T1', '')``) does not make its key + valid (see `_unknown_key_hints`). + """ + if isinstance(key, bst.Unit): + top = _top(key, owners) + return top is not key and id(top) in live + if not isinstance(key, tuple): return False + return (any(held is None or held.ID == key[1] + for _, held in _auxiliary_slots(key, universe, owners)) + or _names_unbuilt_stage(key, universe, owners)) + +def _unknown_keys(keys, universe, owners=None): + """The keys of `keys` (units, str or pairs of str) that name neither a + unit of `universe` (see `_names_unit`, with owners along `_parent`) + nor an auxiliary unit that one of them may build (see + `_names_unbuilt_auxiliary`).""" + live = {id(u) for u in universe} + return [key for key in keys + if not any(_names_unit(key, u, live, owners) for u in universe) + and not _names_unbuilt_auxiliary(key, universe, live, owners)] + +def _unknown_key_hints(keys, universe, owners=None): + """ + Hints for the unknown keys `keys` (see `_unknown_keys`) that name a + unit of `universe` (owners along `_parent` with `owners`) by another + key: for a str key, each auxiliary unit of that ID (``"'condenser' is + ('D1', 'condenser')"``), and for a tuple key of the ID that biosteam + gives an auxiliary (see `_auxiliary_slots`), each unit held there under + another ID (``"T1.heat_exchanger is ('T1', '')"``), or the keys of the + units held there when the key takes the other form (a list's name + alone, or an index into a single one, see `_other_form_hints`). A tuple + key ``(owner_ID, ID)`` of a unit of `universe` without an owner gets that + unit's key (``"('K1', 'K1_H1') is 'K1_H1' (...)"``), and a str key, + the stages without an owner that a `biosteam.MultistageCompressor` of + that ID gives its IDs (see `_is_stage_ID`), such as the intercoolers + of the heat utilities of a compressor K1 outside a HeatExchangerNetwork + (which, with the `key_units` of `hensmith.PinchTargets.from_heat_utilities`, + alone takes K1 as their owner, see `_held_owners`). Returns them + as ``'; '`` each, joined ('' if none). + """ + hints = {} + tickets = _stage_tickets() + for key in keys: + if isinstance(key, str): + stages = [] + for unit in universe: + if unit.ID == key and _parent(unit, owners) is not None: + hints[f'; {key!r} is {_unit_name(unit, owners)!r}'] = None + elif (_parent(unit, owners) is None + and _is_stage_ID(unit.ID, key, tickets)): + stages.append(unit.ID) + if stages: + one = len(stages) == 1 + keyed = ('its ID is its key' if one + else 'their IDs are their keys') + hints[f"; {', '.join(map(repr, stages))} " + f"{'has' if one else 'have'} no owner here, so {keyed}" + f' (a MultistageCompressor {key!r} owns the stages it ' + 'builds only among the units available to a ' + 'HeatExchangerNetwork, or the key_units of ' + 'PinchTargets.from_heat_utilities)'] = None + elif isinstance(key, tuple): + for unit, held in _auxiliary_slots(key, universe, owners): + if held is not None and held.ID != key[1]: + hints[f'; {unit.ID}.{key[1]} is ' + f'{_unit_name(held, owners)!r}'] = None + for hint in _other_form_hints(key, universe, owners): + hints[hint] = None + if key[0] != key[1] and any( + unit.ID == key[1] and _parent(unit, owners) is None + for unit in universe): + hints[f'; {key!r} is {key[1]!r} (a unit without an owner ' + 'here)'] = None + return ''.join(hints) + +def _mapping_contributions(hus, mapping, units, held=None): + """ + `_stream_contributions` of a mapping from keys naming units to + contributions: checks the types of keys and values, then that every key + names a unit of the universe (the owner chains of the units of `hus` + and of `units`, the units at hand of `_names_unit`, along the owners + they give the auxiliaries they hold without one, after those of the + snapshot `held`, see `_key_universe`) or an auxiliary unit that one of them may build but does not hold now + (see `_names_unbuilt_auxiliary`: such a key names nothing), then that + no two keys of the same level for a unit of the universe give it + different values (None and nan are equal); then each heat utility takes + the value of the key of the lowest level on its unit's owner chain + (None without one). + """ + items = [] + for key, value in mapping.items(): + _check_contribution_key(key) + items.append((key, _contribution_value(value, f'key {key!r}'))) + universe, live, owners = _key_universe([hu.unit for hu in hus] + + list(units or ()), held) + unknown = _unknown_keys([key for key, _ in items], universe, owners) + if unknown: + raise ValueError('approach_contributions keys that name no unit: ' + f"{', '.join(map(repr, unknown))} (a key is a unit, " + 'the ID of a unit without an owner, or (owner.ID, ' + 'unit.ID)' + f'{_unknown_key_hints(unknown, universe, owners)})') + _check_key_levels(items, universe, live, owners, 'approach_contributions') + return [_nearest_key_value(items, hu.unit, live, owners) for hu in hus] + +def _check_key_levels(items, universe, live, owners, what): + """Raise a ValueError if two keys of `items` (``(key, value)`` pairs + of unit keys) of the same level for a unit of `universe` (see + `_contribution_key_level`, which takes `live` and `owners`) give it + different values; `what` names the keys (``'h'``, say).""" + for unit in universe: + first = {} # level -> (key, value) + for key, value in items: + level = _contribution_key_level(key, unit, live, owners) + if level is None: continue + other, other_value = first.setdefault(level, (key, value)) + if other_value != value: + raise ValueError( + f'{what} keys {other!r} and {key!r} give unit ' + f'{_unit_message_name(unit, owners)} different values ' + f'at the same level: {other_value!r} and {value!r}') + +def _nearest_key_value(items, unit, live, owners, default=None): + """The value of the key of `items` (``(key, value)`` pairs of unit + keys) of the lowest level on the owner chain of `unit` (see + `_contribution_key_level`, which takes `live` and `owners`; the first + of that level), or `default` if none names it.""" + best = None # (level, value) of the nearest key + for key, value in items: + level = _contribution_key_level(key, unit, live, owners) + if level is not None and (best is None or level < best[0]): + best = (level, value) + return default if best is None else best[1] + +def _stream_contributions(hus, approach_contributions, units=None, + held=None): + """ + Resolve `approach_contributions` to one approach temperature + contribution per heat utility of `hus`, in the given order (zero-duty + ones included): a list of float, or None where unspecified (nan becomes + None; not filled with ``T_min_app / 2``). Accepts the forms of + `synthesize_network`'s parameter: None (all unspecified), a mapping + whose keys name units (see `_mapping_contributions`), a sequence + aligned with `hus` (not a str or bytes), or a callable of a heat + utility (called once per heat utility, in order). `units` adds units + (with their owner chains) to those that keys may name and that are + checked for conflicting keys, whether or not they hold a heat utility + of `hus`, and `held` gives the holders of auxiliaries taken earlier + (see `_held_owners`). + """ + N = len(hus) + x = approach_contributions + if x is None: return [None] * N + if isinstance(x, Mapping): + return _mapping_contributions(hus, x, units, held) + if isinstance(x, (Sequence, np.ndarray)) and not isinstance(x, (str, bytes)): + values = list(x) + if len(values) != N: + raise ValueError(f'{len(values)} approach contributions given for ' + f'{N} heat utilities') + return [_contribution_value(v, f'heat utility {k}') + for k, v in enumerate(values)] + if callable(x): + # labeled with the owners the keys of a mapping would take + owners = _held_owners([hu.unit for hu in hus] + list(units or ()), + held) + return [_contribution_value( + x(hu), f'unit {_unit_message_name(hu.unit, owners)}') + for hu in hus] + raise TypeError('approach_contributions must be a mapping, a sequence or ' + f'a callable, not {type(x).__name__!r}') + +def _explicit_contributions(hus, approach_contributions, T_min_app, + units=None, held=None): + """ + `_stream_contributions` of `hus` (with `units` and `held`), or None if + nothing is specified (`approach_contributions` None, or every value + None or nan: the default path). With a value, `T_min_app` must be + finite and > 0. + """ + if approach_contributions is None: return None + values = _stream_contributions(hus, approach_contributions, units, held) + if all(v is None for v in values): return None + _approach_contributions(len(values), T_min_app, values) # checks T_min_app + return values + +def _arrange_heat_utilities(hus, sort_hus_by_T): + """ + The heat utilities of `hus` in stream order, ``(hus_rearranged, + N_heating)``: the heating ones (``hu.duty > 0``, cold streams), then the + cooling ones; zero-duty ones are dropped. With `sort_hus_by_T`, heating + utilities are sorted by descending and cooling ones by ascending inlet + temperature of their unit. + """ + hus_heating = [hu for hu in hus if hu.duty > 0] + hus_cooling = [hu for hu in hus if hu.duty < 0] if sort_hus_by_T: hus_heating.sort(key=lambda i: i.unit.ins[0].T, reverse=True) hus_cooling.sort(key=lambda i: i.unit.ins[0].T) - hx_utils_rearranged = hus_heating + hus_cooling - hxs = [hu.unit for hu in hx_utils_rearranged] - # unregistered copies (see `hensmith._curves._copy`); the inlets are - # registered under their own IDs below + return hus_heating + hus_cooling, len(hus_heating) + +def _process_streams(hxs, force_ideal_thermo): + """ + The process streams of the exchangers `hxs`, ``(streams_inlet, + streams_quenched)``: unregistered copies (see `hensmith._curves._copy`) + of each inlet and of each outlet re-flashed at its own enthalpy and + pressure, with ideal thermodynamics if `force_ideal_thermo`. + """ if force_ideal_thermo: streams_inlet = [_copy(hx.ins[0], hx.ins[0].thermo.ideal()) for hx in hxs] streams_quenched = [_copy(hx.outs[0], hx.outs[0].thermo.ideal()) for hx in hxs] @@ -753,11 +1340,29 @@ def _pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, streams_inlet = [_copy(hx.ins[0]) for hx in hxs] streams_quenched = [_copy(hx.outs[0]) for hx in hxs] for i in streams_quenched: i.vle(H=i.H, P=i.P) + return streams_inlet, streams_quenched + +def _pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, + sort_hus_by_T=False, approach_contributions=None): + """ + The first step of `synthesize_network`: prepare the process streams + behind `hus` and run the problem table on them. Returns the 12 values + of `temperature_interval_pinch_analysis` (which wraps this function) + and then the table's ``table, curves, grid`` (see `_problem_table`), + on which the network is planned, so that the curves are built only + once. `approach_contributions` (see `_stream_contributions`) is resolved + first, before any stream is copied or flashed. + """ + values = _explicit_contributions(hus, approach_contributions, T_min_app) + hx_utils_rearranged, N_heating = _arrange_heat_utilities(hus, sort_hus_by_T) + hxs = [hu.unit for hu in hx_utils_rearranged] + # unregistered copies; the inlets are registered under their own IDs + # below + streams_inlet, streams_quenched = _process_streams(hxs, force_ideal_thermo) for i in range(len(streams_inlet)): stream = streams_inlet[i] ID = 'Util_%s'%i stream.ID = 's_%s__%s'%(i,ID) - N_heating = len(hus_heating) cold_indices = list(range(N_heating)) hot_indices = list(range(N_heating, len(hxs))) indices = cold_indices + hot_indices @@ -765,38 +1370,47 @@ def _pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, T_out_arr = np.array([i.T for i in streams_quenched]) is_hot = np.zeros(len(hxs), dtype=bool) is_hot[hot_indices] = True + 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 hx_utils_rearranged] table, curves, grid = _problem_table(streams_inlet, streams_quenched, - is_hot, T_min_app) + is_hot, T_min_app, None, + contributions) hot_util_load = table.hot_util_load cold_util_load = table.cold_util_load - pinch_cold_stream_T = table.pinch_T - pinch_hot_stream_T = pinch_cold_stream_T + T_min_app + pinch_T = table.pinch_T # shifted scale + shift = grid['shift'] # Per-stream pinch temperature, for information only (returned as # `HeatExchangerNetwork.pinch_Ts`; `load_duties` splits a stream there): # the network is planned on the curves (see `synthesize_network`), not - # on these temperatures. A stream already entirely on one side of the - # process pinch (T_in past pinch_cold_stream_T for a cold stream, or - # past pinch_hot_stream_T for a hot stream) is not split; its pinch_T is - # its own T_in. So is a non-monotone stream (T_out on the wrong side of - # T_in for its duty, e.g. a cold stream whose VLE outlet ends up cooler - # than it entered: a point load at T_out), whose whole duty - # `load_duties` then puts on a single side (hot side for a cold stream, - # cold side for a hot one). + # on these temperatures. Stream i's pinch is the shifted pinch plus its + # shift, T_p (pinch_T for a cold and pinch_T + T_min_app for a hot + # stream by default). A stream already entirely on one side of the + # process pinch (T_in past T_p) is not split; its pinch_T is its own + # T_in. So is a non-monotone stream (T_out on the wrong side of T_in for + # its duty, e.g. a cold stream whose VLE outlet ends up cooler than it + # entered: a point load at T_out), whose whole duty `load_duties` then + # puts on a single side (hot side for a cold stream, cold side for a hot + # one). pinch_T_arr = [] for i in cold_indices: - if T_in_arr[i] > pinch_cold_stream_T or T_in_arr[i] > T_out_arr[i]: + T_p = pinch_T + shift[i] + if T_in_arr[i] > T_p or T_in_arr[i] > T_out_arr[i]: pinch_T_arr.append(T_in_arr[i]) - elif T_out_arr[i] < pinch_cold_stream_T: + elif T_out_arr[i] < T_p: pinch_T_arr.append(T_out_arr[i]) else: - pinch_T_arr.append(pinch_cold_stream_T) + pinch_T_arr.append(T_p) for i in hot_indices: - if T_in_arr[i] < pinch_hot_stream_T or T_in_arr[i] < T_out_arr[i]: + T_p = pinch_T + shift[i] + if T_in_arr[i] < T_p or T_in_arr[i] < T_out_arr[i]: pinch_T_arr.append(T_in_arr[i]) - elif T_out_arr[i] > pinch_hot_stream_T: + elif T_out_arr[i] > T_p: pinch_T_arr.append(T_out_arr[i]) else: - pinch_T_arr.append(pinch_hot_stream_T) + pinch_T_arr.append(T_p) pinch_T_arr = np.array(pinch_T_arr) return pinch_T_arr, hot_util_load, cold_util_load, T_in_arr, T_out_arr,\ hxs, hot_indices, cold_indices, indices, streams_inlet, hx_utils_rearranged, \ @@ -805,7 +1419,8 @@ def _pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, def temperature_interval_pinch_analysis(hus, T_min_app=10, force_ideal_thermo=False, - sort_hus_by_T=False): + sort_hus_by_T=False, + approach_contributions=None): """ Prepare the process streams behind `hus` and run the problem table on them, as `synthesize_network` does first; a standalone pinch analysis @@ -815,7 +1430,8 @@ def temperature_interval_pinch_analysis(hus, Heating utilities (``hu.duty > 0``, cold streams) come first, then cooling utilities; zero-duty utilities are dropped. Each stream is a copy of its exchanger's inlet (renamed ``s___Util_``) and - of its outlet re-flashed at its own enthalpy. + of its outlet re-flashed at its own enthalpy. `approach_contributions` + takes the forms of `synthesize_network`'s parameter. Returns ------- @@ -836,7 +1452,7 @@ def temperature_interval_pinch_analysis(hus, """ return _pinch_analysis(hus, T_min_app, force_ideal_thermo, - sort_hus_by_T)[:12] + sort_hus_by_T, approach_contributions)[:12] def pinch_state(stream_in, stream_out, T_pinch, side=None, curve=None): """ @@ -935,8 +1551,9 @@ def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, # %% Network synthesis #: Exact-state approach acceptance [K]. Synthesized process exchangers get -#: ``HXprocess(dT=T_min_app - _APPROACH_TOL)`` as a guard only: the planner -#: and the exactness check enforce `T_min_app` on the exact states. +#: ``HXprocess(dT=required - _APPROACH_TOL)``, with `required` their pair's +#: required approach, as a guard only: the planner and the exactness check +#: enforce the required approach on the exact states. _APPROACH_TOL = 1e-6 #: A simulated process-exchanger duty that differs from the planned one by #: more than this times the two streams' total duties is a deviation. @@ -1042,7 +1659,8 @@ def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, """ Exact-state check of one counter-current exchanger of duty `Q` in which stream `h` enters hot at `H_hot_in` and stream `c` enters cold at - `H_cold_in` (enthalpies relative to each stream's H_lo). + `H_cold_in` (enthalpies relative to each stream's H_lo); `T_min_app` is + the pair's required approach. With stream splitting, `fh` and `fc` are the flow fractions of the hot and the cold branch in the exchanger: a branch at fraction f has its @@ -1067,9 +1685,10 @@ def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, Returns the smallest approach found [K] (exact where evaluated) and the exact states ``(T_hot, H_hot, T_cold, H_cold)`` wherever the exact - approach is below ``T_min_app - _APPROACH_TOL``. With ``T_min_app = - inf`` every interval is checked, so the approach returned is the exact - minimum over the exchanger and the states are all those evaluated. + approach is below the pair's required approach less the tolerance, + ``T_min_app - _APPROACH_TOL``. With ``T_min_app = inf`` every interval + is checked, so the approach returned is the exact minimum over the + exchanger and the states are all those evaluated. This is the only exact internal-approach check of the synthesis (the exchangers themselves, `HXprocess`, check their two terminals only, which misses an internal pinch at a phase change). @@ -1111,14 +1730,32 @@ def exact(q): worst = min(worst, _interval_min(exact, a, fa, b, fb)) return worst, points -def _exact_approach(plan, duties, ends, curves, knots, T_min_app): +def _required_approach(required, h, c): + """The required approach [K] of hot stream `h` against cold stream `c`: + `required` itself if it is a scalar, else ``required[h] - required[c]`` + (`required` the streams' shifts, ``grid['shift']``).""" + if np.ndim(required) == 0: return required + return float(required[h] - required[c]) + +def _check_required(required, knots): + """Raise a ValueError unless `required` is a scalar or has one shift per + stream of `knots`.""" + if np.ndim(required) and len(required) != len(knots): + raise ValueError(f'{len(required)} shifts given for {len(knots)} ' + 'streams') + +def _exact_approach(plan, duties, ends, curves, knots, required): """ `_exchanger_approach` of every exchanger in `duties` (duty by index into ``plan.exchangers``) at the enthalpies of the walk `ends` (see `_walk`), - with the flow fractions of its branches (1 on a trunk). + with the flow fractions of its branches (1 on a trunk), against its + pair's required approach: `required` is the scalar pair requirement, or + the per-stream shift vector ``grid['shift']`` (never + ``grid['contributions']``; see `_required_approach`). Returns the smallest approach [K], the violating exact states by stream, ``{stream: [(T_exact, H - H_lo), ...]}``, and the violating exchangers. """ + _check_required(required, knots) worst = np.inf violations = {} bad = [] @@ -1126,8 +1763,8 @@ def _exact_approach(plan, duties, ends, curves, knots, T_min_app): e = plan.exchangers[n] h, c = e.hot, e.cold approach, points = _exchanger_approach( - curves, knots, h, c, ends[n, h][0], ends[n, c][0], Q, T_min_app, - e.hot_frac, e.cold_frac + curves, knots, h, c, ends[n, h][0], ends[n, c][0], Q, + _required_approach(required, h, c), e.hot_frac, e.cold_frac ) worst = min(worst, approach) if points: bad.append(n) @@ -1136,12 +1773,31 @@ def _exact_approach(plan, duties, ends, curves, knots, T_min_app): violations.setdefault(c, []).append((T_cold, H_cold)) return worst, violations, bad +def _approach_margin(plan, duties, ends, curves, knots, required): + """ + The smallest approach less its pair's required approach [K] over the + exchangers in `duties`, as `_exact_approach` evaluates it (`required` + as there). + """ + margin = np.inf + for n, Q in duties.items(): + e = plan.exchangers[n] + h, c = e.hot, e.cold + req = _required_approach(required, h, c) + approach = _exchanger_approach( + curves, knots, h, c, ends[n, h][0], ends[n, c][0], Q, req, + e.hot_frac, e.cold_frac + )[0] + margin = min(margin, approach - req) + return margin + def _shrink(curves, knots, h, c, H_hot_in, H_cold_in, Q, T_min_app, fh=1., fc=1.): """ Largest duty ``Q' <= Q`` (to 1e-9 of Q) at which the exchanger of `_exchanger_approach` (branches at flow fractions `fh` and `fc`) keeps - ``T_min_app - _APPROACH_TOL`` on the exact states. With both inlets + the pair's required approach ``T_min_app`` less `_APPROACH_TOL` on the + exact states. With both inlets fixed, a smaller duty lowers the cold stream's enthalpy (so its temperature) at every position and shortens the exchanger, so the approach can only grow: the feasible duties form an interval [0, Q'] @@ -1172,10 +1828,11 @@ def ok(x): else: hi = mid return lo -def _repair(plan, duties, knots, is_hot, curves, T_min_app): +def _repair(plan, duties, knots, is_hot, curves, required): """ - Shrink every exchanger that falls short of ``T_min_app - _APPROACH_TOL`` - on the exact states to its largest feasible duty (`_shrink`); the rest of + Shrink every exchanger that falls short of its pair's required approach + less `_APPROACH_TOL` on the exact states (`required` as in + `_exact_approach`) to its largest feasible duty (`_shrink`); the rest of its duty goes to the utilities. Shrinking a match moves the later stages of both its streams toward their inlets, which never reduces another exchanger's approach (the curves are monotone), so one pass suffices; @@ -1189,13 +1846,14 @@ def _repair(plan, duties, knots, is_hot, curves, T_min_app): changes = [] for _ in range(len(duties) + 1): ends = _walk(plan, duties, knots, is_hot)[0] - bad = _exact_approach(plan, duties, ends, curves, knots, T_min_app)[2] + bad = _exact_approach(plan, duties, ends, curves, knots, required)[2] if not bad: break for n in bad: e = plan.exchangers[n] ends = _walk(plan, duties, knots, is_hot)[0] Q = _shrink(curves, knots, e.hot, e.cold, ends[n, e.hot][0], - ends[n, e.cold][0], duties[n], T_min_app, + ends[n, e.cold][0], duties[n], + _required_approach(required, e.hot, e.cold), e.hot_frac, e.cold_frac) changes.append((n, duties[n], Q)) duties[n] = Q @@ -1272,7 +1930,7 @@ def _first_inlet(stream, point_load, T_point, hot): and the partner's outlet is capped at the inlet temperature -/+ `dT`), so a point-load stream's real inlet, on the wrong side of `T_point` by definition, would make it refuse or cut short a match that the plan - keeps `T_min_app` for at `T_point`. E.g. a reboiler fed as a liquid + keeps the required approach for at `T_point`. E.g. a reboiler fed as a liquid above its boiling point enters as the vapor-liquid mixture it flashes to, and a vapor fed below its dew point (e.g. the ideal-thermo copy of a saturated vapor whose ideal dew point is higher) as the mixture it @@ -1500,10 +2158,12 @@ def _discard(units): for s in (*unit.ins, *unit.outs): bst.main_flowsheet.stream.discard(s) bst.main_flowsheet.unit.discard(unit) -def _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app): +def _realize(plan, duties, curves, knots, streams_inlet, is_hot, required): """ Build and run one plain `HXprocess` per exchanger in `duties` (duty by - index into ``plan.exchangers``), in plan order. Raise + index into ``plan.exchangers``), in plan order, each with ``dT`` its + pair's required approach less `_APPROACH_TOL` (`required` as in + `_exact_approach`). Raise `_RealizationError` (after discarding the units built so far) if one of them cannot be simulated. Returns ``(units, first, last)``: the units by exchanger index, each stream's first exchanger (None if it has none) @@ -1517,15 +2177,16 @@ def _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app): first exchanger of every branch of a split at a stream's inlet takes the real inlet (`_split_nodes`). """ + _check_required(required, knots) ends, last, pair_index = _walk(plan, duties, knots, is_hot) first = [next((n for n in plan.stages[j] if n in duties), None) for j in range(len(is_hot))] first_nodes = _split_nodes(plan, duties, ends)[0] if plan.splits else {} units = {} - dT = T_min_app - _APPROACH_TOL for n in sorted(duties): e = plan.exchangers[n] h, c = e.hot, e.cold + dT = _required_approach(required, h, c) - _APPROACH_TOL suffix = '' if pair_index[n] == 1 else f'_{pair_index[n]}' if e.side == 'above': ID = f'HX_{c}_{h}_hs{suffix}' @@ -1689,7 +2350,7 @@ def run(unit, k, live): def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, avoid_recycle=False, sort_hus_by_T=False, info=None, - stream_splitting=False): + stream_splitting=False, approach_contributions=None): """ Synthesize a heat exchanger network for the process streams behind a set of utility heat exchangers: pinch analysis (`problem_table`), then a @@ -1715,7 +2376,9 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, T_min_app : float, optional Minimum approach temperature [K]: kept on the exact stream states at both ends of and everywhere inside every process exchanger, and - used to shift hot streams in the problem table. Defaults to 5. + used to shift hot streams in the problem table. With + `approach_contributions`, the default approach: each stream without + a contribution contributes ``T_min_app / 2``. Defaults to 5. Qmin : float, optional Planned exchangers with a duty below this [kJ/hr] are dropped and their duty left to the utilities (a large value can cost MER). @@ -1743,14 +2406,22 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, (``Q_hot_plan - Q_hot_target``), 'sides' (per side of the pinch: status, method, work, proof of a needed split, gaps, units), 'plan_targets' (the planner's own cascade in the first round: - Q_hot, Q_cold, pinch_T, cut), 'refine_rounds', 'min_approach' (the - smallest approach inside any process exchanger [K]; exact on the - stream states wherever it is within the curves' linearization - tolerance of `T_min_app`, else from the knots), - 'deviations' (exchangers whose simulated duty differs from the - plan), 'qmin_dropped' (matches dropped by `Qmin`), 'repaired' - (matches shrunk to keep `T_min_app` on the exact states, see - Notes), 'dropped' (matches that could not be simulated; + Q_hot, Q_cold, pinch_T on the shifted scale, cut), + 'refine_rounds', 'min_approach' (the smallest approach inside any + process exchanger [K]; exact on the stream states wherever it is + within the curves' linearization tolerance of its pair's required + approach, else from the knots; None without process exchangers), + 'min_approach_margin' (the smallest approach less its pair's + required approach over the process exchangers [K], as evaluated by + the exact check: at least -1e-6 up to round-off; None when + 'min_approach' is None), 'approach_contributions' (each stream's + approach temperature contribution as used [K], unspecified ones at + ``T_min_app / 2``, in stream order: that of the returned arrays, + not of `hus`), 'deviations' (exchangers whose simulated duty + differs from the plan), 'qmin_dropped' (matches dropped by + `Qmin`), 'repaired' (matches shrunk to keep their pair's required + approach on the exact states, see Notes), 'dropped' (matches that + could not be simulated; normally empty) and 'point_loads' (the indices of the streams whose outlet temperature does not move with their duty, e.g. an isothermal condenser or a reboiler fed as a liquid above its @@ -1782,6 +2453,57 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, and ``synthesis_info['splits']`` (`HeatExchangerNetwork`). With `avoid_recycle`, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Defaults to False. + approach_contributions : Mapping, Sequence or callable, optional + Approach temperature contribution of each process stream [K]: a + match between hot stream h and cold stream c must keep ``c_h + + c_c`` instead of `T_min_app` (see Notes, "Approach + contributions"). A value is None or nan (unspecified: ``T_min_app + / 2``) or a finite number > 0 (`T_min_app` must then be finite and + > 0 too). Given as one of: + + - a mapping ``{key: value}`` whose keys name units: a unit (that + very object; a stale auxiliary that its owner rebuilt, no longer + among the units of `hus` and their owners, names the one of the + same ID under owners of the same IDs instead, if any), + ``(owner.ID, unit.ID)`` with the top owner (the unit's own ID + twice for a unit without an owner; it names every auxiliary of + that ID under that owner, at any depth, so the heaters of two + auxiliary flash drums are told apart by keying the drums or the + heater objects) or the ID of a unit without an owner (a bare + auxiliary ID such as ``'condenser'`` names nothing). Only the + units of `hus` and their owners are at hand, so an auxiliary that + its unit holds without setting its owner is a unit without an + owner here: an intercooler that a `biosteam.MultistageCompressor` + K1 built anew is ``'K1_H1'``, and ``'K1'`` raises (a + `HeatExchangerNetwork` that K1 is available to takes K1 as its + owner instead). A heat utility takes the value of + the key nearest to its unit along the unit's chain of owners: a + unit's own key beats its owner's, which beats the owner's owner's + (e.g. ``'D1'`` covers the condenser and the reboiler of column + D1, and ``('D1', 'condenser')`` overrides it for the condenser). + A None or nan value wins at its level like any other. Heat + utility keys are rejected (BioSTEAM replaces them at every + simulation). A key that names neither the unit of a heat utility + of `hus` nor one of its owners, nor an auxiliary that one of them + may build but does not hold now (such a key has no effect: e.g. + ``('X1', 'heat_exchangers[2]')``, biosteam's ID of the third + exchanger of a `biosteam.HXutilities` X1, while X1 needs fewer, + or an old exchanger of X1 that none took the place of), or two + keys of the same level for a unit with different values, raise a + ValueError; + - a sequence (or `numpy.ndarray`; not a str or bytes) with one + value per heat utility, aligned with `hus` as given (before the + heating utilities are placed first; zero-duty utilities + included); a wrong length raises a ValueError; + - a callable ``f(hu)`` returning the value of a heat utility, + called once per heat utility in the order of `hus`. + + Anything else raises a TypeError. Checked and resolved before any + stream is copied or flashed. If every value is unspecified, the + result is the default one. The contributions as used are reported + in ``info['approach_contributions']`` in stream order, not in the + order of `hus`. Defaults to None (every stream contributes + ``T_min_app / 2``). Returns ------- @@ -1807,8 +2529,10 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, pinch_T_arr : numpy.ndarray Per-stream pinch temperature [K] (informational): the process pinch on the stream's own scale when the stream crosses it - (`ProblemTable.pinch_T` for a cold stream, that plus `T_min_app` for - a hot one); the inlet temperature of a stream whose inlet already + (`ProblemTable.pinch_T` plus the stream's shift: `T_min_app` for a + hot stream, 0 for a cold one by default, ``T_min_app/2 + c_j`` and + ``T_min_app/2 - c_j`` with approach contributions); the inlet + temperature of a stream whose inlet already lies past the pinch in its direction of flow, or that is isothermal or non-monotone; the outlet temperature of a stream that ends before reaching the pinch. @@ -1845,10 +2569,11 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, below it the cold streams must be served completely by process matches ("musts"), while the partners ("flexes") leave any remainder to a utility at their far end. A depth-first search builds each side from - the pinch outward, one match at a time. Every match keeps `T_min_app` - at every knot (so internal pinches, e.g. a condensing vapor against a - boiling mixture, are respected), and every step keeps the problem - table of the remaining problem feasible (remaining problem analysis + the pinch outward, one match at a time. Every match keeps its pair's + required approach (`T_min_app` by default) at every knot (so internal + pinches, e.g. a condensing vapor against a boiling mixture, are + respected), and every step keeps the problem table of the remaining + problem feasible (remaining problem analysis [Smith05]_, as a closed-form bound on the duty). At every level where that table is tight, the pinch design rules [LH83]_ (see also [Seider17]_, Chapter 9; number and heat-capacity-flow rules, @@ -1874,10 +2599,11 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, cold-side matches, then its hot-side matches, then its heater. Each match becomes a plain `HXprocess` whose two enthalpy limits (`H_lim0`, `H_lim1`) are the planned outlet enthalpies, so that its - duty reproduces the plan; its `dT` is ``T_min_app - 1e-6`` K, only a - guard against rounding (the approach is enforced by the plan and the - check below). A planned outlet whose equilibrium state is not past the - stream's state at the exchanger inlet cannot be a limit (`HXprocess` + duty reproduces the plan; its `dT` is its pair's required approach + (`T_min_app` by default) less 1e-6 K, only a guard against rounding + (the approach is enforced by the plan and the check below). A planned + outlet whose equilibrium state is not past the stream's state at the + exchanger inlet cannot be a limit (`HXprocess` rejects it): one strictly inside a non-equilibrium end jump (see `StreamCurve.jumps`), or on a point-load stream colder (hotter) than its inlet state when heated (cooled). That stream's limit is left out @@ -1902,16 +2628,16 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, *Exactness.* The knots are exact at grid points but chords in between (at most 0.002 K off inside glides and curved single-phase stretches). Every planned exchanger is therefore checked on the exact stream states - wherever its planned approach is within that margin of `T_min_app`. - Where a MER plan falls short by more than 1e-6 K, the exact states are - inserted as knots and the network is planned again (at most three - rounds). A best-effort plan (or a MER plan still short after the last - round) instead has each violating match shrunk to the largest duty that - keeps the approach, the rest going to the utilities: with its inlets - fixed a smaller duty can only raise a match's approach, and the later - stages of both streams move toward their inlets, which never reduces - another match's approach. Constant heat capacity streams never need - either step. + wherever its planned approach is within that margin of its pair's + required approach. Where a MER plan falls short by more than 1e-6 K, the + exact states are inserted as knots and the network is planned again (at + most three rounds). A best-effort plan (or a MER plan still short after + the last round) instead has each violating match shrunk to the largest + duty that keeps the approach, the rest going to the utilities: with its + inlets fixed a smaller duty can only raise a match's approach, and the + later stages of both streams move toward their inlets, which never + reduces another match's approach. Constant heat capacity streams never + need either step. *Stream splitting.* With `stream_splitting`, a side that no unsplit network serves at MER is planned with splits (see @@ -1937,8 +2663,9 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, dropped, as in ``info['dropped']``). *Guarantees and limits.* The utilities are never below the targets. - Every process exchanger keeps ``T_min_app - 1e-6`` K on the exact - states at its ends and at every checked position inside it, and the + Every process exchanger keeps its pair's required approach (`T_min_app` + by default) less 1e-6 K on the exact states at its ends and at every + checked position inside it, and the heat balance closes on every stream. 'mer' is reported only if the realized network reaches the targets. The result is deterministic. Completeness is empirical, not proven: every pruning test is a @@ -1962,9 +2689,9 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, about 1e-9 of the total duty). The remaining gap is the realization. With constant heat capacities the knots are exact, and the realized network reaches the targets to round-off. With real thermodynamics - the knots are chords, so a split plan can fall short of `T_min_app` on - the exact states by up to the chords' tolerance; the refine rounds - close that as they do for unsplit plans, with up to two more rounds + the knots are chords, so a split plan can fall short of the required + approach on the exact states by up to the chords' tolerance; the refine + rounds close that as they do for unsplit plans, with up to two more rounds that exclude the violating network (see "Stream splitting" above), and only an exchanger still short after them is shrunk (reported in 'repaired', with status 'best_effort'). A rigorous mixer's outlet @@ -1976,6 +2703,24 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, minimized only among the candidate split plans a side generates, and splitters and mixers are not costed. + *Approach contributions.* With `approach_contributions`, each stream j + contributes ``c_j`` (``T_min_app / 2`` unless given), and a match + between hot stream h and cold stream c must keep ``c_h + c_c`` instead + of `T_min_app` [Kemp07]_: e.g. a small contribution for a condensing or + boiling stream (high film coefficients) and a large one for a gas. + Stream j is shifted down by ``s_j = T_min_app/2 + c_j`` if hot and + ``T_min_app/2 - c_j`` if cold, so that ``T*_h >= T*_c`` if and only if + ``T_h - T_c >= c_h + c_c``: the problem table, the targets and the + planner work on one shared shifted scale, and every guarantee above + carries over. A pair's required approach, ``s_h - s_c``, is enforced on + the exact states by the exactness check and the repair, is each + exchanger's `dT` guard (less 1e-6 K), and ``info['min_approach_margin']`` + reports the smallest margin over it. Stream j's pinch temperature is + the shifted pinch plus ``s_j``. Only the process matches follow the + contributions: each stream's `HXutility` gets its agent from BioSTEAM's + own rule. With every contribution unspecified, the shifts are the + default ones bit for bit (``x/2 + x/2 == x``), and so is the result. + Thermosteam's TP flashes fail silently inside the glides of some mixtures (e.g. water-ethanol with 20-50 % ethanol); an exchanger simulated there can deviate from its plan (reported in @@ -2032,6 +2777,22 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, >>> round(info['Q_hot_target'] / 3600., 6), round(info['Q_cold_target'] / 3600., 6) (80.0, 450.0) + With an approach temperature contribution of 2 K for the hot stream + (keyed by its unit) and the default ``T_min_app / 2 = 5`` K for the + cold streams, every match needs 7 K instead of 10 K, so the targets + fall to those of ``T_min_app = 7`` K, and every exchanger's `dT` + guard is 7 K less 1e-6 K: + + >>> info = {} + >>> result = synthesize_network(hus, T_min_app=10., info=info, + ... approach_contributions={units[3]: 2.}) + >>> info['status'], round(info['Q_hot_target'] / 3600., 6), round(info['Q_cold_target'] / 3600., 6) + ('mer', 74.0, 444.0) + >>> info['approach_contributions'] + array([5., 5., 5., 2.]) + >>> sorted({round(hx.dT, 6) for hx in result[0] + result[1]}) + [6.999999] + References ---------- .. [LH83] Linnhoff, B., & Hindmarsh, E. (1983). The pinch design method @@ -2050,10 +2811,21 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, raise ValueError('stream_splitting=True needs an info dict: the ' 'splitters and mixers are returned in ' 'info["splits"]') + # resolved before any stream is copied or flashed (None: the default) + values = _explicit_contributions(hus, approach_contributions, T_min_app) pinch_T_arr, hot_util_load, cold_util_load, T_in_arr, T_out_arr, \ hxs, hot_indices, cold_indices, indices, streams_inlet, \ hx_utils_rearranged, streams_quenched, table, curves, grid = \ - _pinch_analysis(hus, T_min_app, force_ideal_thermo, sort_hus_by_T) + _pinch_analysis(hus, T_min_app, force_ideal_thermo, sort_hus_by_T, + values) + # every pair's required approach is shift[h] - shift[c]; by default it + # is the scalar T_min_app itself, passed as given (not the shift vector, + # whose float() pair difference in `_required_approach` would promote a + # numpy float32 T_min_app to float64 and change the guards), so the + # default checks, repair and realization are unchanged to the bit; the + # planner gets the contributions only if any is given, likewise + shift = T_min_app if values is None else grid['shift'] + contributions = None if values is None else grid['contributions'] N = len(hxs) is_hot = [False] * N for i in hot_indices: is_hot[i] = True @@ -2083,12 +2855,14 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, **(dict(stream_splitting=True, _split_exclude=exclude, _split_prefer=prefer) - if stream_splitting else {})) + if stream_splitting else {}), + **({} if contributions is None else + dict(approach_contributions=contributions))) if not refine_round: plan_targets = plan.info['cascade'] duties = {n: e.Q for n, e in enumerate(plan.exchangers)} ends = _walk(plan, duties, knots, is_hot)[0] min_approach, violations, bad = _exact_approach( - plan, duties, ends, curves, knots, T_min_app + plan, duties, ends, curves, knots, shift ) if stream_splitting and plan.status == 'mer' and ( best is None or len(bad) < best[0]): @@ -2125,7 +2899,7 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, repaired = [] if bad: duties, changes = _repair(plan, duties, knots, is_hot, curves, - T_min_app) + shift) for n, Q_before, Q_after in changes: e = plan.exchangers[n] repaired.append(dict(side=e.side, hot=e.hot, cold=e.cold, @@ -2133,7 +2907,19 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, if Q_after < Qmin: del duties[n] ends = _walk(plan, duties, knots, is_hot)[0] min_approach = _exact_approach(plan, duties, ends, curves, knots, - T_min_app)[0] + shift)[0] + # the smallest approach less its pair's required approach: by default + # every pair's is T_min_app (rounding is monotone, so this is the + # smallest per-exchanger margin); else measured per pair, on fresh walk + # ends (`ends` is stale after a best plan was restored) + if not duties: + min_approach_margin = None + elif contributions is None: + min_approach_margin = min_approach - T_min_app + else: + min_approach_margin = _approach_margin( + plan, duties, _walk(plan, duties, knots, is_hot)[0], curves, + knots, shift) # Realize; a match that cannot be simulated is dropped (its duty goes to # the utilities: removing a match never reduces another's approach), and # a split whose splitters or mixer cannot be simulated becomes a trunk @@ -2143,7 +2929,7 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, while True: try: units, first, last = _realize(plan, duties, curves, knots, - streams_inlet, is_hot, T_min_app) + streams_inlet, is_hot, shift) splits, split_deviations = ( _realize_splits(plan, duties, units, curves, knots, streams_inlet, is_hot) @@ -2229,6 +3015,8 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, cut=plan_targets['cut']), refine_rounds=refine_round, min_approach=min_approach if duties else None, + min_approach_margin=min_approach_margin if duties else None, + approach_contributions=grid['contributions'].copy(), deviations=deviations, qmin_dropped=plan.info['qmin_dropped'], dropped=dropped, repaired=repaired, diff --git a/hensmith/targeting.py b/hensmith/targeting.py new file mode 100644 index 0000000..be3b3a4 --- /dev/null +++ b/hensmith/targeting.py @@ -0,0 +1,4158 @@ +# -*- coding: utf-8 -*- +# hensmith: Heat Exchanger Network Synthesis, Modeling, Integration, +# Thermodynamics, and Heuristics +# Copyright (C) 2020-, Sarang Bhagwat +# +# 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 ``' - '`` 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 + + +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 + + >>> 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.) + + + 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 + + @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 (``' - '`` 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 + + 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)] + + 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 + + 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)) + + 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 + + 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 + + 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) + + 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))) + + 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 + + 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 + + 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())) + +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 + + >>> 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 + + >>> 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 + + +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]) + + 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 + +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 + +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:' and 'Leader:'. 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:', 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 + +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:' (the segments, 'Utility:unserved' for a + remainder) and 'Label:'. + + 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 + +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 + + >>> 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 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 4344a7f..3eeef03 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -1270,9 +1270,9 @@ def test_docstring_references_are_cited(): # the documentation build fails on a reference ('.. [label]') that its # docstring never cites ('[label]_') import inspect, re - import hensmith._curves, hensmith._planner + import hensmith._curves, hensmith._planner, hensmith.targeting modules = (hensmith._heat_exchanger_network, hensmith.hxn_synthesis, - hensmith._planner, hensmith._curves) + hensmith._planner, hensmith._curves, hensmith.targeting) def docstrings(obj, seen): if id(obj) in seen: return seen.add(id(obj)) diff --git a/tests/test_hxn_contributions.py b/tests/test_hxn_contributions.py new file mode 100644 index 0000000..8952cbd --- /dev/null +++ b/tests/test_hxn_contributions.py @@ -0,0 +1,1846 @@ +# -*- coding: utf-8 -*- +# hensmith: Heat Exchanger Network Synthesis, Modeling, Integration, +# Thermodynamics, and Heuristics +# Copyright (C) 2026-, Sarang Bhagwat +# +# This module is under the UIUC open-source license. See +# github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt +# for license details. +""" +Tests of per-stream approach temperature contributions +(``approach_contributions``): a match between hot stream h and cold stream c +must keep ``c_h + c_c`` instead of ``T_min_app``. + +A. Default unchanged: unspecified contributions (None, nan, an empty + mapping, a callable returning None, or every stream at + ``T_min_app / 2``) give the default plans, problem tables and facility + networks bit for bit, compared in-process with ``float.hex`` and + ``tobytes``; a numpy float32 ``T_min_app`` keeps its own float32 + arithmetic in every exchanger's guard. Without contributions, the + facility's simulations (cached or writing over the heat utilities) do + not depend on the key machinery: they run the same with its helpers + raising. +B. Uniform contributions: every stream at ``x`` plans the network of + ``T_min_app = 2x`` on a shifted scale offset by a constant, and on + real-thermo cases all hot streams at ``a`` and all cold ones at ``b`` + give the default targets at ``T_min_app = a + b`` (references that do + not use the contribution code); mixed contributions give targets between + those of the smallest and of the largest pair requirement. A hot + stream's contribution that reproduces the shifts of a smaller + ``T_min_app`` gives that default network bit for bit. +C. Non-uniform contributions against INDEPENDENT references: a closed-form + constant-CP cascade with the classic symmetric shifts (hot streams down + by ``c_j``, cold streams up by ``c_j``; hensmith's shifted scale is that + scale less ``T_min_app / 2``), and a walk of every planned match on + recomputed constant-CP temperatures that checks each pair's requirement + at both ends (the minimum for linear profiles). The exactness check, the + repair and the realization enforce each pair's own requirement. The + facility (split and unsplit, constant CP and real thermodynamics) + reaches the contribution-aware problem table's targets, keeps every + pair's requirement on the exact states of its streams (independent + recomputation) and gives each exchanger its pair's ``dT`` guard; each + stream's pinch temperature is the shifted pinch plus its own shift. +D. Keys and validation: the value domain (None or nan, else a finite + number > 0); the mapping, sequence and callable forms; keys naming + units, auxiliary units through their owners (the nearest key wins), + auxiliaries of one ID under different owners, stale auxiliaries, + auxiliaries a unit holds under other IDs than biosteam's or without an + owner, and the units option; unknown keys, conflicting keys and bad + values, checked before any stream is flashed and, in the facility, even + for idle units. +E. The ``cache_network`` rule: a cached network is reused while the + explicit contributions resolve to the same values per stream. +""" +import math +import random +import re +import numpy as np +import pytest +import biosteam as bst +from numpy.testing import assert_allclose +from hensmith import (hxn_synthesis, targeting, HeatExchangerNetwork, + PinchTargets) +from hensmith import _heat_exchanger_network as network_module +from hensmith import _planner as P +from hensmith.hxn_synthesis import problem_table, synthesize_network, _pinch_cut +from test_hxn import (cp_units, KEMP, R002 as R002_UNITS, planned_split, + simulate_HXN, simulate_strictly) +from test_hxn_regression import (internal_approach, actual_loads, CASES, + STATUS_RTOL, CASCADE_RTOL) +from test_hxn_mer import _build, _case, _kelvin, APPROACH_TOL +from test_hxn_planner import (streams_from, check_network, duty_scale, + random_problem, R002, LINNHOFF4, HARD, + REPEATED_PAIR, SPLIT as PLANNER_SPLIT) +from hxn_mer_cases import NO_SPLIT, Q_UNITS, T_UNITS + +NAN = float('nan') + +# --------------------------------------------------------------------------- +# Independent helpers (no hensmith code) +# --------------------------------------------------------------------------- + +#: contribution factors of the corpus streams, times dTmin / 2 +FACTORS = (0.4, 1.0, 1.7) + +def corpus_contributions(case): + """Contributions of a corpus case's streams [source degrees].""" + return [FACTORS[k % 3] * case['dTmin'] / 2 + for k in range(len(case['streams']))] + +def corpus_streams(case): + """A constant-CP corpus case as `_plan_numeric` streams (source units).""" + return [dict(name=str(n), kind=k, T_in=float(a), T_out=float(b), + CP=float(cp)) for n, k, a, b, cp in case['streams']] + +def classic_cascade(streams, c): + """ + Closed-form constant-CP problem table with the classic symmetric shifts + (hot streams at ``T - c_j``, cold streams at ``T + c_j``; `c` aligned + with `streams`). Returns ``(Qh, Qc, zeros)``: the targets and the + interval boundaries where the cascaded heat flow (``Qh`` at the top) is + at most 1e-9 of the total duty (the top when ``Qh = 0``, the bottom + when ``Qc = 0``). hensmith's shifted pinch is ``z - T_min_app / 2`` for + some ``z`` in `zeros`. + """ + spans = [(s['T_out'] - cj, s['T_in'] - cj, s['CP']) if s['kind'] == 'hot' + else (s['T_in'] + cj, s['T_out'] + cj, -s['CP']) + for s, cj in zip(streams, c)] + Ts = sorted({T for lo, hi, _ in spans for T in (lo, hi)}, reverse=True) + heat = [0.] + for upper, lower in zip(Ts, Ts[1:]): + heat.append(heat[-1] + sum(CP * (upper - lower) for lo, hi, CP in spans + if lo <= lower and hi >= upper)) + Qh = max(0., -min(heat)) + tol = 1e-9 * duty_scale(streams) + zeros = [T for T, h in zip(Ts, heat) if h + Qh <= tol] + return Qh, heat[-1] + Qh, zeros + +def pair_ends_ok(streams, cmap, net): + """Every match keeps ``c_h + c_c`` at both ends (constant CP: the ends + are the minimum); `cmap` maps stream names to contributions.""" + for m in net['matches']: + approach = min(m['T_hot_in'] - m['T_cold_out'], + m['T_hot_out'] - m['T_cold_in']) + assert approach >= cmap[m['hot']] + cmap[m['cold']] - 1e-6, m + return True + +def assert_targets(net, Qh, Qc, streams): + tol = 1e-9 * duty_scale(streams) + assert abs(net['targets']['Qh'] - Qh) <= tol + assert abs(net['targets']['Qc'] - Qc) <= tol + +def plan_record(plan): + """Every number of a plan that the default path must keep, as hex.""" + hx = lambda x: float(x).hex() + record = [(e.side, e.hot, e.cold, hx(e.Q), hx(e.H_hot_in), + hx(e.H_cold_in), hx(e.hot_frac), hx(e.cold_frac)) + for e in plan.exchangers] + record.append((hx(plan.pinch_T), hx(plan.Q_hot), hx(plan.Q_cold), + [hx(u) for u in plan.utility], + hx(plan.info['min_approach']))) + return record + +def table_record(table): + """Every field of a `ProblemTable` as bytes or hex.""" + return [x.tobytes() + str(x.shape).encode() if isinstance(x, np.ndarray) + else float(x).hex() for x in table] + +def water_example(): + """The two-stream water example of `problem_table`'s docstring.""" + bst.settings.set_thermo(['Water'], cache=True) + bst.main_flowsheet.set_flowsheet('contributions_water') + hot_in = bst.Stream(Water=1000., T=400., P=5e5, phase='l', units='kmol/hr') + hot_out = hot_in.copy(); hot_out.vle(T=300., P=5e5) + cold_in = bst.Stream(Water=900., T=300., P=5e5, phase='l', units='kmol/hr') + cold_out = cold_in.copy(); cold_out.vle(T=390., P=5e5) + return [hot_in, cold_in], [hot_out, cold_out], [True, False], 5. + +def unit_streams(units): + """Inlets, re-flashed outlets and is_hot of the units' process streams + (in the given order), as the facility prepares them.""" + hus = [hx.heat_utilities[0] for hx in units] + inlets = [hu.unit.ins[0].copy() for hu in hus] + outlets = [hu.unit.outs[0].copy() for hu in hus] + for s in outlets: s.vle(H=s.H, P=s.P) + return inlets, outlets, [hu.duty < 0 for hu in hus] + +def by_duty(units): + return sorted((hx.heat_utilities[0] for hx in units), + key=lambda hu: hu.duty) + +def required(HXN, hx, of_unit): + """The required approach of a synthesized process exchanger: the sum of + its two streams' contributions, `of_unit` of their original units.""" + return sum(of_unit(HXN.original_heat_exchangers[i]) + for i in hxn_synthesis._stream_ports(hx)) + +def sampled_approach(hx, n=21): + """The smallest hot-minus-cold temperature difference of a process + exchanger at `n` uniform duty points, from PH flashes of copies of its + streams (any mixture; at least the exact minimum).""" + def T(stream, H): + s = stream.copy() + s.vle(H=H, P=stream.P) + return s.T + dH = [s_in.H - s_out.H for s_in, s_out in zip(hx.ins, hx.outs)] + h = 0 if dH[0] >= dH[1] else 1 + hot_in, hot_out = hx.ins[h], hx.outs[h] + cold_in, cold_out = hx.ins[1 - h], hx.outs[1 - h] + return min(T(hot_in, hot_in.H - q) - T(cold_in, max(cold_out.H - q, + cold_in.H)) + for q in np.linspace(0., hot_in.H - hot_out.H, n)) + +def network_record(HXN): + """Every number of a facility's network that the default path must + keep, as hex or bytes.""" + hx = lambda x: float(x).hex() + info = HXN.synthesis_info + return ([(u.ID, hx(u.Q), hx(u.dT)) for u in HXN.new_HXs], + [hx(u.Q) for u in HXN.new_HX_utils], # an int 0 if served + HXN.pinch_Ts.tobytes(), hx(info['min_approach']), info['status'], + [u.ID for u in HXN.new_splitters]) + +def contribution_table(units, T, of_unit): + """The problem table of the units' process streams with their + contributions (`of_unit` of each unit), prepared as the facility + prepares them, and the total duty.""" + hus = by_duty(units) + inlets, outlets, is_hot = unit_streams([hu.unit for hu in hus]) + table = problem_table(inlets, outlets, is_hot, T, approach_contributions=[ + of_unit(hu.unit) for hu in hus]) + return table, sum(abs(hu.duty) for hu in hus) + +def quickstart(name): + """The system of `HeatExchangerNetwork`'s first example: column D1 + (condenser and reboiler), its product coolers D1_H1 and D1_H2, and + flash F1 (its heater).""" + bst.main_flowsheet.set_flowsheet(name) + bst.settings.set_thermo(['Water', 'Methanol', 'Glycerol'], cache=True) + feed1 = bst.Stream('feed1', flow=(8000, 100, 25)) + feed2 = bst.Stream('feed2', flow=(10000, 1000, 10)) + D1 = bst.ShortcutColumn('D1', ins=feed1, + outs=('distillate', 'bottoms_product'), + LHK=('Methanol', 'Water'), y_top=0.99, + x_bot=0.01, k=2, is_divided=True) + D1_H1 = bst.HXutility('D1_H1', ins=D1.outs[1], T=300) + D1_H2 = bst.HXutility('D1_H2', ins=D1.outs[0], T=300) + F1 = bst.Flash('F1', ins=feed2, outs=('vapor', 'liquid'), V=0.9, + P=101325) + return D1, D1_H1, D1_H2, F1 + +# --------------------------------------------------------------------------- +# A. Default unchanged +# --------------------------------------------------------------------------- + +PLANNER_DEFAULT = { + 'R002': (10., R002, {}), + 'LINNHOFF4': (10., LINNHOFF4, {}), + 's13_111_3x9': (*HARD['s13_111_3x9'], {}), + 'smith2005_exr18_4': (*PLANNER_SPLIT['smith2005_exr18_4'], + dict(stream_splitting=True)), +} + +@pytest.mark.parametrize('name', list(PLANNER_DEFAULT)) +def test_default_plans_bit_for_bit(name): + # unspecified contributions plan the default network to the bit + dT, rows, kw = PLANNER_DEFAULT[name] + streams = streams_from(rows) + N = len(streams) + default = P._plan_numeric(streams, dT, **kw)['plan'] + reference = plan_record(default) + assert 'shift' not in default.info + assert 'min_approach_margin' not in default.info + margins = set() + for contributions in ([None] * N, [NAN] * N, [dT / 2] * N): + plan = P._plan_numeric(streams, dT, approach_contributions=contributions, + **kw)['plan'] + assert plan_record(plan) == reference + assert plan.status == default.status + assert plan.info['shift'].tobytes() == np.where( + [s['kind'] == 'hot' for s in streams], dT, 0.).tobytes() + margins.add(float(plan.info['min_approach_margin']).hex()) + assert abs(plan.info['min_approach_margin'] + - (default.info['min_approach'] - dT)) <= 1e-12 + assert len(margins) == 1 + +@pytest.mark.parametrize('name', ['water', 'case_05_boiling_cold']) +def test_default_problem_table_bit_for_bit(name): + if name == 'water': + inlets, outlets, is_hot, T = water_example() + else: + units, T = CASES[name][0]() + inlets, outlets, is_hot = unit_streams(units) + N = len(inlets) + table, curves, grid = hxn_synthesis._problem_table(inlets, outlets, is_hot, + T) + reference = table_record(table) + assert grid['shift'].tobytes() == np.where(is_hot, T, 0.).tobytes() + assert (grid['contributions'] == T / 2).all() + assert table_record(problem_table(inlets, outlets, is_hot, T)) == reference + for contributions in ([None] * N, [NAN] * N, [T / 2] * N): + other, _, other_grid = hxn_synthesis._problem_table( + inlets, outlets, is_hot, T, curves, contributions) + assert table_record(other) == reference + for key in ('Ts', 'shift', 'Hl', 'Hr', 'k_hi', 'k_lo', + 'contributions'): + assert other_grid[key].tobytes() == grid[key].tobytes(), key + assert table_record(problem_table( + inlets, outlets, is_hot, T, curves, + approach_contributions=contributions)) == reference + +@pytest.mark.parametrize('T', [0., math.ulp(0.)]) +def test_unspecified_contributions_need_no_positive_T_min_app(T): + # every value unspecified is the default path, which accepts any finite + # T_min_app (T_min_app = 0 included): same plan and table to the bit + streams = streams_from(LINNHOFF4) + N = len(streams) + is_hot_plan = [s['kind'] == 'hot' for s in streams] + default = P._plan_numeric(streams, T)['plan'] + reference = plan_record(default) + for contributions in ([None] * N, [NAN] * N, np.full(N, NAN)): + plan = P._plan_numeric(streams, T, + approach_contributions=contributions)['plan'] + assert plan_record(plan) == reference + assert plan.status == default.status + assert plan.info['shift'].tobytes() == np.where( + is_hot_plan, T, 0.).tobytes() + inlets, outlets, is_hot, _ = water_example() + table, curves, grid = hxn_synthesis._problem_table(inlets, outlets, is_hot, + T) + reference = table_record(table) + for contributions in ([None, None], [NAN, NAN], np.full(2, NAN)): + other, _, other_grid = hxn_synthesis._problem_table( + inlets, outlets, is_hot, T, curves, contributions) + assert table_record(other) == reference + for key in ('Ts', 'shift', 'Hl', 'Hr', 'k_hi', 'k_lo', + 'contributions'): + assert other_grid[key].tobytes() == grid[key].tobytes(), key + assert table_record(problem_table( + inlets, outlets, is_hot, T, curves, + approach_contributions=contributions)) == reference + +def default_network_units(name): + """(units, T_min_app, stream_splitting) of a facility test case.""" + if name == 'r002': + return cp_units('contributions_default_r002', R002_UNITS, 300.), 10., False + if name == 'case_05_boiling_cold': + return (*CASES[name][0](), False) + return (*_build(_case(name)), True) + +@pytest.mark.parametrize('name', ['r002', 'case_05_boiling_cold', + 'smith2005_ex18_2_split']) +def test_default_networks_bit_for_bit(name): + # the facility with unspecified contributions in every form, or every + # stream at T_min_app / 2, synthesizes the default network to the bit + units, T, splitting = default_network_units(name) + sys, HXN = simulate_HXN(units, T, f'contributions_default_{name}', + stream_splitting=splitting) + reference = network_record(HXN) + assert HXN._resolved_contributions is None + for contributions in ({}, {u: NAN for u in units}, lambda hu: None, + {u: T / 2 for u in units}, None): + HXN.approach_contributions = contributions + simulate_strictly(sys) + assert network_record(HXN) == reference + info = HXN.synthesis_info + assert (info['approach_contributions'] == T / 2).all() + assert (float(info['min_approach_margin']).hex() + == float(info['min_approach'] - T).hex()) + if splitting: assert HXN.new_splitters + +@pytest.mark.parametrize('name', ['r002', 'case_05_boiling_cold', + 'smith2005_ex18_2_split']) +def test_default_float32_T_min_app_keeps_its_own_arithmetic(name): + # the default path uses a numpy float32 T_min_app as given (as before + # approach contributions existed), not promoted to float64 through the + # per-stream shifts: every exchanger's guard is HXprocess.dT = T_min_app + # - _APPROACH_TOL in float32, with contributions unspecified in any form + units, T, splitting = default_network_units(name) + T = np.float32(T) + guard = float(T - hxn_synthesis._APPROACH_TOL).hex() + # the float32 guard differs from the float64 one, so this discriminates + assert guard != (float(T) - hxn_synthesis._APPROACH_TOL).hex() + sys, HXN = simulate_HXN(units, T, f'contributions_f32_{name}', + stream_splitting=splitting) + assert HXN.new_HXs + assert all(float(u.dT).hex() == guard for u in HXN.new_HXs) + reference = network_record(HXN) + for contributions in ({}, {u: NAN for u in units}, lambda hu: None, + None): + HXN.approach_contributions = contributions + simulate_strictly(sys) + assert HXN._resolved_contributions is None + assert network_record(HXN) == reference + assert (float(HXN.synthesis_info['min_approach_margin']).hex() + == float(HXN.synthesis_info['min_approach'] - T).hex()) + +#: The helpers of the key machinery: key resolution, the walks of +#: auxiliaries and the owners of auxiliaries held without one. +KEY_HELPERS = ('_parent', '_top', '_held_owners', '_unit_name', '_id_path', + '_unit_message_name', '_names_unit', + '_contribution_key_level', '_check_contribution_key', + '_owner_chains', '_nested_auxiliaries', '_heat_utility_units', + '_with_auxiliaries', '_key_universe', '_auxiliary_slots', + '_other_form_hints', '_auxiliary_attributes', '_stage_tickets', + '_is_stage_ID', '_check_key_levels', '_nearest_key_value', + '_names_unbuilt_stage', '_names_unbuilt_auxiliary', + '_unknown_keys', '_unknown_key_hints', + '_mapping_contributions', '_stream_contributions') + +def break_key_machinery(monkeypatch): + """Make every helper of the key machinery raise wherever hensmith + takes it from, `HeatExchangerNetwork._candidate_units` too; returns the + list that the names of those called are added to.""" + called = [] + + def broken(name): + def helper(*args, **kwargs): + called.append(name) + raise RuntimeError(f'{name} called') + return helper + + for name in KEY_HELPERS: + assert hasattr(hxn_synthesis, name), name # still the machinery's + for module in (hxn_synthesis, network_module, targeting): + if hasattr(module, name): + monkeypatch.setattr(module, name, broken(name)) + monkeypatch.setattr(HeatExchangerNetwork, '_candidate_units', + broken('_candidate_units')) + return called + +def compressor_system(name, **options): + """A MultistageCompressor K1, whose intercoolers have no owner, and a + cold stream's heater C1, with a facility HXN of `options`.""" + bst.main_flowsheet.set_flowsheet(name) + bst.settings.set_thermo(['Water', 'Methane'], cache=True) + gas = bst.Stream('gas', Methane=100., units='kmol/hr', T=300., + P=101325., phase='g') + K1 = bst.MultistageCompressor('K1', ins=gas, pr=3., n_stages=3, eta=0.7) + cold = bst.Stream('cold', Water=200., units='kmol/hr', T=290., P=5e5) + C1 = bst.HXutility('C1', ins=cold, T=350., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=5., **options) + return bst.System.from_units(name, units=[K1, C1, HXN]), HXN, K1.ins[0] + +def quickstart_system(name, **options): + """`quickstart`'s units with a facility HXN of `options`.""" + D1, D1_H1, D1_H2, F1 = quickstart(name) + HXN = HeatExchangerNetwork('HXN', T_min_app=5., **options) + return (bst.System.from_units(name, units=[D1, D1_H1, D1_H2, F1, HXN]), + HXN, D1.ins[0]) + +@pytest.mark.parametrize('system, options', [ + (quickstart_system, {}), + (quickstart_system, dict(cache_network=True)), + (quickstart_system, dict(replace_unit_heat_utilities=True)), + (compressor_system, {}), +], ids=['quickstart', 'quickstart_cached', 'quickstart_replacing', + 'compressor']) +def test_default_facility_needs_none_of_the_key_machinery(system, options, + monkeypatch): + # without approach contributions, a facility's simulations do not + # depend on the key machinery: with each of its helpers raising, the + # network of the quickstart system (a column with its condenser and + # reboiler, product coolers, a flash) is synthesized, reused from the + # cache after a feed change, or written over its units' heat utilities + # just the same, and none of them is called; the intercoolers of a + # compressor are held without an owner, so their holders are taken, + # for the labels of targets() only, and are None if that fails + exchanger_duties = lambda units: [(u.ID, float(u.Q).hex()) + for u in units] + + def run(name, broken): + sys, HXN, feed = system(name, **options) + called = break_key_machinery(monkeypatch) if broken else [] + records = [] + for scale in (1., 1.02): + feed.F_mass *= scale + # (biosteam warns that methane has no Dortmund groups) + if system is compressor_system: sys.simulate() + else: simulate_strictly(sys) + duties = [hu.duty for unit in sys.units if unit is not HXN + for hu in unit.heat_utilities] + records.append((exchanger_duties(HXN.new_HXs), + exchanger_duties(HXN.new_HX_utils), duties, + HXN.HXN_sys)) + monkeypatch.undo() + return records, called, HXN + + name = '_'.join([system.__name__, *options]) + reference, _, HXN0 = run(f'contributions_unbroken_{name}', False) + records, called, HXN = run(f'contributions_broken_{name}', True) + assert [r[:3] for r in records] == [r[:3] for r in reference] + assert records[0][0] # process exchangers + cached = options.get('cache_network', False) + assert (records[1][3] is records[0][3]) == cached + assert (reference[1][3] is reference[0][3]) == cached + if system is quickstart_system: + assert not called + assert HXN._holders is None is HXN0._holders + else: # the holders, best-effort, fail + assert called and HXN._holders is None + assert HXN0._holders is not None + # with the machinery back, the targets are those of the network + # taken without it, labeled by the units as they are + targets, targets0 = HXN.targets(), HXN0.targets() + assert targets.labels == targets0.labels + assert targets.summary() == targets0.summary() + if system is compressor_system: + assert targets.labels == ['C1', 'K1 - K1_H1', 'K1 - K1_H2', + 'K1 - K1_H3'] + +# --------------------------------------------------------------------------- +# B. Uniform contributions +# --------------------------------------------------------------------------- + +UNIFORM = { + 'LINNHOFF4': (10., LINNHOFF4, {}), + 'R002': (10., R002, {}), + '4sp2_dt20F': (*REPEATED_PAIR['4sp2_dt20F'], {}), + 'smith2005_exr18_4': (*PLANNER_SPLIT['smith2005_exr18_4'], + dict(stream_splitting=True)), +} + +@pytest.mark.parametrize('name', list(UNIFORM)) +def test_uniform_contributions_equal_doubled_T_min_app(name): + # every stream at x (T_min_app = 4): the network of T_min_app = 2x, on a + # shifted scale x - 2 K above it + dT, rows, kw = UNIFORM[name] + streams = streams_from(rows) + N = len(streams) + x = dT / 2. + scale = duty_scale(streams) + ref = P._plan_numeric(streams, 2. * x, **kw) + net = P._plan_numeric(streams, 4., approach_contributions=[x] * N, **kw) + assert net['status'] == ref['status'] + for key in ('Qh', 'Qc'): + assert abs(net['targets'][key] - ref['targets'][key]) <= 1e-9 * scale + plan, plan_ref = net['plan'], ref['plan'] + assert ([(e.side, e.hot, e.cold) for e in plan.exchangers] + == [(e.side, e.hot, e.cold) for e in plan_ref.exchangers]) + assert_allclose([e.Q for e in plan.exchangers], + [e.Q for e in plan_ref.exchangers], rtol=0, + atol=1e-9 * scale) + assert abs(plan.pinch_T - (plan_ref.pinch_T + x - 2.)) <= 1e-9 + assert abs(plan.info['min_approach'] + - plan_ref.info['min_approach']) <= 1e-9 + assert abs(plan.info['min_approach_margin'] + - (plan_ref.info['min_approach'] - 2. * x)) <= 1e-9 + +def test_uniform_contributions_in_the_facility(): + # KEMP: every unit at 5 K with T_min_app = 4 synthesizes the network of + # T_min_app = 10 + units = cp_units('contributions_uniform', KEMP, 300.) + sys, HXN = simulate_HXN(units, 10., 'contributions_uniform') + ref = [(hx.ID, hx.Q) for hx in HXN.new_HXs] + HXN.T_min_app = 4. + HXN.approach_contributions = {u: 5. for u in units} + simulate_strictly(sys) + assert [hx.ID for hx in HXN.new_HXs] == [ID for ID, _ in ref] + assert_allclose([hx.Q for hx in HXN.new_HXs], [Q for _, Q in ref], + rtol=1e-9) + assert all(hx.dT == 10. - 1e-6 for hx in HXN.new_HXs) + assert (HXN.synthesis_info['approach_contributions'] == 5.).all() + +def test_contributions_equal_to_a_shift_are_exact(): + # r002 at T_min_app = 10 with H1 at 2 K has the shifts of the default + # run at 7 K (hot 5 + 2, cold 5 - 5), so its network is that one to the + # bit + units = cp_units('contributions_shift_7', R002_UNITS, 300.) + sys, HXN = simulate_HXN(units, 7., 'contributions_shift_7') + reference = network_record(HXN) + min_approach = HXN.synthesis_info['min_approach'] + units = cp_units('contributions_shift_10', R002_UNITS, 300.) + sys, HXN = simulate_HXN(units, 10., 'contributions_shift_10', + approach_contributions={units[3]: 2.}) + info = HXN.synthesis_info + assert network_record(HXN) == reference + assert info['status'] == 'mer' + assert (float(info['min_approach_margin']).hex() + == float(min_approach - 7.).hex()) + assert info['approach_contributions'].tolist() == [5., 5., 5., 2.] + assert all(hx.dT == 7. - 1e-6 for hx in HXN.new_HXs) + assert round(info['Q_hot_target'] / 3600., 6) == 74. + assert round(info['Q_cold_target'] / 3600., 6) == 444. + +@pytest.mark.parametrize('name', ['case_05_boiling_cold', + 'case_08_two_condensers']) +def test_real_thermo_targets_against_uniform_runs(name): + units, T = CASES[name][0]() + hus = by_duty(units) + hot = [hu.duty < 0 for hu in hus] + total = sum(abs(hu.duty) for hu in hus) + + def table(T_min_app, contributions=None): + r = hxn_synthesis._pinch_analysis(hus, T_min_app, False, False, + contributions) + return r[12], r[14], r[10] # table, grid, heat utilities by stream + + # all hot streams at a, all cold ones at b: the default at a + b, on a + # grid offset by b - T/2 + for a, b in ((1., 4.), (3., 0.5)): + t, grid, streams = table(T, [a if h else b for h in hot]) + ref = table(a + b)[0] + assert_allclose([t.hot_util_load, t.cold_util_load], + [ref.hot_util_load, ref.cold_util_load], + rtol=1e-12, atol=1e-12 * total) + assert t.Ts.shape == ref.Ts.shape + assert_allclose(t.Ts, ref.Ts + (b - T / 2), rtol=0, atol=1e-9) + assert grid['contributions'].tolist() == [ + a if hu.duty < 0 else b for hu in streams] # stream order + # mixed contributions: between the smallest and the largest requirement + C = [(1., 4., 2., 6., 3., 0.5)[k % 6] for k in range(len(hus))] + t = table(T, C)[0] + c_hot = [c for c, h in zip(C, hot) if h] + c_cold = [c for c, h in zip(C, hot) if not h] + lo = table(min(c_hot) + min(c_cold))[0] + hi = table(max(c_hot) + max(c_cold))[0] + tol = 1e-9 * total + for load in ('hot_util_load', 'cold_util_load'): + assert (getattr(lo, load) - tol <= getattr(t, load) + <= getattr(hi, load) + tol), load + +# --------------------------------------------------------------------------- +# C. Non-uniform contributions, independent references +# --------------------------------------------------------------------------- + +def test_planner_targets_match_classic_cascade(): + rng = random.Random(21) + for _ in range(40): + rows, dT = random_problem(rng, 2, 10) + streams = streams_from(rows) + c = [rng.choice((0.5, 1., 2.5, 4., 7.5)) for _ in streams] + net = P._plan_numeric(streams, dT, approach_contributions=c, + work_scale=0.05) + Qh, Qc, zeros = classic_cascade(streams, c) + assert_targets(net, Qh, Qc, streams) + assert min(abs(net['plan'].pinch_T - (z - dT / 2)) for z in zeros) <= 1e-9 + pair_ends_ok(streams, {s['name']: cj for s, cj in zip(streams, c)}, + net) + +@pytest.mark.parametrize('name', ['kemp', 'smith2005_exr17_2_dT10']) +def test_problem_table_matches_classic_cascade(name): + if name == 'kemp': + T = 10. + units = cp_units('contributions_kemp_table', KEMP, 300.) + streams = [dict(name=ID, kind='hot' if T_in > T_out else 'cold', + T_in=T_in + 300., T_out=T_out + 300., CP=CP) + for ID, T_in, T_out, CP in KEMP] + given = [2., 6., 1., None] + else: + case = _case(name) + units, T = _build(case) + scale = T_UNITS[case['T_unit']][0] + kW = Q_UNITS[case['Q_unit']] + streams = [dict(name=n, kind=k, T_in=_kelvin(case, a), + T_out=_kelvin(case, b), CP=cp * kW / scale) + for n, k, a, b, cp in case['streams']] + given = [x * scale for x in corpus_contributions(case)] + c = [T / 2 if x is None else x for x in given] + inlets, outlets, is_hot = unit_streams(units) + assert is_hot == [s['kind'] == 'hot' for s in streams] + table, curves, grid = hxn_synthesis._problem_table( + inlets, outlets, is_hot, T, None, given) + Qh, Qc, zeros = classic_cascade(streams, c) + if name == 'kemp': # closed form: 7 kW hot, 47 kW cold, pinch 81 degC + assert_allclose((Qh, Qc), (7., 47.), rtol=0, atol=1e-9) + assert 381. in zeros + total = 3600. * sum(abs(s['CP'] * (s['T_out'] - s['T_in'])) for s in streams) + assert abs(table.hot_util_load - 3600. * Qh) <= 1e-9 * total + assert abs(table.cold_util_load - 3600. * Qc) <= 1e-9 * total + assert min(abs(table.pinch_T - (z - T / 2)) for z in zeros) <= 1e-9 + assert grid['shift'].tobytes() == np.where( + is_hot, T / 2 + np.array(c), T / 2 - np.array(c)).tobytes() + assert grid['contributions'].tolist() == c + for (Tk, _), s in zip(hxn_synthesis._grid_knots(curves, grid), streams): + assert_allclose([Tk[0], Tk[-1]], sorted((s['T_in'], s['T_out'])), + rtol=0, atol=1e-9) + +NO_SPLIT_CP = [case for case in NO_SPLIT if case['kind'] == 'constant_cp'] + +def test_no_split_corpus_with_contributions(): + # no status assertion: an unsplit MER network is known for the default + # requirement, not for these contributions (all 24 reached 'mer' when + # this test was written) + assert len(NO_SPLIT_CP) == 24 + for case in NO_SPLIT_CP: + streams = corpus_streams(case) + c = corpus_contributions(case) + net = P._plan_numeric(streams, case['dTmin'], approach_contributions=c) + Qh, Qc, _ = classic_cascade(streams, c) + assert_targets(net, Qh, Qc, streams) + hot, cold = check_network(streams, -math.inf, net) + pair_ends_ok(streams, {s['name']: cj for s, cj in zip(streams, c)}, + net) + info = net['plan'].info + assert net['matches'], case['name'] + assert info['min_approach_margin'] >= -1e-9, case['name'] + walked = min(min(m['T_hot_in'] - m['T_cold_out'], + m['T_hot_out'] - m['T_cold_in']) + for m in net['matches']) + assert abs(info['min_approach'] - walked) <= 1e-9, case['name'] + if net['status'] == 'mer': + tol = 1e-6 * duty_scale(streams) + assert abs(hot - Qh) <= tol and abs(cold - Qc) <= tol + +SPLIT_NAMES = ['smith2005_ex18_2_split', 'smith2005_exr18_4', + '7sp4_dolan1990_dt10K', 'fs_15sp_tkm', + 'cornell_processdesign_four_stream_split'] + +@pytest.mark.parametrize('name', SPLIT_NAMES) +def test_split_cases_with_contributions_reach_mer(name): + # Theorem M holds on the shared shifted scale: MER with splits + case = _case(name) + streams = corpus_streams(case) + c = corpus_contributions(case) + net = P._plan_numeric(streams, case['dTmin'], approach_contributions=c, + stream_splitting=True) + assert net['status'] == 'mer' + Qh, Qc, _ = classic_cascade(streams, c) + assert_targets(net, Qh, Qc, streams) + # branch temperatures: the parent's at the parent-equivalent enthalpies + cmap = {s['name']: cj for s, cj in zip(streams, c)} + pair_ends_ok(streams, cmap, net) + assert net['plan'].splits + info = net['plan'].info + assert info['min_approach_margin'] >= -1e-9 + # split-mode reporting against the walked ends (linear profiles: the + # ends are each match's minimum) + ends = [min(m['T_hot_in'] - m['T_cold_out'], + m['T_hot_out'] - m['T_cold_in']) for m in net['matches']] + assert abs(info['min_approach'] - min(ends)) <= 1e-9 + margin = min(a - (cmap[m['hot']] + cmap[m['cold']]) + for a, m in zip(ends, net['matches'])) + assert abs(info['min_approach_margin'] - margin) <= 1e-9 + +def test_exact_check_and_repair_per_pair(): + # hot stream 2 (on branches above the pinch) needs 5 K more against + # every partner: only its exchangers are flagged and shrunk, and only + # their exchangers get the larger dT guard + plan, curves, knots, is_hot, streams_inlet, dT = planned_split( + 'smith2005_ex18_2_split') + j = 2 + assert is_hot[j] + shift = np.where(is_hot, dT, 0.) + shift[j] += 5. + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + ends = hxn_synthesis._walk(plan, duties, knots, is_hot)[0] + bad = hxn_synthesis._exact_approach(plan, duties, ends, curves, knots, + shift)[2] + assert bad and all(plan.exchangers[n].hot == j for n in bad) + assert any(plan.exchangers[n].hot_frac != 1. for n in bad) + others = [n for n in duties if n not in bad] + assert others + new, changes = hxn_synthesis._repair(plan, duties, knots, is_hot, curves, + shift) + assert {n for n, _, _ in changes} == set(bad) + assert all(0. <= Q_after < Q_before for _, Q_before, Q_after in changes) + assert all(new[n] == duties[n] for n in others) + ends = hxn_synthesis._walk(plan, new, knots, is_hot)[0] + assert not hxn_synthesis._exact_approach(plan, new, ends, curves, knots, + shift)[2] + bst.main_flowsheet.set_flowsheet('contributions_repair') + units = hxn_synthesis._realize(plan, new, curves, knots, streams_inlet, + is_hot, shift)[0] + try: + for n, hx in units.items(): + # the pair sets the requirement: every exchanger of stream j + required = dT + 5. if plan.exchangers[n].hot == j else dT + assert hx.dT == required - 1e-6, hx.ID + assert internal_approach(hx) >= required - APPROACH_TOL, hx.ID + finally: + hxn_synthesis._discard(units.values()) + # one shift per stream, or a scalar + with pytest.raises(ValueError, match='3 shifts given for 4 streams'): + hxn_synthesis._exact_approach(plan, duties, ends, curves, knots, + shift[:3]) + with pytest.raises(ValueError, match='3 shifts given for 4 streams'): + hxn_synthesis._realize(plan, new, curves, knots, streams_inlet, + is_hot, shift[:3]) + +def facility_case(name): + """(units, T_min_app, stream_splitting, contributions by unit) of a + facility case with contributions: the corpus factors on constant-CP + split cases, and hand values in the case's own unit order on + real-thermo ones.""" + if name in CASES: + units, T = CASES[name][0]() + values, splitting = { + 'case_04_report_case': ([1., 4., 2., 6.], True), + 'case_08_two_condensers': ([1., 4., 2., 6., 3., 0.5], False), + }[name] + return units, T, splitting, dict(zip(units, values)) + case = _case(name) + units, T = _build(case) + scale = T_UNITS[case['T_unit']][0] + return units, T, True, dict(zip(units, [ + scale * c for c in corpus_contributions(case)])) + +@pytest.mark.parametrize('name', ['smith2005_ex18_2_split', + '7sp4_dolan1990_dt10K', + 'case_04_report_case', + 'case_08_two_condensers']) +def test_facility_contributions_split_and_unsplit(name): + # the facility reaches the contribution-aware problem table's targets + # and keeps every pair's own requirement on the exact stream states + units, T, splitting, C = facility_case(name) + sys, HXN = simulate_HXN(units, T, f'contributions_{name}', + stream_splitting=splitting, + approach_contributions=C) + info = HXN.synthesis_info + # Theorem M with splitting; observed for case_08 (unsplit) + assert info['status'] == 'mer' + table, total = contribution_table(units, T, C.get) + heat, cool = actual_loads(HXN) + assert abs(heat - table.hot_util_load) <= STATUS_RTOL * total + assert abs(cool - table.cold_util_load) <= STATUS_RTOL * total + assert abs(HXN.energy_balance_percent_error) < 1e-6 + for hx in HXN.new_HXs: + r = required(HXN, hx, C.get) + assert internal_approach(hx) >= r - APPROACH_TOL, hx.ID + assert abs(hx.dT - (r - 1e-6)) <= 1e-12, hx.ID + assert info['min_approach_margin'] >= -APPROACH_TOL + assert info['approach_contributions'].tolist() == [ + C[hx] for hx in HXN.original_heat_exchangers] + planned = info['plan_targets'] + assert abs(planned['Q_hot'] - table.hot_util_load) <= CASCADE_RTOL * total + assert abs(planned['Q_cold'] - table.cold_util_load) <= CASCADE_RTOL * total + assert abs(planned['pinch_T'] - table.pinch_T) <= 1e-9 + assert planned['cut'] == _pinch_cut(table) + if splitting: assert HXN.new_splitters and info['splits'] + +@pytest.mark.parametrize('sort_hus_by_T', [False, True]) +def test_pinch_Ts_per_stream(sort_hus_by_T): + # KEMP (300 K above the classic temperatures) at 10 K with C1 at 2, H2 + # at 6 and C3 at 1 K (H4 at the default 5 K): 7 kW of heating (closed + # form, see test_problem_table_matches_classic_cascade); every stream's + # pinch temperature is the shifted pinch plus its own shift + units = cp_units('contributions_pinch_Ts', KEMP, 300.) + C1, H2, C3, H4 = units + hand = {C1: 2., H2: 6., C3: 1., H4: 5.} + sys, HXN = simulate_HXN(units, 10., 'contributions_pinch_Ts', + sort_hus_by_T=sort_hus_by_T, + approach_contributions={C1: 2., H2: 6., 'C3': 1.}) + info = HXN.synthesis_info + assert info['status'] == 'mer' # observed + assert round(info['Q_hot_target'] / 3600., 6) == 7. + hxs = HXN.original_heat_exchangers + assert info['approach_contributions'].tolist() == [hand[hx] for hx in hxs] + pinch_T = info['plan_targets']['pinch_T'] + expected = {'C1': 379., 'C3': 380., 'H2': 387., 'H4': 386.} + for i, hx in enumerate(hxs): + hot = i not in HXN.cold_indices + T_p = pinch_T + 5. + (hand[hx] if hot else -hand[hx]) + T_in, T_out = HXN.inlet_Ts[i], HXN.outlet_Ts[i] + # every stream reaches the pinch (C3 starts there) + assert min(T_in, T_out) <= T_p <= max(T_in, T_out), hx.ID + assert abs(HXN.pinch_Ts[i] - T_p) <= 1e-9, hx.ID + assert abs(T_p - expected[hx.ID]) <= 1e-9, hx.ID + +# --------------------------------------------------------------------------- +# D. Keys and validation +# --------------------------------------------------------------------------- + +def test_contribution_keys_on_auxiliary_units(): + # column D1 holds its condenser and reboiler as auxiliary units, flash F1 + # its heater; D1_H1 and D1_H2 have no owner + D1, D1_H1, D1_H2, F1 = quickstart('contributions_keys') + units = [D1, D1_H1, D1_H2, F1] + bst.System.from_units('contributions_keys', units=units).simulate() + hus = bst.process_tools.heat_exchanger_utilities_from_units(units) + IDs = [hu.unit.ID for hu in hus] + assert sorted(IDs) == ['D1_H1', 'D1_H2', 'condenser', 'heat_exchanger', + 'reboiler'] + none = dict.fromkeys(IDs) + + def resolve(x): + return dict(zip(IDs, hxn_synthesis._stream_contributions(hus, x))) + + # an owner's key covers its auxiliary units; the nearest key wins + assert resolve({'D1': 3.}) == none | {'condenser': 3., 'reboiler': 3.} + assert resolve({'D1': 3., ('D1', 'condenser'): 1.}) == none | { + 'condenser': 1., 'reboiler': 3.} + assert resolve({D1: 3., D1.condenser: 1.}) == none | { + 'condenser': 1., 'reboiler': 3.} + assert resolve({F1: 2., D1_H1: 4., 'D1_H2': 1., + ('D1_H1', 'D1_H1'): 4.}) == none | { + 'heat_exchanger': 2., 'D1_H1': 4., 'D1_H2': 1.} + # None and nan are an explicit default at their level + for unspecified in (None, NAN): + assert resolve({'D1': 3., ('D1', 'condenser'): unspecified}) == ( + none | {'reboiler': 3.}) + assert resolve({D1: None, 'D1': NAN}) == none + # same-level keys must agree; a bare auxiliary ID names nothing + assert resolve({D1: 3., 'D1': 3., ('D1', 'D1'): 3.})['reboiler'] == 3. + for x in ({D1_H1: 4., 'D1_H1': 3.}, {D1: 3., 'D1': 2.}, + {D1: None, 'D1': 3.}, {'D1': 3., D1.reboiler: 1., + ('D1', 'reboiler'): 2.}): + with pytest.raises(ValueError, match='different values at the same ' + 'level'): + resolve(x) + with pytest.raises(ValueError, match="name no unit: 'condenser', " + r"\('D1', 'nope'\)"): + resolve({'condenser': 1., ('D1', 'nope'): 1., 'D1': 2.}) + # biosteam gives a single auxiliary its bare name, so an indexed ID of + # one names nothing, and the message gives the unit's key + for key in (('D1', 'condenser[0]'), ('D1', 'reboiler[7]'), + ('D1', 'pump[0]'), ('F1', 'heat_exchanger[3]')): + with pytest.raises(ValueError, match='name no unit'): + resolve({key: 1.}) + for key, hint in ((('D1', 'condenser[0]'), "D1.condenser is ('D1', " + "'condenser')"), + (('F1', 'heat_exchanger[3]'), "F1.heat_exchanger is " + "('F1', 'heat_exchanger')")): + with pytest.raises(ValueError, match=re.escape(f'; {hint}')): + resolve({key: 1.}) + # sequence (aligned with hus) and callable (once per heat utility, in + # order) forms; heat utility and other keys are rejected + assert hxn_synthesis._stream_contributions( + hus, [1., None, NAN, 2., 3.]) == [1., None, None, 2., 3.] + calls = [] + def f(hu): + calls.append(hu) + return 2. if hu.unit.owner is D1 else None + assert resolve(f) == none | {'condenser': 2., 'reboiler': 2.} + assert [id(hu) for hu in calls] == [id(hu) for hu in hus] + with pytest.raises(TypeError, match='a heat utility cannot be an ' + r'approach_contributions key.*hu\.unit'): + resolve({hus[0]: 1.}) + for key in (1, ('D1',), ('D1', 'condenser', 'x'), ('D1', 1), D1.ins[0]): + with pytest.raises(TypeError, match='keys must be units'): + resolve({key: 1.}) + # stale auxiliary units: biosteam.HXutilities rebuilds its exchangers at + # every simulation, and a key holding an old one names the new one + bst.main_flowsheet.set_flowsheet('contributions_stale') + bst.settings.set_thermo(['Water'], cache=True) + water = bst.Stream('water', Water=100., T=300., P=5e6, units='kmol/hr') + U = bst.HXutilities('U', ins=water, T=500., rigorous=False) + U.simulate() + old = U.heat_exchangers[0] + U.simulate() + stale_hus = bst.process_tools.heat_exchanger_utilities_from_units([U]) + assert len(stale_hus) > 1 + assert all(hu.unit is not old for hu in stale_hus) + assert hxn_synthesis._stream_contributions(stale_hus, {old: 2., 'U': 3.}) \ + == [2.] + [3.] * (len(stale_hus) - 1) + +class TwoFlash(bst.Unit): + """Two auxiliary flash drums, each with its own auxiliary heater: both + heaters have the tuple key ``(ID, 'heat_exchanger')``.""" + _N_ins = 2 + _N_outs = 4 + auxiliary_unit_names = ('flash_a', 'flash_b') + + def _init(self): + self.auxiliary('flash_a', bst.Flash, ins=self.ins[0], + outs=self.outs[:2], V=0.5, P=101325) + self.auxiliary('flash_b', bst.Flash, ins=self.ins[1], + outs=self.outs[2:], V=0.3, P=101325) + + def _run(self): + self.flash_a.simulate() + self.flash_b.simulate() + + def _design(self): pass + def _cost(self): pass + +class NestedHeaters(bst.Unit): + """A `biosteam.HXutilities` as an auxiliary unit: its exchangers, rebuilt + at every simulation, are auxiliaries of an auxiliary.""" + _N_ins = 1 + _N_outs = 1 + auxiliary_unit_names = ('hxs',) + + def _init(self): + self.auxiliary('hxs', bst.HXutilities, ins=self.ins[0], + outs=self.outs[0], T=500., rigorous=False) + + def _run(self): self.hxs.simulate() + def _design(self): pass + def _cost(self): pass + +def test_contribution_keys_on_nested_auxiliary_units(): + # a unit key names that very unit, not a namesake under another owner; + # a tuple key names every auxiliary of that ID under the top owner + bst.main_flowsheet.set_flowsheet('contributions_nested') + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + a = bst.Stream('a', Water=100., Ethanol=20., units='kmol/hr', T=300.) + b = bst.Stream('b', Water=150., Ethanol=10., units='kmol/hr', T=300.) + U = TwoFlash('U', ins=(a, b)) + hot = bst.Stream('hot', Water=400., units='kmol/hr', T=430., P=6e5) + C1 = bst.HXutility('C1', ins=hot, T=320., rigorous=False) + bst.System.from_units('contributions_nested', units=[U, C1]).simulate() + ha, hb = U.flash_a.heat_exchanger, U.flash_b.heat_exchanger + hus = bst.process_tools.heat_exchanger_utilities_from_units([U, C1]) + assert [hu.unit for hu in hus] == [ha, hb, C1] + assert ha.ID == hb.ID == 'heat_exchanger' + + def resolve(x): + return hxn_synthesis._stream_contributions(hus, x) + + assert resolve({ha: 2.}) == [2., None, None] + assert resolve({ha: 2., hb: 6.}) == [2., 6., None] + assert resolve({('U', 'flash_b'): 6., ha: 2.}) == [2., 6., None] + assert resolve({('U', 'flash_a'): 2., ('U', 'flash_b'): 6.}) == [ + 2., 6., None] + assert resolve({('U', 'heat_exchanger'): 2.}) == [2., 2., None] + assert resolve({'U': 3., ha: 2.}) == [2., 3., None] + # a conflict names the unit with its owners below the top one + with pytest.raises(ValueError, match=r"give unit \('U', 'heat_exchanger'" + r"\) \(of 'flash_b'\) different"): + resolve({('U', 'heat_exchanger'): 2., hb: 6.5, ha: 2.}) + with pytest.raises(ValueError, match=r"unit \('U', 'heat_exchanger'\) " + r"\(of 'flash_a'\) must be"): + resolve(lambda hu: 0. if hu.unit is ha else None) + # film coefficient keys of targets resolve the same way + t = PinchTargets.from_heat_utilities(hus, 10.) + j = next(j for j, unit in enumerate(t.units) if unit is ha) + area = t.area_target(h={ha: 2.}) + assert area == t.area_target(h={j: 2.}) + assert area != t.area_target(h={('U', 'heat_exchanger'): 2.}) + # in the facility + HXN = HeatExchangerNetwork('HXN', T_min_app=10.) + sys = bst.System.from_units('contributions_nested_facility', + units=[U, C1, HXN]) + for x, given in [({ha: 2.}, {ha: 2.}), ({ha: 2., hb: 6.}, {ha: 2., hb: 6.})]: + HXN.approach_contributions = x + sys.simulate() + assert [given.get(hx, 5.) for hx in HXN.original_heat_exchangers] == ( + HXN.synthesis_info['approach_contributions'].tolist()) + # a stale auxiliary of an auxiliary names the one that took its place, + # and only that one + bst.settings.set_thermo(['Water'], cache=True) + water = bst.Stream('water', Water=100., T=300., P=5e6, units='kmol/hr') + V = NestedHeaters('V', ins=water) + V.simulate() + old = V.hxs.heat_exchangers[0] + V.simulate() + stale_hus = bst.process_tools.heat_exchanger_utilities_from_units([V]) + assert len(stale_hus) > 1 + assert all(hu.unit is not old for hu in stale_hus) + assert stale_hus[0].unit.ID == old.ID + assert hxn_synthesis._stream_contributions( + stale_hus, {old: 2., 'V': 3.}) == [2.] + [3.] * (len(stale_hus) - 1) + +def test_contribution_keys_in_the_facility(): + # the facility, simulated twice: every key resolves to the hand values + D1, D1_H1, D1_H2, F1 = quickstart('contributions_keys_facility') + HXN = HeatExchangerNetwork('HXN', T_min_app=5., approach_contributions={ + 'D1': 1., ('D1', 'condenser'): 1.5, F1: 1., 'D1_H1': 4., + D1.reboiler: 2.}) + sys = bst.System.from_units('contributions_keys_facility', + units=[D1, D1_H1, D1_H2, F1, HXN]) + hand = {'condenser': 1.5, 'reboiler': 2., 'heat_exchanger': 1., + 'D1_H1': 4., 'D1_H2': 2.5} + of_unit = lambda unit: hand[unit.ID] + for _ in range(2): + simulate_strictly(sys) + info = HXN.synthesis_info + assert info['status'] == 'mer' # observed + assert info['approach_contributions'].tolist() == [ + hand[hx.ID] for hx in HXN.original_heat_exchangers] + assert info['min_approach_margin'] >= -APPROACH_TOL + assert len({round(hx.dT, 6) for hx in HXN.new_HXs}) > 1 + for hx in HXN.new_HXs: + r = required(HXN, hx, of_unit) + assert abs(hx.dT - (r - 1e-6)) <= 1e-12, hx.ID + # ternary streams (internal_approach takes binaries at most) + assert sampled_approach(hx) >= r - APPROACH_TOL, hx.ID + +def test_invalid_contributions(monkeypatch): + streams = streams_from(LINNHOFF4) + N = len(streams) + inlets, outlets, is_hot, T = water_example() + for n in (N - 1, N + 1): + with pytest.raises(ValueError, match=f'{n} approach contributions ' + f'given for {N} streams'): + P._plan_numeric(streams, 10., approach_contributions=[1.] * n) + with pytest.raises(ValueError, match='3 approach contributions given ' + 'for 2 streams'): + problem_table(inlets, outlets, is_hot, T, + approach_contributions=[1., 1., 1.]) + for value in (0., -1., math.inf, -math.inf, True, np.bool_(True), '3'): + with pytest.raises(ValueError, match='approach contribution of ' + 'stream 1 must be'): + P._plan_numeric(streams, 10., + approach_contributions=[None, value, None, None]) + with pytest.raises(ValueError, match='approach contribution of ' + 'stream 1 must be'): + problem_table(inlets, outlets, is_hot, T, + approach_contributions=[None, value]) + # a contribution needs a positive T_min_app (it sets the default T/2) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + P._plan_numeric(streams, 0., approach_contributions=[1.] * N) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + problem_table(inlets, outlets, is_hot, 0., + approach_contributions=[1., 1.]) + # the length is checked even when every value is unspecified + with pytest.raises(ValueError, match=f'{N + 1} approach contributions ' + f'given for {N} streams'): + P._plan_numeric(streams, 0., approach_contributions=[None] * (N + 1)) + with pytest.raises(ValueError, match='3 approach contributions given ' + 'for 2 streams'): + problem_table(inlets, outlets, is_hot, 0., + approach_contributions=[None, NAN, None]) + # nan is unspecified; numpy numbers are accepted + default = plan_record(P._plan_numeric(streams, 10.)['plan']) + for contributions in ([NAN] * N, np.full(N, NAN), np.full(N, 5.), + [np.float64(5.), 5, None, NAN]): + plan = P._plan_numeric(streams, 10., + approach_contributions=contributions)['plan'] + assert plan_record(plan) == default + # synthesize_network: one value per heat utility, checked before any + # stream is copied or flashed + units = cp_units('contributions_invalid', R002_UNITS, 300.) + hus = [hx.heat_utilities[0] for hx in units] + def no_copy(*args, **kwargs): + raise AssertionError('copied') + monkeypatch.setattr(hxn_synthesis, '_copy', no_copy) + with pytest.raises(ValueError, match='3 approach contributions given ' + 'for 4 heat utilities'): + synthesize_network(hus, 10., approach_contributions=[1.] * 3) + for x in ({1., 2., 3., 4.}, '1234', b'1234', 3.): + with pytest.raises(TypeError, match='approach_contributions must be'): + synthesize_network(hus, 10., approach_contributions=x) + with pytest.raises(ValueError, match='approach contribution of heat ' + 'utility 1 must be'): + synthesize_network(hus, 10., approach_contributions=[1., 0., 1., 1.]) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + synthesize_network(hus, 0., approach_contributions=[1., 1., 1., 1.]) + with pytest.raises(AssertionError, match='copied'): # valid: flashes next + synthesize_network(hus, 10., approach_contributions=[1., 2., None, 4.]) + # mappings and callables: keys and values are checked before any flash + for x, error, match in [ + ({'nope': 1., 'C1': 2.}, ValueError, "name no unit: 'nope'"), + ({hus[0]: 1.}, TypeError, 'a heat utility cannot be'), + ({'C1': 2., ('H1', 'H1'): -1.}, ValueError, + r"approach contribution of key \('H1', 'H1'\) must be"), + (lambda hu: 0. if hu.unit.ID == 'C2' else None, ValueError, + r"approach contribution of unit \('C2', 'C2'\) must be"), + ({'C1': 2., units[0]: 3.}, ValueError, 'different values')]: + with pytest.raises(error, match=match): + synthesize_network(hus, 10., approach_contributions=x) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + synthesize_network(hus, 0., approach_contributions={'C1': 2.}) + # a zero-duty heat utility of hus may be keyed (it is then dropped) + idle_in = bst.Stream('idle_in', Fluid=1000., T=400., units='kmol/hr') + idle = bst.HXutility('idle', ins=idle_in, T=400., rigorous=False) + idle.simulate() + assert idle.heat_utilities[0].duty == 0. + for x in ({idle: 3., 'C1': 2.}, lambda hu: 3.): + with pytest.raises(AssertionError, match='copied'): + synthesize_network(hus + idle.heat_utilities, 10., + approach_contributions=x) + +def test_invalid_contributions_in_the_facility(): + # bad keys and values raise at simulate, even when their unit is idle; + # valid keys of idle or ignored units have no effect + units = cp_units('contributions_invalid_facility', R002_UNITS, 300.) + idle_in = bst.Stream('idle_in', Fluid=1000., T=400., units='kmol/hr') + idle = bst.HXutility('idle', ins=idle_in, T=400., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=10., ignored=[units[0]]) + sys = bst.System.from_units('contributions_invalid_facility', + units=[*units, idle, HXN]) + simulate_strictly(sys) + reference = network_record(HXN) + for x, error, match in [ + ({'nope': 1.}, ValueError, 'name no unit'), + ([1.] * 3, TypeError, 'must be a mapping or a callable'), + (np.ones(3), TypeError, 'must be a mapping or a callable'), + ({units[1].heat_utilities[0]: 1.}, TypeError, + 'a heat utility cannot be'), + ({idle: -1.}, ValueError, 'approach contribution of key'), + ({idle: 2., 'idle': 3.}, ValueError, 'different values'), + ({units[0]: 2., ('C1', 'C1'): None}, ValueError, + 'different values')]: + HXN.approach_contributions = x + with pytest.raises(error, match=match): + sys.simulate() + for x in ({units[0]: 3., idle: 2.}, {idle: 2., 'idle': 2., 'C2': NAN}): + HXN.approach_contributions = x + simulate_strictly(sys) + assert network_record(HXN) == reference + assert HXN._resolved_contributions is None + +def test_keys_of_auxiliaries_a_unit_does_not_build(): + # biosteam.HXutilities builds one exchanger per steam level it needs, so + # a lower outlet temperature drops 'heat_exchangers[2]' (and [1]): a key + # of it, the tuple or the old object, is valid but has no effect while + # it is missing, and names the exchanger again once it is built; keys + # of IDs the unit never gives an auxiliary still raise (the bare name of + # a list of auxiliaries among them) + bst.main_flowsheet.set_flowsheet('contributions_unbuilt') + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + feed = bst.Stream('feed', Water=500., units='kmol/hr', T=300., P=20e5) + X1 = bst.HXutilities('X1', ins=feed, T=480., rigorous=False) + hot = bst.Stream('hot', Water=800., Ethanol=50., units='kmol/hr', T=460., + P=20e5) + X2 = bst.HXutility('X2', ins=hot, T=320., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=10.) + sys = bst.System.from_units('contributions_unbuilt', units=[X1, X2, HXN]) + + def run(T, x): + X1.T = T + HXN.approach_contributions = x + simulate_strictly(sys) + return network_record(HXN) + + tuple_key = {('X1', 'heat_exchangers[2]'): 3.} + run(480., tuple_key) + old = X1.heat_exchangers[2] + assert [hx.ID for hx in X1.heat_exchangers] == [ + 'heat_exchangers[0]', 'heat_exchangers[1]', 'heat_exchangers[2]'] + assert HXN.synthesis_info['approach_contributions'].tolist() == [ + 3. if hx is old else 5. for hx in HXN.original_heat_exchangers] + for T, N in ((440., 2), (400., 1)): + reference = run(T, None) + assert len(X1.heat_exchangers) == N + for x in (tuple_key, {old: 3.}): + assert run(T, x) == reference + assert HXN._resolved_contributions is None + # film coefficient keys of the facility's targets alike + targets = HXN.targets() + area = targets.area_target() + assert targets.area_target(h={old: 2.}) == area + assert targets.area_target(h={('X1', 'heat_exchangers[2]'): 2.}) == area + # built again: the old object names the exchanger that took its place + run(480., {old: 3.}) + new = X1.heat_exchangers[2] + assert new is not old + assert HXN.synthesis_info['approach_contributions'].tolist() == [ + 3. if hx is new else 5. for hx in HXN.original_heat_exchangers] + run(400., None) + targets = HXN.targets() + for key in (('X1', 'heat_exchanger[2]'), ('X1', 'heat_exchangers[x]'), + ('X1', 'heat_exchangers[2'), ('X2', 'heat_exchangers[2]'), + ('X9', 'heat_exchangers[2]'), ('X1', 'heat_exchangers')): + with pytest.raises(ValueError, match='h keys that name no stream'): + targets.area_target(h={key: 2.}) + HXN.approach_contributions = {key: 3.} + with pytest.raises(ValueError, match='name no unit'): + sys.simulate() + # the bare name of the list gets the keys of its exchangers + hint = "; X1.heat_exchangers is a list: ('X1', 'heat_exchangers[0]')" + with pytest.raises(ValueError, match=re.escape(hint) + r'\)$'): + sys.simulate() + with pytest.raises(ValueError, match=re.escape(hint)): + targets.area_target(h={('X1', 'heat_exchangers'): 2.}) + # outside the facility, the universe is that of the heat utilities + hus = bst.process_tools.heat_exchanger_utilities_from_units([X1, X2]) + assert hxn_synthesis._stream_contributions(hus, tuple_key) == [None, None] + assert hxn_synthesis._stream_contributions(hus, {old: 3.}) == [None, None] + +class ListedHeaters(bst.Unit): + """Two heaters in the list ``hxs``, built as some biosteam units build + their auxiliaries (the high-rate wastewater reactors, say): under IDs of + their own (``'_hx'``), not the ``'hxs[k]'`` that + `biosteam.Unit.auxiliary` gives.""" + _N_ins = 1 + _N_outs = 1 + auxiliary_unit_names = ('hxs',) + + def _init(self, Ts=(330., 360.)): + self.hxs = [bst.HXutility(f'.{self.ID}_hx{k}', ins=bst.Stream(None), + outs=bst.Stream(None), T=T, rigorous=False) + for k, T in enumerate(Ts)] + + def _run(self): + feed = self.ins[0] + for hx in self.hxs: + hx.ins[0].copy_like(feed) + hx.simulate() + feed = hx.outs[0] + self.outs[0].copy_like(feed) + + def _design(self): pass + def _cost(self): pass + +def test_keys_of_auxiliaries_under_other_IDs(): + # where a unit holds an auxiliary under another ID than the one biosteam + # gives it ('' for the heater of an EnzymeTreatment, '_hx' for + # ListedHeaters'), a key of biosteam's ID names nothing and raises, with + # the held unit's key in the message, for approach contributions and + # film coefficients, in and outside the facility; the held unit's own + # key and its owner's apply, and a list place past the end stays valid + # (no effect) + bst.main_flowsheet.set_flowsheet('contributions_other_IDs') + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + feed = bst.Stream('feed', Water=1000., units='kmol/hr', T=300.) + T1 = bst.units.EnzymeTreatment('T1', ins=feed, T=340.) + water = bst.Stream('water', Water=500., units='kmol/hr', T=300.) + U = ListedHeaters('U', ins=water) + hot = bst.Stream('hot', Water=1500., units='kmol/hr', T=420., P=5e5) + H1 = bst.HXutility('H1', ins=hot, T=320., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=10.) + sys = bst.System.from_units('contributions_other_IDs', + units=[T1, U, H1, HXN]) + simulate_strictly(sys) + hx = T1.heat_exchanger + assert hx.ID == '' and [i.ID for i in U.hxs] == ['U_hx0', 'U_hx1'] + + def used(x): + HXN.approach_contributions = x + simulate_strictly(sys) + return dict(zip(HXN.original_heat_exchangers, + HXN.synthesis_info['approach_contributions'].tolist())) + + default = used(None) + assert set(default) == {hx, *U.hxs, H1} + for x, given in [({'T1': 2.}, {hx: 2.}), ({('T1', ''): 2.}, {hx: 2.}), + ({hx: 2.}, {hx: 2.}), + ({'U': 3.}, dict.fromkeys(U.hxs, 3.)), + ({('U', 'U_hx1'): 3.}, {U.hxs[1]: 3.}), + ({('U', 'hxs[2]'): 3.}, {})]: + assert used(x) == default | given + bad = [(('T1', 'heat_exchanger'), r"T1\.heat_exchanger is \('T1', ''\)"), + (('U', 'hxs[1]'), r"U\.hxs\[1\] is \('U', 'U_hx1'\)")] + for key, hint in bad: + HXN.approach_contributions = {key: 2.} + with pytest.raises(ValueError, match='name no unit: ' + + re.escape(repr(key)) + '.*; ' + hint): + sys.simulate() + HXN.approach_contributions = None + simulate_strictly(sys) + targets = HXN.targets() + # labeled by owner, the heater of an empty ID by its owner's alone + assert dict(zip(targets.units, targets.labels)) == { + hx: 'T1', U.hxs[0]: 'U - U_hx0', U.hxs[1]: 'U - U_hx1', H1: 'H1'} + area = targets.area_target() + assert targets.area_target(h={('T1', ''): .5}) != area + assert targets.area_target(h={('T1', ''): .5}) == targets.area_target( + h={'T1': .5}) + assert targets.area_target(h={('U', 'hxs[2]'): .5}) == area + for key, hint in bad: + with pytest.raises(ValueError, match='h keys that name no stream: ' + + re.escape(repr(key)) + '.*; ' + hint): + targets.area_target(h={key: .5}) + # outside the facility, the universe is that of the heat utilities + hus = bst.process_tools.heat_exchanger_utilities_from_units([T1, U, H1]) + assert [hu.unit for hu in hus] == [hx, *U.hxs, H1] + assert hxn_synthesis._stream_contributions(hus, {('T1', ''): 2.}) == [ + 2., None, None, None] + for key, hint in bad: + with pytest.raises(ValueError, match=hint): + hxn_synthesis._stream_contributions(hus, {key: 2.}) + +def test_keys_of_auxiliaries_held_without_an_owner(): + # biosteam.MultistageCompressor builds its compressors and intercoolers + # anew, without an owner, whenever pr changes (System.from_units sets + # the owners of those that exist when it is called): the facility takes + # the compressor as their owner, so 'K1' and ('K1', 'K1_H1') apply and + # the bare 'K1_H1' raises in every sample, whichever order the system + # was built in, and the user's objects are left as they are + for presimulated in (False, True): + name = f'contributions_no_owner_{presimulated}' + bst.main_flowsheet.set_flowsheet(name) + bst.settings.set_thermo(['Water', 'Methane'], cache=True) + gas = bst.Stream('gas', Methane=100., units='kmol/hr', T=300., + P=101325., phase='g') + K1 = bst.MultistageCompressor('K1', ins=gas, pr=2., n_stages=3, + eta=0.7) + cold = bst.Stream('cold', Water=200., units='kmol/hr', T=290., + P=5e5) + C1 = bst.HXutility('C1', ins=cold, T=350., rigorous=False) + if presimulated: K1.simulate() + HXN = HeatExchangerNetwork('HXN', T_min_app=5.) + sys = bst.System.from_units(name, units=[K1, C1, HXN]) + + def used(x): + HXN.approach_contributions = x + simulate_strictly(sys) + return dict(zip(HXN.original_heat_exchangers, HXN.synthesis_info[ + 'approach_contributions'].tolist())) + + for k, pr in enumerate((2., 2.2, 2.)): + K1.pr = pr + default = used(None) + hxs = K1.hxs + assert set(default) == {*hxs, C1} + assert [hx.ID for hx in hxs] == ['K1_H1', 'K1_H2', 'K1_H3'] + assert used({'K1': 2.}) == default | dict.fromkeys(hxs, 2.) + assert used({('K1', 'K1_H1'): 2.}) == default | {hxs[0]: 2.} + HXN.approach_contributions = {'K1_H1': 2.} + with pytest.raises(ValueError, + match=r"name no unit: 'K1_H1' .*; 'K1_H1' is " + r"\('K1', 'K1_H1'\)"): + sys.simulate() + HXN.approach_contributions = None + simulate_strictly(sys) + targets = HXN.targets() + area = targets.area_target() + assert targets.area_target(h={'K1': .5}) != area + assert targets.area_target(h={'K1': .5}) == targets.area_target( + h={('K1', f'K1_H{n}'): .5 for n in (1, 2, 3)}) + with pytest.raises(ValueError, match='h keys that name no stream'): + targets.area_target(h={'K1_H1': .5}) + # the owners as biosteam left them: set by System.from_units for + # the intercoolers it saw, none for those built since + owner = K1 if presimulated and k == 0 else None + assert all(getattr(hx, '_owner', None) is owner for hx in hxs) + +def test_keys_of_stages_a_compressor_does_not_build(): + # biosteam.MultistageCompressor sets its auxiliary_unit_names to the IDs + # of the stages it builds, so a sweep over n_stages drops ('K1', 'K1_H3') + # at 2 stages: the key of a stage of any number is valid but has no + # effect while it is missing, and names the stage again once it is + # built, whichever n_stages the system started at; keys of IDs that K1 + # never gives a stage still raise + for start in (3, 2): + name = f'contributions_unbuilt_stage_{start}' + bst.main_flowsheet.set_flowsheet(name) + bst.settings.set_thermo(['Water', 'Methane'], cache=True) + gas = bst.Stream('gas', Methane=100., units='kmol/hr', T=300., + P=101325., phase='g') + K1 = bst.MultistageCompressor('K1', ins=gas, pr=2., n_stages=start, + eta=0.7) + cold = bst.Stream('cold', Water=200., units='kmol/hr', T=290., + P=5e5) + C1 = bst.HXutility('C1', ins=cold, T=350., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=5.) + sys = bst.System.from_units(name, units=[K1, C1, HXN]) + x = {'K1': 2., ('K1', 'K1_H3'): 4., ('K1', 'K1_K3'): 1.} + + def used(n, x): + K1.n_stages = n + HXN.approach_contributions = x + simulate_strictly(sys) + return dict(zip([hx.ID for hx in HXN.original_heat_exchangers], + HXN.synthesis_info['approach_contributions'] + .tolist())) + + for n in (start, 2, 3, 4, 2): + stages = {f'K1_H{i}': 2. for i in range(1, n + 1)} + if n >= 3: stages['K1_H3'] = 4. + assert used(n, x) == {'C1': 2.5} | stages + assert K1.auxiliary_unit_names == tuple( + f'K1_{t}{i}' for i in range(1, n + 1) for t in 'KH') + targets = HXN.targets() + assert targets.area_target(h={('K1', 'K1_H3'): .5}) == ( + targets.area_target() if n < 3 + else targets.area_target(h={K1.hxs[2]: .5})) + for key in (('K1', 'K1_H0'), ('K1', 'K1_H03'), ('K1', 'K1_X3'), + ('K1', 'K1_H'), ('K1', 'K2_H3'), ('C1', 'K1_H3'), + ('K1', 'K1_H3x')): + with pytest.raises(ValueError, match='h keys that name no stream'): + targets.area_target(h={key: 2.}) + HXN.approach_contributions = {key: 3.} + with pytest.raises(ValueError, match='name no unit'): + sys.simulate() + # a bad value of a callable names a stage by its key in the facility, + # as a mapping does (K1 holds its stages without an owner) + HXN.approach_contributions = lambda hu: (-1. if hu.unit.ID == 'K1_H2' + else None) + with pytest.raises(ValueError, match=r"unit \('K1', 'K1_H2'\) must be"): + sys.simulate() + # outside the facility, only the heat utilities' units are at hand: the + # intercoolers have no owner, and the messages say so + hus = bst.process_tools.heat_exchanger_utilities_from_units([K1, C1]) + assert [hu.unit.ID for hu in hus] == ['K1_H1', 'K1_H2', 'C1'] + assert hxn_synthesis._stream_contributions(hus, {'K1_H1': 2.}) == [ + 2., None, None] + for key, hint in ( + ('K1', r"; 'K1_H1', 'K1_H2' have no owner here, so their IDs are " + r"their keys \(a MultistageCompressor 'K1' owns"), + (('K1', 'K1_H1'), r"; \('K1', 'K1_H1'\) is 'K1_H1' \(a unit " + r"without an owner here\)\)$")): + with pytest.raises(ValueError, match=hint): + hxn_synthesis._stream_contributions(hus, {key: 2.}) + with pytest.raises(ValueError, match=hint): + PinchTargets.from_heat_utilities(hus, 5., {key: 2.}) + # a key of a stage beyond n_stages is no key of a unit not at hand + with pytest.raises(ValueError, match=r"name no unit: \('K1', 'K1_H3'\)"): + hxn_synthesis._stream_contributions(hus, {('K1', 'K1_H3'): 2.}) + # a callable's bad value names a stage by its key with the units given + bad = lambda hu: -1. if hu.unit.ID == 'K1_H2' else None + for units, name in ((None, 'K1_H2'), ([K1], 'K1')): + with pytest.raises(ValueError, match=rf"unit \('{name}', 'K1_H2'\) " + r"must be"): + hxn_synthesis._stream_contributions(hus, bad, units) + +def test_targets_keep_the_holders_of_stages_rebuilt_since(): + # targets keep the holders of the stages that a MultistageCompressor + # holds without an owner as they were when they were made (those of + # HXN.targets() as at the synthesis): stored targets of one sample keep + # 'K1' and ('K1', 'K1_H1') on their intercoolers, and their labels, + # after K1 rebuilds its stages in the next sample or is simulated alone + bst.main_flowsheet.set_flowsheet('contributions_kept_holders') + bst.settings.set_thermo(['Water', 'Methane'], cache=True) + gas = bst.Stream('gas', Methane=100., units='kmol/hr', T=300., + P=101325., phase='g') + K1 = bst.MultistageCompressor('K1', ins=gas, pr=2., n_stages=3, eta=0.7) + cold = bst.Stream('cold', Water=200., units='kmol/hr', T=290., P=5e5) + C1 = bst.HXutility('C1', ins=cold, T=350., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=5., + approach_contributions={'K1': 2.}) + sys = bst.System.from_units('contributions_kept_holders', + units=[K1, C1, HXN]) + keys = ({'K1': .5}, {('K1', 'K1_H1'): .5}, {('K1', 'K1_H3'): .5}) + + def results(targets): + return (targets.labels, targets.approach_contributions, + [targets.area_target(h=h) for h in keys], + targets.at(10.).area_target(h=keys[0])) + + stored = [] + for pr in (2., 2.5, 3., 2.2): + K1.pr = pr + simulate_strictly(sys) + hxs = list(K1.hxs) + targets = HXN.targets() + assert dict(zip(targets.units, targets.labels)) == { + C1: 'C1', **{hx: f'K1 - {hx.ID}' for hx in hxs}} + first = results(targets) + area = targets.area_target() + assert all(a != area for a in first[2]) + stored.append((targets, first, hxs)) + for targets, first, hxs in stored: + assert all(getattr(hx, '_owner', None) is None for hx in hxs) + assert results(targets) == first + # K1 simulated alone rebuilds its stages: the facility's targets keep + # the contributions the network was synthesized with (and warn that + # their streams are of the former stages) + info = HXN.synthesis_info['approach_contributions'].tolist() + old = list(K1.hxs) + K1.pr = 2.5 + K1.simulate() + assert not any(hx in old for hx in K1.hxs) + with pytest.warns(RuntimeWarning, match='former state of K1 - K1_H'): + targets = HXN.targets() + assert [2.5 if c is None else c + for c in targets.approach_contributions] == info == [ + 2.5, 2., 2., 2.] + assert targets.area_target(h={'K1': .5}) != targets.area_target() + # outside the facility, key_units put the compressor at hand + simulate_strictly(sys) + hus = bst.process_tools.heat_exchanger_utilities_from_units(sys.units) + hus.sort(key=lambda hu: hu.duty) # in the facility's order + for x in ({'K1': 2.}, {K1: 2.}, {('K1', 'K1_H1'): 2., 'K1': 2.}): + targets = PinchTargets.from_heat_utilities(hus, 5., x, + key_units=sys.units) + assert targets.approach_contributions == (None, 2., 2., 2.) + assert targets.key_units == HXN._candidate_units(sys.units) + assert targets.labels == HXN.targets().labels + assert targets.area_target(h={'K1': .5}) == HXN.targets( + ).area_target(h={'K1': .5}) + with pytest.raises(ValueError, match="name no unit: +# +# This module is under the UIUC open-source license. See +# github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt +# for license details. +""" +Tests of `hensmith.targeting`. + +A. Composite and grand composite curves: closed-form knots of Linnhoff & + Hindmarsh's problem, consistency with the problem table on real-thermo + cases, and approach contributions against an independent cascade. +B. Utility targets: the multiple-utility allocation on the grand composite + curve with biosteam's agents (levels recomputed from the agents' + attributes, list order, biosteam's dT, unserved remainders, served + stretches), priced against an independent isolated pricing, a sensible + agent's line against an independent cascade of each served stream at its + own shift, and the balanced curves. +C. Supertargets: the area target against closed forms and an independent + integration, the units target on references with process and utility + pinches, the capital costs against biosteam's own costing, the + economics, and the sweep against a closed-form optimum. +D. The facility and the report: the facility's targets equal the + synthesis's bit for bit (before and after a simulation, with approach + contributions and with replaced heat utilities), the report's areas + against terminal log-mean temperature differences and against an + independent integration on the network's own streams (branches of a + split, phase changes), an infinite area where biosteam's utility + crosses its stream inside the exchanger, the report at the units' + current states only (a cached network re-simulated, also split, with + replaced heat utilities and approach contributions; stale states + detected, also with ideal thermodynamics, and a stream that changes + temperature by a micro-kelvin not taken for stale), and no side + effects on the flowsheet, the agents' stream dumps or a later + synthesis. +E. Plots: stable gids whose data equal the curves, and readable labels, + markers and legends. + +Reference A: H1 400 -> 300 K and C1 330 -> 440 K, both 1 kW/K, at +T_min_app = 10 K: 50 kW hot and 40 kW cold utility, shifted pinch 390 K. +""" +import inspect +import math +import time +import types +import warnings +import numpy as np +import pytest +import biosteam as bst +from numpy.testing import assert_allclose +from hensmith import (HeatExchangerNetwork, PinchTargets, Supertargets, supertarget, + network_report, plot_composite_curves, plot_grand_composite_curve, + plot_supertargets) +from hensmith._curves import _copy +from hensmith.hxn_synthesis import _problem_table, temperature_interval_pinch_analysis +from hensmith.targeting import (_exchanger_costs, _economics, _lmtd, _profile_area, + _duty_off_stream) +from test_hxn import (build_system, cp_units, KEMP, simulate_HXN, simulate_strictly, + _gid_artists) +from test_hxn_contributions import classic_cascade, KeptHeater +import test_hxn_targets + +K = 273.15 +kW = 3600. # kJ/hr +#: Reference A (temperatures in K, CP in kW/K). +REF_A = [('H1', 400., 300., 1.), ('C1', 330., 440., 1.)] +#: A balanced pair: no utility up to T_min_app = 10 K, 10 K apart. +PAIR = [('H1', 400., 300., 1.), ('C1', 290., 390., 1.)] +#: Reference A with C1 heated to 520 K: natural gas above high pressure steam. +FURNACE = [('C1', 330., 520., 1.), ('H1', 400., 300., 1.)] +#: Contributions of the KEMP reference with approach contributions [K]. +KEMP_C = {'C1': 1., 'C3': 2., 'H2': 6., 'H4': 8.} +#: Film coefficients of the KEMP reference of the area target [kW/m2/K]. +KEMP_H = {'C1': 1., 'H2': 2., 'C3': .5, 'H4': 4.} + +def targets_of(units, T_min_app, **kwargs): + """`PinchTargets` of the units' heat utilities, sorted by duty as the + facility sorts them.""" + hus = sorted([u.heat_utilities[0] for u in units], key=lambda hu: hu.duty) + return PinchTargets.from_heat_utilities(hus, T_min_app, **kwargs) + +def targets(name, rows, T_min_app, **kwargs): + """`targets_of` constant-CP streams given in K.""" + return targets_of(cp_units(name, rows), T_min_app, **kwargs) + +def agent(ID): + return bst.HeatUtility.get_agent(ID) + +def fresh_price(q, T_in, T_out, agent): + """``(cost, utility duty, utility outlet T)`` of a heat utility priced + with its own empty stream dump in its own temporary flowsheet (`q` > 0 + heats the process): an independent copy of the isolated pricing.""" + dump = agent.utility_stream_dump + agent.utility_stream_dump = [] + try: + with bst.Flowsheet('test_targeting_price'): + hu = bst.HeatUtility() + hu(q, T_in, T_out, agent=agent) + return hu.cost, abs(hu.duty), hu.outlet_utility_stream.T + finally: + agent.utility_stream_dump = dump + +def min_vertical_gap(hot_T, hot_H, cold_T, cold_H): + """Smallest T_hot(H) - T_cold(H) over the H range both composite curves + span (from docs/_demo_src/_common.py). Curves are piecewise linear in H, + so the minimum is at a vertex; at a vertical (equal-H) run the hot curve + takes its lower T and the cold curve its higher T (conservative).""" + hot_H, hot_T = np.asarray(hot_H), np.asarray(hot_T) + cold_H, cold_T = np.asarray(cold_H), np.asarray(cold_T) + _, i = np.unique(hot_H, return_index=True) # first = lower T + hH, hT = hot_H[i], hot_T[i] + _, j = np.unique(cold_H[::-1], return_index=True) # last = higher T + j = cold_H.size - 1 - j + cH, cT = cold_H[j], cold_T[j] + lo, hi = max(hH[0], cH[0]), min(hH[-1], cH[-1]) + Hs = np.concatenate([hH, cH]) + Hs = Hs[(Hs >= lo) & (Hs <= hi)] + return float(np.min(np.interp(Hs, hH, hT) - np.interp(Hs, cH, cT))) + +def min_driving_force(hot_T, hot_H, cold_T, cold_H): + """Smallest T_hot - T_cold over every stretch of positive heat between + the breakpoints of two piecewise-linear curves (a vertical run carries + no heat: each stretch is evaluated inside the segments it lies in; a + sliver of rounding, where the two curves' vertical runs end at heats + that differ only by rounding, would pair one curve's run with the + other's, and is skipped, as `_bath` skips slivers).""" + end = min(hot_H[-1], cold_H[-1]) + X = np.unique(np.concatenate([hot_H, cold_H])) + X = X[(X >= 0) & (X <= end)] + def T_at(T, H, i, x): + return T[i] + (T[i + 1] - T[i]) * (x - H[i]) / (H[i + 1] - H[i]) + gaps = [] + for x, y in zip(X[:-1], X[1:]): + if y - x <= 1e-9 * end: continue + m = 0.5 * (x + y) + i = np.searchsorted(hot_H, m, 'right') - 1 + j = np.searchsorted(cold_H, m, 'right') - 1 + for z in (x, y): + gaps.append(T_at(hot_T, hot_H, i, z) - T_at(cold_T, cold_H, j, z)) + return min(gaps) + +def points(T, H): + """The polyline's points with repeated ones dropped.""" + keep = np.ones(T.size, dtype=bool) + keep[1:] = (np.diff(T) != 0) | (np.diff(H) != 0) + return np.column_stack([T[keep], H[keep]]) + +def by_ID(utilities): + return {u.ID: u for u in utilities} + +def member(T_a, T_b, Q, h=1.): + """A member of `integrated_area`.""" + return (min(T_a, T_b), max(T_a, T_b), Q, h) + +def utility_members(utilities, h=None): + """The `integrated_area` members of the served utilities ``(hot, + cold)``: latent at the agent's temperature (no `T_limit`), else linear + between the utility's inlet and outlet temperatures.""" + h = h or {} + hot, cold = [], [] + for u in utilities: + if u.agent.T_limit is None: T_a = T_b = u.agent.T + else: T_a, T_b = u.T_utility_in, u.T_utility_out + (hot if u.heating else cold).append(member(T_a, T_b, u.duty, h.get(u.ID, 1.))) + return hot, cold + +def integrated_area(hot, cold, n=1_500_000): + """ + Area [m2] by midpoint integration of ``sum_j (1/h_j) (dq_j/dH) / dT`` + over the heat of two composite curves, each built here from members + ``(T_a, T_b, Q [kJ/hr], h)``: linear from T_a to T_b, or latent at T_a + == T_b. At each H the curve's temperature is found by inverting the + cumulative heat, and the heat is shared by the members present there in + proportion to their heat capacity flow rates (on a latent flat, by the + latent members at that temperature). + """ + def side(members): + knots = np.unique([T for m in members for T in m[:2]]) + def H_at(T, right): + total = np.zeros(T.size) + for T_a, T_b, Q, _ in members: + if T_a == T_b: total += Q * ((T > T_a) | (right & (T == T_a))) + else: total += Q * np.clip((T - T_a) / (T_b - T_a), 0., 1.) + return total + T = np.repeat(knots, 2) + H = np.column_stack([H_at(knots, False), H_at(knots, True)]).ravel() + return T, H + hot_T, hot_H = side(hot) + cold_T, cold_H = side(cold) + end = min(hot_H[-1], cold_H[-1]) + dH = end / n + H = (np.arange(n) + 0.5) * dH + def weights(members, T): + latent = [m for m in members if m[0] == m[1]] + flat = np.zeros(T.size, dtype=bool) + for T_a, _, _, _ in latent: flat |= T == T_a + CP = np.zeros(T.size) + w = np.zeros(T.size) + Q_flat = np.zeros(T.size) + w_flat = np.zeros(T.size) + for T_a, T_b, Q, h in members: + if T_a == T_b: + at = T == T_a + Q_flat += Q * at + w_flat += Q * at / h + else: + present = (T > T_a) & (T < T_b) + CP += Q / (T_b - T_a) * present + w += Q / (T_b - T_a) * present / h + with np.errstate(invalid='ignore', divide='ignore'): + return np.where(flat, w_flat / Q_flat, w / CP) + Th = np.interp(H, hot_H, hot_T) + Tc = np.interp(H, cold_H, cold_T) + return float(np.sum((weights(hot, Th) + weights(cold, Tc)) / (Th - Tc)) * dH / kW) + +def rows_members(rows, offset=0., h=None): + """``(hot, cold)`` `integrated_area` members of constant-CP rows.""" + h = h or {} + hot, cold = [], [] + for ID, T_in, T_out, CP in rows: + m = member(T_in + offset, T_out + offset, CP * abs(T_in - T_out) * kW, + h.get(ID, 1.)) + (hot if T_in > T_out else cold).append(m) + return hot, cold + +@pytest.fixture(scope='module') +def kemp_hxn(): + """KEMP synthesized by the facility at 10 K.""" + units = cp_units('test_targeting_kemp_hxn', KEMP, K) + sys, HXN = simulate_HXN(units, 10.) + return units, HXN + +@pytest.fixture(scope='module') +def kemp_hxn_replaced(): + """KEMP synthesized by the facility at 10 K, replacing the units' heat + utilities.""" + units = cp_units('test_targeting_kemp_hxn_replaced', KEMP, K) + sys, HXN = simulate_HXN(units, 10., replace_unit_heat_utilities=True) + return units, HXN + +@pytest.fixture(scope='module') +def quickstart_hxn(): + """The documentation's quickstart system simulated with its facility at + 5 K (two auxiliary exchangers of the column D0, a boiling and a + condensing stream): ``(sys, HXN)``.""" + sys, HXN, _ = build_system() + sys.simulate() + return sys, HXN + +@pytest.fixture(scope='module') +def quickstart(quickstart_hxn): + """The targets of the documentation's quickstart system at 5 K.""" + hus = sorted(quickstart_hxn[1].original_heat_utils, key=lambda hu: hu.duty) + return PinchTargets.from_heat_utilities(hus, 5.) + +def liquids(name, **kwargs): + """The liquids of the second example of `HeatExchangerNetwork` (and of + `network_report`) simulated with a facility at 10 K with the options + `kwargs`: ``(sys, HXN, units)``.""" + bst.main_flowsheet.set_flowsheet(name) + 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.)] + sys, HXN = simulate_HXN(units, 10., **kwargs) + return sys, HXN, units + +# %% Composites and grand composite curve + +def test_composites_closed_form_kemp(): + t = targets_of(cp_units('test_targeting_kemp', KEMP, K), 10.) + hot_T, hot_H, cold_T, cold_H = t.composite_curves() + hot = [(30, 0), (60, 45), (90, 180), (145, 427.5), (150, 450), (170, 510)] + cold = [(20, 60), (50, 120), (80, 180), (135, 510), (140, 530)] + to_K = lambda knots: np.array([(T + K, H * kW) for T, H in knots]) + assert_allclose(points(hot_T, hot_H), to_K(hot), rtol=1e-12) + assert_allclose(points(cold_T, cold_H), to_K(cold), rtol=1e-12) + shifted = t.composite_curves(shifted=True) + assert_allclose(points(*shifted[:2]), to_K(hot) - [10., 0.], rtol=1e-12) + assert_allclose(points(*shifted[2:]), to_K(cold), rtol=1e-12) + T, H = t.grand_composite_curve() + assert T[0] == t.table.Ts[0] and H[0] == pytest.approx(20 * kW, rel=1e-12) + assert H.min() == pytest.approx(0., abs=1e-9 * kW) + assert T[np.argmin(H)] == t.pinch_T == 80. + K + +@pytest.mark.parametrize('name', ['linnhoff', 'two_stream', 'condenser_at_pinch', + 'binary_glide']) +def test_composites_consistent_with_table(name): + units, T_min_app = test_hxn_targets.CASES[name]() + t = targets_of(units, T_min_app) + hus = sorted([u.heat_utilities[0] for u in units], key=lambda hu: hu.duty) + # bit for bit the synthesis's own pinch analysis + analysis = temperature_interval_pinch_analysis(hus, T_min_app) + assert t.Q_hot.hex() == analysis[1].hex() + assert t.Q_cold.hex() == analysis[2].hex() + # and the problem table of the same streams given directly + direct = PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, T_min_app) + assert direct.table.residual.tobytes() == t.table.residual.tobytes() + assert direct.labels == [s.ID for s in t.streams_inlet] + assert direct.units is direct.heat_utilities is None + assert t.units == [hu.unit for hu in hus if hu.duty > 0] + [ + hu.unit for hu in hus if hu.duty < 0] + hot_T, hot_H, cold_T, cold_H = t.composite_curves() + assert_allclose(cold_H[-1] - hot_H[-1], t.Q_hot, rtol=1e-9) + assert_allclose(cold_H[0], t.Q_cold, rtol=1e-9) + assert min_vertical_gap(hot_T, hot_H, cold_T, cold_H) >= T_min_app - 1e-6 + if t.Q_hot > 0: + shifted_gap = min_vertical_gap(*t.composite_curves(shifted=True)) + assert abs(shifted_gap) <= 1e-6 + T, H = t.grand_composite_curve() + assert np.all(np.diff(T) <= 0) + assert abs(H.min()) <= t._tol + assert_allclose([H[0], H[-1]], [t.Q_hot, t.Q_cold], rtol=1e-12, atol=t._tol) + # the utility targets add up to the MER targets, the balanced grand + # composite curve is non-negative and zero at the pinch, the balanced + # composite curves end at the same heat, and the area is finite + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + utilities = t.utility_targets() + area = t.area_target(utilities) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + assert_allclose([sum(u.duty for u in utilities if u.heating), + sum(u.duty for u in utilities if not u.heating)], + [t.Q_hot, t.Q_cold], rtol=1e-9, atol=t._tol) + T, B = t.balanced_grand_composite_curve(utilities) + assert B.min() >= -t._tol and B[T == t.pinch_T].min() <= t._tol + hot_T, hot_H, cold_T, cold_H = t.balanced_composite_curves(utilities) + assert_allclose(hot_H[-1], cold_H[-1], rtol=1e-9) + assert 0. < area < math.inf + +def test_contributions(): + assert 'approach_contributions' in inspect.signature(_problem_table).parameters + units = cp_units('test_targeting_contributions', KEMP, K) + t = targets_of(units, 10.) + N = len(units) + # all unspecified: the default, bit for bit + none = PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, 10., + [None] * N, curves=t.curves) + assert none.approach_contributions is None + assert none.table.residual.tobytes() == t.table.residual.tobytes() + assert none.Q_hot.hex() == t.Q_hot.hex() + # ... at any T_min_app (only a given value needs T_min_app > 0), with + # the length checked all the same + t0 = targets_of(units, 0.) + none0 = PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, 0., + [float('nan')] * N, curves=t.curves) + assert none0.approach_contributions is None + assert none0.Q_hot.hex() == t0.Q_hot.hex() + with pytest.raises(ValueError, match=f'{N + 1} approach contributions ' + f'given for {N} streams'): + PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, 10., + [None] * (N + 1), curves=t.curves) + assert targets_of(units, 10., approach_contributions={}).approach_contributions is None + # KEMP with contributions against the classic cascade (hot T - c, cold T + c) + tc = targets_of(units, 10., approach_contributions=KEMP_C) + labels = tc.labels + assert labels == ['C1', 'C3', 'H2', 'H4'] + rows = {ID: (T_in + K, T_out + K, CP) for ID, T_in, T_out, CP in KEMP} + streams = [dict(T_in=rows[ID][0], T_out=rows[ID][1], CP=rows[ID][2], + kind='hot' if rows[ID][0] > rows[ID][1] else 'cold') + for ID in labels] + c = [KEMP_C[ID] for ID in labels] + Qh, Qc, zeros = classic_cascade(streams, c) + assert_allclose([tc.Q_hot / kW, tc.Q_cold / kW], [Qh, Qc], rtol=1e-9) + assert_allclose([Qh, Qc], [12., 52.], rtol=1e-12) + assert tc.approach_contributions == tuple(c) + assert abs(min_vertical_gap(*tc.composite_curves(shifted=True))) <= 1e-6 + real_gap = min_vertical_gap(*tc.composite_curves()) + assert real_gap >= min(c[2:]) + min(c[:2]) - 1e-9 + assert real_gap == pytest.approx(8.222222, abs=1e-6) + # the levels of section 3.4, recomputed from shifts computed here + hot = np.array([s['kind'] == 'hot' for s in streams]) + shift = np.where(hot, 5. + np.array(c), 5. - np.array(c)) + assert_allclose(tc.shift, shift, rtol=0, atol=0) + pinch = zeros[0] - 5. + assert tc.pinch_T == pytest.approx(pinch, abs=1e-9) + top = np.array([max(s['T_in'], s['T_out']) for s in streams]) - shift + bottom = np.array([min(s['T_in'], s['T_out']) for s in streams]) - shift + dT = bst.HeatUtility.dT + LP, CW = agent('low_pressure_steam'), agent('cooling_water') + # a stream reaching beyond its limit caps the level at the limit, one + # wholly beyond it at its own end (constant-CP streams: no point loads), + # all within the served streams' limits + R = LP.T - LP.dT - dT + above = [j for j in range(N) if not hot[j] and top[j] > pinch] + L_LP = min([max(R - shift[j] for j in above)] + + [max(R - shift[j], bottom[j]) for j in above if top[j] > R - shift[j]]) + R = CW.T + CW.dT + dT + below = [j for j in range(N) if hot[j] and bottom[j] < pinch] + L_CW = max([min(R - shift[j] for j in below)] + + [min(R - shift[j], top[j]) for j in below if bottom[j] < R - shift[j]]) + utilities = by_ID(tc.utility_targets()) + assert set(utilities) == {'low_pressure_steam', 'cooling_water'} + assert utilities['low_pressure_steam'].level == L_LP + assert utilities['cooling_water'].level == L_CW + assert_allclose([L_LP, L_CW], [403.189, 299.372], rtol=1e-12) + # every form of synthesize_network: a mapping keyed by unit ID, a + # sequence aligned with the heat utilities as given (a zero-duty one + # included and dropped) and a callable; the per-stream sequence of + # __init__ (stream order) + zero = cp_units('test_targeting_contributions_zero', [('Z', 350., 350., 1.)]) + assert zero[0].heat_utilities[0].duty == 0. + given = sorted(units, key=lambda u: u.heat_utilities[0].duty) + given.insert(2, zero[0]) + hus = [u.heat_utilities[0] for u in given] + forms = [KEMP_C, [KEMP_C.get(u.ID, 5.) for u in given], + lambda hu: KEMP_C.get(hu.unit.ID)] + for form in forms: + other = PinchTargets.from_heat_utilities(hus, 10., form) + assert other.labels == labels + assert other.approach_contributions == tc.approach_contributions + assert other.Q_hot.hex() == tc.Q_hot.hex() + direct = PinchTargets(tc.streams_inlet, tc.streams_quenched, tc.is_hot, 10., + c, curves=tc.curves) + assert direct.Q_hot.hex() == tc.Q_hot.hex() + with pytest.raises(ValueError, match='5 approach contributions given for 4'): + PinchTargets.from_heat_utilities(hus[:4], 10., forms[1]) + with pytest.raises(TypeError, match='a heat utility cannot be'): + PinchTargets.from_heat_utilities(hus, 10., {hus[0]: 1.}) + with pytest.raises(TypeError, match='approach_contributions must be a sequence'): + PinchTargets(tc.streams_inlet, tc.streams_quenched, tc.is_hot, 10., + KEMP_C, curves=tc.curves) + with pytest.raises(ValueError, match='approach contribution of stream 1'): + PinchTargets(tc.streams_inlet, tc.streams_quenched, tc.is_hot, 10., + [1., 0., None, None], curves=tc.curves) + # at: unspecified streams follow T_min_app, specified ones stay or scale + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + tc.at(0.) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + tc.at(0., scale_contributions=True) + partial = targets_of(units, 10., approach_contributions={'C1': 2.}) + at20 = partial.at(20.) + assert at20.approach_contributions == (2., None, None, None) + assert_allclose(at20.shift, [8., 0., 20., 20.], rtol=0, atol=0) + assert partial.at(20., scale_contributions=True).approach_contributions == ( + 4., None, None, None) + assert at20.curves is partial.curves and at20.units is partial.units + assert at20.labels == partial.labels + assert t.at(0.).Q_hot < t.Q_hot + assert t.at(10.).Q_hot.hex() == t.Q_hot.hex() + +# %% Utilities + +def test_two_steam_two_coolant_allocation(): + t = targets('test_targeting_ref_A', REF_A, 10.) + assert (t.Q_hot / kW, t.Q_cold / kW, t.pinch_T, t.cut) == ( + pytest.approx(50.), pytest.approx(40.), 390., 'below') + dT = bst.HeatUtility.dT + LP, MP = agent('low_pressure_steam'), agent('medium_pressure_steam') + CW, ChW = agent('cooling_water'), agent('chilled_water') + q_LP = LP.T - LP.dT - dT - 390. + q_CW = 330. - (CW.T + CW.dT + dT - 10.) + utilities = t.utility_targets() + assert [u.ID for u in utilities] == [ + 'low_pressure_steam', 'medium_pressure_steam', 'cooling_water', 'chilled_water'] + assert [u.heating for u in utilities] == [True, True, False, False] + assert_allclose([u.duty / kW for u in utilities], + [q_LP, 50. - q_LP, q_CW, 40. - q_CW], rtol=1e-9) + served = [(390., 407.189), (407.189, 440.), (340., 312.372), (312.372, 300.)] + assert_allclose([(u.T_in, u.T_out) for u in utilities], served, rtol=1e-12) + for u in utilities: + cost, utility_duty, T_out = fresh_price( + u.duty if u.heating else -u.duty, u.T_in, u.T_out, u.agent) + assert u.cost == pytest.approx(cost, rel=1e-12) + assert u.utility_duty == pytest.approx(utility_duty, rel=1e-12) + assert u.utility_duty == pytest.approx( + u.duty / u.agent.heat_transfer_efficiency, rel=1e-12) + assert u.T_utility_out == T_out + assert u.flow > 0. + assert by_ID(utilities)['cooling_water'].T_utility_out == CW.T_limit + assert by_ID(utilities)['medium_pressure_steam'].level > t.table.Ts[0] + +def test_list_order_rule(): + t = targets('test_targeting_list_order', REF_A, 10.) + LP, MP = agent('low_pressure_steam'), agent('medium_pressure_steam') + CW, ChW = agent('cooling_water'), agent('chilled_water') + utilities = t.utility_targets([MP, LP], [ChW, CW]) + assert [(u.ID, round(u.duty / kW, 9)) for u in utilities] == [ + ('medium_pressure_steam', 50.), ('chilled_water', 40.)] + assert [(u.T_in, u.T_out) for u in utilities] == [(390., 440.), (340., 300.)] + +def test_dT_follows_biosteam(monkeypatch): + t = targets('test_targeting_dT', REF_A, 10.) + before = by_ID(t.utility_targets()) + monkeypatch.setattr(bst.HeatUtility, 'dT', 10.) + after = by_ID(t.utility_targets()) + LP, CW = 'low_pressure_steam', 'cooling_water' + assert after[LP].duty == pytest.approx(before[LP].duty - 5 * kW, rel=1e-9) + assert after[CW].duty == pytest.approx(before[CW].duty - 5 * kW, rel=1e-9) + assert after[CW].T_out == pytest.approx(before[CW].T_out + 5., rel=1e-12) + for u in after.values(): + cost, _, T_out = fresh_price(u.duty if u.heating else -u.duty, + u.T_in, u.T_out, u.agent) + assert u.cost == pytest.approx(cost, rel=1e-12) + assert u.T_utility_out == T_out + +def test_unserved_warns(): + t = targets('test_targeting_unserved', REF_A, 10.) + LP, CW = agent('low_pressure_steam'), agent('cooling_water') + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + utilities = t.utility_targets([LP], [CW]) + messages = [str(w.message) for w in caught if w.category is RuntimeWarning] + assert len(messages) == 2 + assert 'heating agent' in messages[0] and 'kJ/hr' in messages[0] + assert 'cooling agent' in messages[1] + unserved = [u for u in utilities if u.ID is None] + assert [(u.heating, u.level) for u in unserved] == [(True, math.inf), + (False, -math.inf)] + assert_allclose([u.duty / kW for u in unserved], [32.811, 12.372], rtol=1e-9) + for u in unserved: + assert u.agent is None + assert all(math.isnan(x) for x in (u.cost, u.flow, u.utility_duty, + u.T_utility_in, u.T_utility_out)) + # the unserved heat is missing from the balanced composites: the + # unserved cooling, the coldest of the cold side, is a gap at the start + # of the cold one (which keeps the curves aligned, dT apart at cooling + # water), the unserved heating one at the end of the hot one + curves = t.balanced_composite_curves(utilities) + hot_T, hot_H, cold_T, cold_H = curves + assert hot_H[0] == 0. and cold_H[0] == pytest.approx(12.372 * kW, rel=1e-9) + assert_allclose(cold_H[-1] - hot_H[-1], 32.811 * kW, rtol=1e-9) + assert min_vertical_gap(*curves) == pytest.approx(bst.HeatUtility.dT, abs=1e-6) + # so the area, the capital cost and the total annualized cost are nan + assert math.isnan(t.area_target(utilities)) + assert math.isnan(t.capital_cost_target(utilities)['installed_cost']) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + summary = t.summary(heating_agents=[LP], cooling_agents=[CW]) + for key in ('utility_cost', 'area', 'installed_cost', 'TAC'): + assert math.isnan(summary[key]), key + assert summary['N_units'] == t.units_target(utilities)['total'] + +def test_kemp_single_LP(kemp_hxn): + units, HXN = kemp_hxn + t = targets_of(units, 10.) + utilities = t.utility_targets() + assert [(u.ID, round(u.duty / kW, 9)) for u in utilities] == [ + ('low_pressure_steam', 20.), ('cooling_water', 60.)] + # the network serves C1 with medium pressure steam (its outlet, 135 degC, + # is above low pressure steam's limit) and H4 with chilled water + agents = {} + for hx in HXN.new_HX_utils: + for hu in hx.heat_utilities: + if hu.agent: agents[hu.unit.ID] = hu.agent.ID + index = {hx.ID: i for i, hx in enumerate(HXN.original_heat_exchangers)} + assert agents == {f'Util_{index["C1"]}_hs': 'medium_pressure_steam', + f'Util_{index["H4"]}_cs': 'chilled_water'} + network_cost = sum(hu.cost for hx in HXN.new_HX_utils for hu in hx.heat_utilities) + assert network_cost > sum(u.cost for u in utilities) + +def test_served_interval_and_balanced(): + t = targets('test_targeting_balanced', REF_A, 10.) + utilities = t.utility_targets() + hot_T, hot_H, cold_T, cold_H = t.balanced_composite_curves(utilities) + hot = [(300, 0), (340, 40), (400, 100), (412.189, 100), (412.189, 117.189), + (454.77, 117.189), (454.77, 150)] + cold = [(280.372, 0), (300.372, 12.372), (305.372, 12.372), (324.817, 40), + (330, 40), (390, 100), (440, 150)] + assert_allclose(points(hot_T, hot_H / kW), hot, rtol=1e-9) + assert_allclose(points(cold_T, cold_H / kW), cold, rtol=1e-9) + assert_allclose([hot_H[-1], cold_H[-1]], 150 * kW, rtol=1e-9) + T, B = t.balanced_grand_composite_curve(utilities) + assert np.all(np.diff(T) <= 0) and B.min() >= -1e-9 + for level in (390., 407.189): + assert B[T == level].min() == pytest.approx(0., abs=1e-9) + assert B[0] == pytest.approx(by_ID(utilities)['medium_pressure_steam'].duty, rel=1e-12) + assert B[-1] == pytest.approx(by_ID(utilities)['chilled_water'].duty, rel=1e-12) + # cooling water serves the stretch it reaches, priced as biosteam prices + # that stream (outlet 5 K below the process inlet), and the balanced + # composites keep a positive driving force + rows = [('H2', 400., 350., 1.), ('C2', 340., 390., 1.), ('H1', 322., 315., 1.)] + t = targets('test_targeting_served', rows, 10.) + CW = by_ID(t.utility_targets())['cooling_water'] + assert (CW.T_in, CW.T_out) == (322., 315.) + assert CW.T_utility_out == pytest.approx(317., abs=1e-9) + for T_min_app in (1., 5., 10.): + tt = t.at(T_min_app) + curves = tt.balanced_composite_curves() + assert_allclose(curves[1][-1], curves[3][-1], rtol=1e-9) + assert min_driving_force(*curves) > 0. + assert 0. < tt.area_target() < math.inf + +def test_pricing_is_isolated(): + # a fuel case: C1 330 -> 520 K needs natural gas above high pressure steam + t = targets('test_targeting_isolated', [('C1', 330., 520., 1.), + ('H1', 400., 300., 1.)], 10.) + agents = bst.HeatUtility.heating_agents + bst.HeatUtility.cooling_agents + dumps = [(a.utility_stream_dump, list(a.utility_stream_dump)) for a in agents] + registered = lambda: {ID: id(s) for ID, s in bst.main_flowsheet.stream.data.items()} + registry = registered() + gas = agent('natural_gas') + before = fresh_price(10 * kW, 450., 500., gas)[0] + first = t.utility_targets() + second = t.utility_targets() + assert [u.cost for u in first] == [u.cost for u in second] + assert [u.ID for u in first] == ['low_pressure_steam', 'medium_pressure_steam', + 'high_pressure_steam', 'natural_gas', + 'cooling_water', 'chilled_water'] + assert_allclose([u.duty / kW for u in first[:4]], [17.189, 42.581, 54.221, 16.009], + atol=1e-9) + fuel = first[3] + assert fuel.T_utility_out == pytest.approx(508.991, abs=1e-9) + assert fuel.cost == pytest.approx(fresh_price(fuel.duty, fuel.T_in, fuel.T_out, gas)[0], + rel=1e-12) + assert registered() == registry + assert '_hensmith_targets' not in bst.Flowsheet.flowsheet.__dict__ + for a, (dump, items) in zip(agents, dumps): + assert a.utility_stream_dump is dump and a.utility_stream_dump == items + assert fresh_price(10 * kW, 450., 500., gas)[0] == before + # a flowsheet of the temporary one's ID that is already registered is + # registered again on exit, and gets none of the pricing's streams + flowsheets = bst.Flowsheet.flowsheet.__dict__ + mine = bst.Flowsheet('_hensmith_targets') + try: + assert [u.cost for u in t.utility_targets()] == [u.cost for u in first] + assert flowsheets['_hensmith_targets'] is mine and not mine.stream.data + assert registered() == registry + finally: + flowsheets.pop('_hensmith_targets', None) + +def vacuum_condenser(name, T_sat): + """A vacuum condenser and a liquid cooler of water: 40 kmol/hr of vapour + condensed at `T_sat` [K] (from 0.5 K above it) and 20 kmol/hr of liquid + cooled from 345 K to `T_sat`.""" + test_hxn_targets.setup(name, ['Water']) + P = bst.settings.chemicals.Water.Psat(T_sat) + vapour = bst.Stream(name + '_vapour', Water=40., T=T_sat + .5, P=P, phase='g') + condenser = bst.HXutility(name + '_condenser', ins=vapour, V=0., rigorous=True) + liquid = bst.Stream(name + '_liquid', Water=20., T=345., P=3e5) + cooler = bst.HXutility(name + '_cooler', ins=liquid, T=T_sat, rigorous=False) + condenser.simulate() + cooler.simulate() + return [condenser, cooler] + +@pytest.mark.parametrize('T_sat', [316., 330.]) +def test_sensible_line_condenser(T_sat): + # cooling water serves a condenser at the cold end of its stretch: + # priced from the warm end alone (biosteam's rule) it would leave at its + # limit, 324.817 K, passing the condensing temperature with most of its + # duty taken (the balanced composites would cross) + t = targets_of(vacuum_condenser(f'test_targeting_vacuum_{T_sat:.0f}', T_sat), 10.) + dT, CW = bst.HeatUtility.dT, agent('cooling_water') + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + utilities = t.utility_targets() + area = t.area_target(utilities) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + (u,) = utilities + assert u.ID == 'cooling_water' and u.duty == pytest.approx(t.Q_cold, rel=1e-12) + # the highest outlet of a line that has taken H when it passes the knot + # (T, H) of the hot composite at T - dT or below: the series minimum flow + hot_T, hot_H, _, _ = t.composite_curves() + knot = hot_H > 0. + bound = CW.T + np.min((hot_T[knot] - dT - CW.T) * hot_H[-1] / hot_H[knot]) + T_out = min(CW.T_limit, bound) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-9) + priced = fresh_price(-u.duty, min(u.T_in, bound + dT), u.T_out, CW)[0] + assert u.cost == pytest.approx(priced, rel=1e-9) + warm_end = fresh_price(-u.duty, u.T_in, u.T_out, CW)[0] + if T_sat == 316.: + assert T_out < T_sat - dT + 1. and u.cost > 3 * warm_end + else: + assert u.T_utility_out == CW.T_limit and u.cost == warm_end + # the line keeps dT below the hot composite, so the area is finite, at + # any T_min_app + curves = t.balanced_composite_curves(utilities) + assert min_vertical_gap(*curves) >= dT - 1e-6 + assert 0. < area < math.inf + for T_min_app in (2., 20.): + assert 0. < t.at(T_min_app).area_target() < math.inf + +def test_sensible_line_point_load_alone(): + # a pure condenser, a point load alone at the cold end of cooling water's + # stretch, is present there: the line keeps dT below its condensing + # temperature (taken for absent, cooling water would leave at its limit + # and the balanced composites would cross by 4.5 K) + test_hxn_targets.setup('test_targeting_line_point_load', ['Water']) + P = bst.settings.chemicals.Water.Psat(318.) + vapour = bst.Stream(None, Water=40., T=318., P=P, phase='g') + vapour.vle(V=1., P=P) + liquid = vapour.copy() + liquid.vle(V=0., P=P) + hot_in = bst.Stream(None, Water=200., T=345., P=3e5) + hot_out = hot_in.copy() + hot_out.T = 330. + t = PinchTargets([vapour, hot_in], [liquid, hot_out], [True, True], 10., + labels=['COND', 'H']) + Ts = t.grid['Ts'] + assert t.grid['k_lo'][0] == t.grid['k_hi'][0] == Ts.size - 1 + T_c = Ts[-1] + t.shift[0] + dT, CW = bst.HeatUtility.dT, agent('cooling_water') + assert Ts[-1] > CW.T + CW.dT + dT - t.shift[0] # the stretch ends at the grid's end + (u,) = t.utility_targets() + assert u.ID == CW.ID and u.duty == pytest.approx(t.Q_cold, rel=1e-12) + assert (u.T_in, u.T_out) == (pytest.approx(345., abs=1e-9), T_c) + # the line has taken the condensation's heat when it reaches T_c - dT + T_out = CW.T + (T_c - dT - CW.T) * u.duty / (vapour.H - liquid.H) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert T_out < CW.T_limit - 10. + assert u.cost == pytest.approx(fresh_price(-u.duty, T_out + dT, u.T_out, CW)[0], + rel=1e-9) + curves = t.balanced_composite_curves([u]) + assert min_vertical_gap(*curves) == pytest.approx(dT, abs=1e-6) + assert min_driving_force(*curves) >= dT - 1e-6 + +def test_sensible_line_constant_cp(): + dT = bst.HeatUtility.dT + # cooling water: 40 of its 64 kW are below 316 K + t = targets('test_targeting_line_water', [('H1', 340., 312.372, 1.), + ('H2', 316., 312.372, 10.)], 10.) + CW = agent('cooling_water') + (u,) = t.utility_targets() + q = 340. - 312.372 + 10. * (316. - 312.372) + assert u.duty / kW == pytest.approx(q, rel=1e-9) + T_out = CW.T + (316. - dT - CW.T) * q / (q - (340. - 316.)) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-9) and T_out < 315. + assert u.cost == pytest.approx(fresh_price(-u.duty, T_out + dT, u.T_out, CW)[0], + rel=1e-9) + curves = t.balanced_composite_curves([u]) + assert min_vertical_gap(*curves) == pytest.approx(dT, abs=1e-6) + assert 0. < t.area_target([u]) < math.inf + # a sensible heating agent against a concave cold composite: 110 of the + # 180 kW of a hot oil (480 -> 420 K at most) are above 460 K + t = targets('test_targeting_line_oil', [('C1', 390., 470., 1.), + ('C2', 460., 470., 10.)], 10.) + oil = bst.UtilityAgent('test_targeting_hot_oil', Water=1, T=480., P=5e6, + phase='l', thermo=bst.HeatUtility.thermo_water, + T_limit=420., heat_transfer_price=1e-6) + (u,) = t.utility_targets([oil], []) + assert (u.ID, u.T_in, u.T_out) == ('test_targeting_hot_oil', 390., 470.) + T_out = oil.T - (oil.T - dT - 460.) * 180. / 110. + assert u.T_utility_out == pytest.approx(T_out, rel=1e-9) and T_out > 455. + cost, utility_duty, priced = fresh_price(u.duty, T_out - dT, u.T_out, oil) + assert (u.cost, u.utility_duty) == (pytest.approx(cost, rel=1e-12), + pytest.approx(utility_duty, rel=1e-12)) + assert priced == pytest.approx(T_out, rel=1e-12) + curves = t.balanced_composite_curves([u]) + assert min_vertical_gap(*curves) == pytest.approx(dT, abs=1e-6) + assert 0. < t.area_target([u]) < math.inf + +def sensible_line_sweep(t, agents, T_min_apps): + """``{T_min_app: utility targets}`` over a sweep of `t`, asserting that + the balanced composite curves keep min(T_min_app, dT) apart (no warning, + a finite area that falls with T_min_app).""" + dT, sweep, areas = bst.HeatUtility.dT, {}, [] + for T_min_app in T_min_apps: + at = t.at(T_min_app) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + utilities = at.utility_targets(*agents) + areas.append(at.area_target(utilities)) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + curves = at.balanced_composite_curves(utilities) + assert min_driving_force(*curves) >= min(T_min_app, dT) - 1e-9 + sweep[T_min_app] = utilities + assert np.all(np.isfinite(areas)) and np.all(np.diff(areas) < 0.) + return sweep + +def test_sensible_line_process_in_stretch(): + # a process stream of the other kind inside a sensible agent's stretch: + # the line must keep dT from the process where the agent has taken little + # of its duty, not only at the stretch's warm end (cooling) or cold end + # (heating), which crossed the curves (area inf at any T_min_app) + dT, T_min_apps = bst.HeatUtility.dT, (1., 2., 3., 5., 8., 10.) + # chilled water serves H1 from 312.372 K (the utility pinch of cooling + # water) to 287.372 K, but C1, from 297 K, takes most of H1's heat above + # 297 K + T_min_app: the bound is there + t = targets('test_targeting_line_cold_in', [('H1', 518.9, 272.3, 4.99), + ('C1', 297., 450.3, 4.85)], 10.) + CHW = agent('chilled_water') + for T_min_app, utilities in sensible_line_sweep(t, (), T_min_apps).items(): + assert [u.ID for u in utilities] == ['cooling_water', 'chilled_water', + 'chilled_brine'] + u = utilities[1] + T_C1 = 297. + T_min_app # H1 where C1 starts + below = 4.99 * kW * (T_C1 - u.T_out) # H1's heat below it + T_out = min(CHW.T_limit, CHW.T + (T_C1 - dT - CHW.T) * u.duty / below) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + priced = fresh_price(-u.duty, min(u.T_in, T_out + dT), u.T_out, CHW)[0] + assert u.cost == pytest.approx(priced, rel=1e-12) + if T_min_app <= 5.: assert T_out < CHW.T_limit - 2. + # its mirror: a hot oil (500 K, down to 300 K at most) serves C1 from 290 + # K, but H1, up to 450 K, gives most of C1's heat below 450 K - T_min_app + t = targets('test_targeting_line_hot_in', [('C1', 290., 470., 5.), + ('H1', 450., 320., 4.85)], 10.) + oil = bst.UtilityAgent('test_targeting_hot_oil_in', Water=1, T=500., P=5e6, + phase='l', thermo=bst.HeatUtility.thermo_water, + T_limit=300., heat_transfer_price=1e-6) + q = (5. * 180. - 4.85 * 130.) * kW + for T_min_app, utilities in sensible_line_sweep(t, ([oil], []), T_min_apps).items(): + (u,) = utilities + assert (u.T_in, u.T_out) == (pytest.approx(290., abs=1e-9), + pytest.approx(470., abs=1e-9)) + assert u.duty == pytest.approx(q, rel=1e-12) + T_H1 = 450. - T_min_app # C1 where H1 starts + above = 5. * kW * (470. - T_H1) # C1's heat above it + T_out = oil.T - (oil.T - dT - T_H1) * q / above + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert u.cost == pytest.approx(fresh_price(u.duty, T_out - dT, u.T_out, oil)[0], + rel=1e-12) + +def line_gaps(t, rows, u): + """ + ``{ID: smallest gap [K]}`` between the line of the sensible utility `u` + (linear in its heat, from `T_utility_in` at its level to `T_utility_out`) + and each constant-CP stream of `rows` (as in `targets`), each at its own + shift, every stream on the level's served side being served by `u`: an + independent cascade over the shifted stretch below `u.level` (above it, + for cooling), positive where the line is hotter than a cold stream + (colder than a hot one). + """ + shift = dict(zip(t.labels, t.shift)) + ends = {} + for ID, T_a, T_b, CP in rows: + lo, hi = min(T_a, T_b) - shift[ID], max(T_a, T_b) - shift[ID] + if u.heating: hi = min(hi, u.level) + else: lo = max(lo, u.level) + if lo < hi: ends[ID] = (lo, hi, CP * kW) + knots = np.ravel([(lo, hi) for lo, hi, _ in ends.values()]) + P = np.unique(np.concatenate([knots, np.linspace(knots.min(), knots.max(), 2001)])) + def given(p): # the heat `u` has exchanged beyond the shifted positions p + if u.heating: + return sum(CP * np.clip(hi - np.maximum(lo, p), 0., None) + for lo, hi, CP in ends.values()) + return sum(CP * np.clip(np.minimum(hi, p) - lo, 0., None) + for lo, hi, CP in ends.values()) + assert u.duty == pytest.approx(given(-math.inf if u.heating else math.inf), + rel=1e-12) + gaps = {} + for ID, (lo, hi, _) in ends.items(): + p = P[(lo <= P) & (P <= hi)] + T_line = u.T_utility_in + (u.T_utility_out - u.T_utility_in) * given(p) / u.duty + gap = T_line - (p + shift[ID]) + gaps[ID] = float(np.min(gap if u.heating else -gap)) + return gaps + +def test_sensible_line_streams_of_other_shifts(): + # with approach contributions the streams at a position of a sensible + # agent's stretch are at different real temperatures: its line must keep + # dT from the hottest cold stream there (the coldest hot one, for + # cooling). A hot oil (480 K, dT 0) serves C1 (390 -> 470 K, shift 4 K) + # and C2 (400 -> 475 of 478 K, shift -4 K): where C1 ends (shifted 466 + # K) it has given only C2's 13 kW above it, and C1 is 8 K hotter than C2 + # there. Bounded by C2 there, it would leave at 395.1 K and pass 3 K + # below C1 at shifted 404 K. + dT, water = bst.HeatUtility.dT, bst.HeatUtility.thermo_water + rows = [('C1', 390., 470., 1.), ('C2', 400., 478., 1.)] + t = targets('test_targeting_line_shifts_heat', rows, 10., + approach_contributions={'C1': 1., 'C2': 9.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['C1'], shift['C2']) == (4., -4.) + oil = bst.UtilityAgent('test_targeting_hot_oil_shifts', Water=1, T=480., P=5e6, + phase='l', thermo=water, T_limit=300., + heat_transfer_price=1e-6) + NG = agent('natural_gas') + u, gas = t.utility_targets([oil, NG], []) + assert (u.ID, gas.ID) == (oil.ID, NG.ID) + assert (u.T_in, u.T_out) == (pytest.approx(390., abs=1e-9), 475.) + q = 155. * kW + assert u.duty == pytest.approx(q, rel=1e-12) + T_out = oil.T - (oil.T - dT - 470.) * q / (13. * kW) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert u.cost == pytest.approx(fresh_price(u.duty, T_out - dT, u.T_out, oil)[0], + rel=1e-12) + # both keep dT: C2 at the level (its limit), C1 where it ends + assert line_gaps(t, rows, u) == pytest.approx({'C1': dT, 'C2': dT}, abs=1e-9) + # its mirror: a coolant (300 K, dT 0) serves H1 (390 -> 310 K, shift 6 + # K) and H2 (380 -> 305 of 302 K, shift 14 K): where H1 ends (shifted + # 304 K) it has taken only H2's 13 kW below it, and H1 is 8 K colder + # than H2 there + rows = [('H1', 390., 310., 1.), ('H2', 380., 302., 1.)] + t = targets('test_targeting_line_shifts_cool', rows, 10., + approach_contributions={'H1': 1., 'H2': 9.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['H1'], shift['H2']) == (6., 14.) + coolant = bst.UtilityAgent('test_targeting_coolant_shifts', Water=1, T=300., + P=101325., phase='l', thermo=water, T_limit=370., + heat_transfer_price=1e-6) + CHW = agent('chilled_water') + u, chilled = t.utility_targets([], [coolant, CHW]) + assert (u.ID, chilled.ID) == (coolant.ID, CHW.ID) + assert (u.T_in, u.T_out) == (pytest.approx(390., abs=1e-9), 305.) + assert u.duty == pytest.approx(q, rel=1e-12) + T_out = coolant.T + (310. - dT - coolant.T) * q / (13. * kW) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert u.cost == pytest.approx(fresh_price(-u.duty, T_out + dT, u.T_out, coolant)[0], + rel=1e-12) + assert line_gaps(t, rows, u) == pytest.approx({'H1': dT, 'H2': dT}, abs=1e-9) + +def test_sensible_line_stream_ending_without_driving_force(): + # with approach contributions a stream can end at a sensible agent's + # zero-margin temperature, agent.T - dT (its limit when agent.dT is 0), + # while another stream sets the level higher: the agent, having given + # heat above that end, would have to leave at its inlet temperature + # there (biosteam would divide by a zero enthalpy change). That end + # caps the level, as a stream reaching beyond its limit does. A hot oil + # (480 K, dT 0) serves C1 (390 -> 480 K, shift -4 K, limit 479 K + # shifted) and C2 (400 -> 475 K, shift 0, limit 475 K, its end): it is + # placed at 475 K and natural gas serves the 9 kW of C1 above it. + dT, water = bst.HeatUtility.dT, bst.HeatUtility.thermo_water + rows = [('C1', 390., 480., 1.), ('C2', 400., 475., 1.)] + t = targets('test_targeting_line_end_heat', rows, 10., + approach_contributions={'C1': 9.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['C1'], shift['C2']) == (-4., 0.) + oil = bst.UtilityAgent('test_targeting_hot_oil_end', Water=1, T=480., P=5e6, + phase='l', thermo=water, T_limit=300., + heat_transfer_price=1e-6) + assert oil.dT == 0. and 475. == oil.T - dT + NG = agent('natural_gas') + u, gas = t.utility_targets([oil, NG], []) + assert (u.ID, gas.ID) == (oil.ID, NG.ID) + assert u.level == 475. + assert (u.T_in, u.T_out) == (pytest.approx(390., abs=1e-9), 475.) + q = 156. * kW + assert u.duty == pytest.approx(q, rel=1e-12) + assert gas.duty == pytest.approx(9. * kW, rel=1e-12) + # bounded where C2 starts (shifted 400 K), having given 150 kW above it + T_out = oil.T - (oil.T - dT - 400.) * q / (150. * kW) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert math.isfinite(u.flow) and math.isfinite(u.cost) + assert u.cost == pytest.approx(fresh_price(u.duty, T_out - dT, u.T_out, oil)[0], + rel=1e-12) + assert line_gaps(t, rows, u) == pytest.approx({'C1': 9., 'C2': dT}, abs=1e-9) + assert math.isfinite(t.summary(heating_agents=[oil, NG], + cooling_agents=[])['utility_cost']) + # its mirror: a coolant (300 K, dT 0) serves H1 (390 -> 300 K, shift 14 + # K, limit 291 K shifted) and H2 (380 -> 305 K, shift 10 K, limit 295 + # K, its end): it is placed at 295 K and chilled water serves the 9 kW + # of H1 below it + rows = [('H1', 390., 300., 1.), ('H2', 380., 305., 1.)] + t = targets('test_targeting_line_end_cool', rows, 10., + approach_contributions={'H1': 9.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['H1'], shift['H2']) == (14., 10.) + coolant = bst.UtilityAgent('test_targeting_coolant_end', Water=1, T=300., + P=101325., phase='l', thermo=water, T_limit=385., + heat_transfer_price=1e-6) + assert coolant.dT == 0. and 305. == coolant.T + dT + CHW = agent('chilled_water') + u, chilled = t.utility_targets([], [coolant, CHW]) + assert (u.ID, chilled.ID) == (coolant.ID, CHW.ID) + assert u.level == 295. + assert (u.T_in, u.T_out) == (pytest.approx(390., abs=1e-9), 305.) + assert u.duty == pytest.approx(q, rel=1e-12) + assert chilled.duty == pytest.approx(9. * kW, rel=1e-12) + # bounded where H2 starts (shifted 370 K), having taken 150 kW below it + T_out = coolant.T + (380. - dT - coolant.T) * q / (150. * kW) + assert u.T_utility_out == pytest.approx(T_out, rel=1e-12) + assert math.isfinite(u.flow) and math.isfinite(u.cost) + assert u.cost == pytest.approx(fresh_price(-u.duty, T_out + dT, u.T_out, coolant)[0], + rel=1e-12) + assert line_gaps(t, rows, u) == pytest.approx({'H1': 9., 'H2': dT}, abs=1e-9) + assert math.isfinite(t.summary(heating_agents=[], + cooling_agents=[coolant, CHW])['utility_cost']) + +def test_pricing_temperatures(): + dT = bst.HeatUtility.dT + # the hot stream's top, 445 K shifted, is above every cold stream: medium + # pressure steam serves C1 up to its outlet, 440 K + t = targets('test_targeting_ends', [('H1', 455., 300., 1.), + ('C1', 330., 440., 3.)], 10.) + MP = by_ID(t.utility_targets())['medium_pressure_steam'] + assert (MP.T_in, MP.T_out) == (pytest.approx(407.189, abs=1e-9), 440.) + # with approach contributions, the temperatures of the streams served: + # the outlet within the agent's limit, as biosteam requires + rng = np.random.default_rng(11) + for n in range(24): + rows = [] + for i in range(int(rng.integers(2, 6))): + a, b = rng.uniform(290., 520., 2).round(1) + if abs(a - b) < 2.: b = a + 5. + rows.append((('H' if a > b else 'C') + str(i), float(a), float(b), + round(float(rng.uniform(.1, 5.)), 2))) + contributions = {ID: round(float(rng.uniform(.5, 15.)), 1) + for ID, *_ in rows if rng.random() < .6} + t = targets(f'test_targeting_ends_{n}', rows, + float(rng.choice([1., 5., 10., 20.])), + approach_contributions=contributions or None) + for u in t.utility_targets(): + a = u.agent + if a is None or a.isfuel: continue + if u.heating: + assert u.T_in <= u.T_out and u.T_out + dT <= a.T - a.dT + 1e-6 + if a.T_limit: assert u.T_utility_out >= u.T_in + dT - 1e-6 + else: + assert u.T_in >= u.T_out and u.T_out - dT >= a.T + a.dT - 1e-6 + if a.T_limit: assert u.T_utility_out <= u.T_in - dT + 1e-6 + +def water_boiler(ID, T_sat, kmol_hr): + """Saturated liquid water boiled to saturated vapour at `T_sat` [K]: a + point load alone (water thermo and flowsheet set by the caller).""" + P = bst.settings.chemicals.Water.Psat(T_sat) + liquid = bst.Stream(ID + '_liquid', Water=kmol_hr, T=T_sat, P=P, phase='l') + boiler = bst.HXutility(ID, ins=liquid, V=1., rigorous=True) + boiler.simulate() + return boiler + +def test_point_loads_at_stretch_ends(): + # a stream served only at a point load at an end of a stretch (a boiler + # at the pinch with the cut 'above', a condenser at it with the cut + # 'below') is served there: the targets give its temperature, not a + # stretch end (shifted, with approach contributions), and a sensible + # agent keeps dT from it, as biosteam requires and the balanced + # composites need + dT = bst.HeatUtility.dT + # the reference case: C1 leaves boiled at the pinch, not at H1's inlet + units, T_min_app = test_hxn_targets.case_two_stream() + t = targets_of(units, T_min_app) + T_sat = units[1].outs[0].T + LP = by_ID(t.utility_targets())['low_pressure_steam'] + assert t.cut == 'above' + assert (LP.T_in, LP.T_out) == (pytest.approx(T_sat, abs=1e-9),) * 2 + # a hot oil serving a boiler at the pinch leaves dT above it + test_hxn_targets.setup('test_targeting_boiler', ['Water']) + H1 = test_hxn_targets.utility_hx('H1', 395., 5e5, 'l', 300., Water=100.) + C1 = water_boiler('C1', bst.settings.chemicals.Water.Tb, 10.) + T_b = C1.outs[0].T + oil = bst.UtilityAgent('test_targeting_boiler_oil', Water=1, T=480., P=5e6, + phase='l', thermo=bst.HeatUtility.thermo_water, + T_limit=300., heat_transfer_price=1e-6) + for c in ({}, {'C1': 3.}, {'C1': 3., 'H1': 1.}): + t = targets_of([H1, C1], 10., approach_contributions=c) + assert t.cut == 'above' + utilities = t.utility_targets([oil], None) + u = utilities[0] + assert u.ID == oil.ID and u.duty == pytest.approx(t.Q_hot, rel=1e-12) + assert (u.T_in, u.T_out) == (pytest.approx(T_b, abs=1e-9),) * 2 + assert u.T_utility_out == pytest.approx(T_b + dT, abs=1e-9) + assert u.cost == pytest.approx(fresh_price(u.duty, T_b, T_b, oil)[0], rel=1e-9) + gap = c.get('H1', 5.) + c.get('C1', 5.) + curves = t.balanced_composite_curves(utilities) + assert min_driving_force(*curves) >= min(gap, dT) - 1e-9 + # cooling water serving a condenser at the pinch leaves dT below it + test_hxn_targets.setup('test_targeting_condenser', ['Water']) + P = bst.settings.chemicals.Water.Psat(320.) + vapour = bst.Stream('test_targeting_condenser_vapour', Water=10., T=335., P=P, + phase='g') + COND = bst.HXutility('COND', ins=vapour, V=0., rigorous=True) + liquid = bst.Stream('test_targeting_condenser_liquid', Water=100., T=300., P=3e5) + C2 = bst.HXutility('C2', ins=liquid, T=330., rigorous=False) + COND.simulate(); C2.simulate() + T_c, CW = COND.outs[0].T, agent('cooling_water') + for c in ({}, {'COND': 1.}, {'COND': 1., 'C2': 1.}): + t = targets_of([COND, C2], 10., approach_contributions=c) + assert t.cut == 'below' + utilities = t.utility_targets() + u = by_ID(utilities)['cooling_water'] + assert u.duty == pytest.approx(t.Q_cold, rel=1e-12) + assert (u.T_in, u.T_out) == (pytest.approx(T_c, abs=1e-9),) * 2 + assert u.T_utility_out == pytest.approx(T_c - dT, abs=1e-9) + assert u.cost == pytest.approx(fresh_price(-u.duty, T_c, T_c, CW)[0], rel=1e-9) + gap = c.get('COND', 5.) + c.get('C2', 5.) + curves = t.balanced_composite_curves(utilities) + assert min_driving_force(*curves) >= min(gap, dT) - 1e-9 + # no stream with heat in a stretch is an internal error, never a + # stretch end taken for a temperature + with pytest.raises(RuntimeError, match='no stream has heat in the stretch'): + t._ends(np.zeros((2, 3)), 0, 2, 300., 310., True) + +def test_point_loads_at_the_pinch_follow_the_cut(): + # the point loads at the pinch temperature lie on the side of the cut: + # a condenser there with the cut 'below' is served below the pinch, so + # it caps the cooling levels. Cooling water, whose limit lies above the + # condensing temperature, then serves nothing and chilled water serves + # all, leaving above the coldest temperature at which biosteam takes it + # (on the other side, cooling water would leave below its limit) + test_hxn_targets.setup('test_targeting_cut_condenser', ['Water']) + P = bst.settings.chemicals.Water.Psat(311.) + vapour = bst.Stream(None, Water=20., T=311., P=P, phase='g') + liquid = vapour.copy() + liquid.vle(V=0., P=P) + hot_in = bst.Stream(None, Water=200., T=330., P=3e5) + hot_out = hot_in.copy() + hot_out.T = 300. + # the cold inlet at the condenser's shifted temperature + cold_in = bst.Stream(None, Water=400., T=liquid.T - 6., P=3e5) + cold_out = cold_in.copy() + cold_out.T = 340. + t = PinchTargets([cold_in, vapour, hot_in], [cold_out, liquid, hot_out], + [False, True, True], 10., [None, 1., 9.], + labels=['C', 'COND', 'H']) + assert t.cut == 'below' + assert t.pinch_T + t.shift[1] == pytest.approx(liquid.T, abs=1e-9) + dT = bst.HeatUtility.dT + cooling = [u for u in t.utility_targets() if not u.heating] + assert [u.ID for u in cooling] == ['chilled_water'] + assert cooling[0].duty == pytest.approx(t.Q_cold, rel=1e-12) + for u in cooling: # biosteam's get_suitable_cooling_agent + assert u.T_out > u.agent.T + u.agent.dT + dT + # a boiler at the pinch with the cut 'above' is served above it: above + # the pinch are C1's latent heat, H1 and low pressure steam (2 units), + # below it H1, C1's sensible heat and cooling water (2 units) + units, T_min_app = test_hxn_targets.case_two_stream() + t = targets_of(units, T_min_app) + assert t.cut == 'above' + assert t.units_target() == {'above': 2, 'below': 2, 'total': 4} + +@pytest.mark.parametrize('offset', [-1e-11, 0., 1e-11]) +def test_level_at_a_point_load_up_to_rounding(offset): + # a level equal to a grid temperature serves that temperature's point + # loads, also when it is off by rounding: a steam whose limit (its T + # less its dT and biosteam's) lies within rounding of where a boiler + # boils serves it all + test_hxn_targets.setup('test_targeting_snap', ['Water']) + boiler = water_boiler('B', 380., 10.) + t = targets_of([boiler], 10.) + k = np.flatnonzero(np.abs(t.table.point_H[0]) > 0.) + assert k.size == 1 + T_b = t.grid['Ts'][k[0]] + t.shift[0] + dT = bst.HeatUtility.dT + T = T_b + dT + offset + steam = bst.UtilityAgent('test_targeting_snap_steam', Water=1, T=T, + P=bst.settings.chemicals.Water.Psat(T), phase='g', + thermo=bst.HeatUtility.thermo_water, + regeneration_price=0.2378) + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) # nothing unserved + utilities = t.utility_targets([steam], None) + assert [u.ID for u in utilities] == [steam.ID] + assert utilities[0].duty == pytest.approx(t.Q_hot, rel=1e-12) + +def test_levels_of_streams_beyond_reach(): + # a level serves only what lies below it (above it, for cooling): a + # stream lying wholly beyond an agent's limit bounds the level only at + # its own end, so its contribution moves no level (it moved duty to a + # dearer agent). B lies above low pressure steam's limit, then below + # cooling water's. + cases = [[('A', 330., 406., 1.), ('B', 420., 430., 1.), ('H1', 380., 300., 1.)], + [('A', 340., 313., 1.), ('B', 300., 290., 1.), ('C1', 350., 420., 1.)]] + for n, rows in enumerate(cases): + base = targets(f'test_targeting_beyond_{n}', rows, 10.).utility_targets() + for c in (1., 2.5, 4.9): + t = targets(f'test_targeting_beyond_{n}_{c}', rows, 10., + approach_contributions={'B': c}) + utilities = t.utility_targets() + assert [(u.ID, u.level) for u in utilities] == [(u.ID, u.level) for u in base] + assert_allclose([u.duty for u in utilities], [u.duty for u in base], rtol=1e-12) + assert_allclose([u.cost for u in utilities], [u.cost for u in base], rtol=1e-9) + # a boiler wholly beyond low pressure steam's limit, below the limit of + # the stream it serves: the level stays just short of the boiler (a + # level at a grid temperature serves its point loads) + test_hxn_targets.setup('test_targeting_beyond_boiler', ['Water']) + A = test_hxn_targets.utility_hx('A', 330., 6e5, 'l', 406., Water=50.) + B = water_boiler('B', 409., 5.) + H = test_hxn_targets.utility_hx('H', 380., 6e5, 'l', 300., Water=50.) + t = targets_of([A, B, H], 10., approach_contributions={'B': 1.}) + level = B.ins[0].T - t.shift[t.labels.index('B')] # where B boils, shifted: 405 K + dT, LP = bst.HeatUtility.dT, agent('low_pressure_steam') + utilities = by_ID(t.utility_targets()) + u, MP = utilities['low_pressure_steam'], utilities['medium_pressure_steam'] + assert level - 1e-8 < u.level < level < LP.T - LP.dT - dT + assert u.T_out == u.level and u.T_out + dT <= LP.T - LP.dT + assert (MP.T_in, MP.T_out) == (pytest.approx(level, abs=1e-8), + pytest.approx(B.outs[0].T, abs=1e-9)) + assert u.duty + MP.duty == pytest.approx(t.Q_hot, rel=1e-12) + # its mirror: a condenser wholly beyond cooling water's limit, above the + # limit of the stream it serves (and above CW.T + dT, where a sensible + # agent's line would have no driving force left, which also keeps a + # level short of a point load): the level stays just short of it + test_hxn_targets.setup('test_targeting_beyond_condenser', ['Water']) + P = bst.settings.chemicals.Water.Psat(311.) + vapour = bst.Stream(None, Water=10., T=311., P=P, phase='g') + vapour.vle(V=1., P=P) + liquid = vapour.copy() + liquid.vle(V=0., P=P) + hot_in = bst.Stream(None, Water=100., T=340., P=3e5) + hot_out = hot_in.copy() + hot_out.T = 300. + t = PinchTargets([vapour, hot_in], [liquid, hot_out], [True, True], 10., + [1., 9.], labels=['COND', 'H']) + level = liquid.T - t.shift[0] # where COND condenses, shifted: 305 K + CW, CHW = agent('cooling_water'), agent('chilled_water') + assert CW.T + dT < liquid.T < CW.T + CW.dT + dT + u, chilled = t.utility_targets() + assert (u.ID, chilled.ID) == (CW.ID, CHW.ID) + assert level < u.level < level + 1e-8 + assert u.T_out == u.level + t.shift[1] and u.T_out - dT > CW.T + CW.dT + # cooling water serves H above its outlet; chilled water serves the + # condenser and H below it + cooled = hot_in.copy() + cooled.T = u.T_out + assert u.duty == pytest.approx(hot_in.H - cooled.H, rel=1e-12) + assert chilled.duty == pytest.approx(vapour.H - liquid.H + cooled.H - hot_out.H, + rel=1e-12) + +def test_levels_of_streams_past_a_zero(): + # the heat of a level reaches only the next zero of the balanced curve + # given the agents placed before it: a stream wholly past that zero (a + # utility pinch, or a second process pinch) moves no level. Heating: low + # pressure steam takes 25.5 kW and leaves a utility pinch at shifted + # 447 K; Ck (shifted 420 -> 446.5 K) lies below it and is served by + # Hmid, so it does not cap medium pressure steam at its own limit (MP's + # less 4.5 K), and MP, not high pressure steam, serves Ctop's 20 kW. + rows = [('Ctop', 447., 449., 10.), ('Hmid', 457., 410., 3.), + ('Ck', 424.5, 451., 1.), ('Clow', 330., 400., 2.)] + t = targets('test_targeting_past_zero_heat', rows, 10., + approach_contributions={'Ck': .5}) + utilities = t.utility_targets() + LP, MP = agent('low_pressure_steam'), agent('medium_pressure_steam') + assert [u.ID for u in utilities] == [LP.ID, MP.ID] + assert_allclose([u.duty for u in utilities], [25.5 * kW, 20. * kW], rtol=1e-12) + u = utilities[1] + assert u.level == MP.T - MP.dT - bst.HeatUtility.dT + assert (u.T_in, u.T_out) == (pytest.approx(447., abs=1e-9), + pytest.approx(449., abs=1e-9)) + assert u.cost == pytest.approx(fresh_price(20. * kW, 447., 449., MP)[0], rel=1e-9) + # biosteam's own choice for Ctop + hu = bst.HeatUtility() + hu(20. * kW, 447., 449.) + assert hu.agent is MP + # cooling: the curve is zero at shifted 450 K (the pinch) and 300 K. H1 + # (shifted 450 -> 302 or 307 K) lies between the two and gives all its + # heat to C2, so wherever it ends it moves no level: cooling water + # serves H2 (shifted 300 -> 280 K) down to its limit, 312.372 K real. + CW, CHW = agent('cooling_water'), agent('chilled_water') + T_limit = CW.T + CW.dT + bst.HeatUtility.dT # real + results = [] + for end, CP in ((308., 1.), (313., 148. / 143.)): + rows = [('C1', 450., 500., 1.), ('H1', 456., end, CP), + ('C2', 300., 440., 148. / 140.), ('H2', 314., 294., 10.)] + t = targets(f'test_targeting_past_zero_cool_{end:g}', rows, 10., + approach_contributions={'H1': 1., 'H2': 9.}) + assert (t.Q_hot, t.Q_cold) == (pytest.approx(50. * kW, rel=1e-12), + pytest.approx(200. * kW, rel=1e-12)) + T, H = t.grand_composite_curve() + assert np.abs(H[(T == 450.) | (T == 300.)]).max() <= 1e-6 + cool = [u for u in t.utility_targets() if not u.heating] + assert [u.ID for u in cool] == [CW.ID, CHW.ID] + u = cool[0] + assert u.duty == pytest.approx(10. * kW * (314. - T_limit), rel=1e-9) + assert (u.T_in, u.T_out) == (pytest.approx(314., abs=1e-9), + pytest.approx(T_limit, abs=1e-9)) + assert u.cost == pytest.approx(fresh_price(-u.duty, 314., T_limit, CW)[0], + rel=1e-9) + results.append([(u.level, u.duty, u.cost) for u in cool]) + assert_allclose(results[0], results[1], rtol=1e-12) + +def latent_water(ID, T, phase, **prices): + """A latent water agent at `T` [K] (no `T_limit`): condensing steam + (`phase` 'g', a heating agent) or boiling water ('l', a cooling one).""" + water = bst.HeatUtility.thermo_water + return bst.UtilityAgent(ID, Water=1, T=T, P=water.chemicals.Water.Psat(T), + phase=phase, thermo=water, **prices) + +def test_levels_of_streams_between_zeros(): + # of several zeros of the balanced curve beyond the pinch, the heat of a + # level reaches only the furthest (the highest above the pinch, the + # lowest below it): a stream between two zeros moves no level, though it + # reaches beyond the agent's limit. Cooling: the curve is zero at + # shifted 450 K (the pinch), 445, 400 and 360 K. S (shifted 395 -> 365 + # K) lies between the zeros at 400 and 360 K and gives all its heat to + # CS; in reach, it would cap the boiling water (latent at 370 K, R = 375 + # K real) at S's limit, shifted 369.5 K, short of the zero at 360 K, + # where the water gets nothing, and chilled water would serve HL. The + # water serves HL (shifted 355 -> 335 K, 400 -> 380 K real) whole, at + # HL's limit, shifted 330 K. + dT = bst.HeatUtility.dT + rows = [('C1', 450., 500., 1.), ('HA', 455., 415., 1.), ('CA', 400., 440., 1.), + ('S', 400.5, 370.5, 1.), ('CS', 360., 390., 1.), ('HL', 400., 380., 1.)] + t = targets('test_targeting_between_zeros_cool', rows, 10., + approach_contributions={'S': .5, 'HL': 40.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['S'], shift['HL'], t.pinch_T) == (5.5, 45., 450.) + assert (t.Q_hot, t.Q_cold) == (pytest.approx(50. * kW, rel=1e-12), + pytest.approx(20. * kW, rel=1e-12)) + T, H = t.grand_composite_curve() + assert np.abs(H[np.isin(T, [450., 445., 400., 360.])]).max() <= 1e-6 + assert_allclose(H[np.isin(T, [390., 365.])], 5. * kW, rtol=1e-9) + boiling = latent_water('test_targeting_between_zeros_boiling', 370., 'l', + heat_transfer_price=1e-7) + cool = [u for u in t.utility_targets(None, [boiling, agent('chilled_water')]) + if not u.heating] + assert [u.ID for u in cool] == [boiling.ID] + u, = cool + assert u.level == pytest.approx(boiling.T + boiling.dT + dT - shift['HL'], abs=1e-9) + assert u.level == pytest.approx(330., abs=1e-9) + assert u.duty == pytest.approx(20. * kW, rel=1e-12) + assert (u.T_in, u.T_out) == (pytest.approx(400., abs=1e-9), + pytest.approx(380., abs=1e-9)) + assert u.cost == pytest.approx(fresh_price(-u.duty, 400., 380., boiling)[0], + rel=1e-9) + # heating: the curve has no zero above the pinch (shifted 400 K). A1 + # (latent at 415 K, R = 410 K real) serves 5 kW at shifted 420 K, where + # S, reaching beyond A1's limit (shifted 405.5 K), starts, and leaves + # zeros at 420 and 440 K, with Hb's heat between them. S (shifted 420 -> + # 430 K) lies between the two; in reach, it would cap A2 (latent at 431 + # K, R = 426 K real) at S's limit, shifted 421.5 K, short of the zero at + # 440 K, where A2 gets nothing, and natural gas would serve CL. A2 + # serves CL (shifted 440 -> 460 K, 405 -> 425 K real) whole, at CL's + # limit, shifted 461 K. + rows = [('C0', 400., 410., 1.), ('Ha', 430., 420., .5), ('S', 424.5, 434.5, 1.), + ('Hb', 450., 440., 1.), ('CL', 405., 425., 1.), ('Hlow', 410., 380., 1.)] + t = targets('test_targeting_between_zeros_heat', rows, 10., + approach_contributions={'S': .5, 'CL': 40.}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['C0'], shift['S'], shift['CL'], t.pinch_T) == (0., 4.5, -35., 400.) + assert (t.Q_hot, t.Q_cold) == (pytest.approx(25. * kW, rel=1e-12), + pytest.approx(30. * kW, rel=1e-12)) + T, H = t.grand_composite_curve() + assert np.abs(H[T > 400.]).min() == pytest.approx(5. * kW, rel=1e-9) + assert_allclose(H[np.isin(T, [440., 420.])], 5. * kW, rtol=1e-9) + A1 = latent_water('test_targeting_between_zeros_A1', 415., 'g', + regeneration_price=0.2) + A2 = latent_water('test_targeting_between_zeros_A2', 431., 'g', + regeneration_price=0.2) + heat = [u for u in t.utility_targets([A1, A2, agent('natural_gas')], None) + if u.heating] + assert [u.ID for u in heat] == [A1.ID, A2.ID] + assert_allclose([u.level for u in heat], [420., 461.], atol=1e-9) + assert heat[1].level == pytest.approx(A2.T - A2.dT - dT - shift['CL'], abs=1e-9) + assert_allclose([u.duty for u in heat], [5. * kW, 20. * kW], rtol=1e-12) + assert_allclose([(u.T_in, u.T_out) for u in heat], [(400., 410.), (405., 425.)], + atol=1e-9) + for u, a in zip(heat, (A1, A2)): + assert u.cost == pytest.approx(fresh_price(u.duty, u.T_in, u.T_out, a)[0], + rel=1e-9) + +def test_levels_of_streams_across_the_pinch(): + # an agent serves only the streams with heat on its side of the pinch + # (cold streams above it, hot ones below it): a stream wholly on the + # other side moves no level, though it reaches beyond the agent's limit. + # Heating: the pinch is at shifted 400 K. CB (shifted 380 -> 398 K) lies + # below it and gets all its heat from H1; served, it would cap the steam + # (latent at 407 K, R = 402 K real) at CB's limit, shifted 397.5 K, + # below the pinch, where the steam gets nothing, and natural gas would + # serve all of Cup. The steam serves Cup (400 -> 410 K) up to its limit, + # 402 K, and natural gas the rest. + dT = bst.HeatUtility.dT + rows = [('Cup', 400., 410., 1.), ('CB', 384.5, 402.5, 1.), ('H1', 410., 380., 2.)] + t = targets('test_targeting_across_pinch_heat', rows, 10., + approach_contributions={'CB': .5}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['Cup'], shift['CB'], t.pinch_T) == (0., 4.5, 400.) + assert (t.Q_hot, t.Q_cold) == (pytest.approx(10. * kW, rel=1e-12), + pytest.approx(42. * kW, rel=1e-12)) + steam = latent_water('test_targeting_across_pinch_steam', 407., 'g', + regeneration_price=0.2) + gas = agent('natural_gas') + heat = [u for u in t.utility_targets([steam, gas], None) if u.heating] + assert [u.ID for u in heat] == [steam.ID, gas.ID] + u, fuel = heat + assert u.level == pytest.approx(steam.T - steam.dT - dT - shift['Cup'], abs=1e-9) + assert u.level == pytest.approx(402., abs=1e-9) + assert_allclose([u.duty for u in heat], [2. * kW, 8. * kW], rtol=1e-12) + assert_allclose([(u.T_in, u.T_out) for u in heat], [(400., 402.), (402., 410.)], + atol=1e-9) + for u, a in zip(heat, (steam, gas)): + assert u.cost == pytest.approx(fresh_price(u.duty, u.T_in, u.T_out, a)[0], + rel=1e-9) + # cooling, its mirror: HB (shifted 420 -> 402 K) lies above the pinch + # (shifted 400 K) and gives all its heat to C1; served, it would cap the + # boiling water (latent at 403 K, R = 408 K real) at HB's limit, shifted + # 402.5 K, above the pinch, and cooling water would serve all of Hdown. + # The water serves Hdown (410 -> 400 K) down to its limit, 408 K, and + # cooling water the rest. + rows = [('Hdown', 410., 400., 1.), ('HB', 425.5, 407.5, 1.), ('C1', 400., 430., 2.)] + t = targets('test_targeting_across_pinch_cool', rows, 10., + approach_contributions={'HB': .5}) + shift = dict(zip(t.labels, t.shift)) + assert (shift['Hdown'], shift['HB'], t.pinch_T) == (10., 5.5, 400.) + assert (t.Q_hot, t.Q_cold) == (pytest.approx(42. * kW, rel=1e-12), + pytest.approx(10. * kW, rel=1e-12)) + boiling = latent_water('test_targeting_across_pinch_boiling', 403., 'l', + heat_transfer_price=1e-6) + CW = agent('cooling_water') + cool = [u for u in t.utility_targets(None, [boiling, CW]) if not u.heating] + assert [u.ID for u in cool] == [boiling.ID, CW.ID] + u = cool[0] + assert u.level == pytest.approx(boiling.T + boiling.dT + dT - shift['Hdown'], + abs=1e-9) + assert u.level == pytest.approx(398., abs=1e-9) + assert_allclose([u.duty for u in cool], [2. * kW, 8. * kW], rtol=1e-12) + assert_allclose([(u.T_in, u.T_out) for u in cool], [(410., 408.), (408., 400.)], + atol=1e-9) + for u, a in zip(cool, (boiling, CW)): + assert u.cost == pytest.approx(fresh_price(-u.duty, u.T_in, u.T_out, a)[0], + rel=1e-9) + +def test_sensible_agent_without_driving_force(): + # a sensible agent with agent.dT = 0 (UtilityAgent's default) has no + # driving force left at a point load at its limit, agent.T - dT + # (agent.T + dT for cooling): it would leave at its inlet temperature at + # an unbounded flow, and biosteam's rule, strict there, does not take + # it. Its level stays just short of the point load; it serves the rest + # at a finite flow and the next agent the point load. + dT = bst.HeatUtility.dT + water = bst.HeatUtility.thermo_water + test_hxn_targets.setup('test_targeting_stuck_boiler', ['Water']) + oil = bst.UtilityAgent('test_targeting_stuck_oil', Water=1, T=480., P=5e6, + phase='l', thermo=water, T_limit=420., + regeneration_price=0.01) + B = water_boiler('B', oil.T - dT, 10.) + L = test_hxn_targets.utility_hx('L', 400., 5e6, 'l', 470., Water=50.) + T_b = B.ins[0].T + assert T_b == oil.T - oil.dT - dT and not B.outs[0].T + dT < oil.T - oil.dT + t = targets_of([B, L], 10.) + HP = agent('high_pressure_steam') + u, steam = t.utility_targets([oil, HP], None) + assert u.ID == oil.ID and T_b - 1e-8 < u.level < T_b + assert (u.T_utility_in, u.T_utility_out) == (oil.T, oil.T_limit) + assert u.duty == pytest.approx(L.heat_utilities[0].unit_duty, rel=1e-9) + assert u.cost == pytest.approx(fresh_price(u.duty, 400., 470., oil)[0], rel=1e-9) + assert steam.ID == HP.ID + assert steam.duty == pytest.approx(B.heat_utilities[0].unit_duty, rel=1e-9) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + u, unserved = t.utility_targets([oil], None) + assert unserved.ID is None and unserved.duty == steam.duty + assert [str(x.message) for x in w] == [ + f'no heating agent can serve {steam.duty:.6g} kJ/hr of the hot utility ' + 'target; it is left unserved'] + # at the caller's line, not at hensmith's + assert [x.filename for x in w] == [__file__] + # cooling: a condenser at a coolant's limit goes to the next coolant + test_hxn_targets.setup('test_targeting_stuck_condenser', ['Water']) + P = bst.settings.chemicals.Water.Psat(305.) + vapour = bst.Stream('test_targeting_stuck_vapour', Water=10., T=305., P=P, + phase='g') + COND = bst.HXutility('COND', ins=vapour, V=0., rigorous=True) + COND.simulate() + H = test_hxn_targets.utility_hx('H', 340., 3e5, 'l', 310., Water=50.) + T_c = COND.outs[0].T + coolant = bst.UtilityAgent('test_targeting_stuck_coolant', Water=1, + T=T_c - dT, P=101325., phase='l', thermo=water, + T_limit=T_c - 1., regeneration_price=0.01) + t = targets_of([COND, H], 10.) + CHW = agent('chilled_water') + u, chilled = t.utility_targets(None, [coolant, CHW]) + assert u.ID == coolant.ID and u.level > T_c - t.T_min_app + assert u.level - (T_c - t.T_min_app) < 1e-8 + assert (u.T_utility_in, u.T_utility_out) == (coolant.T, coolant.T_limit) + assert u.duty == pytest.approx(-H.heat_utilities[0].unit_duty, rel=1e-9) + assert chilled.ID == CHW.ID + assert chilled.duty == pytest.approx(-COND.heat_utilities[0].unit_duty, rel=1e-9) + +# %% Area, units, capital and total annualized cost + +def test_bath_balanced_pair(quickstart): + t = targets('test_targeting_pair', PAIR, 10.) + utilities = t.utility_targets() + assert (t.Q_hot, t.Q_cold, utilities) == (0., 0., []) + assert t.labels == ['C1', 'H1'] + # 100 kW, 10 K apart: U = 0.5 kW/m2/K by default, 1 / (1/2 + 1/0.5) else + assert t.area_target(utilities) == pytest.approx(20., rel=1e-12) + forms = [{0: .5, 1: 2.}, {'H1': 2., 'C1': .5}, {('H1', 'H1'): 2., ('C1', 'C1'): .5}, + {t.units[1]: 2., t.units[0]: .5}, [.5, 2.], np.array([.5, 2.]), + {'H1': 1., 1: 2., 'C1': .5}] # the stream index is nearer + for h in forms: + assert t.area_target(utilities, h=h) == pytest.approx(25., rel=1e-12), h + assert t.area_target(h=2.) == pytest.approx(10., rel=1e-12) + assert t.area_target(h={'H1': 2.}) == pytest.approx(15., rel=1e-12) + for h in ({'H9': 1.}, {2: 1.}, {-1: 1.}, {('D0', 'H1'): 1.}): + with pytest.raises(ValueError, match='h keys that name no stream'): + t.area_target(h=h) + for h, message in (({0: True}, 'h of key 0 must be'), (0., 'h must be'), + (math.inf, 'h must be'), ([1., -1.], r'h\[1\] must be'), + ({'C1': math.nan}, "h of key 'C1' must be"), + ([1.], '1 film coefficients h given for 2')): + with pytest.raises(ValueError, match=message): + t.area_target(h=h) + with pytest.raises(ValueError, match='different values at the same level'): + t.area_target(h={'H1': 2., ('H1', 'H1'): 3.}) + with pytest.raises(TypeError, match='a heat utility cannot be a key of h'): + t.area_target(h={t.heat_utilities[0]: 2.}) + for key in (True, 1.5, ('H1',)): + with pytest.raises(TypeError, match='h keys must be'): + t.area_target(h={key: 2.}) + # labels are not keys of targets built from streams + direct = PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, 10., + labels=['C1', 'H1']) + assert direct.area_target(h={0: .5, 1: 2.}) == pytest.approx(25., rel=1e-12) + with pytest.raises(ValueError, match='name no stream'): + direct.area_target(h={'H1': 2.}) + # streams left out of a stream index mapping take 1 + assert direct.area_target(h={0: .5}) == direct.area_target(h=[.5, 1.]) + assert direct.area_target(h={}) == direct.area_target() == t.area_target(h={}) + assert direct.area_target(h={1: 2.}) == t.area_target(h={1: 2.}) + area = direct.area_target(h=[1., 2.]) + assert direct.summary(h={1: 2.})['area'] == area + sweep = supertarget(direct, [5., 15.], refine=0, h={1: 2.}) + assert_allclose(sweep['area'], supertarget(direct, [5., 15.], refine=0, + h=[1., 2.])['area'], rtol=0) + assert direct.capital_cost_target(h={1: 2.}) == direct.capital_cost_target(h=[1., 2.]) + # utilities: reference A, keyed by agent ID or agent + a = targets('test_targeting_pair_utilities', REF_A, 10.) + utilities = a.utility_targets() + LP = agent('low_pressure_steam') + by_ID_ = a.area_target(utilities, h_utilities={'low_pressure_steam': 2.}) + assert by_ID_ < a.area_target(utilities) + assert a.area_target(utilities, h_utilities={LP: 2.}) == by_ID_ + assert a.area_target(utilities, h_utilities=2.) < by_ID_ + with pytest.raises(ValueError, match='h_utilities keys that name no agent'): + a.area_target(utilities, h_utilities={'steam': 2.}) + with pytest.raises(ValueError, match='different values'): + a.area_target(utilities, h_utilities={LP: 2., 'low_pressure_steam': 3.}) + with pytest.raises(TypeError, match='h_utilities keys must be'): + a.area_target(utilities, h_utilities={1: 2.}) + with pytest.raises(ValueError, match='h_utilities must be'): + a.area_target(utilities, h_utilities=-1.) + # auxiliary units: the owner's key reaches both, a key naming the unit + # (owner.ID, unit.ID) is nearer + labels = quickstart.labels + reboiler, condenser = labels.index('D0 - reboiler'), labels.index('D0 - condenser') + utilities = quickstart.utility_targets() + both = quickstart.area_target(utilities, h={'D0': 2.}) + assert both == quickstart.area_target(utilities, h={reboiler: 2., condenser: 2.}) + assert both != quickstart.area_target(utilities) + assert quickstart.area_target(utilities, h={'D0': 2., ('D0', 'reboiler'): 1.}) == ( + quickstart.area_target(utilities, h={condenser: 2.})) + with pytest.raises(ValueError, match='name no stream'): + quickstart.area_target(utilities, h={'reboiler': 2.}) + +def test_utilities_must_be_of_the_targets(quickstart): + # utilities that do not add up to the targets' own utility targets (of + # another T_min_app, none, or a part) raise wherever they are balanced; + # the targets' own, with any agents, are accepted; the grand composite + # plot draws any + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + t = quickstart + t20 = t.at(20.) + own = t20.utility_targets() + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) # unserved cooling + partial = t20.utility_targets(cooling_agents=['cooling_water']) + assert any(u.agent is None for u in partial) + mismatched = t.utility_targets() + assert t.Q_hot != t20.Q_hot + calls = [t20.area_target, t20.units_target, t20.capital_cost_target, + t20.balanced_composite_curves, t20.balanced_grand_composite_curve, + lambda u: plot_composite_curves(t20, kind='balanced', utilities=u)] + for utilities in (mismatched, [], own[:1], own[1:], own + own[:1]): + for call in calls: + with pytest.raises(ValueError, match='duties of utilities add up to'): + call(utilities) + # they must add up to rounding: a heating or a cooling duty off by 1e-6 + # (far above the remainders of at most _tol that utility_targets leaves + # out) raises, one off by 1e-12 does not + for i in (0, -1): + for f, raises in ((1e-6, True), (-1e-6, True), (1e-12, False)): + off = list(own) + off[i] = own[i]._replace(duty=own[i].duty * (1. + f)) + if raises: + with pytest.raises(ValueError, match='duties of utilities add up to'): + t20.balanced_grand_composite_curve(off) + else: + t20.balanced_grand_composite_curve(off) + for utilities in (own, partial): + assert t20.units_target(utilities)['total'] > 0 + t20.balanced_composite_curves(utilities) + t20.balanced_grand_composite_curve(utilities) + assert t20.area_target(own) > 0. and math.isnan(t20.area_target(partial)) + fig, ax = plot_grand_composite_curve(t20, utilities=mismatched[:1]) + plt.close(fig) + +def test_utilities_of_another_shift(): + # the utility targets of streams of one kind, or of a threshold problem + # below its threshold, do not depend on T_min_app, but the levels of + # cooling utilities do (they are on the shifted temperatures): the + # utilities of another shift raise wherever they are balanced even + # though their duties add up, so that the balanced grand composite + # curve stays non-negative and the units are those of these targets. + # Those of targets with the same shifts are the targets' own; one + # built by hand (shift None) is checked by its duties only + hot_only = [('H1', 400., 320., 1.), ('H2', 500., 290., 1.)] + threshold = [('H1', 450., 300., 2.), ('C1', 290., 380., 1.)] + for n, rows in enumerate((hot_only, threshold)): + t = targets(f'test_targeting_shift_{n}', rows, 10.) + own = t.utility_targets() + assert t.Q_hot == 0. and t.Q_cold > 0. + assert all(u.shift == tuple(t.shift.tolist()) for u in own) + calls = [t.area_target, t.units_target, t.capital_cost_target, + t.balanced_composite_curves, t.balanced_grand_composite_curve] + for T_min_app in (0., 5., 20.): + other = t.at(T_min_app) + assert other.Q_cold == pytest.approx(t.Q_cold, rel=1e-12) + utilities = other.utility_targets() + for call in calls: + with pytest.raises(ValueError, match='shifted temperatures of other targets'): + call(utilities) + for utilities in (t.at(10.).utility_targets(), + [u._replace(shift=None) for u in own]): + assert t.units_target(utilities) == t.units_target(own) + assert t.area_target(utilities) == t.area_target(own) + T, B = t.balanced_grand_composite_curve(utilities) + assert_allclose(B, t.balanced_grand_composite_curve(own)[1], rtol=0, atol=0) + assert B.min() >= 0. + # streams that are all cold have the same shifts (0) at every T_min_app + rows = [('C1', 300., 420., 1.), ('C2', 310., 380., 2.)] + t = targets('test_targeting_shift_cold', rows, 10.) + utilities = t.at(0.).utility_targets() + assert t.units_target(utilities) == t.units_target(t.utility_targets()) + +def test_h_utilities_keys_of_agents_serving_nothing(): + # a custom agent serves 10 kW at T_min_app = 20 K and nothing at 5 K + # (the curves are 10 K apart): its ID keys h_utilities of area_target + # and capital_cost_target at both once the agents the utilities were + # priced with are passed in agents, as summary takes them from its + # heating_agents and cooling_agents; without them, only where it serves + t = targets('test_targeting_key_agents', [('H1', 450., 300., 1.), + ('C1', 290., 440., 1.)], 10.) + LP = agent('low_pressure_steam') + oil = bst.UtilityAgent('test_targeting_key_oil', Water=1, T=600., P=1e7, + phase='g', thermo=bst.HeatUtility.thermo_water, + regeneration_price=.5) + by_ID_ = {'test_targeting_key_oil': .3} + by_agent = {oil: .3} + served, idle = t.at(20.), t.at(5.) + for at in (served, idle): + utilities = at.utility_targets([LP, oil]) + area = at.area_target(utilities, h_utilities=by_agent) + capital = at.capital_cost_target(utilities, h_utilities=by_agent) + for agents in ([LP, oil], ['low_pressure_steam', oil], (oil,)): + assert at.area_target(utilities, h_utilities=by_ID_, + agents=agents) == area + assert at.capital_cost_target(utilities, h_utilities=by_ID_, + agents=agents) == capital + # they change nothing else + assert (at.area_target(utilities, agents=agents) + == at.area_target(utilities)) + summary = at.summary(heating_agents=[LP, oil], h_utilities=by_ID_) + assert (summary['area'], summary['installed_cost']) == ( + area, capital['installed_cost']) + utilities = served.utility_targets([LP, oil]) + assert 'test_targeting_key_oil' in [u.ID for u in utilities] + assert (served.area_target(utilities, h_utilities=by_ID_) + == served.area_target(utilities, h_utilities=by_agent) + > served.area_target(utilities)) + assert idle.utility_targets([LP, oil]) == [] + message = ("h_utilities keys that name no agent of the utilities, of " + "agents, or of biosteam's lists: 'test_targeting_key_oil' " + r'\(key another agent by the UtilityAgent itself\)') + for call in (idle.area_target, idle.capital_cost_target): + with pytest.raises(ValueError, match=message): + call([], h_utilities=by_ID_) + with pytest.raises(ValueError, match=message): + call([], h_utilities=by_ID_, agents=[LP]) + with pytest.raises(ValueError, match='of heating_agents or cooling_agents, or'): + idle.summary(heating_agents=[LP], h_utilities=by_ID_) + for agents, error, match in ( + ('low_pressure_steam', TypeError, 'agents must be a sequence'), + (oil, TypeError, 'agents must be a sequence'), + (['steam'], ValueError, "no agent 'steam' in bst.HeatUtility"), + ([1], TypeError, 'agents must be UtilityAgent objects or their IDs')): + for call in (idle.area_target, idle.capital_cost_target): + with pytest.raises(error, match=match): + call([], agents=agents) + +def test_bath_independent_integration(quickstart): + # reference A: the streams plus the four utilities + t = targets('test_targeting_integration_A', REF_A, 10.) + utilities = t.utility_targets() + hot, cold = rows_members(REF_A) + u_hot, u_cold = utility_members(utilities) + expected = integrated_area(hot + u_hot, cold + u_cold) + A = t.area_target(utilities) + assert A == pytest.approx(expected, rel=1e-6) + assert A == pytest.approx(24.144381021937, rel=1e-9) + # KEMP with film coefficients per stream + units = cp_units('test_targeting_integration_kemp', KEMP, K) + t = targets_of(units, 10.) + utilities = t.utility_targets() + hot, cold = rows_members(KEMP, K, KEMP_H) + u_hot, u_cold = utility_members(utilities) + expected = integrated_area(hot + u_hot, cold + u_cold) + A = t.area_target(utilities, h=KEMP_H) + assert A == pytest.approx(expected, rel=1e-5) + assert A == pytest.approx(56.139, rel=1e-5) + assert t.area_target(utilities) == pytest.approx(59.6813, rel=1e-6) + # KEMP with approach contributions: Bath on real temperatures with the + # utilities allocated on the shifted ones + t = targets_of(units, 10., approach_contributions=KEMP_C) + utilities = t.utility_targets() + hot, cold = rows_members(KEMP, K) + u_hot, u_cold = utility_members(utilities) + expected = integrated_area(hot + u_hot, cold + u_cold) + A = t.area_target(utilities) + assert A == pytest.approx(expected, rel=1e-6) + assert A == pytest.approx(64.2819, rel=1e-6) + # the quickstart at 5 K + assert quickstart.area_target() == pytest.approx(5946.1, rel=1e-5) + +def test_units_targets(quickstart): + cases = [ + ('kemp', [(ID, T_in + K, T_out + K, CP) for ID, T_in, T_out, CP in KEMP], (4, 3)), + ('ref_A', REF_A, (2, 3)), + ('pair', PAIR, (0, 1)), + ('threshold', [('H1', 400., 320., 1.), ('C1', 310., 350., 1.)], (0, 2)), + ('cold_only', [('C1', 330., 440., 1.), ('C2', 300., 350., 2.)], (3, 0)), + # low pressure steam is pinched at 407.189 K and touches both cold + # streams on either side + ('utility_pinch', [('H1', 400., 300., 2.), ('C1', 330., 440., 1.), + ('C2', 330., 440., 1.)], (4, 4)), + # H2 and C2 balance: the network needs fewer units than the estimate + ('balanced_subset', [('H2', 400., 350., 1.), ('C2', 340., 390., 1.), + ('H1', 325., 315., 1.)], (0, 2)), + ] + for name, rows, (above, below) in cases: + t = targets('test_targeting_units_' + name, rows, 10.) + assert t.units_target() == {'above': above, 'below': below, + 'total': above + below}, name + t = targets('test_targeting_units_pinched', cases[5][1], 10.) + LP = by_ID(t.utility_targets())['low_pressure_steam'] + assert (LP.duty / kW, LP.level) == (pytest.approx(34.378, abs=1e-9), 407.189) + assert quickstart.units_target() == {'above': 5, 'below': 1, 'total': 6} + # where the balanced curve stays zero, a stream across several pieces + # counts once: a zero-duty stream given directly (a grid temperature in + # the zero stretch) counts nowhere + zero = cp_units('test_targeting_units_zero', [('Z', 350., 350., 1.)])[0] + for name, rows in (('ref_A', REF_A), ('pair', PAIR)): + t = targets('test_targeting_units_zero_' + name, rows, 10.) + direct = PinchTargets(t.streams_inlet + zero.ins, t.streams_quenched + zero.outs, + [*t.is_hot, True], 10.) + assert direct.table.Ts.size == t.table.Ts.size + 1 + assert direct.units_target() == t.units_target(), name + # and two balanced pairs in one zero stretch are two units + rows = [('H1', 400., 350., 1.), ('C1', 340., 390., 1.), + ('H2', 340., 300., 1.), ('C2', 290., 330., 1.)] + t = targets('test_targeting_units_two_pairs', rows, 10.) + assert (t.Q_hot, t.Q_cold) == (0., 0.) + assert t.units_target() == {'above': 0, 'below': 2, 'total': 2} + +def test_exchanger_costs_match_biosteam(kemp_hxn): + units, HXN = kemp_hxn + seen = set() + for hx in HXN.new_HXs + HXN.new_HX_utils: + design = hx.design_results + if 'Area' not in design: continue + purchase, installed, kind = _exchanger_costs( + design['Area'], design['Operating pressure'] * 101325 / 14.7) + assert list(hx.purchase_costs) == [kind] + assert purchase == pytest.approx(hx.purchase_cost, rel=1e-12) + assert installed == pytest.approx(hx.installed_cost, rel=1e-12) + seen.add(kind) + assert seen == {'Double pipe', 'Floating head'} + assert _exchanger_costs(math.inf)[:2] == (math.inf, math.inf) + assert _exchanger_costs(1e300) == (math.inf, math.inf, None) # overflow + assert all(math.isnan(x) for x in _exchanger_costs(math.nan)[:2]) + # a type and a material of biosteam's lists + p, i, kind = _exchanger_costs(500., heat_exchanger_type='U tube', + material='Stainless steel/stainless steel') + assert kind == 'U tube' and p < _exchanger_costs(500., material='Stainless steel/stainless steel')[0] + with pytest.raises(ValueError, match='heat_exchanger_type must be one of'): + _exchanger_costs(500., heat_exchanger_type='Furnace') + with pytest.raises(ValueError, match='material must be one of'): + _exchanger_costs(500., material='steel') + +@pytest.mark.parametrize('A, P_psi, kind, material', [ + (1., 14.7, 'Floating head', 'Carbon steel/carbon steel'), # F_l < 1 + (2.1718, 150., 'Fixed head', 'Carbon steel/carbon steel'), + (149.999, 600., 'Floating head', 'Stainless steel/stainless steel'), + (150., 600., 'U tube', 'Stainless steel/stainless steel'), + (150., 14.7, 'Floating head', 'Carbon steel/carbon steel'), + (2000., 150., 'Fixed head', 'Carbon steel/brass'), + (5000., 600., 'Kettle vaporizer', 'Monel/Monel'), + (1e4, 14.7, 'Double pipe', 'Titanium/titanium')]) +def test_exchanger_costs_match_biosteam_design(A, P_psi, kind, material): + # biosteam's own costing of an exchanger with these design results + with bst.Flowsheet('test_targeting_costs'): + hx = bst.HXprocess('test_targeting_costs_hx', thermo=bst.HeatUtility.thermo_water, + heat_exchanger_type=kind, material=material) + hx.isfurnace = False + hx.design_results.update({'Area': A, 'Total tube length': 20, + 'Operating pressure': P_psi}) + hx._cost() + hx._load_costs() + purchase, installed, kind = _exchanger_costs(A, P_psi * 101325 / 14.7, kind, material) + assert list(hx.purchase_costs) == [kind] + assert purchase == pytest.approx(hx.purchase_cost, rel=1e-12) + assert installed == pytest.approx(hx.installed_cost, rel=1e-12) + +def test_lmtd_nearly_equal(): + from decimal import Decimal, localcontext + d2 = np.array([10., 10., 10., 10., 10., 10., 10., 1e-3, 7.]) + d1 = d2 * np.array([1., 1. + 3e-12, 1. + 1e-10, 1. - 1e-7, 1. + 1e-3, 1.4, .6, + 1e6, 1e-6]) + with localcontext() as context: + context.prec = 60 + exact = [float(a) if a == b else float((a - b) / (a / b).ln()) + for a, b in zip(map(Decimal, d1), map(Decimal, d2))] + assert_allclose(_lmtd(d1, d2), exact, rtol=4e-16, atol=0) + assert_allclose(_lmtd(d2, d1), exact, rtol=4e-16, atol=0) + +def test_capital_target_formula(): + from biosteam.units.heat_exchange import compute_furnace_purchase_cost + t = targets('test_targeting_capital', REF_A, 10.) + utilities = t.utility_targets() + capital = t.capital_cost_target(utilities) + N = t.units_target(utilities)['total'] + A = t.area_target(utilities) + assert capital['area'] == A and capital['N_shells'] == N == 5 + # five equal double pipes of 52 ft2 cost less to install than the one + # floating head that the 260 ft2 fill, with four shells of no area + p, i, kind = _exchanger_costs(A * 10.763 / N) + assert kind == 'Double pipe' and A * 10.763 == pytest.approx(259.866, abs=1e-3) + p1, i1, kind1 = _exchanger_costs(A * 10.763) + p0, i0, _ = _exchanger_costs(0.) + assert p0 == 0. and i0 == pytest.approx(1304.568, abs=1e-3) + assert N * i < i1 + 4 * i0 + assert capital['N_area_shells'] == 1 and capital['shell_area'] == A + assert (capital['purchase_cost'], capital['installed_cost'], capital['type']) == ( + p1 + 4 * p0, i1 + 4 * i0, 'Floating head') + assert capital['furnace_duty'] == 0. + # a higher pressure costs more + assert t.capital_cost_target(utilities, P=20e5)['installed_cost'] > capital['installed_cost'] + assert t.capital_cost_target(utilities, P=np.float64(101325))['installed_cost'] == ( + capital['installed_cost']) + finite = 'P must be a finite number' + for P, message in ((math.nan, finite), (math.inf, finite), (True, finite), + ('2 bar', finite), (0., 'P must be > 0'), (-1e6, 'P must be > 0')): + with pytest.raises(ValueError, match=message): + t.capital_cost_target(utilities, P=P) + with pytest.raises(ValueError, match=message): + t.summary(P=P) + with pytest.raises(ValueError, match=message): + _exchanger_costs(500., P) + # the furnace: one for all fuel, costed on the utility duty + t = targets('test_targeting_capital_furnace', FURNACE, 10.) + utilities = t.utility_targets() + assert_allclose([u.duty / kW for u in utilities[:4]], [17.189, 42.581, 54.221, 16.009], + atol=1e-9) + fuel = utilities[3] + assert fuel.ID == 'natural_gas' and fuel.T_utility_out == pytest.approx(508.991, abs=1e-9) + assert fuel.cost == pytest.approx(fresh_price(fuel.duty, fuel.T_in, fuel.T_out, fuel.agent)[0], + rel=1e-12) + capital = t.capital_cost_target(utilities) + N = t.units_target(utilities)['total'] + assert capital['furnace_duty'] == fuel.utility_duty + assert capital['N_shells'] == N - 1 == 6 + A = t.area_target(utilities) + # 336 ft2: six equal double pipes, or the two floating heads they fill + # and four shells of no area, which cost more + assert int(A * 10.763 // 150) == 2 == capital['N_area_shells'] + assert capital['shell_area'] == A / 2 and capital['type'] == 'Floating head' + p, i, _ = _exchanger_costs(A * 10.763 / 2) + assert 2 * i + 4 * i0 > 6 * _exchanger_costs(A * 10.763 / 6)[1] + C_b = compute_furnace_purchase_cost(fuel.utility_duty * 0.947817, bst.CE) + assert capital['installed_cost'] == pytest.approx(2 * i + 4 * i0 + C_b * 2.19, rel=1e-12) + assert capital['purchase_cost'] == pytest.approx(2 * p + C_b, rel=1e-12) + summary = t.summary() + assert summary['utility_cost'] == pytest.approx(sum(u.cost for u in utilities), rel=1e-12) + assert summary['installed_cost'] == capital['installed_cost'] + # a zero minimum approach temperature: no driving force at the pinch + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + zero = t.at(0.).summary() + assert any('non-positive driving force' in str(w.message) for w in caught) + assert zero['area'] == zero['installed_cost'] == zero['TAC'] == math.inf + assert zero['Q_hot'] < t.Q_hot + with pytest.raises(ValueError, match='heat_exchanger_type must be one of'): + t.capital_cost_target(utilities, heat_exchanger_type='shell') + +def test_capital_target_split(): + # the equal division, unless it puts the shells below 150 ft2 where the + # area fills k = A // 150 ft2 shell and tubes, which with the other + # shells of no area cost more: so a unit added always adds cost + from hensmith.targeting import _shell_split + p0, i0, _ = _exchanger_costs(0.) + for M in range(1, 12): + for A_ft2 in np.geomspace(1., 5000., 157): + A = A_ft2 / 10.763 + p, i, k, kind = _shell_split(A, M) + pe, ie, kinde = _exchanger_costs(A_ft2 / M) + if A_ft2 / M >= 150. or A_ft2 < 150.: + assert k == M and kind == kinde + assert (p, i) == pytest.approx((M * pe, M * ie), rel=1e-12) + else: + ks = int(A_ft2 // 150.) + pk, ik, kindk = _exchanger_costs(A_ft2 / ks) + filled = ks * ik + (M - ks) * i0 + assert i == pytest.approx(max(M * ie, filled), rel=1e-12) + if filled > M * ie: + assert (k, kind) == (ks, kindk) and kind != 'Double pipe' + assert p == pytest.approx(ks * pk + (M - ks) * p0, rel=1e-12) + else: + assert (k, kind) == (M, 'Double pipe') + assert _shell_split(A, M + 1)[1] > i + assert _shell_split(A * 1.001, M)[1] >= i + assert _shell_split(math.inf, 3)[1:] == (math.inf, 3, None) + nan = _shell_split(math.nan, 3) + assert math.isnan(nan[1]) and nan[2:] == (3, None) + +def test_capital_target_keeps_shells(): + # KEMP: with no hot utility up to 5.554 K, four floating heads of 202 + # ft2; a few watts of steam at 5.560 K add three units, and an equal + # division would put the same area in seven double pipes for a fifth of + # the cost. The target keeps the shell and tubes the area fills + t = targets_of(cp_units('test_targeting_keeps_shells', KEMP, K), 10.) + before, after = t.at(5.554).summary(), t.at(5.56).summary() + assert (before['N_units'], after['N_units']) == (4, 7) + assert before['Q_hot'] == 0. < after['Q_hot'] < 0.1 * 3600 + assert after['area'] == pytest.approx(before['area'], rel=1e-3) + assert 7 * _exchanger_costs(after['area'] * 10.763 / 7)[1] < 0.25 * before['installed_cost'] + assert (before['N_area_shells'], before['shell_type']) == (4, 'Floating head') + assert (after['N_area_shells'], after['shell_type']) == (5, 'Floating head') + assert after['installed_cost'] > before['installed_cost'] + assert after['TAC'] > before['TAC'] + # so the least TAC is not where the steam appears: past a step of one + # shell, not of a factor of five + sweep = supertarget(t) + assert sweep.T_min_app > 10. and not sweep.at_bound + i = sweep.records.index(sweep.optimum) + below = sweep.records[i - 1] + assert below['N_area_shells'] == sweep.optimum['N_area_shells'] + 1 + assert below['TAC'] < 1.3 * sweep.optimum['TAC'] + # the pair of supertarget's example: one floating head of 215 ft2 at + # 9.9 K, kept with the two utility units of 10.05 K + t = targets('test_targeting_keeps_shells_pair', PAIR, 10.) + before, after = t.at(9.9).summary(), t.at(10.05).summary() + assert (before['N_units'], after['N_units']) == (1, 3) + assert before['installed_cost'] == _exchanger_costs(20. * 10.763)[1] + assert (after['N_area_shells'], after['shell_type']) == (1, 'Floating head') + assert after['installed_cost'] == pytest.approx( + _exchanger_costs(after['area'] * 10.763)[1] + 2 * _exchanger_costs(0.)[1], rel=1e-12) + assert after['installed_cost'] > before['installed_cost'] + +def test_crf_and_tea(): + from biosteam._tea import solve_payment + assert _economics(.1, 10)[0] == solve_payment(1., .1, 10) + assert _economics() == (pytest.approx(0.16275, abs=1e-5), 8000.) + assert _economics(0., 10) == (0.1, 8000.) + tea = types.SimpleNamespace(IRR=.08, duration=(2020, 2040), operating_hours=7920) + assert _economics(tea=tea) == (solve_payment(1., .08, 20), 7920.) + assert _economics(.05, 5, 100., tea) == (solve_payment(1., .05, 5), 100.) + tea.operating_hours = None + assert _economics(tea=tea)[1] == 8000. + for kwargs, message in ((dict(years=0), 'years must be > 0'), + (dict(operating_hours=-1.), 'operating_hours must be >= 0'), + (dict(interest=-1.), 'interest must be > -1'), + (dict(interest=math.nan), 'interest must be a finite number'), + (dict(years=True), 'years must be a finite number')): + with pytest.raises(ValueError, match=message): + _economics(**kwargs) + # the total annualized cost of the summary + t = targets('test_targeting_tea', REF_A, 10.) + tea.operating_hours = 7920 + summary = t.summary(tea=tea, interest=.12) + crf = solve_payment(1., .12, 20) + assert summary['annualized_capital_cost'] == crf * summary['installed_cost'] + assert summary['operating_cost'] == 7920 * summary['utility_cost'] + assert summary['TAC'] == summary['annualized_capital_cost'] + summary['operating_cost'] + with pytest.raises(TypeError, match="unexpected options: 'dT'"): + t.summary(dT=5.) + with pytest.raises(ValueError, match='years must be > 0'): + t.summary(years=-1) + +def test_T_min_app_validated(kemp_hxn): + # supertarget's rule everywhere: finite and >= 0 (> 0 with approach + # contributions), before any target is computed + t = targets('test_targeting_T_min_app', REF_A, 10.) + units, HXN = kemp_hxn + for x, message in ((math.nan, 'a finite number'), (math.inf, 'a finite number'), + (-10., '>= 0 K'), ('5', 'a finite number'), + (True, 'a finite number')): + for build in (lambda: PinchTargets(t.streams_inlet, t.streams_quenched, + t.is_hot, x, curves=t.curves), + lambda: PinchTargets.from_heat_utilities(t.heat_utilities, x), + lambda: t.at(x), lambda: HXN.targets(x)): + with pytest.raises(ValueError, match=f'T_min_app must be {message}'): + build() + # 0 K is the limit (an infinite area); with contributions it is not allowed + assert t.at(0.).Q_hot == pytest.approx(40. * 3600., rel=1e-9) + with pytest.raises(ValueError, match='T_min_app must be finite and > 0'): + PinchTargets(t.streams_inlet, t.streams_quenched, t.is_hot, 0., [1., None]) + # the system's sums by agent belong to no exchanger + with pytest.raises(ValueError, match=r'must belong to a heat exchanger \(hu.unit\)' + r'.*heat_exchanger_utilities_from_units'): + PinchTargets.from_heat_utilities(HXN.system.heat_utilities, 10.) + +class HeatedMixer(bst.Unit): + """Two feeds mixed and heated to 360 K by a heat utility of its own + (``add_heat_utility``, so ``hxn_ok`` False): its first inlet and outlet + are not the stream it heats.""" + _N_ins = 2 + _N_outs = 1 + + def _run(self): + self.outs[0].mix_from(self.ins) + self.outs[0].T = 360. + + def _design(self): + self.add_heat_utility(self.outs[0].H - sum([i.H for i in self.ins]), + min([i.T for i in self.ins]), self.outs[0].T) + +def test_from_heat_utilities_takes_exchangers(): + # a heat utility that is not a heat exchanger's (hxn_ok False) is + # refused, whatever its unit's first inlet and outlet: here they would + # be one feed of two and the mix, 30 % off the duty. biosteam's + # selection leaves it out, as the facility does, and gives the facility's + # targets. + test_hxn_targets.setup('test_targeting_non_exchanger') + M = HeatedMixer('M', ins=[bst.Stream('M_water', Water=500., T=300.), + bst.Stream('M_ethanol', Ethanol=200., T=340.)]) + H = bst.HXutility('H', ins=M-0, T=310., rigorous=False) + M.simulate(); H.simulate() + C = test_hxn_targets.utility_hx('C', 300., 101325., 'l', 340., Water=200.) + units = [M, H, C] + hus = [hu for u in units for hu in u.heat_utilities] + assert [(hu.unit, hu.hxn_ok) for hu in hus] == [(M, False), (H, True), (C, True)] + mixer = hus[0].unit_duty + assert abs(M.outs[0].H - M.ins[0].H - mixer) > 0.3 * mixer + message = (r"must be a heat exchanger's \(hu.hxn_ok\), not those of 'M'," + r".*heat_exchanger_utilities_from_units\(sys.units\)") + for given in (hus, hus[::-1]): + with pytest.raises(ValueError, match=message): + PinchTargets.from_heat_utilities(given, 10.) + selected = bst.process_tools.heat_exchanger_utilities_from_units(units) + assert [hu.unit for hu in selected] == [H, C] + t = PinchTargets.from_heat_utilities(sorted(selected, key=lambda hu: hu.duty), 10.) + # a heat utility given twice (more often than its unit lists it) would + # count its stream twice + for given in (selected + selected[:1], selected[:1] + selected): + with pytest.raises(ValueError, match=r"the heat utilities of 'H' \(2 given, 1 " + r"listed\) are given more often than " + r"their units.*heat_exchanger_utilities"): + PinchTargets.from_heat_utilities(given, 10.) + HXN = HeatExchangerNetwork('HXN', T_min_app=10., units=units) + facility = HXN.targets() + assert facility.labels == t.labels == ['C', 'H'] + assert (facility.Q_hot, facility.Q_cold) == (t.Q_hot, t.Q_cold) + # a non-exchanger heat utility without duty is dropped, as any is, once + # its unit holds it (one that it does not hold raises, see + # test_from_heat_utilities_of_a_former_selection) + idle = bst.HeatUtility(unit=M) + assert not idle.hxn_ok and idle.duty == 0. + with pytest.raises(ValueError, match=r"the heat utilities of 'M' are not \(or no " + r"longer\) among their units' heat_utilities"): + PinchTargets.from_heat_utilities([idle, *selected], 10.) + M.heat_utilities.append(idle) + same = PinchTargets.from_heat_utilities( + [idle, *sorted(selected, key=lambda hu: hu.duty)], 10.) + assert same.labels == t.labels and (same.Q_hot, same.Q_cold) == (t.Q_hot, t.Q_cold) + +class Fermentation(bst.AbstractStirredTankReactor): + """biosteam's own example of a stirred-tank reactor (the + ContinuousFermentation of its docstring): in parallel, ``parallel['self']`` + reactors, each with a recirculation cooler (its auxiliary + ``heat_exchanger``, hxn_ok) at 1/N of the duty.""" + _N_ins = 1 + _N_outs = 2 + T_default = 32. + 273.15 + P_default = 101325. + tau_default = 8. + + def _run(self): + vent, effluent = self.outs + effluent.mix_from(self.ins, energy_balance=False) + self.reactions(effluent) + effluent.T = vent.T = self.T + effluent.P = vent.P = self.P + vent.phase = 'g' + vent.empty() + vent.receive_vent(effluent, energy_balance=False) + +def test_from_heat_utilities_of_units_in_parallel(): + # biosteam lists the heat utility of a unit in parallel once per unit in + # its owner's heat_utilities, and so in its selection: the synthesis + # takes it as a stream per reactor, and so do the targets of the + # selection and those of a facility before any simulation (which take + # the selection), equal to the facility's after its simulation + import thermosteam as tmo + glucose = tmo.Chemical('Glucose', phase='l', default=True) + test_hxn_targets.setup('test_targeting_parallel', ['Water', 'Ethanol', 'CO2', glucose]) + feed = bst.Stream('feed', Water=6000., Glucose=300., T=340.) + C0 = bst.HXutility('C0', ins=feed, T=305.15) + R1 = Fermentation('R1', ins=C0-0, outs=('vent', 'beer'), + reactions=bst.ReactionSystem( + bst.Reaction('Glucose -> 2Ethanol + 2CO2', 'Glucose', 0.9), + basis='mol')) + H1 = bst.HXutility('H1', ins=R1-1, T=360.) + units = [C0, R1, H1] + plain = bst.System.from_units('plain', units=units) + plain.simulate() + N = R1.parallel['self'] + hu = R1.heat_exchanger.heat_utilities[0] + assert N > 1 and hu.hxn_ok and hu.duty < 0. + assert [h is hu for h in R1.heat_utilities] == [True] * N + selected = bst.process_tools.heat_exchanger_utilities_from_units(units) + assert sum(h is hu for h in selected) == N + with warnings.catch_warnings(): + warnings.simplefilter('error') + t = PinchTargets.from_heat_utilities(sorted(selected, key=lambda h: h.duty), 10., + key_units=units) + preview = HeatExchangerNetwork('preview', T_min_app=10., units=units).targets() + assert t.labels.count('R1 - heat_exchanger') == N + assert preview.labels == t.labels + assert (preview.Q_hot, preview.Q_cold) == (t.Q_hot, t.Q_cold) + # each reactor's cooling counts: the reactor's whole duty, N times one + # recirculation cooler's + dH = R1.heat_exchanger.outs[0].H - R1.heat_exchanger.ins[0].H + alone = PinchTargets.from_heat_utilities([h for h in selected if h is not hu], 10.) + assert t.Q_cold - alone.Q_cold == pytest.approx(-N * dH, rel=1e-6) + # the heat utility given more often than the reactor lists it: a unit + # and its auxiliary unit both selected + overlapping = bst.process_tools.heat_exchanger_utilities_from_units( + [*units, R1.heat_exchanger]) + with pytest.raises(ValueError, match=r"the heat utilities of 'R1 - heat_exchanger' " + rf"\({N + 1} given, {N} listed\)"): + PinchTargets.from_heat_utilities(overlapping, 10., key_units=units) + # the auxiliary unit alone: its own single heat utility, one stream + single = PinchTargets.from_heat_utilities(R1.heat_exchanger.heat_utilities, 10.) + assert single.labels == ['R1 - heat_exchanger'] + # the facility's synthesis takes them alike + sys, HXN = simulate_HXN(units, 10., name='sys_parallel') + facility = HXN.targets() + assert facility.labels == preview.labels + assert (facility.Q_hot, facility.Q_cold) == (preview.Q_hot, preview.Q_cold) + info = HXN.synthesis_info + assert (info['Q_hot_target'], info['Q_cold_target']) == (t.Q_hot, t.Q_cold) + +def test_facility_units_in_parallel_whose_number_changes(): + # a fermentor sets its number of reactors in parallel by its volume, so + # a feed change changes the number of streams of its recirculation + # cooler. With cache_network, the network is synthesized again for the + # new number (it was reused for the former one, its loads those of the + # former number of reactors, while only the set of exchangers was + # compared); with the units simulated alone, the facility's streams stay + # one per reactor of its last simulation, which its targets warn of and + # its report refuses until the system is simulated + import thermosteam as tmo + glucose = tmo.Chemical('Glucose', phase='l', default=True) + test_hxn_targets.setup('test_targeting_parallel_changed', + ['Water', 'Ethanol', 'CO2', glucose]) + feed = bst.Stream('feed', Water=6000., Glucose=300., T=340.) + C0 = bst.HXutility('C0', ins=feed, T=305.15) + R1 = Fermentation('R1', ins=C0-0, outs=('vent', 'beer'), + reactions=bst.ReactionSystem( + bst.Reaction('Glucose -> 2Ethanol + 2CO2', 'Glucose', 0.9), + basis='mol')) + H1 = bst.HXutility('H1', ins=R1-1, T=360.) + C2 = bst.HXutility('C2', ins=bst.Stream('cold', Water=3000., T=290.), T=320.) + units = [C0, R1, H1, C2] + sys, HXN = simulate_HXN(units, 5., name='sys_parallel_changed', + cache_network=True) + hx = R1.heat_exchanger + count = lambda: HXN.original_heat_exchangers.count(hx) + N = R1.parallel['self'] + assert N > 2 and count() == N + # the same number of reactors: the cached network is reused + network = HXN.HXN_sys + feed.F_mol *= 1.000001 + simulate_strictly(sys) + assert R1.parallel['self'] == N and HXN.HXN_sys is network + # fewer reactors: synthesized again, as a facility without the cache + feed.F_mol *= 0.4 + simulate_strictly(sys) + N = R1.parallel['self'] + assert HXN.HXN_sys is not network and count() == N + cached = (HXN.actual_heat_util_load, HXN.actual_cool_util_load, + HXN.installed_costs['Heat exchangers']) + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + targets = HXN.targets() + network_report(HXN) + assert targets.labels.count('R1 - heat_exchanger') == N + HXN.cache_network = False + simulate_strictly(sys) + assert (HXN.actual_heat_util_load, HXN.actual_cool_util_load, + HXN.installed_costs['Heat exchangers']) == cached + assert HXN.targets().summary() == targets.summary() + # more reactors, the units simulated alone: the streams stay one per + # reactor of the facility's last simulation, at the new duty of each + feed.F_mol *= 2.5 + for unit in units: unit.simulate() + assert R1.parallel['self'] > N + message = (rf"'R1 - heat_exchanger' {N} times, listed " + rf"{R1.parallel['self']} times now \(Unit.parallel changed " + r"since, its unit simulated alone\?\): simulate the system") + with pytest.warns(RuntimeWarning, match='the targets of HXN take a stream ' + 'per heat utility .*: ' + message): + targets = HXN.targets() + assert targets.labels.count('R1 - heat_exchanger') == N + for args in ((HXN,), (HXN, targets)): + with pytest.raises(ValueError, match='the network of HXN has a stream ' + 'per heat utility .*: ' + message): + network_report(*args) + # simulated again: one stream per reactor now, and no warning + N = R1.parallel['self'] + simulate_strictly(sys) + assert count() == N + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + assert HXN.targets().labels.count('R1 - heat_exchanger') == N + network_report(HXN) + +def test_from_heat_utilities_reversed_stream(): + # a feed set hotter than its heater's outlet since the heater was + # simulated: the stream from the inlet now to the outlet as last + # simulated is cooled, against its heat utility, which heats. The error + # says so in terms of the heat utility (not of is_hot, which the caller + # never gave); the warning of a changed flow comes before it + test_hxn_targets.setup('test_targeting_reversed') + a = bst.Stream('a', Water=100., T=300.) + H1 = bst.HXutility('H1', ins=a, T=350.) + C1 = test_hxn_targets.utility_hx('C1', 370., 101325., 'l', 310., Water=100.) + H1.simulate() + hus = [H1.heat_utilities[0], C1.heat_utilities[0]] + a.T = 360. + message = (r"the heat utility of 'H1' heats \(hu.duty = \+\d+ kJ/hr\), but its " + r"stream, from its unit's inlet now to its outlet as last simulated, " + r"is cooled \(-\d+(\.\d+)? kJ/hr\): its inlet has changed since its " + r"unit was simulated") + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + with pytest.raises(ValueError, match=message) as error: + PinchTargets.from_heat_utilities(hus, 10.) + assert 'is_hot' not in str(error.value) + assert str(error.value).endswith('simulate the system (or its units) first, ' + 'converging any recycle more tightly, then ' + 'select the heat utilities again') + assert not [x for x in w if 'differ in flow' in str(x.message)] + a.imol['Water'] = 120. + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + with pytest.raises(ValueError, match=message): + PinchTargets.from_heat_utilities(hus, 10.) + assert [str(x.message)[:43] for x in w if 'differ in flow' in str(x.message)] == [ + "the inlet and outlet of 'H1' differ in flow"] + # simulated again: a cooler now + H1.simulate() + t = PinchTargets.from_heat_utilities([H1.heat_utilities[0], hus[1]], 10.) + assert t.labels == ['H1', 'C1'] and list(t.is_hot) == [True, True] + +def test_from_heat_utilities_of_a_former_selection(): + # biosteam gives a unit new heat utilities each time it is simulated, + # emptying the former ones (no duty): a selection kept from before is + # refused, naming the units simulated since, rather than leaving their + # streams out as zero-duty ones (the cold utility target 60 % off here) + # or, once the whole system is simulated, finding no stream at all. The + # warning of a changed inlet, which comes first, says to select them + # again after simulating; a selection made after a simulation is right + name = 'test_targeting_former_selection' + units = cp_units(name, [('H1', 400., 300., 1.), ('C1', 330., 440., 1.), + ('H3', 420., 340., .5)]) + H1, C1, H3 = units + sys = bst.System.from_units(name, units) + sys.simulate() + select = lambda: bst.process_tools.heat_exchanger_utilities_from_units(sys.units) + hus = select() + assert [hu.unit for hu in hus] == units + assert PinchTargets.from_heat_utilities(hus, 10.).labels == ['C1', 'H1', 'H3'] + H3.ins[0].F_mol *= 2. + with pytest.warns(RuntimeWarning, match=r"the inlet and outlet of 'H3' differ in " + r"flow .*simulate the system \(or its " + r"units\) first, then select the heat " + r"utilities again$"): + PinchTargets.from_heat_utilities(hus, 10.) + H3.simulate() # as the warning says + assert hus[2].duty == 0. and not any(hu is hus[2] for hu in H3.heat_utilities) + message = (r"the heat utilities of {} are not \(or no longer\) among their " + r"units' heat_utilities: biosteam gives a unit new heat utilities " + r"each time it is simulated, emptying the former ones, so a " + r"selection is valid until its units are simulated again; select " + r"them again after any simulation, e\.g\. bst\.process_tools\." + r"heat_exchanger_utilities_from_units\(sys\.units\)$") + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + with pytest.raises(ValueError, match=message.format("'H3'")): + PinchTargets.from_heat_utilities(hus, 10.) + now = PinchTargets.from_heat_utilities(select(), 10.) + assert now.labels == ['C1', 'H1', 'H3'] + assert (now.Q_hot, now.Q_cold) == (pytest.approx(1.08e5, rel=1e-9), + pytest.approx(3.6e5, rel=1e-9)) + # the whole system simulated: every heat utility of the former selection + # is empty, and each unit is named + sys.simulate() + with pytest.raises(ValueError, match=message.format("'H1', 'C1', 'H3'")): + PinchTargets.from_heat_utilities(hus, 10.) + again = PinchTargets.from_heat_utilities(select(), 10.) + assert (again.Q_hot, again.Q_cold) == (now.Q_hot, now.Q_cold) + +def test_from_heat_utilities_after_replaced_heat_utilities(kemp_hxn, kemp_hxn_replaced): + # biosteam's selection after a simulation with + # replace_unit_heat_utilities=True holds the network's utilities: C1 + # and H4 keep 20 and 60 kW of their 230 and 180 kW, and H2 and C3, + # served by the process exchangers alone, are empty and left out. A + # RuntimeWarning says so (the targets would be those of C1 and H4 + # alone); the facility's targets are the process's. + def select(sys): + hus = bst.process_tools.heat_exchanger_utilities_from_units(sys.units) + return sorted(hus, key=lambda hu: hu.unit.ID) + units, HXN = kemp_hxn_replaced + hus = select(HXN.system) + assert [hu.unit for hu in hus] == [units[0], units[3]] + assert_allclose([hu.unit_duty / kW for hu in hus], [20., -60.], rtol=1e-9) + message = (r"heat utilities of 'C1', 'H4' have duties \(hu.unit_duty\) other " + r"than their process streams' .*they hold the utilities of " + r"HeatExchangerNetwork 'HXN', simulated with " + r"replace_unit_heat_utilities=True.*take HXN.targets\(\) instead") + with pytest.warns(RuntimeWarning, match=message) as record: + t = PinchTargets.from_heat_utilities(hus, 10.) + assert [w.filename for w in record] == [__file__] # the caller's line + assert t.labels == ['C1', 'H4'] + facility = HXN.targets() + assert facility.labels == ['C1', 'C3', 'H2', 'H4'] + # the curves are named by the labels (in their warnings), not by the + # IDs of the targets' private stream copies + assert [c.label for c in facility.curves] == facility.labels + assert (facility.Q_hot, facility.Q_cold) == (pytest.approx(20. * kW, rel=1e-9), + pytest.approx(60. * kW, rel=1e-9)) + # every unit's own heat utility: the empty ones are named too + with pytest.warns(RuntimeWarning, match="of 'C1', 'H2', 'C3', 'H4' have duties"): + PinchTargets.from_heat_utilities([u.heat_utilities[0] for u in units], 10.) + # without replace_unit_heat_utilities the selection is the process's, and + # no warning is raised + units, HXN = kemp_hxn + hus = select(HXN.system) + assert [hu.unit for hu in hus] == [units[0], units[2], units[1], units[3]] + with warnings.catch_warnings(): + warnings.simplefilter('error') + t = PinchTargets.from_heat_utilities(hus, 10.) + facility = HXN.targets() + assert t.labels == facility.labels + assert (t.Q_hot, t.Q_cold) == (pytest.approx(facility.Q_hot, rel=1e-12), + pytest.approx(facility.Q_cold, rel=1e-12)) + # a written-over duty off by 1e-4 of its stream's is another than the + # stream's; one off by 1e-9 is the stream's up to rounding + units, HXN = kemp_hxn_replaced + hu = units[0].heat_utilities[0] + dH = units[0].outs[0].H - units[0].ins[0].H + replaced = hu.unit_duty + try: + for f, off in ((1e-4, True), (-1e-4, True), (1e-9, False)): + hu.unit_duty = dH * (1. + f) + assert _duty_off_stream(hu) == off + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PinchTargets.from_heat_utilities([hu], 10.) + messages = [str(x.message) for x in w + if 'other than their process' in str(x.message)] + assert len(messages) == off + if off: assert messages[0].startswith("the heat utilities of 'C1' have duties") + finally: + hu.unit_duty = replaced + # a duty off its stream's that no network has written over warns of + # nothing: the targets are the streams' + units = cp_units('test_targeting_duty_off', REF_A) + hus = [u.heat_utilities[0] for u in units] + dH = units[0].outs[0].H - units[0].ins[0].H + hus[0].unit_duty = dH * (1. + 1e-4) + assert _duty_off_stream(hus[0]) + with warnings.catch_warnings(): + warnings.simplefilter('error') + t = PinchTargets.from_heat_utilities(hus, 10.) + assert t.labels == ['C1', 'H1'] + +def test_from_heat_utilities_of_a_total_condenser(): + # the heat utility of the total condenser of biosteam's MESHDistillation + # reports a duty 0.1 % off the enthalpy change of the condenser's own + # streams (no network about): neither the selection nor the facility's + # targets before a simulation warn of a network that wrote it over + bst.main_flowsheet.set_flowsheet('test_targeting_total_condenser') + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + feed = bst.Stream('feed', Water=800., Ethanol=100., units='kmol/hr', T=350.) + D1 = bst.MESHDistillation('D1', ins=feed, outs=('dist', 'bott'), N_stages=12, + feed_stages=(6,), reflux=2., boilup=1.5, + LHK=('Ethanol', 'Water'), full_condenser=True) + C1 = bst.HXutility('C1', ins=D1-0, T=310.) + C2 = bst.HXutility('C2', ins=D1-1, T=320.) + sys = bst.System.from_units('sys', units=[D1, C1, C2]) + sys.simulate() + hu = D1.condenser.heat_utilities[0] + dH = D1.condenser.outs[0].H - D1.condenser.ins[0].H + assert _duty_off_stream(hu) and abs(hu.unit_duty / dH - 1.) > 1e-4 + with warnings.catch_warnings(): + warnings.simplefilter('error') + hus = bst.process_tools.heat_exchanger_utilities_from_units(sys.units) + t = PinchTargets.from_heat_utilities(sorted(hus, key=lambda hu: hu.duty)) + facility = HeatExchangerNetwork('HXN', units=sys.units).targets() + assert 'D1 - condenser' in t.labels and facility.labels == t.labels + assert (facility.Q_hot, facility.Q_cold) == (t.Q_hot, t.Q_cold) + +def test_is_hot_validated(): + # is_hot must agree with each stream's direction (a stream without duty + # may be either) + t = targets('test_targeting_is_hot', REF_A, 10.) + assert t.labels == ['C1', 'H1'] and list(t.is_hot) == [False, True] + for is_hot, message in ( + ([True, True], r"stream 'C1' is heated \(\+396000 kJ/hr\) but marked hot"), + ([False, False], r"stream 'H1' is cooled \(-360000 kJ/hr\) but marked cold"), + ([True, False], r"stream 'C1' is heated")): + for curves in (None, t.curves): + with pytest.raises(ValueError, match=message + '.*is_hot must be True ' + 'for a cooled stream'): + PinchTargets(t.streams_inlet, t.streams_quenched, is_hot, 10., + labels=t.labels, curves=curves) + zero = cp_units('test_targeting_is_hot_zero', [('Z', 350., 350., 1.)])[0] + for flag in (True, False): + direct = PinchTargets(t.streams_inlet + zero.ins, t.streams_quenched + zero.outs, + [*t.is_hot, flag], 10.) + assert (direct.Q_hot, direct.Q_cold) == (t.Q_hot, t.Q_cold) + +def test_agents_by_ID(): + # the agents of utility_targets, summary and supertarget may be the IDs + # of biosteam's agents + t = targets('test_targeting_agent_IDs', REF_A, 10.) + heating = ['low_pressure_steam', 'high_pressure_steam'] + cooling = ['cooling_water', 'chilled_water'] + by_ID = dict(heating_agents=heating, cooling_agents=cooling) + agents = dict(heating_agents=[agent(i) for i in heating], + cooling_agents=[agent(i) for i in cooling]) + utilities = t.utility_targets(**by_ID) + assert [u.ID for u in utilities] == heating + cooling + assert utilities == t.utility_targets(**agents) + assert t.summary(**by_ID) == t.summary(**agents) + sweep = supertarget(t, [10.], refine=0, **by_ID) + assert sweep.options['heating_agents'] == agents['heating_agents'] + with pytest.raises(ValueError, match="no heating agent 'cooling_water'"): + t.utility_targets(['cooling_water']) + with pytest.raises(ValueError, match="no cooling agent 'steam'"): + t.summary(cooling_agents=['steam']) + with pytest.raises(TypeError, match='heating_agents must be a sequence'): + t.utility_targets('low_pressure_steam') + # an agent of the other kind (steam as a coolant would be priced as a + # negative cost), or a fuel as a coolant + LP, CW, gas = agent('low_pressure_steam'), agent('cooling_water'), agent('natural_gas') + with pytest.raises(ValueError, match="'low_pressure_steam' is not a cooling agent; " + 'pass it in heating_agents'): + t.utility_targets(None, [LP, CW]) + with pytest.raises(ValueError, match="'cooling_water' is not a heating agent"): + t.summary(heating_agents=[CW]) + with pytest.raises(ValueError, match="'natural_gas' is not a cooling agent"): + t.utility_targets(None, [gas]) + assert [u.ID for u in t.utility_targets([LP, gas], [CW, 'chilled_water'])] == [ + 'low_pressure_steam', 'natural_gas', 'cooling_water', 'chilled_water'] + +def test_optimum_T_min_app_closed_form(): + from biosteam.units.heat_exchange import (compute_double_pipe_purchase_cost, + compute_floating_head_purchase_cost, p2) + from biosteam.units.design_tools.specification_factors import ( + compute_shell_and_tube_material_factor, shell_and_tube_material_factor_coefficients) + CP = 100. + rows = [('H1', 400., 320., CP), ('C1', 310., 390., CP)] + t = targets('test_targeting_closed_form', rows, 20.) + thermo = agent('low_pressure_steam').thermo + with bst.Flowsheet('test_targeting_closed_form_agents'): + steam = bst.UtilityAgent('test_steam', Water=1, T=450, P=1e6, phase='g', + heat_transfer_price=4e-7, thermo=thermo) + refrigerant = bst.UtilityAgent('test_refrigerant', Water=1, T=280, P=1e3, + phase='l', heat_transfer_price=2e-7, thermo=thermo) + options = dict(heating_agents=[steam], cooling_agents=[refrigerant]) + def LMTD(a, b): return a if a == b else (a - b) / math.log(a / b) + def installed(A): # [ft2], biosteam's correlations at 1 atm + if A < 150: + F_l = min(A / 2.1718, 1.) + C_b = compute_double_pipe_purchase_cost(max(A, 2.1718), bst.CE) + p = 14.7 / 600 + return C_b * (1.8 + F_l * (0.8510 + 0.1292 * p + 0.0198 * p**2) * 2 - 1.) + p = 14.7 / 100 + F_m = compute_shell_and_tube_material_factor( + A, *shell_and_tube_material_factor_coefficients['Carbon steel/carbon steel']) + F = np.polyval(p2, 20) * (0.9803 + 0.018 * p + 0.0017 * p**2) * F_m + return compute_floating_head_purchase_cost(A, bst.CE) * (3.17 + F - 1.) + def TAC(T): + Q = CP * (T - 10.) # each utility [kW]: C1 is above the shifted top of H1 + A = 2 * (Q / LMTD(40., 40. + Q / CP) + (80 * CP - Q) / T + + Q / LMTD(60. + Q / CP, 60.)) + crf = 0.1 * 1.1**10 / (1.1**10 - 1.) + return crf * 3 * installed(A * 10.763 / 3) + 8000 * 3600 * (4e-7 * Q + 2e-7 * Q) + for T in (12., 20., 35.): + summary = t.at(T).summary(**options) + assert summary['N_units'] == 3 + assert summary['TAC'] == pytest.approx(TAC(T), rel=1e-9) + fine = np.arange(11., 60., 0.001) + values = np.array([TAC(T) for T in fine]) + T_opt = fine[np.argmin(values)] + assert T_opt == pytest.approx(21.595, abs=1e-6) + assert np.all(np.diff(values[fine < T_opt]) < 0) and np.all(np.diff(values[fine > T_opt]) > 0) + sweep = supertarget(t, np.arange(11., 61.), **options) + assert abs(sweep.T_min_app - T_opt) <= 0.02 + assert sweep.optimum['TAC'] == pytest.approx(TAC(sweep.T_min_app), rel=1e-9) + assert not sweep.at_bound + assert sweep.options == options + assert repr(sweep) == ('') + +def test_sweep(monkeypatch): + import hensmith.hxn_synthesis + t = targets('test_targeting_sweep', REF_A, 10.) + def flash(*args, **kwargs): raise AssertionError('the curves are rebuilt') + monkeypatch.setattr(hensmith.hxn_synthesis, 'stream_curves', flash) + sweep = supertarget(t, [20., 5., 10., 5.], refine=1, n_refine=3) + assert isinstance(sweep, Supertargets) and sweep.targets is t + T = sweep['T_min_app'] + assert np.all(np.diff(T) > 0) and T.size == 3 + 3 + assert_allclose(sweep['TAC'], [r['TAC'] for r in sweep.records], rtol=0, atol=0) + frame = sweep.to_frame() + assert frame.index.name == 'T_min_app' and list(frame.index) == list(T) + assert list(frame.columns) == ['Q_hot', 'Q_cold', 'utility_cost', 'area', 'N_units', + 'N_shells', 'N_area_shells', 'shell_area', 'shell_type', + 'purchase_cost', 'installed_cost', + 'annualized_capital_cost', 'operating_cost', 'TAC'] + capital = t.at(T[0]).capital_cost_target() + assert [sweep.records[0][key] for key in ('N_area_shells', 'shell_area', 'shell_type')] == [ + capital[key] for key in ('N_area_shells', 'shell_area', 'type')] + assert sweep.optimum['TAC'] == frame['TAC'].min() + assert sweep.T_min_app == sweep.optimum['T_min_app'] + assert sweep.at_bound == (sweep.T_min_app in (T[0], T[-1])) + single = supertarget(t, [10.], refine=2) + assert len(single.records) == 1 and single.at_bound and 'at the bound' in repr(single) + assert not single.constant and not sweep.constant + assert single.optimum == t.summary() + # the options reach every evaluation + hours = supertarget(t, [5., 10.], refine=0, operating_hours=0.) + assert np.all(hours['operating_cost'] == 0.) + LP, MP = agent('low_pressure_steam'), agent('medium_pressure_steam') + assert supertarget(t, (x for x in [10., np.float64(5.), 5]), refine=0).records == ( + supertarget(t, [5., 10.], refine=0).records) + listed = supertarget(t, [5., 10.], refine=0, heating_agents=[MP, LP]) + assert listed.records == supertarget(t, [5., 10.], refine=0, + heating_agents=iter([MP, LP])).records + assert listed.records != supertarget(t, [5., 10.], refine=0).records + # validation, before sweeping + for kwargs, error, message in ( + (dict(T_min_apps=[]), ValueError, 'non-empty'), + (dict(T_min_apps=[[5.]]), ValueError, 'non-empty'), + (dict(T_min_apps=[5., math.nan]), ValueError, 'finite'), + (dict(T_min_apps=5.), ValueError, 'non-empty'), + # the rule of PinchTargets.at: no bools, no strings + (dict(T_min_apps=[True, 5.]), ValueError, + 'each of T_min_apps must be a finite number, not True'), + (dict(T_min_apps=['10']), ValueError, "finite number, not '10'"), + (dict(T_min_apps=[None]), ValueError, 'finite number, not None'), + (dict(T_min_apps=[-1., 5.]), ValueError, '>= 0 K'), + (dict(refine=-1), ValueError, 'refine must be an int >= 0'), + (dict(refine=1.), ValueError, 'refine must be an int >= 0'), + (dict(n_refine=0), ValueError, 'n_refine must be an int >= 1'), + (dict(n_refine=True), ValueError, 'n_refine must be an int >= 1'), + (dict(dT=5.), TypeError, 'unexpected options'), + (dict(h={'H9': 1.}), ValueError, 'name no stream'), + (dict(h_utilities={'steam': 1.}), ValueError, 'name no agent'), + (dict(material='steel'), ValueError, 'material must be'), + (dict(P=math.nan), ValueError, 'P must be a finite number'), + (dict(P='2 bar'), ValueError, 'P must be a finite number'), + (dict(P=True), ValueError, 'P must be a finite number'), + (dict(P=0.), ValueError, 'P must be > 0'), + (dict(P=-1e6), ValueError, 'P must be > 0')): + with pytest.raises(error, match=message): + supertarget(t, **kwargs) + # no finite TAC anywhere (the cooling is unserved): the optimum is + # meaningless, and the sweep says so + # (the unserved points are counted in one warning, not one per point) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + unserved = supertarget(t, [5., 10.], refine=1, cooling_agents=[]) + assert [str(w.message) for w in caught] == [ + '2 of the 2 minimum approach temperatures evaluated leave part of a ' + 'utility target unserved (no agent given can serve it), so their TAC ' + 'is nan', + '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'] + assert all(w.category is RuntimeWarning for w in caught) + assert np.all(np.isnan(unserved['TAC'])) and unserved.T_min_app == 5. + assert len(unserved.records) == 2 # not refined around nan + # outside a sweep, each evaluation warns as before + with pytest.warns(RuntimeWarning, match='no cooling agent can serve'): + t.at(5.).summary(cooling_agents=[]) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + supertarget(t, [5., 10.], refine=0) + assert not [w for w in caught if 'finite TAC' in str(w.message)] + # every stream with an approach contribution: T_min_app changes nothing + # unless the contributions scale with it + monkeypatch.undo() + units = cp_units('test_targeting_sweep_kemp', KEMP, K) + tc = targets_of(units, 10., approach_contributions=KEMP_C) + with pytest.raises(ValueError, match='scale_contributions=True'): + supertarget(tc) + start = time.perf_counter() + sweep = supertarget(tc, scale_contributions=True) + assert time.perf_counter() - start < 20. + assert len(sweep.records) > 60 and sweep.scale_contributions + assert np.ptp(sweep['TAC']) > 0. and np.ptp(sweep['Q_hot']) > 0. + record = sweep.records[0] + assert record == tc.at(record['T_min_app'], scale_contributions=True).summary() + partial = targets_of(units, 10., approach_contributions={'C1': 1.}) + with pytest.raises(ValueError, match='> 0 K with approach contributions'): + supertarget(partial, [0., 5.]) + assert np.ptp(supertarget(partial, [5., 20.], refine=0)['Q_hot']) > 0. + +def test_sweep_constant(): + # streams that recover no heat (only heaters): every target is the same + # at every T_min_app, so the sweep is not refined, and says so instead + # of flagging its optimum, the first record, at the bound + t = targets('test_targeting_sweep_constant', [('C1', 330., 380., 1.), + ('C2', 300., 350., 2.)], 10.) + sweep = supertarget(t) + assert len(sweep.records) == 40 and np.ptp(sweep['TAC']) == 0. + assert sweep.constant and sweep.at_bound and sweep.T_min_app == 1. + assert repr(sweep) == ('') + # a balanced pair below its threshold (10 K): no utility, and the same + # exchanger whatever the T_min_app + pair = targets('test_targeting_sweep_constant_pair', PAIR, 10.) + below = supertarget(pair, [2., 4., 6., 8.]) + assert len(below.records) == 4 and below.constant + assert np.ptp(below['TAC']) <= 1e-12 * below['TAC'].max() + # past the threshold the TAC changes: refined as before + across = supertarget(pair, [5., 10., 15., 20.], refine=1, n_refine=2) + assert len(across.records) == 6 and not across.constant + # a nan TAC is not constant (nor are the TACs of a single temperature) + with pytest.warns(RuntimeWarning, match='leave part of a utility target unserved'): + unserved = supertarget(pair, [12., 14.], refine=0, cooling_agents=[]) + assert np.isnan(unserved['TAC']).all() and not unserved.constant + # the same finite TACs, the others nan (the cooling past the threshold + # unserved): not refined either, and its optimum, the smallest + # temperature of that flat stretch, is not said to be at the bound + with pytest.warns(RuntimeWarning, match='leave part of a utility target unserved'): + partly = supertarget(pair, [2., 4., 6., 8., 12., 14.], cooling_agents=[]) + assert len(partly.records) == 6 and not partly.constant and partly.at_bound + assert list(np.isfinite(partly['TAC'])) == [True] * 4 + [False] * 2 + assert repr(partly) == ('') + # but one finite TAC among nan ones is no flat stretch: at the bound + with pytest.warns(RuntimeWarning, match='leave part of a utility target unserved'): + one = supertarget(pair, [8., 12., 14.], refine=0, cooling_agents=[]) + assert repr(one).endswith(', at the bound>') + +def test_sweep_refinement(): + # each round evaluates n_refine new temperatures, on both sides of the + # least TAC and never on it: on an evenly spaced grid the bracket is + # symmetric, and an odd n_refine once put one on the optimum itself, so + # n_refine=1 refined nothing + t = targets('test_targeting_sweep_refinement', REF_A, 10.) + grid = np.arange(1., 41.) + coarse = supertarget(t, grid, refine=0) + x = coarse.T_min_app + assert x == 8. and not coarse.at_bound + for n in (1, 2, 3, 8, 9): + new = np.setdiff1d(supertarget(t, grid, refine=1, n_refine=n)['T_min_app'], grid) + assert new.size == n and x not in new + below, above = new[new < x], new[new > x] + # the n + 2 gaps of the bracket shared by its sides (the odd one below) + assert below.size == n - n // 2 and above.size == n // 2 + assert_allclose(np.diff([x - 1., *below, x]), 1. / (below.size + 1), rtol=1e-12) + assert_allclose(np.diff([x, *above, x + 1.]), 1. / (above.size + 1), rtol=1e-12) + assert len(supertarget(t, grid, refine=3, n_refine=n).records) == grid.size + 3 * n + one = supertarget(t, grid, refine=1, n_refine=1) + assert len(one.records) == grid.size + 1 and 7.5 in one['T_min_app'] + # a lopsided bracket: the gaps in proportion to the widths of its sides + assert_allclose(np.setdiff1d(supertarget(t, [5., 10., 20.], refine=1, n_refine=3)['T_min_app'], + [5., 10., 20.]), [7.5, 40. / 3., 50. / 3.], rtol=1e-12) + # at a bound of the sweep, all on its one side, as evenly spaced as before + assert supertarget(t, [10., 20., 30.], refine=0).at_bound + at_bound = supertarget(t, [10., 20., 30.], refine=1, n_refine=3) + assert list(at_bound['T_min_app']) == [10., 12.5, 15., 17.5, 20., 30.] + +# %% Facility and report + +def terminal_area(hx): + """Area [m2] of an exchanger of constant heat capacity streams from its + terminal temperatures, with U = 0.5 kW/m2/K and the process duty.""" + if isinstance(hx, bst.HXprocess): + hot = 0 if hx.ins[0].H > hx.outs[0].H else 1 + hi, ho = hx.ins[hot], hx.outs[hot] + ci, co = hx.ins[1 - hot], hx.outs[1 - hot] + Q = hi.H - ho.H + d1, d2 = hi.T - co.T, ho.T - ci.T + else: + hu = hx.heat_utilities[0] + pi, po = hx.ins[0], hx.outs[0] + ui, uo = hu.inlet_utility_stream.T, hu.outlet_utility_stream.T + Q = abs(po.H - pi.H) + if po.H > pi.H: d1, d2 = ui - po.T, uo - pi.T + else: d1, d2 = po.T - ui, pi.T - uo + LMTD = d1 if abs(d1 - d2) < 1e-9 else (d1 - d2) / math.log(d1 / d2) + return Q / kW / (0.5 * LMTD) + +def test_facility_targets_bitwise(kemp_hxn, kemp_hxn_replaced): + def assert_synthesis_targets(t, HXN): + info = HXN.synthesis_info + assert (t.Q_hot, t.Q_cold) == (info['Q_hot_target'], info['Q_cold_target']) + for k, contributions in enumerate((None, {'C1': 2.})): + units = cp_units(f'test_targeting_facility_{k}', KEMP, K) + HXN = HeatExchangerNetwork('HXN', T_min_app=10., units=units, + approach_contributions=contributions) + before = HXN.targets() # from the units' heat utilities + sys = bst.System.from_units('sys', units=[*units, HXN]) + simulate_strictly(sys) + after = HXN.targets() # from the network's process streams + for t in (before, after): + assert_synthesis_targets(t, HXN) + assert t.labels == ['C1', 'C3', 'H2', 'H4'] + assert t.T_min_app == 10. and t.force_ideal_thermo is False + assert t.approach_contributions == (None if contributions is None + else (2., None, None, None)) + assert all(u is hx for u, hx in zip(after.units, HXN.original_heat_exchangers)) + assert all(a is b for a, b in zip(after.heat_utilities, HXN.original_heat_utils)) + # another minimum approach temperature (the facility's is unchanged) + assert HXN.targets(20.).Q_hot == after.at(20.).Q_hot != after.Q_hot + assert HXN.T_min_app == 10. + assert_synthesis_targets(kemp_hxn[1].targets(), kemp_hxn[1]) + # the units' heat utilities now carry the network's (H2 and C3 are served + # by process exchangers alone), but the targets are those of the + # original streams + units, HXN = kemp_hxn_replaced + assert sorted(u.ID for u in units if not u.heat_utilities[0].duty) == ['C3', 'H2'] + t = HXN.targets() + assert_synthesis_targets(t, HXN) + assert t.labels == ['C1', 'C3', 'H2', 'H4'] + with pytest.raises(RuntimeError, match='before targeting'): + HeatExchangerNetwork('test_targeting_alone').targets() + +def test_facility_targets_reversed_stream(): + # C1 made a cooler after the synthesis, simulated alone or with the + # cached network reused: the targets take it as a hot stream at its + # current state, as those of the units' heat utilities do + for k, cache_network in enumerate((False, True)): + units = cp_units(f'test_targeting_reversed_{k}', KEMP, K) + sys, HXN = simulate_HXN(units, 10., cache_network=cache_network) + assert list(HXN.targets().is_hot) == [False, False, True, True] + C1 = units[0] + C1.T = C1.ins[0].T - 20. + if cache_network: + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + sys.simulate() + assert HXN.original_heat_exchangers[0] is C1 # not synthesized again + else: + C1.simulate() + t = HXN.targets() + assert t.labels == ['C1', 'C3', 'H2', 'H4'] + assert list(t.is_hot) == [True, False, True, True] + fresh = PinchTargets.from_heat_utilities([u.heat_utilities[0] for u in units], 10.) + assert t.Q_hot == pytest.approx(fresh.Q_hot, abs=1e-6) + assert t.Q_cold == pytest.approx(fresh.Q_cold, rel=1e-12) + assert t.Q_cold > 1e6 > HXN.synthesis_info['Q_cold_target'] + +def test_facility_targets_changed_inlet(): + # A feed changed in flow since its unit was simulated: each stream runs + # from its exchanger's inlet now to its outlet as last simulated, so the + # targets mix a new inlet with a former outlet (a state of no process), + # with a RuntimeWarning at the caller's line; the report raises instead + def stale_warnings(f, *args): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + result = f(*args) + return result, [x for x in w if 'differ in flow' in str(x.message)] + units = cp_units('test_targeting_changed_inlet', KEMP, K) + sys, HXN = simulate_HXN(units, 10.) + C1, C3 = units[0], units[2] + assert (C1.ID, C3.ID) == ('C1', 'C3') + hus = [u.heat_utilities[0] for u in units] + simulated, w = stale_warnings(HXN.targets) + assert not w + C1.ins[0].F_mass *= 2. + t, w = stale_warnings(HXN.targets) + assert [str(x.message) for x in w] == [ + "the inlet and outlet of 'C1' differ in flow (its inlet changed since " + 'its unit was simulated?): the targets of HXN take its stream from ' + 'its inlet now to its former outlet, a state of no process; simulate ' + 'the system (or its units) first'] + assert [x.filename for x in w] == [__file__] + # (the stale targets are of neither state) + assert t.Q_hot != simulated.Q_hot + selection, w = stale_warnings(PinchTargets.from_heat_utilities, hus, 10.) + assert [str(x.message) for x in w] == [ + "the inlet and outlet of 'C1' differ in flow (its inlet changed since " + 'its unit was simulated?): the targets take its stream from its inlet ' + 'now to its former outlet, a state of no process; simulate the system ' + '(or its units) first, then select the heat utilities again'] + assert [x.filename for x in w] == [__file__] + assert (selection.Q_hot, selection.Q_cold) == (t.Q_hot, t.Q_cold) + # the report raises, without the warning + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + with pytest.raises(ValueError, match='not of the current states of C1.*simulate the system'): + network_report(HXN) + assert not [x for x in w if 'differ in flow' in str(x.message)] + # two streams: both named + C3.ins[0].F_mass *= 0.5 + stale, w = stale_warnings(HXN.targets) + assert len(w) == 1 and str(w[0].message).startswith( + "the inlets and outlets of 'C1', 'C3' differ in flow (their inlets " + 'changed since their units were simulated?): the targets of HXN take ' + 'their streams') + # the units simulated alone: the targets of the process now, which the + # system's simulation synthesizes to + C1.simulate(); C3.simulate() + now, w = stale_warnings(HXN.targets) + assert not w and now.Q_hot != stale.Q_hot + simulate_strictly(sys) + info = HXN.synthesis_info + assert (now.Q_hot, now.Q_cold) == (pytest.approx(info['Q_hot_target'], rel=1e-12), + pytest.approx(info['Q_cold_target'], rel=1e-12)) + # a change of temperature alone keeps the flows equal: no warning, and + # the targets of a unit that sets its outlet by temperature are right + C1.ins[0].T -= 5. + t, w = stale_warnings(HXN.targets) + assert not w + C1.simulate() + assert HXN.targets().Q_hot == t.Q_hot + # before any simulation of the facility: the targets of the units' heat + # utilities warn (once) + units = cp_units('test_targeting_changed_inlet_alone', KEMP, K) + for u in units: u.simulate() + HXN = HeatExchangerNetwork('HXN', T_min_app=10., units=units) + units[3].ins[0].F_mass *= 1.5 + _, w = stale_warnings(HXN.targets) + assert len(w) == 1 and str(w[0].message).startswith( + "the inlet and outlet of 'H4' differ in flow (its inlet changed") + assert w[0].filename == __file__ + +def test_facility_targets_replaced_callable(): + # replace_unit_heat_utilities overwrites the heat utilities a callable + # reads (the network's utility, or none for H2 and C3, which the process + # exchangers serve): after a simulation the targets take the values that + # simulation resolved, without calling it again + # (H4 ends on chilled water) + cases = [(lambda hu: hu.duty < 0, (None, None, 2., 2.)), + (lambda hu: hu.agent.ID == 'cooling_water', (None, None, 2., None))] + for k, (read, expected) in enumerate(cases): + calls = [] + def f(hu): + calls.append(hu) + return 2. if read(hu) else None + units = cp_units(f'test_targeting_replaced_callable_{k}', KEMP, K) + sys, HXN = simulate_HXN(units, 10., replace_unit_heat_utilities=True, + approach_contributions=f) + assert sorted(u.ID for u in units if not u.heat_utilities[0].duty) == ['C3', 'H2'] + info = HXN.synthesis_info + assert info['status'] == 'mer' + N_calls = len(calls) + t = HXN.targets() + assert len(calls) == N_calls + assert t.approach_contributions == HXN._resolved_contributions == expected + assert (t.Q_hot, t.Q_cold) == (info['Q_hot_target'], info['Q_cold_target']) + assert HXN.targets(20.).Q_hot == t.at(20.).Q_hot + report = network_report(HXN) + assert report['targets']['Q_hot'] == report['Q_hot'] == info['Q_hot_target'] + # a callable set since applies from the next simulation; a mapping + # (resolved by unit) at once + HXN.approach_contributions = lambda hu: 3. + assert HXN.targets().approach_contributions == (None, None, 2., None) + HXN.approach_contributions = {'H2': 3.} + assert HXN.targets().approach_contributions == (None, None, 3., None) + +def test_facility_targets_operating_hours(kemp_hxn): + # the facility's targets price over its system's operating hours (read + # at call time), as the report does, unless operating_hours or tea is + # given + units, HXN = kemp_hxn + sys = HXN.system + previous = sys.operating_hours + try: + sys.operating_hours = None + t = HXN.targets() + assert t.system is sys and t.at(20.).system is sys + s = t.summary() + assert s['operating_cost'] == 8000. * s['utility_cost'] + sys.operating_hours = 8760. + s = t.summary() + assert s['operating_cost'] == 8760. * s['utility_cost'] + assert network_report(HXN)['targets'] == pytest.approx(s, rel=1e-12) + for r in supertarget(t, [5., 10.], refine=0).records: + assert r['operating_cost'] == 8760. * r['utility_cost'] + assert t.summary(operating_hours=100.)['operating_cost'] == 100. * s['utility_cost'] + tea = types.SimpleNamespace(IRR=.1, duration=(0, 10), operating_hours=7920.) + assert t.summary(tea=tea)['operating_cost'] == 7920. * s['utility_cost'] + t.system = None # settable: the default 8000 h again + assert t.summary()['operating_cost'] == 8000. * s['utility_cost'] + finally: + sys.operating_hours = previous + +def test_facility_targets_keys_of_idle_units(): + # film coefficient keys of a unit without a stream in a simulation (no + # duty, as in a Monte Carlo sample, or ignored) have no effect, as the + # facility's approach contribution keys have none; unknown keys still + # raise, and targets made from heat utilities know no other units + sys, HXN, (H1, H2, C1, H3) = liquids('test_targeting_idle_keys') + h, busy_h = {'H3': .5, 'H1': 2.}, {'H1': 2.} + HXN.approach_contributions = {'H3': 2.} + simulate_strictly(sys) + t = HXN.targets() + assert H3 in t.units and t.area_target(h=h) != t.area_target(h=busy_h) + H3.T = H3.ins[0].T # no duty + for ignored in (None, [H2]): + HXN.ignored = ignored + simulate_strictly(sys) + assert HXN._resolved_contributions is None # the key of H3 is idle + t = HXN.targets() + assert H3 not in t.units and H3 in t.key_units + assert t.at(20.).key_units is t.key_units + for keys in (h, h | {'H2': 3.}): + assert t.area_target(h=keys) == t.area_target(h=busy_h | { + k: v for k, v in keys.items() if k == 'H2' and not ignored}) + assert t.summary(h=h) == t.summary(h=busy_h) + assert network_report(HXN, h=h) == network_report(HXN, h=busy_h) + assert supertarget(t, [5., 10.], refine=0, h=h).records == ( + supertarget(t, [5., 10.], refine=0, h=busy_h).records) + for keys in ({'H9': 1.}, {('H3', 'heat_exchanger'): 1.}): + with pytest.raises(ValueError, match='h keys that name no stream'): + t.area_target(h=keys) + with pytest.raises(ValueError, match='different values at the same level'): + t.area_target(h={'H3': 2., H3: 3.}) + direct = PinchTargets.from_heat_utilities(t.heat_utilities) + assert direct.key_units is None + with pytest.raises(ValueError, match="h keys that name no stream: 'H3'"): + direct.area_target(h=h) + # unless given them, as the facility's + given = PinchTargets.from_heat_utilities(t.heat_utilities, + key_units=sys.units) + assert H3 in given.key_units and H3 not in given.units + assert given.area_target(h=h) == given.area_target(h=busy_h) + with pytest.raises(ValueError, match='h keys that name no stream'): + given.area_target(h={'H9': 1.}) + +#: Keys of `network_report`. +REPORT_KEYS = ['T_min_app', 'targets_T_min_app', 'status', 'applied', 'Q_hot', 'Q_cold', + 'utilities', 'utility_cost', 'N_units', 'N_process_units', 'N_utility_units', + 'area', 'area_biosteam', 'exchanger_areas', 'purchase_cost', 'installed_cost', + 'annualized_capital_cost', 'operating_cost', 'TAC', 'targets'] + +def test_network_report(kemp_hxn): + units, HXN = kemp_hxn + report = network_report(HXN) + assert list(report) == REPORT_KEYS + assert report['applied'] is True and report['status'] == 'mer' + assert report['T_min_app'] == report['targets_T_min_app'] == 10. + exchangers = HXN.new_HXs + HXN.new_HX_utils + assert report['installed_cost'] == pytest.approx( + sum(hx.installed_cost for hx in exchangers), rel=1e-12) + assert report['purchase_cost'] == pytest.approx( + sum(hx.purchase_cost for hx in exchangers), rel=1e-12) + sized = [hx for hx in exchangers if 'Area' in hx.design_results] + assert report['area_biosteam'] == pytest.approx( + sum(hx.design_results['Area'] for hx in sized) / 10.763, rel=1e-12) + # like-for-like areas: the medium pressure steam heater (efficiency 0.9) + # is sized on the process duty + areas = report['exchanger_areas'] + assert sorted(areas) == sorted(hx.ID for hx in sized) + for hx in sized: + assert areas[hx.ID][0] == pytest.approx(terminal_area(hx), rel=1e-9), hx.ID + MP = [hx for hx in HXN.new_HX_utils if hx.ID in areas + and hx.heat_utilities[0].agent.ID == 'medium_pressure_steam'] + assert len(MP) == 1 and MP[0].heat_utilities[0].duty == pytest.approx(72000. / 0.9) + assert round(report['area'], 2) == 54.06 + assert report['area'] < report['targets']['area'] + # the network has fewer units than the estimate (H2 and C3 balance) + assert (report['N_units'], report['N_process_units'], report['N_utility_units']) == (6, 4, 2) + assert report['targets']['N_units'] == 7 + assert report['Q_hot'] >= report['targets']['Q_hot'] * (1 - 1e-9) + assert report['Q_hot'] == pytest.approx(20 * kW, rel=1e-9) + assert report['Q_cold'] == pytest.approx(60 * kW, rel=1e-9) + assert sorted(report['utilities']) == ['chilled_water', 'medium_pressure_steam'] + steam = report['utilities']['medium_pressure_steam'] + assert steam['duty'] == pytest.approx(20 * kW) and steam['utility_duty'] == pytest.approx(20 * kW / 0.9) + assert report['utility_cost'] == pytest.approx( + sum(u['cost'] for u in report['utilities'].values()), rel=1e-12) + assert report['utility_cost'] > report['targets']['utility_cost'] + crf = _economics()[0] + assert report['annualized_capital_cost'] == crf * report['installed_cost'] + assert report['operating_cost'] == 8000. * report['utility_cost'] + assert report['TAC'] == report['annualized_capital_cost'] + report['operating_cost'] + assert report['targets'] == HXN.targets().summary() + # options: film coefficients and the system's operating hours + halved = network_report(HXN, h=2., h_utilities=2.) + assert halved['area'] == pytest.approx(report['area'] / 2, rel=1e-12) + assert halved['targets']['area'] == pytest.approx(report['targets']['area'] / 2, rel=1e-12) + HXN.system.operating_hours = 4000. + try: + hours = network_report(HXN) + assert hours['operating_cost'] == 4000. * report['utility_cost'] + assert hours['targets']['operating_cost'] == 4000. * report['targets']['utility_cost'] + assert network_report(HXN, operating_hours=100.)['operating_cost'] == ( + 100. * report['utility_cost']) + finally: + HXN.system.operating_hours = None + with pytest.raises(TypeError, match='unexpected options'): + network_report(HXN, hours=1.) + with pytest.raises(RuntimeError, match='simulate the heat exchanger network'): + network_report(HeatExchangerNetwork('test_targeting_report_alone')) + # targets of the same streams in the facility's stream order (KEMP's own + # order arranges like the facility's), and of other ones + hus = [u.heat_utilities[0] for u in units] + same = PinchTargets.from_heat_utilities(hus, 10.) + assert network_report(HXN, same)['targets'] == report['targets'] + with pytest.raises(ValueError, match="of the network's process streams"): + network_report(HXN, PinchTargets.from_heat_utilities(hus[::-1], 10.)) + with pytest.raises(ValueError, match='force_ideal_thermo'): + network_report(HXN, PinchTargets.from_heat_utilities(hus, 10., force_ideal_thermo=True)) + inlet = HXN.original_heat_exchangers[0].ins[0] + T = inlet.T + try: # a stale state + inlet.T = T + 1. + with pytest.raises(ValueError, match='current states'): + network_report(HXN, same) + finally: + inlet.T = T + assert network_report(HXN, same)['targets'] == report['targets'] + # no process match: the report describes the utility exchangers + rows = [('H1', 320., 300., 1.), ('C1', 330., 340., 1.)] + _, alone = simulate_HXN(cp_units('test_targeting_report_utilities', rows), 10.) + report = network_report(alone) + assert report['applied'] is False and alone.installed_costs['Heat exchangers'] == 0. + assert (report['N_units'], report['N_process_units']) == (2, 0) + for hx in alone.new_HX_utils: + assert report['exchanger_areas'][hx.ID][0] == pytest.approx(terminal_area(hx), rel=1e-9) + assert report['installed_cost'] == sum(hx.installed_cost for hx in alone.new_HX_utils) > 0. + # a zero (or crossed) driving force + with pytest.warns(RuntimeWarning, match='HX transfers heat at a driving force of 0 K'): + assert _profile_area(np.array([0., 1.]), np.array([5., 0.]), 2., 'HX') == math.inf + assert _profile_area(np.array([0., kW]), np.array([5., 5.]), 2., 'HX') == 2. / 5. + +def flashed_T(stream, H): + """Temperature [K] of a copy of `stream` at equilibrium at the enthalpy + `H` [kJ/hr] and its own pressure.""" + s = _copy(stream) + s.vle(H=H, P=s.P) + return s.T + +def independent_area(hx, n=41): + """ + Area [m2] of an exchanger of a network with films of 1 kW/m2/K on both + sides, integrated independently of the targets: the process duty in n - + 1 equal parts, the temperatures of its streams at each boundary from + flashes of copies of its own outlet and inlet streams (on a branch, + the branch's flow), log-mean in between; a utility's temperature linear + in the duty from its inlet (at the process outlet) to its outlet. + """ + if isinstance(hx, bst.HXprocess): + hot = 0 if hx.ins[0].H > hx.outs[0].H else 1 + hot_in, cold_out = hx.ins[hot], hx.outs[1 - hot] + x = np.linspace(0., hot_in.H - hx.outs[hot].H, n) + dT = np.array([flashed_T(hot_in, hot_in.H - q) - flashed_T(cold_out, cold_out.H - q) + for q in x]) + else: + hu = hx.heat_utilities[0] + s_in, s_out = hx.ins[0], hx.outs[0] + heating = s_out.H > s_in.H + x = np.linspace(0., abs(s_out.H - s_in.H), n) + T = np.array([flashed_T(s_out, s_out.H - q if heating else s_out.H + q) for q in x]) + T_a, T_b = hu.inlet_utility_stream.T, hu.outlet_utility_stream.T + T_u = T_a + (T_b - T_a) * x / x[-1] + dT = T_u - T if heating else T - T_u + d1, d2 = dT[:-1], dT[1:] + LMTD = np.where(np.abs(d1 - d2) <= 1e-9 * d1, d1, (d1 - d2) / np.log(d1 / d2)) + return float(np.sum(np.diff(x) / LMTD)) * 2. / kW + +def changes_phase(hx): + """Whether a stream of `hx` leaves it at another vapor fraction.""" + return any(abs(s_in.vapor_fraction - s_out.vapor_fraction) > 1e-6 + for s_in, s_out in zip(hx.ins, hx.outs)) + +def test_network_report_independent_areas(quickstart_hxn): + # The report integrates each exchanger along the curves of its streams + # in the targets; an independent integration on the network's own + # streams agrees, across the breakpoints of a phase change inside an + # exchanger and on the branches of a split (a branch stage takes its + # fraction of the whole stream's curve). The quickstart network boils + # and condenses streams in process and utility exchangers alike. + _, split, _ = liquids('test_targeting_report_split', stream_splitting=True) + branches = {st.unit.ID: st.fraction for lc in split.stream_life_cycles + for st in lc.life_cycle if st.fraction != 1.} + assert sorted(branches) == ['HX_0_2_hs', 'HX_0_3_hs'] + assert_allclose(sorted(branches.values()), [0.4799, 0.5201], atol=1e-4) + for HXN, rtol in ((split, 1e-4), (quickstart_hxn[1], 1e-3)): + report = network_report(HXN) + exchangers = {hx.ID: hx for hx in HXN.new_HXs + HXN.new_HX_utils} + areas = report['exchanger_areas'] + assert len(areas) == report['N_units'] + for ID, (area, _) in areas.items(): + assert area == pytest.approx(independent_area(exchangers[ID]), rel=rtol), ID + if HXN is not split: + boiling = [ID for ID in areas if changes_phase(exchangers[ID])] + assert any(ID.startswith('HX_') for ID in boiling) + assert any(ID.startswith('Util_') for ID in boiling) + +def test_network_report_crossed_utility(): + # A vacuum condenser: steam at 10.6 kPa (dew point 320.10 K) condensed + # to 317 K. biosteam heats its cooling water to the limit, 324.82 K, + # since the process inlet less dT is above it: the terminal + # differences are positive, but the water's line crosses the + # condensing stretch inside the exchanger. Its area is inf, and so the + # network's, with a warning giving the cause; biosteam's area, the + # costs and the targets stay finite. + bst.main_flowsheet.set_flowsheet('test_targeting_report_crossed') + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + vapor = bst.Stream('V_in', Water=100., T=345., P=10600., phase='g') + liquid = bst.Stream('L_in', Water=5., T=300., P=101325., phase='l') + H1 = bst.HXutility('H1', ins=vapor, T=317., rigorous=True) + C1 = bst.HXutility('C1', ins=liquid, T=305., rigorous=False) + dew = vapor.dew_point_at_P().T + sys, HXN = simulate_HXN([H1, C1], 5.) + condenser, = [hx for hx in HXN.new_HX_utils if hx.total_heat_transfer > 1e-12] + hu = condenser.heat_utilities[0] + water = bst.HeatUtility.get_agent('cooling_water') + assert hu.agent.ID == 'cooling_water' + assert hu.outlet_utility_stream.T == water.T_limit + s_in, s_out = condenser.ins[0], condenser.outs[0] + assert s_in.T - hu.outlet_utility_stream.T > 0. and s_out.T - hu.inlet_utility_stream.T > 0. + assert s_out.T < dew < water.T_limit + # independently: flashes of the outlet put the stream below the water + # inside the exchanger + x = np.linspace(0., s_in.H - s_out.H, 41) + T_u = hu.inlet_utility_stream.T + (water.T_limit - hu.inlet_utility_stream.T) * x / x[-1] + dT = np.array([flashed_T(s_out, s_out.H + q) for q in x]) - T_u + assert dT.min() < -1. + with pytest.warns(RuntimeWarning, match=( + rf'{condenser.ID} transfers heat at a driving force of -[\d.]+ K: its ' + 'cooling_water from 305.37 to 324.82 K, as biosteam specifies it, ' + 'meets or crosses the profile of H1')): + report = network_report(HXN) + areas = report['exchanger_areas'] + assert sorted(areas) == sorted(hx.ID for hx in HXN.new_HXs + [condenser]) + assert areas[condenser.ID][0] == report['area'] == math.inf + assert math.isfinite(areas[condenser.ID][1]) and areas[condenser.ID][1] > 0. + assert all(math.isfinite(a) for ID, (a, _) in areas.items() if ID != condenser.ID) + assert report['area_biosteam'] == pytest.approx(sum(b for _, b in areas.values()), rel=1e-12) + assert math.isfinite(report['TAC']) and math.isfinite(report['installed_cost']) + targets = report['targets'] + assert math.isfinite(targets['area']) and math.isfinite(targets['TAC']) + # the targets keep the water's line inside the grand composite curve + (cooling,) = [u for u in HXN.targets().utility_targets() if not u.heating] + assert cooling.agent.ID == 'cooling_water' + assert cooling.T_utility_out < dew + +def test_network_report_current_states(): + # With cache_network, the network re-simulated at changed states is + # reported at those states + units = cp_units('test_targeting_report_cache', KEMP, K) + sys, HXN = simulate_HXN(units, 10., cache_network=True) + network = HXN.HXN_sys + C1 = units[0] + inlet = C1.ins[0] + inlet.F_mass *= 1.05 + simulate_strictly(sys) + assert HXN.HXN_sys is network + report = network_report(HXN) + assert report['targets'] == HXN.targets().summary() + # (the reused structure no longer reaches the energy targets) + assert report['Q_hot'] > report['targets']['Q_hot'] * (1 + 1e-3) + exchangers = {hx.ID: hx for hx in HXN.new_HXs + HXN.new_HX_utils} + for ID, (area, _) in report['exchanger_areas'].items(): + assert area == pytest.approx(terminal_area(exchangers[ID]), rel=1e-9), ID + # a stream changed since the facility's last simulation (or its unit + # simulated alone): the network is not of the current states + T, F = inlet.T, inlet.F_mass + for change in ('T', 'F', 'unit'): + if change == 'F': inlet.F_mass = 1.5 * F + else: inlet.T = T + 15. + if change == 'unit': C1.simulate() + with pytest.raises(ValueError, match='not of the current states of C1.*simulate the system'): + network_report(HXN) + inlet.T, inlet.F_mass = T, F + C1.simulate() + assert network_report(HXN) == report + # an inlet at the enthalpy reference (298.15 K) has no enthalpy at any + # flow: the flows are compared too + rows = [('H1', 400., 320., 1.), ('C1', 298.15, 370., 1.)] + sys, HXN = simulate_HXN(cp_units('test_targeting_report_reference', rows), 10.) + inlet = HXN.original_heat_exchangers[0].ins[0] + assert inlet.ID == 'C1_in' and inlet.H == 0. + targets = HXN.targets() + inlet.F_mass *= 2. + assert inlet.H == 0. + with pytest.raises(ValueError, match='targets are not of the current states of C1'): + network_report(HXN, targets) + with pytest.raises(ValueError, match='simulate the system'): + network_report(HXN) + # with ideal thermodynamics, on the curves' basis: stale targets, and a + # network older than the targets + sys, HXN, units = liquids('test_targeting_report_ideal', force_ideal_thermo=True) + stale = HXN.targets() + inlet = units[0].ins[0] + inlet.T += 10. + simulate_strictly(sys) + with pytest.raises(ValueError, match='targets are not of the current states of H1'): + network_report(HXN, stale) + targets = HXN.targets() + assert network_report(HXN, targets)['targets'] == network_report(HXN)['targets'] + inlet.T -= 10. + units[0].simulate() + with pytest.raises(ValueError, match='simulate the system'): + network_report(HXN) + with pytest.raises(ValueError, match='targets are not of the current states of H1'): + network_report(HXN, targets) + +def test_network_report_rebuilt_stages(): + # A MultistageCompressor simulated alone after a change of pr rebuilds + # its intercoolers: the network is of the former ones, no longer in the + # process, whose states are consistent but stale; the report raises (by + # default and with explicit targets) until the system is simulated + bst.main_flowsheet.set_flowsheet('test_targeting_report_rebuilt') + bst.settings.set_thermo(['Water', 'Methane'], cache=True) + gas = bst.Stream('gas', Methane=100., units='kmol/hr', T=300., + P=101325., phase='g') + K1 = bst.MultistageCompressor('K1', ins=gas, pr=2., n_stages=3, eta=0.7) + cold = bst.Stream('cold', Water=200., units='kmol/hr', T=290., P=5e5) + C1 = bst.HXutility('C1', ins=cold, T=350., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=5.) + sys = bst.System.from_units('test_targeting_report_rebuilt', + units=[K1, C1, HXN]) + simulate_strictly(sys) + report = network_report(HXN) + former = list(K1.hxs) + assert set(HXN.original_heat_exchangers) == {C1, *former} + assert report['targets']['Q_hot'] > 0. + K1.pr = 3. + K1.simulate() + assert not set(K1.hxs) & set(former) + # the facility's targets are still of the former stages (labeled by + # K1), at their former states, and say so + rebuilt = (r"the targets of HXN are of the former state of K1 - K1_H\d, " + r"which is no longer one of the facility's units nor an " + r"auxiliary of one \(rebuilt .*\): simulate the system") + with pytest.warns(RuntimeWarning, match=rebuilt): + targets = HXN.targets() + assert set(targets.units) == {C1, *former} + assert targets.summary() == report['targets'] + message = (r"the network of HXN is of K1 - K1_H\d, which is no longer one " + r"of the facility's units nor an auxiliary of one \(rebuilt " + r".*\): simulate the system") + for args in ((HXN,), (HXN, targets)): # it raises, without the warning + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + with pytest.raises(ValueError, match=message): + network_report(*args) + assert not [w for w in caught if 'former state' in str(w.message)] + # simulated again: the network and the report are of the new stages + # (hotter, whose heat now covers the cold stream's), and the targets + # warn no more + simulate_strictly(sys) + assert set(HXN.original_heat_exchangers) == {C1, *K1.hxs} + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + report = network_report(HXN) + HXN.targets() + assert report['targets'] == HXN.targets().summary() + assert report['targets']['Q_hot'] == 0. and report['targets']['Q_cold'] > 0. + +@pytest.mark.parametrize('replace', [False, True]) +def test_network_report_of_exchangers_held_through_heat_utilities(replace): + # a unit that keeps its heater outside its auxiliary_unit_names and + # takes its heat utilities: right after a simulation, the facility's + # targets and report are of that heater, still at hand (labeled by the + # unit), and neither warns nor raises; a unit that builds its heater + # anew at every simulation, simulated alone, leaves the network of the + # former heater, and they say so until the system is simulated again + bst.main_flowsheet.set_flowsheet('test_targeting_report_kept_heater') + bst.settings.set_thermo(['Water'], cache=True) + feed = bst.Stream('feed', Water=300., units='kmol/hr', T=300.) + U = KeptHeater('U', ins=feed, T=360., rebuild=True) + hot = bst.Stream('hot', Water=200., units='kmol/hr', T=400.) + H1 = bst.HXutility('H1', ins=hot, T=310., rigorous=False) + HXN = HeatExchangerNetwork('HXN', T_min_app=5., + replace_unit_heat_utilities=replace) + sys = bst.System.from_units('test_targeting_report_kept_heater', + units=[U, H1, HXN]) + + def check_clean(): + simulate_strictly(sys) + assert set(HXN.original_heat_exchangers) == {U.hx, H1} + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + targets = HXN.targets() + report = network_report(HXN) + assert dict(zip(targets.units, targets.labels)) == { + U.hx: 'U - U_hx', H1: 'H1'} + assert report['targets'] == targets.summary() + assert targets.Q_hot == HXN.synthesis_info['Q_hot_target'] + return report + + check_clean() + report = check_clean() # the same once simulated again + former = U.hx + U.simulate() + assert U.hx is not former + with pytest.warns(RuntimeWarning, + match=r'the targets of HXN are of the former state of ' + r'U - U_hx, which is no longer'): + targets = HXN.targets() + assert targets.summary() == report['targets'] + with pytest.raises(ValueError, match=r'the network of HXN is of U - U_hx, ' + r'which is no longer'): + network_report(HXN) + check_clean() + +@pytest.mark.parametrize('contributions', [None, {'H1': 3., 'C1': 2.}]) +def test_network_report_cached_split_network(contributions): + # A split network the facility reuses (cache_network) with its + # utilities written over the units' (replace_unit_heat_utilities), with + # and without approach contributions: the report's targets are the + # facility's, at the synthesis and at changed feeds after the network + # is re-simulated, and each area agrees with an independent integration + name = f'test_targeting_report_cached_split_{contributions is None}' + sys, HXN, units = liquids(name, stream_splitting=True, cache_network=True, + replace_unit_heat_utilities=True, + approach_contributions=contributions) + network = HXN.HXN_sys + report = network_report(HXN) + targets = HXN.targets() + expected = dict.fromkeys(['H1', 'H2', 'C1', 'H3'], None) | (contributions or {}) + assert dict(zip(targets.labels, targets.approach_contributions + or [None] * 4)) == expected + assert report['targets'] == targets.summary() + for key in ('Q_hot', 'Q_cold'): + assert report['targets'][key] == pytest.approx( + HXN.synthesis_info[key + '_target'], rel=1e-6), key + assert any(st.fraction != 1. for lc in HXN.stream_life_cycles + for st in lc.life_cycle) + units[2].ins[0].F_mass *= 1.05 + simulate_strictly(sys) + assert HXN.HXN_sys is network + changed = network_report(HXN) + assert changed['targets'] == HXN.targets().summary() + assert changed['targets']['Q_hot'] > report['targets']['Q_hot'] * (1 + 1e-3) + exchangers = {hx.ID: hx for hx in HXN.new_HXs + HXN.new_HX_utils} + for ID, (area, _) in changed['exchanger_areas'].items(): + assert area == pytest.approx(independent_area(exchangers[ID]), rel=1e-4), ID + +def test_network_report_micro_kelvin_stream(): + # A large stream that changes temperature by a micro-kelvin still has + # duty (above the facility's Qmin); the network's rigorous utility + # exchanger reaches its outlet only to the flash tolerance, which is + # more than 1e-6 of that duty: the network is of the current states + bst.main_flowsheet.set_flowsheet('test_targeting_report_micro_kelvin') + 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', 420., 320., 5e5, Water=60.), + process_stream('C1', 310., 380., 5e5, Water=50.), + process_stream('H2', 305.150001, 305.15, 101325., Water=20000.)] + sys, HXN = simulate_HXN(units, 10.) + H2 = units[2] + assert -2. < H2.heat_utilities[0].duty < -1. # (above Qmin) + report = network_report(HXN) + assert report['targets'] == HXN.targets().summary() + # a set point changed by 0.1 mK is a change of states + H2.T -= 1e-4 + H2.simulate() + with pytest.raises(ValueError, match='not of the current states of H2.*simulate the system'): + network_report(HXN) + H2.T += 1e-4 + simulate_strictly(sys) + assert network_report(HXN)['targets'] == report['targets'] + +def test_network_report_cheaper_than_utility_target(): + # The utility cost target is the cost of biosteam's placement of its + # agents (list order, fuel last), not a minimum: C1 ends above high + # pressure steam, so the network heats it with natural gas from its + # match on, which biosteam's default prices make cheaper per kJ than + # the steam the targets place below the fuel (the case of the Notes of + # network_report and of concepts.rst) + _, HXN = simulate_HXN(cp_units('test_targeting_report_cheaper', FURNACE), 10.) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + report = network_report(HXN) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + targets = report['targets'] + assert report['status'] == 'mer' + assert report['Q_hot'] == pytest.approx(targets['Q_hot'], rel=1e-9) + assert report['Q_cold'] == pytest.approx(targets['Q_cold'], rel=1e-9) + [furnace] = [hx for hx in HXN.new_HX_utils if hx.heat_utilities[0].duty > 0.] + assert furnace.heat_utilities[0].agent.ID == 'natural_gas' + assert furnace.ins[0].T == pytest.approx(390.) and furnace.outs[0].T == pytest.approx(520.) + gas = report['utilities']['natural_gas'] + assert gas['duty'] == pytest.approx(report['Q_hot'], rel=1e-9) + heating = [u for u in HXN.targets().utility_targets() if u.heating] + assert [u.ID for u in heating] == ['low_pressure_steam', 'medium_pressure_steam', + 'high_pressure_steam', 'natural_gas'] + assert heating[-1].T_in == pytest.approx(503.991) # fuel above high pressure steam + # per kJ of process heat [USD/kJ] + assert gas['cost'] / gas['duty'] == pytest.approx(5.165e-6, rel=1e-3) + assert [round(u.cost / u.duty, 8) for u in heating[:3]] == [6.47e-6, 8.46e-6, 1.16e-5] + assert gas['cost'] < sum(u.cost for u in heating) + assert (round(report['utility_cost'], 2), round(targets['utility_cost'], 2)) == (3.14, 4.56) + assert report['TAC'] < targets['TAC'] + # natural gas is cheaper per kJ than any steam for a furnace heating + # from below about 565 K + LP = heating[0].cost / heating[0].duty + gas_per_kJ = lambda T_in: fresh_price(10 * kW, T_in, T_in + 10., + agent('natural_gas'))[0] / (10 * kW) + assert gas_per_kJ(560.) < LP < gas_per_kJ(570.) + +def test_no_side_effects(kemp_hxn): + units, HXN = kemp_hxn + # the furnace case of test_capital_target_formula: the network heats C1 + # in a furnace + _, furnace_HXN = simulate_HXN(cp_units('test_targeting_side_effects', FURNACE), 10.) + report = network_report(furnace_HXN) + assert 'natural_gas' in report['utilities'] + furnace = [hx.ID for hx in furnace_HXN.new_HX_utils + if hx.heat_utilities and hx.heat_utilities[0].agent + and hx.heat_utilities[0].agent.isfuel] + assert len(furnace) == 1 and report['exchanger_areas'][furnace[0]] == (0., 0.) + gas = agent('natural_gas') + def furnace_cost(): + # priced with the agent's own stream dump (on which it leaves its + # streams), in a temporary flowsheet + with bst.Flowsheet('test_targeting_furnace_cost'): + hu = bst.HeatUtility() + hu(10 * kW, 450., 500., agent=gas) + cost = hu.cost + hu.empty() + return cost + furnace_cost() + before = furnace_cost() + facilities = (HXN, furnace_HXN) + Q = [[float(hx.Q).hex() for hx in facility.new_HXs] for facility in facilities] + agents = bst.HeatUtility.heating_agents + bst.HeatUtility.cooling_agents + dumps = [(a.utility_stream_dump, list(a.utility_stream_dump)) for a in agents] + registered = lambda: {ID: id(s) for ID, s in bst.main_flowsheet.stream.data.items()} + registry = registered() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + for facility in facilities: + facility.targets().summary() + network_report(facility) + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + assert registered() == registry + for a, (dump, items) in zip(agents, dumps): + assert a.utility_stream_dump is dump and a.utility_stream_dump == items + assert furnace_cost() == before + for facility in facilities: simulate_strictly(facility.system) + assert [[float(hx.Q).hex() for hx in facility.new_HXs] for facility in facilities] == Q + +# %% Plots + +def test_plot_composite_curves_gids(tmp_path): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + t = targets('test_targeting_plots', REF_A, 10.) + for kind in ('real', 'shifted', 'balanced'): + fig, ax = plot_composite_curves(t, kind=kind) + try: + lines = {a.get_gid(): a for a in ax.get_lines()} + if kind == 'balanced': + hot_T, hot_H, cold_T, cold_H = t.balanced_composite_curves() + hot_line = lines['Hot balanced composite'] + cold_line = lines['Cold balanced composite'] + labels = _gid_artists(ax, 'Utility:') + assert len(labels) == 4 + assert not _gid_artists(ax, 'Fuel') + else: + hot_T, hot_H, cold_T, cold_H = t.composite_curves(kind == 'shifted') + hot_line = lines['Hot composite'] + cold_line = lines['Cold composite'] + assert len(_gid_artists(ax, 'Heat recovered')) == 1 + assert len(_gid_artists(ax, 'Hot utility')) == 1 + assert len(_gid_artists(ax, 'Cold utility')) == 1 + assert_allclose(hot_line.get_xdata(), hot_H, rtol=0, atol=0) + assert_allclose(hot_line.get_ydata(), hot_T - 273.15, rtol=0, atol=0) + assert_allclose(cold_line.get_xdata(), cold_H, rtol=0, atol=0) + assert_allclose(cold_line.get_ydata(), cold_T - 273.15, rtol=0, atol=0) + pinch = _gid_artists(ax, 'Pinch') + assert len(pinch) == (kind == 'shifted') + if pinch: + assert pinch[0].get_xdata()[0] == pytest.approx(100 * kW) + assert pinch[0].get_ydata()[0] == pytest.approx(390. - 273.15) + finally: + plt.close(fig) + with pytest.raises(ValueError, match="kind must be"): + plot_composite_curves(t, kind='hot') + # utilities are those of the balanced curves only, which need them (no + # figure is made before the error) + figures = plt.get_fignums() + for kind in ('real', 'shifted'): + with pytest.raises(ValueError, match=f'the {kind} composite curves ' + r"take no utilities \(those of kind='balanced'\)"): + plot_composite_curves(t, kind=kind, utilities=t.utility_targets()) + with pytest.raises(ValueError, match='the balanced composite curves are ' + 'those of their utilities, not False'): + plot_composite_curves(t, kind='balanced', utilities=False) + assert plt.get_fignums() == figures + # fuel is labeled, not drawn + furnace = targets('test_targeting_plots_fuel', [('C1', 330., 520., 1.), + ('H1', 400., 300., 1.)], 10.) + file = tmp_path / 'balanced.png' + fig, ax = plot_composite_curves(furnace, kind='balanced', file=str(file)) + try: + assert file.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n' + fuel = _gid_artists(ax, 'Fuel') + assert len(fuel) == 1 and 'natural_gas (furnace)' in fuel[0].get_text() + hot = [a for a in ax.get_lines() if a.get_gid() == 'Hot balanced composite'][0] + assert np.all(np.isfinite(hot.get_ydata())) + finally: + plt.close(fig) + +def test_plot_gcc_gids(): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + t = targets('test_targeting_plot_gcc', REF_A, 10.) + utilities = t.utility_targets() + fig, ax = plot_grand_composite_curve(t, utilities) + try: + T, H = t.grand_composite_curve() + gcc, = _gid_artists(ax, 'GCC') + assert_allclose(gcc.get_xdata(), H, rtol=0, atol=0) + assert_allclose(gcc.get_ydata(), T - 273.15, rtol=0, atol=0) + pinch, = _gid_artists(ax, 'Pinch') + assert (pinch.get_xdata()[0], pinch.get_ydata()[0]) == (0., 390. - 273.15) + segments = {a.get_gid(): a for a in _gid_artists(ax, 'Utility:')} + assert len(segments) == 4 + for u in utilities: + x, y = segments['Utility:' + u.ID].get_data() + assert_allclose(y, u.level - 273.15, rtol=1e-12) + assert x[1] - x[0] == pytest.approx(u.duty, rel=1e-12) + assert len(_gid_artists(ax, 'Label:')) == 4 + finally: + plt.close(fig) + fig, ax = plot_grand_composite_curve(t, utilities=False) + try: + assert not _gid_artists(ax, 'Utility:') and not _gid_artists(ax, 'Label:') + finally: + plt.close(fig) + +def test_plot_readability(quickstart): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + def boxes(fig, artists): + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + return [a.get_window_extent(renderer) for a in artists] + # fuel above high pressure steam: the label of the furnace sits above + # the hot end of the cold curve, whose top its heat closes, and over + # that of the top flat, which runs into it there + furnace = targets('test_targeting_plot_readable_fuel', FURNACE, 10.) + fig, ax = plot_composite_curves(furnace, kind='balanced') + try: + fuel, = _gid_artists(ax, 'Fuel') + steam, = _gid_artists(ax, 'Utility:high_pressure_steam') + hot_T, hot_H, cold_T, cold_H = furnace.balanced_composite_curves() + assert fuel.xy == pytest.approx((cold_H[-1], 520. - K), rel=1e-9) + assert steam.xy[0] < fuel.xy[0] and steam.xy[1] < fuel.xy[1] + fuel, steam, frame = boxes(fig, [fuel, steam, ax]) + assert fuel.x0 < steam.x1 and steam.x0 < fuel.x1 # stacked + assert not fuel.overlaps(steam) and fuel.y0 >= steam.y1 - 1. + assert frame.y1 >= fuel.y1 + finally: + plt.close(fig) + # the pinch is marked only where the shifted composites touch: not for a + # threshold problem whose composites end apart, nor for hot streams + # only (whose empty cold composite stays out of the legend); a balanced + # pair touches at the top without any utility + threshold = targets('test_targeting_plot_threshold', [('H1', 400., 300., 2.), + ('C1', 330., 380., 1.)], 10.) + hot = targets('test_targeting_plot_hot', [('H1', 400., 300., 1.), + ('H2', 380., 320., 1.)], 10.) + pair = targets('test_targeting_plot_pair', PAIR, 10.) + assert threshold.Q_hot == hot.Q_hot == pair.Q_hot == 0. + for t, pinch, legend in ((threshold, False, ['heat recovered', 'hot composite', 'cold composite']), + (hot, False, ['hot composite']), + (pair, True, ['heat recovered', 'hot composite', 'cold composite', 'pinch'])): + fig, ax = plot_composite_curves(t, kind='shifted') + try: + assert len(_gid_artists(ax, 'Pinch')) == pinch + assert [x.get_text() for x in ax.get_legend().get_texts()] == legend + finally: + plt.close(fig) + # no utility on a threshold problem's other side: a plain 0 + for kind in ('real', 'shifted'): + fig, ax = plot_composite_curves(threshold, kind=kind) + try: + label, = _gid_artists(ax, 'Hot utility') + assert label.get_text() == 'hot utility 0 kJ/hr' + finally: + plt.close(fig) + # the unserved remainders, without which the balanced curves end apart: + # the cold one starts at the unserved cooling, the coldest of its + # members, so the curves drawn do not cross (they keep dT apart at + # cooling water, as when it is all served) + t = targets('test_targeting_plot_unserved', REF_A, 10.) + texts = {True: ('unserved heating 1.18E5 kJ/hr (not drawn)\n' + 'unserved cooling 4.45E4 kJ/hr (not drawn)'), + False: 'unserved cooling 4.45E4 kJ/hr (not drawn)'} + def noted(fig, ax): + """The note on the unserved remainders inside the figure, under the + axes and the decorations of the x axis, and clear of the labels and + of the legend.""" + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + note, = _gid_artists(ax, 'Utility:unserved') + box = note.get_window_extent(renderer) + page = fig.bbox + assert (page.x0 <= box.x0 and box.x1 <= page.x1 + and page.y0 <= box.y0 and box.y1 <= page.y1) + above = [ax, ax.xaxis.label, ax.xaxis.get_offset_text(), + *[x for x in ax.get_xticklabels() if x.get_text()]] + assert all(box.y1 < a.get_window_extent(renderer).y0 for a in above) + others = [a for a in _gid_artists(ax, 'Utility:') + _gid_artists(ax, 'Fuel') + if a is not note] + [ax.get_legend()] + assert len(others) >= 3 + for a in others: + assert not box.overlaps(a.get_window_extent(renderer)), a + for LP_only, size in ((True, None), (False, None), (False, (4.5, 4.2))): + heating = [agent('low_pressure_steam')] if LP_only else None + with pytest.warns(RuntimeWarning, match='left unserved'): + utilities = t.utility_targets(heating, [agent('cooling_water')]) + if size is None: + fig, ax = plot_composite_curves(t, kind='balanced', utilities=utilities) + else: + # of a figure of the user's, where a label of the steam at the + # top of the curves, moved inside the left edge, reaches the + # upper left corner + fig, ax = plt.subplots(figsize=size, layout='constrained') + plot_composite_curves(t, kind='balanced', utilities=utilities, ax=ax) + try: + label, = _gid_artists(ax, 'Utility:unserved') + assert label.get_text() == texts[LP_only] + noted(fig, ax) + hot_curve, = _gid_artists(ax, 'Hot balanced composite') + cold_curve, = _gid_artists(ax, 'Cold balanced composite') + (hot_H, hot_T), (cold_H, cold_T) = hot_curve.get_data(), cold_curve.get_data() + assert hot_H[0] == 0. and cold_H[0] == pytest.approx(12.372 * kW, rel=1e-9) + assert min_vertical_gap(hot_T, hot_H, cold_T, cold_H) == pytest.approx( + bst.HeatUtility.dT, abs=1e-6) + finally: + plt.close(fig) + # at the default size, where the labels of both steams, kept off the + # tall riser of the hot curve under their span, rise to the upper left + # corner of the axes + short = targets('test_targeting_plot_unserved_steam', + [('H0', 634.16, 621.99, 1.101), ('C1', 401.54, 412.56, 2.498), + ('C2', 552.11, 649.10, 1.515)], 10.) + with pytest.warns(RuntimeWarning, match='left unserved'): + utilities = short.utility_targets( + [agent('low_pressure_steam'), agent('high_pressure_steam')], + [agent('chilled_brine'), agent('propane')]) + fig, ax = plot_composite_curves(short, kind='balanced', utilities=utilities) + try: + label, = _gid_artists(ax, 'Utility:unserved') + assert label.get_text() == 'unserved heating 4.81E5 kJ/hr (not drawn)' + noted(fig, ax) + finally: + plt.close(fig) + def readable(fig, ax, labels, curves, legend=True): + """Every one of the `labels` and the legend (if `legend`) inside the + axes and off the `curves` (and the legend off the labels), and the + axis labels inside the figure: whether or not it is saved with a + tight bounding box (or shown in a window).""" + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + within = lambda box, frame: (frame.x0 <= box.x0 and box.x1 <= frame.x1 + and frame.y0 <= box.y0 and box.y1 <= frame.y1) + frame = ax.get_window_extent(renderer) + points = [] + for line in curves: + xy = ax.transData.transform(np.column_stack(line.get_data())) + points += [np.linspace(a, b, 500) for a, b in zip(xy, xy[1:])] + points = np.concatenate(points) + assert labels + named = [(label.get_text(), label) for label in labels] + artists = list(named) + if legend: + legend = ax.get_legend() + assert legend is not None + artists.append(('legend', legend)) + for name, artist in artists: + box = artist.get_window_extent(renderer) + assert within(box, frame), name + inside = ((points[:, 0] >= box.x0) & (points[:, 0] <= box.x1) + & (points[:, 1] >= box.y0) & (points[:, 1] <= box.y1)) + assert not inside.any(), name + if legend: + box = legend.get_window_extent(renderer) + for name, label in named: + assert not label.get_window_extent(renderer).overlaps(box), name + for label in (ax.xaxis.label, ax.yaxis.label, ax.xaxis.get_offset_text()): + assert within(label.get_window_extent(renderer), fig.bbox), label.get_text() + # every utility label inside the axes and off both balanced composites: + # a sensible agent's beside the middle of its line (below right of the + # rising cold curve for cooling water and chilled water, above left of + # the hot curve for a hot oil), a latent one's by its flat and the + # furnace's above the end + oil = bst.UtilityAgent('test_targeting_plot_hot_oil', Water=1, T=480., P=5e6, + phase='l', thermo=bst.HeatUtility.thermo_water, + T_limit=420., heat_transfer_price=1e-6) + oiled = targets('test_targeting_plot_oil', [('C1', 390., 470., 1.), + ('C2', 460., 470., 10.)], 10.) + liquid = liquids('test_targeting_plot_liquids')[1].targets() + # and where a label at its anchor would run past the left edge of the + # axes, moved inside them: the low pressure steam's above a short flat + # near that edge (pyro), and a furnace's that served only a cold + # stream far above the short hot curve (fuel_only), whose label at the + # top of the curves stays clear of that edge and of the cold + # utilities' labels + pyro = targets('test_targeting_plot_pyro', [('C1', 300., 773., 1.), + ('H1', 750., 320., 0.6)], 10.) + fuel_only = targets('test_targeting_plot_fuel_only', [('C1', 600., 700., 1.), + ('H1', 330., 300., 0.5)], 10.) + assert [u.ID for u in fuel_only.utility_targets() if u.heating] == ['natural_gas'] + cases = [(threshold, None, True), (furnace, None, True), + (oiled, oiled.utility_targets([oil], []), True), (liquid, None, True), + (pyro, None, False), (fuel_only, None, True)] + for t, utilities, sensible in cases: + fig, ax = plot_composite_curves(t, kind='balanced', utilities=utilities) + try: + labels = _gid_artists(ax, 'Utility:') + _gid_artists(ax, 'Fuel') + assert sensible == any(not (u.agent.isfuel or u.agent.T_limit is None) + for u in utilities or t.utility_targets()) + curves = [line for line in ax.get_lines() + if 'balanced composite' in (line.get_gid() or '')] + assert len(curves) == 2 + readable(fig, ax, labels, curves) + label_boxes = boxes(fig, labels) + assert all(not a.overlaps(b) for i, a in enumerate(label_boxes) + for b in label_boxes[i + 1:]) + finally: + plt.close(fig) + # the low pressure steam's label of pyro, at its anchor, would start + # left of the axes: moved right just inside them, over the hot curve + fig, ax = plot_composite_curves(pyro, kind='balanced') + try: + steam, = _gid_artists(ax, 'Utility:low_pressure_steam') + box, frame = boxes(fig, [steam, ax]) + anchor = ax.transData.transform(steam.xy) + assert box.x1 > anchor[0] and box.y0 > anchor[1] + assert box.x0 - frame.x0 == pytest.approx(2. * fig.dpi / 72., abs=0.5) + finally: + plt.close(fig) + def in_order(fig, ax): + """The utility labels of the balanced curves of each side in the + order of their anchors (a heating label no lower than that of a + lower level, a cooling one no higher than that of a higher one), + and each beside its anchor (within 1.5 times its height) or with a + leader from its anchor to the nearest point of its box; the number + of leaders.""" + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + pt = fig.dpi / 72. + leaders = {a.get_gid(): a.get_path().vertices for a in _gid_artists(ax, 'Leader:')} + labels = sorted(_gid_artists(ax, 'Utility:'), key=lambda label: label.xy[1]) + labels = [label for label in labels if label.get_gid() != 'Utility:unserved'] + heating = [label for label in labels if label.get_ha() == 'right'] + cooling = [label for label in labels if label.get_ha() == 'left'] + for side, up in ((heating, True), (cooling, False)): + boxes = [label.get_window_extent(renderer) for label in side] + ends = [box.y0 if up else box.y1 for box in boxes] + assert all(a <= b + 2. * pt for a, b in zip(ends, ends[1:])), ends + drawn = 0 + for label in labels: + box = label.get_window_extent(renderer) + anchor = ax.transData.transform(label.xy) + near = np.clip(anchor, (box.x0, box.y0), (box.x1, box.y1)) + ends = leaders['Leader:' + label.get_gid().split(':', 1)[1]] + if math.dist(anchor, near) <= 1.5 * box.height: + assert not len(ends), label.get_text() + else: + assert len(ends) == 2, label.get_text() + assert math.dist(ends[0], anchor) <= 2. * pt + assert math.dist(ends[1], near) <= 2. * pt + drawn += 1 + return drawn + # labels crowding one end of the curves (refrigerants at the cold end): + # stacked in a column below right of their flats, in the room made for + # it, inside the axes and off the x tick labels + crowded = [targets('test_targeting_plot_crowded', [('C0', 367., 382., 2.47), + ('H1', 877., 796., 1.7), + ('C2', 482., 892., 2.29), + ('H3', 651., 237., 0.83)], 10.), + targets('test_targeting_plot_crowded_2', [('C0', 670., 760., 0.41), + ('H1', 828., 240., 2.72), + ('C2', 348., 848., 1.4), + ('H3', 578., 545., 1.2)], 10.)] + for t in crowded: + assert sum(not u.heating for u in t.utility_targets()) == 5 + fig, ax = plot_composite_curves(t, kind='balanced') + try: + labels = _gid_artists(ax, 'Utility:') + _gid_artists(ax, 'Fuel') + readable(fig, ax, labels, [line for line in ax.get_lines() + if 'balanced composite' in (line.get_gid() or '')]) + label_boxes = boxes(fig, labels) + assert all(not a.overlaps(b) for i, a in enumerate(label_boxes) + for b in label_boxes[i + 1:]) + ticks = boxes(fig, [x for x in ax.get_xticklabels() if x.get_text()]) + assert all(not a.overlaps(b) for a in label_boxes for b in ticks) + in_order(fig, ax) + finally: + plt.close(fig) + # on narrower axes (of a figure of the user's, alone or beside another), + # where the labels of pyro moved inside the left edge rise over the + # curve: in the order of the levels, each with a leader from its flat + for size, columns in (((4.5, 4.2), 1), ((5., 4.2), 1), ((10., 4.2), 2)): + fig, axes = plt.subplots(1, columns, figsize=size, layout='constrained', + squeeze=False) + try: + ax = axes[0, 0] + plot_composite_curves(pyro, kind='balanced', ax=ax) + if columns > 1: plot_composite_curves(pyro, kind='real', ax=axes[0, 1]) + labels = _gid_artists(ax, 'Utility:') + _gid_artists(ax, 'Fuel') + readable(fig, ax, labels, [line for line in ax.get_lines() + if 'balanced composite' in (line.get_gid() or '')]) + assert in_order(fig, ax) >= 2 + finally: + plt.close(fig) + # at the default size, beside their flats or a step above another + for t in (pyro, furnace, liquid): + fig, ax = plot_composite_curves(t, kind='balanced') + try: + assert in_order(fig, ax) == 0 + finally: + plt.close(fig) + # the utility labels of the real and shifted composites inside the axes + # and off the curves, also where the hot curve's hot end is the top of + # the plot, at the right of it (KEMP, hot streams only); the legend off + # them too, also where the cold composite of a threshold problem that + # needs only cooling runs into the lower right corner + kemp = targets_of(cp_units('test_targeting_plot_kemp', KEMP, K), 10.) + cooling = targets('test_targeting_plot_cooling', [('H1', 420., 330., 3.), + ('C1', 300., 340., 1.)], 10.) + assert cooling.Q_hot == 0. and cooling.Q_cold > 0. + for t in (kemp, hot, threshold, cooling, quickstart): + for kind in ('real', 'shifted'): + fig, ax = plot_composite_curves(t, kind=kind) + try: + labels = _gid_artists(ax, 'Hot utility') + _gid_artists(ax, 'Cold utility') + assert len(labels) == 2 + readable(fig, ax, labels, _gid_artists(ax, 'Hot composite') + + _gid_artists(ax, 'Cold composite')) + finally: + plt.close(fig) + # the legend of the composite curves of any kind over none of the + # labels, decided when drawn: at matplotlib's 'best' location where + # that is clear of them (as at the default size); on smaller axes (of + # a figure of the user's, alone or the middle one of three in a row), + # where each spot clear of the curves holds a label, at the spot clear + # of the labels that covers the least of the curves; and above the axes + # (and their title) where each spot in the axes covers a text + def clear_legend(fig, ax): + """The legend inside the figure and off every text of `ax`: inside + the axes, or above them (and their title), left-aligned with them; + whether it is inside them.""" + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + box = ax.get_legend().get_window_extent(renderer) + for text in ax.texts: + assert not box.overlaps(text.get_window_extent(renderer)), text.get_text() + frame, page = ax.get_window_extent(renderer), fig.bbox + assert (page.x0 <= box.x0 and box.x1 <= page.x1 + and page.y0 <= box.y0 and box.y1 <= page.y1) + if (frame.x0 <= box.x0 and box.x1 <= frame.x1 + and frame.y0 <= box.y0 and box.y1 <= frame.y1): + return True + top = ax.title.get_window_extent(renderer).y1 if ax.get_title() else frame.y1 + pad = 0.5 * 8. * fig.dpi / 72. # borderaxespad of the 8 point font + assert box.x0 == pytest.approx(frame.x0) and box.y0 == pytest.approx(top + pad) + return False + for t, kind in ((furnace, 'balanced'), (liquid, 'balanced'), (kemp, 'real'), + (liquid, 'shifted')): + fig, ax = plot_composite_curves(t, kind=kind) + try: + assert clear_legend(fig, ax) + ours, = boxes(fig, [ax.get_legend()]) + mpl_best, = boxes(fig, [ax.legend(loc='best', fontsize=8)]) + assert ours.bounds == pytest.approx(mpl_best.bounds, abs=1e-6) + finally: + plt.close(fig) + for t in (furnace, liquid): + for size, columns in (((4., 3.), 1), ((10., 4.), 3)): + fig, axes = plt.subplots(1, columns, figsize=size, layout='constrained') + try: + ax = axes[1] if columns > 1 else axes + plot_composite_curves(t, kind='balanced', ax=ax) + labels = _gid_artists(ax, 'Utility:') + _gid_artists(ax, 'Fuel') + readable(fig, ax, labels, [line for line in ax.get_lines() + if 'balanced composite' in (line.get_gid() or '')], + legend=False) + label_boxes = boxes(fig, labels) + assert all(not a.overlaps(b) for i, a in enumerate(label_boxes) + for b in label_boxes[i + 1:]) + assert clear_legend(fig, ax) + finally: + plt.close(fig) + for kind in ('real', 'shifted'): + fig, ax = plt.subplots(figsize=(3., 3.), layout='constrained') + try: + plot_composite_curves(liquid, kind=kind, ax=ax) + assert clear_legend(fig, ax) + finally: + plt.close(fig) + fig, ax = plt.subplots(figsize=(6., 4.), layout='constrained') + try: + plot_composite_curves(liquid, kind='real', ax=ax) + for x, ha in ((0.02, 'left'), (0.5, 'center'), (0.98, 'right')): + for y, va in ((0.03, 'bottom'), (0.5, 'center'), (0.97, 'top')): + ax.text(x, y, 'a note of the user', ha=ha, va=va, transform=ax.transAxes) + assert not clear_legend(fig, ax) + ax.set_title('composite curves') + assert not clear_legend(fig, ax) + finally: + plt.close(fig) + # the grand composite curve at its default size: heats of six digits + # [kJ/hr] in scientific notation, whose tick labels do not run together + gcc_A = targets('test_targeting_plot_readable_gcc', REF_A, 10.) + fig, ax = plot_grand_composite_curve(gcc_A) + try: + lo, hi = ax.get_xlim() + ticks = [x for x in ax.get_xticklabels() if lo <= x.get_position()[0] <= hi] + assert ax.get_xticks()[-1] > 1e5 + tick_boxes = boxes(fig, ticks) + assert ax.xaxis.get_offset_text().get_text() == '1e5' + assert all(not a.overlaps(b) for a, b in zip(tick_boxes, tick_boxes[1:])) + finally: + plt.close(fig) + # a label for each step beside the end of its step (3 points right or + # left of it, 2 points above or below its level), inside the axes and + # off the curve: on the side of the end with more room, toward the + # pinch right of it and beyond the level left of it (the last steps end + # at the largest heats: left of the end, above a heating level and + # below a cooling one), as for the first seven (KEMP's cooling water + # ends short of the curve, which turns back below its level); else + # where else it is clear, where a pocket of the curve under a heating + # level (pocket, twin) or a curve that leaves the end of a cooling step + # rising at a shallow angle (shallow) runs toward the pinch right of + # the end; else on along its level, where the nose of the curve runs + # through each place beside the end on the side with room (nose: the + # low pressure steam's) + pocket = targets('test_targeting_plot_gcc_pocket', + [('C0', 401., 539., 0.49), ('H1', 430., 406., 2.87), + ('C2', 358., 508., 2.13), ('H3', 484., 460., 2.92)], 10.) + shallow = targets('test_targeting_plot_gcc_shallow', + [('C0', 300., 429., 1.56), ('H1', 538., 460., 1.69), + ('C2', 357., 424., 0.33), ('H3', 477., 342., 0.84)], 10.) + twin = targets('test_targeting_plot_gcc_twin', + [('C1', 300., 700., 1.), ('H1', 650., 320., 0.8), + ('C2', 340., 420., 3.), ('H2', 480., 290., 1.5)], 10.) + nose = targets('test_targeting_plot_gcc_nose', + [('C0', 463., 529., 1.84), ('H1', 407., 392., 0.95), + ('C2', 300., 527., 1.56), ('H3', 438., 377., 2.33)], 10.) + # (whether any label is not where the side with room puts it, whether + # any is on along its level) + cases = [(t, (False, False)) for t in (gcc_A, furnace, liquid, kemp, + threshold, hot, quickstart)] + cases += [(pocket, (True, False)), (shallow, (True, False)), + (twin, (True, False)), (nose, (True, True))] + for t, expected in cases: + fig, ax = plot_grand_composite_curve(t) + try: + heating = {u.ID: u.heating for u in t.utility_targets()} + labels = _gid_artists(ax, 'Label:') + assert len(labels) == len(heating) + readable(fig, ax, labels, _gid_artists(ax, 'GCC'), legend=False) + renderer = fig.canvas.get_renderer() + frame = ax.get_window_extent(renderer) + pt = fig.dpi / 72. + moved = slid = False + for label in labels: + box = label.get_window_extent(renderer) + x, y = ax.transData.transform(label.xy) + right, above = box.x0 > x, box.y0 > y + assert (box.y0 - y if above else y - box.y1) == pytest.approx(2. * pt, abs=0.5) + off = box.x0 - x if right else x - box.x1 + if off > 3. * pt + 0.5: slid = True + else: assert off == pytest.approx(3. * pt, abs=0.5) + room = frame.x1 - x >= x - frame.x0 + moved |= not (right == room and above == (heating[label.get_text()] != right)) + assert (moved, slid) == expected + finally: + plt.close(fig) + +def test_plot_labels_placed_once(monkeypatch): + # the labels of the balanced curves are placed in one pass for each + # layout of the figure, not again for each label measured (which made + # a draw exponential in the number of labels): fifteen steam levels + # take a few passes, kept in the order of their levels, apart, inside + # the axes and off the curves + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from hensmith import targeting + t = targets('test_targeting_plot_many', [('C1', 300., 600., 1.), + ('H1', 590., 320., 0.3)], 10.) + thermo = agent('low_pressure_steam').thermo + steam = [bst.UtilityAgent(f'test_targeting_plot_steam_{i}', Water=1, + T=330. + 230. * i / 14, P=1e5, phase='g', thermo=thermo, + heat_transfer_price=1e-7 * (i + 1)) for i in range(15)] + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) + utilities = t.utility_targets(steam) + assert sum(u.heating and u.agent is not None for u in utilities) == 15 + calls = [] + place = targeting._Stacked._place + monkeypatch.setattr(targeting._Stacked, '_place', lambda self, *args, **kwargs: + calls.append(None) or place(self, *args, **kwargs)) + fig, ax = plot_composite_curves(t, kind='balanced', utilities=utilities) + try: + # a few passes at the first draw, one per layout; at most as many at + # the next (none where the layout is the same to the bit) + counts = [] + for _ in range(2): + calls.clear() + fig.canvas.draw() + counts.append(len(calls)) + assert 1 <= counts[0] <= 4 and counts[1] <= 4 + renderer = fig.canvas.get_renderer() + frame = ax.get_window_extent(renderer) + labels = sorted(_gid_artists(ax, 'Utility:test_targeting_plot_steam'), + key=lambda label: label.xy[1]) + assert len(labels) == 15 + boxes = [label.get_window_extent(renderer) for label in labels] + assert all(frame.y0 <= box.y0 and box.y1 <= frame.y1 for box in boxes) + assert all(a.y0 <= b.y0 for a, b in zip(boxes, boxes[1:])) + assert all(not a.overlaps(b) for i, a in enumerate(boxes) for b in boxes[i + 1:]) + finally: + plt.close(fig) + +def test_plot_supertargets_gids(tmp_path): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + t = targets('test_targeting_plot_sweep', REF_A, 10.) + sweep = supertarget(t, [0., 5., 10., 20.], refine=1, n_refine=2) + assert sweep['TAC'][0] == math.inf + file = tmp_path / 'sweep.png' + fig, ax = plot_supertargets(sweep, file=str(file)) + try: + assert file.read_bytes()[:8] == b'\x89PNG\r\n\x1a\n' + T = sweep['T_min_app'] + for gid, key in (('TAC', 'TAC'), ('Capital', 'annualized_capital_cost'), + ('Operating', 'operating_cost')): + line, = _gid_artists(ax, gid) + assert_allclose(line.get_xdata(), T, rtol=0, atol=0) + assert_allclose(line.get_ydata(), sweep[key], rtol=0, atol=0) + optimum, = _gid_artists(ax, 'Optimum') + assert (optimum.get_xdata()[0], optimum.get_ydata()[0]) == ( + sweep.T_min_app, sweep.optimum['TAC']) + assert not sweep.at_bound and optimum.get_label() == 'optimum 10 K' + # the axis labels inside the figure, without a tight bounding box + fig.canvas.draw() + frame = fig.bbox + for label in (ax.xaxis.label, ax.yaxis.label): + box = label.get_window_extent(fig.canvas.get_renderer()) + assert (frame.x0 <= box.x0 and box.x1 <= frame.x1 + and frame.y0 <= box.y0 and box.y1 <= frame.y1), label.get_text() + finally: + plt.close(fig) + # an optimum at an end of the sweep is labeled so; one without a finite + # TAC (an unserved utility everywhere) is not marked + at_bound = supertarget(t, [10., 20., 30.], refine=0) + with pytest.warns(RuntimeWarning): + unserved = supertarget(t, [5., 10.], heating_agents=[agent('low_pressure_steam')], + cooling_agents=[agent('cooling_water')]) + assert at_bound.at_bound and not np.isfinite(unserved['TAC']).any() + for sweep, labels in ((at_bound, ['optimum 10 K (at the bound)']), (unserved, [])): + fig, ax = plot_supertargets(sweep) + try: + assert [a.get_label() for a in _gid_artists(ax, 'Optimum')] == labels + assert len(ax.lines) == 3 + 2 * len(labels) # with the dashed line + finally: + plt.close(fig) + # the temperatures without a finite TAC are shaded, halfway to their + # finite neighbors, so the axis keeps the whole sweep: the infinite area + # at 0 K, the unserved cooling of a pair past its threshold (10 K), a + # sweep unserved everywhere; none where every TAC is finite + pair = targets('test_targeting_plot_sweep_pair', PAIR, 10.) + with pytest.warns(RuntimeWarning, match='leave part of a utility target unserved'): + partly = supertarget(pair, [5., 10., 15., 20., 30.], refine=0, cooling_agents=[]) + assert list(np.isfinite(partly['TAC'])) == [True, True, False, False, False] + # two stretches (one legend entry), on records edited to have them + records = [dict(r) for r in supertarget(t, [5., 10., 15., 20., 25.], refine=0).records] + records[0]['TAC'] = math.inf + records[3]['TAC'] = records[4]['TAC'] = math.nan + two = Supertargets(t, records) + for sweep, spans in ((supertarget(t, [0., 5., 10., 20.], refine=0), [(0., 2.5)]), + (partly, [(12.5, 30.)]), (unserved, [(5., 10.)]), + (two, [(5., 7.5), (17.5, 25.)]), (at_bound, [])): + fig, ax = plot_supertargets(sweep) + try: + shaded = _gid_artists(ax, 'Nonfinite') + assert [(a.get_x(), a.get_x() + a.get_width()) for a in shaded] == spans + lo, hi = ax.get_xlim() + assert lo <= sweep['T_min_app'][0] and sweep['T_min_app'][-1] <= hi + labels = ax.get_legend_handles_labels()[1] + assert labels.count('no finite TAC') == (1 if spans else 0) + assert ax.get_legend().get_texts()[-1].get_text() == ( + 'no finite TAC' if spans else labels[-1]) + finally: + plt.close(fig) + # a constant TAC is labeled so, and one constant where finite too + flat = supertarget(targets('test_targeting_plot_sweep_flat', [('C1', 330., 380., 1.)], 10.), + [5., 10.]) + with pytest.warns(RuntimeWarning, match='leave part of a utility target unserved'): + flat_partly = supertarget(pair, [2., 4., 12.], cooling_agents=[]) + for sweep, label in ((flat, 'optimum 5 K (TAC constant)'), + (flat_partly, 'optimum 2 K (TAC constant where finite)')): + fig, ax = plot_supertargets(sweep) + try: + assert [a.get_label() for a in _gid_artists(ax, 'Optimum')] == [label] + finally: + plt.close(fig)