diff --git a/README.md b/README.md index 1dc3856..0db04f6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,12 @@ facility previously distributed as `biosteam.facilities.hxn`, including `HeatExchangerNetwork`, pinch/problem-table analysis, and pinch diagram plotting for [BioSTEAM](https://github.com/BioSTEAMDevelopmentGroup/biosteam) -systems. +systems. Targets come from a problem table on each stream's temperature-enthalpy +curve (phase changes included), and a pinch-outward planner synthesizes a +network without stream splits that reaches those minimum energy requirement +(MER) targets whenever it finds one, keeping the minimum approach temperature +everywhere inside every exchanger; where MER provably needs a split, the +network is a best-effort one close to the targets. ```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 59b2754..f5eaeff 100644 --- a/docs/_demo_src/README.md +++ b/docs/_demo_src/README.md @@ -55,8 +55,9 @@ python docs\_demo_src\build_all.py first failure. That sequencing is not incidental: importing biosteam writes numba's shared on-disk cache, and two Python processes writing it at once corrupt it — so never run two of these scripts (or the test suite alongside -one) concurrently by hand either. A full run takes a minute or two; the hero -GIFs dominate it. +one) concurrently by hand either. A full run takes about three minutes; the +hero GIFs (about 70 s) and chapter 04's six-point ``T_min_app`` sweep and +ten-stream synthesis (about 50 s) dominate it. Graphviz's `dot` must be on `PATH` — the flowsheet figures in chapters 01 and 03 are rendered by biosteam's `system.diagram()`. diff --git a/docs/_demo_src/examples/ch01_quickstart.py b/docs/_demo_src/examples/ch01_quickstart.py index 31e0c6e..f092ad4 100644 --- a/docs/_demo_src/examples/ch01_quickstart.py +++ b/docs/_demo_src/examples/ch01_quickstart.py @@ -58,6 +58,7 @@ def main(): print(f'energy balance error: {HXN.energy_balance_percent_error:.2g} %') print(f'added installed cost: {HXN.installed_costs["Heat exchangers"]:.3g} USD') print(f'process exchangers: {[hx.ID for hx in HXN.new_HXs]}') + print(f"synthesis status: {HXN.synthesis_info['status']}") # [end:loads] with capturing('ch01_life_cycles'): # [start:life_cycles] @@ -89,6 +90,7 @@ def main(): 'energy_balance_percent_error_abs': f'{abs(HXN.energy_balance_percent_error):.0e}', }) assert len(HXN.new_HXs) == 4 and len(HXN.stream_life_cycles) == 5, 'quickstart network changed' + assert HXN.synthesis_info['status'] == 'mer', 'quickstart network no longer reaches MER' assert abs(HXN.energy_balance_percent_error) < 1e-6 diff --git a/docs/_demo_src/examples/ch02_pinch_analysis.py b/docs/_demo_src/examples/ch02_pinch_analysis.py index d58d65c..c698d8c 100644 --- a/docs/_demo_src/examples/ch02_pinch_analysis.py +++ b/docs/_demo_src/examples/ch02_pinch_analysis.py @@ -84,7 +84,7 @@ def main(): 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) table = problem_table([hot_in, cold_in], [hot_out, cold_out], [True, False], 5.) - print('shifted grid Ts [K]:', table.Ts) + print('shifted grid Ts [K]:', table.Ts.size, 'points,', table.Ts[0], 'down to', table.Ts[-1]) print('hot utility target [kJ/hr]: ', round(table.hot_util_load, 3)) print('cold utility target [kJ/hr]:', round(table.cold_util_load, -1)) print('pinch (shifted) [K]:', table.pinch_T) @@ -117,7 +117,8 @@ def main(): for s in streams_quenched: s.vle(H=s.H, P=s.P) is_hot = [hu.duty < 0 for hu in hus] table = problem_table(streams_inlet, streams_quenched, is_hot, T_min_app=5.) - print('shifted grid Ts [K]:', table.Ts.round(2)) + print(f'shifted grid Ts: {table.Ts.size} points, {table.Ts[0]:.2f} down to {table.Ts[-1]:.2f} K') + print(f'point loads: {np.count_nonzero(table.point_H)}') print(f'hot utility target: {table.hot_util_load:.4g} kJ/hr') print(f'cold utility target: {table.cold_util_load:.4g} kJ/hr') print(f'pinch (shifted): {table.pinch_T:.2f} K') @@ -169,6 +170,7 @@ def main(): cool = -sum(hu.unit_duty for hu in new_hus if hu.unit_duty < 0) print(f'hot utility, process side: target {table.hot_util_load:.4g}, network {heat:.4g} kJ/hr') print(f'cold utility, process side: target {table.cold_util_load:.4g}, network {cool:.4g} kJ/hr') + print(f"synthesis status: {HXN.synthesis_info['status']}") # [end:compare] # plumbing checks: the drawn curves are consistent with the table gap = min_vertical_gap(hot_T, hot_H, cold_T, cold_H) diff --git a/docs/_demo_src/examples/ch04_configuring.py b/docs/_demo_src/examples/ch04_configuring.py index 1ce6628..d73dd56 100644 --- a/docs/_demo_src/examples/ch04_configuring.py +++ b/docs/_demo_src/examples/ch04_configuring.py @@ -75,10 +75,16 @@ def main(): HXN.T_min_app = T_min_app sys.simulate() rows.append((T_min_app, HXN.actual_heat_util_load, HXN.actual_cool_util_load, - HXN.installed_costs['Heat exchangers'])) - print('T_min_app [K] heating [kJ/hr] cooling [kJ/hr] added installed cost [USD]') - for T, heat, cool, cost in rows: - print(f'{T:13.0f} {heat:15.4g} {cool:15.4g} {cost:26.4g}') + HXN.installed_costs['Heat exchangers'], len(HXN.new_HXs), + dict(HXN.synthesis_info))) + print('T_min_app heating cooling added installed pinch process status') + print('[K] [kJ/hr] [kJ/hr] cost [USD] [K] exchangers') + for T, heat, cool, cost, n, info in rows: + pinch_T = info['plan_targets']['pinch_T'] # shifted scale + status = info['status'] + if status != 'mer': # process-side hot utility above the MER target + status += f" (+{info['Q_hot'] - info['Q_hot_target']:.3g} kJ/hr)" + print(f'{T:<9.0f} {heat:<9.4g} {cool:<9.4g} {cost:<15.4g} {pinch_T:<8.2f} {n:<11d} {status}') # [end:sweep] # [start:sweep_plot] T = [r[0] for r in rows] @@ -137,11 +143,13 @@ def main(): print(f'hot utility, process side: target {table.hot_util_load:.4g}, network {heat:.4g} kJ/hr') print(f'cold utility, process side: target {table.cold_util_load:.4g}, network {cool:.4g} kJ/hr') print(f'energy balance error: {HXN10.energy_balance_percent_error:.2g} %') + print(f"synthesis status: {HXN10.synthesis_info['status']}") fig, ax = HXN10.plot_pinch_diagram() # [end:ten_streams] save(fig, 'tutorial_04_ten_streams_pinch_diagram.png') plt.close(fig) - assert len(HXN10.new_HXs) == 15, len(HXN10.new_HXs) + assert len(HXN10.new_HXs) == 10, len(HXN10.new_HXs) + assert HXN10.synthesis_info['status'] == 'mer' assert HXN10.actual_heat_util_load >= table.hot_util_load * (1 - 1e-3) diff --git a/docs/source/API/heat_exchanger_network.rst b/docs/source/API/heat_exchanger_network.rst index a9905dc..25f7235 100644 --- a/docs/source/API/heat_exchanger_network.rst +++ b/docs/source/API/heat_exchanger_network.rst @@ -6,11 +6,13 @@ HeatExchangerNetwork :class:`HeatExchangerNetwork` is a BioSTEAM facility that runs a pinch analysis over the heating and cooling utilities of a whole system, synthesizes a network of process heat exchangers that meets part of those -duties by stream-to-stream exchange, and 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. +duties by stream-to-stream exchange -- at the minimum energy requirement +(MER) targets whenever it finds such a network without stream splits -- and +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. .. autoclass:: HeatExchangerNetwork :no-members: @@ -37,16 +39,16 @@ shows what each of them changes. - Units whose heat utilities are excluded from the analysis; a callable is evaluated at simulation time. Defaults to None. * - ``Qmin`` - float, kJ/hr - - Candidate exchangers with a duty below this are discarded during synthesis, and utility exchangers at or below it are not marked on the pinch diagram. Defaults to 1e-3. + - Planned exchangers with a duty below this are dropped and their duty left to the utilities (a large value can cost MER), and utility exchangers at or below it are not marked on the pinch diagram. Defaults to 1e-3. * - ``force_ideal_thermo`` - bool - 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 is unchanged, updating only stream states and exchanger specifications. Defaults to False. + - Reuse the network configuration of the previous simulation when the set of units contributing heat utilities is 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, 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, so that no two exchangers connect the same pair and form a recycle loop. Defaults to False. + - 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. * - ``acceptable_energy_balance_error`` - float - When given, sets an instance attribute that overrides the class default of 0.02 (see below). Defaults to None, i.e. the class value is used. @@ -55,7 +57,7 @@ shows what each of them changes. - Copy each synthesized utility exchanger's heat utility onto the corresponding original heat utility and reload that unit's utility cost, instead of reporting the net utilities on the facility itself. Applies only when at least one process exchanger was synthesized. Defaults to False. * - ``sort_hus_by_T`` - bool - - Sort the heating utilities by inlet temperature descending and the cooling utilities ascending before the analysis, so that inlet temperature rather than signed duty (the default: smallest heating duty first, largest cooling duty first) sets the matching priority. Defaults to False. + - Sort the heating utilities by inlet temperature descending and the cooling utilities ascending before the analysis, so that inlet temperature rather than signed duty (the default: smallest heating duty first, largest cooling duty first) sets the stream indices, which break ties in the planner's search. Defaults to False. Class attributes ---------------- @@ -115,6 +117,9 @@ the cached network. * - ``energy_balance_percent_error`` - float, % - 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. 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``. @@ -123,10 +128,10 @@ the cached network. - All synthesized process exchangers, the hot-side ones followed by the cold-side ones. * - ``new_HXs_hot_side`` - list[HXprocess] - - Process exchangers of the hot-side (above-pinch) design. + - Process exchangers of the hot-side (above-pinch) design, in plan order (from the pinch outward), IDs ``HX___hs``. * - ``new_HXs_cold_side`` - list[HXprocess] - - Process exchangers of the cold-side (below-pinch) design. + - Process exchangers of the cold-side (below-pinch) design, in plan order, IDs ``HX___cs``; on either side the *n*-th exchanger of a repeated pair gets the suffix ``_``. * - ``new_HX_utils`` - list[HXutility] - One rigorous utility exchanger per stream, bringing it from its last process exchanger (or its inlet, if it was not matched) to its outlet enthalpy. @@ -144,7 +149,7 @@ the cached network. - The flowsheet ``_HXN`` holding the network's stream copies and exchangers. * - ``pinch_Ts`` - ndarray, K - - Per-stream pinch temperature at which the stream's duty is split between the hot-side and cold-side designs. + - 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). * - ``inlet_Ts`` - ndarray, K - Inlet temperature of each stream. @@ -156,7 +161,7 @@ the cached network. - One copy of each stream's inlet, in stream order, as prepared for the analysis; the synthesis works on further copies, so these keep their inlet state. * - ``stream_HXs_dict`` - dict[int, list[Unit]] - - Exchangers, the process ones then the utility one, that each stream index passes through, in synthesis order rather than flow order. + - Exchangers that each stream index passes through: its process exchangers in flow order, then its utility exchanger. * - ``cold_indices`` - list[int] - Stream indices of the heated (cold) streams. diff --git a/docs/source/API/hxn_synthesis.rst b/docs/source/API/hxn_synthesis.rst index 0d26bcf..bd62cc1 100644 --- a/docs/source/API/hxn_synthesis.rst +++ b/docs/source/API/hxn_synthesis.rst @@ -5,12 +5,15 @@ Pinch analysis and synthesis (hensmith.hxn_synthesis) ``hensmith.hxn_synthesis`` holds the machinery behind :class:`HeatExchangerNetwork`: :func:`problem_table` builds the -temperature-interval heat cascade of a set of process streams and locates -the pinch, :func:`synthesize_network` adds the sequential, heuristic -matching of hot and cold streams on each side of that pinch, -:class:`StreamLifeCycle` records the exchangers each stream ends up passing -through, and :func:`plot_pinch_diagram` draws the result. All four are -usable on their own, without a :class:`HeatExchangerNetwork` instance. +temperature-interval heat cascade of a set of process streams on their +temperature-enthalpy curves and locates the pinch, :func:`synthesize_network` +plans an unsplit network from the pinch outward on the same curves -- one that +reaches the minimum energy requirement (MER) targets whenever its search finds +one -- and realizes it as BioSTEAM exchangers, :class:`StreamLifeCycle` +records the exchangers each stream ends up passing through, and +:func:`plot_pinch_diagram` draws the result. All four are usable on their own, +without a :class:`HeatExchangerNetwork` instance; :doc:`../concepts` explains +the method. .. autofunction:: problem_table @@ -29,9 +32,13 @@ usable on their own, without a :class:`HeatExchangerNetwork` instance. .. note:: - **Internals.** ``hensmith.hxn_synthesis.temperature_interval_pinch_analysis``, - ``hensmith.hxn_synthesis.pinch_state`` and - ``hensmith.hxn_synthesis.load_duties`` are public in name only: they are - steps of :func:`synthesize_network`, are not exported by ``hensmith``, and - are not part of the supported API. Their signatures and behavior may change - without notice. + **Internals.** ``hensmith.hxn_synthesis.temperature_interval_pinch_analysis`` + (the first step of :func:`synthesize_network`: preparing the process streams + and running the problem table on them), ``hensmith.hxn_synthesis.pinch_state`` + and ``hensmith.hxn_synthesis.load_duties`` (standalone helpers that split a + stream at a pinch temperature; the synthesis itself plans on the stream + curves and does not use them) are public in name only: they are not exported + by ``hensmith``, and are not part of the supported API. Neither are the + private modules ``hensmith._curves`` (the stream temperature-enthalpy curves) + and ``hensmith._planner`` (the MER planner). Their signatures and behavior + may change without notice. diff --git a/docs/source/_generated/ch01_life_cycles.txt b/docs/source/_generated/ch01_life_cycles.txt index 465047b..cc146c8 100644 --- a/docs/source/_generated/ch01_life_cycles.txt +++ b/docs/source/_generated/ch01_life_cycles.txt @@ -4,15 +4,15 @@ , H_in = 4.24e+07 kJ/hr, H_out = 6.92e+07 kJ/hr> ]>, , H_in = 0 kJ/hr, H_out = 3.34e+04 kJ/hr> - , H_in = 3.34e+04 kJ/hr, H_out = 5.06e+06 kJ/hr> - , H_in = 5.06e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> + , H_in = 0 kJ/hr, H_out = 5.05e+06 kJ/hr> + , H_in = 5.05e+06 kJ/hr, H_out = 5.08e+06 kJ/hr> + , H_in = 5.08e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> , H_in = 2.3e+07 kJ/hr, H_out = 2.79e+08 kJ/hr> ]>, , H_in = 4.52e+07 kJ/hr, H_out = 8.12e+06 kJ/hr> - , H_in = 8.12e+06 kJ/hr, H_out = 3.1e+06 kJ/hr> - , H_in = 3.1e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> + , H_in = 8.12e+06 kJ/hr, H_out = 3.07e+06 kJ/hr> + , H_in = 3.07e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> ]>, , H_in = 2.04e+07 kJ/hr, H_out = 2.47e+06 kJ/hr> diff --git a/docs/source/_generated/ch01_loads.txt b/docs/source/_generated/ch01_loads.txt index 1c851df..37d861d 100644 --- a/docs/source/_generated/ch01_loads.txt +++ b/docs/source/_generated/ch01_loads.txt @@ -1,5 +1,6 @@ heating utility: 3.609e+08 -> 2.977e+08 kJ/hr -cooling utility: 6.201e+07 -> 1.96e+06 kJ/hr +cooling utility: 6.201e+07 -> 1.936e+06 kJ/hr energy balance error: -1.8e-11 % added installed cost: 1.11e+06 USD -process exchangers: ['HX_0_2_hs', 'HX_1_4_hs', 'HX_1_2_hs', 'HX_1_3_hs'] +process exchangers: ['HX_1_2_hs', 'HX_0_2_hs', 'HX_1_4_hs', 'HX_1_3_hs'] +synthesis status: mer diff --git a/docs/source/_generated/ch01_summary.txt b/docs/source/_generated/ch01_summary.txt index b852470..be97232 100644 --- a/docs/source/_generated/ch01_summary.txt +++ b/docs/source/_generated/ch01_summary.txt @@ -5,8 +5,8 @@ heating_before = 3.609e+08 heating_after = 2.977e+08 heating_reduction_percent = 17.5 cooling_before = 6.201e+07 -cooling_after = 1.96e+06 -cooling_reduction_percent = 96.8 +cooling_after = 1.936e+06 +cooling_reduction_percent = 96.9 heat_ratio = 0.82 utility_cost_usd_per_hr = -605 installed_cost_usd = 1.11e+06 diff --git a/docs/source/_generated/ch02_compare.txt b/docs/source/_generated/ch02_compare.txt index 8a87465..e8b439d 100644 --- a/docs/source/_generated/ch02_compare.txt +++ b/docs/source/_generated/ch02_compare.txt @@ -1,4 +1,5 @@ hot utility: target 2.828e+08, network 2.977e+08 kJ/hr -cold utility: target 1.936e+06, network 1.96e+06 kJ/hr +cold utility: target 1.936e+06, network 1.936e+06 kJ/hr hot utility, process side: target 2.828e+08, network 2.828e+08 kJ/hr -cold utility, process side: target 1.936e+06, network 1.96e+06 kJ/hr +cold utility, process side: target 1.936e+06, network 1.936e+06 kJ/hr +synthesis status: mer diff --git a/docs/source/_generated/ch02_table.txt b/docs/source/_generated/ch02_table.txt index d6677af..cba4964 100644 --- a/docs/source/_generated/ch02_table.txt +++ b/docs/source/_generated/ch02_table.txt @@ -1,4 +1,5 @@ -shifted grid Ts [K]: [372.6 369.07 366.38 333.53 333. 333. 332.98 306.37 298.15 295. ] +shifted grid Ts: 175 points, 372.60 down to 295.00 K +point loads: 0 hot utility target: 2.828e+08 kJ/hr cold utility target: 1.936e+06 kJ/hr pinch (shifted): 298.15 K diff --git a/docs/source/_generated/ch02_threshold.txt b/docs/source/_generated/ch02_threshold.txt index 0a0424e..8426473 100644 --- a/docs/source/_generated/ch02_threshold.txt +++ b/docs/source/_generated/ch02_threshold.txt @@ -1,4 +1,4 @@ -shifted grid Ts [K]: [395. 390. 300. 295.] +shifted grid Ts [K]: 25 points, 395.0 down to 295.0 hot utility target [kJ/hr]: 0.0 cold utility target [kJ/hr]: 1445550.0 pinch (shifted) [K]: 395.0 diff --git a/docs/source/_generated/ch03_accounting.txt b/docs/source/_generated/ch03_accounting.txt index 5b5f409..149cb85 100644 --- a/docs/source/_generated/ch03_accounting.txt +++ b/docs/source/_generated/ch03_accounting.txt @@ -1,10 +1,10 @@ energy balance error: -1.8e-11 % (warns above 2 %) original exchangers, purchase cost: 3.365e+05 USD -new process exchangers, purchase: 4.73e+05 USD -new utility exchangers, purchase: 2.096e+05 USD -facility purchase cost (added): 3.461e+05 USD -facility installed cost (added): 1.114e+06 USD +new process exchangers, purchase: 4.734e+05 USD +new utility exchangers, purchase: 2.095e+05 USD +facility purchase cost (added): 3.464e+05 USD +facility installed cost (added): 1.112e+06 USD facility heat utilities (new minus original): - low_pressure_steam duty -6.321e+07 kJ/hr cost -388.6 USD/hr - chilled_water duty 4.212e+07 kJ/hr cost -210.6 USD/hr + low_pressure_steam duty -6.324e+07 kJ/hr cost -388.8 USD/hr + chilled_water duty 4.214e+07 kJ/hr cost -210.7 USD/hr cooling_water duty 1.794e+07 kJ/hr cost -5.976 USD/hr diff --git a/docs/source/_generated/ch03_flowsheet.txt b/docs/source/_generated/ch03_flowsheet.txt index 694928e..6da2bfb 100644 --- a/docs/source/_generated/ch03_flowsheet.txt +++ b/docs/source/_generated/ch03_flowsheet.txt @@ -1,3 +1,3 @@ sys_HXN sys_HXN -['HX_1_4_hs', 'HX_0_2_hs', 'Util_0_hs', 'HX_1_2_hs', 'HX_1_3_hs', 'Util_1_hs', 'Util_3_cs', 'Util_2_cs', 'Util_4_cs'] +['HX_0_2_hs', 'HX_1_2_hs', 'HX_1_4_hs', 'HX_1_3_hs', 'Util_2_cs', 'Util_3_cs', 'Util_4_cs', 'Util_0_hs', 'Util_1_hs'] diff --git a/docs/source/_generated/ch03_life_cycles.txt b/docs/source/_generated/ch03_life_cycles.txt index 4e1a952..b432089 100644 --- a/docs/source/_generated/ch03_life_cycles.txt +++ b/docs/source/_generated/ch03_life_cycles.txt @@ -5,16 +5,16 @@ StreamLifeCycle: Stream_0, cold StreamLifeCycle: Stream_1, cold life_cycle: - , H_in = 0 kJ/hr, H_out = 3.34e+04 kJ/hr> - , H_in = 3.34e+04 kJ/hr, H_out = 5.06e+06 kJ/hr> - , H_in = 5.06e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> + , H_in = 0 kJ/hr, H_out = 5.05e+06 kJ/hr> + , H_in = 5.05e+06 kJ/hr, H_out = 5.08e+06 kJ/hr> + , H_in = 5.08e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> , H_in = 2.3e+07 kJ/hr, H_out = 2.79e+08 kJ/hr> StreamLifeCycle: Stream_2, hot life_cycle: , H_in = 4.52e+07 kJ/hr, H_out = 8.12e+06 kJ/hr> - , H_in = 8.12e+06 kJ/hr, H_out = 3.1e+06 kJ/hr> - , H_in = 3.1e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> + , H_in = 8.12e+06 kJ/hr, H_out = 3.07e+06 kJ/hr> + , H_in = 3.07e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> StreamLifeCycle: Stream_3, hot life_cycle: diff --git a/docs/source/_generated/ch03_stage.txt b/docs/source/_generated/ch03_stage.txt index 2c00550..10cc0b1 100644 --- a/docs/source/_generated/ch03_stage.txt +++ b/docs/source/_generated/ch03_stage.txt @@ -1,3 +1,3 @@ -HX_1_4_hs | stream position 0 -s_1__HX_1_4_hs -> HX_1_4_hs__s_1 -0 -> 3.338e+04 kJ/hr +HX_1_2_hs | stream position 0 +s_1__HX_1_2_hs -> HX_1_2_hs__s_1 +0 -> 5.051e+06 kJ/hr diff --git a/docs/source/_generated/ch04_ignored.txt b/docs/source/_generated/ch04_ignored.txt index 43c991d..72a8e9a 100644 --- a/docs/source/_generated/ch04_ignored.txt +++ b/docs/source/_generated/ch04_ignored.txt @@ -1,3 +1,3 @@ streams in the network: 4 heating utility: 3.609e+08 -> 3.42e+08 kJ/hr -cooling utility: 1.797e+07 -> 5.336e-07 kJ/hr +cooling utility: 1.797e+07 -> 0 kJ/hr diff --git a/docs/source/_generated/ch04_sweep.txt b/docs/source/_generated/ch04_sweep.txt index 63a4bab..9bef536 100644 --- a/docs/source/_generated/ch04_sweep.txt +++ b/docs/source/_generated/ch04_sweep.txt @@ -1,7 +1,8 @@ -T_min_app [K] heating [kJ/hr] cooling [kJ/hr] added installed cost [USD] - 2 2.957e+08 1.167e+05 2.396e+06 - 5 2.977e+08 1.96e+06 1.114e+06 - 10 3.009e+08 5.033e+06 6.025e+05 - 15 3.095e+08 1.317e+07 4.003e+05 - 20 3.136e+08 1.704e+07 3.374e+05 - 30 3.289e+08 3.158e+07 2.454e+05 +T_min_app heating cooling added installed pinch process status +[K] [kJ/hr] [kJ/hr] cost [USD] [K] exchangers +2 2.957e+08 9.219e+04 2.02e+06 298.15 3 mer +5 2.977e+08 1.936e+06 1.112e+06 298.15 4 mer +10 3.009e+08 5.009e+06 6.004e+05 298.15 4 mer +15 3.053e+08 9.144e+06 6.684e+05 323.53 8 best_effort (+6.63e+03 kJ/hr) +20 3.131e+08 1.663e+07 4.131e+05 318.53 6 best_effort (+4.7e+03 kJ/hr) +30 3.289e+08 3.157e+07 2.496e+05 308.53 5 best_effort (+837 kJ/hr) diff --git a/docs/source/_generated/ch04_ten_streams.txt b/docs/source/_generated/ch04_ten_streams.txt index d162233..b4e76b8 100644 --- a/docs/source/_generated/ch04_ten_streams.txt +++ b/docs/source/_generated/ch04_ten_streams.txt @@ -1,6 +1,7 @@ -process exchangers: 15 -hot utility: target 1.394e+07, network 1.481e+07 kJ/hr -cold utility: target 7.928e+06, network 8.065e+06 kJ/hr -hot utility, process side: target 1.394e+07, network 1.407e+07 kJ/hr -cold utility, process side: target 7.928e+06, network 8.065e+06 kJ/hr -energy balance error: 1e-11 % +process exchangers: 10 +hot utility: target 1.447e+07, network 1.523e+07 kJ/hr +cold utility: target 8.462e+06, network 8.462e+06 kJ/hr +hot utility, process side: target 1.447e+07, network 1.447e+07 kJ/hr +cold utility, process side: target 8.462e+06, network 8.462e+06 kJ/hr +energy balance error: 2e-12 % +synthesis status: mer diff --git a/docs/source/_static/images/demo/hero_dark.gif b/docs/source/_static/images/demo/hero_dark.gif index 35c43e3..6e28c65 100644 Binary files a/docs/source/_static/images/demo/hero_dark.gif and b/docs/source/_static/images/demo/hero_dark.gif differ diff --git a/docs/source/_static/images/demo/hero_dark_still.png b/docs/source/_static/images/demo/hero_dark_still.png index 2123f3b..00a3d0e 100644 Binary files a/docs/source/_static/images/demo/hero_dark_still.png and b/docs/source/_static/images/demo/hero_dark_still.png differ diff --git a/docs/source/_static/images/demo/hero_light.gif b/docs/source/_static/images/demo/hero_light.gif index b7345aa..43d02d7 100644 Binary files a/docs/source/_static/images/demo/hero_light.gif and b/docs/source/_static/images/demo/hero_light.gif differ diff --git a/docs/source/_static/images/demo/hero_light_still.png b/docs/source/_static/images/demo/hero_light_still.png index 594a3c0..984dda8 100644 Binary files a/docs/source/_static/images/demo/hero_light_still.png and b/docs/source/_static/images/demo/hero_light_still.png differ diff --git a/docs/source/_static/images/examples/quickstart_demo_poster.png b/docs/source/_static/images/examples/quickstart_demo_poster.png index f43236a..f4d3866 100644 Binary files a/docs/source/_static/images/examples/quickstart_demo_poster.png and b/docs/source/_static/images/examples/quickstart_demo_poster.png differ diff --git a/docs/source/_static/images/examples/tutorial_01_quickstart_pinch_diagram.png b/docs/source/_static/images/examples/tutorial_01_quickstart_pinch_diagram.png index bb384e0..06dbd64 100644 Binary files a/docs/source/_static/images/examples/tutorial_01_quickstart_pinch_diagram.png and b/docs/source/_static/images/examples/tutorial_01_quickstart_pinch_diagram.png differ diff --git a/docs/source/_static/images/examples/tutorial_02_composite_curves.png b/docs/source/_static/images/examples/tutorial_02_composite_curves.png index 0f4ad7d..121948e 100644 Binary files a/docs/source/_static/images/examples/tutorial_02_composite_curves.png and b/docs/source/_static/images/examples/tutorial_02_composite_curves.png differ diff --git a/docs/source/_static/images/examples/tutorial_02_grand_composite.png b/docs/source/_static/images/examples/tutorial_02_grand_composite.png index 7a29bf8..1378c2f 100644 Binary files a/docs/source/_static/images/examples/tutorial_02_grand_composite.png and b/docs/source/_static/images/examples/tutorial_02_grand_composite.png differ diff --git a/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_dark.png b/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_dark.png index b4382fd..36a1ff6 100644 Binary files a/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_dark.png and b/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_dark.png differ diff --git a/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_light.png b/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_light.png index 4b1d411..00e2220 100644 Binary files a/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_light.png and b/docs/source/_static/images/examples/tutorial_03_hxn_flowsheet_light.png differ diff --git a/docs/source/_static/images/examples/tutorial_03_pinch_diagram_minimal.png b/docs/source/_static/images/examples/tutorial_03_pinch_diagram_minimal.png index 7334c25..878d99e 100644 Binary files a/docs/source/_static/images/examples/tutorial_03_pinch_diagram_minimal.png and b/docs/source/_static/images/examples/tutorial_03_pinch_diagram_minimal.png differ diff --git a/docs/source/_static/images/examples/tutorial_04_T_min_app_sweep.png b/docs/source/_static/images/examples/tutorial_04_T_min_app_sweep.png index 67a5576..9aa0e97 100644 Binary files a/docs/source/_static/images/examples/tutorial_04_T_min_app_sweep.png and b/docs/source/_static/images/examples/tutorial_04_T_min_app_sweep.png differ diff --git a/docs/source/_static/images/examples/tutorial_04_ten_streams_pinch_diagram.png b/docs/source/_static/images/examples/tutorial_04_ten_streams_pinch_diagram.png index 18204fe..2f0c087 100644 Binary files a/docs/source/_static/images/examples/tutorial_04_ten_streams_pinch_diagram.png and b/docs/source/_static/images/examples/tutorial_04_ten_streams_pinch_diagram.png differ diff --git a/docs/source/_static/quickstart_demo.html b/docs/source/_static/quickstart_demo.html index 66ce4ed..8364bd5 100644 --- a/docs/source/_static/quickstart_demo.html +++ b/docs/source/_static/quickstart_demo.html @@ -547,7 +547,7 @@

Synthesize the heat exchanger network of any BioSTEAM system.

Total purchase cost USD 3.46e+05 Installed equipment cost USD 1.11e+06 Utility cost USD/hr -605`}, - callouts:[{"v": "−17.5 %", "l": "heating utility"}, {"v": "−96.8 %", "l": "cooling utility"}, {"v": "−605 USD/hr", "l": "utility cost (savings)"}] }, + callouts:[{"v": "−17.5 %", "l": "heating utility"}, {"v": "−96.9 %", "l": "cooling utility"}, {"v": "−605 USD/hr", "l": "utility cost (savings)"}] }, { num:"03", label:"Draw the pinch diagram", dur:13000, lineMs:520, code:["fig, ax = HXN.plot_pinch_diagram()"], caption:"Cold streams above, hot streams below, one connector per process exchanger.", @@ -564,15 +564,15 @@

Synthesize the heat exchanger network of any BioSTEAM system.

<LifeStage: <HXutility: Util_0_hs>, H_in = 4.24e+07 kJ/hr, H_out = 6.92e+07 kJ/hr> ]>, <StreamLifeCycle: Stream_1, cold life_cycle = [ - <LifeStage: <HXprocess: HX_1_4_hs>, H_in = 0 kJ/hr, H_out = 3.34e+04 kJ/hr> - <LifeStage: <HXprocess: HX_1_2_hs>, H_in = 3.34e+04 kJ/hr, H_out = 5.06e+06 kJ/hr> - <LifeStage: <HXprocess: HX_1_3_hs>, H_in = 5.06e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> + <LifeStage: <HXprocess: HX_1_2_hs>, H_in = 0 kJ/hr, H_out = 5.05e+06 kJ/hr> + <LifeStage: <HXprocess: HX_1_4_hs>, H_in = 5.05e+06 kJ/hr, H_out = 5.08e+06 kJ/hr> + <LifeStage: <HXprocess: HX_1_3_hs>, H_in = 5.08e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> <LifeStage: <HXutility: Util_1_hs>, H_in = 2.3e+07 kJ/hr, H_out = 2.79e+08 kJ/hr> ]>, <StreamLifeCycle: Stream_2, hot life_cycle = [ <LifeStage: <HXprocess: HX_0_2_hs>, H_in = 4.52e+07 kJ/hr, H_out = 8.12e+06 kJ/hr> - <LifeStage: <HXprocess: HX_1_2_hs>, H_in = 8.12e+06 kJ/hr, H_out = 3.1e+06 kJ/hr> - <LifeStage: <HXutility: Util_2_cs>, H_in = 3.1e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> + <LifeStage: <HXprocess: HX_1_2_hs>, H_in = 8.12e+06 kJ/hr, H_out = 3.07e+06 kJ/hr> + <LifeStage: <HXutility: Util_2_cs>, H_in = 3.07e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> ]>, <StreamLifeCycle: Stream_3, hot life_cycle = [ <LifeStage: <HXprocess: HX_1_3_hs>, H_in = 2.04e+07 kJ/hr, H_out = 2.47e+06 kJ/hr> diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst index 2a7e3e7..dfd4d79 100644 --- a/docs/source/concepts.rst +++ b/docs/source/concepts.rst @@ -8,8 +8,10 @@ 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 guaranteed to be. Everything below describes the behavior of the code in -``hensmith/hxn_synthesis.py`` and ``hensmith/_heat_exchanger_network.py``; the -:doc:`tutorial/index` shows the same concepts on a running system. +``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. Heat integration and the minimum approach temperature ----------------------------------------------------- @@ -27,19 +29,20 @@ streams are in temperature, the more area is needed for the same duty, since the area of a counter-current exchanger scales as :math:`Q / (U \Delta T_{lm})`. hensmith expresses that limit as a single number, the minimum approach temperature ``T_min_app`` (in K, default ``5.``), -required between the streams of every candidate match, enforced on every -synthesized exchanger, and used to shift hot streams in the problem table: +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 streams which meet on the shifted scale are really ``T_min_app`` apart; -- in every matching pass of :func:`~hensmith.synthesize_network`, where it - sets which candidates are eligible (a hot stream is only paired with a cold - stream more than ``T_min_app`` below it) and enters the driving-force - ranking that orders them; and -- on every synthesized process exchanger, which is a ``biosteam.HXprocess`` - constructed with ``dT=T_min_app`` and therefore stops transferring heat when - its outlet temperatures come that close. +- 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 + breakpoint of their temperature-enthalpy curves along the whole 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. 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 @@ -56,42 +59,65 @@ of a set of streams, given each stream's inlet, its outlet quenched to equilibrium at its own enthalpy, a flag saying whether it is cooled, and ``T_min_app``. Its result is a :class:`~hensmith.ProblemTable`. -**The shifted grid.** Hot streams are shifted down by ``T_min_app``; cold -streams are not. The grid ``Ts`` is the sorted (descending) set of shifted end -temperatures of all streams, so the intervals between consecutive grid -temperatures are exactly the intervals over which the population of streams -does not change. +**Stream curves.** Each stream is first described by a piecewise-linear +temperature-enthalpy curve over its own enthalpy range, built once from a +handful of flashes and evaluated afterwards without flashing. Its breakpoints +are the stream's two end temperatures and every phase boundary inside its +range (a pure component's saturation temperature, a mixture's bubble and dew +points); a pure component's latent heat is a flat (isothermal) segment +between its saturated-liquid and saturated-vapor enthalpies; a mixture's +two-phase glide is sampled, and so is every curved single-phase stretch +(temperature-dependent heat capacity), densely enough that linear +interpolation stays within 0.002 K of the true curve. Inside its range the +stream is taken at equilibrium with its enthalpy clipped to +:math:`[H_{lo}, H_{hi}]`, so a stream copy at an interior temperature can +never carry more enthalpy than the real stream ever has (a non-equilibrium +outlet, for instance); what a non-equilibrium end state departs from +equilibrium at its own end temperature becomes a flat there. -**Per-stream contributions.** For a monotone stream -- one whose outlet moves -in the direction its duty implies -- the heat contributed to the interval -between grid temperatures :math:`T_k` and :math:`T_{k+1}` is +**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 +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 +is linear to within 0.002 K. A grid of the stream end temperatures alone +would average a boiling point, a dew or bubble point, or the curvature of a +glide that falls strictly inside an interval over that interval, which can +hide a pinch and make the hot utility target too low. + +**Per-stream contributions.** Write :math:`H_j^-(T)` and :math:`H_j^+(T)` for +the low- and high-enthalpy limits of stream :math:`j`'s curve at the *real* +temperature :math:`T + \mathrm{shift}_j`; they differ only where the curve has +a flat at that temperature, and they are :math:`H_{hi}` above and +:math:`H_{lo}` below the stream's range. The heat a stream contributes to the +open interval between grid temperatures :math:`T_k` and :math:`T_{k+1}`, and +at the grid temperature :math:`T_k` itself, are .. math:: - \mathrm{interval\_H}[j,k] = s_j \left( H_j(T_k) - H_j(T_{k+1}) \right), - \qquad s_j = +1 \; \text{(hot)}, \; -1 \; \text{(cold)}, + \mathrm{interval\_H}[j,k] = s_j \left( H_j^-(T_k) - H_j^+(T_{k+1}) \right), + \qquad + \mathrm{point\_H}[j,k] = s_j \left( H_j^+(T_k) - H_j^-(T_k) \right), -where :math:`H_j` is obtained by flashing a copy of the stream at the *real* -temperature :math:`T + \mathrm{shift}` and clipping the result to -:math:`[H_{in}, H_{out}]`. The stream's own two end points are assigned -:math:`H_{in}` and :math:`H_{out}` by position rather than by a float -comparison, so the sum over intervals telescopes exactly: +with :math:`s_j = +1` for a hot stream and :math:`-1` for a cold one. The +stream's own end points are grid points selected by position rather than by +a float comparison, so the contributions telescope exactly: .. math:: - \sum_k \mathrm{interval\_H}[j,k] = s_j \left| H_{out} - H_{in} \right|, + \sum_k \mathrm{interval\_H}[j,k] + \sum_k \mathrm{point\_H}[j,k] + = s_j \left| H_{out} - H_{in} \right|, -that is, to the stream's duty. The clipping matters: a stream copy flashed at -an interior temperature may carry more enthalpy than the real stream ever has -(a non-equilibrium outlet, for instance), and without it that stream would -inflate an interval and break the identity above. +that is, to the stream's duty. -**Point loads.** Isothermal streams, and streams whose outlet temperature -moves *against* their duty -- a heated stream that leaves cooler than it -entered, such as a reboiler outlet quenched to equilibrium -- have no interval -to occupy. They enter the table as a point load -:math:`s_j |H_{out} - H_{in}|` at their shifted outlet temperature, in -``point_H``. +**Point loads.** ``point_H`` is nonzero only where a curve is flat: at a pure +component's (shifted) saturation temperature, which receives its latent heat; +at an end temperature where a non-equilibrium end state departs from +equilibrium; and at the shifted outlet temperature of a *point-load stream* +-- an isothermal stream, or one whose outlet temperature moves *against* its +duty (a heated stream that leaves cooler than it entered, such as a reboiler +outlet quenched to equilibrium) -- which has no interval to occupy and puts +its whole duty :math:`s_j |H_{out} - H_{in}|` there. **The cascade.** Starting from zero hot utility, the heat leaving grid boundary :math:`T_k` (after that boundary's point loads) is @@ -118,17 +144,25 @@ flows; the most negative value is the deficit hot utility must make up, \mathrm{hot\_util\_load} = -\min_k \min(\mathrm{residual}[k], \mathrm{arriving}[k]), -its location is the pinch, and the heat left at the bottom of the cascade is -the cold utility, +its first location is the pinch, and the heat left at the bottom of the +cascade is the cold utility, :math:`\mathrm{cold\_util\_load} = \mathrm{residual}[-1] + -\mathrm{hot\_util\_load}`. +\mathrm{hot\_util\_load}`. Whether that minimum is the arriving or the leaving +flow at the pinch fixes the side of the pinch that the point loads *at* the +pinch temperature belong to, the pinch *cut*. + +Because every stream is linear to within 0.002 K between grid points, the +grid minimum of the cascade is the true one to within 0.002 K times the sum of +the heat capacity flow rates; equivalently, the targets lie between the exact +targets at ``T_min_app`` minus and plus 0.004 K (approximately: the 0.002 K +tolerance is established by midpoint tests). For streams of constant heat +capacity the curves are exact. **Threshold problems.** When that minimum is not negative -- or negative by no -more than a tiny fraction of the total stream duty -- no hot utility is needed -at all. The table then reports zero hot -utility and places the pinch at the top of the grid, ``Ts[0]``. A cold utility -that comes out slightly negative through rounding is absorbed back into the -hot utility so that the identity +more than 1e-9 of the total stream duty -- no hot utility is needed at all. +The table then reports zero hot utility and places the pinch at the top of the +grid, ``Ts[0]``. A cold utility that comes out slightly negative through +rounding is absorbed back into the hot utility so that the identity .. math:: @@ -161,7 +195,7 @@ and by construction they approach no closer than ``T_min_app``. .. figure:: /_static/images/examples/tutorial_02_composite_curves.png :class: white-bg :width: 720 - :alt: Composite curves of the quickstart system: a red hot composite curve above a blue cold composite curve, on axes of temperature in degrees Celsius against enthalpy in GJ/hr, with a shaded band marking the recovered heat, a cold utility arrow of 1.936e+06 kJ/hr at the cold end and a hot utility arrow of 2.828e+08 kJ/hr at the warm end. + :alt: Composite curves of the quickstart system: a red hot composite curve above a blue cold composite curve, on axes of temperature in degrees Celsius against enthalpy in GJ/hr, with a shaded band marking the recovered heat, a cold utility bracket labelled 1.94e+06 kJ/hr at the cold end and a hot utility arrow labelled 2.83e+08 kJ/hr at the warm end. Composite curves of the quickstart system at ``T_min_app = 5`` K. The shaded band is the heat the two curves can exchange with each other; the @@ -184,126 +218,188 @@ 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. -From targets to a network: hensmith's synthesis heuristics ----------------------------------------------------------- +From targets to a network: the pinch-outward MER planner +-------------------------------------------------------- :func:`~hensmith.synthesize_network` takes the heat utilities of the process, -runs the pinch analysis above, and then places exchangers. Streams are -numbered in a rearranged order -- heated streams first, then cooled streams -- -and every array, exchanger ID and life cycle uses that index. - -Each stream gets a **pinch temperature** of its own: the process pinch on the -stream's own scale (the table's ``pinch_T`` for a cold stream, that plus -``T_min_app`` for a hot one) when the stream crosses it; its inlet temperature -when the stream already starts past the pinch, or is isothermal or -non-monotone; its outlet temperature when the stream ends before reaching the -pinch. That temperature splits the stream's duty into a hot-side (above-pinch) -part and a cold-side (below-pinch) part, and the state of the stream there -- -computed by ``pinch_state``, with enthalpy clipped to the stream's real range --- is the state in which it enters the design on the far side of the pinch. -Each design works with its own **transient stream** per stream index, advanced -every time a match is made, so a candidate is always evaluated at the state -the stream has actually reached rather than at its original inlet. The passes -walk the streams by index, so the stream order is the matching priority. The -facility hands the utilities over sorted by signed duty, so by default the cold -stream with the smallest heating duty and the hot stream with the largest -cooling duty are tried first; ``sort_hus_by_T`` replaces that with inlet -temperature. - -Matching then proceeds in four passes, each creating ``HXprocess`` units that -exchange as much heat as the approach temperature (``dT``), the outlet -enthalpy of one stream (``H_lim0``) and a temperature limit on the other -(``T_lim1``) allow: - -1. **Cold-side design.** For each hot stream, the eligible cold streams are - those with a heat-capacity flow rate no greater than the hot stream's - (:math:`C_{hot} \ge C_{cold}`) and a current temperature more than - ``T_min_app`` below it. They are tried in decreasing order of - - .. math:: - - \min(C_{hot}, C_{cold}) \cdot (T_{hot} - T_{cold} - T_{min,app}), - - a rough measure of how much heat the match can move, with ``H_lim0`` the - hot stream's outlet enthalpy and ``T_lim1`` the cold stream's pinch - temperature; the loop ends as soon as the hot stream reaches its outlet - enthalpy. Streams lying entirely above the pinch are skipped, and so are - isothermal and non-monotone streams, whose pinch temperature equals their - inlet temperature and therefore marks them unavailable on both sides. - -2. **Hot-side design.** The mirror image, run per cold stream, with the - heat-capacity inequality reversed (:math:`C_{cold} \ge C_{hot}`), the same - ranking, ``H_lim0`` the cold stream's outlet enthalpy and ``T_lim1`` the - hot stream's pinch temperature; streams lying entirely below the pinch are - skipped. - - The two inequalities are the feasibility criteria of the pinch design - method: immediately below the pinch a match can only keep the approach - temperature over its whole length if the hot stream's heat-capacity flow - rate is at least the cold stream's, and immediately above it the reverse. - -3. **Offset passes.** Two clean-up passes, one for the heating still owed on - the cold side and one for the cooling still owed on the hot side, walk the - streams in index order and match any pair that still has opposite demands - on that side and is at least ``T_min_app`` apart. These passes drop the - heat-capacity inequality and use the limited stream's own *outlet* - temperature instead of its pinch temperature as ``T_lim1``; in the - hot-side pass the cold stream is taken in whichever of its two transient - states carries the most enthalpy. - -4. **Utility exchangers.** One rigorous ``HXutility`` per stream finishes the - job, taking the stream from its furthest transient state to its required - outlet enthalpy. The result is asserted against the stream's quenched - outlet enthalpy and temperature, so a network that would not actually - deliver the specified outlets fails loudly rather than silently. - -Three settings guard the passes. ``Qmin`` (default ``1e-3`` kJ/hr) discards -any candidate exchanger whose duty comes out below it. A match whose -``HXprocess`` cannot be simulated is discarded too, and because each candidate -is keyed by its exchanger ID -- ``HX___cs`` on the cold side, -``HX___hs`` on the hot side -- **a given ordered pair is attempted -at most once per side**, across the design pass and the offset pass that share -that ID namespace. Finally, ``avoid_recycle=True`` refuses any pair already -matched anywhere in the four passes, so that no two exchangers connect the -same pair of streams -- a second exchanger between the same two streams can -form a recycle loop in the network. - -The passes are what make the result a *heuristic* network: every match is -committed as soon as it is made, and no pass revisits an earlier decision. +runs the problem table above, plans a network *without stream splits* that +reaches the MER targets whenever its search finds one, and realizes the plan +as BioSTEAM exchangers. Streams are numbered in a rearranged order -- heated +streams first, then cooled streams -- and every array, exchanger ID and life +cycle uses that index. The order only breaks ties in the planner's search: the +facility hands the utilities over sorted by signed duty, and +``sort_hus_by_T`` sorts them by inlet temperature instead. + +**The planner's model.** Each stream enters the planner as its knots on the +problem-table grid: its enthalpy at every grid temperature inside its range, +with two knots where its curve has a flat. Every breakpoint of every curve is +a grid point and the table is linear between grid points, so the planner's own +cascade reproduces the table exactly -- the same targets, the same pinch and +the same pinch cut. Each stream is cut at the pinch into an above-pinch part +and a below-pinch part (a stream lying wholly on one side has an empty part on +the other), and the two sides are planned as two independent problems. + +**Must and flex streams.** Above the pinch no cold utility may be used, so +every hot stream there is a **must**: process matches have to cool it +completely. The cold streams above the pinch are **flex** streams: whatever +their matches leave over is supplied by one hot utility at their far (hot) +end. Below the pinch the roles swap: no hot utility may be used, so every cold +stream is a must, and the hot streams are flex streams finished by one cooler +at their cold end. Putting a flex stream's utility at its far end loses +nothing: moving a stream's later matches toward its inlet never reduces the +approach of any match on it, because every stream's curve is monotone. + +**Building each side from the pinch outward.** A depth-first search builds +each side one match at a time, starting at the pinch, where the driving forces +are smallest, and moving outward. A match of duty :math:`x` between a must and +a flex stream is feasible only if the two streams keep ``T_min_app`` at every +knot along it, so internal pinches -- a condensing vapor against a boiling +mixture, say -- are respected, not just the exchanger's terminals. Every step +also keeps the problem table of the *remaining* problem feasible (remaining +problem analysis): the largest duty that does so has a closed form, so no step +can make MER unreachable by its own table. Wherever that remaining table is +tight -- a pinch of the remaining problem -- the pinch design rules of Linnhoff +and Hindmarsh must hold: + +- above the pinch, every hot stream at the pinch needs its own cold stream at + the pinch, with :math:`C_{hot} \le C_{cold}` (the number rule + :math:`N_{hot} \le N_{cold}` and the heat-capacity-flow rule); +- below the pinch, every cold stream at the pinch needs its own hot stream at + the pinch, with :math:`C_{hot} \ge C_{cold}`. + +Both rules are generalized to isothermal segments: a flat -- a pure component +boiling or condensing exactly at the pinch -- has unlimited series capacity +and can serve several partners in turn. At the process pinch itself a +violation of these rules *proves* that MER needs stream splitting. + +The candidate duties of a match are the largest feasible one and a finite set +of events: a stream is ticked off, a partner is saved for another stream, a +stream switches partner or returns to an earlier one. The search is budgeted +in deterministic work units rather than seconds, so its result does not depend +on the speed of the machine. Once a MER plan is found, a branch and bound on +the number of exchangers looks for a smaller one; consecutive pieces of the +same match are merged into one exchanger. + +**Repeated pairs.** The same hot and cold stream may be matched more than +once on the same side: alternating two partners in series emulates a split, +and some unsplit MER networks need it. Process exchangers are named +``HX___hs`` above the pinch (the hot-side design) and +``HX___cs`` below it (the cold-side design), the first number being +the stream at port 0; the *n*-th exchanger of the same pair on the same side, +counted in the order the hot stream meets them, gets the suffix ``_`` for +:math:`n \ge 2` (for example ``HX_3_2_cs_2``). Utility exchangers are +``Util__hs`` for a cold stream and ``Util__cs`` for a hot one. +``avoid_recycle=True`` forbids matching any pair twice anywhere -- on one side +or across the two -- so that no two exchangers connect the same pair of +streams, at the cost of the MER networks that need a repeated pair. + +**Best effort when splitting is needed.** A side whose pinch rules prove that +MER needs a split, or whose search runs out of budget, gets a best-effort plan +instead. Heat that a must stream cannot place (a *gap*) is moved to the +stream's pinch end, where it crosses the pinch at the cost of an equal amount +of extra hot and cold utility, the *penalty*; greedy dives and a bisection of +the gaps keep that penalty small, though not minimal in general. Such a +network reports ``'best_effort'``. + +**Small matches.** A planned exchanger with a duty below ``Qmin`` (default +``1e-3`` kJ/hr) is dropped and its duty left to the utilities. Removing a +match never reduces the approach of another, so the rest of the plan stays +feasible; a large ``Qmin`` can, however, cost MER. + +**Realization.** Each stream is walked in flow order from its inlet: a hot +stream through its above-pinch matches (from its inlet end), then its +below-pinch matches, then its cooler; a cold stream through its below-pinch +matches, then its above-pinch matches, then its heater. Each match becomes one +plain ``HXprocess`` whose two enthalpy limits, ``H_lim0`` and ``H_lim1``, are +the planned outlet enthalpies of its two streams, so that its duty reproduces +the plan. 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, or a state of a point-load stream +on the wrong side of its inlet. That stream's limit is left out, and the other +stream's sets the duty. A stream's first exchanger receives its real inlet -- +except a point-load stream, which enters at equilibrium at its inlet enthalpy, +on the side of its outlet temperature where the plan put its duty -- and every +later exchanger receives the stream's exact state at the planned enthalpy. +Each exchanger is simulated once; one whose duty differs from the plan is +reported, and one that cannot be simulated at all is dropped, its duty going +to the utilities. Finally one rigorous ``HXutility`` per stream takes it to its outlet +enthalpy, and an ``AssertionError`` is raised if that does not reproduce the +quenched outlet enthalpy and temperature, so a network that would not deliver +the specified outlets fails loudly rather than silently. + +**Exact approach verification.** The knots are exact at grid points but are +chords in between, up to 0.002 K off inside glides and curved single-phase +stretches. Every planned exchanger is therefore checked on the exact states of +its two streams wherever its planned approach is within that margin of +``T_min_app``: at its ends, at every breakpoint inside it and, where the exact +approach is not linear between two positions, by a search for a dip in +between. ``HXprocess`` itself checks only its two terminals, which would miss +an internal pinch at a phase change. 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, for 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. Streams of constant heat capacity never need either step. + +**The report.** ``synthesize_network(..., info={})`` fills the dictionary it is +given, and :class:`~hensmith.HeatExchangerNetwork` keeps it as +``synthesis_info``. ``'status'`` is ``'mer'`` only if the utilities of the +*realized* network, computed from the simulated exchanger duties, equal the +targets (to 1e-6 of the total stream duty), and ``'best_effort'`` otherwise. +Next to it are the targets (``'Q_hot_target'``, ``'Q_cold_target'``), the +planned and the realized utilities, the ``'penalty'``, per side of the pinch +(``'sides'``) the search method, its work, any proof that a split is needed +and the gaps, the planner's own targets and pinch, the number of refinement +rounds, the smallest approach inside any process exchanger, the matches that +were shrunk (``'repaired'``), dropped (``'qmin_dropped'``, ``'dropped'``) or +deviated from their plan (``'deviations'``), and the point-load streams. The +full list is under the ``info`` keyword of +:func:`~hensmith.synthesize_network`. Rigor and phase change ---------------------- Process streams in a biorefinery boil, condense and change composition, so hensmith never assumes a constant heat capacity. Every enthalpy it uses comes -from a thermosteam flash: +from thermosteam: - **Quenched outlets.** Before any analysis, each stream's outlet copy is re-flashed at its own enthalpy (``s.vle(H=s.H, P=s.P)``). An upstream ``HXutility`` that was not solved rigorously can leave an outlet in a non-equilibrium state; quenching puts that heat at the temperature the equilibrium model says it is available at. -- **Interval enthalpies.** Within the problem table a single stream copy is - walked down the grid, so each flash is warm-started from the previous - boundary, and every result is clipped to the stream's own enthalpy range. If - a flash fails, hensmith warns and interpolates that boundary's enthalpy - linearly in temperature rather than abandoning the table. -- **Pinch states.** ``pinch_state`` flashes the inlet copy at the stream's - pinch temperature and accepts the result only when its enthalpy lies inside - the stream's real range; otherwise the stream never passes through that - equilibrium state, and the equilibrium state at the nearer *end* enthalpy is - used instead. Either way the hot-side and cold-side loads split - :math:`|H_{in} - H_{out}|` exactly, and the transient stream used for - matching never carries heat the real stream does not have. -- **Point loads.** A condenser or reboiler stream that changes phase at one - temperature contributes its whole duty at that temperature instead of being - smeared over an interval, which is what keeps the cascade -- and the pinch - it locates -- correct for latent duties. +- **Curves instead of flashes on the grid.** Flashing every stream at every + grid temperature has three defects that the stream curves avoid. A phase + boundary or a curvature inside a grid interval is averaged away (see *The + shifted grid* above). A ``vle(T=T_sat)`` of a single chemical keeps + whatever phase split the stream had, so the enthalpy exactly at a + saturation temperature depends on history; the curve takes a pure + component's latent heat as a flat between V-specified saturated states and + evaluates single-phase stretches with their phases fixed, so a grid point + on a saturation temperature is never ambiguous. And thermosteam's + two-phase flashes of some mixtures (water and ethanol with 20-50 % ethanol, + for instance) silently return non-converged states; the curve traces a + binary glide along its bubble-point curve and sanity-checks the flashes of + other mixtures. +- **Point loads.** A pure component's latent heat contributes at its + saturation temperature as a point load, and so does the whole duty of an + isothermal or non-monotone stream at its outlet temperature, instead of + being smeared over an interval; that is what keeps the cascade -- and the + pinch it locates -- correct for latent duties. +- **Pinch states.** ``pinch_state`` returns the state of a stream at a pinch + temperature from its curve, deterministically: a pinch on the stream's own + saturation temperature is resolved by the side of the pinch cut, never by + whatever phase split a previous flash left, and the enthalpy is clipped to + the stream's real range, so the state never carries heat the real stream + does not have. It is a standalone analysis helper; the synthesis plans on + the curves themselves. - **Rigorous exchangers.** Every synthesized process exchanger is an - ``HXprocess`` solved rigorously against its ``dT``, ``H_lim0`` and - ``T_lim1`` limits, and every synthesized utility exchanger is an - ``HXutility`` with ``rigorous=True``, specified by enthalpy rather than by - temperature. + ``HXprocess`` simulated from exact inlet states with both enthalpy limits + at its planned outlets and its approach verified on exact states, and every + synthesized utility exchanger is an ``HXutility`` with ``rigorous=True``, + specified by enthalpy rather than by temperature. The network as a BioSTEAM system -------------------------------- @@ -330,16 +426,26 @@ anything listed in ``ignored``, and anything with zero duty, and sorts what is left by duty. Auxiliary exchangers -- a column's condenser and reboiler, a flash's feed heater -- are included like any other. -**Convergence.** The new exchangers are assembled into a network with -``Network.from_units(..., interaction=False)``, whose path follows the rewired -stream connections rather than the order in which the exchangers were -synthesized. That ordering matters because a hot-side exchanger is created -before the cold-side ones that feed it, and a single pass in synthesis order -would run it on stale inlets; if the path cannot be fully ordered, hensmith -warns and leaves convergence to settle the loop. The resulting ``HXN_sys`` has -its tolerance set with ``method='fixedpoint'`` on itself and its subsystems -and is converged with ``System.converge()``; should convergence raise, every -unit is run once and a ``RuntimeWarning`` is issued. +**Convergence.** After synthesis each stream's stages are rewired in series, +and the new exchangers are assembled into a ``System``, ``HXN_sys``, whose +path follows the streams: every stage links to the next stage of the same +stream, and the path is a topological order of that graph (Kahn's algorithm, +ties broken by the order of the exchangers). Where the graph has a cycle -- +a pair of streams matched both above and below the pinch, or repeated matches +in alternating order -- the unit with the fewest unplaced predecessors comes +next and its inlets from later units become recycle streams. Every exchanger +starts at its planned state, so the loops are at their fixed point after one +pass; the system is converged by fixed-point iteration to tight tolerances +(a temperature change of 1e-8 K), which closes the energy balance to about +1e-10 %. Should convergence raise, every unit is run once and a +``RuntimeWarning`` is issued. + +**Streams served by process exchange alone.** A stream that its process +exchangers bring to its outlet (to within 1e-9 of its duty, the residual of +the enthalpy flashes) leaves its utility exchanger in exactly the state it +enters it, so that exchanger has no duty and no cost -- rather than a spurious +duty of a few 1e-9 kJ/hr from re-flashing the stream, which biosteam would +design and cost as a minimum-size exchanger. **Where it sits among the facilities.** ``network_priority = -2`` is lower than that of any other standard BioSTEAM facility, and facilities are @@ -354,24 +460,37 @@ replaces is reported as adding nothing rather than as a credit. Its heat utilities are the new utilities summed by agent with the *reversed* original ones -- new minus original -- so a negative utility cost on the facility is a saving. Setting ``replace_unit_heat_utilities=True`` instead overwrites each -original unit's heat utility with the new one and leaves the facility itself -carrying none. If no process match was made at all, the facility reports zero +original unit's heat utility with that of its own stream's utility exchanger, +reloads the utility costs of the unit and of its owner, and leaves the +facility itself carrying none; the original data are given back before the +network is costed again, so the next network is synthesized from the units' +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 with updated inlet states and enthalpy -limits instead of being synthesized again. If the reused network then fails -its outlet checks, hensmith warns, discards the cache and re-synthesizes from -scratch. If it fails its energy balance instead, the cache is discarded and -the network re-synthesized silently; a warning is issued only if the freshly -synthesized network fails the same check. +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 +duty at which its limit sat at synthesis, so a changed feed rescales every +stage instead of letting the first one take the whole duty. Its partner, the +flex stream, transfers what that sets, but never past its own outlet, and +takes the rest to its utility exchanger; a port that had no limit at +synthesis gets none. The utility exchangers bring every stream to its new +outlet enthalpy. A reused network is not planned again, so it need not be at +MER for the new duties, and ``synthesis_info`` still describes the synthesis +that produced it. If the reused network then fails its outlet checks, +hensmith warns, discards the cache and re-synthesizes from scratch. If it fails +its energy balance instead, the cache is discarded and the network +re-synthesized silently; a warning is issued only if the freshly synthesized +network fails the same check. Validation ---------- -Nothing about a heuristic network is self-evidently right, so hensmith checks -it in four ways. +hensmith checks every network it synthesizes, and its test suite checks the +synthesizer against independent references. **The energy balance.** After convergence, every process exchanger duty is counted twice -- it satisfies a cooling demand and a heating demand at once -- @@ -397,37 +516,86 @@ as the original exchanger's outlet: composition, pressure and enthalpy are asserted stream by stream. The utility exchangers created during synthesis are checked the same way, against the quenched outlet enthalpy and temperature. -**The MER bound.** The targets of the problem table are a lower bound the -network cannot beat. ``tests/test_hxn_regression.py`` builds ten synthetic +**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. + +**MER identification and achievement.** ``tests/test_hxn_mer.py`` synthesizes +a corpus of 78 problems through the public facility. For 40 of them -- 24 +constant heat capacity problems from the literature and 16 +real-thermodynamics problems with condensers and boilers at the pinch, +desuperheating and subcooling, and binary glides; 11 with more than ten +streams -- an unsplit MER network exists, proven inside the suite by a +certificate network that is re-checked there (by plain arithmetic for +constant heat capacity); the synthesized network must reach the targets and +report ``'mer'``. For the other 38 (25 from the literature and 13 with real +thermodynamics; 13 with more than ten streams) the pinch design rules prove +that MER needs stream splitting, a proof re-derived in the test module; the +network must never beat the targets and must report ``'best_effort'``. In both sets the targets must equal an independent +reference -- a closed-form constant heat capacity cascade and the published +values, or a dense-grid calculator for real thermodynamics -- and every +material and energy balance and the exact internal approach of every +exchanger are checked. + +**Regression cases.** ``tests/test_hxn_regression.py`` builds ten synthetic 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 -(iii) recovers at least as much heat as a load recorded in the test file. A -network that improves leaves slack in (iii); those recorded numbers are -lowered deliberately by a maintainer, never raised to make a failing test -pass. +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 +recorded numbers are lowered deliberately by a maintainer, never raised to +make a failing test pass. **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. -Limitations ------------ - -hensmith implements the classical pinch design method with a specific set of -heuristics. Four consequences are worth stating plainly. - -- **The network is heuristic, not optimal.** Matches are committed in sequence - and never revisited; there is no search over the space of networks and no - optimization of area, capital or number of units. The result depends on the - order of the streams and on the driving-force ranking, and it is not - guaranteed to reach the MER targets -- only never to beat them. +Guarantees and limitations +-------------------------- + +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. +- **Deterministic.** The search is budgeted in work units, not seconds, so + the same streams give the same network however fast the machine is. + +What it is not: + +- **MER is reached whenever the planner finds an unsplit network -- which is + an empirical, not a proven, property.** Every pruning test of the search is + a necessary condition, so a missed MER network can only come from the + finite set of candidate duties, the caps on repeated pairs or the work + budgets. The planner reached MER on every problem of a certified benchmark + of about 1,700 problems with 2 to 40 streams for which an unsplit MER + network exists, and it does so on all 40 no-split problems of the test + suite, but no proof covers every problem. - **Streams are not split.** Every stream stays a single branch through the - network. Where the pinch design method would split a stream to satisfy the - heat-capacity flow rate inequality, hensmith simply does not make that match - in the design pass; the duty is picked up later by an offset pass or by a - utility exchanger. + network. Where the pinch design rules prove that MER needs a split, the + network is a best-effort one whose penalty is small but not minimal in + general; repeated matches between the same two streams, alternating in + series, can approach a split only in the limit. +- **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. +- **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 + step, which costs time rather than quality. +- **Flash failures inside some glides.** Thermosteam's TP flashes fail + silently inside the glides of some mixtures (water and ethanol with 20-50 % + ethanol, for instance); an exchanger simulated there can deviate from its + plan, and is then reported in ``synthesis_info['deviations']``. - **Only streams behind existing utility exchangers are integrated.** The facility sees a process stream only through a heat utility attached to a unit of the system. A duty carried some other way is invisible to it; @@ -445,12 +613,13 @@ References - Linnhoff, B., & Hindmarsh, E. (1983). The pinch design method for heat exchanger networks. *Chemical Engineering Science*, 38(5), 745-763. -- 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). +- Smith, R. (2005). *Chemical Process Design and Integration*. Wiley. - Kemp, I. C. (2007). *Pinch Analysis and Process Integration: A User Guide on Process Integration for the Efficient Use of Energy* (2nd ed.). Butterworth-Heinemann. +- 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). - 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 7437eda..7943049 100644 --- a/docs/source/contributing/contributing.rst +++ b/docs/source/contributing/contributing.rst @@ -9,7 +9,8 @@ change has to respect. Where the code lives -------------------- -The library is two modules under ``hensmith/``: +The library is two public modules under ``hensmith/``, and two private ones +behind them: ``hensmith/_heat_exchanger_network.py`` The ``HeatExchangerNetwork`` facility unit: its ``_run``, ``_design`` and @@ -17,13 +18,26 @@ The library is two modules under ``hensmith/``: utilities. ``hensmith/hxn_synthesis.py`` - The algorithms: the problem table and pinch analysis (``problem_table``, - ``ProblemTable``), network synthesis (``synthesize_network``), the - per-stream bookkeeping of ``StreamLifeCycle``, and ``plot_pinch_diagram``. - -``hensmith/__init__.py`` re-exports the ``__all__`` of both modules and holds -the biosteam registration block described in `The import contract`_ below. -The public API of both modules is documented under :doc:`../API/api`. + The problem table and pinch analysis (``problem_table``, + ``ProblemTable``), network synthesis (``synthesize_network``: realizing + the planner's network as BioSTEAM exchangers and verifying it on exact + stream states), the per-stream bookkeeping of ``StreamLifeCycle``, and + ``plot_pinch_diagram``. + +``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 + the synthesis work. + +``hensmith/_planner.py`` (private) + The pinch-outward planner of unsplit networks at minimum energy + requirement, on numbers only (numpy; no BioSTEAM objects). Its module + docstring documents the model, the lemmas the search relies on and its + guarantees. + +``hensmith/__init__.py`` re-exports the ``__all__`` of the two 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/``: @@ -31,12 +45,31 @@ Tests live in ``tests/``: Behavior of the ``HeatExchangerNetwork`` unit on small, hand-built systems. +``tests/test_hxn_targets.py`` + The stream curves, the problem table built on them, the pinch cut and + the exact internal-approach check, against an independent dense-grid + calculator of the targets. + +``tests/test_hxn_planner.py`` + The planner on numbers only, against helpers written in the test module: + a constant heat capacity problem table, the pinch design rules, a network + walk and direct feasibility checks. + +``tests/test_hxn_mer.py`` (data in ``tests/hxn_mer_cases.py``) + 78 problems synthesized through the public facility: 40 for which an + unsplit MER network provably exists, where the network must reach the + targets, and 38 that provably need stream splits, where it must never + beat them; the targets are checked against independent references, and + every balance and the exact internal approach of every exchanger are + checked in both sets. + ``tests/test_hxn_regression.py`` Ten synthetic systems of increasing complexity. For each, the synthesized network must close its energy balance, must not beat the minimum-energy - requirement targets of the problem table computed on the same streams, - and must recover at least as much heat as the utility loads documented in - the file. + requirement targets of the problem table computed on the same streams + (and must report ``'mer'`` exactly when it reaches them), must keep the + minimum approach temperature inside every exchanger, and must recover at + least as much heat as the utility loads documented in the file. Development environment ----------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 06aed82..935cb1f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -38,8 +38,10 @@ hensmith (**H**\ eat **E**\ xchanger **N**\ etwork **S**\ ynthesis, is the automated heat exchanger network synthesis facility for BioSTEAM systems: :class:`HeatExchangerNetwork` is a BioSTEAM ``Facility`` that performs a pinch analysis on every heating and cooling utility in a system, -synthesizes a network of process exchangers, and reports the utility savings -and added capital cost as part of the system's techno-economic analysis. +synthesizes a network of process exchangers that reaches the minimum energy +requirement (MER) whenever it finds one without stream splits, and reports the +utility savings and added capital cost as part of the system's +techno-economic analysis. Watch it run in the `Quickstart`_ demo below. Quickstart @@ -49,8 +51,9 @@ The canonical example is a small methanol/water system: a shortcut distillation column whose condenser and reboiler are auxiliary exchangers, a cooler on each of the column's two products, and a flash whose feed is heated by its own auxiliary exchanger. Adding a :class:`HeatExchangerNetwork` to that -system cuts its heating utility by 17.5 % and its cooling utility by 96.8 % -with 4 process exchangers, and the network's installed cost joins the +system cuts its heating utility by 17.5 % and its cooling utility by 96.9 % +with 4 process exchangers -- a network at the MER targets of its pinch +analysis -- and the network's installed cost joins the system's techno-economic analysis like any other unit's. The interactive demo below runs it end to end: build the flowsheet, add the network, simulate, and inspect the synthesized exchangers and the pinch diagram. @@ -114,7 +117,7 @@ how to configure the network for a larger system. :class: only-light :align: center - Pinch analysis and the synthesis heuristics + Pinch analysis and MER network synthesis .. grid-item-card:: API Reference diff --git a/docs/source/tutorial/01_quickstart.rst b/docs/source/tutorial/01_quickstart.rst index 3248fda..134c0bd 100644 --- a/docs/source/tutorial/01_quickstart.rst +++ b/docs/source/tutorial/01_quickstart.rst @@ -152,13 +152,20 @@ adding nothing, rather than as a capital credit. The heating load falls from 3.609e+08 to 2.977e+08 kJ/hr, a reduction of 17.5 %; the ratio of the two, 0.82, is the value checked by the :class:`~hensmith.HeatExchangerNetwork` docstring example. The cooling load -falls from 6.201e+07 to 1.96e+06 kJ/hr, a reduction of 96.8 %: nearly all of +falls from 6.201e+07 to 1.936e+06 kJ/hr, a reduction of 96.9 %: nearly all of the cooling duty of this system can be recovered into a stream that needed heating. Four process exchangers do that work, and they are the ``new_HXs`` of the network. The energy balance error, -1.8e-11 %, checks that the synthesized network moves exactly as much heat as the original one; it is computed on every synthesis and compared against ``acceptable_energy_balance_error``. +The last line is the verdict of the synthesis, ``HXN.synthesis_info['status']``: +``mer`` means that the utilities of this network equal the minimum energy +requirement (MER) targets of the pinch analysis -- no network of process +exchangers operating with a 5 K approach can use less heating or cooling. +:doc:`02_pinch_analysis` computes those targets and compares them with the +network line by line. + Draw the pinch diagram ---------------------- @@ -171,7 +178,7 @@ Draw the pinch diagram .. figure:: /_static/images/examples/tutorial_01_quickstart_pinch_diagram.png :class: white-bg :width: 100% - :alt: Pinch diagram of the synthesized quickstart network: two blue cold streams above three red hot streams, joined by four vertical process-exchanger connectors labelled 3.34E4, 5.03E6, 3.71E7 and 1.79E7 kJ/hr, all to the right of the dashed pinch line, with hot utility circles at the outlet of both cold streams and one cold utility circle on the hot stream D1_H1 (bottoms_product). + :alt: Pinch diagram of the synthesized quickstart network: two blue cold streams above three red hot streams, joined by four vertical process-exchanger connectors labelled 5.05E6, 3.71E7, 3.34E4 and 1.79E7 kJ/hr, all to the right of the dashed pinch line, with hot utility circles at the outlet of both cold streams and one cold utility circle on the hot stream D1_H1 (bottoms_product). The synthesized network, read as a pinch diagram. The two cold streams (blue, drawn left to right) are ``0`` ``D1 - reboiler`` and ``1`` @@ -180,10 +187,10 @@ Draw the pinch diagram ``D1 - condenser (vapor)`` and ``4`` ``D1_H2 (distillate)``. Each stream is annotated with its inlet and outlet temperature and enthalpy flow. The four vertical connectors are the process exchangers, each labelled with its duty - in kJ/hr: 3.34E4 between streams 1 and 4, 5.03E6 between 1 and 2, 3.71E7 - between 0 and 2, and 1.79E7 between 1 and 3. Columns are ordered so that a + in kJ/hr: 5.05E6 between streams 1 and 2, 3.71E7 between 0 and 2, 3.34E4 + between 1 and 4, and 1.79E7 between 1 and 3. Columns are ordered so that a stream meets its exchangers in flow direction, which is why stream 1 reads - 3.34E4, 5.03E6, 1.79E7 from left to right. The dashed line is the pinch, + 5.05E6, 3.34E4, 1.79E7 from left to right. The dashed line is the pinch, separating the cold-side design on its left from the hot-side design on its right; all four exchangers of this network lie on the hot side. The open circles are the utility exchangers that finish each stream: a hot utility diff --git a/docs/source/tutorial/02_pinch_analysis.rst b/docs/source/tutorial/02_pinch_analysis.rst index 806550b..8216e03 100644 --- a/docs/source/tutorial/02_pinch_analysis.rst +++ b/docs/source/tutorial/02_pinch_analysis.rst @@ -70,22 +70,32 @@ temperature range. Hot streams are shifted *down* by ``T_min_app`` and cold streams are left alone. On that shifted scale, two streams at equal temperature are in reality exactly ``T_min_app`` apart, so heat may be cascaded from any shifted -temperature to any lower one without ever violating the minimum approach. In -the grid above, the hot stream's two end temperatures appear shifted down by -the 5 K approach as 395 and 295 K, while the cold stream's appear unshifted as -390 and 300 K; the grid ``Ts`` is the union of all such end temperatures, -sorted descending. - -Between consecutive grid temperatures, each monotone stream contributes the -enthalpy it releases or absorbs over that interval, evaluated at its *real* +temperature to any lower one without ever violating the minimum approach. The +grid above runs from 395 K, the hot stream's inlet shifted down by the 5 K +approach, to 295 K, its shifted outlet; the cold stream's ends, unshifted at +390 and 300 K, lie in between. The grid holds 25 points rather than those four +because it is the union, sorted descending, of the breakpoints of every +stream's temperature-enthalpy curve. hensmith describes each stream by such a +curve, built once from a handful of flashes: its breakpoints are the stream's +end temperatures, every phase boundary inside its range, and -- since the heat +capacity of liquid water varies with temperature -- interior points that keep +a straight line between neighbours within 0.002 K of the true curve. Every +temperature at which a curve bends is therefore a grid point, and nothing that +happens inside an interval can hide a pinch. + +Between consecutive grid temperatures, each stream contributes the enthalpy +its curve releases or absorbs over that interval, evaluated at its *real* temperature and clipped to its own enthalpy range, with a positive sign for hot -streams and a negative one for cold. Because the grid always contains a -stream's own end temperatures, those contributions telescope exactly to the -stream's duty: no heat is created or lost by the discretization. Streams with -no temperature span of their own -- an isothermal condenser, or a stream whose -outlet moves against its duty, such as a reboiler outlet at equilibrium -- are -not spread over intervals at all; they enter as *point loads* at their shifted -outlet temperature. +streams and a negative one for cold. Because the grid always contains every +breakpoint of a stream's curve, its own end temperatures among them, those +contributions telescope exactly to the stream's duty: no heat is created or +lost by the discretization. Heat that a curve gives up or takes at a single +temperature is not spread over an interval at all but enters as a *point load* +at that grid temperature: the latent heat of a pure component boiling or +condensing at its saturation temperature, and the whole duty of a stream with +no temperature span of its own -- an isothermal condenser, or a stream whose +outlet moves against its duty, such as a reboiler outlet at equilibrium -- +which sits at its shifted outlet temperature. Cascading those contributions down the grid, with no hot utility supplied, gives the heat *leaving* each boundary, the ``residual`` field. Feasibility @@ -114,22 +124,21 @@ The quickstart system is the same computation on five streams: .. literalinclude:: /_generated/ch02_table.txt :language: text -The five streams produce a grid of ten shifted temperatures, from 372.6 K down -to 295 K. Ten boundaries out of five streams is itself a statement about the -streams: a monotone stream contributes both of its end temperatures and a -point load contributes only one, so every stream here is monotone. Exactly -equal boundaries would be merged into one grid entry; the two entries that -print as 333 are the same temperature -- the condenser's outlet is the -cooler's inlet -- kept apart only by floating-point round-off of the -equilibrium quench, far below any printed precision. Four of those boundaries -sit within about half a Kelvin of each other around 333 K -- 333.53, 333 twice, -and 332.98. The column's condenser spans the upper two, 333.53 down to 333, -which is 65.4 down to 64.9 °C on the real scale; the distillate cooler -``D1_H2`` takes the stream from there, so its own upper boundary is that second -333, and the outlet the analysis works with lies only 0.02 K below it, at -332.98, because the cooler removes just 3.34e+04 kJ/hr. Neither is a point -load: both are spread over intervals like any other stream, only very narrow -ones, and the cooler's load is too small to see on the curves below. The +The five streams produce a grid of 175 shifted temperatures, from 372.60 K +down to 295.00 K. Their shifted end temperatures are among them; most of the +rest trace two-phase glides. Every stream here is a mixture of water, methanol +and glycerol, so none boils or condenses at a single temperature: the +column's reboiler and the flash's feed heater heat a liquid past its bubble +point and on along a glide, the condenser and the distillate cooler ``D1_H2`` +glide from end to end, and the bottoms cooler ``D1_H1`` cools a liquid whose +heat capacity varies with temperature. Each glide and each curved stretch is +sampled until a straight line between neighbouring points is within 0.002 K +of the true curve. With no flat segment in any curve, and every outlet moving +in the direction of its stream's duty, the table has no point loads at all -- +the second line. The condenser is still the most conspicuous stream: it gives +up its latent heat over about half a Kelvin, from 65.4 to 64.9 °C on the real +scale (:doc:`03_network_anatomy` lists every stream's end temperatures), and +the distillate cooler takes the stream on from there. The targets are 2.828e+08 kJ/hr of hot utility and 1.936e+06 kJ/hr of cold utility, and the pinch is at 298.15 K on the shifted scale. Since hot streams were shifted down by the 5 K approach, that one shifted temperature stands for two @@ -205,17 +214,17 @@ The same cascade can also be plotted directly, as a grand composite curve: .. figure:: /_static/images/examples/tutorial_02_grand_composite.png :class: white-bg :width: 720 - :alt: Grand composite curve of the quickstart system: heat cascaded in GJ/hr against shifted temperature in °C, running from the top of the grid down through an open circle where the curve touches zero at the pinch, 25.0 °C on the shifted scale (298.15 K), and on below the pinch to the bottom of the grid at 295 K, with a near-horizontal step near 60 °C shifted where the column condenser condenses over a span of about half a Kelvin, between the grid boundaries 333.53 and 333 K. + :alt: Grand composite curve of the quickstart system: heat cascaded in GJ/hr against shifted temperature in °C, running from the top of the grid down through an open circle where the curve touches zero at the pinch, 25.0 °C on the shifted scale (298.15 K), and on below the pinch to the bottom of the grid at 295 K, with a near-horizontal step near 60 °C shifted where the column condenser condenses over a span of about half a Kelvin. The grand composite curve: the heat cascaded through each shifted grid temperature once the minimum hot utility is supplied, plotted against that shifted temperature. Each boundary contributes two values, the heat arriving at it and the heat leaving it after its point loads, so a point load would appear as an exactly horizontal step. This system has none: the - near-horizontal step near 60 °C shifted is the column condenser, spread over - the two grid boundaries about half a Kelvin apart, 333.53 and 333 K - on the shifted scale -- the same load that steps the hot composite curve at - the corresponding real temperature. The curve touches zero exactly at the + near-horizontal step near 60 °C shifted is the column condenser, whose + glide spans about half a Kelvin, 65.4 to 64.9 °C on the real scale and + 5 K lower on the shifted one -- the same load that steps the hot composite + curve at the corresponding real temperature. The curve touches zero exactly at the pinch, 298.15 K on the shifted scale, marked with an open circle, and continues below it to the bottom of the grid, 295 K. The value at the top of the curve is the hot utility supplied, 2.828e+08 kJ/hr, and the value @@ -238,7 +247,7 @@ of chapter 1 actually achieves is reported by the facility: .. literalinclude:: /_generated/ch02_compare.txt :language: text -The four lines are two different comparisons, and the difference between them +The first four lines are two different comparisons, and the difference between them is not a property of the network at all. The first pair uses ``HXN.actual_heat_util_load`` and ``HXN.actual_cool_util_load``, which sum the ``duty`` of each new utility exchanger's ``HeatUtility``. That is the @@ -249,29 +258,43 @@ instead -- the process-side duty of the same exchangers -- which is the quantity the problem table computes, an enthalpy difference of the process streams themselves. -Compared like with like, on the process side, the network reaches the hot -utility target exactly: 2.828e+08 kJ/hr against a target of 2.828e+08 kJ/hr. -The utility-side figure, 2.977e+08 kJ/hr, is that same target divided by the +Compared like with like, on the process side, the network reaches both +targets exactly: 2.828e+08 kJ/hr of hot utility and 1.936e+06 kJ/hr of cold +utility, against targets of 2.828e+08 and 1.936e+06 kJ/hr. The utility-side +heating figure, 2.977e+08 kJ/hr, is that same target divided by the heat-transfer efficiency of biosteam's low-pressure steam agent, which is below one; it is the steam the plant must raise, not heat the network failed to recover. The cold utility needs no such correction, because the cooling -agents used here (chilled and cooling water) have an efficiency of one and -both lines therefore read 1.96e+06 kJ/hr against a target of 1.936e+06 kJ/hr. -That small excess is a genuine shortfall of the network: the targets are a -bound the synthesizer works towards, not a guarantee it attains, because a -network has to be built from real exchangers between real streams, one side -of the pinch at a time. - -Both directions of that statement are checked by the test suite, and checked on -the process side: ``tests/test_hxn_regression.py`` compares with its -``actual_loads`` helper, which sums ``unit_duty`` exactly as the second pair of -lines above does. It synthesizes ten synthetic systems of -increasing complexity and requires of each synthesized network that it close -its energy balance, that it "never beat the minimum-energy-requirement (MER) -targets of the problem table computed on the same streams", and that it -"recover at least as much heat as documented in ``CASES``". A network that beat -its target would be reporting an infeasible design; a network that fell short -of a recorded result would be a silent regression in the synthesizer. +agents used here (chilled and cooling water) have an efficiency of one, and +both of its lines read 1.936e+06 kJ/hr. + +The last line says the same thing in one word: the synthesis reports +``HXN.synthesis_info['status']`` as ``mer`` because the utilities of the network +it realized equal these targets. That is by construction rather than by luck. +The synthesizer plans each side of the pinch from the pinch outward on the +same stream curves this table was built from, so its own cascade *is* this +table, and it reaches the targets whenever its search finds a network without +stream splits that does (:doc:`../concepts` describes the planner). Where the +pinch design rules prove that the targets need a stream split, which hensmith +does not make, the status is ``best_effort`` and the network lies slightly +above the targets instead; :doc:`04_configuring` shows both outcomes on this +system. + +Both directions of that statement are checked by the test suite, on the +process side. ``tests/test_hxn_mer.py`` synthesizes 40 problems for which an +unsplit MER network is known to exist and requires every one of them to reach +its targets and report ``mer``, and 38 problems that provably need splits, +which must never beat their targets and must report ``best_effort``. +``tests/test_hxn_regression.py`` compares with its ``actual_loads`` helper, +which sums ``unit_duty`` exactly as the second pair of lines above does. It +synthesizes ten synthetic systems of increasing complexity and requires of +each synthesized network that it close its energy balance, that it never beat +the MER targets of the problem table computed on the same streams (and report +``mer`` exactly when it reaches them), that it keep ``T_min_app`` inside every +exchanger, and that it recover at least as much heat as recorded in the test +file. A network that beat its target would be reporting an infeasible design; +a network that fell short of a recorded result would be a silent regression +in the synthesizer. Where to next ------------- diff --git a/docs/source/tutorial/03_network_anatomy.rst b/docs/source/tutorial/03_network_anatomy.rst index e9a928b..2b55629 100644 --- a/docs/source/tutorial/03_network_anatomy.rst +++ b/docs/source/tutorial/03_network_anatomy.rst @@ -50,35 +50,46 @@ before synthesis, so the network's IDs neither collide with the original flowsheet's nor accumulate across repeated simulations. ``HXN.HXN_sys`` is the ``bst.System`` built from the synthesized units. It is -constructed from a network of those units and named after the flowsheet, -``sys_HXN``, in whose system registry it is registered -- so -``HXN.HXN_flowsheet.system.sys_HXN`` resolves to it, just as the exchangers -resolve through ``HXN.HXN_flowsheet.unit``. It is an ordinary ``System`` -holding the nine units listed on the third line. They are listed in the order the system -simulates them, which is derived from the rewired stream connections rather -than from the order synthesis created them: a hot-side exchanger is synthesized -before the cold-side exchangers that feed it, so synthesis order would leave it -with stale inlets. Recycle loops in the network are converged by the system's -own fixed-point solver. +named after the flowsheet, ``sys_HXN``, in whose system registry it is +registered -- so ``HXN.HXN_flowsheet.system.sys_HXN`` resolves to it, just as +the exchangers resolve through ``HXN.HXN_flowsheet.unit``. It is an ordinary +``System`` holding the nine units listed on the third line. They are listed in +the order the system simulates them, which follows the streams: after +synthesis every stream's stages are rewired in series, each stage feeding the +next, and the path is a topological order of those connections, ties broken +by the order in which the synthesis returned the exchangers -- the process +exchangers in plan order, then the utility exchangers, hot streams first. +``HX_0_2_hs`` therefore runs first: it is the first stage of both of its +streams, while ``HX_1_2_hs`` has to wait for stream 2 to leave it. Where the +stages of a network form a loop -- a pair of streams matched on both sides of +the pinch, or two streams matched repeatedly in alternation -- the loop is +torn at a recycle stream and converged by the system's own fixed-point +solver. The IDs carry the whole topology. A process exchanger is an ``HXprocess`` named -``HX___hs`` when the match was made in the hot-side pass and -``HX___cs`` when it was made in the cold-side pass -- note that the -two orders differ: a cold-side exchanger names its hot stream first, a hot-side -one its cold stream first. A utility -exchanger is an ``HXutility`` named ``Util__hs`` for a cold stream, -which is finished by a hot utility above the pinch, and ``Util__cs`` for -a hot stream, which is finished by a cold utility below it. The indices are -stream indices: positions in the rearranged utility list of +``HX___hs`` when it lies above the pinch, in the hot-side design, and +``HX___cs`` when it lies below the pinch, in the cold-side design -- +note that the two orders differ: a cold-side exchanger names its hot stream +first, a hot-side one its cold stream first, and in both the first number is +the stream at port 0. When the same two streams are matched more than once on +the same side, the second and later exchangers carry a suffix, ``_2``, +``_3``, ..., in the order the hot stream meets them (``HX_3_2_cs_2``, say). A +utility exchanger is an ``HXutility`` named ``Util__hs`` for a cold +stream, which is finished by a hot utility above the pinch, and +``Util__cs`` for a hot stream, which is finished by a cold utility below +it. The indices are stream indices: positions in the rearranged utility list of :func:`~hensmith.synthesize_network`, cold streams first and then hot ones, as described in :doc:`02_pinch_analysis`. The stream copies are named after the exchanger they touch, ``s___`` on the way in and ``__s_`` on the way out. -All four process exchangers of this network end in ``_hs``: every match was -made in the hot-side pass, which is the same fact as the pinch diagram of +All four process exchangers of this network end in ``_hs``: every match lies +above the pinch, which is the same fact as the pinch diagram of :doc:`01_quickstart` showing all four connectors to the right of the pinch -line. +line. Below the pinch there is nothing to design: no cold stream needs heat +there (stream 1 enters exactly at the pinch temperature and stream 0 above +it, as the pinch temperatures below show), so the only stream below it, hot +stream 2, is finished by its cooler. .. code-block:: python @@ -87,7 +98,7 @@ line. .. figure:: /_static/images/examples/tutorial_03_hxn_flowsheet_light.png :figclass: only-light :width: 720 - :alt: Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_1_4_hs, HX_0_2_hs, HX_1_2_hs and HX_1_3_hs drawn as two-inlet nodes feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs. + :alt: Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_0_2_hs, HX_1_2_hs, HX_1_4_hs and HX_1_3_hs drawn as two-inlet nodes in a chain feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs. The synthesized network as its own flowsheet, ``sys_HXN``. The four two-inlet nodes are the process exchangers; each takes one cold and one hot @@ -97,13 +108,13 @@ line. drawn grey because they carry no utility at all -- their inlet and outlet enthalpies are equal, 2.47e+06 and 7.18e+05 kJ/hr, so streams 3 and 4 are brought to their outlet states by process heat exchange alone. The stream - names show the wiring: ``s_1__HX_1_4_hs`` enters ``HX_1_4_hs`` carrying - stream 1, and ``HX_1_4_hs__s_1`` leaves it and enters ``HX_1_2_hs``. + names show the wiring: ``s_1__HX_1_2_hs`` enters ``HX_1_2_hs`` carrying + stream 1, and ``HX_1_2_hs__s_1`` leaves it and enters ``HX_1_4_hs``. .. figure:: /_static/images/examples/tutorial_03_hxn_flowsheet_dark.png :figclass: only-dark :width: 720 - :alt: Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_1_4_hs, HX_0_2_hs, HX_1_2_hs and HX_1_3_hs drawn as two-inlet nodes feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs. + :alt: Flowsheet of the synthesized quickstart network: nine units, the four process heat exchangers HX_0_2_hs, HX_1_2_hs, HX_1_4_hs and HX_1_3_hs drawn as two-inlet nodes in a chain feeding the utility exchangers Util_0_hs and Util_1_hs (heating), Util_2_cs (cooling), and the grey zero-duty nodes Util_3_cs and Util_4_cs. The synthesized network as its own flowsheet, ``sys_HXN``. The four two-inlet nodes are the process exchangers; each takes one cold and one hot @@ -113,8 +124,8 @@ line. drawn grey because they carry no utility at all -- their inlet and outlet enthalpies are equal, 2.47e+06 and 7.18e+05 kJ/hr, so streams 3 and 4 are brought to their outlet states by process heat exchange alone. The stream - names show the wiring: ``s_1__HX_1_4_hs`` enters ``HX_1_4_hs`` carrying - stream 1, and ``HX_1_4_hs__s_1`` leaves it and enters ``HX_1_2_hs``. + names show the wiring: ``s_1__HX_1_2_hs`` enters ``HX_1_2_hs`` carrying + stream 1, and ``HX_1_2_hs__s_1`` leaves it and enters ``HX_1_4_hs``. Stream life cycles ------------------ @@ -136,18 +147,26 @@ The facility builds one per stream after synthesis, aligned with A life cycle has the attributes ``index``, the stream's index; ``name``, ``s_``; ``cold``, ``True`` for a heated stream and ``False`` for a cooled -one; and ``life_cycle``, the list of stages. It is recovered from IDs alone -- -the exchangers whose ID contains ``__``, keeping those whose inlet at the -matching position has an ID containing ``s__``. The -stages are then sorted by inlet enthalpy, ascending for a cold stream and -descending for a hot one, which is flow direction in both cases since a cold -stream gains enthalpy as it goes and a hot stream loses it. - -Read stream 1, the longest life cycle here: it passes ``HX_1_4_hs``, -``HX_1_2_hs`` and ``HX_1_3_hs`` and then its utility exchanger ``Util_1_hs``, -its enthalpy rising 0, 3.34e+04, 5.06e+06, 2.3e+07 and finally 2.79e+08 kJ/hr. -Stream 2 runs the other way, 4.52e+07 to 8.12e+06 to 3.1e+06 kJ/hr through two -process exchangers and then to 1.14e+06 kJ/hr through ``Util_2_cs``. Each +one; and ``life_cycle``, the list of stages. It is recovered from IDs alone: +each exchanger ID is parsed -- ``HX___``, with an optional +``_`` suffix, or ``Util__`` -- the first number being the stream +at port 0 and the second the stream at port 1, so a stream index is matched +exactly and never as a substring of another index. The stages are then sorted +by inlet enthalpy, ascending for a cold stream and descending for a hot one, +which is flow direction in both cases since a cold stream gains enthalpy as it +goes and a hot stream loses it (ties, which only stages without duty can +produce, put the stream's first side of the pinch first and its utility +last). + +Read stream 1, the longest life cycle here: it passes ``HX_1_2_hs``, +``HX_1_4_hs`` and ``HX_1_3_hs`` and then its utility exchanger ``Util_1_hs``, +its enthalpy rising 0, 5.05e+06, 5.08e+06, 2.3e+07 and finally 2.79e+08 kJ/hr. +Its first exchanger is the match the pinch design method asks for: stream 1 +enters exactly at the pinch temperature, and ``HX_1_2_hs`` pairs it there with +stream 2, the only hot stream that reaches the pinch, from which the planner +builds the hot-side design outward. Stream 2 runs the other way, 4.52e+07 to +8.12e+06 to 3.07e+06 kJ/hr through two process exchangers and then to +1.14e+06 kJ/hr through ``Util_2_cs``. Each stage's outlet enthalpy is the next stage's inlet enthalpy because the facility rewires the units after synthesis, making each stage's outlet stream the inlet of the following stage. Streams 3 and 4 end on a stage whose inlet and outlet @@ -173,7 +192,7 @@ process exchanger is constructed with its cold stream first. ``s_in`` and ``s_out`` are ``unit.ins[index]`` and ``unit.outs[index]``, and ``H_in`` and ``H_out`` are their enthalpies, so a life cycle always reflects the current state of the network rather than a snapshot taken at synthesis. This stage -takes stream 1 from 0 to 3.338e+04 kJ/hr. +takes stream 1 from 0 to 5.051e+06 kJ/hr. Per-stream pinch temperatures ----------------------------- @@ -182,8 +201,11 @@ The pinch analysis produces three arrays indexed like the life cycles, which the facility stores as ``HXN.inlet_Ts``, ``HXN.outlet_Ts`` and ``HXN.pinch_Ts``. The first two are each stream's inlet temperature and its quenched outlet temperature (:doc:`02_pinch_analysis`). The third is the -temperature at which a stream is handed from the cold-side design to the -hot-side design -- the point at which the synthesizer splits it in two. +temperature at which a stream crosses the process pinch on its own scale, +where it passes from the cold-side design into the hot-side design. It is +reported for information: the synthesizer cuts every stream at the pinch on +the stream's temperature-enthalpy curve itself, and ``pinch_Ts`` summarizes +where that cut lies. .. literalinclude:: /../_demo_src/examples/ch03_network_anatomy.py :language: python @@ -199,16 +221,16 @@ The process pinch of this system is a single shifted temperature, 298.15 K streams and, ``T_min_app`` higher, 30.0 °C for hot streams. Each stream is then classified against the pinch temperature of its own kind. -- A stream that reaches the pinch is split there, and its ``pinch_T`` is the +- A stream that reaches the pinch is cut there, and its ``pinch_T`` is the pinch temperature of its kind. Stream 2 crosses it, 98.2 to 26.8 °C, and - stream 1 enters exactly at it, 25.0 °C; they are split at 30.0 and 25.0 °C + stream 1 enters exactly at it, 25.0 °C; they are cut at 30.0 and 25.0 °C respectively. - A stream whose outlet stops short of the pinch never reaches it, and its ``pinch_T`` is its own *outlet* temperature: it lies wholly on one side, and - the split is a formality at its far end. Streams 3 and 4 are hot streams that + the cut is a formality at its far end. Streams 3 and 4 are hot streams that cool only to 64.9 and 64.8 °C, far above the 30.0 °C hot-stream pinch, and those outlet temperatures are exactly what ``pinch_Ts`` reports for them. -- A stream whose *inlet* is already past the pinch is likewise not split, and +- A stream whose *inlet* is already past the pinch is likewise not cut, and its ``pinch_T`` is its inlet temperature. Stream 0 is a cold stream entering at 33.2 °C, above the 25.0 °C cold-stream pinch, so its ``pinch_T`` is 33.2 °C. @@ -216,9 +238,12 @@ classified against the pinch temperature of its own kind. That last clause also catches isothermal and non-monotone streams -- a stream whose outlet lies on the wrong side of its inlet for the sign of its duty, such as a cold stream whose equilibrium outlet ends up cooler than it entered. -Rather than spread a point load across the cascade, these get -``pinch_T = T_in`` too, which assigns the whole of their duty to a single side -of the design: the hot side for a cold stream, the cold side for a hot one. +These get ``pinch_T = T_in`` too, but only as a label. The synthesis treats +them as the problem table does, as a point load: their whole duty sits at their +outlet temperature, on whichever side of the pinch that temperature lies (and, +exactly at the pinch, on the side the problem table's cascade assigns the +point loads there), and such a stream enters its first process exchanger in +the equilibrium state at its inlet enthalpy. Reading the pinch diagram ------------------------- @@ -273,19 +298,20 @@ diagram: the ``Cold side`` and ``Hot side`` captions. The columns read off the life cycles above: stream 1 enters at 25.0 °C with 0.00E0 kJ/hr and leaves at 95.9 °C with 2.79E8 kJ/hr, and the first connector it meets carries the - 3.34E4 kJ/hr of its first stage. The four duties are the same four as in + 5.05E6 kJ/hr of its first stage. The four duties are the same four as in :doc:`01_quickstart`. Exchanger columns are ordered independently on each side of the pinch, by ``_order_exchanger_columns``. Every stream's stage order is a chain of precedence constraints between the exchangers it meets -- reversed for hot streams, which are drawn right to left -- and a topological sort of that graph -(Kahn's algorithm, ties broken by the order the exchangers were synthesized in) -lays them out so that every stream meets its exchangers in flow direction. That -is why stream 1 reads its three connectors left to right in exactly the order -of its life cycle. Constraints that contradict each other, which would require -some stream to flow backwards, cannot be satisfied by any ordering; the -synthesis order is then used unchanged. +(Kahn's algorithm, ties broken by the order in which the synthesis returned the +exchangers, the plan order from the pinch outward) lays them out so that every +stream meets its exchangers in flow direction. That is why stream 1 reads its +three connectors left to right in exactly the order of its life cycle. +Constraints that contradict each other, which would require some stream to +flow backwards, cannot be satisfied by any ordering; the given order is then +used unchanged. Energy balance and cost accounting ---------------------------------- @@ -322,13 +348,13 @@ is set to ``True``. The costs are differences, clipped at zero. ``original_purchase_costs`` is the purchase cost of each *original* exchanger, one entry per stream, 3.365e+05 USD in total here; ``new_purchase_costs_HXp`` and ``new_purchase_costs_HXu`` are -the same for the synthesized process and utility exchangers, 4.73e+05 and -2.096e+05 USD. The facility's own ``purchase_costs['Heat exchangers']`` -- and +the same for the synthesized process and utility exchangers, 4.734e+05 and +2.095e+05 USD. The facility's own ``purchase_costs['Heat exchangers']`` -- and its identical ``baseline_purchase_costs`` entry -- is ``max(0, new - original)`` -over those three sums, 4.73e+05 + 2.096e+05 - 3.365e+05 = 3.461e+05 USD. Its +over those three sums, 4.734e+05 + 2.095e+05 - 3.365e+05 = 3.464e+05 USD. Its ``installed_costs['Heat exchangers']`` is formed exactly the same way from the installed costs of the same exchangers rather than their purchase costs, and -is the larger figure here, 1.114e+06 USD. Clipping at zero means a network +is the larger figure here, 1.112e+06 USD. Clipping at zero means a network whose exchangers happen to be cheaper than the ones they replace is reported as adding nothing rather than as a capital credit; and if the synthesis produced no process exchangers at all, both entries are set to zero and the facility @@ -339,11 +365,11 @@ original heat utilities summed by agent -- reversed in sign, since they are the very objects that were negated to form the difference -- and ``new_utility_costs`` holds the new utility exchangers' utilities summed by agent. ``HXN.heat_utilities`` is the sum of the two, that is new - original, -which is why every cost printed above is negative: -388.6 USD/hr of low -pressure steam, -210.6 USD/hr of chilled water and -5.976 USD/hr of cooling +which is why every cost printed above is negative: -388.8 USD/hr of low +pressure steam, -210.7 USD/hr of chilled water and -5.976 USD/hr of cooling water are savings. The duties carry the sign convention of their agent, so the -steam duty is negative, -6.321e+07 kJ/hr, while the chilled and cooling water -duties are positive, 4.212e+07 and 1.794e+07 kJ/hr, because cooling duties are +steam duty is negative, -6.324e+07 kJ/hr, while the chilled and cooling water +duties are positive, 4.214e+07 and 1.794e+07 kJ/hr, because cooling duties are negative to begin with and a positive difference again means less of them. Setting ``replace_unit_heat_utilities=True`` moves this reporting onto the process units instead, as :doc:`04_configuring` describes. @@ -353,6 +379,6 @@ 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:`../concepts` -- the pinch concepts, the synthesis heuristics, and what +- :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 60d850c..cb082f4 100644 --- a/docs/source/tutorial/04_configuring.rst +++ b/docs/source/tutorial/04_configuring.rst @@ -7,7 +7,7 @@ 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, 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 fifteen process exchangers. +finishes by synthesizing a ten-stream network with ten process exchangers. 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 @@ -66,21 +66,38 @@ duties -- which makes a sweep a loop over six assignments. .. literalinclude:: /_generated/ch04_sweep.txt :language: text -Both utility loads rise monotonically with the approach temperature and the -added capital falls monotonically: heating goes from 2.957e+08 kJ/hr at 2 K to -3.289e+08 kJ/hr at 30 K, cooling from 1.167e+05 to 3.158e+07 kJ/hr, and the -added installed cost from 2.396e+06 USD down to 2.454e+05 USD. That is the -classic pinch trade-off. ``T_min_app`` enters the calculation in three places, -and all of them push the same way. In the problem table it is the amount by -which hot streams are shifted down before the cascade, so a larger value moves -the hot streams further from the cold ones and raises both utility targets. In -the synthesis it decides the eligibility of a match -- a candidate is only -considered when the two streams are at least ``T_min_app`` apart -- and it is -the approach each synthesized process exchanger observes, since every one of -them is an ``HXprocess(dT=T_min_app)``. 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. +Both utility loads rise monotonically with the approach temperature: heating +goes from 2.957e+08 kJ/hr at 2 K to 3.289e+08 kJ/hr at 30 K, and cooling from +9.219e+04 to 3.157e+07 kJ/hr. The added installed cost mostly falls, from +2.02e+06 USD at 2 K to 2.496e+05 USD at 30 K. That is the classic pinch +trade-off. ``T_min_app`` enters the calculation in two places, and both push +the same way. In the problem table it is the amount by which hot streams are +shifted down before the cascade, so a larger value moves the hot streams +further from the cold ones and raises both utility targets. In the synthesis +it is the approach every match must keep along its whole length -- checked at +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. + +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 +the pinch stays at 298.15 K on the shifted scale, and the network reaches the +minimum energy requirement (MER) targets -- status ``mer`` -- with three or +four exchangers. From 15 K on, the pinch moves to the column condenser's +vapor inlet, 65.4 °C on the real scale (323.53, 318.53 and 308.53 K shifted: +the same real temperature less 15, 20 and 30 K). Below that pinch the pinch +design rules fail: the cold streams that reach it cannot each be paired with +a hot stream whose heat capacity flow rate is at least as large, which proves +that the targets need a stream split. hensmith does not split streams, so the +network is a best-effort one, and the status column shows how close it comes: +6.63e+03, 4.7e+03 and 837 kJ/hr of process-side heating above the target, at +most a few thousandths of a percent of the heating load. At 15 K it takes eight +exchangers to get that close, among them repeated matches between the same +two streams, which emulate the missing split -- and which cost more than the +four exchangers at 10 K. ``HXN.synthesis_info`` records each outcome, +including the pinch-rule proof, under ``'sides'``. .. literalinclude:: /../_demo_src/examples/ch04_configuring.py :language: python @@ -91,19 +108,21 @@ same duty -- which is what the right-hand panel below prices. .. figure:: /_static/images/examples/tutorial_04_T_min_app_sweep.png :class: white-bg :width: 720 - :alt: Two-panel line plot of a minimum approach temperature sweep on the quickstart system. Left panel: utility load in GJ/hr against T_min_app in K, with the heating utility (red circles) rising gently across the top of the panel and the cooling utility (blue squares) rising from near zero along the bottom as T_min_app goes from 2 to 30 K. Right panel: added installed cost in MUSD against T_min_app, falling steeply from 2.396e+06 USD at 2 K to 2.454e+05 USD at 30 K. + :alt: Two-panel line plot of a minimum approach temperature sweep on the quickstart system. Left panel: utility load in GJ/hr against T_min_app in K, with the heating utility (red circles) rising gently across the top of the panel and the cooling utility (blue squares) rising from near zero along the bottom as T_min_app goes from 2 to 30 K. Right panel: added installed cost in MUSD against T_min_app, falling steeply from 2.02e+06 USD at 2 K to 6.004e+05 USD at 10 K, rising slightly to 6.684e+05 USD at 15 K, and falling again to 2.496e+05 USD at 30 K. The ``T_min_app`` trade-off on the quickstart system. Left: the heating utility load rises from 2.957e+08 kJ/hr at 2 K to 3.289e+08 kJ/hr at 30 K - and the cooling utility load from 1.167e+05 to 3.158e+07 kJ/hr, so less heat + and the cooling utility load from 9.219e+04 to 3.157e+07 kJ/hr, so less heat is recovered as the approach widens. Right: the added installed cost of the - network falls over the same range, from 2.396e+06 USD at 2 K to 2.454e+05 - USD at 30 K, most of the drop happening between 2 and 10 K. The default 5 K - used throughout this tutorial sits on the steep part of the cost curve: - 2.977e+08 kJ/hr of heating, 1.96e+06 kJ/hr of cooling and 1.114e+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. + network falls over the same range, from 2.02e+06 USD at 2 K to 2.496e+05 + USD at 30 K, most of the drop happening between 2 and 10 K; the bump at + 15 K is the best-effort network that needs more exchangers once the pinch + has moved. The default 5 K used throughout this tutorial sits on the steep + 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. Scoping the analysis -------------------- @@ -137,10 +156,12 @@ bottoms product, makes the point: Four streams are analyzed instead of the five of :doc:`01_quickstart`, and the one that left carried most of the recoverable heat of this system. On the cooling side the pool itself shrinks: chapter 1 reported ``6.201e+07 -> -1.96e+06 kJ/hr``, and here the same two numbers read ``1.797e+07 -> -5.336e-07 kJ/hr``. The remaining cooling demand is recovered essentially -completely -- 5.336e-07 kJ/hr is zero to every digit that matters -- but it is -a much smaller demand, and the cooling actually saved falls with it. The +1.936e+06 kJ/hr``, and here the same two numbers read ``1.797e+07 -> +0 kJ/hr``. The remaining cooling demand is recovered completely -- the process +exchangers bring every remaining hot stream to its outlet, and a stream served +that way passes through its utility exchanger untouched, so no cooling +utility is left at all -- but it is a much smaller demand, and the cooling +actually saved falls with it. The heating side shows the loss directly, since its pool is unchanged: the same 3.609e+08 kJ/hr of heating is only reduced to 3.42e+08 kJ/hr, against 2.977e+08 kJ/hr when ``D1_H1`` was in scope. The bottoms cooler is a large, @@ -158,15 +179,16 @@ synthesis is described below. Four of them -- ``Qmin``, ``force_ideal_thermo``, :func:`~hensmith.synthesize_network`, which can also be called directly on a list of heat utilities. -``Qmin`` is a duty floor, in kJ/hr, defaulting to 1e-3. During synthesis a -candidate match is simulated first and then discarded if the exchanger's duty -came out below it (``abs(new_HX.Q) < Qmin``), which keeps numerically -negligible matches out of the network; the stream simply carries that heat on -to its next match or to its utility exchanger. The same value is passed to the -pinch diagram, where a utility exchanger whose enthalpy change is at or below -it (``<=``, rather than the strict ``<`` of the synthesis) is not marked with a -circle -- the reason a stream that finishes on process heat alone carries no -utility symbol. +``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 +left to the utilities, which keeps numerically negligible matches out of the +network; the streams simply carry that heat on to their next match or to their +utility exchangers. Dropping a match never makes another one infeasible, but a +large ``Qmin`` can cost MER, and ``HXN.synthesis_info['qmin_dropped']`` lists +what it removed. The same value is passed to the pinch diagram, where a utility +exchanger whose enthalpy change is at or below it (``<=``, rather than the +strict ``<`` of the synthesis) is not marked with a circle -- the reason a +stream that finishes on process heat alone carries no utility symbol. ``cache_network`` (default ``False``) reuses a synthesized topology across simulations, which is worth doing when the same system is simulated many times @@ -174,14 +196,21 @@ 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 -and the exchanger specifications are updated -- each life cycle's first inlet -is copied from the current stream, and the outlet enthalpy is re-imposed on -every stage. The reused network is then re-converged and re-checked stream by -stream: the outlet of each life cycle must reproduce the original exchanger's -outlet in composition, pressure and enthalpy, and each stream's original -exchanger must have a finite installed cost. If any of those checks fails, -hensmith warns with a -``RuntimeWarning`` saying that the cache algorithm failed and the cached +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 +stream above the pinch, the cold stream below it), the same *fraction* of that +stream's duty as at synthesis, so a changed feed rescales every stage instead +of letting the first one take the whole duty; its partner transfers what that +limit sets, but never past its own outlet, and takes the rest to its utility +exchanger, which brings every stream to its new outlet enthalpy. A reused +network is not planned again, so it need not be at MER for the new duties, +and ``synthesis_info`` still describes the synthesis that produced it. The +reused network is then re-converged and re-checked stream by stream: the +outlet of each life cycle must reproduce the original exchanger's outlet in +composition, pressure and enthalpy, and each stream's original exchanger must +have a finite installed cost. If any of those checks fails, hensmith warns with +a ``RuntimeWarning`` saying that the cache algorithm failed and the cached network was ignored, discards the cache and synthesizes a fresh network. The energy balance is treated the same way but silently: a cached network whose energy balance error exceeds the tolerance is discarded and re-synthesized @@ -201,28 +230,30 @@ when at least one process exchanger was synthesized; if the synthesis produced no matches, the facility reports no utilities and no added cost either way. ``avoid_recycle`` (default ``False``) forbids matching the same hot/cold stream -pair more than once over the whole synthesis. Without it, a pair may be matched -again in a later pass, on the other side of the pinch, -and two exchangers between the same two streams can close a recycle loop in the -network -- a loop that the network's ``System`` must then converge by -fixed-point iteration. Turning it on trades some recovery for a network that is -guaranteed acyclic in that respect. +pair more than once anywhere -- neither twice on one side of the pinch nor once +on each side. Without it, the planner may match a pair repeatedly: alternating +two partners in series emulates a stream split, and some unsplit MER networks +need it (the repeated exchangers carry a suffix, ``HX_3_2_cs_2`` for the second +one, say). Two exchangers between the same two streams can close a loop in the +network, which its ``System`` tears at a recycle stream and converges by +fixed-point iteration. Turning the option on trades those networks -- and +possibly MER with them -- for a network in which no two exchangers connect the +same pair of streams. ``force_ideal_thermo`` (default ``False``) runs the analysis on copies of the streams made with ideal thermodynamics (``i.thermo.ideal()``), and the synthesized exchangers inherit that property package. It is an escape hatch for -systems whose rigorous VLE is expensive or fragile inside the many exchanger -simulations the synthesis performs; the network it produces is only as accurate -as that assumption. +systems whose rigorous VLE is expensive or fragile; the network it produces is +only as accurate as that assumption. ``sort_hus_by_T`` (default ``False``) reorders the streams before the analysis: heating utilities are sorted by inlet temperature in descending order and cooling utilities in ascending order. Heating utilities always precede cooling ones in the rearranged list regardless, and it is that list that fixes the stream indices used by every per-stream array, by the stream life cycles and by -the pinch diagram. Because the matching passes walk the streams in index order, -sorting them changes which matches are attempted first, and so can change the -network that comes out. The default order is by signed duty, which is how the +the pinch diagram. The order does not change the targets; it breaks ties in +the planner's search, so sorting can change the network that comes out. The +default order is by signed duty, which is how the facility hands the utilities to the synthesis: cold streams from the smallest heating duty up, hot streams from the largest cooling duty down. @@ -239,8 +270,8 @@ A larger system --------------- The quickstart system has five streams and produces four process exchangers. -The system below has ten streams and produces fifteen, which is enough for the -structure of a synthesized network to be visible. It is the ten-stream case of +The system below has ten streams and produces ten, on both sides of the pinch, +which is enough for the structure of a synthesized network to be visible. It is the ten-stream case of the regression suite, ``tests/test_hxn_regression.py::case_10_ten_streams``, inlined here rather than imported from ``tests``: five hot streams -- two condensers (``H1``, ``H2``), one partial condensation (``H5``) and two liquid @@ -265,35 +296,45 @@ heat transfer efficiency. The problem table knows nothing about agents -- its targets are process-side enthalpy differences. Summing the ``unit_duty`` of the new utility exchangers instead, as ``tests/test_hxn_regression.py`` does, gives the process-side loads that are comparable with the targets. On the hot side -the two differ: 1.481e+07 kJ/hr utility-side against 1.407e+07 kJ/hr -process-side, for a target of 1.394e+07 kJ/hr. On the cold side the cooling -agents used here need no such correction and both read 8.065e+06 kJ/hr, against -a target of 7.928e+06 kJ/hr. Those process-side numbers, 1.407e+07 and -8.065e+06 kJ/hr, are the values the regression suite records for this case. The -network is above both targets, as a synthesized network must be, and its -energy balance error is 1e-11 %. +the two differ: 1.523e+07 kJ/hr utility-side against 1.447e+07 kJ/hr +process-side, for a target of 1.447e+07 kJ/hr. On the cold side the cooling +agents used here need no such correction and both read 8.462e+06 kJ/hr, against +a target of 8.462e+06 kJ/hr. The network reaches both targets -- the synthesis +reports ``mer`` -- and those process-side numbers, 1.447e+07 and +8.462e+06 kJ/hr, are the values the regression suite records for this case. +Its energy balance error is 2e-12 %. + +The phase changes of these streams are what make this case demanding. Two +condensers, a partial condensation and two streams boiled past their bubble +points put flat and steep stretches into the stream curves, and a network +checked only at the terminals of its exchangers could let two streams cross +*inside* one of them. hensmith keeps ``T_min_app`` everywhere inside every +exchanger, on the exact stream states, so its targets and its network hold for +the real streams. .. figure:: /_static/images/examples/tutorial_04_ten_streams_pinch_diagram.png :class: white-bg :width: 100% - :alt: Pinch diagram of the synthesized ten-stream network: five blue cold streams labelled C5, C4, C2, C1 and C3 drawn left to right above five red hot streams labelled H1, H2, H4, H3 and H5 drawn right to left, joined by fifteen vertical process-exchanger connectors, nine of them to the left of the dashed pinch line and six to the right, with hot utility circles at the outlets of three cold streams and cold utility circles at the outlets of four hot streams. + :alt: Pinch diagram of the synthesized ten-stream network: five blue cold streams labelled C5, C4, C2, C1 and C3 drawn left to right above five red hot streams labelled H1, H2, H4, H3 and H5 drawn right to left, joined by ten vertical process-exchanger connectors, five of them to the left of the dashed pinch line and five to the right, with hot utility circles at the outlets of three cold streams and cold utility circles at the outlets of four hot streams. The synthesized ten-stream network. The five cold streams (blue, indices 0 to 4) run left to right and the five hot streams (red, indices 5 to 9) right to left, each annotated with its inlet and outlet temperature and enthalpy flow, the outlets being the quenched end states the analysis works from. The - fifteen vertical connectors are the process exchangers, each labelled with - its duty in kJ/hr; nine of them lie to the left of the dashed pinch line, in - the cold-side design, and six to the right, in the hot-side design. Three - cold streams still need a hot utility at their outlet (open red circles on - the right) and four hot streams a cold utility (open blue circles on the - left); the others are brought to their outlets by process heat exchange - alone. The shaded background marks the two sides of the pinch. + ten vertical connectors are the process exchangers, each labelled with its + duty in kJ/hr; five of them lie to the left of the dashed pinch line, in the + cold-side design, and five to the right, in the hot-side design. Streams 0 + (``C5``) and 5 (``H1``) are matched with each other on both sides of the + pinch, which closes a loop in the network that its ``System`` tears and + converges. Three cold streams still need a hot utility at their outlet (open + red circles on the right) and four hot streams a cold utility (open blue + circles on the left); the others are brought to their outlets by process + heat exchange alone. The shaded background marks the two sides of the pinch. Where to next ------------- -- :doc:`../concepts` -- the pinch concepts, the synthesis heuristics and the +- :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 constructor option and every attribute the facility sets. diff --git a/hensmith/_curves.py b/hensmith/_curves.py new file mode 100644 index 0000000..dba191d --- /dev/null +++ b/hensmith/_curves.py @@ -0,0 +1,1361 @@ +# -*- 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. +""" +Per-stream temperature-enthalpy curves for the problem table and the network +planner (private module). + +A `StreamCurve` is a piecewise-linear temperature-enthalpy curve of one +process stream over its own enthalpy range, built once from a handful of +flashes and evaluated afterwards without flashing. `problem_table` builds its +grid from the curves' breakpoints, and the network synthesis plans on the +same curves, so both work on one model of every stream. + +Why a curve rather than flashes at grid temperatures: a table that puts only +the stream end temperatures on its grid and flashes every stream at every +grid point with ``vle(T=...)`` has three defects: + +* a boiling or condensing point, a bubble or dew point, or the curvature of a + mixture glide or of a single-phase stretch with temperature-dependent heat + capacity that falls strictly inside a grid interval is averaged over the + interval, which can hide a pinch (targets too low, by up to 100 %); +* ``vle(T=T_sat)`` of a single chemical keeps whatever phase split the stream + had (thermosteam leaves it unchanged when ``|P - Psat| <= 1e-3`` Pa), so + the enthalpy at a grid point exactly on T_sat depends on history; +* thermosteam's two-phase TP/PH/PV flashes of some mixtures (water/ethanol + with an ethanol mole fraction of 0.2-0.5) silently return non-converged + states. + +The curve removes all three: phase boundaries are breakpoints; a pure +component's latent heat is an isothermal (flat) segment between its +saturated-liquid and saturated-vapor enthalpies from V-specified flashes; +single-phase stretches are evaluated with the phases FIXED (no flash at all, +so there is no ambiguity at T_sat) and subdivided until linear +interpolation is within `GLIDE_TOL_T` kelvin; and mixture glides are sampled +adaptively to the same tolerance, binaries along their bubble-point curve +(thermosteam's BubblePoint only), other mixtures with TP flashes whose +results are sanity-checked. + +Nothing here imports biosteam or thermosteam: the curves only call methods of +the streams they are given. +""" +import heapq +from functools import partial +from warnings import warn +import numpy as np + +#: Segment kinds of a `StreamCurve`. +FLAT, SENSIBLE, GLIDE = 'flat', 'sensible', 'glide' + +#: Default maximum linear-interpolation error of a `StreamCurve` along a +#: two-phase glide or a curved single-phase stretch [K]. +GLIDE_TOL_T = 0.002 +#: Default maximum number of equilibrium evaluations per glide. +GLIDE_MAX_SAMPLES = 400 +#: Phase-boundary temperatures closer than this to a stream end are moved +#: onto it [K] (end states computed by a different thermosteam path than the +#: boundary agree to ~1e-10 K; a sliver interval would only add noise). +_T_SNAP = 1e-6 +#: Grid temperatures closer than this are the same grid point [K]. +_T_EQ = 1e-9 +#: A flat at an end temperature is a non-equilibrium jump if the PH flash at +#: its mid-enthalpy lands further than this outside the stream's own range +#: [K] (a latent-heat flat snapped onto an end lands within `_T_SNAP`). +_T_JUMP = 1e-5 +#: A glide root (`StreamCurve._glide_root`) that misses its enthalpy by more +#: than this fraction of the stream's duty is rejected: flash-resolution +#: jumps next to a phase boundary are ~1e-8, spurious flashes ~1e-3 or more. +_ROOT_RTOL = 1e-7 +#: An equilibrium state within this of a temperature [K] is not on the wrong +#: side of it: the inlet of a point-load stream (`_point_load_inlet`), or an +#: enthalpy limit (`hensmith.hxn_synthesis._enthalpy_limit`). Flash noise of +#: an isothermal stream is ~1e-10 K; `HXprocess` rejects 0.01 K on the wrong +#: side. +_T_SIDE = 1e-6 + + +def _copy(stream, thermo=None): + """ + Copy of `stream`, with its ID, that is not registered in the flowsheet + (a leading '.' names a stream without registering it). A plain + ``stream.copy()`` takes its ID from the source line of the call + (thermosteam's ID magic): unless that line assigns the copy to a plain + variable, a multi-phase copy registers as '-' and replaces the previous + one with a RuntimeWarning, and a single-phase one takes a registry + ticket, which keeps it alive. The curves copy streams hundreds of times + per synthesis, so every internal copy goes through here. + """ + return stream.copy('.' + stream.ID, thermo) + + +def _point_load_inlet(stream_in, T_point, is_hot): + """ + State in which a point-load stream (a non-monotone `StreamCurve`, whose + whole duty is at its outlet temperature `T_point`) enters its first + exchanger: a copy of `stream_in` at equilibrium at its own enthalpy and + pressure, or of the stream as given if that flash fails or lands on the + wrong side of `T_point` (colder than it for a cooled stream, hotter for + a heated one; more than `_T_SIDE`). + + A stream is a point load because its real inlet temperature lies on the + wrong side of its outlet: a cooled stream fed colder than its outlet + (e.g. a vapor fed below its dew point, or the copy of a saturated vapor + under ideal thermodynamics, whose ideal dew point is higher), or a + heated one fed hotter (a liquid above its bubble point). The plan puts + the whole duty at the outlet temperature, where the real inlet state + offers no heat as such: `HXprocess` compares the inlet temperatures and + caps the partner at the real inlet temperature -/+ its `dT`. But the + inlet relaxes, adiabatically and at constant pressure, to its + equilibrium state at the same enthalpy (as every flash in the network + takes it), and at fixed pressure the equilibrium temperature does not + decrease with the enthalpy: a cooled stream's equilibrium temperature + at its inlet enthalpy is at least the one at its lower outlet + enthalpy, `T_point` (the outlet is quenched to equilibrium), and a + heated stream's at most. From that state the stream delivers (takes) + its heat no colder (hotter) than `T_point`, where the plan and the + problem table put it, so the terminal checks of `HXprocess` agree with + the plan. The enthalpy is the same, so no balance changes. + """ + stream = _copy(stream_in) + try: + stream.vle(H=stream_in.H, P=stream.P) + except Exception: + return _copy(stream_in) + T = stream.T + if T >= T_point - _T_SIDE if is_hot else T <= T_point + _T_SIDE: + return stream + return _copy(stream_in) + + +# %% Evaluators of single-phase (sensible) segments + +class _FixedPhase: + """ + Exact enthalpy of a single-phase stretch of a stream: a reference state + whose phase distribution is the equilibrium one throughout the stretch + (all VLE chemicals liquid below the bubble point, vapor above the dew + point; locked chemicals where thermosteam puts them), re-evaluated at + any temperature without a flash. + """ + __slots__ = ('stream',) + + def __init__(self, stream): + self.stream = _copy(stream) + + def H(self, T): + s = self.stream + s.T = T + return s.H + + def state_at_T(self, T): + s = _copy(self.stream) + s.T = T + return s + + def state_at_H(self, H): + s = _copy(self.stream) + s.H = H # solves T with the phase distribution fixed + return s + + +class _TPFlash: + """ + Fallback evaluator: a TP flash at each temperature, warm-started along + the way (the behavior of the problem table before stream curves). Used + only when the phase analysis of a stream is not possible (e.g. above the + critical pressure, or a failed bubble/dew point). + """ + __slots__ = ('stream0', 'stream', 'label') + + def __init__(self, stream, label): + self.stream0 = _copy(stream) + self.stream = _copy(stream) + self.label = label + + def H(self, T): + s = self.stream + try: + s.vle(T=T, P=s.P) + return s.H + except Exception as error: + self.stream = _copy(self.stream0) + warn(f"could not solve VLE for stream {self.label!r} at " + f"{T:.2f} K ({error!r}); interpolating enthalpy linearly " + "in temperature for the problem table", RuntimeWarning) + return None + + def state_at_T(self, T): + s = _copy(self.stream0) + s.vle(T=T, P=s.P) + return s + + def state_at_H(self, H): + s = _copy(self.stream0) + s.vle(H=H, P=s.P) + return s + + +# %% Samplers of two-phase glides (parameter t: 0 at the glide's low-T end, +# 1 at its high-T end; T and H increase with t) + +class _BinaryGlide: + """ + Two-phase states of a binary at fixed P traced along the bubble-point + curve of the liquid: the liquid mole fraction of the first VLE chemical + goes from the feed's (t = 0, bubble point, V = 0) to the dew-point + liquid's (t = 1, V = 1); T and the vapor composition y are that liquid's + bubble point, V follows from the lever rule, and H is the enthalpy of + liquid F(1-V)x plus vapor FVy at T. Only thermosteam's BubblePoint is + used (robust), never a TP/PH/PV flash, which fail silently for some + water/ethanol compositions. By the phase rule (2 components, 2 phases, + fixed P) this path is exactly the set of equilibrium states of the feed. + """ + __slots__ = ('bp', 'IDs', 'z0', 'F', 'P', 'u0', 'u1', 'work', 'n') + + def __init__(self, sat_liquid, IDs, bp, x_dew, P): + mol = np.asarray(sat_liquid.imol['l', IDs], dtype=float) + self.F = F = mol.sum() + self.z0 = mol[0] / F + self.bp = bp + self.IDs = IDs + self.P = P + self.u0 = self.z0 + self.u1 = float(x_dew[0]) + self.work = _copy(sat_liquid) + self.n = 0 + + def _set(self, s, t): + u = self.u0 + (self.u1 - self.u0) * t + x = np.array([u, 1. - u]) + r = self.bp(x, P=self.P) + y = np.asarray(r.y, dtype=float) + dy = y[0] - u + if dy == 0.: + V = 0. if t < 0.5 else 1. + else: + V = (self.z0 - u) / dy + V = 0. if V < 0. else 1. if V > 1. else V + F = self.F + s.imol['l', self.IDs] = F * (1. - V) * x + s.imol['g', self.IDs] = F * V * y + s.T = r.T + self.n += 1 + return r.T + + def __call__(self, t, strict=True): + s = self.work + T = self._set(s, t) + return T, s.H + + def state(self, t): + s = _copy(self.work) + self._set(s, t) + return s + + sample_state = state # bubble-point tracing reproduces its samples + + +class _TPGlide: + """ + Two-phase states of a general mixture by TP flashes, t linear in T over + [Ta, Tb]. With `strict` (Ta and Tb are the true bubble and dew points: + no always-gas or dissolved-solute chemicals), a flash whose result is + single-phase strictly inside the glide is reported as failed (None) so + that the sampler drops it; a call with ``strict=False`` (root solves + between accepted samples, see `StreamCurve._glide_root`) accepts it, + because a TP flash a hair inside a glide end legitimately comes back + single phase (it cannot resolve the phase boundary to the last digits). + """ + __slots__ = ('template', 'work', 'Ta', 'Tb', 'P', 'IDs', 'n', 'failed', + 'strict', 'phase', 'states') + + def __init__(self, template, Ta, Tb, P, IDs, strict): + self.template = template + self.work = _copy(template) + self.Ta = Ta + self.Tb = Tb + self.P = P + self.IDs = IDs + self.n = 0 + self.failed = 0 + self.strict = strict + self.phase = {} # t -> 'l', 'g' or 'lg' (where the VLE chemicals are) + self.states = {} # t -> copy of the flashed state + + def __call__(self, t, strict=True): + T = self.Ta + (self.Tb - self.Ta) * t + s = self.work + self.n += 1 + try: + s.vle(T=T, P=self.P) + except Exception: + self.failed += 1 + self.work = _copy(self.template) + return None + v = float(np.sum(s.imol['g', self.IDs])) + l = float(np.sum(s.imol['l', self.IDs])) + tiny = 1e-12 * (v + l) + phase = 'l' if v <= tiny else 'g' if l <= tiny else 'lg' + if not strict: return T, s.H # a root solve: nothing to record + self.phase[t] = phase + self.states[t] = _copy(s) + if self.strict and 0. < t < 1. and phase != 'lg': + self.failed += 1 + return None + return T, s.H + + def phase_at(self, t): + if t not in self.phase: self(t) + return self.phase.get(t) + + def phase_boundaries(self, ts, T_tol=1e-6): + """ + Where the phase set of the VLE chemicals changes between consecutive + sampled parameters `ts` (e.g. the boiling onset of a sugar solution, + raised above the solvent's bubble point by the dissolved solute, or + the dew point of a humid gas, neither of which thermosteam's + bubble/dew point functions see), located by bisection to `T_tol` + kelvin. Returns [(t_before, t_after), ...]. + """ + out = [] + dt_tol = T_tol / max(self.Tb - self.Ta, T_tol) + for a, b in zip(ts[:-1], ts[1:]): + pa, pb = self.phase_at(a), self.phase_at(b) + if pa is None or pb is None or pa == pb: continue + while b - a > dt_tol: + m = 0.5 * (a + b) + pm = self.phase_at(m) + if pm is None: break + if pm == pa: a = m + else: b = m + out.append((a, b)) + return out + + def state(self, t): + s = _copy(self.template) + s.vle(T=self.Ta + (self.Tb - self.Ta) * t, P=self.P) + return s + + def sample_state(self, t): + """ + The state evaluated at `t` (a copy), or a fresh flash if `t` was + never evaluated: a fresh TP flash does not always reproduce a + sample, because thermosteam's TP flash of some mixtures depends on + its starting point (see `StreamCurve._glide_root`). + """ + s = self.states.get(t) + return self.state(t) if s is None else _copy(s) + + +def _illinois(f, a, b, fa, fb, xtol=1e-12, maxiter=100): + """Root of a monotone f on the bracket [a, b] (fa, fb of opposite sign).""" + side = 0 + x = a + for _ in range(maxiter): + x = (a * fb - b * fa) / (fb - fa) if fb != fa else 0.5 * (a + b) + if not (min(a, b) <= x <= max(a, b)): x = 0.5 * (a + b) + fx = f(x) + if fx == 0. or abs(b - a) < xtol: return x + if (fx > 0.) == (fb > 0.): + b, fb = x, fx + if side == -1: fa *= 0.5 + side = -1 + else: + a, fa = x, fx + if side == 1: fb *= 0.5 + side = 1 + return x + + +def _sample_glide(f, t0, s0, t1, s1, tol_T, max_samples, init=None): + """ + Adaptive sampling of a glide between parameters t0 < t1 whose exact end + states s0 = (T0, H0), s1 = (T1, H1) are given. The worst segment is split + first (at its parameter midpoint) until every segment's midpoint lies + within `tol_T` kelvin (horizontally, at the midpoint's enthalpy) of the + chord, or `max_samples` evaluations were spent. Samples that fail or are + not monotone with respect to their neighbors are dropped. A glide no + wider than `tol_T` needs no interior sample (the chord cannot be further + than its width from the curve); up to 3 uniform samples are taken first + so that an S-shaped stretch cannot fool the first midpoint test. + + `init` may hold already known interior samples {t: (T, H)}, which then + replace the uniform ones. + + Returns (t, T, H) arrays sorted by t, and the largest remaining error + [K] (0 when the tolerance was met). + """ + pts = {t0: s0, t1: s1} + n = 0 + width = s1[0] - s0[0] + if width <= tol_T: + return np.array([t0, t1]), np.array([s0[0], s1[0]]), \ + np.array([s0[1], s1[1]]), 0. + if init: + pts.update({t: r for t, r in init.items() if t0 < t < t1}) + n0 = 1 + else: + n0 = 4 if width > 1. else 2 if width > 8. * tol_T else 1 + + def ok(r, a, b): + if r is None: return False + (Ta, Ha), (Tb, Hb) = pts[a], pts[b] + T, H = r + return Ta <= T <= Tb and Ha <= H <= Hb + + ts = np.linspace(t0, t1, n0 + 1) + for t in ts[1:-1]: + r = f(t) + n += 1 + if r is not None: pts[t] = r + # enforce monotonicity of the initial set (drop offenders) + keys = sorted(pts) + clean = [keys[0]] + for k in keys[1:-1]: + if pts[clean[-1]][0] <= pts[k][0] <= pts[t1][0] and \ + pts[clean[-1]][1] <= pts[k][1] <= pts[t1][1]: + clean.append(k) + else: + del pts[k] + clean.append(t1) + heap = [] + + def push(a, b): + nonlocal n + m = 0.5 * (a + b) + if n >= max_samples: return False + r = f(m) + n += 1 + if not ok(r, a, b): return True # failed sample: keep the chord + pts[m] = r + (Ta, Ha), (Tb, Hb), (Tm, Hm) = pts[a], pts[b], r + dH = Hb - Ha + err = abs(Ta + (Tb - Ta) * (Hm - Ha) / dH - Tm) if dH > 0. else 0. + if err > tol_T: heapq.heappush(heap, (-err, a, m, b)) + return True + + err_left = 0. + for a, b in zip(clean[:-1], clean[1:]): + if not push(a, b): err_left = np.inf # not even checked + while heap and err_left == 0.: + err, a, m, b = heapq.heappop(heap) + if not push(a, m) or not push(m, b): + err_left = -err + if heap: err_left = max(err_left, -heap[0][0]) + keys = sorted(pts) + T = np.array([pts[k][0] for k in keys]) + H = np.array([pts[k][1] for k in keys]) + return np.array(keys), T, H, err_left + + +def _glide_crossings(sampler, t, T, H, H_lo, H_hi, tol_H, tol_T, max_samples): + """ + Glide samples (t, T, H) completed with the exact states at which the + glide crosses the ends of the stream's enthalpy range, H_lo and H_hi, + between two samples. That happens when an end state is not at + equilibrium and its enthalpy falls inside the glide, e.g. a hot stream + fed as a liquid above its bubble point: equilibrium reaches the feed's + enthalpy inside the glide, below the feed temperature, and above that + temperature the curve is clipped to H_hi. Without the crossing, the + chord from the last sample inside the range to the first clipped one + ends at the clipped sample's temperature, off by up to the sample + spacing rather than by `tol_T`, at a breakpoint (where the curve is + taken as exact). The crossing is solved on the glide's own parameter + (illinois), and the part of the bracket inside the range is sampled + again to `tol_T`; if the solve fails, the chord's crossing is used. + + Returns (t, T, H, error) like `_sample_glide`. + """ + err = 0. + for H_end in (H_hi, H_lo): + k = int(np.searchsorted(H, H_end, 'right')) # H[k-1] <= H_end < H[k] + if k == 0 or k == H.size: continue + if H_end - H[k - 1] <= tol_H or H[k] - H_end <= tol_H: continue + a, b = t[k - 1], t[k] + found = {} + + def f(x): + r = sampler(x) + if r is None: raise RuntimeError('glide evaluation failed') + found[x] = r + return r[1] - H_end + + try: + x = _illinois(f, a, b, H[k - 1] - H_end, H[k] - H_end) + if x not in found: f(x) + Tx, Hx = found[x] + ok = (T[k - 1] <= Tx <= T[k] and a < x < b and abs(Hx - H_end) + <= _ROOT_RTOL * (H_hi - H_lo) + tol_H) + except Exception: + ok = False + if ok: + if H_end == H_hi: # keep the samples inside the range + g = _sample_glide(sampler, a, (T[k - 1], H[k - 1]), + x, (Tx, H_end), tol_T, max_samples) + part = slice(1, None) + else: + g = _sample_glide(sampler, x, (Tx, H_end), b, (T[k], H[k]), + tol_T, max_samples) + part = slice(None, -1) + ts, Ts, Hs = g[0][part], g[1][part], g[2][part] + err = max(err, g[3]) + else: + w = (H_end - H[k - 1]) / (H[k] - H[k - 1]) + ts = np.array([a + w * (b - a)]) + Ts = np.array([T[k - 1] + w * (T[k] - T[k - 1])]) + Hs = np.array([H_end]) + t = np.concatenate((t[:k], ts, t[k:])) + T = np.concatenate((T[:k], Ts, T[k:])) + H = np.concatenate((H[:k], Hs, H[k:])) + return t, T, H, err + + +def _sensible_crossing(H_of_T, Ta, Ha, Tb, Hb, H_end): + """ + Temperature in (Ta, Tb) at which a single-phase stretch's exact + enthalpy `H_of_T` equals `H_end` (Ha < H_end < Hb), or None if an + evaluation failed (see `_glide_crossings` for why the curve needs it). + """ + def f(T): + H = H_of_T(T) + if H is None: raise RuntimeError('evaluation failed') + return H - H_end + try: + T = _illinois(f, Ta, Tb, Ha - H_end, Hb - H_end, xtol=1e-10) + except Exception: + return None + return T if Ta < T < Tb else None + + +def _sensible_samples(H_of_T, Ta, Ha, Tb, Hb, clip, tol_T, max_samples): + """ + Interior breakpoints of a single-phase stretch [Ta, Tb] (enthalpies + Ha, Hb already clipped) such that linear interpolation between them is + within `tol_T` kelvin of the exact (clipped) H(T): bisection in T until + every segment's midpoint lies within `tol_T` (horizontally) of its chord + or the segment is no wider than `tol_T`. Returns [(T, H), ...] sorted. + + Why: with temperature-dependent heat capacities the cascade is NOT + linear between grid points, so two sensible streams whose composite + curves touch inside an interval (e.g. liquid ethanol, whose Cp rises + ~40 % over 100 K, against liquid water) hide a pinch from a table that + only checks the interval ends. With these breakpoints every stream is + within `tol_T` of piecewise linear on the grid, so the grid minimum is + the true one to within ``tol_T * sum(CP)``. + """ + out = [] + stack = [(Ta, Ha, Tb, Hb)] + n = 0 + while stack and n < max_samples: + a, fa, b, fb = stack.pop() + if b - a <= tol_T or fb <= fa: continue + m = 0.5 * (a + b) + fm = H_of_T(m) + n += 1 + if fm is None: continue + fm = clip(fm) + if abs(fm - 0.5 * (fa + fb)) * (b - a) <= tol_T * (fb - fa): continue + out.append((m, fm)) + stack.append((m, fm, b, fb)) + stack.append((a, fa, m, fm)) + out.sort() + return out + + +def _phase_mol(s, phase, IDs): + if len(s.phases) > 1: + if phase not in s.phases: return np.zeros(len(IDs)) + return np.array(s.imol[phase, IDs], dtype=float) + if s.phase != phase: return np.zeros(len(IDs)) + return np.array(s.imol[IDs], dtype=float) + + +def _lever(A, B, H, T): + """ + State at temperature `T` with enthalpy `H` made of a fraction of state + `A` and the rest of state `B` (both taken to `T` with their phases + fixed; thermosteam enthalpies are additive in the phase flows, so H is + linear in the fraction). This is the state on a flat (isothermal) + segment of a `StreamCurve`: for a pure component's latent heat it equals + the PH flash at T_sat; for the end jump of a non-equilibrium end state + (e.g. a vapor below its dew point) it stays at the end's temperature, + where the curve puts that heat, whereas a PH flash would move it to the + equilibrium temperature, outside the stream's own range. + """ + a = _copy(A); a.T = T + b = _copy(B); b.T = T + Ha, Hb = a.H, b.H + if Hb == Ha: return a + w = (H - Ha) / (Hb - Ha) + w = 0. if w < 0. else 1. if w > 1. else w + IDs = a.chemicals.IDs + phases = tuple(sorted(set(a.phases) | set(b.phases))) + s = _copy(a) + if len(phases) > 1 and set(s.phases) != set(phases): s.phases = phases + for p in phases: + mol = (1. - w) * _phase_mol(a, p, IDs) + w * _phase_mol(b, p, IDs) + if len(phases) > 1: s.imol[p, IDs] = mol + else: s.imol[IDs] = mol + s.T = T + return s + + +def _end_state(stream_end, T_lo, T_hi): + """ + Return a copy of `stream_end` at equilibrium at its own enthalpy, or the + stream as given if that equilibrium state lies outside the stream's own + temperature range [T_lo, T_hi] (e.g. a non-condensable mislabelled as a + liquid, whose equilibrium state at the same enthalpy is a gas at an + absurd temperature). Either way the enthalpy is exactly `stream_end.H`. + """ + stream = _copy(stream_end) + try: + stream.vle(H=stream_end.H, P=stream.P) + except Exception: + return _copy(stream_end) + if T_lo <= stream.T <= T_hi: return stream + return _copy(stream_end) + + +# %% The curve + +class StreamCurve: + """ + Piecewise-linear temperature-enthalpy curve of one process stream over + its own enthalpy range, built once (a handful of flashes) and evaluated + afterwards without flashing. + + Parameters + ---------- + stream_in, stream_out : Stream + The stream's real end states (the outlet already re-flashed at its + own enthalpy, as `problem_table` expects). The curve keeps private + copies. + is_hot : bool, optional + True if the stream is cooled. Defaults to ``H_in > H_out``. + tol_T : float, optional + Maximum linear-interpolation error along a two-phase glide or a + curved single-phase stretch [K]. Defaults to `GLIDE_TOL_T`. + max_samples : int, optional + Maximum number of equilibrium evaluations per glide or stretch. + label : str, optional + Name used in warnings (defaults to the inlet's ID). + + Attributes + ---------- + T, H : numpy.ndarray + Breakpoints [K, kJ/hr] sorted by enthalpy; both non-decreasing. The + first is the low-enthalpy end (``H[0] == H_lo``), the last the + high-enthalpy end (``H[-1] == H_hi``), exactly. + kinds : tuple[str] + Kind of each segment i (breakpoints i -> i+1): 'flat' (isothermal: + a pure component's latent heat at T_sat between its + saturated-liquid and -vapor enthalpies, the enthalpy by which a + non-equilibrium end state departs from equilibrium at its own + temperature, or the whole duty of a non-monotone stream); 'sensible' + (one phase: exact H(T) from a phase-fixed reference state, clipped; + subdivided until linear interpolation is within `tol_T` kelvin, + because Cp varies with T); 'glide' (a mixture's two-phase region: + linear between samples no more than `tol_T` from the true curve). + jumps : tuple[tuple[float, float]] + Enthalpy ranges (H_a, H_b) of the flats that come from a + non-equilibrium end state rather than from latent heat: flats of a + monotone curve at an end temperature whose PH flash at + mid-enthalpy lands outside [T_lo, T_hi] (e.g. a vapor fed below its + dew point: its excess over the equilibrium liquid at the feed + temperature). A state strictly inside a jump exists only as a mix + of the two end states at the jump's temperature (see `state_at_H`); + a PH flash there leaves the stream's range. Always empty for a + point-load (non-monotone) curve. + monotone : bool + False for streams whose outlet temperature does not move with their + duty (isothermal, or e.g. a reboiler outlet at VLE colder than its + inlet); their curve is one flat segment at the outlet temperature, + i.e. a point load there. + T_lo, T_hi, H_lo, H_hi : float + The stream's own temperature and enthalpy range. + boundaries : dict + Phase boundaries found inside the range ('T_sat', or 'T_bubble' / + 'T_dew'), real temperatures [K]. + + Notes + ----- + Inside [T_lo, T_hi] the stream is taken at equilibrium at the real + temperature, with the enthalpy clipped to [H_lo, H_hi] so that a + non-equilibrium end state can never inflate the duty; at its own two end + temperatures it has exactly its end enthalpies. Where the equilibrium + enthalpy crosses H_lo or H_hi inside the range (e.g. a liquid fed above + its bubble point), the exact crossing state is a breakpoint, beyond + which the curve is vertical (clipped). If equilibrium at an end + temperature differs from the end's own enthalpy (a superheated liquid or + subcooled vapor feed, say), the difference is a flat segment AT that end + temperature: the limit of sampling ever more densely, and a property of + the stream alone. + + Linearization error: with every glide sample, and every breakpoint of + a curved single-phase stretch, within `tol_T` kelvin of the chord, the + linearized stream is the true one shifted by at most `tol_T` in + temperature at every enthalpy. Moving a hot stream's heat to higher + temperature (or a cold stream's demand to lower temperature) never + raises the hot-utility target, so the targets computed from the curves + are bracketed by the exact targets at T_min_app -/+ 2 tol_T. The bound + rests on midpoint tests, so it is approximate rather than guaranteed. + + """ + __slots__ = ('T', 'H', 'kinds', 'evals', 'glides', 'T_in', 'T_out', + 'H_in', 'H_out', 'T_lo', 'T_hi', 'H_lo', 'H_hi', 'P', + 'monotone', 'is_hot', 'label', 'boundaries', 'n_evals', + 'glide_error', 'stream_in', 'stream_out', 'tol_H', + 'method', 'tol_T', 'max_samples', 'jumps') + + def __init__(self, stream_in, stream_out, is_hot=None, + tol_T=GLIDE_TOL_T, max_samples=GLIDE_MAX_SAMPLES, + label=None): + self.label = label or stream_in.ID + self.tol_T = tol_T + self.max_samples = max_samples + # private copies: later changes to the caller's streams cannot + # change the curve's end states + self.stream_in = stream_in = _copy(stream_in) + self.stream_out = stream_out = _copy(stream_out) + self.T_in = T_in = stream_in.T + self.T_out = T_out = stream_out.T + self.H_in = H_in = stream_in.H + self.H_out = H_out = stream_out.H + self.P = stream_in.P + if is_hot is None: is_hot = H_in > H_out + self.is_hot = is_hot = bool(is_hot) + sign = 1. if is_hot else -1. + self.H_lo, self.H_hi = H_lo, H_hi = sorted((H_in, H_out)) + self.tol_H = 1e-9 * (abs(H_lo) + abs(H_hi) + (H_hi - H_lo)) + 1e-12 + self.boundaries = {} + self.n_evals = 0 + self.glide_error = 0. + self.glides = () + self.monotone = monotone = sign * (T_in - T_out) > 0. + if not monotone: + self.T_lo = self.T_hi = T_out + self.T = np.array([T_out, T_out]) + self.H = np.array([H_lo, H_hi]) + self.kinds = (FLAT,) + self.evals = (None,) + self.method = 'point load' + else: + self.T_lo, self.T_hi = sorted((T_in, T_out)) + try: + pieces = self._phase_pieces(tol_T, max_samples) + except Exception as error: + warn(f"phase analysis failed for stream {self.label!r} " + f"({error!r}); falling back to TP flashes at grid points", + RuntimeWarning) + pieces = [(SENSIBLE, self.T_lo, self.T_hi, + _TPFlash(stream_in, self.label))] + self.method = 'legacy' + self._assemble(pieces) + self.jumps = self._find_jumps() + + # -- construction ------------------------------------------------------ + + def _phase_pieces(self, tol_T, max_samples): + """ + Split [T_lo, T_hi] into ascending pieces: (SENSIBLE, Ta, Tb, + evaluator), (FLAT, T, T, (H_left, H_right)) or (GLIDE, Ta, Tb, + (t, T, H, sampler)). + """ + s = self.stream_in + P = self.P + T_lo, T_hi = self.T_lo, self.T_hi + vle = s.vle_chemicals + if not vle: + self.method = 'no VLE' + return [(SENSIBLE, T_lo, T_hi, _FixedPhase(s))] + chemicals = s.chemicals + mol = s.mol + light = any(mol[chemicals.index(c.ID)] > 0. + for c in getattr(chemicals, 'light_chemicals', ())) + heavy = any(mol[chemicals.index(c.ID)] * (getattr(c, 'N_solutes', 0) or 0) > 0. + for c in getattr(chemicals, 'heavy_chemicals', ())) + IDs = tuple(c.ID for c in vle) + if len(vle) == 1 and not (light or heavy): + return self._pure_pieces(vle[0]) + # -- mixture --------------------------------------------------- + if light: + T_b = -np.inf + satL = None + else: + satL = _copy(s) + satL.vle(V=0, P=P) + T_b = satL.T + self.n_evals += 1 + if T_b >= T_hi - _T_SNAP: + self.method = 'mixture, liquid' + return [(SENSIBLE, T_lo, T_hi, _FixedPhase(satL))] + binary = len(vle) == 2 and not (light or heavy) + x_dew = None + if heavy: + T_d = np.inf + satV = None + else: + satV = _copy(s) + satV.vle(V=1, P=P) + T_d = satV.T + self.n_evals += 1 + if binary: x_dew = s.dew_point_at_P(P).x + if T_d <= T_lo + _T_SNAP: + self.method = 'mixture, vapor' + return [(SENSIBLE, T_lo, T_hi, _FixedPhase(satV))] + pieces = [] + Ta = T_lo + if T_b > T_lo + _T_SNAP: + pieces.append((SENSIBLE, T_lo, T_b, _FixedPhase(satL))) + self.boundaries['T_bubble'] = T_b + Ta = T_b + Tb = T_hi + top = None + if T_d < T_hi - _T_SNAP: + self.boundaries['T_dew'] = T_d + Tb = T_d + top = (SENSIBLE, T_d, T_hi, _FixedPhase(satV)) + # glide over [Ta, Tb] + if binary: + self.method = 'mixture, binary bubble-curve glide' + sampler = _BinaryGlide(satL, IDs, s.get_bubble_point(), x_dew, P) + T0, T1 = T_b, T_d + fT = lambda t: sampler(t)[0] + t_at = lambda T: _illinois(lambda t: fT(t) - T, 0., 1., T0 - T, T1 - T) + # an azeotrope (T0 == T1) gives ta = 0, tb = 1: a flat, as for + # a pure component + ta = 0. if Ta <= T0 + _T_SNAP else t_at(Ta) + tb = 1. if Tb >= T1 - _T_SNAP else t_at(Tb) + sa = (T_b, satL.H) if ta == 0. else sampler(ta) + sb = (T_d, satV.H) if tb == 1. else sampler(tb) + if ta > 0.: sa = (Ta, sa[1]) # exact end temperature + if tb < 1.: sb = (Tb, sb[1]) + else: + self.method = 'mixture, TP-flash glide' + template = satL if satL is not None else satV + if template is None: # light AND heavy solutes: all glide + template = _copy(s) + template.vle(T=T_lo, P=P) + strict = not (light or heavy) # [Ta, Tb] inside the true glide + sampler = _TPGlide(template, Ta, Tb, P, IDs, strict) + ta, tb = 0., 1. + sa = (Ta, satL.H) if Ta == T_b else sampler(0.) + sb = (Tb, satV.H) if Tb == T_d else sampler(1.) + if sa is None or sb is None: + raise RuntimeError('TP flash failed at a glide end') + # the saturated end states are the glide's end samples + if Ta == T_b: sampler.states[0.] = _copy(satL) + if Tb == T_d: sampler.states[1.] = _copy(satV) + t, T, H, err = _sample_glide(sampler, ta, sa, tb, sb, tol_T, max_samples) + if not binary and not strict: + # always-gas or dissolved-solute chemicals: thermosteam's bubble + # and dew points ignore them, so the real phase boundaries (the + # kinks of the curve) are found where the TP flashes change + # phase, and single-phase stretches become exact sensible pieces + cuts = sampler.phase_boundaries(t) + if cuts: + samples = {k: (Tk, Hk) for k, Tk, Hk in zip(t, T, H)} + bounds = [(ta, sa)] + for a, b in cuts: + ra, rb = sampler(a), sampler(b) + if ra is None or rb is None: continue + bounds.append((a, ra)); bounds.append((b, rb)) + bounds.append((tb, sb)) + err = 0. + for (u, su), (w, sw) in zip(bounds[:-1], bounds[1:]): + if w <= u: continue + if sw[0] - su[0] <= _T_SNAP: # the bracket of a boundary + pieces.append((GLIDE, su[0], sw[0], + (np.array([u, w]), np.array([su[0], sw[0]]), + np.array([su[1], sw[1]]), sampler))) + continue + m = 0.5 * (u + w) + if sampler.phase_at(m) in ('l', 'g'): + pieces.append((SENSIBLE, su[0], sw[0], + _FixedPhase(sampler.state(m)))) + else: + g = _sample_glide(sampler, u, su, w, sw, tol_T, + max_samples, init=samples) + err = max(err, g[3]) + pieces.append((GLIDE, su[0], sw[0], (*g[:3], sampler))) + self.method += ' (phase boundaries located by TP flashes)' + for i, (a, b) in enumerate(cuts): + self.boundaries[f'T_phase_{i}'] = float( + sampler.Ta + (sampler.Tb - sampler.Ta) * b) + t = None + self.n_evals += sampler.n + self.glide_error = max(self.glide_error, err) + if err > tol_T: + warn(f"glide of stream {self.label!r} sampled to {err:.3g} K " + f"(tolerance {tol_T} K) within {max_samples} evaluations", + RuntimeWarning) + if t is not None: pieces.append((GLIDE, Ta, Tb, (t, T, H, sampler))) + if top is not None: pieces.append(top) + return pieces + + def _pure_pieces(self, chemical): + s = self.stream_in + P = self.P + T_lo, T_hi = self.T_lo, self.T_hi + Pc = getattr(chemical, 'Pc', None) + if Pc is not None and P >= Pc: + self.method = 'pure, supercritical (TP flashes)' + return [(SENSIBLE, T_lo, T_hi, _TPFlash(s, self.label))] + satL = _copy(s) + satL.vle(V=0, P=P) + satV = _copy(s) + satV.vle(V=1, P=P) + self.n_evals += 2 + T_sat = satL.T + if T_sat > T_hi + _T_SNAP: + self.method = 'pure, liquid' + return [(SENSIBLE, T_lo, T_hi, _FixedPhase(satL))] + if T_sat < T_lo - _T_SNAP: + self.method = 'pure, vapor' + return [(SENSIBLE, T_lo, T_hi, _FixedPhase(satV))] + self.method = 'pure, phase change' + if T_sat - T_lo <= _T_SNAP: T_sat = T_lo + elif T_hi - T_sat <= _T_SNAP: T_sat = T_hi + self.boundaries['T_sat'] = T_sat + pieces = [] + if T_sat > T_lo: pieces.append((SENSIBLE, T_lo, T_sat, _FixedPhase(satL))) + pieces.append((FLAT, T_sat, T_sat, (satL.H, satV.H, satL, satV))) + if T_sat < T_hi: pieces.append((SENSIBLE, T_sat, T_hi, _FixedPhase(satV))) + return pieces + + def _assemble(self, pieces): + H_lo, H_hi = self.H_lo, self.H_hi + T_lo, T_hi = self.T_lo, self.T_hi + tol_H = self.tol_H + tol_T, max_samples = self.tol_T, self.max_samples + clip = lambda H: H_lo if H < H_lo else H_hi if H > H_hi else H + if self.H_in <= self.H_out: + low_end, high_end = self.stream_in, self.stream_out + else: + low_end, high_end = self.stream_out, self.stream_in + Ts = [T_lo] + Hs = [H_lo] + kinds = [] + evals = [] # sensible: evaluator; flat: (state_a, state_b) thunks + # (lazy) state at the last breakpoint, for the flats' lever rule + last = [lambda: _end_state(low_end, T_lo, T_hi)] + + def add(T, H, kind, ev=None, state=None): + H = clip(H) + if H < Hs[-1]: H = Hs[-1] # keep non-decreasing + if T < Ts[-1]: T = Ts[-1] + elif T > T_hi: T = T_hi # a phase boundary a hair above T_hi + if T == Ts[-1]: + if H - Hs[-1] <= tol_H: # same point + if state is not None: last[0] = state + return + kind, ev = FLAT, (last[0], state) + elif kind == FLAT: # cannot happen for well-formed pieces + kind, ev = GLIDE, None # (linear) + Ts.append(T); Hs.append(H); kinds.append(kind); evals.append(ev) + if state is not None: last[0] = state + + def at_T(ev, T): + return lambda: ev.state_at_T(T) + + def at_t(sampler, t): + return lambda: sampler.state(t) + + glides = [] + for kind, Ta, Tb, data in pieces: + if kind == SENSIBLE: + ev = data + Ha = ev.H(Ta) + # at T_lo: a jump here is the non-equilibrium excess of the end + if Ha is not None: add(Ta, Ha, FLAT, state=at_T(ev, Ta)) + Hb = ev.H(Tb) + if Hb is None: # fallback flash failed: linear + Hb = H_hi if Tb == T_hi else Hs[-1] + elif Ha is not None: # curvature (temperature-dependent Cp) + # exact breakpoints where the stretch enters and leaves + # the enthalpy range (see `_glide_crossings`) + a, fa, b, fb = Ta, clip(Ha), Tb, clip(Hb) + if Ha < H_lo - tol_H and Hb > H_lo + tol_H: + T_c = _sensible_crossing(ev.H, Ta, Ha, Tb, Hb, H_lo) + if T_c is not None: + add(T_c, H_lo, SENSIBLE, ev, state=at_T(ev, T_c)) + a, fa = T_c, H_lo + T_top = None + if Ha < H_hi - tol_H and Hb > H_hi + tol_H: + T_top = _sensible_crossing(ev.H, Ta, Ha, Tb, Hb, H_hi) + if T_top is not None and T_top > a: b, fb = T_top, H_hi + else: T_top = None + for Tk, Hk in _sensible_samples(ev.H, a, fa, b, fb, + clip, tol_T, max_samples): + add(Tk, Hk, SENSIBLE, ev, state=at_T(ev, Tk)) + if T_top is not None: + add(T_top, H_hi, SENSIBLE, ev, state=at_T(ev, T_top)) + add(Tb, Hb, SENSIBLE, ev, state=at_T(ev, Tb)) + elif kind == FLAT: + H_l, H_v, sL, sV = data + add(Ta, H_l, FLAT, state=partial(_copy, sL)) + add(Ta, H_v, FLAT, state=partial(_copy, sV)) + else: # GLIDE + t, T, H, sampler = data + t, T, H, err = _glide_crossings(sampler, t, T, H, H_lo, H_hi, + tol_H, tol_T, max_samples) + self.glide_error = max(self.glide_error, err) + add(T[0], H[0], FLAT, state=at_t(sampler, t[0])) + for tk, Tk, Hk in zip(t[1:], T[1:], H[1:]): + add(Tk, Hk, GLIDE, state=at_t(sampler, tk)) + glides.append((t, T, H, sampler)) + self.glides = tuple(glides) + # exact high end + if abs(Hs[-1] - H_hi) <= tol_H and Ts[-1] == T_hi: + Hs[-1] = H_hi + else: + if Ts[-1] < T_hi: + # vertical (clipped) stretch up to T_hi: H constant + Ts.append(T_hi); Hs.append(Hs[-1]) + kinds.append(SENSIBLE); evals.append(None) + Ts.append(T_hi); Hs.append(H_hi) + kinds.append(FLAT) + evals.append((last[0], lambda: _end_state(high_end, T_lo, T_hi))) + self.T = np.array(Ts) + self.H = np.array(Hs) + self.kinds = tuple(kinds) + self.evals = tuple(evals) + + def _find_jumps(self): + """ + Enthalpy ranges of the flats that stem from a non-equilibrium end + state (see `jumps`). Only the flats of a monotone curve at an end + temperature can be jumps (interior flats are latent heat; the flat + of a point-load stream is its whole duty); each candidate costs one + PH flash. + """ + if not self.monotone: return () + jumps = [] + T_lo, T_hi = self.T_lo, self.T_hi + for j, kind in enumerate(self.kinds): + if kind != FLAT: continue + T = self.T[j] + if T_lo < T < T_hi: continue + Ha, Hb = float(self.H[j]), float(self.H[j + 1]) + if Hb <= Ha: continue + s = _copy(self.stream_in) + try: + s.vle(H=0.5 * (Ha + Hb), P=self.P) + T_eq = s.T + except Exception: # no equilibrium state there at all + T_eq = np.nan + if not (T_lo - _T_JUMP <= T_eq <= T_hi + _T_JUMP): + jumps.append((Ha, Hb)) + return tuple(jumps) + + # -- evaluation ---------------------------------------------------------- + + def _segment_H(self, j, T): + """Enthalpy at real T strictly inside segment j (T[j] < T < T[j+1]).""" + Ta, Tb = self.T[j], self.T[j + 1] + Ha, Hb = self.H[j], self.H[j + 1] + kind = self.kinds[j] + if kind == SENSIBLE and self.evals[j] is not None: + H = self.evals[j].H(T) + if H is not None: + return Ha if H < Ha else Hb if H > Hb else H + if Tb == Ta: return Hb + return Ha + (Hb - Ha) * (T - Ta) / (Tb - Ta) + + def _segment_of_H(self, H): + """Index j of the segment with H[j] < H < H[j+1], or None if `H` is + a breakpoint enthalpy or outside (H_lo, H_hi).""" + Hbp = self.H + if not (Hbp[0] < H < Hbp[-1]): return None + j = int(np.searchsorted(Hbp, H, 'right')) - 1 + if Hbp[j] == H: return None + return j + + def _glide_at(self, H): + """The glide sample arrays (t, T, H, sampler) that contain `H`.""" + for g in self.glides: + Hg = g[2] + if Hg[0] <= H <= Hg[-1]: return g + return None + + def _glide_root(self, H, state=False): + """ + ``(t, (T, H), state)`` of the glide state with enthalpy `H`, solved + between the two glide samples that bracket it (`state`: a copy of + that very state, whose enthalpy is the one matched, if requested; + else None), or None if `H` is in no glide, an evaluation failed, or + the root missed `H` by more than `_ROOT_RTOL` times the duty. + + The parameters inside the bracket are evaluated with + ``strict=False``: next to a bubble or dew point a TP flash can + legitimately come back single phase, which a strict `_TPGlide` + reports as a failure; the root then sits on that jump of the flash + results, within about 1e-8 of the duty of `H`. A larger miss means + the flashes are not monotone inside the bracket: thermosteam's TP + flash of some mixtures returns spurious states at isolated + temperatures (e.g. near-total vaporization inside the glide of + water with 1.4 % methanol and 0.3 % glycerol, the reboiler of the + `HeatExchangerNetwork` doctest system), which the glide sampler + drops but a root solve cannot avoid. + """ + g = self._glide_at(H) + if g is None: return None + t, Tg, Hg, sampler = g + i = min(max(int(np.searchsorted(Hg, H, 'right')) - 1, 0), Hg.size - 2) + last = {} + evaluated = [None] + + def f(tt): + r = sampler(tt, strict=False) + if r is None: raise RuntimeError('glide evaluation failed') + last[tt] = r + evaluated[0] = tt + return r[1] - H + + try: + tt = _illinois(f, t[i], t[i + 1], Hg[i] - H, Hg[i + 1] - H) + if evaluated[0] != tt: f(tt) # the sampler must hold state tt + except Exception: # a flash that raised inside a TP glide + return None + r = last[tt] + if abs(r[1] - H) > _ROOT_RTOL * (self.H_hi - self.H_lo) + self.tol_H: + return None + return tt, r, _copy(sampler.work) if state else None + + def _glide_state(self, H): + """ + Glide state with enthalpy `H` (the `state_at_H` of a glide), or + None if `H` is in no glide. + + In order: the root of `_glide_root` (a residual from a phase-boundary + jump of the flash results, at most `_ROOT_RTOL` of the duty, is + closed with the phase split fixed, a shift of ~1e-6 K); else a PH + flash, accepted only if its temperature lies between the two glide + samples that bracket `H` (the curve is monotone through exact + samples, so the true state does); else the curve's own state: the + lever-rule mix of the two bracketing sampled states at the + linearized temperature `T_at(H)`. The fallbacks matter only where + thermosteam's flashes fail or are not monotone inside a glide (see + `_glide_root`). + """ + g = self._glide_at(H) + if g is None: return None + tol_H = self.tol_H + root = self._glide_root(H, state=True) + if root is not None: + s = root[2] # the state whose enthalpy the root matched + if abs(s.H - H) > tol_H: + try: s.H = H + except Exception: pass + if abs(s.H - H) <= 10. * tol_H: return s + t, Tg, Hg, sampler = g + i = min(max(int(np.searchsorted(Hg, H, 'right')) - 1, 0), Hg.size - 2) + s = _copy(self.stream_in) + try: + s.vle(H=H, P=self.P) + if (Tg[i] - _T_EQ <= s.T <= Tg[i + 1] + _T_EQ + and abs(s.H - H) <= 10. * tol_H): + return s + except Exception: + pass + tol = _ROOT_RTOL * (self.H_hi - self.H_lo) + tol_H + try: + A, B = sampler.sample_state(t[i]), sampler.sample_state(t[i + 1]) + if abs(A.H - Hg[i]) > tol or abs(B.H - Hg[i + 1]) > tol: return None + s = _lever(A, B, H, self.T_at(H)) + if abs(s.H - H) > tol_H: s.H = H + except Exception: + return None + return s if abs(s.H - H) <= 10. * tol_H else None + + def H_at(self, T, side='right'): + """ + Enthalpy [kJ/hr] at real temperature `T`. At a flat segment (a point + load) 'left' gives the low-enthalpy end and 'right' the high one. + Below T_lo it is H_lo, above T_hi it is H_hi. + """ + Tbp = self.T + il = int(np.searchsorted(Tbp, T - _T_EQ, 'left')) + ir = int(np.searchsorted(Tbp, T + _T_EQ, 'right')) + if ir > il: # T is (within _T_EQ) a breakpoint temperature + return self.H[il] if side == 'left' else self.H[ir - 1] + if il == 0: return self.H_lo + if il >= Tbp.size: return self.H_hi + return self._segment_H(il - 1, T) + + def grid_limits(self, Ts, shift, index): + """ + Left and right enthalpy limits on a descending shifted grid `Ts`. + `index[i]` is the grid position of breakpoint i (so breakpoints are + matched by position, never by comparing shifted floats). + """ + n = Ts.size + Hl = np.empty(n) + Hr = np.empty(n) + top = index[-1] # highest-T breakpoint -> smallest descending index + bottom = index[0] + Hl[:top] = Hr[:top] = self.H_hi + Hl[bottom + 1:] = Hr[bottom + 1:] = self.H_lo + # breakpoints: first (lowest H) is the left limit, last the right one + H = self.H + for i in range(index.size - 1, -1, -1): + Hl[index[i]] = H[i] + for i in range(index.size): + Hr[index[i]] = H[i] + # grid points strictly inside a segment + kinds, evals = self.kinds, self.evals + T = self.T + for j in range(index.size - 1): + k_hi, k_lo = index[j + 1], index[j] + if k_lo - k_hi < 2: continue + if kinds[j] == SENSIBLE and evals[j] is not None: + for k in range(k_hi + 1, k_lo): + Hl[k] = Hr[k] = self._segment_H(j, Ts[k] + shift) + else: # linear (glide, or a clipped constant stretch) + x = Ts[k_hi + 1:k_lo] + shift + Hl[k_hi + 1:k_lo] = Hr[k_hi + 1:k_lo] = \ + H[j] + (H[j + 1] - H[j]) * (x - T[j]) / (T[j + 1] - T[j]) + return Hl, Hr + + def T_at(self, H, side='low'): + """ + Real temperature [K] at enthalpy `H`: exact in single-phase segments, + linear inside glides, T_sat on a flat segment. Where the enthalpy is + constant over a temperature range (a clipped non-equilibrium + stretch), 'low' gives its lowest temperature and 'high' its highest. + """ + Hbp = self.H + if H <= Hbp[0]: + if side == 'low': return self.T[0] + return self.T[int(np.searchsorted(Hbp, Hbp[0], 'right')) - 1] + if H >= Hbp[-1]: + if side == 'high': return self.T[-1] + return self.T[int(np.searchsorted(Hbp, Hbp[-1], 'left'))] + j = int(np.searchsorted(Hbp, H, 'right')) - 1 + if Hbp[j] == H: # exactly at a breakpoint enthalpy + if side == 'low': + return self.T[int(np.searchsorted(Hbp, H, 'left'))] + return self.T[int(np.searchsorted(Hbp, H, 'right')) - 1] + Ta, Tb = self.T[j], self.T[j + 1] + Ha, Hb = Hbp[j], Hbp[j + 1] + if Ta == Tb: return Ta + if self.kinds[j] == SENSIBLE and self.evals[j] is not None: + try: + T = self.evals[j].state_at_H(H).T + return Ta if T < Ta else Tb if T > Tb else T + except Exception: + pass + return Ta + (Tb - Ta) * (H - Ha) / (Hb - Ha) + + def T_exact(self, H, side='low'): + """ + Real temperature [K] of the stream's equilibrium state with enthalpy + `H`, without the linearization of `T_at`: inside a glide the + temperature of the glide state with that enthalpy (a binary's + bubble-curve root, or the TP-flash root of other mixtures); in a + single-phase segment the exact phase-fixed solution; T_sat on a + flat; and `T_at(H, side)` at breakpoints and on a vertical (clipped) + stretch. Costs a root solve (about ten equilibrium evaluations) + only inside glides; elsewhere it equals `T_at`. Inside a glide it is + the temperature of ``state_at_H(H)`` (see `_glide_state` for where + thermosteam's flashes fail), or `T_at` if no state was found. + """ + j = self._segment_of_H(H) + if j is None or self.kinds[j] != GLIDE: + return self.T_at(H, side) + s = self._glide_state(H) + if s is None: return self.T_at(H, side) + T = s.T + Ta, Tb = self.T[j], self.T[j + 1] + return Ta if T < Ta else Tb if T > Tb else T + + def state_at_H(self, H): + """ + Stream state with enthalpy `H` (clipped to the range): the + equilibrium end state at either end (the stream as given if that + equilibrium lies outside the range; at the inlet of a point-load + stream, `_point_load_inlet`); single phase: the phase-fixed + reference at the temperature that gives H (exact); flat (a pure + component's T_sat or an end jump): the lever-rule mix of the flat's + two end states at its temperature; binary glide: the bubble-curve + state with that enthalpy (robust); other glides: a TP flash at the + temperature (found by bracketing) whose enthalpy is H, with + fallbacks where thermosteam's flashes fail (see `_glide_state`). + """ + H_lo, H_hi = self.H_lo, self.H_hi + end = None + if H <= H_lo + self.tol_H: + end = self.stream_in if self.H_in <= self.H_out else self.stream_out + elif H >= H_hi - self.tol_H: + end = self.stream_in if self.H_in > self.H_out else self.stream_out + if end is not None: + if end is self.stream_in and not self.monotone: + return _point_load_inlet(end, self.T_out, self.is_hot) + return _end_state(end, self.T_lo, self.T_hi) + Hbp = self.H + j = int(np.searchsorted(Hbp, H, 'right')) - 1 + j = min(j, len(self.kinds) - 1) + kind = self.kinds[j] + if kind == SENSIBLE and self.evals[j] is not None: + return self.evals[j].state_at_H(H) + if kind == GLIDE: + s = self._glide_state(H) + if s is not None: return s + # flat (T_sat of a pure chemical, or an end jump): lever rule + # between its two end states at the flat's temperature + if kind == FLAT and self.monotone and isinstance(self.evals[j], tuple): + try: + A, B = (f() for f in self.evals[j]) + s = _lever(A, B, H, self.T[j]) + except Exception: + s = None + if s is not None and abs(s.H - H) <= 10. * self.tol_H: + return s + # non-monotone stream (a point load), clipped stretch, or a glide + # none of whose states could be found: PH flash + s = _copy(self.stream_in) + s.vle(H=H, P=self.P) + return s + + def state_at_T(self, T, side='right'): + """ + Stream state at real temperature `T`, on the `side` branch of a flat + segment there. Single-phase: the equilibrium state at exactly T; + otherwise the state at the curve's enthalpy `H_at(T, side)` (inside + a glide its temperature differs from T by at most the sampling + tolerance, but its enthalpy is the one the problem table used). + """ + H = self.H_at(T, side) + Tbp = self.T + il = int(np.searchsorted(Tbp, T - _T_EQ, 'left')) + ir = int(np.searchsorted(Tbp, T + _T_EQ, 'right')) + if ir == il and 0 < il < Tbp.size: + j = il - 1 + if (self.kinds[j] == SENSIBLE and self.evals[j] is not None + and self.H[j] < H < self.H[j + 1]): + return self.evals[j].state_at_T(T) + return self.state_at_H(H) + + def __repr__(self): + return (f"") + + def show(self): + """Print the curve, one segment per line.""" + print(repr(self)) + for i, kind in enumerate(self.kinds): + print(f" {self.T[i]:10.4f} -> {self.T[i+1]:10.4f} K " + f"H {self.H[i]:14.1f} -> {self.H[i+1]:14.1f} {kind}") + + +def stream_curves(streams_inlet, streams_quenched, is_hot, **kwargs): + """One `StreamCurve` per stream (see `StreamCurve` for keywords).""" + return [StreamCurve(si, so, hot, **kwargs) + for si, so, hot in zip(streams_inlet, streams_quenched, is_hot)] diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 28bd2d9..2d5e98c 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -10,15 +10,102 @@ Created on Sat Aug 22 21:58:19 2020 @author: sarangbhagwat and yoelcp """ +import heapq import biosteam as bst -import thermosteam as tmo import numpy as np -from .hxn_synthesis import synthesize_network, StreamLifeCycle, plot_pinch_diagram +from .hxn_synthesis import ( + synthesize_network, StreamLifeCycle, plot_pinch_diagram, _first_inlet, +) from warnings import warn -import warnings __all__ = ('HeatExchangerNetwork',) +#: A stream whose utility exchanger would transfer at most this fraction of +#: the stream's duty is already at its outlet: the plan leaves it no utility +#: duty (the planner resolves duties to 1e-9 of the total), and what is left +#: is the residual of the enthalpy flashes that realize the plan (at most +#: 1e-10 of the duty over the test suite, where the smallest utility duty a +#: network does leave is 3e-7 of the stream's duty). +_SERVED_RTOL = 1e-9 + +def _pass_through_served_streams(stream_life_cycles, original_units): + """ + Let every stream that the process exchangers bring to its outlet (to + within `_SERVED_RTOL` of its duty) leave its utility exchanger in the + state it enters it. + + A rigorous `HXutility` re-flashes its feed at the outlet enthalpy even + when the feed already has it; the flash converges from its own previous + state, so the phase split can move in the last digits and, with the + enthalpies of formation, leave a spurious net duty (~1e-9 kJ/hr) that + biosteam designs and costs as a minimum-size exchanger, depending on + flash noise (e.g. present in a cached network but not in the fresh one). + Passing the feed through gives the exchanger exactly no duty, which + biosteam neither designs nor costs; an exchanger with any real duty is + left as it is (and costed as always). Utility outlets feed nothing else + in the network, so the converged network needs no further pass. + """ + for life_cycle, unit in zip(stream_life_cycles, original_units): + util = life_cycle.life_cycle[-1].unit + if not isinstance(util, bst.HXutility): continue + feed, product = util.ins[0], util.outs[0] + duty = abs(unit.outs[0].H - unit.ins[0].H) + if abs(product.H - feed.H) <= _SERVED_RTOL * duty: + product.copy_like(feed) + +def _load_utility_costs(unit): + """Recompute the utility cost of `unit` and of its owner (the unit whose + heat utilities include those of `unit`, e.g. a column for its + condenser) from their heat utilities.""" + unit._load_operation_costs() + owner = unit.owner + if owner is not unit: owner._load_operation_costs() + +def _network_path(units, stream_life_cycles): + """ + Return the simulation path of a synthesized network and its recycle + (tear) streams. + + Every stream passes its stages in series, so the network is a directed + graph with an edge from each stage to the next one of the same stream. + The path is a topological order of that graph (Kahn's algorithm, ties + broken by the order of `units`); where the graph has a cycle (e.g. a + pair of streams matched both above and below the pinch, or repeated + matches in alternating order), the unit with the fewest unplaced + predecessors comes next and its inlets from later units become recycle + streams. Every unit then runs after all its feeders except across a + declared recycle, and the fixed-point iteration of the resulting + `System` converges the loops. (Deterministic, unlike a general network + sort, which need not settle on intertwined loops.) + """ + position = {u: i for i, u in enumerate(units)} + successors = {u: [] for u in units} + N_waiting = {u: 0 for u in units} + for life_cycle in stream_life_cycles: + stages = life_cycle.life_cycle + for a, b in zip(stages, stages[1:]): + successors[a.unit].append((b.unit, a.unit.outs[a.index])) + N_waiting[b.unit] += 1 + ready = [position[u] for u in units if not N_waiting[u]] + heapq.heapify(ready) + placed = {} + while len(placed) < len(units): + if ready: + unit = units[heapq.heappop(ready)] + if unit in placed: continue + else: # a cycle: break it where the fewest feeders are missing + unit = min((u for u in units if u not in placed), + key=lambda u: (N_waiting[u], position[u])) + placed[unit] = len(placed) + for other, _ in successors[unit]: + N_waiting[other] -= 1 + if not N_waiting[other] and other not in placed: + heapq.heappush(ready, position[other]) + path = sorted(units, key=placed.__getitem__) + recycles = [s for unit in units for other, s in successors[unit] + if placed[other] <= placed[unit]] + return path, recycles + class HeatExchangerNetwork(bst.Facility): """ @@ -39,15 +126,64 @@ class HeatExchangerNetwork(bst.Facility): Notes ----- - The network is synthesized with the pinch design method [1]_. - 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 to the - HeatExchangerNetwork object. - + The network is synthesized without stream splits by + :func:`~hensmith.hxn_synthesis.synthesize_network`: a problem table on + the streams' temperature-enthalpy curves gives the minimum energy + requirement (MER) targets, and a planner builds each side of the pinch + from the pinch outward [1]_ [2]_, keeping `T_min_app` everywhere inside + every exchanger on the exact stream states. It reaches the targets + whenever its search finds an unsplit network that does; the same pair + of streams may then be matched more than once (IDs with a suffix + ``_``, e.g. ``HX_3_2_cs_2``), since series alternation can replace a + split. Where the pinch design rules prove that MER needs stream + splitting, the network is a best-effort one close to the targets. The + outcome is recorded in `synthesis_info` (a dict; see the `info` keyword + of `synthesize_network`): 'status' is 'mer' when the network's utilities + equal the targets and 'best_effort' otherwise, with the targets, the + planned utilities and, per side of the pinch, any proof that a split is + needed. + + Original system stream and heat exchanger objects are preserved. All + stream copies and new HX objects can be found in a newly created + flowsheet '_HXN' where is the name of the system associated + to the HeatExchangerNetwork object. Each stream passes its exchangers in + series; the network is simulated as a `System` (`HXN_sys`) whose path + follows the streams, with the loops that repeated matches can form torn + and converged to a tight tolerance (every exchanger starts at its + planned state, so the loops are at their fixed point after one pass). + + With `cache_network`, a network is reused while the set of heat + exchangers is the same: each process exchanger keeps, as its enthalpy + limit, the share of the stream's duty it had at synthesis (on the + stream that the plan serves completely on that side of the pinch; its + partner transfers that share, but never past its own outlet, and takes + the rest to its utility), and the utility exchangers + bring every stream to its new outlet. If the cached network does not + reproduce the outlets, or its energy balance is off, the network is + synthesized again. + + Every utility exchanger is designed and costed by biosteam as usual. A + stream that its process exchangers bring to its outlet (within 1e-9 of + its duty: the residual of the enthalpy flashes) leaves its utility + exchanger in the state it enters it, so that exchanger has exactly no + duty and no cost instead of a spurious duty from re-flashing the + stream. + + The facility's heat utilities are the new utilities less the original + ones, summed by agent (a negative utility cost is a saving). With + `replace_unit_heat_utilities`, each original heat utility takes the heat + utility of its own stream's utility exchanger instead, the utility costs + of its unit and of that unit's owner are reloaded, and the facility + carries no heat utilities. The original data are given back before the + network is costed again, so that the network is synthesized from the + units' own utilities whether or not the units were simulated again. + References ---------- - .. [1] Seider, W. D., Lewin, D. R., Seader, J. D., Widagdo, S., Gani, R., + .. [1] Linnhoff, B., & Hindmarsh, E. (1983). The pinch design method for + heat exchanger networks. Chemical Engineering Science, 38(5), + 745-763. + .. [2] Seider, W. D., Lewin, D. R., Seader, J. D., Widagdo, S., Gani, R., & Ng, M. K. (2017). Product and Process Design Principles. Wiley. Heat Exchanger Networks (Chapter 9) @@ -74,6 +210,8 @@ class HeatExchangerNetwork(bst.Facility): 0.82 >>> abs(HXN.energy_balance_percent_error) < 0.01 True + >>> HXN.synthesis_info['status'] # the utilities equal the MER targets + 'mer' >>> HXN.stream_life_cycles [, H_in = 4.24e+07 kJ/hr, H_out = 6.92e+07 kJ/hr> ]>, , H_in = 0 kJ/hr, H_out = 3.34e+04 kJ/hr> - , H_in = 3.34e+04 kJ/hr, H_out = 5.06e+06 kJ/hr> - , H_in = 5.06e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> + , H_in = 0 kJ/hr, H_out = 5.05e+06 kJ/hr> + , H_in = 5.05e+06 kJ/hr, H_out = 5.08e+06 kJ/hr> + , H_in = 5.08e+06 kJ/hr, H_out = 2.3e+07 kJ/hr> , H_in = 2.3e+07 kJ/hr, H_out = 2.79e+08 kJ/hr> ]>, , H_in = 4.52e+07 kJ/hr, H_out = 8.12e+06 kJ/hr> - , H_in = 8.12e+06 kJ/hr, H_out = 3.1e+06 kJ/hr> - , H_in = 3.1e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> + , H_in = 8.12e+06 kJ/hr, H_out = 3.07e+06 kJ/hr> + , H_in = 3.07e+06 kJ/hr, H_out = 1.14e+06 kJ/hr> ]>, , H_in = 2.04e+07 kJ/hr, H_out = 2.47e+06 kJ/hr> @@ -144,15 +282,70 @@ def _get_original_heat_utilties(self): return [i for i in hx_utils if i.duty and i not in ignored_hx_utils] def _run(self): pass + + def _enter_network(self, index, stage, stream): + """Bring `stream` (the network copy of stream `index`'s real inlet, + which enters the network at `stage`) to the state in which its first + process exchanger takes it: at equilibrium at its inlet enthalpy for + a point-load stream (see `hensmith.hxn_synthesis._first_inlet`).""" + if not isinstance(stage.unit, bst.HXprocess): return + point_load = index in self.synthesis_info.get('point_loads', ()) + _first_inlet(stream, point_load, self.outlet_Ts[index], + index not in self.cold_indices) + + def _replace_unit_heat_utilities(self, heat_utilities, stream_life_cycles): + """ + Overwrite each original heat utility with the heat utility of its own + stream's utility exchanger, the last stage of the stream's life + cycle (`heat_utilities` and `stream_life_cycles` are both in stream + order; `new_HX_utils` is not: it lists the hot streams first), and + reload the utility costs of its unit and of the unit's owner. The + original data are kept for `_restore_unit_heat_utilities`. + """ + replaced = [] + for hu, life_cycle in zip(heat_utilities, stream_life_cycles): + new = life_cycle.life_cycle[-1].unit.heat_utilities[0] + replaced.append((hu, hu.copy())) + if new.agent: hu.copy_like(new) + else: hu.empty() # a stream the process exchangers serve + _load_utility_costs(hu.unit) + self._replaced_heat_utilities = replaced + + def _restore_unit_heat_utilities(self): + """ + Copy their original data back into the heat utilities that + `_replace_unit_heat_utilities` last overwrote, and reload their + units' utility costs, wherever the unit still holds that heat + utility. A unit that + is simulated again replaces its heat utilities with new ones (as + every unit is before the facility in a system simulation), but one + that is not (e.g. when the network alone is simulated again, or + once per ignored utility in `_energy_balance_error_contributions`) + would otherwise hand the network its own utilities as the process + duties, and a stream that it serves completely, with no utility + left, would drop out of the next network. + """ + replaced = getattr(self, '_replaced_heat_utilities', None) + if not replaced: return + self._replaced_heat_utilities = None + for hu, original in replaced: + unit = hu.unit + if any(i is hu for i in unit.heat_utilities): + hu.copy_like(original) + _load_utility_costs(unit) + def _design(self): pass def _load_capital_costs(self): pass # Do not replace installed costs def _cost(self): sys = self.system - hx_utils = self._get_original_heat_utilties() flowsheet = bst.Flowsheet(sys.ID + '_HXN') + with flowsheet.temporary(): + self._restore_unit_heat_utilities() + hx_utils = self._get_original_heat_utilties() use_cached_network = False - if self.cache_network and hasattr(self, 'original_heat_utils'): + if (self.cache_network and hasattr(self, 'original_heat_utils') + and hasattr(self, '_stage_fractions')): # Units are a stable key to compare whether system has changed configuration. hu_by_unit = {hu.unit: hu for hu in hx_utils} use_cached_network = ( @@ -168,19 +361,40 @@ def _cost(self): stream_life_cycles = self.stream_life_cycles new_HXs = self.new_HXs new_HX_utils = self.new_HX_utils + stage_fractions = self._stage_fractions for i, life_cycle in enumerate(stream_life_cycles): hx = hxs[i] s_util_in = hx.ins[0] stage = life_cycle.life_cycle[0] s_lc = stage.unit.ins[stage.index] s_lc.copy_like(s_util_in) - s_util_out = hx.outs[0] - H = s_util_out.H + self._enter_network(i, stage, s_lc) + H_in = s_util_in.H + H_out = hx.outs[0].H for lc in life_cycle.life_cycle: - if isinstance(lc.unit, bst.HXutility): - lc.unit.H = H - else: - setattr(lc.unit, f'H_lim{lc.index}', s_util_out.H) + unit = lc.unit + if isinstance(unit, bst.HXutility): + unit.H = H_out + continue + # Each exchanger keeps the share of its "must" + # stream's duty (port 1: the hot stream above the + # pinch, the cold stream below it), which the plan + # serves completely; the partner (port 0), which + # leaves its remainder to its utility, takes what + # that transfers up to its own outlet (its planned + # share would cut the must short when only the + # must's duty changed; no limit at all would let a + # grown must pull it past its outlet, so that its + # utility runs backwards). A port without a limit + # in the synthesis (see `synthesize_network`) gets + # none. + index = lc.index + fraction = stage_fractions.get((unit.ID, index)) + if (index == 0 and fraction is not None + and (unit.ID, 1) in stage_fractions): + fraction = 1. + setattr(unit, f'H_lim{index}', None if fraction is None + else H_in + fraction * (H_out - H_in)) sys = self.HXN_sys for unit in sys.units: for s_in, s_out in zip(unit.ins, unit.outs): @@ -198,12 +412,13 @@ def _cost(self): hx_utils.sort(key = lambda x: x.duty) self.HXN_flowsheet = HXN_F = flowsheet for i in HXN_F.registries: i.clear() + self.synthesis_info = synthesis_info = {} HXs_hot_side, HXs_cold_side, new_HX_utils, hxs, T_in_arr,\ T_out_arr, pinch_T_arr, C_flow_vector, hx_heat_utils_rearranged, streams_inlet, stream_HXs_dict,\ hot_indices, cold_indices = \ - synthesize_network(hx_utils, self.T_min_app, self.Qmin, + synthesize_network(hx_utils, self.T_min_app, self.Qmin, self.force_ideal_thermo, self.avoid_recycle, - self.sort_hus_by_T) + self.sort_hus_by_T, info=synthesis_info) new_HXs = HXs_hot_side + HXs_cold_side self.new_HXs_hot_side = HXs_hot_side self.new_HXs_cold_side = HXs_cold_side @@ -225,46 +440,55 @@ def _cost(self): s_util = hx_heat_utils_rearranged[i].unit.ins[0] s_lc = stage.unit.ins[stage.index] s_lc.copy_like(s_util) + self._enter_network(i, stage, s_lc) for life_cycle in stream_life_cycles: s_out = None for i in life_cycle.life_cycle: unit = i.unit if s_out: unit.ins[i.index] = s_out s_out = unit.outs[i.index] - # Order the path by the rewired stream connections (and - # detect recycle loops) rather than using synthesis order: a - # hot-side exchanger is synthesized before the cold-side - # exchangers that feed it, and a single pass in synthesis - # order would leave it with stale inlets. HXprocess units are - # interaction units, which Network.from_units strips out (and - # disconnects) by default; keep them with interaction=False. - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter('always', RuntimeWarning) - network = tmo.Network.from_units(all_units, interaction=False) - for w in caught: - if 'network path could not be determined' in str(w.message): - warn('heat exchanger network path could not be fully ' - 'ordered from its stream connections; exchangers ' - 'fed by later ones in the path may be simulated ' - 'with stale inlets until convergence', RuntimeWarning) - else: - warn(w.message, w.category) - self.HXN_sys = sys = bst.System._from_network(HXN_F.ID, network) - sys.set_tolerance(method='fixedpoint', subsystems=True) - + # For the cached network: the share of its stream's duty at + # which each process stage's enthalpy limit sits, so that a + # changed feed rescales every stage instead of letting the + # first one take the whole duty. + self._stage_fractions = stage_fractions = {} + for i, life_cycle in enumerate(stream_life_cycles): + hx = hx_heat_utils_rearranged[i].unit + H_in = hx.ins[0].H + span = hx.outs[0].H - H_in + for lc in life_cycle.life_cycle: + unit = lc.unit + if isinstance(unit, bst.HXutility): continue + H_lim = getattr(unit, f'H_lim{lc.index}') + if H_lim is None: continue + stage_fractions[unit.ID, lc.index] = ( + (H_lim - H_in) / span if span else 0. + ) + path, recycles = _network_path(all_units, stream_life_cycles) + self.HXN_sys = sys = bst.System(HXN_F.ID, path, + recycle=recycles or None) + # Every stage starts at its planned state (synthesize_network + # runs each exchanger once), so recycle loops (e.g. a pair + # matched above and below the pinch) are at their fixed point + # after the first pass; tight tolerances close the energy + # balance to ~1e-10 % (molar flows never change: the + # temperature criterion governs; 1e-8 K sits above the flash + # noise). + sys.set_tolerance(method='fixedpoint', subsystems=True, + mol=1e-9, rmol=1e-12, T=1e-8, rT=1e-12, + maxiter=200) + original_purchase_costs = [hx.purchase_cost for hx in hxs] original_installed_costs = [hx.installed_cost for hx in hxs] - # # Handle special case for heat exchanger crossing the pinch - # for hx in new_HXs: - # if all([isinstance(i.sink, bst.HXutility) for i in hx.outs]): - # hx.Tlim1 = None - # hx.Hlim1 = hx.outs[1].sink.H sys._setup() try: sys.converge() except: for i in sys.units: i._run() warn('heat exchanger network was not able to converge', RuntimeWarning) + _pass_through_served_streams( + stream_life_cycles, [hu.unit for hu in hx_heat_utils_rearranged] + ) for i in sys.units: i._summary() for i in range(len(stream_life_cycles)): hx = hx_heat_utils_rearranged[i].unit @@ -327,13 +551,11 @@ def _cost(self): + sum(new_purchase_costs_HXu) - sum(original_purchase_costs) )) - if self.replace_unit_heat_utilities: - self.heat_utilities = [] - for hx_heat_util, new_hx_util in zip(hx_heat_utils_rearranged, new_HX_utils): - hx_heat_util.copy_like(new_hx_util.heat_utilities[0]) - hx_heat_util.unit.owner._load_utility_cost() # Update new utility cost - else: - self.heat_utilities = hus_final + # with replace_unit_heat_utilities, the units carry the new + # utilities (replaced below, once the network is final) + self.heat_utilities = ( + [] if self.replace_unit_heat_utilities else hus_final + ) else: # if no matches were made, retain all original HXutilities (i.e., don't add the -- relatively minor -- differences between new and original HXutilities) self.installed_costs['Heat exchangers'] = 0. self.baseline_purchase_costs['Heat exchangers'] = self.purchase_costs['Heat exchangers'] = 0. @@ -363,7 +585,12 @@ def _cost(self): raise RuntimeError(msg) else: warn(msg, RuntimeWarning, stacklevel=2) - + # Last, so that nothing above (the loads, a re-synthesis) reads + # the original heat utilities after they are overwritten + if new_HXs and self.replace_unit_heat_utilities: + self._replace_unit_heat_utilities(hx_heat_utils_rearranged, + stream_life_cycles) + def _energy_balance_error_contributions(self): original_ignored = ignored = self.ignored if ignored and callable(ignored): ignored = ignored() diff --git a/hensmith/_planner.py b/hensmith/_planner.py new file mode 100644 index 0000000..e120685 --- /dev/null +++ b/hensmith/_planner.py @@ -0,0 +1,2245 @@ +# -*- 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. +""" +Planner for heat exchanger networks at minimum energy requirement (MER) +without stream splits. + +The planner works on numbers only (numpy; no BioSTEAM objects). The caller, +`hensmith.hxn_synthesis.synthesize_network`, describes each process stream +by a piecewise-linear temperature-enthalpy curve. :func:`plan_network` +returns an ordered list of process-to-process matches, the enthalpy at which +each stream enters and leaves each match, and the utility duty left on each +stream. :func:`_plan_numeric` is a front end for constant heat capacity +problems given as ``{name, kind, T_in, T_out, CP}`` dicts. It returns the +network in the certificate format of the scratch oracle used in the tests. + +The model +--------- +**Stream curves.** Stream ``j`` is a list of knots ``(T, H)``. Both +coordinates are non-decreasing, and ``H[0]`` and ``H[-1]`` are the ends. A +hot stream runs from ``H[-1]`` down to ``H[0]``; a cold stream runs from +``H[0]`` up to ``H[-1]``. Two consecutive knots with the same temperature and +different enthalpies make a *flat*: isothermal latent heat, a point load, or +a clipped end jump. Two consecutive knots with the same enthalpy and +different temperatures (a vertical stretch at a clipped non-equilibrium end) +are collapsed to the conservative temperature: the lowest for a hot stream, +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. +Shifted temperatures closer than ``_LEVEL_EQ`` K are merged, exactly as the +problem table merges its grid. + +**Targets.** The planner builds its own problem table [2]_ from these curves. +For every shifted level ``L`` it computes the heat flow arriving at ``L`` +(before the point loads at ``L``) and the heat flow leaving it (after +them). The pinch is the first (hottest) minimum of these flows; when no hot +utility is needed it is the top of the grid. ``cut`` records which side of +the pinch the point loads *at* the pinch temperature belong to. When the +knots come from the problem-table grid, this cascade equals the problem +table. + +**Sides; must and flex streams.** The network is cut at the pinch. On each +side, heat is measured from each stream's pinch end, ``q >= 0``, and every +stream gets a non-decreasing *level curve* ``phi(q)``: + +====== ====== ===== ======================= +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)`` +====== ====== ===== ======================= + +A *must* stream has to be served completely by process matches on its side: +no cold utility above the pinch, no hot utility below it. A *flex* stream +puts whatever is left over into one utility at its far end. That placement +loses nothing, by lemma L1 below. A counter-current match of duty ``x`` +between must ``i`` from frontier ``a`` and flex ``j`` from frontier ``b`` is +feasible if and only if ``phi_i(a + t) >= phi_j(b + t)`` for all ``t`` in +``[0, x]``. The planner checks this at every knot, so it also catches +internal pinches, such as a condensing vapour against a boiling mixture. +Both sides are therefore the same problem, and one search solves both. + +**Lemma L1 (monotonicity).** Suppose a stream's later stages move toward its +inlet: they become hotter on a hot stream or colder on a cold one. Then no +match on that stream loses approach temperature. Every level curve is +non-decreasing, so shifting a must's matches away from the pinch raises its +levels, and shifting a flex's matches toward the pinch lowers its levels. +Four consequences follow: + +(a) Utilities can sit at the far end of each stream without loss of +generality. +(b) Heat a must leaves uncovered (a *gap*) can be moved to its pinch end. +There it crosses the pinch and costs an equal extra amount of hot and of +cold utility, the *penalty*. +(c) Deleting a match keeps every other match feasible. This is what the +``Qmin`` filter and the final safety net rely on. +(d) Feasibility is monotone in the gaps, so they can be bisected. + +**Residual condition (R).** At a search node with frontiers ``(a, b)``, take +any level ``L``. Let ``S(L)`` be the flex heat beyond the frontiers at +levels ``<= L`` (inclusive) or ``< L`` (exclusive), and ``D(L)`` the same +for must heat. Condition (R) is ``s(L) = S(L) - D(L) >= 0`` everywhere. It +is the problem table of the remaining problem [3]_ with splits allowed, so +any completion needs it. Advance the pair ``(i, j)`` by ``x``, and write +``Rf_j`` and ``Rm_i`` for the residuals of flex ``j`` and must ``i``. Then +``s`` becomes ``s - min(x, Rf_j) + min(x, Rm_i)``. Along a feasible match +``Rf_j >= Rm_i``, so this value never increases with ``x``. The largest +duty that keeps (R) therefore has a closed form:: + + x_res(i, j) = min{ s(L) + Rm_i(L) : s(L) - Rf_j(L) + Rm_i(L) < 0 } + +It is evaluated at every level point, inclusive and exclusive, and at the +crossings inside linear pieces. + +**Pinch rules at tight levels.** A level where ``s = 0`` is a pinch of the +remaining problem. Two rules must then hold, each tested as a bipartite +matching that saturates the streams that need a partner: + +- *Outward:* every must that reaches the cut from below and still has heat + above it needs its own flex at the cut whose level rises no faster. + Level slope is ``1/CP``, so this is ``CP_must <= CP_flex``. +- *Inward:* every flex whose residual reaches the cut from below needs its + own must reaching the cut from below. + +A flat piece *at* the cut has unlimited series capacity, so it may serve +several partners. A flat piece that needs a partner accepts only a flat +one. At the root, these rules are the pinch design method's [1]_ proof +that MER needs stream splitting. For constant CP they reduce to the number and +CP rules at every cascade zero. + +**Search.** A depth-first search on an explicit stack builds each side +from the pinch outward. A node is the vector of frontiers. A move advances +one must and one flex by the same duty. The candidate duties are: + +- ``x_top = min(x_dT, x_res, rem_i, cap_j)``, where ``x_dT`` is the + approach limit; +- the *events* listed in :meth:`_Search._duties`. + +Each event is a vertex type of the duty polytope for a fixed match order: +a stream is ticked off, a dT limit is reached, a level becomes R-tight, or +a later match along one link, one block of tied musts, or one return must +be able to start. Chains of partial services ("coupled vertices") are not +enumerated. Repeated (must, flex) pairs are allowed: series alternation +emulates a split. Early passes cap the number of new exchangers per pair. + +Budgets are counted in deterministic *work units*: one per node plus a +share proportional to the residual arrays. No wall clock is used, so the +result does not depend on machine speed. Nodes that fail are memoised. +The memo key holds the quantised frontiers, the pairs at their cap, and +the open continuations. A failure caused by the depth cap is never +memoised. + +**Units.** After the first MER plan is found, a branch and bound on the +number of exchangers (the same search, with a unit bound) looks for a +smaller network. Continuing the current exchanger costs no extra unit. If a +side still has more than ``3 (M + F)`` units, a coarse-to-fine rerun with a +minimum piece size keeps the MER plan with the fewest units. + +**Best effort.** This runs on a side that has a root proof or whose search +ran out of budget: + +1. Twelve greedy dives give an incumbent. Each dive leaks must heat when no + partner fits. +2. Gap bisection follows, using the MER search as a monotone oracle + (consequence (d) of L1). +3. The plan with the lowest penalty is chosen among those within a units + cap. + +**Realisation.** Each stream is walked in flow order from its inlet: + +- *Hot stream:* above-pinch matches far end first, then below-pinch matches + from the pinch, then the cooler. +- *Cold stream:* below-pinch matches far end first, then above-pinch + matches, then the heater. + +Gaps and dropped matches shift the later stages. By L1 that is feasible. +Each exchanger's approach is then checked on the curves at every knot. Any +match that violates it would be a bug; such a match is dropped and +reported. The plan says ``'mer'`` only if the utilities it achieves equal +the targets. + +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. + 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 + therefore only come from the finite set of candidate duties, the pair + caps, or the budgets. In that sense completeness is empirical, not + proven. It was validated on a certified benchmark and on fresh problems. +- Networks whose match order is cyclic cannot be represented. +- Some unsplit MER networks need arbitrarily many exchangers. Series + alternation approaches a split only in the limit. +- A side that needs splits but has no rules proof spends its whole MER + budget before best effort starts. + +References +---------- +.. [1] Linnhoff, B.; Hindmarsh, E. The Pinch Design Method for Heat + Exchanger Networks. Chem. Eng. Sci. 1983, 38 (5), 745-763. +.. [2] Kemp, I. C. Pinch Analysis and Process Integration, 2nd ed.; + Butterworth-Heinemann, 2007. +.. [3] Smith, R. Chemical Process Design and Integration; Wiley, 2005. + +""" +import math +from bisect import bisect_left, bisect_right +from collections import defaultdict + +import numpy as np + +__all__ = () + +# %% Tolerances and constants + +_REL_Q = 1e-11 # heat tolerance, relative to the total stream duty +_REL_T = 1e-11 # level tolerance, relative to the temperature span +_LEVEL_EQ = 1e-9 # K; shifted temperatures closer than this are merged +_KNOT_TOL = 1e-9 # K; collinear interior knots are dropped (< 0: keep) +_MER_TOL = 1e-9 # status 'mer' tolerance, relative to the total duty +_THRESHOLD_TOL = 1e-9 # Qh below this (relative) is a threshold problem +_APPROACH_TOL = 1e-6 # K; safety-net approach tolerance +_SLOPE_EQ = 1e-9 # relative; slopes this close count as equal +_W0 = 2000. # residual-array size worth one work unit +_WORK_XRES = 0.2 # work of x_res and x_dT for one must (+ array share) +_WORK_EVENT = 0.05 # work of one event duty (piecewise curves) +_WORK_EVENTS_VEC = 0.5 # work of all event duties of a pair (constant CP) + +#: MER passes: (mode, pair cap, extra events E5/E6, work units). +_SCHEDULE = ( + ('restricted', 2, False, 3000.), + ('restricted', None, False, 300.), + ('restricted', 3, False, 3000.), + ('full', 3, False, 10000.), + ('restricted', None, True, 5000.), + ('full', None, True, 30000.), +) +_DEPTH_FACTOR = 8 # depth cap = _DEPTH_FACTOR * (M + F) + _DEPTH_EXTRA +_DEPTH_EXTRA = 50 +_UNITS_WORK_MIN = 300. # unit branch and bound: work per step ... +_UNITS_WORK_MAX = 3000. +_UNITS_WORK_TOTAL = 10000. # ... and in total +_GUARD_FACTOR = 3 # units guard when a side has > 3 (M + F) units +_GUARD_ZENOS = (1e-2, 1e-3) +_GUARD_WORK = 3000. +_BE_ORDERS = ('tick', 'bestfit', 'maxduty', 'lowfit') +_BE_DIVE_ZENO = 1e-2 # dives: no non-tick-off piece below this * side duty +_BE_MIN_DUTY = 1e-7 # dives: no piece below this * side duty +_BE_REPEAT_CAP = 2 # gap oracle: pair cap +_BE_MIN_PIECE_FRAC = 0.01 # gap oracle: min piece / smaller stream's duty +_BE_ORACLE_WORK = 1500. +#: gap-oracle passes (mode, pair cap, share of the call's work): passes 1, 3 +#: and 2 of `_SCHEDULE` (the unit bound already limits alternation); a +#: capped pass that runs out of work must not starve the next one +_BE_ORACLE_PASSES = (('restricted', _BE_REPEAT_CAP, 0.25), + ('restricted', _BE_REPEAT_CAP + 1, 0.25), + ('restricted', None, 0.5)) +_BE_SINGLE = True # also bisect single-must leaks (d2's leak search) +_BE_ROUNDS = 14 +_BE_WORK = 30000. +_BE_UNITS_CAP = 1.5 # selection: units cap = ceil(1.5 (M + F)) + +_DONE, _FAIL, _DEPTH = 'done', 'fail', 'depth' + + +# %% Vectorised curve helpers + +def _x_le_many(y, q, L): + """``sup{q : y(q) <= L}`` for every level in `L` (``q[0]`` where + ``L < y[0]``); `y` non-decreasing, `q` strictly increasing.""" + L = np.atleast_1d(np.asarray(L, float)) + n = y.size + i = np.searchsorted(y, L, 'right') - 1 + out = np.where(i < 0, q[0], q[-1]) + mid = (i >= 0) & (i < n - 1) + if mid.any(): + k = i[mid] + out[mid] = q[k] + (q[k + 1] - q[k]) * (L[mid] - y[k]) / ( + y[k + 1] - y[k]) + return out + + +def _x_lt_many(y, q, L): + """``sup{q : y(q) < L}`` for every level in `L` (``q[0]`` where + ``L <= y[0]``).""" + L = np.atleast_1d(np.asarray(L, float)) + n = y.size + j = np.searchsorted(y, L, 'left') + out = np.where(j <= 0, q[0], q[-1]) + mid = (j > 0) & (j < n) + if mid.any(): + k = j[mid] + out[mid] = q[k - 1] + (q[k] - q[k - 1]) * (L[mid] - y[k - 1]) / ( + y[k] - y[k - 1]) + return out + + +def _x_le1(y, q, L): + """Scalar ``sup{q : y(q) <= L}`` on python lists.""" + i = bisect_right(y, L) - 1 + if i < 0: + return q[0] + if i >= len(y) - 1: + return q[-1] + return q[i] + (q[i + 1] - q[i]) * (L - y[i]) / (y[i + 1] - y[i]) + + +def _x_lt1(y, q, L): + """Scalar ``sup{q : y(q) < L}`` on python lists.""" + j = bisect_left(y, L) + if j <= 0: + return q[0] + if j >= len(y): + return q[-1] + return q[j - 1] + (q[j] - q[j - 1]) * (L - y[j - 1]) / (y[j] - y[j - 1]) + + +# %% Level curves + +class _LevelCurve: + """ + Non-decreasing piecewise-linear level curve ``y = phi(q)`` on + ``[0, Q]`` of one stream on one side of the pinch. + + Parameters + ---------- + q : sequence[float] + Heat from the stream's pinch end, starting at 0. + y : sequence[float] + Levels on the shifted scale (see the module docstring). + stream : int, optional + Index of the stream. + H0 : float, optional + Enthalpy at ``q = 0`` (the split enthalpy). + sgn : {1, -1}, optional + ``H = H0 + sgn * q`` (+1 above the pinch, -1 below). + role : {'must', 'flex'}, optional + Which way to collapse vertical stretches (equal `q`, different + `y`): a must keeps the lowest level, a flex the highest (the + conservative choices). + + Notes + ----- + Zero-length pieces are removed; slopes are ``1/CP`` and exactly zero on + flats (equal consecutive levels). Scalar methods use python lists and + bisection, the ``*_many`` methods numpy. + """ + __slots__ = ('q', 'y', 'qa', 'ya', 'n', 'Q', 'linear', 'stream', 'H0', + 'sgn', 'flats') + + def __init__(self, q, y, stream=-1, H0=0., sgn=1, role='must'): + Q2, Y2 = [], [] + for x, v in zip(q, y): + x, v = float(x), float(v) + if Q2 and x <= Q2[-1]: + if role == 'flex': + Y2[-1] = max(Y2[-1], v) + continue + if Y2 and v < Y2[-1]: + v = Y2[-1] + Q2.append(x) + Y2.append(v) + if len(Q2) == 1: + Q2.append(Q2[0]) + Y2.append(Y2[0]) + self.q, self.y = Q2, Y2 + self.qa, self.ya = np.array(Q2), np.array(Y2) + self.n = len(Q2) + self.Q = Q2[-1] + self.linear = self.n == 2 + self.stream, self.H0, self.sgn = stream, H0, sgn + self.flats = tuple(Y2[k] for k in range(self.n - 1) + if Y2[k + 1] == Y2[k] and Q2[k + 1] > Q2[k]) + + def __repr__(self): + return f'<_LevelCurve stream={self.stream} Q={self.Q:.6g} n={self.n}>' + + def at(self, x): + """Level at heat position `x` (clipped to the ends).""" + q, y = self.q, self.y + if x <= q[0]: + return y[0] + if x >= self.Q: + return y[-1] + i = bisect_right(q, x) - 1 + return y[i] + (y[i + 1] - y[i]) * (x - q[i]) / (q[i + 1] - q[i]) + + def at_many(self, xs): + return np.interp(xs, self.qa, self.ya) + + def x_le(self, L): + """``sup{q : phi(q) <= L}``, 0 if ``phi(0) > L``.""" + return _x_le1(self.y, self.q, L) + + def x_lt(self, L): + """``sup{q : phi(q) < L}``, 0 if ``phi(0) >= L``.""" + return _x_lt1(self.y, self.q, L) + + def x_le_many(self, L): + return _x_le_many(self.ya, self.qa, L) + + def x_lt_many(self, L): + return _x_lt_many(self.ya, self.qa, L) + + def slope_right(self, x): + """Slope of the piece starting at or containing `x` (0 on flats).""" + q, y = self.q, self.y + i = min(max(bisect_right(q, x) - 1, 0), self.n - 2) + if q[i + 1] <= q[i]: + return 0. + return (y[i + 1] - y[i]) / (q[i + 1] - q[i]) + + def slope_left(self, x): + """Slope of the piece ending at or containing `x` (0 on flats).""" + q, y = self.q, self.y + i = min(max(bisect_left(q, x), 1), self.n - 1) + if q[i] <= q[i - 1]: + return 0. + return (y[i] - y[i - 1]) / (q[i] - q[i - 1]) + + def window(self, lo, hi): + """Knot positions strictly inside ``(lo, hi)``.""" + q = self.q + return q[bisect_right(q, lo):bisect_left(q, hi)] + + +def _slope1(c): + return (c.y[1] - c.y[0]) / (c.q[1] - c.q[0]) if c.q[1] > c.q[0] else 0. + + +# %% Match geometry (all exact on piecewise-linear curves) + +def _max_duty(cm, a, cf, b, limit, tolP): + """ + x_dT: largest ``x <= limit`` with ``phi_m(a + t) - phi_f(b + t) >= + -tolP`` on ``[0, x]``. The gap is linear between the merged knots of both + curves, so it is checked at each of them; a violation is interpolated to + the zero crossing (conservative). + """ + if limit <= 0.: + return 0. + g0 = cm.at(a) - cf.at(b) + if g0 < -tolP: + return 0. + if cm.linear and cf.linear: + sm, sf = _slope1(cm), _slope1(cf) + # slopes equal to 1e-9 must not be separated by rounding + if sm >= sf * (1. - _SLOPE_EQ): + return limit + return min(limit, max(g0, 0.) / (sf - sm)) + wm = cm.window(a, a + limit) + wf = cf.window(b, b + limit) + if len(wm) + len(wf) <= 8: + ts = sorted({limit, *(x - a for x in wm), *(x - b for x in wf)}) + gp, tp = max(g0, 0.), 0. + for t in ts: + if t <= 0.: + continue + g = cm.at(a + t) - cf.at(b + t) + if g < -tolP: + if gp <= 0.: + return tp + return tp + (t - tp) * gp / (gp - g) + tp, gp = t, max(g, 0.) + return limit + ts = np.unique(np.concatenate(([0., limit], np.asarray(wm) - a, + np.asarray(wf) - b))) + ts = ts[(ts >= 0.) & (ts <= limit)] + g = cm.at_many(a + ts) - cf.at_many(b + ts) + bad = np.flatnonzero(g < -tolP) + if bad.size == 0: + return limit + k = int(bad[0]) + if k == 0: + return 0. + gp = max(float(g[k - 1]), 0.) + if gp <= 0.: + return float(ts[k - 1]) + return float(ts[k - 1] + (ts[k] - ts[k - 1]) * gp / (gp - g[k])) + + +def _return_duty(cm, a, cf, b, R, xt): + """ + E3: the first ``x`` in ``(0, xt)`` with ``phi_m(a + x) = phi_f(b + x + + R)``. Serving must m by x and then other musts with R of the flex's heat + brings the flex exactly back to m's new level, so m can resume on it. + ``h(x) = phi_m(a + x) - phi_f(b + x + R)`` is continuous and piecewise + linear; its first sign change from negative is found by scanning the + merged knots (None if there is none). + """ + if b + R >= cf.Q: + return None + h0 = cm.at(a) - cf.at(b + R) + if h0 >= 0.: + return None + if cm.linear and cf.linear: + sm, sf = _slope1(cm), _slope1(cf) + if sm <= sf * (1. + 1e-12): + return None + return -h0 / (sm - sf) + c0 = b + R + ts = sorted({xt, *(x - a for x in cm.window(a, a + xt)), + *(x - c0 for x in cf.window(c0, c0 + xt))}) + hp, tp = h0, 0. + for t in ts: + if t <= 0.: + continue + h = cm.at(a + t) - cf.at(c0 + t) + if h >= 0.: + return tp + (t - tp) * (-hp) / (h - hp) + tp, hp = t, h + return None + + +def _flex_stop_for(ck, ak, remk, cf, bj, capj): + """ + E1b: the largest flex advance ``x`` after which flex ``j`` can still + serve the whole remainder of another must ``k`` in one match. + + After the flex advanced by x the match is feasible iff ``b_j + x + t <= + X_le_j(phi_k(a_k + t))`` for t in [0, rem_k], i.e. ``x <= psi(t) = + X_le_j(phi_k(a_k + t)) - b_j - t``; also ``x <= cap_j - rem_k``. psi is + piecewise linear with breakpoints at k's knots and where phi_k crosses + j's knot levels; where phi_k rises strictly into a flat level of j the + left limit ``X_lt_j`` applies. Returns ``min(cap_j - rem_k, min psi)`` + (None if ``cap_j < rem_k``). + """ + if capj < remk: + return None + xcap = capj - remk + y0 = ck.at(ak) + y1 = ck.at(ak + remk) + if ck.linear and cf.linear: + sk, sj = _slope1(ck), _slope1(cf) + if sj > 0.: + ds = sj - sk + if ds <= _SLOPE_EQ * sj: + ds = 0. + return min(cf.x_le(y0 - remk * ds) - bj, xcap) + ts = {0., remk} + ts.update(x - ak for x in ck.window(ak, ak + remk)) + exact = {} # points where phi_k crosses a knot level of j (see E2b) + for L in cf.y: + if y0 <= L <= y1: + for x in (ck.x_le(L) - ak, ck.x_lt(L) - ak): + if 0. < x < remk: + ts.add(x) + exact[x] = L + ts = sorted(ts) + phi = ck.at_many(ak + np.array(ts)) + for k, x in enumerate(ts): + if x in exact: + phi[k] = exact[x] + ts = np.array(ts) + m = float((cf.x_le_many(phi) - bj - ts).min()) + for L in cf.flats: + if y0 < L <= y1: + tf = ck.x_lt(L) - ak + if 0. < tf <= remk: + m = min(m, cf.x_lt(L) - bj - tf) + return min(xcap, m) + + +def _must_stop_for(ci, ai, remi, cg, bg, capg, xt): + """ + E2b: the smallest must advance ``x`` in ``(0, xt)`` after which flex + ``g`` can finish must ``i`` in one match. + + With ``s = x + t`` the finishing match is feasible iff ``x >= h(s) = b_g + + s - X_le_g(phi_i(a_i + s))`` for every s in [x, rem_i] (left limits + with ``X_lt_g`` where phi_i rises into a flat of g) and ``rem_i - x <= + cap_g``. h is piecewise linear; scanning its pieces in ascending order + with the suffix maximum SM of h beyond each piece, the smallest x on a + piece [s_k, s_k+1] satisfies ``x >= SM_k+1`` and ``x (1 - sigma) >= h_k - + sigma s_k`` (sigma = the slope of h there). O(knots); None if no such x. + """ + lo_x = max(0., remi - capg) + if lo_x >= xt: + return None + y0 = ci.at(ai) + y1 = ci.at(ai + remi) + if ci.linear and cg.linear: + # the gap of the finishing match is linear: only its two ends bind, + # y0 + si x >= mu (start) and y0 + si remi >= mu + sg (remi - x) + si, sg = _slope1(ci), _slope1(cg) + mu = cg.at(bg) + D = mu - y0 + if D > 0.: + if si <= 0.: + return None + lo_x = max(lo_x, ci.x_lt(mu) - ai) + if sg > si * (1. + _SLOPE_EQ): + lo_x = max(lo_x, remi - (y1 - mu) / sg) + return lo_x + ss = {0., remi} + ss.update(x - ai for x in ci.window(ai, ai + remi)) + exact = {} # breakpoints where phi_i crosses a knot level of g + for L in cg.y: + if y0 <= L <= y1: + for x in (ci.x_le(L) - ai, ci.x_lt(L) - ai): + if 0. < x < remi: + ss.add(x) + exact[x] = L + s = sorted(ss) + K = len(s) + phi = ci.at_many(ai + np.array(s)) + # use the exact level there: an interpolated level one ulp above a flat + # of g would lose the jump of X_le_g at the flat (its left limit) + for k, x in enumerate(s): + if x in exact: + phi[k] = exact[x] + hR = bg + np.array(s) - cg.x_le_many(phi) + hL = hR.copy() + for k in range(1, K): + if ci.slope_left(ai + s[k]) > 0.: + hL[k] = bg + s[k] - cg.x_lt(float(phi[k])) + SM = [0.] * (K + 1) + SM[K] = -math.inf + for k in range(K - 1, -1, -1): + SM[k] = max(SM[k + 1], hL[k], hR[k]) + for k in range(K - 1): + sk, sk1 = s[k], s[k + 1] + lo = max(sk, SM[k + 1], lo_x) + if lo > sk1: + continue + sigma = (hL[k + 1] - hR[k]) / (sk1 - sk) + d = lo - (hR[k] + sigma * (lo - sk)) + if d >= 0.: + return lo + if 1. - sigma > 0.: + x = lo - d / (1. - sigma) + if x <= sk1: + return x + return None + + +# %% Bipartite matching + +def _saturating_matching(left, right, ok, flat_left, flat_right): + """ + True iff every item of `left` gets its own item of `right` with + ``ok(l, r)``. A flat item (isothermal piece at the cut) has unlimited + series capacity: a flat right item can serve any number of left items, + and a flat left item only needs one compatible partner and consumes + none. + """ + owner = {} + + def augment(l, seen): + for r in right: + if r in seen or not ok(l, r): + continue + if flat_right[r]: + return True + seen.add(r) + if r not in owner or augment(owner[r], seen): + owner[r] = l + return True + return False + + for l in left: + if flat_left[l]: + if not any(ok(l, r) for r in right): + return False + continue + if not augment(l, set()): + return False + return True + + +# %% One side of the pinch + +class _Residual: + """Residual arrays at a node (see `_Side.analyse`).""" + __slots__ = ('levels', 'Rmi', 'Rme', 'Rfi', 'Rfe', 'Si', 'Se', 'Di', + 'De', 'slack', 'nL') + + +class _Side: + """ + One side of the pinch in level-curve form. + + Parameters + ---------- + name : {'above', 'below'} + musts, flexes : list[_LevelCurve] + tolQ, tolP : float + Heat and level tolerances. + """ + + def __init__(self, name, musts, flexes, tolQ, tolP): + self.name = name + self.musts, self.flexes = musts, flexes + self.M, self.F = len(musts), len(flexes) + self.Qm = [c.Q for c in musts] + self.Qf = [c.Q for c in flexes] + self.tolQ, self.tolP = tolQ, tolP + self.duty = sum(self.Qm) + sum(self.Qf) + # constant CP (one sloped segment per curve): vectorised events + self.sm = np.array([_slope1(c) for c in musts]) + self.sf = np.array([_slope1(c) for c in flexes]) + self.linear = (all(c.linear for c in musts + flexes) + and bool((self.sm > 0.).all() and (self.sf > 0.).all())) + + # -- pairs in stream indices -------------------------------------------- + def hot_cold(self, i, j): + m, f = self.musts[i].stream, self.flexes[j].stream + return (m, f) if self.name == 'above' else (f, m) + + def local_pairs(self, pairs): + return frozenset((i, j) for i in range(self.M) for j in range(self.F) + if self.hot_cold(i, j) in pairs) + + # -- residual arrays ------------------------------------------------------ + def _pieces(self, curves, front): + tolQ = self.tolQ + own, lo, hi, ln = [], [], [], [] + for k, c in enumerate(curves): + f = front[k] + if c.Q - f <= tolQ: + continue + if c.linear: + own.append(k) + lo.append(c.at(f)) + hi.append(c.y[1]) + ln.append(c.Q - f) + continue + q, y = c.q, c.y + xf, yf = f, c.at(f) + for m in range(bisect_right(q, f), c.n): + own.append(k) + lo.append(yf) + hi.append(y[m]) + ln.append(q[m] - xf) + xf, yf = q[m], y[m] + return (np.array(own, dtype=np.intp), np.array(lo), np.array(hi), + np.array(ln)) + + @staticmethod + def _R(pieces, n, levels): + """Inclusive (level <= L) and exclusive (level < L) residual heat of + every stream at every level: one scatter per quantity and a cumulative + sum along the levels (O(pieces + n * levels)).""" + own, lo, hi, ln = pieces + nL = levels.size + if own.size == 0 or nL == 0: + z = np.zeros((n, nL)) + return z, z.copy() + if own.size == 1 or (own[1:] > own[:-1]).all(): + # at most one piece per stream (constant CP): direct broadcast + w = hi - lo + flat = w <= 0. + with np.errstate(divide='ignore', invalid='ignore'): + f = np.clip((levels - lo[:, None]) / w[:, None], 0., 1.) + if flat.any(): + f[flat] = levels >= lo[flat, None] + fe = f.copy() + fe[flat] = levels > lo[flat, None] + else: + fe = f + Ri = np.zeros((n, nL)) + Ri[own] = f * ln[:, None] + Re = Ri.copy() if fe is f else np.zeros((n, nL)) + if fe is not f: + Re[own] = fe * ln[:, None] + return Ri, Re + W = nL + 1 + size = n * W + il = np.searchsorted(levels, lo) + ih = np.searchsorted(levels, hi) + flat = ih == il + base = own * W + + def scan(index, weights): + out = np.bincount(index, weights, size).reshape(n, W) + return out[:, :nL].cumsum(1) + Ri = scan(base + ih, ln) + Re = scan(base + ih + flat, ln) + nf = ~flat + if nf.any(): + sl = ln[nf] / (hi[nf] - lo[nf]) + idx = np.concatenate((base[nf] + il[nf], base[nf] + ih[nf])) + B = scan(idx, np.concatenate((sl, -sl))) + D = scan(idx, np.concatenate((lo[nf], -lo[nf]))) + part = B * (levels - D) + Ri += part + Re += part + return Ri, Re + + def analyse(self, a, b): + """Residual data at frontiers ``(a, b)``: the level set (every piece + end beyond the frontiers), per-stream residual heat, the sums S and D + and the minimum slack of (R).""" + pm = self._pieces(self.musts, a) + pf = self._pieces(self.flexes, b) + d = _Residual() + d.levels = L = np.unique(np.concatenate((pm[1], pm[2], pf[1], pf[2]))) + d.nL = L.size + d.Rmi, d.Rme = self._R(pm, self.M, L) + d.Rfi, d.Rfe = self._R(pf, self.F, L) + d.Si, d.Se = d.Rfi.sum(0), d.Rfe.sum(0) + d.Di, d.De = d.Rmi.sum(0), d.Rme.sum(0) + d.slack = (min(float((d.Si - d.Di).min()), float((d.Se - d.De).min())) + if d.nL else 0.) + return d + + def xres_for(self, i, d): + """x_res(i, j) for every flex j (closed form, see the module + docstring); +inf where unconstrained.""" + tol = self.tolQ + F = self.F + if d.nL == 0 or F == 0: + return np.full(F, np.inf) + gi = (d.Si - d.Rfi) - (d.Di - d.Rmi[i]) + ge = (d.Se - d.Rfe) - (d.De - d.Rme[i]) + hi = d.Si - d.Di + d.Rmi[i] + he = d.Se - d.De + d.Rme[i] + inf = np.inf + res = np.minimum(np.where(gi < -tol, hi, inf).min(1), + np.where(ge < -tol, he, inf).min(1)) + if d.nL > 1: + gL, gR = gi[:, :-1], ge[:, 1:] + cross = ((gL < -tol) & (gR >= -tol)) | ((gR < -tol) & (gL >= -tol)) + if cross.any(): + with np.errstate(divide='ignore', invalid='ignore'): + th = np.clip(gL / (gL - gR), 0., 1.) + hc = hi[:-1] + th * (he[1:] - hi[:-1]) + res = np.minimum(res, np.where(cross, hc, inf).min(1)) + return np.maximum(res, 0.) + + # -- pinch rules at tight levels ----------------------------------------- + def tight_cuts(self, d): + """Cuts at the zeros of the residual cascade: '+' (just above L; + flats at L lie below it) where the inclusive slack is zero, '-' (just + below L; flats at L lie above it) where only the exclusive one is.""" + if d.nL == 0: + return [] + tol = self.tolQ + bi = d.Si - d.Di + be = d.Se - d.De + L = d.levels + cuts = [(float(L[k]), '+') for k in np.flatnonzero(bi <= tol)] + cuts += [(float(L[k]), '-') for k in + np.flatnonzero((be <= tol) & (np.abs(be - bi) > tol))] + cuts.sort() + return cuts + + def rules_violation(self, a, b, d): + """ + First violation of the flat-aware pinch rules at a tight level, or + None. See the module docstring (outward and inward rules); a flat + piece at the cut has unlimited series capacity but accepts only flat + partners when it needs one. + """ + tolQ, tolP = self.tolQ, self.tolP + musts, flexes = self.musts, self.flexes + open_m = [i for i in range(self.M) if self.Qm[i] - a[i] > tolQ] + open_f = [j for j in range(self.F) if self.Qf[j] - b[j] > tolQ] + for L, cut in self.tight_cuts(d): + above = cut == '-' + # ---- outward + out_m, sm, fm = [], {}, {} + for i in open_m: + c = musts[i] + if c.at(a[i]) > L + tolP: + continue + q = max(c.x_lt(L) if above else c.x_le(L), a[i]) + if q < c.Q - tolQ: + out_m.append(i) + sm[i] = s = c.slope_right(q) + fm[i] = s == 0. + if out_m: + out_f, sf, ff = [], {}, {} + for j in open_f: + c = flexes[j] + if c.at(b[j]) > L + tolP: + continue + q = max(c.x_lt(L) if above else c.x_le(L), b[j]) + if q < c.Q - tolQ: + out_f.append(j) + sf[j] = s = c.slope_right(q) + ff[j] = s == 0. + if not _saturating_matching( + out_m, out_f, + lambda i, j: sm[i] >= sf[j] * (1. - 1e-12), fm, ff): + return dict(side=self.name, level=L, cut=cut, + rule='outward', + musts=[musts[i].stream for i in out_m], + flexes=[flexes[j].stream for j in out_f]) + # ---- inward + in_f, sf, ff = [], {}, {} + for j in open_f: + c = flexes[j] + yb = c.at(b[j]) + if c.y[-1] < L - tolP: + continue + if above: + if yb >= L - tolP: + continue + q = c.x_lt(L) + else: + if yb > L + tolP: + continue + q = c.x_le(L) + if q > b[j] + tolQ: + in_f.append(j) + sf[j] = s = c.slope_left(q) + ff[j] = s == 0. + if in_f: + in_m, sm, fm = [], {}, {} + for i in open_m: + c = musts[i] + ya = c.at(a[i]) + if c.y[-1] < L - tolP: + continue + if above: + if ya >= L - tolP: + continue + q = c.x_lt(L) + else: + if ya > L + tolP: + continue + q = c.x_le(L) + if q > a[i] + tolQ: + in_m.append(i) + sm[i] = s = c.slope_left(q) + fm[i] = s == 0. + if not _saturating_matching( + in_f, in_m, + lambda j, i: sm[i] <= sf[j] * (1. + 1e-12), ff, fm): + return dict(side=self.name, level=L, cut=cut, + rule='inward', + musts=[musts[i].stream for i in in_m], + flexes=[flexes[j].stream for j in in_f]) + return None + + +# %% MER search + +class _Budget(Exception): + pass + + +class _Frame: + """One node on the explicit DFS stack: its frontiers, depth, memo key, + move generator, unit count, the undo token of the child being explored + and whether a descendant hit the depth cap.""" + + def __init__(self, a, b, depth, key, gen, units): + self.a, self.b, self.depth, self.key = a, b, depth, key + self.gen, self.units = gen, units + self.undo = None + self.hit_depth = False + + +class _Search: + """ + Depth-first frontier search for an unsplit MER network on one side. + + Parameters + ---------- + side : _Side + mode : {'restricted', 'full'} + 'restricted' branches only on the musts at the lowest frontier level + (pinch outward), 'full' on every must (lowest level first). + cap : int or None + Maximum number of NEW exchangers per (must, flex) pair; continuing + the current exchanger of both streams is free. + extra : bool + Also offer the preemption (E5) and lead-sharing (E6) duties. + budget : float + Work units. + unit_bound : float + Branch and bound on the number of exchangers. + min_piece : callable(i, j) -> float, optional + Non-tick-off duties below this are not offered. + forbid : frozenset + (i, j) pairs that may not be used. + a0 : list[float], optional + Initial must frontiers (gaps at the pinch end; best effort). + """ + + def __init__(self, side, mode='restricted', cap=None, extra=False, + budget=1000., unit_bound=math.inf, min_piece=None, + forbid=frozenset(), a0=None): + self.side = side + self.mode = mode + self.cap = cap + self.extra = extra + self.budget = budget + self.unit_bound = unit_bound + self.min_piece = min_piece + self.forbid = forbid + self.a0 = a0 + self.work = 0. + self.exhausted = False + self.failed = {} + self.max_depth = _DEPTH_FACTOR * (side.M + side.F) + _DEPTH_EXTRA + + def run(self): + """Return the pieces ``(i, j, a0, b0, x)`` of an MER plan or None.""" + s = self.side + a = list(self.a0) if self.a0 is not None else [0.] * s.M + b = [0.] * s.F + self.last_m = [None] * s.M + self.last_f = [None] * s.F + self.pairs = defaultdict(int) + self.units = 0 + self.serial = 0 + try: + return self._dfs(a, b) + except _Budget: + self.exhausted = True + return None + + # -- bookkeeping ---------------------------------------------------- + def _charge(self, w): + self.work += w + if self.work > self.budget: + raise _Budget + + def _cont(self, i, j): + e = self.last_m[i] + return e is not None and e[1] == j and self.last_f[j] is e + + def _allowed(self, i, j): + if (i, j) in self.forbid: + return False + if self.cap is None or self._cont(i, j): + return True + return self.pairs[i, j] < self.cap + + def _push(self, i, j): + if self._cont(i, j): + return (i, j, None) + saved = (self.last_m[i], self.last_f[j]) + self.serial += 1 + e = (i, j, self.serial) + self.last_m[i] = self.last_f[j] = e + self.units += 1 + self.pairs[i, j] += 1 + return (i, j, saved) + + def _pop(self, tok): + i, j, saved = tok + if saved is None: + return + self.last_m[i], self.last_f[j] = saved + self.units -= 1 + self.pairs[i, j] -= 1 + + def _lb_units(self, a): + """Open musts that cannot simply continue their last exchanger each + need at least one more unit.""" + s = self.side + lb = 0 + for i in range(s.M): + if s.Qm[i] - a[i] > s.tolQ: + e = self.last_m[i] + if e is None or self.last_f[e[1]] is not e: + lb += 1 + return lb + + def _key(self, a, b): + s = self.side + r = 10. * s.tolQ + capped = (frozenset(p for p, c in self.pairs.items() if c >= self.cap) + if self.cap is not None else frozenset()) + cont = frozenset( + (i, e[1]) for i, e in enumerate(self.last_m) + if e is not None and self.last_f[e[1]] is e + and s.Qm[i] - a[i] > s.tolQ and s.Qf[e[1]] - b[e[1]] > s.tolQ) + return (tuple(round(v / r) for v in a), tuple(round(v / r) for v in b), + capped, cont) + + # -- depth-first search on an explicit stack -------------------------- + def _dfs(self, a0, b0): + s = self.side + r = self._expand(a0, b0, 0) + if r is _DONE: + return [] + if not isinstance(r, _Frame): + return None + stack = [r] + path = [] + while stack: + fr = stack[-1] + if fr.undo is not None: + self._pop(fr.undo) + path.pop() + fr.undo = None + mv = next(fr.gen, None) + if mv is None: + stack.pop() + if fr.hit_depth: + if stack: + stack[-1].hit_depth = True + else: + prev = self.failed.get(fr.key) + self.failed[fr.key] = (fr.units if prev is None + else min(prev, fr.units)) + continue + i, j, x = mv + a, b = fr.a, fr.b + a2 = list(a) + b2 = list(b) + a2[i] = min(a[i] + x, s.Qm[i]) + b2[j] = min(b[j] + x, s.Qf[j]) + fr.undo = self._push(i, j) + path.append((i, j, a[i], b[j], x)) + r = self._expand(a2, b2, fr.depth + 1) + if r is _DONE: + return list(path) + if r is _DEPTH: + fr.hit_depth = True + elif isinstance(r, _Frame): + stack.append(r) + return None + + def _expand(self, a, b, depth): + s = self.side + self.work += 1. + if self.work > self.budget: + raise _Budget + if self.units + self._lb_units(a) > self.unit_bound: + return _FAIL + open_m = [i for i in range(s.M) if s.Qm[i] - a[i] > s.tolQ] + if not open_m: + return _DONE + if depth > self.max_depth: + return _DEPTH + key = self._key(a, b) + f = self.failed.get(key) + if f is not None and f <= self.units: + return _FAIL + d = s.analyse(a, b) + open_f = [j for j in range(s.F) if s.Qf[j] - b[j] > s.tolQ] + self.work += len(open_m) * len(open_f) * d.nL / _W0 + if d.slack < -10. * s.tolQ or s.rules_violation(a, b, d) is not None: + self.failed[key] = -1 + return _FAIL + gen = self._candidates(a, b, d, open_m, open_f) + if gen is None: + self.failed[key] = -1 + return _FAIL + return _Frame(a, b, depth, key, gen, self.units) + + # -- moves ---------------------------------------------------------- + def _candidates(self, a, b, d, open_m, open_f): + """Dead-must test, then a lazy generator of moves (i, j, x).""" + s = self.side + tolQ, tolP = s.tolQ, s.tolP + lam = {i: s.musts[i].at(a[i]) for i in open_m} + mu = {j: s.flexes[j].at(b[j]) for j in open_f} + rem_m = {i: s.Qm[i] - a[i] for i in open_m} + rem_f = {j: s.Qf[j] - b[j] for j in open_f} + # a must that no flex can ever start on is dead (flex frontiers only + # rise), unless a flex sits flat at exactly its level + for i in open_m: + cm = s.musts[i] + for j in open_f: + if mu[j] > lam[i] + tolP or not self._allowed(i, j): + continue + cf = s.flexes[j] + lim = min(rem_m[i], rem_f[j], 2. * tolQ) + if _max_duty(cm, a[i], cf, b[j], lim, tolP) > tolQ: + break + if abs(mu[j] - lam[i]) <= tolP and cf.slope_right(b[j]) == 0.: + break + else: + return None + return self._moves(a, b, d, open_m, open_f, lam, mu, rem_m, rem_f) + + def _moves(self, a, b, d, open_m, open_f, lam, mu, rem_m, rem_f): + s = self.side + tolQ, tolP = s.tolQ, s.tolP + order = sorted(open_m, key=lambda i: (lam[i], i)) + if self.mode == 'restricted': + p0 = lam[order[0]] + order = [i for i in order if lam[i] <= p0 + tolP] + for i in order: + cm = s.musts[i] + xres = s.xres_for(i, d) + self._charge(_WORK_XRES * (1. + 0.1 * len(open_f)) + + len(open_f) * d.nL / _W0) + opts = [] + for j in open_f: + if mu[j] > lam[i] + tolP or not self._allowed(i, j): + continue + xd = _max_duty(cm, a[i], s.flexes[j], b[j], + min(rem_m[i], rem_f[j]), tolP) + if xd <= tolQ: + continue + xr = float(xres[j]) + xt = min(xd, xr) + if xt <= tolQ: + continue + cont = self._cont(i, j) + tick_m = xt >= rem_m[i] - tolQ + tick_f = xt >= rem_f[j] - tolQ + dt_lim = not tick_m and not tick_f and xd <= xr + tolQ + opts.append(((not cont, not tick_m, not tick_f, dt_lim, -xt, + -mu[j], j), j, xt)) + opts.sort() + for _, j, xt in opts: + for x in self._duties(i, j, xt, a, b, lam, mu, rem_m, rem_f, + open_m, open_f): + yield i, j, x + + def _duties(self, i, j, xt, a, b, lam, mu, rem_m, rem_f, open_m, open_f): + """ + Candidate duties of the match (must i, flex j): ``x_top`` first, then + the events in descending order (deduplicated within tolQ; kept only + strictly inside ``(tolQ, x_top - tolQ)``): + + - E1 flex saving: the flex stops where it can still start another + must k (``X_le_j(lambda_k)``); + - E1b: the flex stops where it can still finish k in one match; + - E1c: musts tied at one level can be served one after the other; + - E2 vertical switch: the must stops at another flex's level; + - E2b: the must stops where another flex can finish it in one match; + - E3 return: the must stops where the flex, after serving the next + musts completely, returns exactly to its level; + - E5 preemption and E6 lead sharing (passes with ``extra`` only). + """ + s = self.side + tolQ, tolP = s.tolQ, s.tolP + cm, cf = s.musts[i], s.flexes[j] + ai, bj = a[i], b[j] + others = [k for k in open_m if k != i] + flexes = [g for g in open_f if g != j] + ev = [] + if s.linear: + # constant CP: E1, E1b, E2 and E2b for all partners at once + # (the same closed forms as `_flex_stop_for` / `_must_stop_for`) + self._charge(_WORK_EVENTS_VEC) + if others: + ko = np.array(others) + lam_o = np.array([lam[k] for k in others]) + rem_o = np.array([rem_m[k] for k in others]) + ev.extend((cf.x_le_many(lam_o) - bj).tolist()) # E1 + sj = s.sf[j] + ds = sj - s.sm[ko] + ds[ds <= _SLOPE_EQ * sj] = 0. + fits = rem_o <= rem_f[j] + if fits.any(): # E1b + x = np.minimum(cf.x_le_many(lam_o - rem_o * ds) - bj, + rem_f[j] - rem_o) + ev.extend(x[fits].tolist()) + if flexes: + go = np.array(flexes) + mu_o = np.array([mu[g] for g in flexes]) + xl = cm.x_lt_many(mu_o) - ai + ev.extend(xl.tolist()) # E2 + si, remi = s.sm[i], rem_m[i] # E2b + capg = np.array([rem_f[g] for g in flexes]) + lo = np.maximum(0., remi - capg) + up = mu_o > lam[i] + lo = np.where(up, np.maximum(lo, xl), lo) + sg = s.sf[go] + steep = sg > si * (1. + _SLOPE_EQ) + with np.errstate(divide='ignore', invalid='ignore'): + end = remi - (cm.y[-1] - mu_o) / sg + lo = np.where(steep, np.maximum(lo, end), lo) + ev.extend(lo[~(up & (si <= 0.))].tolist()) + else: + self._charge(_WORK_EVENT * (len(others) + len(flexes))) + for k in others: + ev.append(cf.x_le(lam[k]) - bj) # E1 + x = _flex_stop_for(s.musts[k], a[k], rem_m[k], cf, bj, + rem_f[j]) # E1b + if x is not None: + ev.append(x) + for g in flexes: + ev.append(cm.x_lt(mu[g]) - ai) # E2 + x = _must_stop_for(cm, ai, rem_m[i], s.flexes[g], b[g], + rem_f[g], xt) # E2b + if x is not None: + ev.append(x) + groups = {} # E1c + for k in others: + groups.setdefault(round(lam[k] / tolP), []).append(k) + for grp in groups.values(): + if len(grp) < 2: + continue + L = lam[grp[0]] + tot = sum(rem_m[k] for k in grp) + xL = cf.x_le(L) - bj + for last in grp: + ev.append(xL - (tot - rem_m[last])) + R = 0. # E3 + for _, k in sorted((lam[k], k) for k in others): + R += rem_m[k] + if R >= rem_f[j]: + break + x = _return_duty(cm, ai, cf, bj, R, xt) + if x is not None: + ev.append(x) + if self.extra: + for k in others: # E5 + if lam[k] > lam[i]: + ev.append(cm.x_lt(lam[k]) - ai) + w = 1 + sum(1 for k in others if lam[k] <= lam[i] + tolP) + for L in sorted(lam[k] for k in others)[:2]: # E6 + ev.append((cf.x_le(L) - bj) / max(2, w)) + out = [xt] + for x in sorted(ev, reverse=True): + if tolQ < x < out[-1] - tolQ: + out.append(x) + if self.min_piece is not None: + thr = self.min_piece(i, j) + out = [x for x in out if x >= thr or x >= rem_m[i] - tolQ + or x >= rem_f[j] - tolQ] + return out + + +def _count_units(pieces): + """Exchangers after merging continuations (same pair, consecutive on + both streams).""" + last_m, last_f, n = {}, {}, 0 + for i, j, a0, b0, x in pieces: + e = last_m.get(i) + if e is not None and e[1] == j and last_f.get(j) is e: + continue + e = (i, j, n) + last_m[i] = last_f[j] = e + n += 1 + return n + + +def _merge(pieces): + """Merge continuations into exchangers ``[i, j, a0, b0, Q]`` (plan + order of their first piece).""" + out, last_m, last_f = [], {}, {} + for i, j, a0, b0, x in pieces: + e = last_m.get(i) + if e is not None and e[1] == j and last_f.get(j) is e: + e[4] += x + continue + e = [i, j, a0, b0, x] + out.append(e) + last_m[i] = last_f[j] = e + return out + + +# %% Side plans + +class _SidePlan: + """Plan of one side: pieces (i, j, a0, b0, x), per-must gaps, + status ('trivial' | 'mer' | 'best_effort') and diagnostics.""" + __slots__ = ('pieces', 'gaps', 'status', 'proof', 'method', 'work', + 'units') + + def __init__(self, pieces, gaps, status, method, work=0., proof=None): + self.pieces = pieces + self.gaps = gaps + self.status = status + self.method = method + self.work = work + self.proof = proof + self.units = _count_units(pieces) + + @property + def penalty(self): + return float(sum(self.gaps)) + + +def _combine_cap(cap, cap1): + if cap1: + return 1 + return cap + + +def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1.): + """MER search on one side, then units; best effort when there is a root + proof or the search runs out of budget.""" + M, F = side.M, side.F + if M == 0: + return _SidePlan([], [], 'trivial', 'trivial') + zeros_m, zeros_f = [0.] * M, [0.] * F + if F == 0: # cannot happen at MER (a tolerance issue): everything leaks + return _SidePlan([], list(side.Qm), 'best_effort', 'no-flex') + d = side.analyse(zeros_m, zeros_f) + proof = side.rules_violation(zeros_m, zeros_f, d) + if proof is None and d.slack < -10. * side.tolQ: + proof = dict(side=side.name, rule='cascade', slack=d.slack) + if proof is not None: + return _best_effort(side, proof, cap1, forbid, work_scale, 0.) + work = 0. + pieces = method = None + seen = set() + for mode, cap, extra, budget in _SCHEDULE: + cap = _combine_cap(cap, cap1) + if (mode, cap, extra) in seen: + continue + seen.add((mode, cap, extra)) + srch = _Search(side, mode, cap, extra, budget * work_scale, + forbid=forbid) + pieces = srch.run() + work += srch.work + if pieces is not None: + method = f'{mode}-cap{cap}' + ('-extra' if extra else '') + break + if pieces is None: + return _best_effort(side, None, cap1, forbid, work_scale, work) + first = work + pieces, w = _improve_units(side, pieces, first, cap1, forbid, work_scale) + work += w + pieces, w = _units_guard(side, pieces, cap1, forbid, work_scale) + work += w + return _SidePlan(pieces, [0.] * M, 'mer', method, work) + + +def _improve_units(side, pieces, first, cap1, forbid, work_scale): + """Branch and bound on the number of exchangers: the same search with a + unit bound one below the incumbent, restricted then full mode.""" + U = _count_units(pieces) + step = min(_UNITS_WORK_MAX, max(_UNITS_WORK_MIN, 3. * first)) * work_scale + total = _UNITS_WORK_TOTAL * work_scale + used = 0. + cap = 1 if cap1 else None + while U > side.M and used < total: + found = None + for mode in ('restricted', 'full'): + srch = _Search(side, mode, cap, False, min(step, total - used), + unit_bound=U - 1, forbid=forbid) + res = srch.run() + used += srch.work + if res is not None: + found = res + break + if used >= total: + break + if found is None: + break + pieces, U = found, _count_units(found) + return pieces, used + + +def _units_guard(side, pieces, cap1, forbid, work_scale): + """If a side's MER plan has more than ``3 (M + F)`` units, rerun the + last two passes coarse to fine with a minimum piece size and keep the + MER plan with the fewest units (MER always wins over the unit count).""" + U = _count_units(pieces) + if U <= _GUARD_FACTOR * (side.M + side.F): + return pieces, 0. + used = 0. + best, bestU = pieces, U + passes = [p for p in _SCHEDULE if p[2]] + for zeno in _GUARD_ZENOS: + thr = zeno * side.duty + for mode, cap, extra, _ in passes: + srch = _Search(side, mode, _combine_cap(cap, cap1), extra, + _GUARD_WORK * work_scale, + min_piece=lambda i, j, thr=thr: thr, forbid=forbid) + res = srch.run() + used += srch.work + if res is not None: + u = _count_units(res) + if u < bestU: + best, bestU = res, u + break + return best, used + + +# %% Best effort + +def _best_effort(side, proof, cap1, forbid, work_scale, work): + """ + Best effort for a side without a (found) unsplit MER plan: greedy dives + as incumbents, then per-must gap bisection with the MER search as a + monotone oracle, then selection within a units cap. + """ + budget = _BE_WORK * work_scale + be = _Diver(side, cap1, forbid) + cands = [([], list(side.Qm), 'trivial')] + for use_rules, leak_rest in ((True, False), (False, False), (True, True)): + for order in _BE_ORDERS: + pieces, gaps = be.dive(order, use_rules, leak_rest) + cands.append((pieces, gaps, f'dive-{order}' + + ('' if use_rules else '-norules') + + ('-rest' if leak_rest else ''))) + used = be.work + cap = int(math.ceil(_BE_UNITS_CAP * (side.M + side.F))) + inc = min(cands, key=lambda c: (_count_units(c[0]) > cap, sum(c[1]), + _count_units(c[0]))) + res, used = _gap_search(side, inc, cap1, forbid, work_scale, used, budget, + cap) + if res is not None: + cands.append(res) + scored = [(sum(g), _count_units(p), k, p, g, m) + for k, (p, g, m) in enumerate(cands)] + within = [c for c in scored if c[1] <= cap] + if within: + best = min(within, key=lambda c: (c[0], c[1], c[2])) + else: + best = min(scored, key=lambda c: (c[1], c[0], c[2])) + _, _, _, pieces, gaps, method = best + return _SidePlan(pieces, list(gaps), 'best_effort', method, + work + used, proof) + + +def _gap_search(side, inc, cap1, forbid, work_scale, used, budget, max_units): + """ + Shrink the per-must gaps of the incumbent one must at a time (largest + first): try 0, then bisect. The oracle is the MER search from frontiers + ``a = g`` with a pair cap, a minimum piece and the unit bound + `max_units` (a budget failure only makes it conservative). Feasibility + within the unit bound is monotone in each gap: widening a gap only + trims the must's pieces at its pinch end, which by L1 keeps every other + piece feasible and never adds a unit. Returns ((pieces, gaps, method) or + None if no gap shrank, work used). + """ + tolQ = side.tolQ + g = [min(max(0., x), q) for x, q in zip(inc[1], side.Qm)] + if sum(g) <= tolQ: + return None, used + best = None + frac = _BE_MIN_PIECE_FRAC + Qm, Qf = side.Qm, side.Qf + + def min_piece(i, j): + return frac * min(Qm[i], Qf[j]) + + def oracle(trial): + nonlocal used + total = _BE_ORACLE_WORK * work_scale + for mode, cap, share in _BE_ORACLE_PASSES: + left = min(share * total, budget - used) + if left <= 0.: + return None + srch = _Search(side, mode, _combine_cap(cap, cap1), False, left, + unit_bound=max_units, min_piece=min_piece, + forbid=forbid, a0=trial) + res = srch.run() + used += srch.work + if res is not None: + return res + return None + + tol = max(tolQ, 1e-9 * side.duty) + + def shrink(g, label): + """Per-must bisection from the feasible gaps `g` (largest first).""" + found = None + for i in sorted(range(side.M), key=lambda i: (-g[i], i)): + if g[i] <= tol: + continue + lo, hi = 0., g[i] + for it in range(_BE_ROUNDS + 1): + if used >= budget or hi - lo <= tol: + break + mid = 0. if it == 0 else 0.5 * (lo + hi) + trial = list(g) + trial[i] = mid + res = oracle(trial) + if res is not None: + hi = g[i] = mid + found = (res, list(g), label) + if mid == 0.: + break + else: + lo = mid + if used >= budget: + break + return found + + # 1. from the incumbent's gaps + best = shrink(g, 'gap-search') + best_pen = sum(best[1]) if best is not None else sum(g) + # 2. from single-must leaks (d2): the whole deficit on one must, tested + # first at the current best penalty (skipped if it cannot beat it) + if _BE_SINGLE: + for m in sorted(range(side.M), key=lambda i: (-Qm[i], i)): + if used >= budget or best_pen <= tol: + break + g1 = [0.] * side.M + g1[m] = min(Qm[m], best_pen) + res = oracle(g1) + if res is None: + continue + found = shrink(g1, 'gap-search-single') + cand = (found if found is not None + else (res, g1, 'gap-search-single')) + if sum(cand[1]) < best_pen - tol: + best, best_pen = cand, sum(cand[1]) + return best, used + + +class _Diver: + """Greedy dives for best effort (never fail): the lowest must takes the + first admissible child in the given order; when none fits it leaks heat + (a gap), which the realisation moves to its pinch end.""" + + def __init__(self, side, cap1, forbid): + self.side = side + self.cap = 1 if cap1 else None + self.forbid = forbid + self.work = 0. + self.min_duty = _BE_MIN_DUTY * side.duty + self.zeno = _BE_DIVE_ZENO * side.duty + + def _ok(self, a, b): + s = self.side + d = s.analyse(a, b) + self.work += 1. + s.M * s.F * d.nL / _W0 + return d.slack >= -10. * s.tolQ and s.rules_violation(a, b, d) is None + + def dive(self, order, use_rules, leak_rest): + s = self.side + tolQ, tolP = s.tolQ, s.tolP + M, F = s.M, s.F + a, b = [0.] * M, [0.] * F + pieces, gaps = [], [0.] * M + last_m, last_f, pairs = [None] * M, [None] * F, defaultdict(int) + serial = 0 + for _ in range(20 * (M + F) + 50): + live = [i for i in range(M) if s.Qm[i] - a[i] > tolQ] + if not live: + break + lam = {i: s.musts[i].at(a[i]) for i in live} + + def cp(c, x): + sl = c.slope_right(x) + return math.inf if sl == 0. else 1. / sl + m = min(live, key=lambda i: (lam[i], -cp(s.musts[i], a[i]), i)) + cm = s.musts[m] + rem_m = s.Qm[m] - a[m] + open_f = [j for j in range(F) if s.Qf[j] - b[j] > tolQ] + mu = {j: s.flexes[j].at(b[j]) for j in open_f} + d = s.analyse(a, b) + self.work += 1. + M * F * d.nL / _W0 + xres = s.xres_for(m, d) + + def allowed(j): + if (m, j) in self.forbid: + return False + e = last_m[m] + if self.cap is None or (e is not None and e[1] == j + and last_f[j] is e): + return True + return pairs[m, j] < self.cap + kids = [] + for j in open_f: + if mu[j] > lam[m] + tolP or not allowed(j): + continue + rem_f = s.Qf[j] - b[j] + xd = _max_duty(cm, a[m], s.flexes[j], b[j], min(rem_m, rem_f), + tolP) + if xd <= self.min_duty: + continue + x = min(xd, float(xres[j])) + if x <= self.min_duty: + continue + tick = x >= rem_m - tolQ or x >= rem_f - tolQ + if x < self.zeno and not tick: + continue + gap = lam[m] - mu[j] + kids.append((self._key(order, tick, x, gap, j), j, x)) + kids.sort() + choice = None + if kids: + if use_rules: + for _, j, x in kids: + a2, b2 = list(a), list(b) + a2[m] += x + b2[j] += x + if self._ok(a2, b2): + choice = (j, x) + break + if choice is None: + choice = (kids[0][1], kids[0][2]) + else: # relax (R): local progress only + best = None + for j in open_f: + if mu[j] > lam[m] + tolP or not allowed(j): + continue + xd = _max_duty(cm, a[m], s.flexes[j], b[j], + min(rem_m, s.Qf[j] - b[j]), tolP) + if xd > self.min_duty and (best is None or xd > best[1]): + best = (j, xd) + choice = best + if choice is not None: + j, x = choice + pieces.append((m, j, a[m], b[j], x)) + e = last_m[m] + if not (e is not None and e[1] == j and last_f[j] is e): + serial += 1 + e = (m, j, serial) + last_m[m] = last_f[j] = e + pairs[m, j] += 1 + a[m] = min(a[m] + x, s.Qm[m]) + b[j] = min(b[j] + x, s.Qf[j]) + continue + # leak: skip the least must heat that lets some flex serve m + best = None + km = cp(cm, a[m]) + for j in open_f: + cf = s.flexes[j] + rem_f = s.Qf[j] - b[j] + if mu[j] > lam[m] + tolP: + dd = cm.x_le(mu[j]) - a[m] + rate = 1. + else: # equal level and a steeper flex: leak a fraction + kf = cp(cf, b[j]) + if km == math.inf and kf == math.inf: + frac = 0. + else: + frac = max(0., 1. - kf / km) if km > 0. else 1. + y = min(rem_f, rem_m * (1. - frac)) + dd = y * frac / max(1. - frac, 1e-12) + rate = 1. - frac + dd = min(max(dd, self.min_duty), rem_m) + if best is None or (dd, -rate) < best[0]: + best = ((dd, -rate), dd) + dd = rem_m if (best is None or leak_rest) else best[1] + gaps[m] += dd + a[m] = min(a[m] + dd, s.Qm[m]) + for i in range(M): + left = s.Qm[i] - a[i] + if left > 0.: + gaps[i] += left + return pieces, gaps + + @staticmethod + def _key(order, tick, x, gap, j): + if order == 'tick': + return (not tick, -x, gap, j) + if order == 'bestfit': + return (gap, -x, j) + if order == 'maxduty': + return (-x, gap, j) + return (-gap, -x, j) # 'lowfit' + + +# %% Stream curves, targets and sides + +class _StreamCurve: + """Cleaned knots of one stream: enthalpy `H` (strictly increasing) and + shifted temperature `T` (non-decreasing).""" + __slots__ = ('j', 'hot', 'H', 'T', 'H_lo', 'H_hi') + + def __init__(self, j, hot, H, T): + self.j, self.hot = j, hot + self.H, self.T = H, T + self.H_lo, self.H_hi = float(H[0]), float(H[-1]) + + @property + def duty(self): + return self.H_hi - self.H_lo + + +def _simplify(T, H, hot): + """ + Collapse vertical stretches and duplicates (hot: lowest T*, cold: + highest), then drop interior knots that lie within `_KNOT_TOL` of the + chord from the last kept knot to the next knot, for every knot dropped + since. The chord slopes that pass within the tolerance of a knot form an + interval, so the test keeps the running intersection of those intervals + (a "slope cone"): O(knots). + """ + Tk, Hk = [], [] + for t, h in zip(T.tolist(), H.tolist()): + if Hk and h == Hk[-1]: + if not hot: + Tk[-1] = t + continue + Tk.append(t) + Hk.append(h) + n = len(Hk) + if _KNOT_TOL >= 0. and n > 2: + tol = _KNOT_TOL + keep = [0] + T0, H0 = Tk[0], Hk[0] + lo, hi = -math.inf, math.inf + for k in range(1, n - 1): + dH = Hk[k] - H0 + lo = max(lo, (Tk[k] - tol - T0) / dH) + hi = min(hi, (Tk[k] + tol - T0) / dH) + s = (Tk[k + 1] - T0) / (Hk[k + 1] - H0) + if not lo <= s <= hi: + keep.append(k) + T0, H0 = Tk[k], Hk[k] + lo, hi = -math.inf, math.inf + keep.append(n - 1) + Tk = [Tk[k] for k in keep] + Hk = [Hk[k] for k in keep] + 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.""" + raw = [] + for kn, hot in zip(knots, is_hot): + T, H = kn + T = np.asarray(T, float).ravel() + H = np.asarray(H, float).ravel() + if T.size != H.size or T.size == 0: + raise ValueError('every stream needs matching, non-empty T and H ' + 'knot arrays') + if not (np.isfinite(T).all() and np.isfinite(H).all()): + raise ValueError('knots must be finite') + # absorb round-off only: a real decrease would otherwise be + # flattened into a different stream (e.g. a point load) + if T.size > 1 and ( + float(np.diff(T).min()) < -_APPROACH_TOL + or float(np.diff(H).min()) + < -1e-9 * max(float(np.abs(H).max()), 1.)): + raise ValueError('knot temperatures and enthalpies must be ' + '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)) + if not raw: + return [] + u = np.unique(np.concatenate([r[0] for r in raw])) + cid = np.concatenate(([0], np.cumsum(np.diff(u) > _LEVEL_EQ))) + last = np.concatenate((np.flatnonzero(np.diff(cid)), [u.size - 1])) + rep = u[last] # the highest member represents each cluster + curves = [] + for j, ((Ts, H), hot) in enumerate(zip(raw, is_hot)): + Ts = rep[cid[np.searchsorted(u, Ts)]] + if H.size < 2 or not H[-1] > H[0]: + curves.append(None) + else: + curves.append(_StreamCurve(j, bool(hot), H, Ts)) + return curves + + +def _cascade(curves, scale): + """ + The planner's problem table on the stream curves: the heat flow arriving + at (``F_ex``) and leaving (``F_in``) every shifted level, the targets, + the pinch (first minimum; top of the grid if Qh == 0) and the side of + the pinch the point loads at the pinch belong to (``cut``). + """ + act = [c for c in curves if c is not None] + if not act: + return dict(Qh=0., Qc=0., pinch_T=math.nan, cut='below', + levels=np.zeros(0), F_ex=np.zeros(0), F_in=np.zeros(0)) + levels = np.unique(np.concatenate([c.T for c in act]))[::-1] + F_ex = np.zeros(levels.size) + F_in = np.zeros(levels.size) + for c in act: + sg = 1. if c.hot else -1. + F_ex += sg * (c.H_hi - _x_le_many(c.T, c.H, levels)) + F_in += sg * (c.H_hi - _x_lt_many(c.T, c.H, levels)) + flow = np.minimum(F_ex, F_in) + m = float(flow.min()) + if -m <= _THRESHOLD_TOL * scale: + Qh, k = 0., 0 + else: + Qh = -m + k = int(np.flatnonzero(flow <= m + 1e-12 * scale)[0]) + Qc = float(F_in[-1]) + Qh + if Qc < 0.: + Qh -= Qc + Qc = 0. + cut = 'below' if F_ex[k] <= F_in[k] else 'above' + return dict(Qh=Qh, Qc=Qc, pinch_T=float(levels[k]), cut=cut, + levels=levels, F_ex=F_ex, F_in=F_in) + + +def _sides(curves, table, tolQ, tolP): + """Cut every stream at the pinch into its above and below level curves + (a part not longer than tolQ is omitted) and assign must/flex roles.""" + P, cut = table['pinch_T'], table['cut'] + parts = dict(above=([], []), below=([], [])) + for c in curves: + if c is None: + continue + T, H = c.T, c.H + if cut == 'below': + Hs = float(_x_le_many(T, H, P)[0]) + else: + Hs = float(_x_lt_many(T, H, P)[0]) + Hs = min(max(Hs, c.H_lo), c.H_hi) + Ts = P if T[0] <= P <= T[-1] else float(np.interp(Hs, H, T)) + inner = (H > Hs) & (H < c.H_hi) + if c.H_hi - Hs > tolQ: + q = np.concatenate(([0.], H[inner] - Hs, [c.H_hi - Hs])) + y = np.concatenate(([Ts], T[inner], [T[-1]])) + role = 'must' if c.hot else 'flex' + lc = _LevelCurve(q, y, c.j, Hs, 1, role) + parts['above'][0 if c.hot else 1].append(lc) + inner = (H > c.H_lo) & (H < Hs) + if Hs - c.H_lo > tolQ: + q = np.concatenate(([0.], Hs - H[inner][::-1], [Hs - c.H_lo])) + y = -np.concatenate(([Ts], T[inner][::-1], [T[0]])) + role = 'flex' if c.hot else 'must' + lc = _LevelCurve(q, y, c.j, Hs, -1, role) + parts['below'][1 if c.hot else 0].append(lc) + return {name: _Side(name, m, f, tolQ, tolP) + for name, (m, f) in parts.items()} + + +def _plan_sides(sides, avoid_recycle, work_scale): + if not avoid_recycle: + return {name: _plan_side(side, work_scale=work_scale) + for name, side in sides.items()} + + def run(order): + used, plans = set(), {} + for name in order: + side = sides[name] + sp = _plan_side(side, True, side.local_pairs(used), work_scale) + plans[name] = sp + used.update(side.hot_cold(i, j) for i, j, *_ in sp.pieces) + return plans + + def score(plans): + mer = all(p.status in ('mer', 'trivial') for p in plans.values()) + return (not mer, sum(p.penalty for p in plans.values()), + sum(p.units for p in plans.values())) + first = run(('above', 'below')) + if not score(first)[0]: + return first + second = run(('below', 'above')) + return second if score(second) < score(first) else first + + +# %% Plan records + +class Exchanger: + """One process-to-process exchanger of a plan (enthalpies from the + flow-order walk; `hot` and `cold` are stream indices; `hot_seq` and + `cold_seq` are 1-based positions in each stream's flow order; + `pair_index` numbers repeated pairs on one side in the order the hot + stream meets them).""" + __slots__ = ('side', 'hot', 'cold', 'Q', 'pair_index', 'hot_seq', + 'cold_seq', 'H_hot_in', 'H_hot_out', 'H_cold_in', + 'H_cold_out', '_kh', '_kc') + + def __repr__(self): + return (f'') + + +class Plan: + """ + Result of :func:`plan_network`. + + Attributes + ---------- + status : {'mer', 'best_effort'} + 'mer' iff the planned utilities equal the targets (within + ``_MER_TOL`` of the total duty). It does not depend on how each side + was planned: a side that fell back to best effort but whose plan + reaches the targets gives 'mer' (its own status in ``info['sides']`` + stays 'best_effort'). + Q_hot_target, Q_cold_target : float + Targets of the planner's cascade. + pinch_T : float + Pinch on the shifted scale (hot streams shifted down by + ``T_min_app``). + cut : {'above', 'below'} + Side of the pinch the point loads at `pinch_T` belong to. + exchangers : list[Exchanger] + Process exchangers, above-pinch ones first, each side in plan order + (pinch outward). + stages : dict[int, list[int]] + For every stream, its exchangers (indices into `exchangers`) in flow + order. + utility : list[float] + Duty left for each stream's utility at its outlet end (cooling for + hot streams, heating for cold ones; >= 0). + Q_hot, Q_cold : float + Planned hot and cold utility. + penalty : float + ``Q_hot - Q_hot_target`` (>= 0). + info : dict + ``sides`` (per side: status, method, work, proof, gaps, units), + ``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). + """ + __slots__ = ('status', 'Q_hot_target', 'Q_cold_target', 'pinch_T', 'cut', + 'exchangers', 'stages', 'utility', 'Q_hot', 'Q_cold', + 'penalty', 'info') + + def __repr__(self): + return (f'') + + +def _approach_violation(ch, cc, e): + """Smallest ``T*_hot - T*_cold`` inside exchanger `e` at its ends and + at every knot of either curve inside it (shifted scale: >= 0 is + feasible).""" + Q = e.Q + hin, cout = e.H_hot_in, e.H_cold_out + ts = [0., Q] + Hh = ch.H[(ch.H > e.H_hot_out) & (ch.H < hin)] + Hc = cc.H[(cc.H > e.H_cold_in) & (cc.H < cout)] + t = np.concatenate((ts, hin - Hh, cout - Hc)) + return float((np.interp(hin - t, ch.H, ch.T) + - np.interp(cout - t, cc.H, cc.T)).min()) + + +def _walk(curves, recs, N): + """Flow-order walk: each stream from its inlet through its exchangers; + returns (stages, utility).""" + stages = {j: [] for j in range(N)} + for n, e in enumerate(recs): + stages[e.hot].append((e._kh, n)) + stages[e.cold].append((e._kc, n)) + utility = [0.] * N + for j in range(N): + order = [n for _, n in sorted(stages[j])] + stages[j] = order + c = curves[j] + if c is None: + continue + if c.hot: + H = c.H_hi + for pos, n in enumerate(order, 1): + e = recs[n] + e.H_hot_in = H + H -= e.Q + e.H_hot_out = H + e.hot_seq = pos + utility[j] = H - c.H_lo + else: + H = c.H_lo + for pos, n in enumerate(order, 1): + e = recs[n] + e.H_cold_in = H + H += e.Q + e.H_cold_out = H + e.cold_seq = pos + utility[j] = c.H_hi - H + return stages, utility + + +def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., + work_scale=1.): + """ + Plan an unsplit heat exchanger network at minimum energy requirement. + + Parameters + ---------- + knots : list[tuple[array_like, array_like]] + ``(T, H)`` knots of every stream's temperature-enthalpy curve, both + non-decreasing (round-off is absorbed; a real decrease raises a + ValueError), with ``H[0]`` and ``H[-1]`` the stream's end + enthalpies (a hot stream flows from ``H[-1]`` to ``H[0]``, a cold + stream from ``H[0]`` to ``H[-1]``). Consecutive knots with equal + ``T`` are isothermal (flat) pieces. + is_hot : sequence[bool] + True for streams that are cooled. + T_min_app : float + Minimum approach temperature. + avoid_recycle : bool, optional + Never match the same (hot, cold) pair twice anywhere. + Qmin : float, optional + Exchangers with a smaller duty are dropped; their duty goes to the + utilities (this can cost MER). + work_scale : float, optional + Multiplies every work budget. + + Returns + ------- + Plan + See :class:`Plan`. + + Notes + ----- + See the module docstring for the model, the lemma L1 that justifies gap + placement and match removal, the residual condition (R), the pinch + rules at tight levels, the events and the guarantees. The result is + deterministic: budgets are counted in work units, never in seconds. + + Examples + -------- + The four-stream problem of Linnhoff and Hindmarsh (1983) with constant + heat capacity flow rates (kW/K) and ``T_min_app = 10``: + + >>> from hensmith._planner import plan_network + >>> def linear(T_lo, T_hi, CP): + ... return [T_lo, T_hi], [0., CP * (T_hi - T_lo)] + >>> knots = [linear(20., 135., 2.), linear(60., 170., 3.), + ... linear(80., 140., 4.), linear(30., 150., 1.5)] + >>> plan = plan_network(knots, [False, True, False, True], 10.) + >>> plan.status, plan.Q_hot, plan.Q_cold + ('mer', 20.0, 60.0) + >>> len(plan.exchangers) + 4 + + """ + is_hot = [bool(h) for h in is_hot] + N = len(knots) + if len(is_hot) != N: + raise ValueError('knots and is_hot must have the same length') + 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) + 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.) + if act: + tspan = (max(float(c.T[-1]) for c in act) + - min(float(c.T[0]) for c in act)) + else: + tspan = 1. + tolP = _REL_T * max(tspan, 1.) + table = _cascade(curves, scale) + sides = _sides(curves, table, tolQ, tolP) if act else {} + plans = _plan_sides(sides, avoid_recycle, work_scale) if act else {} + # exchangers in plan order (above first), with flow-order keys + recs, qmin_dropped = [], [] + for name in ('above', 'below'): + if name not in plans: + continue + side = sides[name] + for i, j, a0, b0, Q in _merge(plans[name].pieces): + hot, cold = side.hot_cold(i, j) + if Q < Qmin: + qmin_dropped.append((name, hot, cold, Q)) + continue + e = Exchanger() + e.side, e.hot, e.cold, e.Q = name, hot, cold, Q + if name == 'above': # hot is the must (a0), cold the flex (b0) + e._kh, e._kc = (0, -a0), (1, b0) + else: # cold is the must (a0), hot the flex (b0) + e._kh, e._kc = (1, b0), (0, -a0) + recs.append(e) + # walk; a match violating the approach (a bug) is dropped (L1) + dropped = [] + min_dT = math.inf + while True: + stages, utility = _walk(curves, recs, N) + worst = None + min_dT = math.inf + for n, e in enumerate(recs): + v = _approach_violation(curves[e.hot], curves[e.cold], e) + min_dT = min(min_dT, v) + if v < -_APPROACH_TOL and (worst is None or v < worst[0]): + worst = (v, n) + if worst is None: + break + e = recs.pop(worst[1]) + dropped.append((e.side, e.hot, e.cold, e.Q, worst[0])) + # round-off of the summed duties (pieces are exact to tolQ) is not a + # negative utility + utility = [0. if -10. * tolQ < u < 0. else u for u in utility] + count = defaultdict(int) + for j in range(N): + c = curves[j] + if c is None or not c.hot: + continue + for n in stages[j]: + e = recs[n] + count[e.side, e.hot, e.cold] += 1 + e.pair_index = count[e.side, e.hot, e.cold] + Q_hot = float(sum(u for u, c in zip(utility, curves) + if c is not None and not c.hot)) + Q_cold = float(sum(u for u, c in zip(utility, curves) + if c is not None and c.hot)) + # the status comes from the utilities achieved (after the safety net and + # Qmin), never from the side flags: a best-effort side (a greedy dive or + # the gap search after the MER search ran out of budget) can still reach + # the targets, and that network is MER + penalty = Q_hot - table['Qh'] + tol_mer = _MER_TOL * max(scale, 1.) + mer = penalty <= tol_mer and Q_cold - table['Qc'] <= tol_mer + plan = Plan() + plan.status = 'mer' if mer else 'best_effort' + plan.Q_hot_target, plan.Q_cold_target = table['Qh'], table['Qc'] + plan.pinch_T, plan.cut = table['pinch_T'], table['cut'] + plan.exchangers = recs + plan.stages = stages + plan.utility = utility + plan.Q_hot, plan.Q_cold = Q_hot, Q_cold + plan.penalty = max(0., penalty) + side_info = {} + for name, p in plans.items(): + side = sides[name] + side_info[name] = dict( + status=p.status, method=p.method, work=p.work, proof=p.proof, + units=p.units, M=side.M, F=side.F, + gaps={side.musts[i].stream: g for i, g in enumerate(p.gaps) + if g > 0.}) + plan.info = dict(sides=side_info, qmin_dropped=qmin_dropped, + dropped=dropped, + min_approach=(dT + min_dT) if recs else None, + work=sum(p.work for p in plans.values()), + scale=scale, tolQ=tolQ, cascade=table) + return plan + + +# %% Numeric front end (constant CP) + +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`. + + 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, + T_cold_out, hot_seq, cold_seq, pair_index), ``hot_utility`` ({cold name: + Q}), ``cold_utility`` ({hot name: Q}), ``dTmin``, ``status``, + ``penalty``, ``targets`` (Qh, Qc), ``n_units`` and ``plan``. + + Examples + -------- + >>> from hensmith._planner import _plan_numeric + >>> streams = [dict(name='H1', kind='hot', T_in=220., T_out=50., CP=4.), + ... dict(name='C1', kind='cold', T_in=140., T_out=170., CP=1.), + ... dict(name='C2', kind='cold', T_in=100., T_out=120., CP=1.), + ... dict(name='C3', kind='cold', T_in=120., T_out=250., CP=2.)] + >>> net = _plan_numeric(streams, 10.) + >>> net['status'], net['targets'] + ('mer', {'Qh': 80.0, 'Qc': 450.0}) + >>> [(m['hot'], m['cold'], round(m['Q'], 6), m['pair_index']) + ... for m in net['matches']] + [('H1', 'C3', 160.0, 1), ('H1', 'C1', 30.0, 1), ('H1', 'C3', 20.0, 2), + ('H1', 'C2', 20.0, 1)] + + """ + kn, hot, lo, cp, names = [], [], [], [], [] + for s in streams: + T_in, T_out, CP = float(s['T_in']), float(s['T_out']), float(s['CP']) + a, b = min(T_in, T_out), max(T_in, T_out) + T = np.linspace(a, b, int(knots) + 1) + T[0], T[-1] = a, b + kn.append((T, CP * (T - a))) + hot.append(s['kind'] == 'hot') + lo.append(a) + cp.append(CP) + names.append(str(s['name'])) + plan = plan_network(kn, hot, float(dTmin), **kwargs) + matches = [] + for e in plan.exchangers: + h, c = e.hot, e.cold + matches.append(dict( + side=e.side, hot=names[h], cold=names[c], Q=float(e.Q), + T_hot_in=lo[h] + e.H_hot_in / cp[h], + T_hot_out=lo[h] + e.H_hot_out / cp[h], + T_cold_in=lo[c] + e.H_cold_in / cp[c], + T_cold_out=lo[c] + e.H_cold_out / cp[c], + hot_seq=e.hot_seq, cold_seq=e.cold_seq, + pair_index=e.pair_index)) + hu = {names[j]: float(u) for j, u in enumerate(plan.utility) + if not hot[j] and u > 0.} + cu = {names[j]: float(u) for j, u in enumerate(plan.utility) + if hot[j] and u > 0.} + return dict(status=plan.status, matches=matches, hot_utility=hu, + cold_utility=cu, dTmin=float(dTmin), penalty=plan.penalty, + targets=dict(Qh=plan.Q_hot_target, Qc=plan.Q_cold_target), + n_units=len(matches) + len(hu) + len(cu), plan=plan) diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 4f3af43..74d04d0 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -1,24 +1,49 @@ # -*- coding: utf-8 -*- -# HXN: The automated Heat Exchanger Network design package. -# Copyright (C) 2020-, Sarang Bhagwat -# -# This module is under the UIUC open-source license. See +# 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. """ -Created on Sat May 2 16:44:24 2020 - -@author: sarangbhagwat +Pinch analysis and heat exchanger network synthesis: the problem table +(`problem_table`), the synthesis of an unsplit network at minimum energy +requirement (`synthesize_network`, on the planner of `hensmith._planner`), +stream life cycles (`StreamLifeCycle`) and pinch diagrams +(`plot_pinch_diagram`). """ from collections import namedtuple import heapq +import re import numpy as np import biosteam as bst 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 __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', 'synthesize_network', 'plot_pinch_diagram') +#: IDs of the synthesized exchangers: process exchangers above the pinch +#: ``HX___hs`` and below it ``HX___cs`` (the first +#: number is the stream at port 0), with ``_`` for the n-th exchanger of a +#: repeated pair; utility exchangers ``Util__hs|cs``. +_PROCESS_ID = re.compile(r'^HX_(\d+)_(\d+)_(hs|cs)(?:_\d+)?$') +_UTILITY_ID = re.compile(r'^Util_(\d+)_(hs|cs)$') + +def _stream_ports(unit): + """Stream index at each inlet port of a synthesized exchanger, parsed + from its ID (``(a, b)`` for ``HX___...``, ``(a,)`` for + ``Util__...``), or None if the ID is not one of the synthesizer's.""" + ID = unit.ID + match = _PROCESS_ID.match(ID) + if match: return int(match.group(1)), int(match.group(2)) + match = _UTILITY_ID.match(ID) + if match: return (int(match.group(1)),) + return None + class LifeStage: """ One stage of a stream's passage through the synthesized network: the @@ -87,8 +112,12 @@ class StreamLifeCycle: network's stream copies and exchanger IDs embed that index (``s___`` for the inlet streams of the exchangers, ``HX___cs`` / ``HX___hs`` for process exchangers, + with a suffix ``_`` for the n-th exchanger of a repeated pair, ``Util__cs`` / ``Util__hs`` for utility exchangers), which - is how the life cycle is recovered from the exchangers. + is how the life cycle is recovered from the exchangers: the IDs are + parsed (the first number is the stream at port 0, the second the stream + at port 1), so the stream indices are matched exactly and never as + substrings of other indices or of rewired stream IDs. Parameters ---------- @@ -124,9 +153,18 @@ def __init__(self, index, cold): self.life_cycle = None def get_relevant_units(self, index, new_HXs, new_HX_utils): - """Return the process and utility exchangers (two lists) whose ID contains ``__``.""" - new_HXs_relevant = [hx for hx in new_HXs if '_%s_'%index in hx.ID] - new_HX_utils_relevant = [hx for hx in new_HX_utils if '_%s_'%index in hx.ID] + """ + Return the process and utility exchangers (two lists) that carry + stream `index`: those whose parsed ID (``HX___[_]`` + or ``Util__``) names it; for any other ID, those whose ID + contains ``__``. + """ + def relevant(hx): + ports = _stream_ports(hx) + if ports is None: return '_%s_'%index in hx.ID + return index in ports + new_HXs_relevant = [hx for hx in new_HXs if relevant(hx)] + new_HX_utils_relevant = [hx for hx in new_HX_utils if relevant(hx)] return new_HXs_relevant, new_HX_utils_relevant def get_life_cycle(self, new_HXs, new_HX_utils): @@ -143,12 +181,16 @@ def get_life_cycle(self, new_HXs, new_HX_utils): Returns ------- list[LifeStage] - One stage per exchanger whose ID contains ``__`` and whose - inlet at the matching position (0 or 1 for a process exchanger, - 0 for a utility exchanger) carries this stream, i.e. has an ID - containing ``'s__'``; sorted by inlet enthalpy, ascending - for a cold stream and descending for a hot one, i.e. in flow - direction. Also stored as `life_cycle`. + One stage per port that carries this stream: for an exchanger + ID of the synthesizer (see the class notes) the port its ID + assigns to `index` (0 or 1 for a process exchanger, 0 for a + utility exchanger); for any other ID, every port among 0 and 1 + (0 for a utility) whose inlet ID contains ``'s__'``. + Sorted in flow direction: by inlet enthalpy, ascending for a + cold stream and descending for a hot one; ties (zero-duty + stages only) put the stream's first side of the pinch first + (cold-side stages for a cold stream, hot-side stages for a hot + one) and the utility last. Also stored as `life_cycle`. """ index = self.index @@ -156,12 +198,24 @@ def get_life_cycle(self, new_HXs, new_HX_utils): cold = self.cold new_HXs_relevant, new_HX_utils_relevant =\ self.get_relevant_units(index, new_HXs, new_HX_utils) - life_cycle = ( - [LifeStage(unit, 0) for unit in new_HXs_relevant if name + '_' in unit.ins[0].ID] - + [LifeStage(unit, 1) for unit in new_HXs_relevant if name + '_' in unit.ins[1].ID] - + [LifeStage(unit, 0) for unit in new_HX_utils_relevant if name + '_' in unit.ins[0].ID] - ) - life_cycle.sort(key = lambda pt: pt.H_in, reverse = not cold) + life_cycle = [] + for units, N_ports in ((new_HXs_relevant, 2), (new_HX_utils_relevant, 1)): + for unit in units: + ports = _stream_ports(unit) + if ports is None: + life_cycle.extend([LifeStage(unit, k) for k in range(N_ports) + if name + '_' in unit.ins[k].ID]) + else: + life_cycle.extend([LifeStage(unit, k) for k, i in enumerate(ports) + if i == index]) + sign = 1. if cold else -1. + first_side = '_cs' if cold else '_hs' + def flow_order(stage): + ID = stage.unit.ID + if isinstance(stage.unit, bst.HXutility): rank = 2 + else: rank = 0 if first_side in ID else 1 + return (sign * stage.H_in, rank) + life_cycle.sort(key=flow_order) self.life_cycle = life_cycle return life_cycle @@ -204,19 +258,27 @@ def show(self): Attributes ---------- Ts : numpy.ndarray - Shifted grid temperatures [K], descending: the shifted end temperatures - of every monotone stream and the shifted outlet temperature of every - point-load stream (see `point_H`). + Shifted grid temperatures [K], descending: every shifted breakpoint of + every stream's temperature-enthalpy curve, i.e. its end temperatures, + the phase boundaries inside its range (a pure component's saturation + temperature, a mixture's bubble and dew points), the samples of its + two-phase glides and of its curved single-phase stretches + (temperature-dependent heat capacity), and the outlet temperature of + every point-load stream (see `point_H`). Temperatures closer than + 1e-9 K are one grid point. interval_H : numpy.ndarray (N streams x n-1 intervals) heat contributed by each stream to each - interval (Ts[k], Ts[k+1]) [kJ/hr]: positive for hot streams (heat + open interval (Ts[k], Ts[k+1]) [kJ/hr]: positive for hot streams (heat released), negative for cold streams (heat required); zero outside the stream's own temperature range. point_H : numpy.ndarray (N x n) heat contributed *at* each grid temperature [kJ/hr], with the - same sign convention, by the streams treated as point loads at their - shifted outlet temperature: isothermal streams and streams whose outlet - temperature moves against their duty (non-monotone streams). + same sign convention: the jump of the stream's curve there, i.e. a pure + component's latent heat at its (shifted) saturation temperature, the + enthalpy by which a non-equilibrium end state departs from equilibrium + at its own end temperature, and the whole duty of a point-load stream + at its shifted outlet temperature (isothermal streams and streams whose + outlet temperature moves against their duty). residual : numpy.ndarray (n,) heat cascaded *leaving* each grid temperature, after its point loads, when no hot utility is supplied [kJ/hr]; negative where that @@ -236,53 +298,8 @@ def show(self): """ -def _stream_H_at_boundaries(stream_in, H_in, H_out, T_lo, T_hi, Ts, shift, - stream_label): - """ - Enthalpies [kJ/hr] of one monotone stream at the grid boundaries - `Ts` (shifted scale, descending, all within [T_lo, T_hi]). - - Exact at the stream's own end points (H_in/H_out as given) by - *position*: `Ts[0]` and `Ts[-1]` are the stream's own T_hi/T_lo (every - monotone stream has `T_hi > T_lo` strictly, so `Ts` always has at least - these two entries) and are assigned H_in/H_out directly, without a float - comparison. In between, the inlet copy is flashed at the *real* - temperature `T + shift` and the result is clipped to - [min(H_in, H_out), max(H_in, H_out)] so that a non-equilibrium outlet - (e.g. a column reboiler/condenser product) can never inflate an - interval. A single copy is walked down the grid so each VLE is - warm-started from the previous boundary; `stream_label` (the inlet - stream's own ID) identifies the stream in the VLE-failure warning. - """ - assert Ts.size >= 2, ( - "boundary grid for a monotone stream must include both its own " - "end points" - ) - H_lo, H_hi = sorted((H_in, H_out)) - H_top, H_bottom = (H_in, H_out) if H_in > H_out else (H_out, H_in) - Hs = np.empty(Ts.size) - Hs[0] = H_top - Hs[-1] = H_bottom - stream = stream_in.copy() - for k in range(1, Ts.size - 1): - T = Ts[k] - T_real = T + shift - try: - stream.vle(T=T_real, P=stream.P) - H = stream.H - except Exception as error: - warn(f"could not solve VLE for stream {stream_label!r} at " - f"{T_real:.2f} K ({error!r}); interpolating enthalpy " - "linearly in temperature for the problem table", - RuntimeWarning) - # restart the warm start from a clean copy so the failed flash - # does not leave `stream` in a bad state for the next boundary - stream = stream_in.copy() - H = H_lo + (H_hi - H_lo) * (T - T_lo) / (T_hi - T_lo) - Hs[k] = min(max(H, H_lo), H_hi) - return Hs - -def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): +def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, + curves=None): """ Energy-consistent problem table (temperature-interval heat cascade). @@ -296,6 +313,10 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): True where the stream is cooled. T_min_app : float Minimum approach temperature [K]. + 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. Returns ------- @@ -308,29 +329,54 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): Notes ----- - Hot streams are shifted down by `T_min_app`; cold streams are not. For - monotone streams the contribution to interval (Ts[k], Ts[k+1]) is - sign * (H(Ts[k]) - H(Ts[k+1])) with H evaluated at the real temperature - and clipped to [H_in, H_out], so every stream's contributions telescope - exactly to ``sign * |H_out - H_in|``. Isothermal streams, and streams - whose outlet temperature moves against their duty (a heated stream that - exits colder than it entered, e.g. a reboiler outlet at VLE), are point - loads at their outlet temperature. The cascade starting from zero hot - utility is residual[k] = sum(point_H[:, :k+1]) + sum(interval_H[:, :k]), - the heat *leaving* boundary Ts[k]. Feasibility must also hold for the - heat *arriving* at Ts[k] before its point loads are applied, - arriving[k] = residual[k] - sum(point_H[:, k]), because a source at - Ts[k] cannot serve a sink above Ts[k]. The minimum over both flows, - min(residual, arriving), fixes the hot utility target, - `residual[-1] + hot_util_load` the cold one, and its location the - pinch. With the per-stream identity above, hot_util_load - - cold_util_load equals the net heating demand. + Each stream is described by a piecewise-linear temperature-enthalpy + curve built once from a handful of flashes: breakpoints at its end + temperatures and at every phase boundary inside its range (a pure + component's saturation temperature, a mixture's bubble and dew points); + a flat (isothermal) segment for a pure component's latent heat, between + its saturated-liquid and saturated-vapor enthalpies; samples of each + mixture glide (binaries traced along their bubble-point curve); and + interior breakpoints of each curved single-phase stretch + (temperature-dependent Cp), dense enough that linear interpolation is + 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 + 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 + one to within ``0.002 K * sum(CP)``. + + At a grid temperature a stream contributes the jump of its curve there + as a point load (`point_H`), and between two grid temperatures the heat + of its curve in that open interval (`interval_H`). Inside its own + temperature range a stream is taken at equilibrium with its enthalpy + clipped to its real range, so a non-equilibrium end state (e.g. a + superheated liquid from a non-rigorous HXutility) can never inflate the + duty; what it departs from equilibrium at its own end temperature is a + point load there. Every stream's contributions therefore telescope + exactly to ``sign * |H_out - H_in|``. Streams whose outlet temperature + does not move with their duty (isothermal, or a heated stream that + exits colder than it entered, e.g. a reboiler outlet at VLE) are point + loads at their outlet temperature. + + The cascade starting from zero hot utility is residual[k] = + sum(point_H[:, :k+1]) + sum(interval_H[:, :k]), the heat *leaving* + boundary Ts[k]. Feasibility must also hold for the heat *arriving* at + Ts[k] before its point loads are applied, arriving[k] = residual[k] - + sum(point_H[:, k]), because a source at Ts[k] cannot serve a sink above + Ts[k]. The minimum over both flows, min(residual, arriving), fixes the + hot utility target, `residual[-1] + hot_util_load` the cold one, and its + first location the pinch. With the per-stream identity above, + hot_util_load - cold_util_load equals the net heating demand. Examples -------- A threshold problem: 1000 kmol/hr of water cooled 400 -> 300 K supplies every interval of 900 kmol/hr of water heated 300 -> 390 K, so no hot - utility is needed and the surplus leaves as cold utility. + utility is needed and the surplus leaves as cold utility. (The grid + between the ends holds the breakpoints that follow the curvature of + liquid water's enthalpy.) >>> import biosteam as bst >>> from hensmith.hxn_synthesis import problem_table @@ -341,8 +387,8 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): >>> cold_out = cold_in.copy(); cold_out.vle(T=390., P=5e5) >>> table = problem_table([hot_in, cold_in], [hot_out, cold_out], ... [True, False], 5.) - >>> table.Ts - array([395., 390., 300., 295.]) + >>> table.Ts[[0, -1]] # shifted grid ends (hot streams 5 K down) + array([395., 295.]) >>> round(table.hot_util_load, 3) 0.0 >>> round(table.cold_util_load, -1) @@ -350,31 +396,68 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): >>> table.pinch_T 395.0 """ + return _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, + curves)[0] + +def _problem_table(streams_inlet, streams_quenched, is_hot, T_min_app, + curves=None): + """ + Return the `ProblemTable` of `problem_table` together with the stream + curves it was built from and its grid enthalpies, as ``(table, curves, + grid)``. + + `grid` is a dict with + + * '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]``; + * '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 + temperature, and are H_hi above and H_lo below the stream's range; + * 'k_hi', 'k_lo': (N,) grid indices of each stream's own T_hi and T_lo + breakpoints (``k_hi <= k_lo``; equal for a point-load stream), so its + range is selected by position, not by comparing shifted floats. + + The table is exactly ``point_H = sign * (Hr - Hl)`` and ``interval_H = + sign * (Hl[:, :-1] - Hr[:, 1:])`` (sign +1 hot, -1 cold): piecewise- + linear curves through the knots (Ts[k] + shift[j], Hl[j, k]) and + (Ts[k] + shift[j], Hr[j, k]) reproduce the table's cascade exactly. + """ 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.) - T_in = np.array([s.T for s in streams_inlet]) - T_out = np.array([s.T for s in streams_quenched]) - H_in = np.array([s.H for s in streams_inlet]) - H_out = np.array([s.H for s in streams_quenched]) - monotone = (sign * (T_in - T_out)) > 0. - T_hi = np.where(monotone, np.maximum(T_in, T_out), T_out) - shift - T_lo = np.where(monotone, np.minimum(T_in, T_out), T_out) - shift - Ts = np.unique(np.concatenate([T_hi, T_lo]))[::-1] - n = Ts.size - interval_H = np.zeros((N, n - 1)) - point_H = np.zeros((N, n)) - for j in range(N): - if monotone[j]: - idx = np.flatnonzero((Ts <= T_hi[j]) & (Ts >= T_lo[j])) - Hs = _stream_H_at_boundaries(streams_inlet[j], H_in[j], H_out[j], - T_lo[j], T_hi[j], Ts[idx], shift[j], - streams_inlet[j].ID) - interval_H[j, idx[:-1]] = sign[j] * (Hs[:-1] - Hs[1:]) - else: - k = np.searchsorted(-Ts, -T_hi[j]) - point_H[j, k] = sign[j] * abs(H_out[j] - H_in[j]) + if curves is None: + curves = stream_curves(streams_inlet, streams_quenched, is_hot) + elif len(curves) != N: + raise ValueError(f'{len(curves)} curves given for {N} streams') + H_in = np.array([c.H_in for c in curves]) + H_out = np.array([c.H_out for c in curves]) + shifted = [c.T - shift[j] for j, c in enumerate(curves)] + sizes = [x.size for x in shifted] + values, inverse = np.unique(np.concatenate(shifted), return_inverse=True) + # merge grid temperatures closer than 1e-9 K (ascending clusters) + cluster = np.concatenate([[0], np.cumsum(np.diff(values) > _T_EQ)]) + n = int(cluster[-1]) + 1 + # the highest member of each cluster represents it (selected explicitly: + # NumPy does not specify which value a repeated fancy index keeps) + top = values[np.flatnonzero(np.append(np.diff(cluster) > 0, True))] + Ts = top[::-1].copy() + position = (n - 1) - cluster[inverse] # descending grid index of each breakpoint + offsets = np.concatenate([[0], np.cumsum(sizes)]) + Hl = np.empty((N, n)) + Hr = np.empty((N, n)) + k_hi = np.empty(N, dtype=int) + k_lo = np.empty(N, dtype=int) + for j, c in enumerate(curves): + index = position[offsets[j]:offsets[j + 1]] + Hl[j], Hr[j] = c.grid_limits(Ts, shift[j], index) + k_hi[j] = index[-1] + k_lo[j] = index[0] + point_H = sign[:, None] * (Hr - Hl) + interval_H = sign[:, None] * (Hl[:, :-1] - Hr[:, 1:]) point_total = point_H.sum(axis=0) residual = np.cumsum( point_total + np.concatenate([[0.], interval_H.sum(axis=0)]) @@ -398,13 +481,41 @@ def problem_table(streams_inlet, streams_quenched, is_hot, T_min_app): # hot_util_load - cold_util_load == sum(unit_duty) stays exact hot_util_load -= cold_util_load cold_util_load = 0. - return ProblemTable(Ts, interval_H, point_H, residual, - hot_util_load, cold_util_load, Ts[k_pinch]) + 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) + return table, curves, grid -def temperature_interval_pinch_analysis(hus, - T_min_app=10, - force_ideal_thermo=False, - sort_hus_by_T=False): +def _pinch_cut(table): + """ + Return which side of the pinch the point loads *at* the pinch + temperature belong to: 'below' if the zero-heat-flow cut is the flow + arriving at `pinch_T` (before its point loads), 'above' if it is the + flow leaving it (after them); always 'below' for a threshold problem + (no hot utility: everything lies below the pinch at ``Ts[0]``). Split + every stream with ``side = 'right' if cut == 'below' else 'left'`` + (see `pinch_state`) to agree with the table: the heat above the split + is then exactly the hot utility target and the heat below it the cold + one. (`synthesize_network` does not split streams: the planner finds + the same cut in its own cascade, ``plan.cut``, which reproduces the + table's.) + """ + if table.hot_util_load == 0.: return 'below' + k = int(np.flatnonzero(table.Ts == table.pinch_T)[0]) + point_total = table.point_H.sum(axis=0) + 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] @@ -413,14 +524,14 @@ def temperature_interval_pinch_analysis(hus, 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 if force_ideal_thermo: - streams_inlet = [hx.ins[0] for hx in hxs] - streams_quenched = [i.outs[0] for i in hxs] - streams_inlet = [i.copy(thermo=i.thermo.ideal()) for i in streams_inlet] - streams_quenched = [i.copy(thermo=i.thermo.ideal()) for i in streams_quenched] + 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] else: - streams_inlet = [hx.ins[0].copy() for hx in hxs] - streams_quenched = [i.outs[0].copy() for i in hxs] + 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) for i in range(len(streams_inlet)): stream = streams_inlet[i] @@ -434,22 +545,23 @@ def temperature_interval_pinch_analysis(hus, 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 - table = problem_table(streams_inlet, streams_quenched, is_hot, T_min_app) + table, curves, grid = _problem_table(streams_inlet, streams_quenched, + is_hot, T_min_app) 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 - # Per-stream pinch temperature: where each stream is split between the - # hot-side and cold-side network designs. 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. This clause also catches - # non-monotone streams (T_out on the wrong side of T_in for their duty, - # e.g. a cold stream whose VLE outlet ends up cooler than it entered): - # rather than split their problem_table point-load duty across the - # cascade, they get pinch_T = T_in too, so load_duties assigns their - # whole duty to a single side (Q_hot_side for a cold stream, - # Q_cold_side for a hot one). + # 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). 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]: @@ -468,71 +580,121 @@ def temperature_interval_pinch_analysis(hus, 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, \ - streams_quenched - - -def _end_state(stream_end, T_lo, T_hi): + streams_quenched, table, curves, grid + +def temperature_interval_pinch_analysis(hus, + T_min_app=10, + force_ideal_thermo=False, + sort_hus_by_T=False): """ - Return a copy of `stream_end` at equilibrium at its own enthalpy, or the - stream as given if that equilibrium state lies outside the stream's own - temperature range [T_lo, T_hi] (e.g. a non-condensable mislabelled as a - liquid, whose equilibrium state at the same enthalpy is a gas at an - absurd temperature). Either way the enthalpy is exactly `stream_end.H`. + Prepare the process streams behind `hus` and run the problem table on + them, as `synthesize_network` does first; a standalone pinch analysis + (the network itself is planned on the table's stream curves, which this + function does not return). + + 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. + + Returns + ------- + pinch_T_arr : numpy.ndarray + Per-stream pinch temperature (see `synthesize_network`). + hot_util_load, cold_util_load : float + MER targets of `problem_table` [kJ/hr]. + T_in_arr, T_out_arr : numpy.ndarray + Inlet and quenched outlet temperatures [K]. + hxs : list[Unit] + The original heat exchangers, in stream order. + hot_indices, cold_indices, indices : list[int] + Stream indices of the hot streams, the cold streams, and all + (cold first). + streams_inlet, hx_utils_rearranged, streams_quenched : list + Inlet copies, heat utilities and quenched outlet copies, in stream + order. + """ - stream = stream_end.copy() - try: - stream.vle(H=stream_end.H, P=stream.P) - except Exception: - return stream_end.copy() - if T_lo <= stream.T <= T_hi: return stream - return stream_end.copy() + return _pinch_analysis(hus, T_min_app, force_ideal_thermo, + sort_hus_by_T)[:12] -def pinch_state(stream_in, stream_out, T_pinch): +def pinch_state(stream_in, stream_out, T_pinch, side=None, curve=None): """ Return a copy of the stream in the state it has when it crosses the pinch, with enthalpy guaranteed to lie within [min(H_in, H_out), max(H_in, H_out)]. - `stream_in` and `stream_out` are the stream's real end states (the - outlet quenched to equilibrium at its own enthalpy). For an interior - pinch the inlet copy is flashed at `T_pinch`; the result is used as is - when its enthalpy lies within the stream's own range. Otherwise the - stream never passes through that equilibrium state: a non-equilibrium - inlet (e.g. a superheated liquid from a non-rigorous HXutility) has - less enthalpy than the equilibrium fluid at the pinch, and the state - returned is instead the equilibrium state at the nearer end enthalpy. - The same end state is returned when `T_pinch` coincides with an end - temperature, because flashing a non-equilibrium inlet at its own - temperature does not reproduce `H_in` (and the result may even lie - inside the range). Using the *equilibrium* state at the end enthalpy, + Parameters + ---------- + stream_in, stream_out : Stream + The stream's real end states (the outlet quenched to equilibrium at + its own enthalpy). + T_pinch : float + The stream's (real, unshifted) pinch temperature [K]. + side : str, optional + Branch of a flat (isothermal) segment of the stream at `T_pinch` + (latent heat or a non-equilibrium end jump): 'right' takes its + high-enthalpy end (the load at the pinch goes below the pinch), + 'left' its low-enthalpy end (the load goes above). Pass ``side = + 'right' if cut == 'below' else 'left'``, with ``cut`` the pinch cut + of the table (`_pinch_cut`), to split every stream consistently + with the targets. If not given, an end temperature returns that + end's state (below) and elsewhere hot streams take 'right' and cold + streams 'left'. + curve : StreamCurve, optional + Prebuilt curve of the stream (e.g. from `_problem_table`); built + here if needed and not given. + + Notes + ----- + The state comes from the stream's temperature-enthalpy curve (see + `problem_table`), so it is deterministic: a pinch at the stream's own + saturation temperature is resolved by `side`, never by whatever phase + split a previous flash left, and a pinch inside a glide gets the + table's enthalpy. Inside the stream's temperature range the enthalpy is + clipped to its real range, as in the table: a non-equilibrium inlet + (e.g. a superheated liquid from a non-rigorous HXutility) has less + enthalpy than the equilibrium fluid at the pinch, so the state returned + is instead the equilibrium state at the nearer end enthalpy. Without + `side`, `T_pinch` equal to an end temperature returns that end's state + (the equilibrium state at the end enthalpy, see `_end_state`), because + flashing a non-equilibrium inlet at its own temperature does not + reproduce `H_in`. Using the *equilibrium* state at the end enthalpy, rather than the stream as given, keeps the synthesizer consistent with the problem table: the heat is offered at the temperature the - equilibrium model says it is available, not at a fictitious one; see - `_end_state` for the fallback when that state is unphysical. + equilibrium model says it is available, not at a fictitious one. Either way the hot-side and cold-side loads split `|H_in - H_out|` - exactly and the transient stream used for matching never carries heat - the real stream does not have. This is the synthesizer's counterpart of - the clipping done by `_stream_H_at_boundaries` for the problem table. + exactly, and the state never carries heat the real stream does not + have. + + A standalone analysis helper (see also `load_duties`): the network + synthesis does not split streams at a pinch temperature, it plans on + the curves themselves (see `synthesize_network`). + """ + if side is None: + T_lo, T_hi = sorted((stream_in.T, stream_out.T)) + if T_pinch == stream_in.T: return _end_state(stream_in, T_lo, T_hi) + if T_pinch == stream_out.T: return _end_state(stream_out, T_lo, T_hi) + if curve is None: curve = StreamCurve(stream_in, stream_out) + if side is None: side = 'right' if curve.is_hot else 'left' + return curve.state_at_T(T_pinch, side) + +def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, + is_cold, Q_hot_side, Q_cold_side): + """ + Fill `Q_hot_side` and `Q_cold_side` with each stream's duty above and + below its pinch temperature, ``[kind, duty]`` with kind 'heat' (cold + streams) or 'cool' (hot streams) and duties below 0.01 kJ/hr set to 0, + from the stream's `pinch_state` at ``pinch_T_arr[index]`` (e.g. from + `temperature_interval_pinch_analysis`). A standalone analysis helper, + like `pinch_state`: `synthesize_network` does not use it. """ - T_lo, T_hi = sorted((stream_in.T, stream_out.T)) - if T_pinch == stream_in.T: return _end_state(stream_in, T_lo, T_hi) - if T_pinch == stream_out.T: return _end_state(stream_out, T_lo, T_hi) - stream = stream_in.copy() - stream.vle(T=T_pinch, P=stream.P) - H_in = stream_in.H - H_out = stream_out.H - H_lo, H_hi = sorted((H_in, H_out)) - H = stream.H - if H_lo <= H <= H_hi: return stream - H_clipped = H_lo if H < H_lo else H_hi - return _end_state(stream_in if H_clipped == H_in else stream_out, T_lo, T_hi) - -def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, is_cold, Q_hot_side, Q_cold_side): for index in indices: H_in = streams[index].H H_out = streams_quenched[index].H - H_pinch = pinch_state(streams[index], streams_quenched[index], pinch_T_arr[index]).H + H_pinch = pinch_state(streams[index], streams_quenched[index], + pinch_T_arr[index]).H if not is_cold(index): dH1 = H_in - H_pinch dH2 = H_pinch - H_out @@ -547,20 +709,427 @@ def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, is_c if abs(dH2)<0.01: dH2 = 0 Q_hot_side[index] = ['heat', dH1] Q_cold_side[index] = ['heat', dH2] - - -def get_T_transient(pinch_T_arr, indices, T_in_arr): - T_transient = pinch_T_arr.copy() - T_transient[indices] = T_in_arr[indices] - return T_transient + + +# %% 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. +_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. +_DUTY_TOL = 1e-6 +#: Status 'mer' needs the utilities of the realized network (from the +#: simulated duties) within this times the total stream duty of the targets: +#: the accuracy of the enthalpy flashes that realize the plan (the plan +#: itself reaches the targets within `hensmith._planner._MER_TOL`). +_ACHIEVED_TOL = 1e-6 +#: Rounds of exact-state verification and local knot refinement. +_MAX_REFINE = 3 + +def _grid_knots(curves, grid): + """ + Knots ``(T, H - H_lo)`` of every stream on the problem-table grid, the + planner's model of the stream: at every grid temperature inside the + stream's own range, its left and right enthalpy limits there (two knots + where the curve has a flat). Every breakpoint of every curve is a grid + point and the table is linear between grid points, so the planner's + cascade on these knots is exactly the table's. Enthalpies are relative + to the stream's H_lo, so that large absolute enthalpies cost the planner + no precision. + """ + Ts, shift, Hl, Hr = grid['Ts'], grid['shift'], grid['Hl'], grid['Hr'] + knots = [] + for j, curve in enumerate(curves): + k = np.arange(grid['k_lo'][j], grid['k_hi'][j] - 1, -1) # ascending T + T = np.repeat(Ts[k] + shift[j], 2) + H = np.column_stack((Hl[j, k], Hr[j, k])).ravel() - curve.H_lo + keep = np.ones(T.size, dtype=bool) + keep[1::2] = Hr[j, k] != Hl[j, k] + knots.append((T[keep], H[keep])) + return knots + +def _knot_T(knots, H, hot): + """ + Temperature of a knot curve at enthalpies `H`, linear between knots; a + vertical stretch (a clipped non-equilibrium end) counts at its lowest + temperature for a hot stream and at its highest for a cold one, as in + the planner. + """ + T, Hk = knots + rises = np.diff(Hk) > 0. + if hot: keep = np.concatenate(([True], rises)) + else: keep = np.concatenate((rises, [True])) + return np.interp(H, Hk[keep], T[keep]) + +def _curve_tol_T(curve): + """Largest distance [K] between a stream's linearized curve (and so its + grid knots) and its exact states.""" + if not curve.monotone: return 0. + return max(curve.tol_T, curve.glide_error) + +def _interval_min(f, a, fa, b, fb): + """ + Minimum of a smooth function `f` on [a, b], given its end values. + + A minimum inside the interval shows at an end as a slope that points + into it (f falls from `a`, or rises into `b`); both one-sided slopes are + taken from a step of 1e-6 of the interval. Without such a slope the + minimum is an end (the smooth pieces of temperature-enthalpy curves + bend one way over a knot interval). Otherwise a scan of the interval + brackets the dip and golden-section search narrows it to 1e-7 of the + interval. Returns the smallest value found. + """ + eps = 1e-6 * (b - a) + fa_ = f(a + eps) + fb_ = f(b - eps) + if fa_ >= fa and fb_ >= fb: return min(fa, fb) + xs = [a, a + eps, *np.linspace(a, b, 9)[1:-1].tolist(), b - eps, b] + fs = [fa, fa_, *[f(x) for x in xs[2:-2]], fb_, fb] + k = int(np.argmin(fs)) + if k in (0, len(xs) - 1): return fs[k] + lo, x, hi, fx = xs[k - 1], xs[k], xs[k + 1], fs[k] + g = 0.5 * (3. - 5.**0.5) # golden section + xtol = 1e-7 * (b - a) + while hi - lo > xtol: + u = x + g * (hi - x) if hi - x > x - lo else x - g * (x - lo) + fu = f(u) + if fu < fx: + if u > x: lo = x + else: hi = x + x, fx = u, fu + elif u > x: hi = u + else: lo = u + return fx + +def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, + T_min_app): + """ + 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). + + The positions are the ends and every knot and curve breakpoint inside + the exchanger. Between two consecutive positions both knot curves are + linear, so the planned approach (on the knots) is too, and each stream's + exact states are within `_curve_tol_T` of its knots: an interval whose + two ends have a planned approach of at least `T_min_app` plus that + margin is feasible on the exact states. Every other interval is + checked with `StreamCurve.T_exact` at its ends and midpoint and, where + the exact approach is not linear (curved single-phase stretches and + glides), searched for a minimum inside it (`_interval_min`). + + 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. + 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). + """ + hot, cold = curves[h], curves[c] + H_hot_out, H_cold_out = H_hot_in - Q, H_cold_in + Q + qs = [0., Q] + for H in (knots[h][1], hot.H - hot.H_lo): + qs.extend(H_hot_in - H[(H > H_hot_out) & (H < H_hot_in)]) + for H in (knots[c][1], cold.H - cold.H_lo): + qs.extend(H_cold_out - H[(H > H_cold_in) & (H < H_cold_out)]) + qs = np.unique(np.clip(qs, 0., Q)) + planned = (_knot_T(knots[h], H_hot_in - qs, True) + - _knot_T(knots[c], H_cold_out - qs, False)) + near = planned < T_min_app + _curve_tol_T(hot) + _curve_tol_T(cold) + 1e-9 + limit = T_min_app - _APPROACH_TOL + points = [] + states = {} + + def exact(q): + if q not in states: + T_hot = hot.T_exact(hot.H_lo + H_hot_in - q, 'low') + T_cold = cold.T_exact(cold.H_lo + H_cold_out - q, 'high') + states[q] = dT = T_hot - T_cold + if dT < limit: + points.append((T_hot, H_hot_in - q, T_cold, H_cold_out - q)) + return states[q] + + worst = float(planned[~near].min()) if not near.all() else np.inf + if qs.size == 1: + if near[0]: worst = min(worst, exact(qs[0])) + return worst, points + for k in np.flatnonzero(near[:-1] | near[1:]): + a, b = qs[k], qs[k + 1] + fa, fm, fb = exact(a), exact(0.5 * (a + b)), exact(b) + worst = min(worst, fa, fm, fb) + if abs(fa + fb - 2. * fm) > 1e-9: # not linear: look for a dip + worst = min(worst, _interval_min(exact, a, fa, b, fb)) + return worst, points + +def _exact_approach(plan, duties, ends, curves, knots, T_min_app): + """ + `_exchanger_approach` of every exchanger in `duties` (duty by index into + ``plan.exchangers``) at the enthalpies of the walk `ends` (see `_walk`). + Returns the smallest approach [K], the violating exact states by stream, + ``{stream: [(T_exact, H - H_lo), ...]}``, and the violating exchangers. + """ + worst = np.inf + violations = {} + bad = [] + for n, Q in duties.items(): + 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 + ) + worst = min(worst, approach) + if points: bad.append(n) + for T_hot, H_hot, T_cold, H_cold in points: + violations.setdefault(h, []).append((T_hot, H_hot)) + violations.setdefault(c, []).append((T_cold, H_cold)) + return worst, violations, bad + +def _shrink(curves, knots, h, c, H_hot_in, H_cold_in, Q, T_min_app): + """ + Largest duty ``Q' <= Q`` (to 1e-9 of Q) at which the exchanger of + `_exchanger_approach` keeps ``T_min_app - _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'] and bisection finds its end. + """ + def ok(x): + return not _exchanger_approach(curves, knots, h, c, H_hot_in, + H_cold_in, x, T_min_app)[1] + lo, hi = 0., Q + # a first guess from the local heat capacity flow rates saves most of + # the bisection: the violations are within the chord error of the knots + approach, _ = _exchanger_approach(curves, knots, h, c, H_hot_in, + H_cold_in, Q, T_min_app) + CP = 0. + for j in (h, c): + T, H = knots[j] + dT = np.diff(T) + dH = np.diff(H) + slopes = dH[dT > 0.] / dT[dT > 0.] + if slopes.size: CP = max(CP, float(slopes.max())) + guess = Q - 2. * (T_min_app - approach) * CP + if 0. < guess < Q and ok(guess): lo = guess + while hi - lo > 1e-9 * Q: + mid = 0.5 * (lo + hi) + if ok(mid): lo = mid + else: hi = mid + return lo + +def _repair(plan, duties, knots, is_hot, curves, T_min_app): + """ + 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 + 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; + the loop only guards against rounding. Returns the new duties and the + changes, ``[(n, Q_before, Q_after)]``. + """ + duties = dict(duties) + 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] + 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) + changes.append((n, duties[n], Q)) + duties[n] = Q + return duties, changes + +def _refine_knots(knots, violations): + """ + Return `knots` with the exact states of `violations` inserted (or, at an + existing knot, i.e. a chord point inside a glide, corrected), clamped + between the neighbouring knot temperatures so that every curve stays + monotone. + """ + knots = list(knots) + for j, points in violations.items(): + T, H = knots[j] + T, H = T.tolist(), H.tolist() + for T_new, H_new in sorted(points, key=lambda p: p[1]): + i = int(np.searchsorted(H, H_new)) + if i < len(H) and H[i] == H_new: + lo = T[i - 1] if i > 0 else T_new + hi = T[i + 1] if i + 1 < len(T) else T_new + T[i] = min(max(T_new, lo), hi) + else: + lo = T[i - 1] if i > 0 else T_new + hi = T[i] if i < len(T) else T_new + T.insert(i, min(max(T_new, lo), hi)) + H.insert(i, H_new) + knots[j] = (np.array(T), np.array(H)) + return knots + +def _enthalpy_limit(curve, s_in, H, hot): + """ + `H` if `HXprocess` can take it as the enthalpy limit of the stream of + `curve` entering an exchanger in state `s_in`, else None (the other + stream's limit then sets the duty). + + `HXprocess` flashes the stream to the limit and rejects an equilibrium + state on the wrong side of the inlet temperature (a heated stream + colder than its inlet). On a monotone curve that happens only strictly + inside a non-equilibrium end jump (see `StreamCurve.jumps`). A + point-load stream's (non-monotone curve's) equilibrium states are not + ordered with its real inlet temperature: e.g. a liquid fed above its + bubble point and boiled to its dew point, colder than its feed, has + every state past its real inlet on the wrong side, its outlet included. + It enters its first exchanger at equilibrium at its inlet enthalpy + instead (see `_first_inlet`), from which its states are ordered, unless + that flash failed. So the equilibrium state at `H` is compared with the + inlet directly. + """ + if curve.monotone: + tol = curve.tol_H + inside = any(H_a + tol < H < H_b - tol for H_a, H_b in curve.jumps) + return None if inside else H + try: + T = curve.state_at_H(H).T + except Exception: + return None + past = T <= s_in.T + _T_SIDE if hot else T >= s_in.T - _T_SIDE + return H if past else None + +def _first_inlet(stream, point_load, T_point, hot): + """ + Bring `stream`, a copy of a process stream's real inlet, in place to + the state in which the stream enters its first process exchanger: the + real inlet, except that a point-load stream (non-monotone + `StreamCurve`, whose whole duty is planned at its outlet temperature + `T_point`; `hot` if it is cooled) enters at equilibrium at its inlet + enthalpy and pressure, which lies on the plan's side of `T_point` + (see `hensmith._curves._point_load_inlet`; kept as given if that flash + fails). The enthalpy is the same either way, so no balance changes. + + `HXprocess` judges a match by the inlet temperatures (the hotter inlet + is the hot stream, no heat moves unless they are more than `dT` apart, + 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 + 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 + partially condenses to, hotter than its feed. + """ + if point_load: stream.copy_like(_point_load_inlet(stream, T_point, hot)) + +class _RealizationError(Exception): + """An exchanger of the plan could not be simulated.""" + def __init__(self, n, ID, error): + super().__init__(n, ID, error) + self.n, self.ID, self.error = n, ID, error + +def _walk(plan, duties, knots, is_hot): + """ + Enthalpies (relative to each stream's H_lo) at which every stream enters + and leaves each of its exchangers in `duties` (duty by index into + ``plan.exchangers``), walking it in flow order from its inlet + (``plan.stages``), and at which it enters its utility; plus the pair + index of every exchanger (its rank among the exchangers of the same + (side, hot, cold) pair, in the order the hot stream meets them). A + smaller duty (a dropped or shrunk match) shifts the later stages of both + streams toward their inlets. + """ + ends = {} + last = [] + for j, hot in enumerate(is_hot): + H = knots[j][1][-1] if hot else 0. + for n in plan.stages[j]: + if n not in duties: continue + Q = duties[n] + H_next = H - Q if hot else H + Q + ends[n, j] = (H, H_next) + H = H_next + last.append(H) + count = {} + pair_index = {} + for j, hot in enumerate(is_hot): + if not hot: continue + for n in plan.stages[j]: + if n not in duties: continue + e = plan.exchangers[n] + key = (e.side, e.hot, e.cold) + count[key] = pair_index[n] = count.get(key, 0) + 1 + return ends, last, pair_index + +def _discard(units): + """Remove units, and the streams connected to them, from the registry of + the active flowsheet (after a failed realization).""" + for unit in 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): + """ + Build and run one plain `HXprocess` per exchanger in `duties` (duty by + index into ``plan.exchangers``), in plan order. 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) + and the enthalpy at which it enters its utility (relative to its H_lo). + """ + 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))] + units = {} + dT = T_min_app - _APPROACH_TOL + for n in sorted(duties): + e = plan.exchangers[n] + h, c = e.hot, e.cold + suffix = '' if pair_index[n] == 1 else f'_{pair_index[n]}' + if e.side == 'above': + ID = f'HX_{c}_{h}_hs{suffix}' + ports = (c, h) + else: + ID = f'HX_{h}_{c}_cs{suffix}' + ports = (h, c) + ins, outs, H_lims = [], [], [] + for j in ports: + curve = curves[j] + H_in, H_out = ends[n, j] + if first[j] == n: + s = _copy(streams_inlet[j]) # the real inlet state + _first_inlet(s, not curve.monotone, curve.T_out, is_hot[j]) + else: + s = curve.state_at_H(curve.H_lo + H_in) + s.ID = f's_{j}__{ID}' + ins.append(s) + outs.append(s.copy(f'{ID}__s_{j}')) + # a planned outlet whose equilibrium state is not past the inlet + # (inside a non-equilibrium end jump, or a point load's): leave + # it to the other stream's limit + H_lims.append(_enthalpy_limit(curve, s, curve.H_lo + H_out, + is_hot[j])) + hx = bst.HXprocess(ID=ID, ins=ins, outs=outs, H_lim0=H_lims[0], + H_lim1=H_lims[1], dT=dT, thermo=ins[0].thermo) + units[n] = hx + try: + hx._run() + except Exception as error: + _discard(units.values()) + raise _RealizationError(n, ID, error) + return units, first, last def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, - avoid_recycle=False, sort_hus_by_T=False): + avoid_recycle=False, sort_hus_by_T=False, info=None): """ - Synthesize a heat exchanger network for the process streams behind a - set of utility heat exchangers, with pinch analysis followed by a - sequential, heuristic matching of hot and cold streams on each side of - the pinch. + Synthesize a heat exchanger network without stream splits for the + process streams behind a set of utility heat exchangers: pinch analysis + (`problem_table`), then a pinch-outward plan that reaches the minimum + energy requirement (MER) targets whenever the search finds an unsplit + network that does, realized with one `HXprocess` per match and one + rigorous `HXutility` per stream. Parameters ---------- @@ -572,405 +1141,400 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, dropped. Heating utilities are placed before cooling utilities; within each group the given order is kept unless `sort_hus_by_T`. All returned per-stream arrays and lists are indexed in that - rearranged order (the stream index). That order is also the - matching priority: the outer loop of each design pass and both - loops of each offset pass walk the streams by index. - `HeatExchangerNetwork` passes the utilities sorted by signed duty, - so by default the cold stream with the smallest heating duty and - the hot stream with the largest cooling duty are tried first. + rearranged order (the stream index), which also breaks ties in the + planner's search. T_min_app : float, optional - Minimum approach temperature [K]: required between the streams of - every candidate match, enforced on every synthesized exchanger - (``HXprocess(dT=T_min_app)``) and used to shift hot streams in the - problem table. Defaults to 5. + 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. Qmin : float, optional - Candidate exchangers with a duty below this [kJ/hr] are discarded. + Planned exchangers with a duty below this [kJ/hr] are dropped and + their duty left to the utilities (a large value can cost MER). Defaults to 1e-3. force_ideal_thermo : bool, optional Analyze copies of the streams with ideal thermodynamics (``thermo.ideal()``); the synthesized exchangers inherit that thermo. Defaults to False. avoid_recycle : bool, optional - Never match the same (hot, cold) pair twice across the passes, so no - two exchangers connect the same pair of streams (a second exchanger - between them can form a recycle loop in the network). Defaults to - False. + Never match the same (hot, cold) pair twice anywhere, so no two + exchangers connect the same pair of streams (a second exchanger + between them can form a recycle loop in the network). This + forbids the repeated matches that some unsplit MER networks need. + Defaults to False. sort_hus_by_T : bool, optional Sort the heating utilities by inlet temperature, descending, and the - cooling utilities ascending, before analysis, so that inlet - temperature rather than the given order sets the matching priority. - Defaults to False. + cooling utilities ascending, before analysis. Defaults to False. + info : dict, optional + If given, filled with the synthesis report: 'status' ('mer' if the + realized network's utilities equal the targets, else + 'best_effort'), 'Q_hot_target' and 'Q_cold_target' (the problem + table's targets), 'Q_hot_plan' and 'Q_cold_plan' (the planned + utilities), 'Q_hot' and 'Q_cold' (the utilities of the realized + network, from the simulated exchanger duties), 'penalty' + (``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; + 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 + boiling point, so that their whole duty is a point load at the + outlet temperature; each enters its first process exchanger at + equilibrium at its inlet enthalpy, see Notes). Returns ------- HXs_hot_side : list[HXprocess] - Process exchangers of the hot-side (above-pinch) design, IDs - ``HX___hs``; ``ins``/``outs`` [0] is the cold stream and - [1] the hot stream. + Process exchangers of the hot-side (above-pinch) design, in plan + order (from the pinch outward), IDs ``HX___hs``; + ``ins``/``outs`` [0] is the cold stream and [1] the hot stream. HXs_cold_side : list[HXprocess] - Process exchangers of the cold-side (below-pinch) design, IDs - ``HX___cs``; ``ins``/``outs`` [0] is the hot stream and - [1] the cold stream. + Process exchangers of the cold-side (below-pinch) design, in plan + order, IDs ``HX___cs``; ``ins``/``outs`` [0] is the hot + stream and [1] the cold stream. The n-th exchanger (n >= 2) of the + same pair on the same side, counted in the order the hot stream + meets them, gets the suffix ``_`` (e.g. ``HX_3_2_cs_2``). new_HX_utils : list[HXutility] - One rigorous utility exchanger per stream bringing it to its outlet - enthalpy, IDs ``Util__cs`` (hot streams) / ``Util__hs`` - (cold streams); listed hot streams first. + One rigorous utility exchanger per stream (possibly of zero duty) + bringing it from its last process exchanger to its outlet enthalpy, + IDs ``Util__cs`` (hot streams) / ``Util__hs`` (cold + streams); listed hot streams first. hxs : list[Unit] The original heat exchangers, in stream order. T_in_arr, T_out_arr : numpy.ndarray Inlet and (quenched) outlet temperatures of each stream [K]. pinch_T_arr : numpy.ndarray - Per-stream pinch temperature [K] at which the stream is split - between the hot-side and cold-side designs: the process pinch on - the stream's own scale when the stream crosses it + 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 lies past the pinch in its direction of flow, or that is isothermal - or non-monotone (its whole duty then falls on one side); the outlet - temperature of a stream that ends before reaching the pinch. + or non-monotone; the outlet temperature of a stream that ends + before reaching the pinch. C_flow_vector : numpy.ndarray - Heat capacity flow rate of each stream, ``|Q| / |T_in - T_out|`` - [kJ/hr/K] (the temperature difference is replaced by 1e-12 for an - isothermal stream, which therefore ranks as a very large flow rate). + Heat capacity flow rate of each process stream, ``|H_out - H_in| / + |T_in - T_out|`` [kJ/hr/K] from its inlet and quenched outlet (the + temperature difference is replaced by 1e-12 for an isothermal + stream, which therefore ranks as a very large flow rate). hx_utils_rearranged : list[HeatUtility] The heat utilities of `hus` in stream order. streams_inlet : list[Stream] One copy of each stream's inlet, in stream order, as prepared for the analysis (ideal-thermo copies if `force_ideal_thermo`). The - synthesis works on further copies, so these keep their inlet state. + network works on further copies, so these keep their inlet state. stream_HXs_dict : dict[int, list[Unit]] - Exchangers (process, then utility) each stream index passes through, - in synthesis order (not flow order; see `StreamLifeCycle`). + For each stream index, its process exchangers in flow order, then + its utility exchanger. hot_indices, cold_indices : list[int] Stream indices of the hot and cold streams. Notes ----- - The outlet of every stream is first quenched to equilibrium at its own - enthalpy (``s.vle(H=s.H, P=s.P)``) and `problem_table` locates the - pinch. Hot streams enter the cold-side design, and cold streams the - hot-side design, in the state they have when they cross the pinch - (`pinch_state`, with enthalpy clipped to the stream's real range); on - the other side each stream enters at its inlet state. The synthesis - then proceeds in four passes, of which the first three create - `HXprocess` units that exchange as much heat as the approach - temperature (`dT`), the outlet enthalpy of one stream (`H_lim0`) and a - temperature limit on the other (`T_lim1`) allow: - - 1. *Cold-side design.* For each hot stream, candidate cold streams with - heat-capacity flow rate at most that of the hot stream and a current - temperature more than `T_min_app` below the hot stream's, ranked by - ``min(C_hot, C_cold) * (T_hot - T_cold - T_min_app)``, are matched - in that order (`T_lim1` is the cold stream's pinch temperature) until - the hot stream reaches its outlet enthalpy. Streams lying entirely - above the pinch, and isothermal or non-monotone streams, are - excluded. - 2. *Hot-side design.* The mirror image for each cold stream, with the - heat-capacity flow rate inequality reversed (`T_lim1` is the hot - stream's pinch temperature), until the cold stream reaches its - outlet enthalpy. Streams lying entirely below the pinch, and - isothermal or non-monotone streams, are excluded. - 3. *Offset passes.* Remaining cold-side heating demands, then remaining - hot-side cooling demands, are matched in index order with streams - that still have the opposite demand on that side and are at least - `T_min_app` away, without the flow-rate inequality and with `T_lim1` - the other stream's outlet temperature (in the hot-side pass a cold - stream is taken at its furthest state across both sides). - 4. *Utility exchangers* (rigorous `HXutility`) finish every stream from - its furthest state to its outlet enthalpy; an `AssertionError` is - raised if the result does not reproduce the stream's quenched outlet - within tolerance. - - The heat-capacity flow rate inequalities of passes 1 and 2 are the - feasibility criteria of the pinch design method [LH83]_ (see also - [Seider17]_, Chapter 9). A match is attempted at most once per exchanger - ID and dropped when the exchanger cannot be simulated or its duty is - below `Qmin`; each successful match advances the working states of both - streams. `HeatExchangerNetwork` calls this function and turns the result - into a `System` that it converges and costs. + *Curves and targets.* Every stream's outlet is quenched to equilibrium + at its own enthalpy and described by a piecewise-linear temperature- + enthalpy curve (see `problem_table`), built once. The problem table + [Kemp07]_ on the union of all breakpoints gives the targets, the pinch + and the side of the pinch that point loads at the pinch temperature + belong to. The planner models each stream by its knots on that grid, + which reproduces the table's cascade exactly. + + *Planner.* Each stream is cut at the pinch; above it the hot streams, + 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 + [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, + generalized to isothermal segments, which can serve several partners + in series) must hold; at the pinch itself a violation proves that MER + needs stream splitting. + Candidate duties are the largest feasible one and a finite set of + events (a stream ticked off, a partner saved for another stream, a + switch of partner, a return). The same pair may be matched repeatedly, + which emulates a split by series alternation. Budgets are counted in + deterministic work units, so results do not depend on machine speed. + A branch and bound then reduces the number of exchangers. A side that + is proven to need splits, or whose search runs out of budget, gets a + best-effort plan: heat a must cannot place is moved to its pinch end, + where it crosses the pinch at the cost of an equal amount of extra hot + and cold utility (the penalty), minimized by greedy dives and a + bisection of these gaps. See `hensmith._planner` for the details. + + *Realization.* Each stream is walked in flow order from its inlet: a + hot stream through its hot-side matches from its inlet end, then its + cold-side matches, then its cooler; a cold stream through its + 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` + 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 + and the other stream's sets the duty; a match in which neither stream + can take a limit runs to its `dT` guard (a deviation if its duty + differs). A stream's first exchanger gets its real inlet, except a + point-load stream (whose outlet temperature does not move with its + duty, so the plan places its whole duty there, a temperature its real + inlet lies beyond): it enters at equilibrium at its inlet enthalpy, + which lies on the plan's side of its outlet temperature (a reboiler + fed as a liquid above its boiling point enters as the mixture it + flashes to; a vapor fed below its dew point, e.g. under + `force_ideal_thermo`, as the mixture it partially condenses to), + because `HXprocess` compares the inlet temperatures with `dT` and + would refuse or cut short a match planned there. Later exchangers get + the stream's exact state at the planned enthalpy. + Every exchanger is simulated once; one whose duty differs from the + plan is reported in `info`. Each stream ends in one rigorous + `HXutility` to its outlet enthalpy; an `AssertionError` is raised if + it does not reproduce the quenched outlet within tolerance. + + *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. + + *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 + 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 + necessary condition, so a missed MER network can only come from the + finite set of candidate duties, the caps on repeated pairs or the work + budgets; the planner reached MER on every unsplit-feasible problem of a + certified benchmark of about 1,700 problems with 2-40 streams. Networks + whose match order is cyclic cannot be represented. An unsplit MER + network can need many exchangers (series alternation approaches a + split only in the limit); MER always takes precedence over the number + of units. Problems that need stream splits get a best-effort network + whose penalty is small but not minimal in general. A side that needs + splits without a pinch-rule proof spends its whole MER budget before + the best-effort step. 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 + `info['deviations']`). + + `HeatExchangerNetwork` calls this function, rewires each stream's + stages in series, converges the network as a `System` and costs it. + + Examples + -------- + Problem r002: one hot stream against three cold ones (heat capacity + flow rates in kW/K, temperatures 300 K above those of the classic + problem, T_min_app = 10 K). Its only unsplit MER network matches the + hot stream twice with the same cold stream. A constant heat capacity + pseudo-component makes 1000 kmol/hr of fluid per kW/K: + + >>> import biosteam as bst, thermosteam as tmo + >>> from hensmith.hxn_synthesis import synthesize_network + >>> Fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., + ... Cn=3.6, default=True) + >>> bst.settings.set_thermo([Fluid], cache=True) + >>> def process_stream(ID, T_in, T_out, CP): + ... inlet = bst.Stream(ID + '_in', Fluid=1000. * CP, T=T_in, + ... units='kmol/hr') + ... hx = bst.HXutility(ID, ins=inlet, T=T_out, rigorous=False) + ... hx.simulate() + ... return hx + >>> units = [process_stream('C1', 440., 470., 1.), + ... process_stream('C2', 400., 420., 1.), + ... process_stream('C3', 420., 550., 2.), + ... process_stream('H1', 520., 350., 4.)] + >>> hus = [hx.heat_utilities[0] for hx in units] + >>> info = {} + >>> result = synthesize_network(hus, T_min_app=10., info=info) + >>> HXs_hot_side, HXs_cold_side, new_HX_utils = result[:3] + >>> for hx in HXs_hot_side + HXs_cold_side: + ... print(hx.ID, round(hx.Q / 3600., 6), 'kW') + HX_3_2_cs 160.0 kW + HX_3_0_cs 30.0 kW + HX_3_2_cs_2 20.0 kW + HX_3_1_cs 20.0 kW + >>> info['status'] + 'mer' + + The utilities equal the MER targets, 80 kW of heating and 450 kW of + cooling: + + >>> duties = [(hx.outs[0].H - hx.ins[0].H) / 3600. for hx in new_HX_utils] + >>> round(sum(Q for Q in duties if Q > 0), 6), round(-sum(Q for Q in duties if Q < 0), 6) + (80.0, 450.0) + >>> round(info['Q_hot_target'] / 3600., 6), round(info['Q_cold_target'] / 3600., 6) + (80.0, 450.0) References ---------- .. [LH83] Linnhoff, B., & Hindmarsh, E. (1983). The pinch design method for heat exchanger networks. Chemical Engineering Science, 38(5), 745-763. + .. [Kemp07] Kemp, I. C. (2007). Pinch Analysis and Process Integration + (2nd ed.). Butterworth-Heinemann. + .. [Smith05] Smith, R. (2005). Chemical Process Design and Integration. + Wiley. .. [Seider17] 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). """ - 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 = temperature_interval_pinch_analysis(hus, T_min_app, force_ideal_thermo, - sort_hus_by_T) - H_out_arr = [i.H for i in streams_quenched] - duties = np.array([abs(hx.Q) for hx in hxs]) + 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) + N = len(hxs) + is_hot = [False] * N + for i in hot_indices: is_hot[i] = True + H_in_arr = np.array([c.H_in for c in curves]) + H_out_arr = np.array([c.H_out for c in curves]) dTs = np.abs(T_in_arr - T_out_arr) - dTs[dTs == 0.] = 1e-12 - C_flow_vector = duties/dTs - Q_hot_side = {} - Q_cold_side = {} - stream_HXs_dict = {i:[] for i in indices} - is_cold = lambda x: x in cold_indices - load_duties(streams_inlet, streams_quenched, pinch_T_arr, T_out_arr, indices, is_cold, Q_hot_side, Q_cold_side) - matches_hs = {i: [] for i in cold_indices} - matches_cs = {i: [] for i in hot_indices} - HXs_hot_side = [] - HXs_cold_side = [] - streams_transient_cold_side = [i.copy() for i in streams_inlet] - streams_transient_hot_side = [i.copy() for i in streams_inlet] - # Hot streams enter the cold-side design at their pinch state and cold - # streams enter the hot-side design at theirs; the enthalpy of that - # state is clipped to the stream's real range (see `pinch_state`). - for i in hot_indices: - s = streams_transient_cold_side[i] - if s.T != pinch_T_arr[i]: - streams_transient_cold_side[i] = pinch_state(s, streams_quenched[i], pinch_T_arr[i]) - for i in cold_indices: - s = streams_transient_hot_side[i] - if s.T != pinch_T_arr[i]: - streams_transient_hot_side[i] = pinch_state(s, streams_quenched[i], pinch_T_arr[i]) - - def get_stream_at_H_max(cold): - s_cs = streams_transient_cold_side[cold] - s_hs = streams_transient_hot_side[cold] - return s_cs if s_cs.H > s_hs.H else s_hs - - def get_stream_at_H_min(hot): - s_cs = streams_transient_cold_side[hot] - s_hs = streams_transient_hot_side[hot] - return s_cs if s_cs.H < s_hs.H else s_hs - - def get_T_transient_cold_side(index): - return streams_transient_cold_side[index].T - - def get_T_transient_hot_side(index): - return streams_transient_hot_side[index].T - - attempts = set() - success = set() - # ------------- Cold side design ------------- # - unavailables = set([i for i in hot_indices if T_out_arr[i] >= pinch_T_arr[i]]) - unavailables.update([i for i in cold_indices if T_in_arr[i] >= pinch_T_arr[i]]) - for hot in hot_indices: - stream_quenched = False - potential_matches = [] - for cold in cold_indices: - if (C_flow_vector[hot]>= C_flow_vector[cold] and - get_T_transient_cold_side(hot) > get_T_transient_cold_side(cold) + T_min_app and - (hot not in unavailables) and (cold not in unavailables) and - (cold not in matches_cs[hot]) and (cold in cold_indices)): - potential_matches.append(cold) - potential_matches = sorted( - potential_matches, - key = lambda pot_cold: min(C_flow_vector[hot], C_flow_vector[pot_cold]) - * (get_T_transient_cold_side(hot) - - get_T_transient_cold_side(pot_cold) - - T_min_app), - reverse = True + C_flow_vector = np.abs(H_out_arr - H_in_arr) / np.maximum(dTs, 1e-12) + duty = np.abs(H_out_arr - H_in_arr) + scale = float(duty.sum()) + # Plan on the grid knots and check the plan on the exact states. Where it + # falls short, a MER plan is planned again on knots refined with the + # exact states (at most _MAX_REFINE rounds); a best-effort plan, or a MER + # plan still short after the last round, has its violating matches + # shrunk locally instead (a re-plan of a best-effort side repeats its + # whole search to recover a duty of the order of the chord error). + knots = _grid_knots(curves, grid) + for refine_round in range(_MAX_REFINE + 1): + plan = plan_network(knots, is_hot, T_min_app, + avoid_recycle=avoid_recycle, Qmin=Qmin) + 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 ) - for cold in potential_matches: - match = (hot, cold) - ID = 'HX_%s_%s_cs' % match - if ID in attempts or (avoid_recycle and match in success): continue - attempts.add(ID) - hot_stream = streams_transient_cold_side[hot].copy() - cold_stream = streams_transient_cold_side[cold].copy() - - hot_stream.ID = 's_%s__%s'%(hot,ID) - cold_stream.ID = 's_%s__%s'%(cold,ID) - hot_out = hot_stream.copy('%s__s_%s'%(ID,hot)) - cold_out = cold_stream.copy('%s__s_%s'%(ID,cold)) - H_lim = H_out_arr[hot] - new_HX = bst.units.HXprocess(ID = ID, ins = (hot_stream, cold_stream), - outs = (hot_out, cold_out), H_lim0 = H_lim, - T_lim1 = pinch_T_arr[cold], dT = T_min_app, - thermo = hot_stream.thermo) - try: new_HX._run() - except: continue - if abs(new_HX.Q )< Qmin: continue - success.add(match) - HXs_cold_side.append(new_HX) - stream_HXs_dict[hot].append(new_HX) - stream_HXs_dict[cold].append(new_HX) - Q_cold_side[hot][1] -= new_HX.Q - Q_cold_side[cold][1] -= new_HX.Q - streams_transient_cold_side[hot] = new_HX.outs[0] - streams_transient_cold_side[cold] = new_HX.outs[1] - H_out = new_HX.outs[0].H - assert H_out - new_HX.ins[0].H <= 0. - stream_quenched = H_out < H_lim or np.allclose(H_out, H_lim) - matches_cs[hot].append(cold) - if stream_quenched: - break - - # ------------- Hot side design ------------- # - unavailables = set([i for i in hot_indices if T_in_arr[i] <= pinch_T_arr[i]]) - unavailables.update([i for i in cold_indices if T_out_arr[i] <= pinch_T_arr[i]]) - - for cold in cold_indices: - potential_matches = [] - for hot in hot_indices: - if (C_flow_vector[cold]>= C_flow_vector[hot] and - get_T_transient_hot_side(hot) > get_T_transient_hot_side(cold) + T_min_app and - (hot not in unavailables) and (cold not in unavailables) and - (hot not in matches_hs[cold]) and (hot in hot_indices)): - potential_matches.append(hot) - - potential_matches = sorted(potential_matches, key = lambda x: - (min(C_flow_vector[cold], C_flow_vector[x]) - * ( get_T_transient_hot_side(x) - - get_T_transient_hot_side(cold) - T_min_app)), - reverse = True) - stream_quenched = False - for hot in potential_matches: - match = (hot, cold) - ID = 'HX_%s_%s_hs' % (cold, hot) - if ID in attempts or (avoid_recycle and match in success): continue - attempts.add(ID) - hot_stream = streams_transient_hot_side[hot].copy() - cold_stream = streams_transient_hot_side[cold].copy() - cold_stream.ID = 's_%s__%s'%(cold,ID) - hot_stream.ID = 's_%s__%s'%(hot,ID) - hot_out = hot_stream.copy('%s__s_%s'%(ID,hot)) - cold_out = cold_stream.copy('%s__s_%s'%(ID,cold)) - H_lim = H_out_arr[cold] - new_HX = bst.units.HXprocess(ID = ID, ins = (cold_stream, hot_stream), - outs = (cold_out, hot_out), H_lim0 = H_lim, - T_lim1 = pinch_T_arr[hot], dT = T_min_app, - thermo = hot_stream.thermo) - try: new_HX._run() - except: continue - if abs(new_HX.Q)< Qmin: continue - success.add(match) - HXs_hot_side.append(new_HX) - stream_HXs_dict[hot].append(new_HX) - stream_HXs_dict[cold].append(new_HX) - Q_hot_side[hot][1] -= new_HX.Q - Q_hot_side[cold][1] -= new_HX.Q - streams_transient_hot_side[cold] = new_HX.outs[0] - streams_transient_hot_side[hot] = new_HX.outs[1] - H_out = new_HX.outs[0].H - assert H_out - new_HX.ins[0].H >= 0. - stream_quenched = H_out > H_lim or np.allclose(H_out, H_lim) - matches_hs[cold].append(hot) - if stream_quenched: - break - - # Offset heating requirement on cold side - for cold in cold_indices: - if Q_cold_side[cold][0]=='heat' and Q_cold_side[cold][1]>0: - for hot in hot_indices: - match = (hot, cold) - ID = 'HX_%s_%s_cs' % match - if ID in attempts or (avoid_recycle and match in success): continue - attempts.add(ID) - T_cold_in = get_T_transient_cold_side(cold) - T_hot_in = get_T_transient_cold_side(hot) - if (Q_cold_side[hot][0]=='cool' and Q_cold_side[hot][1]>0 and - T_hot_in - T_cold_in >= T_min_app): - hot_stream = streams_transient_cold_side[hot].copy() - cold_stream = streams_transient_cold_side[cold].copy() - hot_stream.ID = 's_%s__%s'%(hot,ID) - cold_stream.ID = 's_%s__%s'%(cold,ID) - hot_out = hot_stream.copy('%s__s_%s'%(ID,hot)) - cold_out = cold_stream.copy('%s__s_%s'%(ID,cold)) - new_HX = bst.units.HXprocess(ID = ID, ins = (hot_stream, cold_stream), - outs = (hot_out, cold_out), H_lim0 = H_out_arr[hot], - T_lim1 = T_out_arr[cold], dT = T_min_app, - thermo = hot_stream.thermo) - try: new_HX._run() - except: continue - if abs(new_HX.Q )< Qmin: continue - success.add(match) - HXs_cold_side.append(new_HX) - stream_HXs_dict[hot].append(new_HX) - stream_HXs_dict[cold].append(new_HX) - Q_cold_side[hot][1] -= new_HX.Q - Q_cold_side[cold][1] -= new_HX.Q - streams_transient_cold_side[hot] = new_HX.outs[0] - streams_transient_cold_side[cold] = new_HX.outs[1] - matches_cs[hot].append(cold) - - # Offset cooling requirement on hot side - for hot in hot_indices: - if Q_hot_side[hot][0]=='cool' and Q_hot_side[hot][1]>0: - for cold in cold_indices: - match = (hot, cold) - ID = 'HX_%s_%s_hs' % (cold, hot) - if ID in attempts or (avoid_recycle and match in success): continue - attempts.add(ID) - original_cold_stream = get_stream_at_H_max(cold) - T_cold_in = original_cold_stream.T - T_hot_in = get_T_transient_hot_side(hot) - if (Q_hot_side[cold][0]=='heat' and Q_hot_side[cold][1]>0 and - T_hot_in - T_cold_in>= T_min_app): - cold_stream = original_cold_stream - hot_stream = streams_transient_hot_side[hot].copy() - cold_stream.ID = 's_%s__%s'%(cold,ID) - hot_stream.ID = 's_%s__%s'%(hot,ID) - hot_out = hot_stream.copy('%s__s_%s'%(ID,hot)) - cold_out = cold_stream.copy('%s__s_%s'%(ID,cold)) - H_lim = H_out_arr[cold] - new_HX = bst.units.HXprocess(ID = ID, ins = (cold_stream, hot_stream), - outs = (cold_out, hot_out), H_lim0 = H_lim, - T_lim1 = T_out_arr[hot], dT = T_min_app, - thermo = hot_stream.thermo) - try: new_HX._run() - except: continue - if abs(new_HX.Q )< Qmin: continue - success.add(match) - HXs_hot_side.append(new_HX) - stream_HXs_dict[hot].append(new_HX) - stream_HXs_dict[cold].append(new_HX) - Q_hot_side[hot][1] -= new_HX.Q - Q_hot_side[cold][1] -= new_HX.Q - streams_transient_hot_side[cold] = new_HX.outs[0] - streams_transient_hot_side[hot] = new_HX.outs[1] - H_out = new_HX.outs[0].H - assert H_out - new_HX.ins[0].H >= 0. - matches_hs[cold].append(hot) - - # Add final utility HXs - new_HX_utils = [] - for hot in hot_indices: - hot_stream = get_stream_at_H_min(hot) - ID = 'Util_%s_cs'%(hot) - hot_stream.ID = 's_%s__%s'%(hot,ID) - outlet = hot_stream.copy('%s__s_%s'%(ID,hot)) - new_HX_util = bst.units.HXutility(ID = ID, ins = hot_stream, outs = outlet, - H = H_out_arr[hot], rigorous = True, - thermo = hot_stream.thermo) - new_HX_util._run() - s_out = new_HX_util-0 - np.testing.assert_allclose(s_out.H, H_out_arr[hot], rtol=5e-3, atol=1.) - atol_T = 5. if 's' in hxs[hot].outs[0].phases else 0.001 - np.testing.assert_allclose(s_out.T, T_out_arr[hot], rtol=5e-3, atol=atol_T) - new_HX_utils.append(new_HX_util) - stream_HXs_dict[hot].append(new_HX_util) - - for cold in cold_indices: - cold_stream = get_stream_at_H_max(cold) - ID = 'Util_%s_hs'%(cold) - cold_stream.ID = 's_%s__%s'%(cold,ID) - outlet = cold_stream.copy('%s__s_%s'%(ID,cold)) - new_HX_util = bst.units.HXutility(ID = ID, ins = cold_stream, outs = outlet, - H = H_out_arr[cold], rigorous = True, - thermo = cold_stream.thermo) + if (not violations or plan.status != 'mer' + or refine_round == _MAX_REFINE): break + knots = _refine_knots(knots, violations) + repaired = [] + if bad: + duties, changes = _repair(plan, duties, knots, is_hot, curves, + T_min_app) + for n, Q_before, Q_after in changes: + e = plan.exchangers[n] + repaired.append(dict(side=e.side, hot=e.hot, cold=e.cold, + Q_plan=Q_before, Q=Q_after)) + 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] + # Realize; a match that cannot be simulated is dropped (its duty goes to + # the utilities: removing a match never reduces another's approach). + dropped = [] + while True: + try: + units, first, last = _realize(plan, duties, curves, knots, + streams_inlet, is_hot, T_min_app) + except _RealizationError as failure: + dropped.append(dict(ID=failure.ID, Q=duties.pop(failure.n), + error=repr(failure.error))) + else: + break + HXs_hot_side = [units[n] for n in sorted(units) + if plan.exchangers[n].side == 'above'] + HXs_cold_side = [units[n] for n in sorted(units) + if plan.exchangers[n].side == 'below'] + deviations = [] + for n, hx in units.items(): + e = plan.exchangers[n] + if abs(hx.Q - duties[n]) > _DUTY_TOL * (duty[e.hot] + duty[e.cold]): + deviations.append(dict(ID=hx.ID, Q_plan=duties[n], Q=hx.Q)) + # One rigorous utility per stream, hot streams first + stream_HXs_dict = {i: [units[n] for n in plan.stages[i] if n in units] + for i in indices} + new_HX_utils = [] + for i in hot_indices + cold_indices: + hot = is_hot[i] + curve = curves[i] + ID = 'Util_%s_cs'%i if hot else 'Util_%s_hs'%i + if first[i] is None: + s = _copy(streams_inlet[i]) + else: + s = curve.state_at_H(curve.H_lo + last[i]) + s.ID = 's_%s__%s'%(i, ID) + outlet = s.copy('%s__s_%s'%(ID, i)) + new_HX_util = bst.units.HXutility(ID=ID, ins=s, outs=outlet, + H=H_out_arr[i], rigorous=True, + thermo=s.thermo) new_HX_util._run() s_out = new_HX_util.outs[0] - np.testing.assert_allclose(s_out.H, H_out_arr[cold], rtol=1e-2, atol=1.) - atol_T = 5. if 's' in hxs[cold].outs[0].phases else 0.001 - np.testing.assert_allclose(s_out.T, T_out_arr[cold], rtol=5e-2, atol=atol_T) + atol_T = 5. if 's' in hxs[i].outs[0].phases else 0.001 + if hot: + np.testing.assert_allclose(s_out.H, H_out_arr[i], rtol=5e-3, atol=1.) + np.testing.assert_allclose(s_out.T, T_out_arr[i], rtol=5e-3, atol=atol_T) + else: + np.testing.assert_allclose(s_out.H, H_out_arr[i], rtol=1e-2, atol=1.) + np.testing.assert_allclose(s_out.T, T_out_arr[i], rtol=5e-2, atol=atol_T) new_HX_utils.append(new_HX_util) - stream_HXs_dict[cold].append(new_HX_util) - + stream_HXs_dict[i].append(new_HX_util) + if info is not None: + # utilities of the plan and of the realized network: each stream's + # duty less what its process exchangers transfer + planned = {n: duties[n] for n in units} + simulated = {n: hx.Q for n, hx in units.items()} + loads = [] + for Q_of in (planned, simulated): + Q_hot = Q_cold = 0. + for i in indices: + remaining = duty[i] - sum(Q_of[n] for n in plan.stages[i] + if n in units) + if is_hot[i]: Q_cold += remaining + else: Q_hot += remaining + loads.append((Q_hot, Q_cold)) + (Q_hot_plan, Q_cold_plan), (Q_hot, Q_cold) = loads + tol = _ACHIEVED_TOL * scale + mer = (plan.status == 'mer' + and abs(Q_hot - table.hot_util_load) <= tol + and abs(Q_cold - table.cold_util_load) <= tol) + info.update( + status='mer' if mer else 'best_effort', + Q_hot_target=table.hot_util_load, + Q_cold_target=table.cold_util_load, + Q_hot_plan=Q_hot_plan, Q_cold_plan=Q_cold_plan, + Q_hot=Q_hot, Q_cold=Q_cold, + penalty=Q_hot_plan - table.hot_util_load, + sides=plan.info['sides'], + plan_targets=dict(Q_hot=plan_targets['Qh'], + Q_cold=plan_targets['Qc'], + pinch_T=plan_targets['pinch_T'], + cut=plan_targets['cut']), + refine_rounds=refine_round, + min_approach=min_approach if duties else None, + deviations=deviations, + qmin_dropped=plan.info['qmin_dropped'], + dropped=dropped, repaired=repaired, + point_loads=[i for i in indices if not curves[i].monotone], + ) return HXs_hot_side, HXs_cold_side, new_HX_utils, hxs, T_in_arr,\ T_out_arr, pinch_T_arr, C_flow_vector, hx_utils_rearranged, streams_inlet, stream_HXs_dict,\ hot_indices, cold_indices - # Pinch diagram def _order_exchanger_columns(hxs, stream_life_cycles): diff --git a/tests/hxn_mer_cases.py b/tests/hxn_mer_cases.py new file mode 100644 index 0000000..018c226 --- /dev/null +++ b/tests/hxn_mer_cases.py @@ -0,0 +1,3025 @@ +# -*- 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. +""" +Data of the minimum-energy-requirement (MER) corpus used by +``test_hxn_mer.py``. Data only: every check lives in the test module. + +``NO_SPLIT`` holds problems for which an *unsplit* network reaching the MER +targets is known to exist (each carries a certificate network); ``SPLIT`` +holds problems for which the pinch design rules *prove* that MER needs stream +splitting (each carries the proof). Both lists mix two kinds of case: + +``kind='constant_cp'`` (literature problems) + ``streams`` are ``(name, 'hot' | 'cold', T_in, T_out, CP)`` in the + SOURCE units: temperatures in ``T_unit`` ('C', 'F' or 'K') and heat + capacity flow rates in ``Q_unit`` per ``T_unit`` degree (``Q_UNITS`` + converts the heat-rate unit to kW, ``T_UNITS`` the temperatures to + kelvin). ``targets`` are the problem-table targets in source units + (hensmith convention: hot streams shifted down by ``dTmin``; + ``pinch_cold`` is the shifted pinch, ``pinch_hot = pinch_cold + dTmin``; + the first cascade minimum, or the top of the grid when ``Q_hot == 0``), + computed by the independent oracle of the benchmark (see the test + module). ``published`` holds the values printed in the source as + ``(value, absolute tolerance)`` in source units (``None`` where the + source gives none): one unit in the last printed digit of a utility + target unless ``note`` says otherwise, zero for a zero target and for a + pinch temperature (always a stream end temperature). ``certificate`` + (NO_SPLIT) is a verified unsplit MER network: + ``matches`` are ``(side, stage, hot, cold, Q, T_hot_in, T_hot_out, + T_cold_in, T_cold_out)`` in source units, hot streams meet their matches + in increasing stage and cold streams in decreasing stage, and each stream + has at most one utility at its far end (``hot_utility`` on cold streams, + ``cold_utility`` on hot streams). ``needs_repeated_pair`` is True when + no unsplit MER network exists without matching some pair twice on one + side (complete search), False when one does, None if unknown. + ``proof`` (SPLIT) is the first pinch-rule violation (top pinch first, + above before below): ``rule`` 'number' (more "must" streams at the pinch + than partners) or 'cp' (no one-to-one CP-feasible pinch matching), with + the streams at the pinch on that side. + +``kind='real_thermo'`` (water / ethanol / methanol and their binaries) + ``streams`` are ``(ID, {chemical: kmol/hr}, T_in, T_out, P, phase_in, + rigorous)`` with T in K or 'bubble' / 'dew'; each is built as a simulated + ``bst.HXutility(ID, ins=inlet, T=T_out, rigorous=rigorous)`` (``V=0`` / + ``V=1`` with ``rigorous=True`` for 'bubble' / 'dew' outlets; a 'bubble' / + 'dew' inlet is the saturated state at ``P``). ``reference`` holds MER + targets [kJ/hr] and the shifted pinch [K] (None: no hot utility) from an + independent dense-grid calculator (``grid_h`` K single-phase grid, ``nV`` + two-phase states traced along the bubble curve; accuracy about 0.2 + kJ/hr). ``certificate`` (NO_SPLIT) lists process matches with their duty + [kJ/hr], side of the pinch and 1-based position in FLOW order along each + stream (``hot_seq`` / ``cold_seq``); every stream then gets at most one + utility at its far end, and ``certificate_utilities`` are that network's + simulated utilities. ``proof`` (SPLIT) is a pinch number-rule violation + at the reference pinch; no stream has an end temperature or phase change + within 0.5 K of that pinch except the supply temperature that creates it. + ``short_of_tickoff`` (rtA02) names the streams of a proof that every + unsplit MER network needs a pinch match that stops short of tick-off and + a repeated pair. + +Provenance: the constant-CP problems, their targets, certificates and proofs +come from a certified benchmark built for the hensmith MER synthesizer +(stream data fetched from the cited sources; labels from an independent +MILP/pinch-rule oracle; certificates re-verified by plain arithmetic); the +real-thermodynamics problems were designed for hensmith, their certificates +simulated with biosteam ``HXprocess`` units and verified at 101+ points per +exchanger (internal approach >= T_min_app - 1e-6 K), and their split proofs +re-derived independently. +""" + +__all__ = ('NO_SPLIT', 'SPLIT', 'Q_UNITS', 'T_UNITS') + +#: Source heat-rate unit -> kW ('unspecified': the source gives no unit; read +#: as kW). International Table Btu (1055.05585262 J). +Q_UNITS = { + 'kW': 1., + 'MW': 1e3, + 'Btu/hr': 1.05505585262 / 3600., + '1e3 Btu/hr': 1.05505585262 / 3.6, + 'unspecified': 1., +} + +#: Source temperature unit -> (kelvin per degree, offset): T_K = (T + offset) * scale. +T_UNITS = { + 'K': (1., 0.), + 'C': (1., 273.15), + 'F': (5. / 9., 459.67), +} + +NO_SPLIT = [ + # ----- constant-CP literature problems ----- + dict( + name='4sp1_lee1970_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '4sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Lee, Masso & Rudd (1970), Ind. Eng. Chem. Fundam. 9, 48 (4SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/4sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/4sp1.dat)' + ), + T_unit='F', Q_unit='1e3 Btu/hr', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 320.0, 200.0, 16.67), + ('H2', 'hot', 480.0, 280.0, 20.0), + ('C1', 'cold', 140.0, 320.0, 14.45), + ('C2', 'cold', 240.0, 500.0, 11.53), + ], + targets=dict(Q_hot=345.9, Q_cold=747.5000000000003, pinch_hot=480.0, pinch_cold=470.0), + published=dict(Q_hot=(345.9, 0.1), Q_cold=(747.5, 0.1), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H2', 'C2', 2651.9, 480.0, 347.405, 240.0, 470.0), + ('below', 2, 'H2', 'C1', 600.5999999999999, + 347.405, 317.375, 278.4359861591696, 320.00000000000006), + ('below', 3, 'H1', 'C1', 2000.4, 320.0, 200.0, 140.0, 278.4359861591696), + ], + hot_utility={'C2': 345.9}, + cold_utility={'H2': 747.5}, + ), + ), + dict( + name='4sp2_dt20F', + kind='constant_cp', + citation=( + 'Problem 4SP2 (Lee, Masso & Rudd 1970 test-problem family) as tabulated in Muraki, M.; ' + 'Hayakawa, T. (1982). Practical synthesis method for heat exchanger network. J. Chem. ' + 'Eng. Japan 15(2), 136-141, Table 1 (stream data; design data: minimum allowable approach' + ' temperature 20 F for exchangers).' + ), + source_url='https://www.jstage.jst.go.jp/article/jcej1968/15/2/15_2_136/_pdf/-char/ja', + T_unit='F', Q_unit='Btu/hr', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 500, 110, 20000), + ('H2', 'hot', 430, 230, 50000), + ('H3', 'hot', 400, 110, 30000), + ('C1', 'cold', 25, 420, 70000), + ], + targets=dict(Q_hot=1150000.0, Q_cold=0.0, pinch_hot=45.0, pinch_cold=25.0), + published=None, + needs_repeated_pair=True, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H1', 'C1', 2140000.0000000014, + 500.0, 392.99999999999994, 372.99999999999994, 403.57142857142856), + ('above', 2, 'H2', 'C1', 6474999.999999996, + 430.0, 300.5000000000001, 280.5, 372.99999999999994), + ('above', 3, 'H1', 'C1', 1409999.9999999977, + 392.99999999999994, 322.50000000000006, 260.3571428571429, 280.5), + ('above', 4, 'H2', 'C1', 3525000.0000000033, + 300.5000000000001, 230.00000000000006, 210.0, 260.3571428571429), + ('above', 5, 'H3', 'C1', 8700000.0, 400.0, 110.0, 85.71428571428572, 210.0), + ('above', 6, 'H1', 'C1', 4250000.000000001, + 322.50000000000006, 110.0, 25.0, 85.71428571428572), + ], + hot_utility={'C1': 1150000.0000000012}, + cold_utility={}, + ), + ), + dict( + name='linnhoff_4stream', + kind='constant_cp', + citation=( + 'Linnhoff, B. & Hindmarsh, E. (1983). The pinch design method for heat exchanger ' + 'networks. Chem. Eng. Sci. 38, 745-763 (four-stream example); also Kemp, I.C. (2007). ' + 'Pinch Analysis and Process Integration, 2nd ed., Butterworth-Heinemann.' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('C1', 'cold', 20, 135, 2), + ('H2', 'hot', 170, 60, 3), + ('C3', 'cold', 80, 140, 4), + ('H4', 'hot', 150, 30, 1.5), + ], + targets=dict(Q_hot=20.0, Q_cold=60.0, pinch_hot=90.0, pinch_cold=80.0), + published=dict(Q_hot=(20, 1.0), Q_cold=(60, 1.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H2', 'C3', 240.0, 170.0, 90.0, 80.0, 140.0), + ('above', 1, 'H4', 'C1', 90.0, 150.0, 90.0, 80.0, 125.0), + ('below', 2, 'H2', 'C1', 30.0, 90.0, 80.0, 65.0, 80.0), + ('below', 3, 'H4', 'C1', 90.0, 90.0, 30.0, 20.0, 65.0), + ], + hot_utility={'C1': 20.0}, + cold_utility={'H2': 60.0}, + ), + ), + dict( + name='nptel_t3_10_threshold', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 3 Lecture 8, Table 3.10 (Example 02).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module3/lecture5/lecture5' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('Hot-1', 'hot', 350, 290, 3.5), + ('Hot-2', 'hot', 400, 290, 1.8), + ('Cold-1', 'cold', 150, 350, 2.0), + ('Cold-2', 'cold', 290, 400, 2.5), + ], + targets=dict(Q_hot=267.0, Q_cold=0.0, pinch_hot=160.0, pinch_cold=150.0), + published=dict(Q_hot=(267, 1.0), Q_cold=(0.0, 0.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'Hot-1', 'Cold-1', 210.0, 350.0, 290.0, 159.0, 264.0), + ('above', 1, 'Hot-2', 'Cold-2', 180.0, 400.0, 300.0, 290.0, 362.0), + ('above', 2, 'Hot-2', 'Cold-1', 18.0, 300.0, 290.0, 150.0, 159.0), + ], + hot_utility={'Cold-1': 172.0, 'Cold-2': 95.0}, + cold_utility={}, + ), + ), + dict( + name='nptel_t3_9_threshold_split_shown', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 3 Lecture 8, Table 3.9 = Module 5 Lecture 29, Table 5.6 (design Fig. 5.30).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module5/lecture4/lecture4' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('Hot-1', 'hot', 190, 55, 3.5), + ('Hot-2', 'hot', 155, 40, 1.8), + ('Cold-1', 'cold', 20, 140, 2.0), + ('Cold-2', 'cold', 70, 150, 2.5), + ], + targets=dict(Q_hot=0.0, Q_cold=239.5, pinch_hot=190.0, pinch_cold=180.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(239.5, 0.1), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'Hot-1', 'Cold-2', 65.0, 190.0, 171.42857142857142, 124.0, 150.0), + ('below', 2, 'Hot-1', 'Cold-1', 240.0, + 171.42857142857142, 102.85714285714285, 20.0, 140.0), + ('below', 2, 'Hot-2', 'Cold-2', 135.0, 155.0, 80.0, 70.0, 124.0), + ], + hot_utility={}, + cold_utility={'Hot-1': 167.49999999999997, 'Hot-2': 72.0}, + ), + ), + dict( + name='nptel_t5_5_threshold_dT5', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 5 Lecture 29, Table 5.5 (designs Figs. 5.27-5.28).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module5/lecture4/lecture4' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=5, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('Hot-1', 'hot', 180, 60, 3.0), + ('Hot-2', 'hot', 140, 30, 1.5), + ('Cold-1', 'cold', 20, 135, 2.0), + ('Cold-2', 'cold', 80, 140, 4.0), + ], + targets=dict(Q_hot=0.0, Q_cold=55.0, pinch_hot=180.0, pinch_cold=175.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(55, 1.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=True, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'Hot-1', 'Cold-1', 45.0, 180.0, 165.0, 112.5, 135.0), + ('below', 2, 'Hot-1', 'Cold-2', 240.0, 165.0, 85.0, 80.0, 140.0), + ('below', 2, 'Hot-2', 'Cold-1', 135.0, 140.0, 50.0, 45.0, 112.5), + ('below', 3, 'Hot-1', 'Cold-1', 50.0, 85.0, 68.33333333333333, 20.0, 45.0), + ], + hot_utility={}, + cold_utility={'Hot-1': 24.999999999999986, 'Hot-2': 30.0}, + ), + ), + dict( + name='smith2005_exr18_9_threshold', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Exercise 9, ' + 'Tables 18.17 and 18.18.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 500, 100, 4.0), + ('2', 'cold', 50, 450, 1.0), + ('3', 'cold', 60, 400, 1.0), + ('4', 'cold', 40, 420, 0.75), + ], + targets=dict(Q_hot=0.0, Q_cold=575.0, pinch_hot=500.0, pinch_cold=480.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(575.0, 1.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=True, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, '1', '3', 51.25, 500.0, 487.1875, 348.75, 400.0), + ('below', 2, '1', '2', 188.75, 487.1875, 440.0, 261.25, 450.0), + ('below', 3, '1', '4', 285.0, 440.0, 368.75, 40.0, 420.0), + ('below', 4, '1', '3', 288.75, 368.75, 296.5625, 60.0, 348.75), + ('below', 5, '1', '2', 211.25, 296.5625, 243.75, 50.0, 261.25), + ], + hot_utility={}, + cold_utility={'1': 575.0}, + ), + ), + dict( + name='turton_shaeiwitz2012_example1', + kind='constant_cp', + citation=( + "Turton R, Shaeiwitz J (2012). 'Pinch Technology / Heat Exchange Networks' lecture notes " + '(West Virginia University), first example (as reproduced on notes.edubirdie.com).' + ), + source_url=( + 'https://notes.edubirdie.com/docs/kentucky-state-university/che-102-general-chemistry-i/1' + '06468-pinch-technology-heat-exchange-networks' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 200, 120, 3.0), + ('2', 'hot', 140, 100, 5.0), + ('3', 'cold', 100, 170, 3.0), + ('4', 'cold', 110, 190, 2.0), + ], + targets=dict(Q_hot=60.0, Q_cold=130.0, pinch_hot=140.0, pinch_cold=130.0), + published=dict(Q_hot=(60, 1.0), Q_cold=(130, 1.0), pinch_hot=(140, 0.0), pinch_cold=(130, 0.0)), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, '1', '4', 60.0, 200.0, 180.0, 130.0, 160.0), + ('above', 2, '1', '3', 120.0, 180.0, 140.0, 130.0, 170.0), + ('below', 3, '1', '4', 40.0, 140.0, 126.66666666666667, 110.0, 130.0), + ('below', 3, '2', '3', 90.0, 140.0, 122.0, 100.0, 130.0), + ], + hot_utility={'4': 60.0}, + cold_utility={'1': 20.000000000000014, '2': 110.0}, + ), + ), + dict( + name='turton_shaeiwitz2012_inclass', + kind='constant_cp', + citation=( + "Turton R, Shaeiwitz J (2012). 'Pinch Technology / Heat Exchange Networks' lecture notes " + '(West Virginia University), in-class example (as reproduced on notes.edubirdie.com).' + ), + source_url=( + 'https://notes.edubirdie.com/docs/kentucky-state-university/che-102-general-chemistry-i/1' + '06468-pinch-technology-heat-exchange-networks' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 250, 100, 1.0), + ('2', 'hot', 280, 120, 4.0), + ('3', 'cold', 100, 200, 2.0), + ('4', 'cold', 120, 230, 5.0), + ], + targets=dict(Q_hot=40.0, Q_cold=80.0, pinch_hot=140.0, pinch_cold=120.0), + published=dict(Q_hot=(40, 1.0), Q_cold=(80, 1.0), pinch_hot=(140, 0.0), pinch_cold=(120, 0.0)), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, '2', '3', 10.0, 280.0, 277.5, 175.0, 180.0), + ('above', 2, '1', '3', 110.0, 250.0, 140.0, 120.0, 175.0), + ('above', 2, '2', '4', 550.0, 277.5, 140.0, 120.0, 230.0), + ('below', 3, '2', '3', 40.0, 140.0, 130.0, 100.0, 120.0), + ], + hot_utility={'3': 40.0}, + cold_utility={'1': 40.0, '2': 40.0}, + ), + ), + dict( + name='5sp1_dt20F', + kind='constant_cp', + citation=( + 'Problem 5SP1 (Lee, Masso & Rudd 1970) as tabulated in Muraki, M.; Hayakawa, T. (1982). ' + 'Practical synthesis method for heat exchanger network. J. Chem. Eng. Japan 15(2), ' + '136-141, Table 1 (stream data; design data: minimum allowable approach temperature 20 F ' + "for exchangers); targets from Muraki & Hayakawa (1982) Table 3 'Problem table for 5SP1'." + ), + source_url='https://www.jstage.jst.go.jp/article/jcej1968/15/2/15_2_136/_pdf/-char/ja', + T_unit='F', Q_unit='Btu/hr', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 480, 250, 31500), + ('H2', 'hot', 400, 150, 25200), + ('C1', 'cold', 200, 400, 24700), + ('C2', 'cold', 100, 400, 21600), + ('C3', 'cold', 150, 360, 24500), + ], + targets=dict(Q_hot=3020000.0, Q_cold=0.0, pinch_hot=120.0, pinch_cold=100.0), + published=dict(Q_hot=(3020000.0, 1.0), Q_cold=(0.0, 0.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H1', 'C3', 5145000.0, 480.0, 316.66666666666663, 150.0, 360.0), + ('above', 1, 'H2', 'C1', 4446000.0, 400.0, 223.57142857142858, 200.0, 380.0), + ('above', 2, 'H1', 'C2', 2100000.0, + 316.66666666666663, 249.99999999999994, 185.83333333333331, 283.05555555555554), + ('above', 3, 'H2', 'C2', 1854000.0, + 223.57142857142858, 150.0, 100.0, 185.83333333333331), + ], + hot_utility={'C1': 494000.0, 'C2': 2526000.0000000005}, + cold_utility={}, + ), + ), + dict( + name='smith2005_exr18_1', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Exercise 1, ' + 'Table 18.6.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 200, 100, 0.4), + ('2', 'hot', 200, 100, 0.2), + ('3', 'hot', 150, 60, 1.2), + ('4', 'cold', 50, 140, 1.1), + ('5', 'cold', 80, 120, 2.4), + ], + targets=dict(Q_hot=30.0, Q_cold=3.0, pinch_hot=90.0, pinch_cold=80.0), + published=dict(Q_hot=None, Q_cold=None, pinch_hot=(90, 0.0), pinch_cold=(80, 0.0)), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, '2', '4', 9.0, 200.0, 155.0, 126.36363636363636, 134.54545454545453), + ('above', 1, '3', '5', 72.0, 150.0, 90.0, 80.0, 110.0), + ('above', 2, '1', '4', 40.0, 200.0, 100.0, 90.0, 126.36363636363636), + ('above', 3, '2', '4', 11.0, 155.0, 100.0, 80.0, 90.0), + ('below', 4, '3', '4', 33.0, 90.0, 62.5, 50.0, 80.0), + ], + hot_utility={'4': 6.000000000000015, '5': 24.0}, + cold_utility={'3': 3.0}, + ), + ), + dict( + name='smith2005_exr17_2_dT10', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 17, Exercise 2, ' + 'Table 17.8 (dTmin = 10 C row).' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2017.pdf', + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 160, 42, 75.0), + ('2', 'hot', 100, 90, 1600.0), + ('3', 'hot', 75, 40, 600.0), + ('4', 'cold', 25, 140, 35.0), + ('5', 'cold', 25, 79, 605.0), + ('6', 'cold', 80, 150, 165.0), + ], + targets=dict(Q_hot=7150.0, Q_cold=4755.0, pinch_hot=100.0, pinch_cold=90.0), + published=dict(Q_hot=(7150.0, 1.0), Q_cold=(4755.0, 1.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, '1', '6', 4500.0, 160.0, 100.0, 90.0, 117.27272727272728), + ('below', 2, '1', '4', 2275.0, 100.0, 69.66666666666667, 25.0, 90.0), + ('below', 2, '2', '6', 1650.0, 100.0, 98.96875, 80.0, 90.0), + ('below', 3, '2', '5', 11670.0, 98.96875, 91.675, 59.710743801652896, 79.0), + ('below', 4, '3', '5', 21000.0, 75.0, 40.0, 25.0, 59.710743801652896), + ], + hot_utility={'4': 1750.0, '6': 5399.999999999999}, + cold_utility={'1': 2075.0000000000005, '2': 2679.9999999999955}, + ), + ), + dict( + name='7sp1_masso1969_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '7sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Masso & Rudd (1969), AIChE J. 15, 10 (7SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/7sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/7sp1.dat)' + ), + T_unit='F', Q_unit='1e3 Btu/hr', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 440.0, 150.0, 28.0), + ('H2', 'hot', 520.0, 300.0, 23.8), + ('H3', 'hot', 390.0, 150.0, 33.6), + ('C1', 'cold', 100.0, 430.0, 16.0), + ('C2', 'cold', 180.0, 350.0, 32.76), + ('C3', 'cold', 200.0, 400.0, 26.35), + ('C4', 'cold', 350.0, 410.0, 19.84), + ], + targets=dict(Q_hot=0.0, Q_cold=4110.4, pinch_hot=520.0, pinch_cold=510.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(4110.4, 0.1), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H1', 'C3', 5270.0, 440.0, 251.78571428571428, 200.0, 400.0), + ('below', 1, 'H2', 'C4', 1190.4, 520.0, 469.98319327731093, 350.0, 410.0), + ('below', 1, 'H3', 'C2', 5569.2, 390.0, 224.25, 180.0, 350.0), + ('below', 2, 'H2', 'C1', 3011.428571428571, + 469.98319327731093, 343.452581032413, 241.7857142857143, 430.0), + ('below', 3, 'H1', 'C1', 2268.571428571429, + 251.78571428571428, 170.76530612244898, 100.0, 241.7857142857143), + ], + hot_utility={}, + cold_utility={'H1': 581.4285714285713, 'H2': 1034.171428571429, 'H3': 2494.8}, + ), + ), + dict( + name='smith2005_ex16_1_low_T_distillation', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 16, Example 16.1, ' + 'Table 16.4 (low-temperature distillation process).' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2016.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=5, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1_feed', 'hot', 20, 0, 0.04), + ('2_col1_cond', 'hot', -19, -20, 1.2), + ('3_col2_cond', 'hot', -39, -40, 0.8), + ('4_col1_reb', 'cold', 19, 20, 1.2), + ('5_col2_reb', 'cold', -1, 0, 0.8), + ('6_col2_bottoms', 'cold', 0, 20, 0.01), + ('7_col2_overheads', 'cold', -40, 20, 0.01), + ], + targets=dict(Q_hot=1.8399999999999999, Q_cold=1.8399999999999999, pinch_hot=-19.0, pinch_cold=-24.0), + published=dict(Q_hot=(1.84, 0.01), Q_cold=(1.84, 0.01), pinch_hot=(-19, 0.0), pinch_cold=(-24, 0.0)), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, '1_feed', '5_col2_reb', 0.64, 20.0, 4.0, -1.0, -0.20000000000000007), + ('above', 2, '1_feed', '7_col2_overheads', 0.16000000000000003, + 4.0, -8.881784197001252e-16, -24.0, -7.9999999999999964), + ('below', 3, '2_col1_cond', '7_col2_overheads', 0.16, + -19.0, -19.133333333333333, -40.0, -24.0), + ], + hot_utility={'4_col1_reb': 1.2, '5_col2_reb': 0.16000000000000006, '6_col2_bottoms': 0.2, '7_col2_overheads': 0.27999999999999997}, + cold_utility={'2_col1_cond': 1.0400000000000005, '3_col2_cond': 0.8}, + ), + ), + dict( + name='10sp1_pho1973_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '10sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Pho & Lapidus (1973), AIChE J. 19, 1182 (10SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/10sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/10sp1.dat)' + ), + T_unit='F', Q_unit='Btu/hr', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 320.0, 200.0, 16670.0), + ('H2', 'hot', 480.0, 280.0, 20000.0), + ('H3', 'hot', 440.0, 150.0, 28000.0), + ('H4', 'hot', 520.0, 300.0, 23800.0), + ('H5', 'hot', 390.0, 150.0, 33600.0), + ('C1', 'cold', 140.0, 320.0, 14450.0), + ('C2', 'cold', 240.0, 431.0, 11530.0), + ('C3', 'cold', 100.0, 430.0, 16000.0), + ('C4', 'cold', 180.0, 350.0, 32760.0), + ('C5', 'cold', 200.0, 400.0, 26350.0), + ], + targets=dict(Q_hot=0.0, Q_cold=6497970.0, pinch_hot=520.0, pinch_cold=510.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(6497970.0, 1.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H2', 'C3', 4000000.0, 480.0, 280.0, 180.0, 430.0), + ('below', 1, 'H3', 'C5', 5270000.0, 440.0, 251.78571428571428, 200.0, 400.0), + ('below', 1, 'H4', 'C2', 2202230.0, 520.0, 427.46932773109245, 240.0, 431.0), + ('below', 1, 'H5', 'C4', 5569200.0, 390.0, 224.25, 180.0, 350.0), + ('below', 2, 'H3', 'C3', 1280000.0, + 251.78571428571428, 206.07142857142856, 100.0, 180.0), + ('below', 2, 'H4', 'C1', 2601000.0, + 427.46932773109245, 318.18361344537817, 140.0, 320.0), + ], + hot_utility={}, + cold_utility={'H1': 2000400.0, 'H3': 1569999.9999999995, 'H4': 432770.00000000047, 'H5': 2494800.0}, + ), + ), + dict( + name='nitric_acid_ph6c5', + kind='constant_cp', + citation=( + 'Caballero, J.A., Pavao, L.V., Costa, C.B.B. & Ravagnani, M.A.S.S. (2021). A Novel ' + 'Sequential Approach for the Design of Heat Exchanger Networks. Front. Chem. Eng. ' + '3:733186, Supplementary Data Sheet 1, Table S19 (Example 10, Nitric Acid Plant PH6C5); ' + 'original problem: Castillo, E., Acevedo, L. & Reverberi, A.P. (1998). Cleaner production' + ' of nitric acid by heat transfer optimization: a case study. Chem. Biochem. Eng. Q. 12, ' + '157-165.' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/733186/file/Data_Sheet_1.docx/7' + '33186_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 840.0, 40.0, 4.9894), + ('H2', 'hot', 76.0, 45.0, 4.684), + ('H3', 'hot', 50.0, 40.0, 0.772), + ('H4', 'hot', 180.0, 77.0, 0.6097), + ('H5', 'hot', 180.0, 179.0, 292.7), + ('H6', 'hot', 90.0, 45.0, 3.066), + ('C1', 'cold', 24.0, 25.0, 329.8), + ('C2', 'cold', 25.0, 70.0, 0.5383), + ('C3', 'cold', 35.0, 122.0, 3.727), + ('C4', 'cold', 90.0, 180.0, 0.6097), + ('C5', 'cold', 180.0, 181.0, 2581.1), + ], + targets=dict(Q_hot=0.0, Q_cold=1323.6676, pinch_hot=840.0, pinch_cold=830.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(1323.67, 0.01), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H1', 'C1', 329.8, 840.0, 773.8998677195655, 24.0, 25.0), + ('below', 1, 'H4', 'C2', 24.2235, 180.0, 140.26980482204362, 25.0, 70.0), + ('below', 2, 'H1', 'C5', 2581.1, + 773.8998677195655, 256.5831562913377, 180.0, 181.0), + ('below', 3, 'H1', 'C3', 324.24899999999997, + 256.5831562913377, 191.59558263518664, 35.0, 122.0), + ('below', 4, 'H1', 'C4', 54.873000000000005, + 191.59558263518664, 180.59766705415487, 90.0, 180.0), + ], + hot_utility={}, + cold_utility={'H1': 701.4980000000003, 'H2': 145.204, 'H3': 7.720000000000001, 'H4': 38.5756, 'H5': 292.7, 'H6': 137.97}, + ), + ), + dict( + name='12sp1_sargent1978_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '12sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Grossmann & Sargent (1978), Comput. Chem. Eng. 2, 1 (12SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/12sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/12sp1.dat)' + ), + T_unit='F', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 668.0, 200.0, 402.678), + ('H2', 'hot', 596.0, 258.0, 222.318), + ('H3', 'hot', 434.0, 240.0, 341.26), + ('H4', 'hot', 301.0, 220.0, 553.66), + ('H5', 'hot', 490.0, 150.0, 37.725), + ('H6', 'hot', 460.0, 415.0, 90.645), + ('H7', 'hot', 405.0, 150.0, 79.546), + ('H8', 'hot', 323.0, 290.0, 92.034), + ('H9', 'hot', 290.0, 150.0, 85.303), + ('C1', 'cold', 40.0, 240.0, 620.198), + ('C2', 'cold', 240.0, 695.0, 822.659), + ('C3', 'cold', 108.0, 354.0, 138.299), + ], + targets=dict(Q_hot=105554.014, Q_cold=0.0, pinch_hot=50.0, pinch_cold=40.0), + published=dict(Q_hot=(105554.014, 0.001), Q_cold=(0.0, 0.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=True, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H1', 'C2', 72020.90585006942, + 668.0, 489.145168471907, 479.145168471907, 566.6916559595167), + ('above', 1, 'H6', 'C1', 4079.0249999999996, + 460.0, 415.0, 233.42302780724864, 240.0), + ('above', 2, 'H2', 'C2', 32552.971632738256, + 596.0, 449.57473694105624, 439.57473694105613, 479.145168471907), + ('above', 2, 'H8', 'C1', 3037.1220000000003, + 323.0, 290.0, 228.52600782330802, 233.42302780724864), + ('above', 3, 'H1', 'C2', 31211.806036767786, + 489.145168471907, 411.6345867247845, 401.6345867247845, 439.57473694105613), + ('above', 3, 'H4', 'C1', 44846.46, + 301.0, 220.0, 156.21610034214876, 228.52600782330802), + ('above', 4, 'H3', 'C2', 13042.984662183188, + 434.0, 395.77991952709607, 385.77991952709607, 401.6345867247845), + ('above', 4, 'H5', 'C1', 12826.5, + 490.0, 150.0, 135.534801789106, 156.21610034214876), + ('above', 5, 'H2', 'C2', 16388.413516000743, + 449.57473694105624, 375.85864775349273, 365.85864775349273, 385.77991952709607), + ('above', 6, 'H2', 'C3', 22079.134000000005, + 375.85864775349273, 276.545348785348, 194.35217897454066, 354.0), + ('above', 6, 'H3', 'C2', 10049.229695092426, + 395.77991952709607, 366.33249030863385, 353.6431007345062, 365.85864775349273), + ('above', 7, 'H1', 'C2', 37854.046422945736, + 411.6345867247845, 317.62883914745044, 307.62883914745044, 353.6431007345062), + ('above', 7, 'H2', 'C1', 4122.964851261007, + 276.545348785348, 257.99999999999994, 128.88698149419858, 135.534801789106), + ('above', 7, 'H9', 'C3', 11942.42, 290.0, 150.0, 108.0, 194.35217897454066), + ('above', 8, 'H3', 'C2', 28402.827493985387, + 366.33249030863385, 283.1032003420823, 273.10320034208223, 307.62883914745044), + ('above', 8, 'H7', 'C1', 20284.230000000003, + 405.0, 149.99999999999997, 96.18092633117, 128.88698149419858), + ('above', 9, 'H1', 'C2', 27232.645690217043, + 317.62883914745044, 250.0, 240.0, 273.10320034208223), + ('above', 10, 'H1', 'C1', 20133.90000000001, + 250.0, 199.99999999999997, 63.71726150155106, 96.18092633117), + ('above', 11, 'H3', 'C1', 14709.398148738961, + 283.1032003420823, 240.0000000000001, 40.0, 63.71726150155106), + ], + hot_utility={'C2': 105554.014}, + cold_utility={}, + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='14sp1_sargent1978_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '14sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Grossmann & Sargent (1978), Comput. Chem. Eng. 2, 1 (14SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/14sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/14sp1.dat)' + ), + T_unit='F', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 480.0, 300.0, 18.4), + ('H2', 'hot', 460.0, 200.0, 24.18), + ('H3', 'hot', 450.0, 300.0, 20.8), + ('H4', 'hot', 400.0, 190.0, 18.2), + ('H5', 'hot', 390.0, 250.0, 28.22), + ('H6', 'hot', 320.0, 125.0, 19.17), + ('H7', 'hot', 280.0, 125.0, 27.2), + ('C1', 'cold', 150.0, 430.0, 13.3), + ('C2', 'cold', 180.0, 400.0, 13.02), + ('C3', 'cold', 220.0, 400.0, 27.2), + ('C4', 'cold', 110.0, 350.0, 22.2), + ('C5', 'cold', 140.0, 390.0, 28.16), + ('C6', 'cold', 130.0, 280.0, 15.6), + ('C7', 'cold', 100.0, 240.0, 13.05), + ], + targets=dict(Q_hot=0.0, Q_cold=426.34999999999934, pinch_hot=480.0, pinch_cold=470.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(426.35, 0.01), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H1', 'C3', 3311.9999999999995, + 480.0, 300.0, 278.2352941176471, 400.0), + ('below', 1, 'H2', 'C2', 1902.3638014718056, + 460.0, 381.32490481919746, 253.88910894993813, 400.0), + ('below', 1, 'H3', 'C1', 531.9999999999998, 450.0, 424.4230769230769, 390.0, 430.0), + ('below', 1, 'H5', 'C4', 1665.5934782608713, + 390.0, 330.97826086956513, 274.9732667450058, 350.0), + ('below', 1, 'H6', 'C6', 147.74898108684283, + 320.0, 312.2926979088762, 270.5289114687921, 280.0), + ('below', 1, 'H7', 'C7', 438.21623656168276, + 280.0, 263.88910894993813, 206.42021175772544, 239.99999999999997), + ('below', 2, 'H2', 'C7', 619.2948941803672, + 381.32490481919746, 355.71303988204414, 158.96466431095402, 206.42021175772544), + ('below', 2, 'H3', 'C5', 1943.652173913045, + 424.4230769230769, 330.97826086956513, 320.97826086956513, 390.0), + ('below', 2, 'H4', 'C1', 2414.0, + 400.0, 267.3626373626373, 208.49624060150376, 390.0), + ('below', 2, 'H6', 'C4', 608.9117860564119, + 312.2926979088762, 280.52891146879216, 247.5448079136359, 274.9732667450058), + ('below', 2, 'H7', 'C2', 962.0361985281945, + 263.88910894993813, 228.52013106287217, 180.0, 253.88910894993813), + ('below', 3, 'H2', 'C3', 1584.0000000000005, + 355.71303988204414, 290.2043550185206, 220.0, 278.2352941176471), + ('below', 3, 'H3', 'C4', 644.3478260869551, + 330.97826086956513, 300.0, 218.52013106287217, 247.5448079136359), + ('below', 3, 'H5', 'C5', 1507.2065217391284, + 330.97826086956513, 277.56909992912824, 267.4553020009881, 320.97826086956513), + ('below', 3, 'H6', 'C6', 2192.251018913157, + 280.52891146879216, 166.17048586038544, 130.0, 270.5289114687921), + ('below', 4, 'H2', 'C5', 2181.1413043478265, + 290.2043550185206, 200.00000000000006, 190.0, 267.4553020009881), + ('below', 4, 'H5', 'C1', 778.0, + 277.56909992912824, 249.99999999999997, 150.0, 208.49624060150376), + ('below', 4, 'H7', 'C4', 1619.908695652173, + 228.52013106287217, 168.96466431095405, 145.55127089835986, 218.52013106287217), + ('below', 5, 'H4', 'C5', 1407.9999999999998, + 267.3626373626373, 189.99999999999994, 140.0, 190.0), + ('below', 5, 'H6', 'C4', 789.2382139435887, + 166.17048586038544, 125.00000000000001, 110.0, 145.55127089835986), + ('below', 5, 'H7', 'C7', 769.48886925795, + 168.96466431095405, 140.6746323529412, 100.0, 158.96466431095402), + ], + hot_utility={}, + cold_utility={'H7': 426.3500000000003}, + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='bandar_imam_aromatics_ph6c10', + kind='constant_cp', + citation=( + 'Caballero, J.A., Pavao, L.V., Costa, C.B.B. & Ravagnani, M.A.S.S. (2021). A Novel ' + 'Sequential Approach for the Design of Heat Exchanger Networks. Front. Chem. Eng. ' + '3:733186, Supplementary Data Sheet 1, Table S23 (Example 12, PH6C10); original problem: ' + 'Khorasany, R.M. & Fesanghary, M. (2009). A novel approach for synthesis of cost-optimal ' + 'heat exchanger networks. Comput. Chem. Eng. 33, 1363-1370 (Bandar Imam aromatics plant).' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/733186/file/Data_Sheet_1.docx/7' + '33186_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 385.0, 159.0, 131.51), + ('H2', 'hot', 516.0, 43.0, 1198.96), + ('H3', 'hot', 132.0, 82.0, 378.52), + ('H4', 'hot', 91.0, 60.0, 589.545), + ('H5', 'hot', 217.0, 43.0, 186.216), + ('H6', 'hot', 649.0, 43.0, 116.0), + ('C1', 'cold', 30.0, 385.0, 119.1), + ('C2', 'cold', 99.0, 471.0, 191.05), + ('C3', 'cold', 437.0, 521.0, 377.91), + ('C4', 'cold', 78.0, 418.6, 160.43), + ('C5', 'cold', 217.0, 234.0, 1297.7), + ('C6', 'cold', 256.0, 266.0, 2753.0), + ('C7', 'cold', 49.0, 149.0, 197.39), + ('C8', 'cold', 59.0, 163.4, 123.156), + ('C9', 'cold', 163.0, 649.0, 95.98), + ('C10', 'cold', 219.0, 221.3, 1997.5), + ], + targets=dict(Q_hot=3965.790000000002, Q_cold=407528.69459999993, pinch_hot=516.0, pinch_cold=506.0), + published=None, + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H6', 'C9', 12765.34, 649.0, 538.9539655172414, 506.0, 639.0), + ('above', 2, 'H6', 'C3', 2662.660000000001, + 538.9539655172414, 516.0, 506.0, 513.0457516339869), + ('below', 3, 'H2', 'C3', 26075.79, 516.0, 494.25132614932943, 437.0, 506.0), + ('below', 3, 'H6', 'C9', 12573.38, 516.0, 407.6087931034483, 375.0, 506.0), + ('below', 4, 'H1', 'C9', 20347.76, 385.0, 230.27594859706485, 163.0, 375.0), + ('below', 4, 'H2', 'C8', 464.92639999999847, + 494.25132614932943, 493.86355141122306, 159.62489850271203, 163.4), + ('below', 4, 'H6', 'C1', 42280.5, + 407.6087931034483, 43.12172413793104, 30.0, 385.0), + ('below', 5, 'H2', 'C2', 24063.237599999968, + 493.86355141122306, 473.7934593314206, 345.0474347029575, 471.0), + ('below', 5, 'H5', 'C8', 9683.128000000002, + 217.0, 165.0005584912145, 81.0, 159.62489850271203), + ('below', 6, 'H2', 'C6', 27530.0, + 473.7934593314206, 450.8318926402883, 256.0, 266.0), + ('below', 6, 'H4', 'C8', 2709.4320000000002, 91.0, 86.40419815281277, 59.0, 81.0), + ('below', 6, 'H5', 'C7', 19739.0, + 165.0005584912145, 58.999999999999986, 49.0, 149.0), + ('below', 7, 'H2', 'C10', 4594.250000000023, + 450.8318926402883, 447.00003002602256, 219.0, 221.3), + ('below', 8, 'H2', 'C5', 22060.9, 447.00003002602256, 428.6, 217.0, 234.0), + ('below', 9, 'H2', 'C4', 54642.458000000006, 428.6, 383.0251201040902, 78.0, 418.6), + ('below', 10, 'H2', 'C2', 47007.36240000004, + 383.0251201040902, 343.81833889370785, 99.0, 345.0474347029575), + ], + hot_utility={'C3': 3005.9900000000016, 'C9': 959.8000000000001}, + cold_utility={'H1': 9373.499999999998, 'H2': 360669.1556, 'H3': 18926.0, 'H4': 15566.463000000002, 'H5': 2979.4559999999974, 'H6': 14.120000000000573}, + ), + ), + dict( + name='20sp1_sargent1978_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '20sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Grossmann & Sargent (1978), Comput. Chem. Eng. 2, 1 (20SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/20sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/20sp1.dat)' + ), + T_unit='F', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 550.0, 450.0, 28.4), + ('H2', 'hot', 520.0, 480.0, 23.87), + ('H3', 'hot', 500.0, 440.0, 24.6), + ('H4', 'hot', 460.0, 370.0, 17.0), + ('H5', 'hot', 415.0, 340.0, 30.69), + ('H6', 'hot', 400.0, 300.0, 19.36), + ('H7', 'hot', 365.0, 320.0, 25.5), + ('H8', 'hot', 300.0, 250.0, 12.42), + ('H9', 'hot', 240.0, 170.0, 20.72), + ('H10', 'hot', 170.0, 140.0, 18.9), + ('C1', 'cold', 375.0, 420.0, 22.32), + ('C2', 'cold', 300.0, 370.0, 23.87), + ('C3', 'cold', 320.0, 360.0, 34.96), + ('C4', 'cold', 280.0, 340.0, 26.04), + ('C5', 'cold', 260.0, 330.0, 13.44), + ('C6', 'cold', 225.0, 280.0, 32.0), + ('C7', 'cold', 240.0, 265.0, 11.1), + ('C8', 'cold', 170.0, 220.0, 22.96), + ('C9', 'cold', 140.0, 200.0, 14.4), + ('C10', 'cold', 80.0, 140.0, 13.92), + ], + targets=dict(Q_hot=0.0, Q_cold=3362.850000000001, pinch_hot=550.0, pinch_cold=540.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(3362.85, 0.01), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H1', 'C1', 1004.4, 550.0, 514.6338028169014, 375.0, 420.0), + ('below', 1, 'H10', 'C10', 567.0, 170.0, 140.0, 99.26724137931035, 140.0), + ('below', 1, 'H2', 'C9', 864.0, 520.0, 483.8039379974864, 140.0, 200.0), + ('below', 1, 'H3', 'C3', 1398.4, 500.0, 443.1544715447154, 320.0, 360.0), + ('below', 1, 'H4', 'C7', 277.5, 460.0, 443.6764705882353, 240.0, 265.0), + ('below', 1, 'H5', 'C4', 1562.3999999999999, + 415.0, 364.0909090909091, 280.0, 340.0), + ('below', 1, 'H6', 'C6', 1760.0, 400.0, 309.0909090909091, 225.0, 280.0), + ('below', 1, 'H9', 'C8', 1148.0, 240.0, 184.59459459459458, 170.0, 220.0), + ('below', 2, 'H1', 'C2', 1670.9, + 514.6338028169014, 455.7992957746479, 300.0, 370.0), + ('below', 2, 'H4', 'C5', 940.8, 443.6764705882353, 388.3352941176471, 260.0, 330.0), + ('below', 2, 'H9', 'C10', 190.60000000000014, + 184.59459459459458, 175.39575289575288, 85.57471264367815, 99.26724137931035), + ('below', 3, 'H3', 'C10', 77.59999999999991, + 443.1544715447154, 440.0, 80.0, 85.57471264367815), + ], + hot_utility={}, + cold_utility={'H1': 164.69999999999962, 'H2': 90.80000000000038, 'H4': 311.7000000000006, 'H5': 739.3500000000009, 'H6': 176.0000000000006, 'H7': 1147.5, 'H8': 621.0, 'H9': 111.79999999999963}, + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='sorak_kravanja_ph13c7', + kind='constant_cp', + citation=( + 'Caballero, J.A., Pavao, L.V., Costa, C.B.B. & Ravagnani, M.A.S.S. (2021). A Novel ' + 'Sequential Approach for the Design of Heat Exchanger Networks. Front. Chem. Eng. ' + '3:733186, Supplementary Data Sheet 1, Table S25 (Example 13, PH13C7); original problem: ' + 'Sorak, A. & Kravanja, Z. (2001). Simultaneous MINLP synthesis of heat exchanger networks' + ' comprising different exchanger types. Comput.-Aided Chem. Eng. 9, 1095-1100.' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/733186/file/Data_Sheet_1.docx/7' + '33186_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 576.0, 437.0, 23.1), + ('H2', 'hot', 599.0, 399.0, 15.22), + ('H3', 'hot', 530.0, 382.0, 15.15), + ('H4', 'hot', 449.0, 237.0, 14.76), + ('H5', 'hot', 368.0, 177.0, 10.7), + ('H6', 'hot', 121.0, 114.0, 149.6), + ('H7', 'hot', 202.0, 185.0, 258.2), + ('H8', 'hot', 185.0, 113.0, 8.38), + ('H9', 'hot', 140.0, 120.0, 59.89), + ('H10', 'hot', 69.0, 66.0, 165.79), + ('H11', 'hot', 120.0, 68.0, 8.74), + ('H12', 'hot', 67.0, 35.0, 7.62), + ('H13', 'hot', 1034.5, 576.0, 21.3), + ('C1', 'cold', 123.0, 343.0, 10.61), + ('C2', 'cold', 20.0, 156.0, 6.65), + ('C3', 'cold', 156.0, 157.0, 3291.0), + ('C4', 'cold', 20.0, 182.0, 26.63), + ('C5', 'cold', 182.0, 318.0, 31.19), + ('C6', 'cold', 318.0, 320.0, 4011.83), + ('C7', 'cold', 322.0, 923.78, 17.6), + ], + targets=dict(Q_hot=1831.0679999999998, Q_cold=0.0, pinch_hot=30.0, pinch_cold=20.0), + published=dict(Q_hot=(1831.1, 0.1), Q_cold=(0.0, 0.0), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'H13', 'C7', 9766.050000000001, + 1034.5, 576.0, 322.0, 876.8892045454546), + ('above', 1, 'H2', 'C6', 3044.0, + 599.0, 399.0, 319.0336355229409, 319.7923915021325), + ('above', 1, 'H3', 'C5', 1725.766170027145, + 530.0, 416.08804158236666, 262.6692475143589, 318.0), + ('above', 1, 'H4', 'C1', 1882.83, + 449.0, 321.4369918699187, 165.54194156456174, 343.0), + ('above', 1, 'H7', 'C4', 1452.88, + 202.0, 196.3730441518203, 127.4419827262486, 182.0), + ('above', 2, 'H3', 'C6', 516.4338299728552, + 416.08804158236666, 382.0, 318.9049077777541, 319.0336355229409), + ('above', 2, 'H4', 'C5', 1246.29, + 321.4369918699187, 237.0, 222.71124815559008, 262.6692475143589), + ('above', 2, 'H8', 'C1', 377.1, 185.0, 140.0, 130.0, 165.54194156456174), + ('above', 3, 'H5', 'C6', 419.4361700271447, + 368.0, 328.80035794138837, 318.80035794138837, 318.9049077777541), + ('above', 3, 'H8', 'C2', 226.26, 140.0, 113.0, 95.97593984962407, 130.0), + ('above', 3, 'H9', 'C1', 74.27, 140.0, 138.7598931374186, 123.0, 130.0), + ('above', 4, 'H1', 'C6', 3210.9, 576.0, 437.0, 318.0, 318.80035794138837), + ('above', 4, 'H11', 'C2', 401.0208336462637, + 120.0, 74.1166094226243, 35.672055090787424, 95.97593984962407), + ('above', 4, 'H5', 'C5', 1133.3885828775797, + 328.80035794138837, 222.8761913173155, 186.3730441518203, 222.71124815559008), + ('above', 4, 'H9', 'C4', 1123.53, + 138.7598931374186, 120.0, 85.25159594442358, 127.4419827262486), + ('above', 5, 'H5', 'C3', 490.8752470952754, + 222.8761913173155, 177.00000000000003, 156.850843133669, 157.0), + ('above', 5, 'H6', 'C4', 1047.2, + 121.0, 114.0, 45.927525347352606, 85.25159594442358), + ('above', 5, 'H7', 'C5', 136.39524709527495, + 196.3730441518203, 195.84478990280684, 182.0, 186.3730441518203), + ('above', 6, 'H11', 'C4', 53.45916635373629, + 74.1166094226243, 68.0, 43.92004632543235, 45.927525347352606), + ('above', 6, 'H7', 'C3', 2800.1247529047246, + 195.84478990280684, 185.0, 156.0, 156.850843133669), + ('above', 7, 'H12', 'C4', 139.6208336462636, + 67.0, 48.67705595193391, 38.67705595193391, 43.92004632543235), + ('above', 8, 'H10', 'C4', 497.37, 69.0, 66.0, 20.0, 38.67705595193391), + ('above', 8, 'H12', 'C2', 104.2191663537364, + 48.67705595193391, 35.0, 20.0, 35.672055090787424), + ], + hot_utility={'C2': 172.9, 'C6': 832.8899999998175, 'C7': 825.2779999999983}, + cold_utility={}, + ), + ), + dict( + name='23sp1_mocsny1984_dt10K', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '23sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Mocsny, D.; Govind, R. (1984). Decomposition strategy for the ' + 'synthesis of minimum-unit heat exchanger networks. AIChE J. 30, 853 (23SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/23sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/23sp1.dat)' + ), + T_unit='K', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 433.0, 366.0, 8.79), + ('H2', 'hot', 522.0, 411.0, 10.55), + ('H3', 'hot', 510.0, 349.0, 14.77), + ('H4', 'hot', 544.0, 422.0, 12.56), + ('H5', 'hot', 472.0, 339.0, 17.73), + ('H6', 'hot', 505.0, 339.0, 14.77), + ('H7', 'hot', 544.0, 420.0, 12.56), + ('H8', 'hot', 475.0, 339.0, 17.73), + ('H9', 'hot', 583.0, 478.0, 12.53), + ('H10', 'hot', 517.0, 366.0, 8.32), + ('H11', 'hot', 511.0, 339.0, 6.96), + ('C1', 'cold', 323.0, 423.0, 7.62), + ('C2', 'cold', 389.0, 495.0, 6.08), + ('C3', 'cold', 311.0, 494.0, 8.44), + ('C4', 'cold', 355.0, 450.0, 17.28), + ('C5', 'cold', 366.0, 478.0, 13.9), + ('C6', 'cold', 311.0, 490.0, 8.44), + ('C7', 'cold', 350.0, 450.0, 17.28), + ('C8', 'cold', 352.0, 468.0, 13.9), + ('C9', 'cold', 366.0, 478.0, 8.44), + ('C10', 'cold', 311.0, 489.0, 8.44), + ('C11', 'cold', 422.0, 478.0, 21.78), + ('C12', 'cold', 339.0, 411.0, 13.84), + ], + targets=dict(Q_hot=0.0, Q_cold=2553.6700000000005, pinch_hot=583.0, pinch_cold=573.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(2553.67, 0.01), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'H10', 'C3', 119.89027127338659, + 517.0, 502.59011162579486, 479.79499155528595, 494.0), + ('below', 1, 'H2', 'C6', 44.93838277524462, + 522.0, 517.7404376516356, 484.67554706454445, 489.99999999999994), + ('below', 1, 'H3', 'C11', 457.2900000000001, + 510.0, 479.039268788084, 457.004132231405, 478.0), + ('below', 1, 'H4', 'C9', 464.2, 544.0, 507.04140127388536, 423.0, 478.0), + ('below', 1, 'H5', 'C4', 1452.5454795115836, + 472.0, 390.07414103149557, 365.9406551208574, 449.99999999999994), + ('below', 1, 'H7', 'C5', 619.5151288693213, + 544.0, 494.6755470645445, 433.43056626839416, 478.0), + ('below', 1, 'H9', 'C10', 486.94539701184476, + 583.0, 544.1376379080731, 431.3050477474118, 489.0), + ('below', 2, 'H1', 'C9', 481.08, 433.0, 378.26962457337885, 366.0, 423.0), + ('below', 2, 'H11', 'C3', 841.643978420791, + 511.0, 390.0741410314955, 380.07414103149557, 479.79499155528595), + ('below', 2, 'H2', 'C1', 762.0, + 517.7404376516356, 445.51294949997686, 323.0, 423.0), + ('below', 2, 'H3', 'C7', 557.3344447707283, + 479.039268788084, 441.3050477474118, 417.7468492609532, 449.99999999999994), + ('below', 2, 'H4', 'C12', 334.321550894797, + 507.04140127388536, 480.4234434000958, 386.84381857696553, 411.0), + ('below', 2, 'H7', 'C6', 937.9248711306788, + 494.6755470645445, 420.0, 373.5470078310517, 484.67554706454445), + ('below', 2, 'H8', 'C11', 559.7260600613718, + 475.0, 443.43056626839416, 431.3050477474118, 457.004132231405), + ('below', 3, 'H2', 'C6', 109.84041830404057, + 445.51294949997686, 435.1015354427218, 360.5327402594829, 373.5470078310517), + ('below', 3, 'H3', 'C10', 1015.3746029881552, + 441.3050477474118, 372.5593061774622, 311.0, 431.3050477474118), + ('below', 3, 'H4', 'C8', 315.7421213151672, + 480.4234434000958, 455.28473947372896, 445.28473947372896, 468.0), + ('below', 3, 'H5', 'C3', 250.58670519561386, + 390.07414103149557, 375.94065512085746, 350.3837731173233, 380.07414103149557), + ('below', 3, 'H6', 'C7', 909.3805362158186, + 505.0, 443.43056626839416, 365.1206608225378, 417.7468492609532), + ('below', 3, 'H8', 'C11', 202.66393993862823, + 443.43056626839416, 432.0, 422.0, 431.3050477474118), + ('below', 3, 'H9', 'C12', 490.3946029881551, + 544.1376379080731, 505.00000000000006, 351.4106825229081, 386.84381857696553), + ('below', 4, 'H3', 'C12', 171.76384611704793, + 372.5593061774622, 360.93006811943593, 339.0, 351.4106825229081), + ('below', 4, 'H4', 'C6', 418.05632779003605, + 455.28473947372896, 422.0, 311.0, 360.5327402594829), + ('below', 4, 'H5', 'C4', 14.538498909207476, + 375.94065512085746, 375.1206608225378, 365.09930680435235, 365.9406551208574), + ('below', 4, 'H6', 'C5', 937.2848711306787, + 443.43056626839416, 379.9718749257619, 366.0, 433.43056626839416), + ('below', 4, 'H9', 'C2', 14.652121315167165, + 505.00000000000006, 503.83063676654695, 492.59011162579486, 495.0), + ('below', 5, 'H10', 'C2', 629.8278786848329, + 502.59011162579486, 426.88964543771397, 389.0, 492.59011162579486), + ('below', 5, 'H11', 'C4', 174.51602157920905, + 390.0741410314955, 364.99999999999994, 355.0, 365.09930680435235), + ('below', 5, 'H5', 'C7', 261.28501901345294, + 375.1206608225378, 360.3837731173233, 350.0, 365.1206608225378), + ('below', 5, 'H9', 'C8', 323.65787868483284, + 503.83063676654695, 478.0, 422.0, 445.28473947372896), + ('below', 6, 'H5', 'C3', 332.3990451102086, + 360.3837731173233, 341.6359420338372, 311.0, 350.3837731173233), + ('below', 6, 'H8', 'C8', 973.0, 432.0, 377.1212633953751, 352.0, 422.0), + ], + hot_utility={}, + cold_utility={'H1': 107.85000000000007, 'H2': 254.27119892071497, 'H3': 176.20710612406867, 'H5': 46.73525225993324, 'H6': 605.154592653503, 'H8': 675.8900000000002, 'H10': 506.6018500417802, 'H11': 180.9599999999996}, + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='fs_28sp_as1', + kind='constant_cp', + citation=( + 'Furman, K.C. & Sahinidis, N.V. (2004). Approximation algorithms for the minimum number ' + 'of matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565, test instance '28sp-as1' (original source per the test set's references.txt: " + "'smith:1989' (Ahmad & Smith, 1989)); digitized in Letsios, D., Kouyialis, G. & Misener, " + 'R. (2018). Heuristics with performance guarantees for the minimum number of matches ' + 'problem in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (data repository ' + 'github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/furman_sahinidis/dat_files/28sp-as1.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS1', 'hot', 230, 80, 30), + ('HS2', 'hot', 200, 40, 45), + ('HS3', 'hot', 110, 45, 0.1), + ('HS4', 'hot', 115, 40, 0.1), + ('HS5', 'hot', 105, 40, 0.1), + ('HS6', 'hot', 110, 42, 0.1), + ('HS7', 'hot', 117, 48, 0.1), + ('HS8', 'hot', 103, 50, 0.12), + ('HS9', 'hot', 110, 45, 0.1), + ('HS10', 'hot', 115, 40, 0.1), + ('HS11', 'hot', 105, 40, 0.1), + ('HS12', 'hot', 110, 42, 0.1), + ('HS13', 'hot', 117, 48, 0.1), + ('HS14', 'hot', 103, 50, 0.1), + ('HS15', 'hot', 115, 42, 0.1), + ('HS16', 'hot', 117, 43, 0.1), + ('CS1', 'cold', 40, 180, 40), + ('CS2', 'cold', 140, 280, 60), + ('CS3', 'cold', 170, 270, 0.1), + ('CS4', 'cold', 175, 265, 0.1), + ('CS5', 'cold', 180, 275, 0.1), + ('CS6', 'cold', 168, 277, 0.1), + ('CS7', 'cold', 181, 267, 0.1), + ('CS8', 'cold', 170, 270, 0.1), + ('CS9', 'cold', 175, 265, 0.1), + ('CS10', 'cold', 180, 275, 0.1), + ('CS11', 'cold', 168, 277, 0.1), + ('CS12', 'cold', 181, 267, 0.1), + ], + targets=dict(Q_hot=5446.0, Q_cold=3144.7599999999993, pinch_hot=150.0, pinch_cold=140.0), + published=dict(Q_hot=(5446.0, 1.0), Q_cold=(3144.76, 0.01), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('above', 1, 'HS1', 'CS2', 1275.0, 230.0, 187.5, 177.5, 198.75), + ('above', 2, 'HS1', 'CS1', 1125.0, 187.5, 150.0, 140.0, 168.125), + ('above', 2, 'HS2', 'CS2', 2250.0, 200.0, 150.0, 140.0, 177.5), + ('below', 3, 'HS2', 'CS1', 4000.0, 150.0, 61.111111111111114, 40.0, 140.0), + ], + hot_utility={'CS1': 475.0, 'CS2': 4875.0, 'CS3': 10.0, 'CS4': 9.0, 'CS5': 9.5, 'CS6': 10.9, 'CS7': 8.6, 'CS8': 10.0, 'CS9': 9.0, 'CS10': 9.5, 'CS11': 10.9, 'CS12': 8.6}, + cold_utility={'HS1': 2100.0, 'HS2': 950.0000000000001, 'HS3': 6.5, 'HS4': 7.5, 'HS5': 6.5, 'HS6': 6.800000000000001, 'HS7': 6.9, 'HS8': 6.359999999999999, 'HS9': 6.5, 'HS10': 7.5, 'HS11': 6.5, 'HS12': 6.800000000000001, 'HS13': 6.9, 'HS14': 5.300000000000001, 'HS15': 7.300000000000001, 'HS16': 7.4}, + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='fs_37sp_yfyv', + kind='constant_cp', + citation=( + 'Furman, K.C. & Sahinidis, N.V. (2004). Approximation algorithms for the minimum number ' + 'of matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565, test instance '37sp-yfyv' (original source per the test set's references.txt:" + " 'yu:2000' (Yu, Fang, Yao & Vian, 2000)); digitized in Letsios, D., Kouyialis, G. & " + 'Misener, R. (2018). Heuristics with performance guarantees for the minimum number of ' + 'matches problem in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (data ' + 'repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/furman_sahinidis/dat_files/37sp-yfyv.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS1', 'hot', 175.0, 150.0, 4923), + ('HS2', 'hot', 168.0, 55.0, 767), + ('HS3', 'hot', 190.0, 175.0, 15796), + ('HS4', 'hot', 160.0, 135.0, 6402), + ('HS5', 'hot', 102.0, 40.0, 40417), + ('HS6', 'hot', 170.0, 40.0, 795), + ('HS7', 'hot', 98.4, 40.0, 2216), + ('HS8', 'hot', 97.4, 40.0, 2290), + ('HS9', 'hot', 98.4, 40.0, 1984), + ('HS10', 'hot', 40.0, 25.0, 652), + ('HS11', 'hot', 25.0, 10.0, 692), + ('HS12', 'hot', 104.7, 40.0, 573), + ('HS13', 'hot', 80.0, 40.0, 840), + ('HS14', 'hot', 40.0, 25.0, 339), + ('HS15', 'hot', 25.0, 7.4, 1553), + ('HS16', 'hot', 57.5, 40.0, 358), + ('HS17', 'hot', 11.7, 0.0, 1819), + ('HS18', 'hot', 99.0, 96.2, 529), + ('HS19', 'hot', 30.8, 28.6, 6690), + ('HS20', 'hot', 44.7, 40.0, 5307), + ('HS21', 'hot', 976.0, 300.0, 25220), + ('CS1', 'cold', 10.0, 160.0, 4923), + ('CS2', 'cold', 170.0, 170.5, 15796), + ('CS3', 'cold', 120.0, 120.5, 6402), + ('CS4', 'cold', 170.0, 170.5, 13667), + ('CS5', 'cold', 170.0, 222.0, 1604), + ('CS6', 'cold', 68.0, 90.0, 778), + ('CS7', 'cold', 14.5, 15.0, 1448), + ('CS8', 'cold', 122.0, 122.5, 386), + ('CS9', 'cold', 2.4, 2.6, 1625), + ('CS10', 'cold', 68.0, 68.5, 3241), + ('CS11', 'cold', 4.5, 5.0, 3262), + ('CS12', 'cold', 52.0, 53.0, 4666), + ('CS13', 'cold', 175.0, 196.0, 6300), + ('CS14', 'cold', 40.0, 99.0, 6490), + ('CS15', 'cold', 33.9, 79.9, 1875), + ('CS16', 'cold', 130.0, 302.0, 12703), + ], + targets=dict(Q_hot=0.0, Q_cold=17180884.30000001, pinch_hot=976.0, pinch_cold=966.0), + published=dict(Q_hot=(0.0, 0.0), Q_cold=(17180884.3, 0.1), pinch_hot=None, pinch_cold=None), + needs_repeated_pair=False, + certificate=dict( + # (side, stage, hot, cold, Q, T_hot_in, T_hot_out, T_cold_in, T_cold_out) + matches=[ + ('below', 1, 'HS12', 'CS12', 4666.0, 104.7, 96.55689354275742, 52.0, 53.0), + ('below', 1, 'HS20', 'CS9', 325.0000000000003, 44.7, 44.63876012813266, 2.4, 2.6), + ('below', 1, 'HS21', 'CS16', 2184916.0, 976.0, 889.3657414750198, 130.0, 302.0), + ('below', 1, 'HS3', 'CS3', 3201.0, 190.0, 189.79735376044567, 120.0, 120.5), + ('below', 1, 'HS4', 'CS15', 86250.00000000001, + 160.0, 146.52764761012185, 33.9, 79.9), + ('below', 1, 'HS5', 'CS10', 1620.5, 102.0, 101.95990548531559, 68.0, 68.5), + ('below', 1, 'HS6', 'CS6', 17116.0, 170.0, 148.4704402515723, 68.0, 90.0), + ('below', 1, 'HS9', 'CS11', 1631.0, 98.4, 97.57792338709677, 4.5, 5.0), + ('below', 2, 'HS21', 'CS14', 382910.0, + 889.3657414750198, 874.1829500396511, 40.0, 99.0), + ('below', 3, 'HS21', 'CS5', 83408.0, + 874.1829500396511, 870.8757335448058, 170.0, 222.0), + ('below', 4, 'HS21', 'CS13', 132300.0, + 870.8757335448058, 865.6298969072166, 175.0, 196.0), + ('below', 5, 'HS21', 'CS2', 7898.0, + 865.6298969072166, 865.3167327517843, 170.0, 170.5), + ('below', 6, 'HS21', 'CS4', 6833.5, + 865.3167327517843, 865.0457771609834, 170.0, 170.5), + ('below', 7, 'HS21', 'CS8', 193.0, + 865.0457771609834, 865.0381245043617, 122.0, 122.5), + ('below', 8, 'HS21', 'CS7', 724.0, + 865.0381245043617, 865.0094171292626, 14.5, 15.0), + ('below', 9, 'HS21', 'CS1', 738450.0, + 865.0094171292626, 835.7290840602698, 10.0, 160.0), + ], + hot_utility={}, + cold_utility={'HS1': 123075.0, 'HS2': 86671.0, 'HS3': 233738.99999999985, 'HS4': 73800.00000000006, 'HS5': 2504233.5, 'HS6': 86233.99999999999, 'HS7': 129414.40000000001, 'HS8': 131446.0, 'HS9': 114234.6, 'HS10': 9780.0, 'HS11': 10380.0, 'HS12': 32407.100000000002, 'HS13': 33600.0, 'HS14': 5085.0, 'HS15': 27332.800000000003, 'HS16': 6265.0, 'HS17': 21282.3, 'HS18': 1481.1999999999985, 'HS19': 14717.999999999995, 'HS20': 24617.900000000023, 'HS21': 13511087.500000004}, + ), + note=( + 'heat unit not stated in the source; read as kW; HS21 (976 -> 300, CP 25220) carries 99.2' + ' % of Q_cold; stream duties span 193 to 1.7e7, so heat tolerances must be absolute or ' + 'per stream' + ), + ), + # ----- real-thermodynamics problems ----- + dict( + name='rtA01_linnhoff4_water', + kind='real_thermo', + description=( + 'Linnhoff/Kemp 4-stream problem transposed to pressurized liquid water (10 bar; T+260 K; ' + "CP ratio 2:3:4:1.5), sensible liquids only. Pinch at C3's supply (340 K shifted). " + 'Textbook design: above H2-C3 and H4-C1 (CP rule), below H2-C1 at the pinch then H4-C1.' + ), + chemicals=['Water'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 95.4}, 280.0, 395.0, 1000000.0, 'l', False), + ('H2', {'Water': 142.0}, 430.0, 320.0, 1000000.0, 'l', False), + ('C3', {'Water': 190.8}, 340.0, 400.0, 1000000.0, 'l', False), + ('H4', {'Water': 71.6}, 410.0, 290.0, 1000000.0, 'l', False), + ], + reference=dict(Q_hot=71439.59349029488, Q_cold=213628.25624684175, pinch_T_shifted=340.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C3', Q=868894.2475563139, side='above', hot_seq=1, cold_seq=1), + dict(hot='H4', cold='C1', Q=327198.45001137024, side='above', hot_seq=1, cold_seq=3), + dict(hot='H2', cold='C1', Q=321281.72114567866, side='below', hot_seq=2, cold_seq=2), + dict(hot='H4', cold='C1', Q=110154.80542325939, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=71439.59349029558, Q_cold=213628.25624684172), + ), + dict( + name='rtA02_r002_short_of_tickoff', + kind='real_thermo', + description=( + "Real-thermo analog of the reviewer's r002 (T' = 280 + 0.7 (T - 50), dT 7): one hot water" + ' liquid (5 bar) is the only hot stream below the pinch (its supply sets the pinch) and ' + 'must heat three cold liquids. The pinch match H1-C3 must stop SHORT of tick-off so that ' + "H1 is still hot enough for C1 (364 + 7 K); C3's remainder needs a second H1-C3 match " + "(repeated pair on one side). Proof data in 'short_of_tickoff'." + ), + chemicals=['Water', 'Methanol'], + T_min_app=7.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 200.0}, 399.0, 280.0, 500000.0, 'l', False), + ('C1', {'Water': 50.0}, 343.0, 364.0, 500000.0, 'l', False), + ('C2', {'Methanol': 45.0}, 315.0, 329.0, 500000.0, 'l', False), + ('C3', {'Water': 100.0}, 329.0, 420.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=215300.6069739886, Q_cold=1189188.2932688468, pinch_T_shifted=392.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H1', cold='C3', Q=426925.2248520291, side='below', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C1', Q=79397.10780429505, side='below', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C3', Q=50438.708460174385, side='below', hot_seq=3, cold_seq=1), + dict(hot='H1', cold='C2', Q=54494.70316711828, side='below', hot_seq=4, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=215300.60697345063, Q_cold=1189188.2932688466), + short_of_tickoff={'flex': 'H1', 'pinch_stream': 'C3', 'blocked': 'C1'}, + ), + dict( + name='rtA03_condenser_at_pinch', + kind='real_thermo', + description=( + 'Pure ethanol isothermal condenser (1 atm, 351.57 K) sets the pinch. Its latent heat ' + 'belongs below the pinch and, being isothermal AT the pinch, serves both cold pinch ' + 'streams (water, methanol liquids) in series. Above: the hot water liquid is matched with' + ' both cold streams in series.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Ethanol': 60.0}, 'dew', 'bubble', 101325.0, 'l', True), + ('H2', {'Water': 100.0}, 410.0, 320.0, 500000.0, 'l', False), + ('C1', {'Water': 150.0}, 300.0, 365.0, 101325.0, 'l', False), + ('C2', {'Methanol': 80.0}, 320.0, 380.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=120517.44856532227, Q_cold=1963607.2531972774, pinch_T_shifted=341.570441659, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C1', Q=265743.13585361623, side='above', hot_seq=2, cold_seq=2), + dict(hot='H2', cold='C2', Q=179369.4143462823, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C1', Q=469792.899233465, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C2', Q=153139.85799572145, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=120517.60446348641, Q_cold=1963607.409095517), + ), + dict( + name='rtA04_boiler_at_pinch_two_hot', + kind='real_thermo', + description=( + 'Pure water boiler (1 atm, 373.12 K, saturated liquid -> saturated vapor) sets the pinch ' + 'and is the only cold stream above it, while TWO hot streams are at the pinch (water ' + 'liquid; ethanol vapor with desuperheat+condense+subcool at 5 bar). The isothermal boiler' + ' takes both hot pinch ends in series: the textbook number rule does not apply to a point' + ' load at the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 60.0}, 'bubble', 'dew', 101325.0, 'l', True), + ('C2', {'Methanol': 100.0}, 300.0, 330.0, 101325.0, 'l', False), + ('H1', {'Water': 120.0}, 440.0, 330.0, 1000000.0, 'l', False), + ('H2', {'Ethanol': 40.0}, 450.0, 360.0, 500000.0, 'g', True), + ], + reference=dict(Q_hot=207461.562170011, Q_cold=362360.7674778069, pinch_T_shifted=373.124295848, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H1', cold='C1', Q=526688.6299635024, side='above', hot_seq=1, cold_seq=1), + dict(hot='H2', cold='C1', Q=1704906.6844570914, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C2', Q=254679.48708687196, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=207461.35793606797, Q_cold=362360.56324386346), + ), + dict( + name='rtA05_steam_desup_cond_sub_pinch', + kind='real_thermo', + description=( + '3 bar steam desuperheated, condensed (406.67 K) and subcooled; its condensation sets the' + ' pinch and its isothermal latent heat (below the pinch) serves three cold streams in ' + 'series. Cold methanol vapor heated above the pinch; hot water liquid.' + ), + chemicals=['Water', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 50.0}, 470.0, 370.0, 300000.0, 'g', True), + ('H2', {'Water': 60.0}, 420.0, 330.0, 500000.0, 'l', False), + ('C1', {'Water': 100.0}, 310.0, 410.0, 500000.0, 'l', False), + ('C2', {'Water': 150.0}, 300.0, 360.0, 101325.0, 'l', False), + ('C3', {'Methanol': 150.0}, 370.0, 430.0, 200000.0, 'g', True), + ], + reference=dict(Q_hot=194351.73207261035, Q_cold=915116.1093185253, pinch_T_shifted=396.67242046090075, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C1', Q=61704.57743186306, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C3', Q=109592.64957664348, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C1', Q=656201.7066676746, side='below', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C3', Q=201067.43893844355, side='below', hot_seq=3, cold_seq=1), + dict(hot='H1', cold='C2', Q=678725.4578145891, side='below', hot_seq=4, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=194351.82227062166, Q_cold=915116.1995166652), + ), + dict( + name='rtA06_boiler_subcooled_to_superheated', + kind='real_thermo', + description=( + 'Ethanol at 2 bar heated from subcooled liquid through boiling (369.86 K, the pinch) to ' + 'superheated vapor; pressurized hot water, superheated methanol vapor and steam (sensible' + ' vapors) above the pinch all end against the isothermal boiling; H1-C2 repeated below.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Ethanol': 50.0}, 320.0, 400.0, 200000.0, 'l', True), + ('C2', {'Water': 100.0}, 300.0, 350.0, 101325.0, 'l', False), + ('H1', {'Water': 200.0}, 450.0, 340.0, 1000000.0, 'l', False), + ('H2', {'Methanol': 100.0}, 430.0, 380.0, 300000.0, 'g', True), + ('H3', {'Water': 80.0}, 420.0, 380.0, 101325.0, 'g', True), + ('H4', {'Water': 80.0}, 345.0, 305.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=561984.2063972547, Q_cold=138719.69179782073, pinch_T_shifted=369.8572114718511, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H1', cold='C1', Q=1085177.8094160939, side='above', hot_seq=1, cold_seq=3), + dict(hot='H2', cold='C1', Q=259996.606789554, side='above', hot_seq=1, cold_seq=4), + dict(hot='H3', cold='C1', Q=109645.12869567331, side='above', hot_seq=1, cold_seq=5), + dict(hot='H1', cold='C1', Q=301383.8797179568, side='below', hot_seq=2, cold_seq=2), + dict(hot='H1', cold='C2', Q=113221.42617213994, side='below', hot_seq=3, cold_seq=3), + dict(hot='H1', cold='C2', Q=189056.71286161157, side='below', hot_seq=4, cold_seq=2), + dict(hot='H4', cold='C1', Q=27853.116260636656, side='below', hot_seq=1, cold_seq=1), + dict(hot='H4', cold='C2', Q=74572.82531694858, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=561984.1703396922, Q_cold=138719.65574025473), + ), + dict( + name='rtA07_hot_glide_below_pinch', + kind='real_thermo', + description=( + 'Water/ethanol xE=0.1 vapor condensed across its glide (370.4 -> 359.5 K) entirely below ' + "the pinch (set by H2's supply); water and methanol liquids." + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 54.0, 'Ethanol': 6.0}, 390.0, 340.0, 101325.0, 'g', True), + ('C1', {'Water': 120.0}, 300.0, 355.0, 101325.0, 'l', False), + ('C2', {'Methanol': 80.0}, 305.0, 330.0, 101325.0, 'l', False), + ('H2', {'Water': 50.0}, 400.0, 320.0, 500000.0, 'l', False), + ('C3', {'Water': 30.0}, 350.0, 400.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=22951.838720510947, Q_cold=2166901.159473365, pinch_T_shifted=390.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C3', Q=91090.40752076614, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C1', Q=497579.48524906003, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C2', Q=170907.40238710644, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=22951.842907392653, Q_cold=2166901.1635355684), + ), + dict( + name='rtA08_cold_glide_threshold_Qc0', + kind='real_thermo', + description=( + 'Threshold problem with ZERO cold utility (pinch at the bottom): water/methanol xM=0.2 ' + 'boiled across its glide (354.8 -> 367.8 K), 2 bar steam condenser, water and methanol ' + 'liquids; H1-C1 repeated.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=5.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 40.0, 'Methanol': 10.0}, 320.0, 375.0, 101325.0, 'l', True), + ('H1', {'Water': 100.0}, 450.0, 340.0, 1000000.0, 'l', False), + ('H2', {'Water': 30.0}, 'dew', 'bubble', 200000.0, 'l', True), + ('C2', {'Water': 100.0}, 300.0, 340.0, 101325.0, 'l', False), + ('H3', {'Methanol': 60.0}, 369.0, 310.0, 500000.0, 'l', False), + ('C3', {'Water': 50.0}, 380.0, 440.0, 1000000.0, 'l', False), + ], + reference=dict(Q_hot=346449.0061063774, Q_cold=0.0, pinch_T_shifted=300.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H3', cold='C2', Q=263629.53775957617, side='above', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C1', Q=122060.26416768176, side='above', hot_seq=3, cold_seq=1), + dict(hot='H1', cold='C2', Q=37715.031266609614, side='above', hot_seq=2, cold_seq=2), + dict(hot='H3', cold='C1', Q=59702.80590285431, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C1', Q=684644.6186496097, side='above', hot_seq=1, cold_seq=3), + dict(hot='H2', cold='C1', Q=1193994.78569886, side='above', hot_seq=1, cold_seq=4), + ], + certificate_utilities=dict(Q_hot=346449.00595814106, Q_cold=0.0), + ), + dict( + name='rtA09_EM_glide_threshold_Qh0', + kind='real_thermo', + description=( + 'Threshold problem with ZERO hot utility: ethanol/methanol 50/50 vapor condensed across ' + 'its 1.9 K glide against water and ethanol liquids.' + ), + chemicals=['Ethanol', 'Methanol', 'Water'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Ethanol': 40.0, 'Methanol': 40.0}, 370.0, 320.0, 101325.0, 'g', True), + ('C1', {'Water': 120.0}, 300.0, 350.0, 101325.0, 'l', False), + ('H2', {'Water': 60.0}, 390.0, 330.0, 500000.0, 'l', False), + ('C2', {'Ethanol': 50.0}, 320.0, 345.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=0.0, Q_cold=2980517.6092781536, pinch_T_shifted=None, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C1', Q=159501.6510267902, side='below', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C2', Q=157702.63148631965, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C1', Q=292719.5061940499, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=0.0, Q_cold=2980517.609051818), + ), + dict( + name='rtA10_near_azeotrope_boiling', + kind='real_thermo', + description=( + 'Water/ethanol xE=0.7 (0.5 K glide) boiled from 320 to 370 K by a 2 bar steam condenser ' + "and hot water; pinch at H2's supply; certificate with repeated pairs (H2-C1 x2, H2-C2 " + 'x3).' + ), + chemicals=['Water', 'Ethanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 12.0, 'Ethanol': 28.0}, 320.0, 370.0, 101325.0, 'l', True), + ('H1', {'Water': 40.0}, 'dew', 'bubble', 200000.0, 'l', True), + ('H2', {'Water': 80.0}, 400.0, 310.0, 500000.0, 'l', False), + ('C2', {'Water': 60.0}, 300.0, 360.0, 101325.0, 'l', False), + ('C3', {'Water': 40.0}, 380.0, 420.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=92231.05593656626, Q_cold=53141.16286954272, pinch_T_shifted=390.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C3', Q=30489.403854961973, side='below', hot_seq=1, cold_seq=1), + dict(hot='H2', cold='C1', Q=152483.90257546544, side='below', hot_seq=2, cold_seq=3), + dict(hot='H2', cold='C2', Q=46654.429105993186, side='below', hot_seq=3, cold_seq=3), + dict(hot='H1', cold='C1', Q=1591993.04759848, side='below', hot_seq=1, cold_seq=2), + dict(hot='H2', cold='C2', Q=142688.73539810738, side='below', hot_seq=4, cold_seq=2), + dict(hot='H2', cold='C1', Q=37750.67623817102, side='below', hot_seq=5, cold_seq=1), + dict(hot='H2', cold='C2', Q=82147.0186217351, side='below', hot_seq=6, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=92231.06247271306, Q_cold=53141.16941052169), + note=( + 'match 5 (H2-C1, below) duty lowered by 0.00432 kJ/hr from the designed ' + '37750.68055333849: on exact planned states its internal approach was 9.999999018 K (the ' + 'original check used the simulated HXprocess, whose dT limit absorbed the shortfall)' + ), + ), + dict( + name='rtA11_boiler_pinch_condenser_below', + kind='real_thermo', + description=( + 'Water boiler at 2 bar (393.36 K) sets the pinch and is served above by superheated ' + 'methanol vapor; below, a 1 atm steam condenser (+subcool) boils ethanol (isothermal) and' + ' heats methanol to its bubble point.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 80.0}, 'dew', 330.0, 101325.0, 'l', True), + ('C1', {'Methanol': 100.0}, 300.0, 'bubble', 101325.0, 'l', True), + ('C2', {'Ethanol': 20.0}, 'bubble', 'dew', 101325.0, 'l', True), + ('H2', {'Methanol': 100.0}, 440.0, 360.0, 200000.0, 'g', True), + ('C3', {'Water': 10.0}, 'bubble', 'dew', 200000.0, 'l', True), + ], + reference=dict(Q_hot=202525.07677420386, Q_cold=2624473.4991559633, pinch_T_shifted=393.36009132800956, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C3', Q=195473.1894763694, side='above', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C2', Q=782805.6359628483, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C1', Q=322999.2659982466, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=202525.0724232506, Q_cold=2624473.4948050147), + ), + dict( + name='rtA12_multi_phase_change', + kind='real_thermo', + description=( + 'Phase change at four pressures: steam condenser (5 bar), ethanol ' + 'desuperheat+condense+subcool (3 bar), methanol heated and boiled to saturated vapor (5 ' + 'bar; its boiling sets the pinch and takes two hot pinch ends in series), water boiler (2' + ' bar), two cold liquids.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 30.0}, 'dew', 'bubble', 500000.0, 'l', True), + ('H2', {'Ethanol': 40.0}, 420.0, 350.0, 300000.0, 'g', True), + ('H3', {'Water': 70.0}, 440.0, 330.0, 1000000.0, 'l', False), + ('C1', {'Methanol': 30.0}, 330.0, 'dew', 500000.0, 'l', True), + ('C2', {'Water': 25.0}, 'bubble', 'dew', 200000.0, 'l', True), + ('C3', {'Ethanol': 80.0}, 300.0, 345.0, 101325.0, 'l', False), + ('C4', {'Water': 100.0}, 300.0, 340.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=500420.7481043098, Q_cold=1150417.3847702402, pinch_T_shifted=384.5157827754926, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C1', Q=83931.63674364518, side='above', hot_seq=1, cold_seq=2), + dict(hot='H3', cold='C1', Q=246372.09596558643, side='above', hot_seq=1, cold_seq=3), + dict(hot='H1', cold='C2', Q=994995.65474905, side='above', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C1', Q=158515.56603519246, side='above', hot_seq=1, cold_seq=4), + dict(hot='H3', cold='C1', Q=157913.7405402002, side='below', hot_seq=2, cold_seq=1), + dict(hot='H2', cold='C3', Q=439000.3781692564, side='below', hot_seq=2, cold_seq=1), + dict(hot='H2', cold='C4', Q=301344.5690261858, side='below', hot_seq=3, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=500420.73265142273, Q_cold=1150417.3693173532), + ), + dict( + name='rtA13_smith4_cp_assignment', + kind='real_thermo', + description=( + "Smith 4-stream problem transposed (T' = 280 + 0.6 (T - 50), dT 6; hot ethanol liquid at " + '10 bar, water otherwise): above the pinch the only CP-feasible pinch assignment is ' + 'H1-C3, H2-C4 (a greedy one-at-a-time choice H1-C4 strands H2).' + ), + chemicals=['Water', 'Ethanol'], + T_min_app=6.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Ethanol': 44.0}, 412.0, 346.0, 1000000.0, 'l', False), + ('H2', {'Water': 110.0}, 382.0, 286.0, 500000.0, 'l', False), + ('C3', {'Water': 100.0}, 280.0, 376.0, 500000.0, 'l', False), + ('C4', {'Water': 250.0}, 346.0, 376.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=144708.54461331974, Q_cold=85052.11983049946, pinch_T_shifted=346.0, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H1', cold='C3', Q=227206.4282181022, side='above', hot_seq=2, cold_seq=2), + dict(hot='H2', cold='C4', Q=250272.12817810866, side='above', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C4', Q=173035.39775382716, side='above', hot_seq=1, cold_seq=2), + dict(hot='H2', cold='C3', Q=497529.9264931389, side='below', hot_seq=2, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=144708.54461332085, Q_cold=85052.11983049929), + ), + dict( + name='rtA14_11_streams', + kind='real_thermo', + description=( + '11 streams: an ethanol condenser at the pinch serves four cold streams below in series; ' + 'steam desuperheat+condense+subcool, methanol vapor, water boiler (2 bar), water/methanol' + ' glide boiler, superheated steam heating; 13-exchanger certificate.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 30.0}, 460.0, 350.0, 300000.0, 'g', True), + ('H2', {'Water': 60.0}, 445.0, 320.0, 1000000.0, 'l', False), + ('H3', {'Ethanol': 40.0}, 'dew', 'bubble', 101325.0, 'l', True), + ('H4', {'Methanol': 60.0}, 420.0, 365.0, 200000.0, 'g', True), + ('H5', {'Water': 40.0}, 400.0, 310.0, 500000.0, 'l', False), + ('C1', {'Water': 100.0}, 300.0, 365.0, 101325.0, 'l', False), + ('C2', {'Ethanol': 30.0}, 330.0, 390.0, 500000.0, 'l', False), + ('C3', {'Water': 30.0}, 'bubble', 'dew', 200000.0, 'l', True), + ('C4', {'Methanol': 50.0}, 300.0, 330.0, 101325.0, 'l', False), + ('C5', {'Water': 32.0, 'Methanol': 8.0}, 330.0, 375.0, 101325.0, 'l', True), + ('C6', {'Water': 200.0}, 390.0, 450.0, 101325.0, 'g', True), + ], + reference=dict(Q_hot=1560258.735430469, Q_cold=1316330.2243316392, pinch_T_shifted=341.570441659, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H1', cold='C5', Q=1358205.2067580407, side='above', hot_seq=1, cold_seq=2), + dict(hot='H2', cold='C1', Q=114565.30614198213, side='above', hot_seq=3, cold_seq=2), + dict(hot='H5', cold='C2', Q=53768.43887301241, side='above', hot_seq=3, cold_seq=2), + dict(hot='H4', cold='C2', Q=153310.0690398405, side='above', hot_seq=2, cold_seq=3), + dict(hot='H5', cold='C1', Q=62596.784427095336, side='above', hot_seq=2, cold_seq=3), + dict(hot='H2', cold='C5', Q=121865.33679724854, side='above', hot_seq=2, cold_seq=3), + dict(hot='H5', cold='C5', Q=30943.70051040844, side='above', hot_seq=1, cold_seq=4), + dict(hot='H2', cold='C3', Q=194105.64009960758, side='above', hot_seq=1, cold_seq=1), + dict(hot='H4', cold='C3', Q=14972.761368432752, side='above', hot_seq=1, cold_seq=2), + dict(hot='H3', cold='C1', Q=313195.2661556434, side='below', hot_seq=1, cold_seq=1), + dict(hot='H3', cold='C2', Q=44290.79681488048, side='below', hot_seq=2, cold_seq=1), + dict(hot='H3', cold='C5', Q=36258.40314162732, side='below', hot_seq=3, cold_seq=1), + dict(hot='H3', cold='C4', Q=127339.74354343598, side='below', hot_seq=4, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=1560258.8319406067, Q_cold=1316330.320852139), + note=( + 'match 5 (H2-C5, above) duty lowered by 0.168 kJ/hr from the designed 121865.50525344802:' + ' on exact planned states its internal approach was 9.99996307 K (the original check used' + ' the simulated HXprocess, whose dT limit absorbed the shortfall)' + ), + ), + dict( + name='rtA15_13_streams', + kind='real_thermo', + description=( + '13 streams: a 5 bar steam condenser sets the pinch; ethanol ' + 'desuperheat+condense+subcool, water/ethanol glide condenser, methanol heated+boiled (2 ' + 'bar), water boiler (3 bar), ethanol/methanol liquid, superheated steam heating; ' + '13-exchanger certificate.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 20.0}, 'dew', 'bubble', 500000.0, 'l', True), + ('H2', {'Water': 50.0}, 450.0, 330.0, 1000000.0, 'l', False), + ('H3', {'Ethanol': 50.0}, 400.0, 330.0, 101325.0, 'g', True), + ('H4', {'Methanol': 40.0}, 380.0, 300.0, 500000.0, 'l', False), + ('H5', {'Water': 18.0, 'Ethanol': 2.0}, 385.0, 350.0, 101325.0, 'g', True), + ('H6', {'Water': 60.0}, 360.0, 310.0, 101325.0, 'l', False), + ('C1', {'Water': 150.0}, 290.0, 340.0, 101325.0, 'l', False), + ('C2', {'Methanol': 20.0}, 300.0, 'dew', 200000.0, 'l', True), + ('C3', {'Ethanol': 30.0}, 320.0, 390.0, 500000.0, 'l', False), + ('C4', {'Water': 15.0}, 'bubble', 'dew', 300000.0, 'l', True), + ('C5', {'Water': 30.0}, 330.0, 440.0, 1000000.0, 'l', False), + ('C6', {'Ethanol': 20.0, 'Methanol': 20.0}, 300.0, 340.0, 101325.0, 'l', False), + ('C7', {'Water': 130.0}, 400.0, 460.0, 101325.0, 'g', True), + ], + reference=dict(Q_hot=162883.29807923216, Q_cold=2133923.14180115, pinch_T_shifted=414.9810791030153, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H2', cold='C7', Q=98033.26346495637, side='above', hot_seq=1, cold_seq=2), + dict(hot='H2', cold='C5', Q=193894.0523444919, side='below', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C7', Q=66853.02291096421, side='below', hot_seq=1, cold_seq=1), + dict(hot='H1', cold='C4', Q=588529.5104252113, side='below', hot_seq=2, cold_seq=1), + dict(hot='H1', cold='C3', Q=90054.93925884178, side='below', hot_seq=3, cold_seq=3), + dict(hot='H3', cold='C3', Q=83579.70977752375, side='below', hot_seq=1, cold_seq=2), + dict(hot='H2', cold='C2', Q=32128.79617349571, side='below', hot_seq=3, cold_seq=4), + dict(hot='H3', cold='C2', Q=50121.82774911004, side='below', hot_seq=2, cold_seq=3), + dict(hot='H4', cold='C2', Q=56991.90964679243, side='below', hot_seq=1, cold_seq=2), + dict(hot='H5', cold='C2', Q=648353.761543988, side='below', hot_seq=1, cold_seq=1), + dict(hot='H6', cold='C3', Q=114581.32092974387, side='below', hot_seq=1, cold_seq=1), + dict(hot='H5', cold='C6', Q=165583.817636755, side='below', hot_seq=2, cold_seq=1), + dict(hot='H3', cold='C1', Q=565056.8769924915, side='below', hot_seq=3, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=162883.30622663576, Q_cold=2133923.149963759), + note=( + 'match 2 (H1-C7, below) duty lowered by 0.00377 kJ/hr from the designed 66853.026678144: ' + 'on exact planned states its internal approach was 9.999999158 K (the original check used' + ' the simulated HXprocess, whose dT limit absorbed the shortfall)' + ), + ), + dict( + name='rtA16_vapor_sensible', + kind='real_thermo', + description=( + 'Sensible superheated steam cooling (480 -> 400 K) and superheated ethanol vapor heating ' + '(356 -> 450 K), methanol isothermal condenser (3 bar), ethanol ' + 'desuperheat+condense+subcool (2 bar) whose condensation sets the pinch and serves two ' + 'cold streams below in series.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 100.0}, 480.0, 400.0, 101325.0, 'g', True), + ('H2', {'Methanol': 30.0}, 'dew', 'bubble', 300000.0, 'l', True), + ('H3', {'Ethanol': 20.0}, 440.0, 330.0, 200000.0, 'g', True), + ('C1', {'Ethanol': 60.0}, 356.0, 450.0, 101325.0, 'g', True), + ('C2', {'Water': 60.0}, 300.0, 360.0, 101325.0, 'l', False), + ('C3', {'Methanol': 50.0}, 300.0, 330.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=51784.17096424871, Q_cold=1466346.9426415022, pinch_T_shifted=359.8572114718511, grid_h=0.25, nV=201), + certificate=[ + dict(hot='H3', cold='C1', Q=114966.52074040612, side='above', hot_seq=1, cold_seq=2), + dict(hot='H1', cold='C1', Q=277033.7096281294, side='above', hot_seq=1, cold_seq=3), + dict(hot='H3', cold='C2', Q=270841.59293253225, side='below', hot_seq=2, cold_seq=1), + dict(hot='H3', cold='C1', Q=17266.598586789332, side='below', hot_seq=3, cold_seq=1), + dict(hot='H2', cold='C3', Q=127339.74354343598, side='below', hot_seq=1, cold_seq=1), + ], + certificate_utilities=dict(Q_hot=51784.251091282465, Q_cold=1466347.0227685352), + ), +] + +SPLIT = [ + # ----- constant-CP literature problems ----- + dict( + name='cornell_processdesign_four_stream_split', + kind='constant_cp', + citation=( + "Cornell University Process Design Wiki (processdesign), 'Pinch analysis' worked example " + '(after Towler & Sinnott 2013, Chemical Engineering Design, 2nd ed.).' + ), + source_url='https://design.cbe.cornell.edu/index.php?title=Pinch_analysis', + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 180, 40, 40.0), + ('2', 'hot', 150, 60, 30.0), + ('3', 'cold', 30, 180, 60.0), + ('4', 'cold', 80, 160, 20.0), + ], + targets=dict(Q_hot=2900.0, Q_cold=600.0, pinch_hot=100.0, pinch_cold=80.0), + published=dict(Q_hot=(2900, 1.0), Q_cold=(600, 1.0), pinch_hot=(100, 0.0), pinch_cold=(80, 0.0)), + proof=dict( + side='above', rule='cp', pinch_hot=100.0, pinch_cold=80.0, + hot_at_pinch=['1', '2'], + cold_at_pinch=['3', '4'], + ), + ), + dict( + name='nptel_t4_4_four_stream_dT20', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 4 Lecture 13, Table 4.4 (modified problem table with multiple utilities).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module4/lecture4/lecture4' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 150, 60, 2.5), + ('2', 'hot', 90, 60, 8.0), + ('3', 'cold', 20, 125, 3.0), + ('4', 'cold', 25, 100, 3.0), + ], + targets=dict(Q_hot=105.0, Q_cold=30.0, pinch_hot=90.0, pinch_cold=70.0), + published=dict(Q_hot=(105, 1.0), Q_cold=(30, 1.0), pinch_hot=None, pinch_cold=None), + proof=dict( + side='below', rule='cp', pinch_hot=90.0, pinch_cold=70.0, + hot_at_pinch=['1', '2'], + cold_at_pinch=['3', '4'], + ), + ), + dict( + name='nptel_t5_3_four_stream_split', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 5 Lecture 28, Table 5.3 (designs Figs. 5.21-5.23).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module5/lecture3/lecture3' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 200, 65, 3.0), + ('2', 'hot', 90, 30, 6.0), + ('3', 'cold', 30, 142, 3.5), + ('4', 'cold', 25, 130, 4.0), + ], + targets=dict(Q_hot=87.0, Q_cold=40.0, pinch_hot=90.0, pinch_cold=80.0), + published=dict(Q_hot=(87, 1.0), Q_cold=(40, 1.0), pinch_hot=(90, 0.0), pinch_cold=(80, 0.0)), + proof=dict( + side='below', rule='cp', pinch_hot=90.0, pinch_cold=80.0, + hot_at_pinch=['1', '2'], + cold_at_pinch=['3', '4'], + ), + ), + dict( + name='smith2005_ex18_2_split', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Example 18.2, ' + 'Table 18.2; MER design Fig. 18.17.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 720, 320, 0.045), + ('2', 'hot', 520, 220, 0.04), + ('3', 'cold', 300, 900, 0.043), + ('4', 'cold', 200, 550, 0.02), + ], + targets=dict(Q_hot=9.2, Q_cold=6.4, pinch_hot=520.0, pinch_cold=500.0), + published=dict(Q_hot=(9.2, 0.1), Q_cold=(6.4, 0.1), pinch_hot=(520, 0.0), pinch_cold=(500, 0.0)), + proof=dict( + side='above', rule='cp', pinch_hot=520.0, pinch_cold=500.0, + hot_at_pinch=['1'], + cold_at_pinch=['3', '4'], + ), + ), + dict( + name='smith2005_ex18_4_split', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Example 18.4, ' + 'Table 18.5; designs Figs. 18.22-18.24 (same data as Exercise 18.6, Table 18.13).' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 150, 50, 0.2), + ('2', 'hot', 170, 40, 0.1), + ('3', 'cold', 50, 120, 0.3), + ('4', 'cold', 80, 110, 0.5), + ], + targets=dict(Q_hot=7.0, Q_cold=4.0, pinch_hot=90.0, pinch_cold=80.0), + published=dict(Q_hot=(7.0, 1.0), Q_cold=(4.0, 1.0), pinch_hot=(90, 0.0), pinch_cold=(80, 0.0)), + proof=dict( + side='below', rule='cp', pinch_hot=90.0, pinch_cold=80.0, + hot_at_pinch=['1', '2'], + cold_at_pinch=['3'], + ), + ), + dict( + name='smith2005_ex16_5_five_stream', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 16, Example 16.5, ' + 'Table 16.7 / cascade Table 16.8.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2016.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 450, 50, 0.25), + ('2', 'hot', 50, 40, 1.5), + ('3', 'cold', 30, 400, 0.22), + ('4', 'cold', 30, 400, 0.05), + ('5', 'cold', 120, 121, 22.0), + ], + targets=dict(Q_hot=21.900000000000002, Q_cold=15.0, pinch_hot=50.0, pinch_cold=30.0), + published=dict(Q_hot=(21.9, 0.1), Q_cold=(15.0, 1.0), pinch_hot=(50, 0.0), pinch_cold=(30, 0.0)), + proof=dict( + side='above', rule='cp', pinch_hot=50.0, pinch_cold=30.0, + hot_at_pinch=['1'], + cold_at_pinch=['3', '4'], + ), + ), + dict( + name='smith2005_exr18_4', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Exercise 4, ' + 'Table 18.10.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'cold', 18, 123, 0.0933), + ('2', 'cold', 118, 193, 0.1961), + ('3', 'cold', 189, 286, 0.1796), + ('4', 'hot', 159, 77, 0.2285), + ('5', 'hot', 267, 80, 0.0204), + ('6', 'hot', 343, 90, 0.0538), + ], + targets=dict(Q_hot=13.9472, Q_cold=8.1852, pinch_hot=159.0, pinch_cold=149.0), + published=dict(Q_hot=(13.95, 0.01), Q_cold=(8.18, 0.01), pinch_hot=(159, 0.0), pinch_cold=(149, 0.0)), + proof=dict( + side='above', rule='number', pinch_hot=159.0, pinch_cold=149.0, + hot_at_pinch=['5', '6'], + cold_at_pinch=['2'], + ), + ), + dict( + name='smith2005_exr18_7_six_stream', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Exercise 7, ' + 'Tables 18.14 and 18.15.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 170, 88, 23.0), + ('2', 'hot', 278, 90, 2.0), + ('3', 'hot', 354, 100, 5.0), + ('4', 'cold', 30, 135, 9.0), + ('5', 'cold', 130, 205, 20.0), + ('6', 'cold', 200, 298, 18.0), + ], + targets=dict(Q_hot=1528.0, Q_cold=851.0, pinch_hot=170.0, pinch_cold=160.0), + published=dict(Q_hot=(1528.0, 1.0), Q_cold=(851.0, 1.0), pinch_hot=(170, 0.0), pinch_cold=(160, 0.0)), + proof=dict( + side='above', rule='number', pinch_hot=170.0, pinch_cold=160.0, + hot_at_pinch=['2', '3'], + cold_at_pinch=['5'], + ), + ), + dict( + name='7sp3_dt20F', + kind='constant_cp', + citation=( + 'Problem 7SP3 (crude-oil preheat train; Grossmann & Sargent 1978, Comput. Chem. Eng. 2, ' + '1) as tabulated in Muraki, M.; Hayakawa, T. (1982). Practical synthesis method for heat ' + 'exchanger network. J. Chem. Eng. Japan 15(2), 136-141, Table 1 (stream data; design ' + 'data: minimum allowable approach temperature 20 F for exchangers); targets from Muraki &' + " Hayakawa (1982) Table 2 'Problem table for 7SP3' and text." + ), + source_url='https://www.jstage.jst.go.jp/article/jcej1968/15/2/15_2_136/_pdf/-char/ja', + T_unit='F', Q_unit='Btu/hr', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 675, 150, 153018), + ('H2', 'hot', 592, 452, 111220), + ('H3', 'hot', 543, 115, 45507), + ('H4', 'hot', 428, 344, 598500), + ('H5', 'hot', 398, 100, 116482), + ('H6', 'hot', 301, 231, 1277434), + ('C1', 'cold', 60, 710, 478351), + ], + targets=dict(Q_hot=85862451.0, Q_cold=64722563.0, pinch_hot=428.0, pinch_cold=408.0), + published=dict(Q_hot=(85900000.0, 100000.0), Q_cold=(64700000.0, 100000.0), pinch_hot=(428, 0.0), pinch_cold=(408, 0.0)), + proof=dict( + side='above', rule='number', pinch_hot=428.0, pinch_cold=408.0, + hot_at_pinch=['H1', 'H3'], + cold_at_pinch=['C1'], + ), + note=( + 'published targets: Muraki & Hayakawa report 85.9e6 / 64.7e6 Btu/hr (three significant ' + 'figures)' + ), + ), + dict( + name='7sp4_dolan1990_dt10K', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '7sp4' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Dolan, W.B.; Cummings, P.T.; LeVan, M.D. (1990). Algorithmic ' + 'efficiency of simulated annealing for heat exchanger network design. Comput. Chem. Eng. ' + '14, 1039 (7SP4).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/7sp4.dat (targets: ' + 'data/mip_instances/furman_sahinidis/7sp4.dat)' + ), + T_unit='K', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 630.555, 338.888, 7.913), + ('H2', 'hot', 583.333, 505.555, 5.803), + ('H3', 'hot', 555.555, 319.444, 2.374), + ('H4', 'hot', 494.444, 447.222, 31.652), + ('H5', 'hot', 477.777, 311.111, 6.3305), + ('H6', 'hot', 422.222, 383.333, 65.943), + ('C1', 'cold', 288.888, 650.0, 24.795), + ], + targets=dict(Q_hot=2431.4914290000006, Q_cold=1911.7607919999969, pinch_hot=494.444, pinch_cold=484.444), + published=dict(Q_hot=(2431.491429, 1e-06), Q_cold=(1911.760792, 1e-06), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=494.444, pinch_cold=484.444, + hot_at_pinch=['H1', 'H3'], + cold_at_pinch=['C1'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='8sp1_dt20F', + kind='constant_cp', + citation=( + 'Problem 8SP1 (Grossmann & Sargent 1978, Comput. Chem. Eng. 2, 1, per the ' + 'Furman-Sahinidis reference list) as tabulated in Muraki, M.; Hayakawa, T. (1982). ' + 'Practical synthesis method for heat exchanger network. J. Chem. Eng. Japan 15(2), ' + '136-141, Table 1 (stream data; design data: minimum allowable approach temperature 20 F ' + 'for exchangers).' + ), + source_url='https://www.jstage.jst.go.jp/article/jcej1968/15/2/15_2_136/_pdf/-char/ja', + T_unit='F', Q_unit='Btu/hr', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 470, 320, 22400), + ('H2', 'hot', 450, 240, 17500), + ('H3', 'hot', 370, 150, 28500), + ('H4', 'hot', 310, 200, 20100), + ('C1', 'cold', 200, 420, 16800), + ('C2', 'cold', 150, 400, 23200), + ('C3', 'cold', 185, 330, 35100), + ('C4', 'cold', 140, 300, 17250), + ], + targets=dict(Q_hot=2227000.0, Q_cold=397500.0, pinch_hot=170.0, pinch_cold=150.0), + published=None, + proof=dict( + side='above', rule='cp', pinch_hot=170.0, pinch_cold=150.0, + hot_at_pinch=['H3'], + cold_at_pinch=['C2', 'C4'], + ), + ), + dict( + name='8sp1_sargent1978_dt10F', + kind='constant_cp', + citation=( + 'Furman, K.C.; Sahinidis, N.V. (2004). Approximation algorithms for the minimum number of' + ' matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565; instance '8sp1' as digitized in Letsios, D.; Kouyialis, G.; Misener, R. " + '(2018). Heuristics with performance guarantees for the minimum number of matches problem' + ' in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (GitHub data set). ' + 'Original problem: Grossmann, I.E.; Sargent, R.W.H. (1978). Optimum design of heat ' + 'exchanger networks. Comput. Chem. Eng. 2, 1 (8SP1).' + ), + source_url=( + 'https://github.com/cog-imperial/min_matches_heuristics/blob/master/data/original_instanc' + 'es/furman_sahinidis/dat_files/8sp1.dat (targets: ' + 'data/mip_instances/furman_sahinidis/8sp1.dat)' + ), + T_unit='F', Q_unit='1e3 Btu/hr', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 470.0, 320.0, 22.4), + ('H2', 'hot', 450.0, 240.0, 17.5), + ('H3', 'hot', 370.0, 150.0, 28.5), + ('H4', 'hot', 310.0, 200.0, 20.1), + ('C1', 'cold', 200.0, 420.0, 16.8), + ('C2', 'cold', 150.0, 400.0, 23.2), + ('C3', 'cold', 185.0, 330.0, 35.1), + ('C4', 'cold', 140.0, 300.0, 17.25), + ], + targets=dict(Q_hot=1942.0, Q_cold=112.5, pinch_hot=160.0, pinch_cold=150.0), + published=dict(Q_hot=(1942.0, 1.0), Q_cold=(112.5, 0.1), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='cp', pinch_hot=160.0, pinch_cold=150.0, + hot_at_pinch=['H3'], + cold_at_pinch=['C2', 'C4'], + ), + ), + dict( + name='nptel_t5_7_eight_stream_split', + kind='constant_cp', + citation=( + "Mohanty B (IIT Roorkee). NPTEL course 103107094 'Process Integration' lecture notes, " + 'Module 5 Lecture 30, Table 5.7 (MER design Figs. 5.34-5.36).' + ), + source_url=( + 'https://archive.nptel.ac.in/content/storage2/courses/103107094/module5/lecture5/lecture5' + '.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H-1', 'hot', 150, 40, 0.1), + ('H-2', 'hot', 140, 30, 0.15), + ('H-3', 'hot', 130, 25, 0.15), + ('H-4', 'hot', 150, 30, 0.2), + ('C-1', 'cold', 20, 140, 0.1), + ('C-2', 'cold', 15, 130, 0.15), + ('C-3', 'cold', 25, 145, 0.25), + ('C-4', 'cold', 80, 140, 0.3), + ], + targets=dict(Q_hot=16.25, Q_cold=6.25, pinch_hot=90.0, pinch_cold=80.0), + published=dict(Q_hot=(16.25, 0.01), Q_cold=(6.25, 0.01), pinch_hot=(90, 0.0), pinch_cold=(80, 0.0)), + proof=dict( + side='below', rule='cp', pinch_hot=90.0, pinch_cold=80.0, + hot_at_pinch=['H-1', 'H-2', 'H-3', 'H-4'], + cold_at_pinch=['C-1', 'C-2', 'C-3'], + ), + ), + dict( + name='smith2005_exr18_5_nine_stream', + kind='constant_cp', + citation=( + 'Smith R (2005). Chemical Process Design and Integration. Wiley, Ch. 18, Exercise 5, ' + 'Tables 18.11 and 18.12.' + ), + source_url='https://www.ou.edu/class/che-design/che5480-13/Smith-Chapter%2018.pdf', + T_unit='C', Q_unit='MW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('1', 'hot', 120, 65, 0.5), + ('2', 'hot', 80, 50, 0.3), + ('3', 'hot', 135, 110, 0.29), + ('4', 'hot', 220, 95, 0.02), + ('5', 'hot', 135, 105, 0.26), + ('6', 'cold', 65, 90, 0.15), + ('7', 'cold', 75, 200, 0.14), + ('8', 'cold', 30, 210, 0.1), + ('9', 'cold', 60, 140, 0.05), + ], + targets=dict(Q_hot=20.95, Q_cold=31.75, pinch_hot=135.0, pinch_cold=115.0), + published=dict(Q_hot=(20.95, 0.01), Q_cold=(31.75, 0.01), pinch_hot=(135, 0.0), pinch_cold=(115, 0.0)), + proof=dict( + side='below', rule='cp', pinch_hot=135.0, pinch_cold=115.0, + hot_at_pinch=['3', '4', '5'], + cold_at_pinch=['7', '8', '9'], + ), + ), + dict( + name='crude_fractionation_ph11c2', + kind='constant_cp', + citation=( + 'Yang, Z., Zhang, N. & Smith, R. (2022). Enhanced superstructure optimization for heat ' + 'exchanger network synthesis using deterministic approach. Front. Sustain. 3:976717, ' + 'Supplementary Table S.3 (case study 3, crude oil fractionation); original problem: Kim, ' + 'S.Y., Jongsuwat, P., Suriyapraphadilok, U. & Bagajewicz, M. (2017). Global optimization ' + 'of heat exchanger networks. Part 1: Stages/substages superstructure. Ind. Eng. Chem. ' + 'Res. 56, 5944-5957, with heat capacities corrected by Pavao, Costa & Ravagnani (2018b) ' + 'Ind. Eng. Chem. Res. 57, 2560-2573.' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/976717/file/Data_Sheet_1.docx/9' + '76717_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 140.2, 39.5, 106.5), + ('H2', 'hot', 248.8, 110, 31.81), + ('H3', 'hot', 170.1, 60, 33.93), + ('H4', 'hot', 277, 121.9, 24.58), + ('H5', 'hot', 250.6, 90, 132.2), + ('H6', 'hot', 210, 163, 115.76), + ('H7', 'hot', 303.6, 270.2, 234.98), + ('H8', 'hot', 360, 290, 39.81), + ('H9', 'hot', 178.6, 108.9, 47.85), + ('H10', 'hot', 359.6, 280, 24.53), + ('H11', 'hot', 290, 115, 39.81), + ('C1', 'cold', 30, 130, 202.48), + ('C2', 'cold', 130, 350, 289.92), + ], + targets=dict(Q_hot=19467.17199999999, Q_cold=7686.155999999995, pinch_hot=210.0, pinch_cold=200.0), + published=None, + proof=dict( + side='above', rule='number', pinch_hot=210.0, pinch_cold=200.0, + hot_at_pinch=['H2', 'H4', 'H5', 'H11'], + cold_at_pinch=['C2'], + ), + ), + dict( + name='bagajewicz_ou_crude_unit_15_stream', + kind='constant_cp', + citation=( + "Bagajewicz M (2013). CHE 5480 'Problems - Pinch Technology', Problem 2 (crude unit), " + 'University of Oklahoma.' + ), + source_url=( + 'https://www.ou.edu/class/che-design/che5480-13/Problems%20Pinch%20Technology.pdf' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=20, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('TCR', 'hot', 134.0, 108.2, 1007.8), + ('MCR', 'hot', 227.7, 190.8, 356.19), + ('LCR', 'hot', 268.1, 198.8, 403.31), + ('KER', 'hot', 232.7, 40.0, 120.03), + ('LGO', 'hot', 238.2, 45.0, 44.43), + ('HGO', 'hot', 279.3, 68.0, 70.57), + ('LR1', 'hot', 335.9, 262.7, 139.55), + ('LR2', 'hot', 335.9, 186.1, 70.65), + ('LR3', 'hot', 239.4, 186.2, 236.3), + ('LR4', 'hot', 186.2, 90.0, 77.23), + ('NAP', 'hot', 115.2, 56.0, 1460.04), + ('C1', 'cold', 39.0, 153.0, 523.35), + ('C2', 'cold', 153.0, 165.2, 619.8), + ('C3', 'cold', 155.3, 348.0, 585.68), + ('C4', 'cold', 150.0, 270.0, 175.8), + ], + targets=dict(Q_hot=56704.56799999998, Q_cold=96477.18799999998, pinch_hot=134.0, pinch_cold=114.0), + published=None, + proof=dict( + side='above', rule='number', pinch_hot=134.0, pinch_cold=114.0, + hot_at_pinch=['KER', 'LGO', 'HGO', 'LR4'], + cold_at_pinch=['C1'], + ), + ), + dict( + name='bjork_pettersson_15sp_ph8c7', + kind='constant_cp', + citation=( + 'Caballero, J.A., Pavao, L.V., Costa, C.B.B. & Ravagnani, M.A.S.S. (2021). A Novel ' + 'Sequential Approach for the Design of Heat Exchanger Networks. Front. Chem. Eng. ' + '3:733186, Supplementary Data Sheet 1, Table S21 (Example 11, PH8C7); original problem: ' + 'Bjork, K.M. & Pettersson, F. (2003). Optimization of large-scale heat exchanger network ' + 'synthesis problems. Proc. IASTED Modelling and Simulation, 313-318.' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/733186/file/Data_Sheet_1.docx/7' + '33186_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 180.0, 75.0, 30.0), + ('H2', 'hot', 280.0, 120.0, 60.0), + ('H3', 'hot', 180.0, 75.0, 30.0), + ('H4', 'hot', 140.0, 40.0, 30.0), + ('H5', 'hot', 220.0, 120.0, 50.0), + ('H6', 'hot', 180.0, 55.0, 35.0), + ('H7', 'hot', 200.0, 60.0, 30.0), + ('H8', 'hot', 120.0, 40.0, 100.0), + ('C1', 'cold', 40.0, 230.0, 20.0), + ('C2', 'cold', 100.0, 220.0, 60.0), + ('C3', 'cold', 40.0, 190.0, 35.0), + ('C4', 'cold', 50.0, 190.0, 30.0), + ('C5', 'cold', 50.0, 250.0, 60.0), + ('C6', 'cold', 90.0, 190.0, 50.0), + ('C7', 'cold', 160.0, 250.0, 60.0), + ], + targets=dict(Q_hot=8900.0, Q_cold=6525.0, pinch_hot=140.0, pinch_cold=130.0), + published=None, + proof=dict( + side='above', rule='cp', pinch_hot=140.0, pinch_cold=130.0, + hot_at_pinch=['H1', 'H2', 'H3', 'H5', 'H6', 'H7'], + cold_at_pinch=['C1', 'C2', 'C3', 'C4', 'C5', 'C6'], + ), + ), + dict( + name='fs_15sp_tkm', + kind='constant_cp', + citation=( + 'Furman, K.C. & Sahinidis, N.V. (2004). Approximation algorithms for the minimum number ' + 'of matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565, test instance '15sp-tkm' (original source per the test set's references.txt: " + "'kokossis:2000' (Tantimuratha, Kokossis & Mueller, 2000)); digitized in Letsios, D., " + 'Kouyialis, G. & Misener, R. (2018). Heuristics with performance guarantees for the ' + 'minimum number of matches problem in heat recovery network design. Comput. Chem. Eng. ' + '113, 57-85 (data repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/furman_sahinidis/dat_files/15sp-tkm.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS1', 'hot', 137, 14, 5.3), + ('HS2', 'hot', 135, 66, 16.8), + ('HS3', 'hot', 111, 110, 3327), + ('HS4', 'hot', 105, 95, 135.4), + ('HS5', 'hot', 76, 66, 134.6), + ('HS6', 'hot', 75, 74, 928), + ('HS7', 'hot', 66, 65, 840), + ('HS8', 'hot', 65, 64, 12000), + ('HS9', 'hot', 37, 14, 324), + ('CS1', 'cold', 4, 112, 56.6), + ('CS2', 'cold', 76, 100, 108.1), + ('CS3', 'cold', 70, 90, 205), + ('CS4', 'cold', 38, 85, 18.4), + ('CS5', 'cold', 45, 70, 280.1), + ('CS6', 'cold', 4, 45, 314), + ], + targets=dict(Q_hot=5828.5, Q_cold=1338.0999999999976, pinch_hot=66.0, pinch_cold=56.0), + published=dict(Q_hot=(5828.5, 0.1), Q_cold=(1338.1, 0.1), pinch_hot=None, pinch_cold=None), + proof=dict( + side='below', rule='number', pinch_hot=66.0, pinch_cold=56.0, + hot_at_pinch=['HS1', 'HS7'], + cold_at_pinch=['CS1', 'CS4', 'CS5'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='cgm_balanced8', + kind='constant_cp', + citation=( + 'Chen, Y., Grossmann, I.E. & Miller, D.C. (2015). Computational strategies for ' + 'large-scale MILP transshipment models for heat exchanger network synthesis. Comput. ' + "Chem. Eng. 82, 68-83, benchmark instance 'balanced8' (minlp.org); as distributed in " + 'Letsios, D., Kouyialis, G. & Misener, R. (2018). Heuristics with performance guarantees ' + 'for the minimum number of matches problem in heat recovery network design. Comput. Chem.' + ' Eng. 113, 57-85 (data repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/chen_grossmann_miller/dat_files/balanced8.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS0', 'hot', 400.0, 120.0, 1.0), + ('HS1', 'hot', 340.0, 120.0, 2.0), + ('HS2', 'hot', 380.0, 150.0, 1.5), + ('HS3', 'hot', 300.0, 100.0, 2.5), + ('HS4', 'hot', 420.0, 160.0, 1.7), + ('HS5', 'hot', 390.0, 110.0, 0.8), + ('HS6', 'hot', 360.0, 200.0, 1.2), + ('HS7', 'hot', 280.0, 130.0, 1.8), + ('CS0', 'cold', 160.0, 400.0, 1.5), + ('CS1', 'cold', 100.0, 250.0, 1.3), + ('CS2', 'cold', 50.0, 300.0, 2.5), + ('CS3', 'cold', 200.0, 380.0, 2.8), + ('CS4', 'cold', 150.0, 450.0, 1.9), + ('CS5', 'cold', 100.0, 180.0, 0.8), + ('CS6', 'cold', 200.0, 350.0, 1.7), + ('CS7', 'cold', 120.0, 330.0, 1.6), + ], + targets=dict(Q_hot=320.0, Q_cold=104.0, pinch_hot=210.0, pinch_cold=200.0), + published=dict(Q_hot=(320, 1.0), Q_cold=(104, 1.0), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=210.0, pinch_cold=200.0, + hot_at_pinch=['HS0', 'HS1', 'HS2', 'HS3', 'HS4', 'HS5', 'HS6', 'HS7'], + cold_at_pinch=['CS0', 'CS1', 'CS2', 'CS3', 'CS4', 'CS6', 'CS7'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='cgm_balanced10', + kind='constant_cp', + citation=( + 'Chen, Y., Grossmann, I.E. & Miller, D.C. (2015). Computational strategies for ' + 'large-scale MILP transshipment models for heat exchanger network synthesis. Comput. ' + "Chem. Eng. 82, 68-83, benchmark instance 'balanced10' (minlp.org); as distributed in " + 'Letsios, D., Kouyialis, G. & Misener, R. (2018). Heuristics with performance guarantees ' + 'for the minimum number of matches problem in heat recovery network design. Comput. Chem.' + ' Eng. 113, 57-85 (data repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/chen_grossmann_miller/dat_files/balanced10.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS0', 'hot', 400.0, 120.0, 1.0), + ('HS1', 'hot', 340.0, 120.0, 2.0), + ('HS2', 'hot', 380.0, 150.0, 1.5), + ('HS3', 'hot', 300.0, 100.0, 2.5), + ('HS4', 'hot', 420.0, 160.0, 1.7), + ('HS5', 'hot', 390.0, 110.0, 0.8), + ('HS6', 'hot', 360.0, 200.0, 1.2), + ('HS7', 'hot', 280.0, 130.0, 1.8), + ('HS8', 'hot', 250.0, 80.0, 1.1), + ('HS9', 'hot', 330.0, 170.0, 1.3), + ('CS0', 'cold', 160.0, 400.0, 1.5), + ('CS1', 'cold', 100.0, 250.0, 1.3), + ('CS2', 'cold', 50.0, 300.0, 2.5), + ('CS3', 'cold', 200.0, 380.0, 2.8), + ('CS4', 'cold', 150.0, 450.0, 1.9), + ('CS5', 'cold', 100.0, 180.0, 0.8), + ('CS6', 'cold', 200.0, 350.0, 1.7), + ('CS7', 'cold', 120.0, 330.0, 1.6), + ('CS8', 'cold', 110.0, 220.0, 0.9), + ('CS9', 'cold', 190.0, 360.0, 2.1), + ], + targets=dict(Q_hot=474.0, Q_cold=197.0, pinch_hot=210.0, pinch_cold=200.0), + published=dict(Q_hot=(474, 1.0), Q_cold=(197, 1.0), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=210.0, pinch_cold=200.0, + hot_at_pinch=['HS0', 'HS1', 'HS2', 'HS3', 'HS4', 'HS5', 'HS6', 'HS7', 'HS8', 'HS9'], + cold_at_pinch=['CS0', 'CS1', 'CS2', 'CS3', 'CS4', 'CS6', 'CS7', 'CS8', 'CS9'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='cgm_unbalanced10', + kind='constant_cp', + citation=( + 'Chen, Y., Grossmann, I.E. & Miller, D.C. (2015). Computational strategies for ' + 'large-scale MILP transshipment models for heat exchanger network synthesis. Comput. ' + "Chem. Eng. 82, 68-83, benchmark instance 'unbalanced10' (minlp.org); as distributed in " + 'Letsios, D., Kouyialis, G. & Misener, R. (2018). Heuristics with performance guarantees ' + 'for the minimum number of matches problem in heat recovery network design. Comput. Chem.' + ' Eng. 113, 57-85 (data repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/chen_grossmann_miller/dat_files/unbalanced10.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS0', 'hot', 400.0, 120.0, 6.0), + ('HS1', 'hot', 340.0, 120.0, 2.0), + ('HS2', 'hot', 380.0, 150.0, 0.5), + ('HS3', 'hot', 300.0, 100.0, 8.0), + ('HS4', 'hot', 420.0, 160.0, 3.0), + ('HS5', 'hot', 390.0, 110.0, 4.0), + ('HS6', 'hot', 360.0, 200.0, 0.2), + ('HS7', 'hot', 280.0, 130.0, 0.6), + ('HS8', 'hot', 250.0, 80.0, 1.5), + ('HS9', 'hot', 330.0, 170.0, 4.0), + ('CS0', 'cold', 160.0, 400.0, 14.0), + ('CS1', 'cold', 100.0, 250.0, 3.0), + ('CS2', 'cold', 50.0, 300.0, 0.4), + ('CS3', 'cold', 200.0, 380.0, 2.5), + ('CS4', 'cold', 150.0, 450.0, 2.0), + ('CS5', 'cold', 100.0, 180.0, 6.0), + ('CS6', 'cold', 200.0, 350.0, 1.5), + ('CS7', 'cold', 120.0, 330.0, 0.2), + ('CS8', 'cold', 110.0, 220.0, 5.5), + ('CS9', 'cold', 190.0, 360.0, 3.0), + ], + targets=dict(Q_hot=825.0, Q_cold=755.0, pinch_hot=300.0, pinch_cold=290.0), + published=dict(Q_hot=(825, 1.0), Q_cold=(755, 1.0), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='cp', pinch_hot=300.0, pinch_cold=290.0, + hot_at_pinch=['HS0', 'HS1', 'HS2', 'HS4', 'HS5', 'HS6', 'HS9'], + cold_at_pinch=['CS0', 'CS2', 'CS3', 'CS4', 'CS6', 'CS7', 'CS9'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='luo_20sp_ph10c10', + kind='constant_cp', + citation=( + 'Caballero, J.A., Pavao, L.V., Costa, C.B.B. & Ravagnani, M.A.S.S. (2021). A Novel ' + 'Sequential Approach for the Design of Heat Exchanger Networks. Front. Chem. Eng. ' + '3:733186, Supplementary Data Sheet 1, Table S27 (Example 14, PH10C10); original problem:' + ' Luo, X., Wen, Q.Y. & Fieg, G. (2009). A hybrid genetic algorithm for synthesis of heat ' + 'exchanger networks. Comput. Chem. Eng. 33, 1169-1181.' + ), + source_url=( + 'https://public-pages-files-2025.frontiersin.org/articles/733186/file/Data_Sheet_1.docx/7' + '33186_supplementary-materials_datasheets_1_docx/1' + ), + T_unit='C', Q_unit='kW', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('H1', 'hot', 180.0, 75.0, 30.0), + ('H2', 'hot', 280.0, 120.0, 15.0), + ('H3', 'hot', 180.0, 75.0, 30.0), + ('H4', 'hot', 140.0, 45.0, 30.0), + ('H5', 'hot', 220.0, 120.0, 25.0), + ('H6', 'hot', 180.0, 55.0, 10.0), + ('H7', 'hot', 170.0, 45.0, 30.0), + ('H8', 'hot', 180.0, 50.0, 30.0), + ('H9', 'hot', 280.0, 90.0, 15.0), + ('H10', 'hot', 180.0, 60.0, 30.0), + ('C1', 'cold', 40.0, 230.0, 20.0), + ('C2', 'cold', 120.0, 260.0, 35.0), + ('C3', 'cold', 40.0, 190.0, 35.0), + ('C4', 'cold', 50.0, 190.0, 30.0), + ('C5', 'cold', 50.0, 250.0, 20.0), + ('C6', 'cold', 40.0, 150.0, 10.0), + ('C7', 'cold', 40.0, 150.0, 20.0), + ('C8', 'cold', 120.0, 210.0, 35.0), + ('C9', 'cold', 40.0, 130.0, 35.0), + ('C10', 'cold', 60.0, 120.0, 30.0), + ], + targets=dict(Q_hot=4650.0, Q_cold=500.0, pinch_hot=180.0, pinch_cold=170.0), + published=None, + proof=dict( + side='below', rule='cp', pinch_hot=180.0, pinch_cold=170.0, + hot_at_pinch=['H1', 'H2', 'H3', 'H5', 'H6', 'H8', 'H9', 'H10'], + cold_at_pinch=['C1', 'C2', 'C3', 'C4', 'C5', 'C8'], + ), + ), + dict( + name='fs_22sp1', + kind='constant_cp', + citation=( + 'Furman, K.C. & Sahinidis, N.V. (2004). Approximation algorithms for the minimum number ' + 'of matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565, test instance '22sp1' (original source per the test set's references.txt: " + "'miguel:1998'); digitized in Letsios, D., Kouyialis, G. & Misener, R. (2018). Heuristics" + ' with performance guarantees for the minimum number of matches problem in heat recovery ' + 'network design. Comput. Chem. Eng. 113, 57-85 (data repository ' + 'github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/furman_sahinidis/dat_files/22sp1.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS1', 'hot', 137.8, 51.7, 5.28), + ('HS2', 'hot', 160, 51.7, 10.12), + ('HS3', 'hot', 232.2, 160, 12.93), + ('HS4', 'hot', 187.8, 87.8, 9.6), + ('HS5', 'hot', 232.2, 148.9, 10.98), + ('HS6', 'hot', 160, 93.3, 21.1), + ('HS7', 'hot', 248.9, 187.8, 7.92), + ('HS8', 'hot', 154.4, 93.3, 10.47), + ('HS9', 'hot', 160, 65.6, 15.04), + ('HS10', 'hot', 232.2, 115.6, 13.19), + ('HS11', 'hot', 243.3, 160, 4.414), + ('CS1', 'cold', 37.8, 115.6, 6.89), + ('CS2', 'cold', 54.4, 137.8, 8.23), + ('CS3', 'cold', 65.6, 148.9, 14.86), + ('CS4', 'cold', 43.3, 173.9, 9.23), + ('CS5', 'cold', 173.9, 215.6, 18.47), + ('CS6', 'cold', 82.2, 204.4, 6.87), + ('CS7', 'cold', 173.9, 260, 22.56), + ('CS8', 'cold', 60, 148.9, 9.1), + ('CS9', 'cold', 85, 173.9, 6.33), + ('CS10', 'cold', 65.6, 204.4, 12.23), + ('CS11', 'cold', 176.7, 260, 19.81), + ], + targets=dict(Q_hot=2369.864400000001, Q_cold=647.8105999999993, pinch_hot=183.9, pinch_cold=173.9), + published=dict(Q_hot=(2369.8644, 0.0001), Q_cold=(647.8106, 0.0001), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=183.9, pinch_cold=173.9, + hot_at_pinch=['HS3', 'HS4', 'HS5', 'HS10', 'HS11'], + cold_at_pinch=['CS5', 'CS6', 'CS7', 'CS10'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='fs_22sp_ph', + kind='constant_cp', + citation=( + 'Furman, K.C. & Sahinidis, N.V. (2004). Approximation algorithms for the minimum number ' + 'of matches problem in heat exchanger network synthesis. Ind. Eng. Chem. Res. 43(14), ' + "3554-3565, test instance '22sp-ph' (original source per the test set's references.txt: " + "'polley:1999' (Polley & Heggs, 1999)); digitized in Letsios, D., Kouyialis, G. & " + 'Misener, R. (2018). Heuristics with performance guarantees for the minimum number of ' + 'matches problem in heat recovery network design. Comput. Chem. Eng. 113, 57-85 (data ' + 'repository github.com/cog-imperial/min_matches_heuristics).' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/furman_sahinidis/dat_files/22sp-ph.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS1', 'hot', 66, 65, 12.0), + ('HS2', 'hot', 140, 30, 14), + ('HS3', 'hot', 65, 38, 46), + ('HS4', 'hot', 56, 55, 5.9), + ('HS5', 'hot', 80, 30, 46), + ('HS6', 'hot', 59, 58, 3.5), + ('HS7', 'hot', 121, 38, 18), + ('HS8', 'hot', 59, 58, 9.5), + ('HS9', 'hot', 188, 30, 52.8), + ('HS10', 'hot', 48, 47, 2.0), + ('HS11', 'hot', 50, 49, 3.46), + ('CS1', 'cold', 20, 80, 8), + ('CS2', 'cold', 38, 80, 72), + ('CS3', 'cold', 38, 49, 30), + ('CS4', 'cold', 140, 141, 12.0), + ('CS5', 'cold', 79, 80, 7.6), + ('CS6', 'cold', 38, 80, 16), + ('CS7', 'cold', 120, 121, 4.1), + ('CS8', 'cold', 110, 111, 8.0), + ('CS9', 'cold', 88, 204, 75.2), + ('CS10', 'cold', 66, 67, 2.2), + ('CS11', 'cold', 114, 115, 3.8), + ], + targets=dict(Q_hot=3209.8999999999996, Q_cold=4897.759999999999, pinch_hot=121.0, pinch_cold=111.0), + published=dict(Q_hot=(3209.9, 0.1), Q_cold=(4897.76, 0.01), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=121.0, pinch_cold=111.0, + hot_at_pinch=['HS2', 'HS9'], + cold_at_pinch=['CS9'], + ), + note='heat unit not stated in the source; read as kW', + ), + dict( + name='grossmann_balanced12_r0', + kind='constant_cp', + citation=( + 'Letsios, D., Kouyialis, G. & Misener, R. (2018). Heuristics with performance guarantees ' + 'for the minimum number of matches problem in heat recovery network design. Comput. Chem.' + " Eng. 113, 57-85, instance 'balanced12_random0' ('Randomly generated by random procedure" + " and seed provided as a personal communication from Ignacio Grossmann to Ruth Misener', " + 'per the instance file header); data repository ' + 'github.com/cog-imperial/min_matches_heuristics.' + ), + source_url=( + 'https://raw.githubusercontent.com/cog-imperial/min_matches_heuristics/master/data/origin' + 'al_instances/grossmann_random/dat_files/balanced12_random0.dat' + ), + T_unit='C', Q_unit='unspecified', # CP in Q_unit per T_unit degree + dTmin=10.0, + streams=[ # (name, kind, T_in, T_out, CP) in source units + ('HS0', 'hot', 400.0, 120.0, 1.0), + ('HS1', 'hot', 340.0, 120.0, 1.8), + ('HS2', 'hot', 380.0, 150.0, 1.6), + ('HS3', 'hot', 300.0, 100.0, 2.5), + ('HS4', 'hot', 420.0, 160.0, 1.8), + ('HS5', 'hot', 390.0, 110.0, 0.8), + ('HS6', 'hot', 360.0, 200.0, 1.3), + ('HS7', 'hot', 280.0, 130.0, 1.7), + ('HS8', 'hot', 250.0, 80.0, 1.2), + ('HS9', 'hot', 330.0, 170.0, 1.2), + ('HS10', 'hot', 430.0, 300.0, 2.2), + ('HS11', 'hot', 200.0, 100.0, 2.4), + ('CS0', 'cold', 160.0, 400.0, 1.6), + ('CS1', 'cold', 100.0, 250.0, 1.3), + ('CS2', 'cold', 50.0, 300.0, 2.3), + ('CS3', 'cold', 200.0, 380.0, 2.6), + ('CS4', 'cold', 150.0, 450.0, 2.0), + ('CS5', 'cold', 100.0, 180.0, 0.8), + ('CS6', 'cold', 200.0, 350.0, 1.7), + ('CS7', 'cold', 120.0, 330.0, 1.7), + ('CS8', 'cold', 110.0, 220.0, 0.9), + ('CS9', 'cold', 190.0, 360.0, 2.3), + ('CS10', 'cold', 260.0, 420.0, 1.7), + ('CS11', 'cold', 80.0, 180.0, 1.2), + ], + targets=dict(Q_hot=482.0, Q_cold=323.0, pinch_hot=210.0, pinch_cold=200.0), + published=dict(Q_hot=(482, 1.0), Q_cold=(323, 1.0), pinch_hot=None, pinch_cold=None), + proof=dict( + side='above', rule='number', pinch_hot=210.0, pinch_cold=200.0, + hot_at_pinch=['HS0', 'HS1', 'HS2', 'HS3', 'HS4', 'HS5', 'HS6', 'HS7', 'HS8', 'HS9'], + cold_at_pinch=['CS0', 'CS1', 'CS2', 'CS3', 'CS4', 'CS6', 'CS7', 'CS8', 'CS9'], + ), + note='heat unit not stated in the source; read as kW', + ), + # ----- real-thermodynamics problems ----- + dict( + name='rtB01_above_3h2c_liquids', + kind='real_thermo', + description=( + "Above the pinch (set by H4's supply, 360 K) three hot liquids but only two cold liquids." + ), + chemicals=['Water', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 50.0}, 420.0, 330.0, 500000.0, 'l', False), + ('H2', {'Methanol': 40.0}, 400.0, 320.0, 1000000.0, 'l', False), + ('H3', {'Water': 45.0}, 430.0, 340.0, 1000000.0, 'l', False), + ('H4', {'Water': 200.0}, 360.0, 300.0, 500000.0, 'l', False), + ('C1', {'Water': 100.0}, 320.0, 410.0, 500000.0, 'l', False), + ('C2', {'Water': 100.0}, 330.0, 400.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=199865.70406719862, Q_cold=855202.7793932713, pinch_T_shifted=350.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB02_below_3c2h_liquids', + kind='real_thermo', + description=( + "Below the pinch (set by C4's supply, 350 K) three cold liquids but only two hot liquids." + ), + chemicals=['Water', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 40.0}, 300.0, 380.0, 500000.0, 'l', False), + ('C2', {'Methanol': 35.0}, 310.0, 370.0, 500000.0, 'l', False), + ('C3', {'Water': 40.0}, 320.0, 365.0, 101325.0, 'l', False), + ('H1', {'Water': 80.0}, 420.0, 320.0, 1000000.0, 'l', False), + ('H2', {'Water': 70.0}, 410.0, 330.0, 1000000.0, 'l', False), + ('C4', {'Water': 150.0}, 350.0, 415.0, 500000.0, 'l', False), + ], + reference=dict(Q_hot=313914.13748605247, Q_cold=34769.5691902393, pinch_T_shifted=350.0, grid_h=0.25, nV=201), + proof=dict(side='below', rule='number', hot_at_pinch=['H1', 'H2'], cold_at_pinch=['C1', 'C2', 'C3']), + ), + dict( + name='rtB03_above_hot_vapors', + kind='real_thermo', + description=( + 'Three superheated vapors (steam, methanol, ethanol; all 15+ K above their dew points) ' + "cross the pinch (set by hot water's supply, 405 K) against two cold streams (water " + 'liquid, ethanol vapor).' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 150.0}, 450.0, 390.0, 101325.0, 'g', True), + ('H2', {'Methanol': 100.0}, 430.0, 360.0, 101325.0, 'g', True), + ('H3', {'Ethanol': 60.0}, 440.0, 385.0, 200000.0, 'g', True), + ('H4', {'Water': 200.0}, 405.0, 330.0, 1000000.0, 'l', False), + ('C1', {'Water': 110.0}, 380.0, 440.0, 1000000.0, 'l', False), + ('C2', {'Ethanol': 100.0}, 370.0, 430.0, 101325.0, 'g', True), + ], + reference=dict(Q_hot=130823.82884698862, Q_cold=1216207.413343743, pinch_T_shifted=395.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB04_below_cold_boilers', + kind='real_thermo', + description=( + 'Three cold streams heated to saturated vapor (ethanol, water at 1 atm, methanol at 2 ' + 'bar) cross the pinch as liquids (boiling 11-33 K above it) against two hot streams ' + '(water liquid; 5 bar steam desuperheat+condense+subcool).' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Ethanol': 20.0}, 300.0, 'dew', 101325.0, 'l', True), + ('C2', {'Water': 10.0}, 310.0, 'dew', 101325.0, 'l', True), + ('C3', {'Methanol': 25.0}, 300.0, 'dew', 200000.0, 'l', True), + ('H1', {'Water': 80.0}, 440.0, 320.0, 1000000.0, 'l', False), + ('H2', {'Water': 20.0}, 470.0, 330.0, 500000.0, 'g', True), + ('C4', {'Water': 200.0}, 340.0, 420.0, 1000000.0, 'l', False), + ], + reference=dict(Q_hot=1895887.4494512205, Q_cold=5772.621524257353, pinch_T_shifted=340.0, grid_h=0.25, nV=201), + proof=dict(side='below', rule='number', hot_at_pinch=['H1', 'H2'], cold_at_pinch=['C1', 'C2', 'C3']), + ), + dict( + name='rtB05_above_2h1c', + kind='real_thermo', + description='Minimal: two hot liquids (water, ethanol) vs one cold liquid above the pinch.', + chemicals=['Water', 'Ethanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 60.0}, 400.0, 320.0, 500000.0, 'l', False), + ('H2', {'Ethanol': 30.0}, 390.0, 330.0, 500000.0, 'l', False), + ('C1', {'Water': 150.0}, 310.0, 410.0, 500000.0, 'l', False), + ('H3', {'Water': 200.0}, 360.0, 300.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=370155.00754718046, Q_cold=752527.3075279158, pinch_T_shifted=350.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2'], cold_at_pinch=['C1']), + ), + dict( + name='rtB06_below_2c1h', + kind='real_thermo', + description=( + 'Minimal: two cold liquids (water, methanol) vs one hot liquid below the pinch.' + ), + chemicals=['Water', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 50.0}, 300.0, 360.0, 101325.0, 'l', False), + ('C2', {'Methanol': 40.0}, 305.0, 360.0, 300000.0, 'l', False), + ('H1', {'Water': 120.0}, 430.0, 310.0, 1000000.0, 'l', False), + ('C3', {'Water': 150.0}, 340.0, 420.0, 1000000.0, 'l', False), + ], + reference=dict(Q_hot=331162.5377505963, Q_cold=89804.33369865306, pinch_T_shifted=340.0, grid_h=0.25, nV=201), + proof=dict(side='below', rule='number', hot_at_pinch=['H1'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB07_above_with_cold_glide', + kind='real_thermo', + description=( + "Three hot liquids vs two cold liquids above the pinch (set by H4's supply, 330 K), with " + 'a water/methanol xM=0.2 glide boiler above the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 30.0}, 400.0, 300.0, 500000.0, 'l', False), + ('H2', {'Methanol': 25.0}, 370.0, 305.0, 500000.0, 'l', False), + ('H3', {'Ethanol': 15.0}, 380.0, 310.0, 500000.0, 'l', False), + ('C1', {'Water': 60.0}, 300.0, 360.0, 101325.0, 'l', False), + ('C2', {'Ethanol': 25.0}, 305.0, 345.0, 101325.0, 'l', False), + ('H4', {'Water': 100.0}, 330.0, 300.0, 101325.0, 'l', False), + ('C3', {'Water': 12.0, 'Methanol': 3.0}, 330.0, 372.0, 101325.0, 'l', True), + ], + reference=dict(Q_hot=543031.7081302783, Q_cold=248869.0700432139, pinch_T_shifted=320.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB08_above_steam_creator', + kind='real_thermo', + description=( + 'Three hot streams (water liquid, methanol vapor, ethanol liquid at 10 bar) vs two cold ' + '(water liquid, methanol vapor) above a pinch set by the supply of 1 atm superheated ' + 'steam that condenses 27 K below the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 40.0}, 440.0, 360.0, 1000000.0, 'l', False), + ('H2', {'Methanol': 50.0}, 440.0, 380.0, 200000.0, 'g', True), + ('H3', {'Ethanol': 20.0}, 420.0, 350.0, 1000000.0, 'l', False), + ('H4', {'Water': 150.0}, 400.0, 330.0, 101325.0, 'g', True), + ('C1', {'Water': 100.0}, 350.0, 445.0, 1000000.0, 'l', False), + ('C2', {'Methanol': 50.0}, 360.0, 430.0, 101325.0, 'g', True), + ], + reference=dict(Q_hot=232877.7794391609, Q_cold=6666506.837679262, pinch_T_shifted=390.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB09_below_with_cold_glide', + kind='real_thermo', + description=( + "Two cold liquids vs one hot liquid below the pinch (set by C4's supply, 340 K); " + 'water/methanol xM=0.2 glide boiler (bubble -> dew) above the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 40.0}, 300.0, 365.0, 101325.0, 'l', False), + ('C2', {'Methanol': 35.0}, 305.0, 350.0, 200000.0, 'l', False), + ('H1', {'Water': 100.0}, 420.0, 300.0, 1000000.0, 'l', False), + ('C4', {'Water': 120.0}, 340.0, 410.0, 500000.0, 'l', False), + ('C5', {'Water': 12.0, 'Methanol': 3.0}, 'bubble', 'dew', 101325.0, 'l', True), + ], + reference=dict(Q_hot=820543.2968615409, Q_cold=150099.186024755, pinch_T_shifted=340.0, grid_h=0.25, nV=201), + proof=dict(side='below', rule='number', hot_at_pinch=['H1'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB10_11_streams_above', + kind='real_thermo', + description=( + '11 streams: three hot (water, methanol liquids, ethanol vapor) vs two cold liquids at ' + "the pinch above (set by H6's supply, 360 K); steam condenser, water boiler, ethanol and " + 'water/methanol liquids elsewhere.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 30.0}, 440.0, 330.0, 1000000.0, 'l', False), + ('H2', {'Methanol': 25.0}, 400.0, 320.0, 1000000.0, 'l', False), + ('H3', {'Ethanol': 15.0}, 420.0, 330.0, 101325.0, 'g', True), + ('H4', {'Water': 10.0}, 'dew', 'bubble', 300000.0, 'l', True), + ('H5', {'Water': 40.0}, 350.0, 300.0, 101325.0, 'l', False), + ('H6', {'Water': 150.0}, 360.0, 310.0, 101325.0, 'l', False), + ('C1', {'Water': 60.0}, 320.0, 410.0, 500000.0, 'l', False), + ('C2', {'Methanol': 50.0}, 330.0, 375.0, 500000.0, 'l', False), + ('C3', {'Water': 10.0}, 'bubble', 'dew', 101325.0, 'l', True), + ('C4', {'Ethanol': 30.0}, 300.0, 340.0, 101325.0, 'l', False), + ('C5', {'Water': 24.0, 'Methanol': 6.0}, 300.0, 345.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=50692.99592223286, Q_cold=1037531.089196143, pinch_T_shifted=350.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), + dict( + name='rtB11_12_streams_below', + kind='real_thermo', + description=( + '12 streams: three cold liquids vs two hot (water liquid, methanol vapor) at the pinch ' + "below (set by C4's supply, 360 K); ethanol and methanol condensers, EM glide condenser, " + 'water boiler, water/methanol liquid elsewhere.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('C1', {'Water': 30.0}, 300.0, 368.0, 101325.0, 'l', False), + ('C2', {'Methanol': 25.0}, 310.0, 365.0, 300000.0, 'l', False), + ('C3', {'Ethanol': 15.0}, 305.0, 375.0, 300000.0, 'l', False), + ('H1', {'Water': 90.0}, 440.0, 320.0, 1000000.0, 'l', False), + ('H2', {'Methanol': 40.0}, 420.0, 365.0, 200000.0, 'g', True), + ('C4', {'Water': 150.0}, 360.0, 440.0, 1000000.0, 'l', False), + ('H3', {'Ethanol': 20.0}, 'dew', 'bubble', 101325.0, 'l', True), + ('H4', {'Water': 40.0}, 345.0, 300.0, 101325.0, 'l', False), + ('H5', {'Ethanol': 10.0, 'Methanol': 10.0}, 360.0, 320.0, 101325.0, 'g', True), + ('H6', {'Methanol': 10.0}, 'dew', 'bubble', 101325.0, 'l', True), + ('C5', {'Water': 16.0, 'Methanol': 4.0}, 300.0, 340.0, 101325.0, 'l', False), + ('C6', {'Water': 10.0}, 'bubble', 'dew', 200000.0, 'l', True), + ], + reference=dict(Q_hot=795155.8893279578, Q_cold=2023387.438575476, pinch_T_shifted=360.0, grid_h=0.25, nV=201), + proof=dict(side='below', rule='number', hot_at_pinch=['H1', 'H2'], cold_at_pinch=['C1', 'C2', 'C3']), + ), + dict( + name='rtB12_above_2h1c_cond_boil_below', + kind='real_thermo', + description=( + "Two hot (water liquid, methanol vapor) vs one cold liquid above the pinch (set by H4's " + 'supply, 380 K); methanol condenser and methanol boiler (1 atm) far below the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 40.0}, 440.0, 340.0, 1000000.0, 'l', False), + ('H2', {'Methanol': 60.0}, 430.0, 365.0, 200000.0, 'g', True), + ('H3', {'Methanol': 15.0}, 'dew', 'bubble', 101325.0, 'l', True), + ('H4', {'Water': 150.0}, 380.0, 320.0, 500000.0, 'l', False), + ('C1', {'Water': 100.0}, 350.0, 430.0, 1000000.0, 'l', False), + ('C2', {'Methanol': 6.0}, 'bubble', 'dew', 101325.0, 'l', True), + ], + reference=dict(Q_hot=119397.5250737188, Q_cold=1012224.379402471, pinch_T_shifted=370.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2'], cold_at_pinch=['C1']), + ), + dict( + name='rtB13_above_3h2c_glide_below', + kind='real_thermo', + description=( + 'Three hot (water, ethanol liquids at 10 bar; methanol vapor) vs two cold water liquids ' + "above the pinch (set by H4's supply, 390 K); water/ethanol xE=0.1 glide condenser and " + 'ethanol boiler below the pinch.' + ), + chemicals=['Water', 'Ethanol', 'Methanol'], + T_min_app=10.0, + streams=[ # (ID, {chemical: kmol/hr}, T_in, T_out, P [Pa], phase_in, rigorous) + ('H1', {'Water': 40.0}, 450.0, 350.0, 1000000.0, 'l', False), + ('H2', {'Ethanol': 20.0}, 415.0, 340.0, 1000000.0, 'l', False), + ('H3', {'Methanol': 50.0}, 440.0, 385.0, 300000.0, 'g', True), + ('H4', {'Water': 150.0}, 390.0, 330.0, 500000.0, 'l', False), + ('C1', {'Water': 90.0}, 360.0, 445.0, 1000000.0, 'l', False), + ('C2', {'Water': 50.0}, 370.0, 420.0, 500000.0, 'l', False), + ('H5', {'Water': 18.0, 'Ethanol': 2.0}, 385.0, 345.0, 101325.0, 'g', True), + ('C3', {'Ethanol': 10.0}, 'bubble', 'dew', 101325.0, 'l', True), + ('C4', {'Water': 60.0}, 300.0, 350.0, 101325.0, 'l', False), + ], + reference=dict(Q_hot=205293.29827915056, Q_cold=1028410.1302859504, pinch_T_shifted=380.0, grid_h=0.25, nV=201), + proof=dict(side='above', rule='number', hot_at_pinch=['H1', 'H2', 'H3'], cold_at_pinch=['C1', 'C2']), + ), +] diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 7a91127..6d90b43 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -45,7 +45,7 @@ def network_results(HXN): installed=HXN.installed_costs['Heat exchangers'], ) -def assert_same_results(a, b, rtol=2e-3): +def assert_same_results(a, b, rtol=1e-6): for key in a: assert_allclose(a[key], b[key], rtol=rtol, err_msg=key) @@ -73,7 +73,7 @@ def test_cache_network_perturbed_feed(): cached = network_results(HXN) HXN.cache_network = False sys.simulate() - assert_same_results(cached, network_results(HXN), rtol=1e-3) + assert_same_results(cached, network_results(HXN)) def test_cache_network_duplicate_IDs(): sys, HXN, feed = build_system(N_columns=2) @@ -84,6 +84,58 @@ def test_cache_network_duplicate_IDs(): simulate_cached(sys, HXN) assert_same_results(network_results(HXN), fresh) +def assert_no_phantom_utility_exchangers(HXN): + """Every utility exchanger either has exactly no duty, and so no design + and no cost, or a real duty, and a cost; a duty at the level of flash + noise would be costed as a minimum-size exchanger.""" + for hx, life_cycle in zip(HXN.original_heat_exchangers, HXN.stream_life_cycles): + util = life_cycle.life_cycle[-1].unit + assert isinstance(util, bst.HXutility) + duty = abs(hx.outs[0].H - hx.ins[0].H) + if util.Hnet == 0.: + assert not util.design_results and util.installed_cost == 0., util.ID + else: + assert abs(util.Hnet) > 1e-6 * duty, (util.ID, util.Hnet) + assert util.installed_cost > 0., util.ID + +def test_served_streams_leave_their_utility_exchangers_unchanged(): + # streams 3 and 4 of the doctest system are served completely by process + # exchangers; the cached network used to re-flash stream 4 at its outlet + # enthalpy in its utility exchanger, which moved its phase split in the + # last digits and left a net duty of -9.3e-10 kJ/hr that was costed as a + # minimum-size exchanger (1,304 USD installed) absent from the fresh one + sys, HXN, feed = build_system() + sys.simulate() + served = [util for util in HXN.new_HX_utils if util.Hnet == 0.] + assert len(served) == 2 + assert_no_phantom_utility_exchangers(HXN) + simulate_cached(sys, HXN) + assert [util for util in HXN.new_HX_utils if util.Hnet == 0.] == served + for util in served: # the stream leaves in the state it enters + inlet, product = util.ins[0], util.outs[0] + assert product.T == inlet.T and product.H == inlet.H + assert (product.mol == inlet.mol).all() + assert_no_phantom_utility_exchangers(HXN) + +def test_utility_exchangers_below_Qmin_are_costed(): + # Qmin drops small process matches only: every utility exchanger with a + # duty is designed and costed, however small the duty (here H1's 10 kW + # cooler, below Qmin = 100,000 kJ/hr) + units = cp_units('Qmin_cost', [('C1', 300., 400., 1.), ('H1', 450., 350., 1.1)]) + Qmin = 1e5 + sys, HXN = simulate_HXN(units, 10., Qmin=Qmin) + hx, = HXN.new_HXs + assert hx.Q > Qmin + cooler, = [u for u in HXN.new_HX_utils if u.Hnet] + assert_allclose(-cooler.Hnet, 10. * kW, rtol=1e-9) + assert -cooler.Hnet < Qmin and cooler.purchase_cost > 0. + utils = HXN.new_HX_utils + assert HXN.new_purchase_costs_HXu == [u.purchase_cost for u in utils] + assert_allclose(HXN.purchase_costs['Heat exchangers'], max(0., ( + hx.purchase_cost + sum(u.purchase_cost for u in utils) + - sum(u.purchase_cost for u in units) + )), rtol=1e-12) + def test_energy_balance_error_contributions_ignored_none(): sys, HXN, feed = build_system() sys.simulate() @@ -92,6 +144,81 @@ def test_energy_balance_error_contributions_ignored_none(): assert len(errors) == N assert HXN.ignored is None +def strict_simulate(item): + """Simulate a system or unit; a RuntimeWarning is an error (except + biosteam's furnace-air registry bookkeeping, see `simulate_HXN`).""" + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + warnings.filterwarnings('ignore', category=RuntimeWarning, + message='.* has been replaced in registry') + item.simulate() + +def network_loads(HXN): + return [HXN.original_heat_util_load, HXN.original_cool_util_load, + HXN.actual_heat_util_load, HXN.actual_cool_util_load] + +def assert_units_carry_the_network_utilities(HXN, units): + """Each original heat utility equals the heat utility of its OWN + stream's utility exchanger, every unit's utility cost is that of its + heat utilities, and the facility carries none.""" + for i, hu in enumerate(HXN.original_heat_utils): + assert hu.unit is HXN.original_heat_exchangers[i] + util = HXN.stream_life_cycles[i].life_cycle[-1].unit + assert _stream_ports(util) == (i,) + new = util.heat_utilities[0] + assert hu.agent is new.agent, hu.unit.ID + assert (hu.duty, hu.flow, hu.cost) == (new.duty, new.flow, new.cost) + for unit in units: + costs = sum(hu.cost for hu in unit.heat_utilities) + unit.power_utility.cost + assert_allclose(unit.utility_cost, costs, rtol=1e-12, atol=0.) + assert HXN.heat_utilities == [] and HXN.utility_cost == 0. + +@pytest.mark.parametrize('case', ['kemp', 'doctest']) +def test_replace_unit_heat_utilities(case): + # replace_unit_heat_utilities raised an AttributeError (biosteam's + # Unit._load_utility_cost no longer exists) and zipped the original + # heat utilities, in stream order (cold streams first), with + # new_HX_utils, listed hot streams first (Kemp: C1's heater would take + # H2's cooler duty). Each original heat utility now takes its own + # stream's utility, so the units report the total utility cost that + # the facility's net utilities report without the option. + if case == 'kemp': + units = cp_units('replace_kemp', KEMP, 273.15) + HXN = HeatExchangerNetwork('HXN', T_min_app=10.) + sys = bst.System.from_units('sys_replace', units=[*units, HXN]) + else: # auxiliary exchangers: a column's condenser and boiler, a flash's + sys, HXN, _ = build_system() + units = [u for u in sys.units if u is not HXN] + strict_simulate(sys) + N = len(HXN.original_heat_utils) + loads = network_loads(HXN) + unit_costs = [u.utility_cost for u in units] + total = sum(unit_costs) + HXN.utility_cost + assert HXN.utility_cost < 0. + served = [i for i, lc in enumerate(HXN.stream_life_cycles) + if not lc.life_cycle[-1].unit.heat_utilities[0].duty] + assert served + HXN.replace_unit_heat_utilities = True + strict_simulate(sys) # the units, then the network + assert_units_carry_the_network_utilities(HXN, units) + assert_allclose(network_loads(HXN), loads, rtol=1e-9) + assert_allclose(sum(u.utility_cost for u in units), total, rtol=1e-9) + assert HXN.synthesis_info['status'] == 'mer' + # the network alone again: the units still hold the replaced utilities, + # in which the served streams have none; the network restores the + # original ones first, so no stream drops out and nothing changes + strict_simulate(HXN) + assert len(HXN.original_heat_utils) == N + assert_units_carry_the_network_utilities(HXN, units) + assert_allclose(network_loads(HXN), loads, rtol=1e-9) + assert_allclose(sum(u.utility_cost for u in units), total, rtol=1e-9) + # without the option, the units get their own utilities back + HXN.replace_unit_heat_utilities = False + strict_simulate(HXN) + assert_allclose([u.utility_cost for u in units], unit_costs, rtol=1e-12) + assert_allclose(sum(unit_costs) + HXN.utility_cost, total, rtol=1e-9) + assert_allclose(network_loads(HXN), loads, rtol=1e-9) + def test_HXN_flowsheet_is_the_network_flowsheet(): """HXN_flowsheet must be the '_HXN' Flowsheet that holds the network, not the main-flowsheet proxy (which reads back as whatever is active).""" @@ -371,40 +498,722 @@ def test_pinch_state_at_endpoints_uses_real_states(): assert h_out.H <= s.H <= h_in.H assert 320. <= s.T <= 400. -def test_unordered_network_path_warns_with_context(monkeypatch): - # thermosteam's Network.sort warns 'network path could not be determined' - # when its ordering heuristic does not settle; HXN re-raises that as its - # own warning so the user knows which facility it concerns. - import thermosteam as tmo - original = tmo.Network.from_units - def from_units_with_warning(*args, **kwargs): - network = original(*args, **kwargs) - warnings.warn('network path could not be determined', RuntimeWarning) - return network - monkeypatch.setattr(tmo.Network, 'from_units', from_units_with_warning) +def assert_path_follows_streams(HXN): + """Every stage of every stream is simulated after the stage that feeds + it, except across a declared recycle (tear) stream.""" + path = list(HXN.HXN_sys.path) + assert set(path) == set(HXN.new_HXs + HXN.new_HX_utils) + recycles = HXN.HXN_sys.recycle or [] + if not isinstance(recycles, list): recycles = [recycles] + position = {unit: i for i, unit in enumerate(path)} + for life_cycle in HXN.stream_life_cycles: + stages = life_cycle.life_cycle + for a, b in zip(stages, stages[1:]): + s = a.unit.outs[a.index] + assert b.unit.ins[b.index] is s + assert position[b.unit] > position[a.unit] or s in recycles, (a.unit, b.unit) + return recycles + +def test_network_path_follows_the_streams(): + # hensmith orders the network itself (a topological order of the stream + # stages, tearing loops), so the path never depends on a general network + # sort that need not settle on intertwined loops units = synthetic_units() - HXN = HeatExchangerNetwork('HXN', T_min_app=5.) - sys = bst.System.from_units('sys_unordered', units=[*units, HXN]) - with pytest.warns(RuntimeWarning, match='heat exchanger network path could not be fully ordered'): - sys.simulate() + sys, HXN = simulate_HXN(units, 5., 'sys_path') + assert_path_follows_streams(HXN) assert abs(HXN.energy_balance_percent_error) < 1e-6 -def test_synthetic_network_reaches_MER(): +def test_synthetic_network_needs_a_split(): + # the report case (regression case 04): above the pinch, the superheat of + # the ethanol vapor and the hot water both reach the pinch against a + # single cold stream, so MER needs a stream split (pinch number rule); + # the unsplit network is a best-effort one that never beats MER units = synthetic_units() - HXN = HeatExchangerNetwork('HXN', T_min_app=5.) - sys = bst.System.from_units('sys_synthetic', units=[*units, HXN]) - sys.simulate() + sys, HXN = simulate_HXN(units, 5., 'sys_synthetic') hus = heat_utilities(units) table = problem_table(*pinch_streams(hus), 5.) actual_heat = sum(hu.unit_duty for hx in HXN.new_HX_utils for hu in hx.heat_utilities if hu.unit_duty > 0) - # the greedy heuristic reaches the (corrected) MER on this case; it can - # never legitimately beat it - assert_allclose(actual_heat, table.hot_util_load, rtol=1e-2) - # the lower bound is exact here only because every synthetic inlet is an - # equilibrium state (so the clipped table is exact) and the synthesizer - # respects T_min_app; with non-equilibrium inlets the table is conservative - assert actual_heat >= table.hot_util_load * (1 - 1e-3) + assert table.hot_util_load * (1 - 1e-9) <= actual_heat <= table.hot_util_load * 1.002 + info = HXN.synthesis_info + assert info['status'] == 'best_effort' + proof = info['sides']['above']['proof'] + assert proof['rule'] == 'outward' and len(proof['musts']) == 2 + assert_feasible(HXN, 5.) + +# --------------------------------------------------------------------------- +# Synthesis on the MER planner: realization, life cycles, facility +# --------------------------------------------------------------------------- + +import thermosteam as tmo +from hensmith.hxn_synthesis import ( + synthesize_network, StreamLifeCycle, _pinch_cut, _stream_ports, +) +import hensmith.hxn_synthesis as hxn_synthesis +from test_hxn_regression import ( + internal_approach, actual_loads, mer_targets, case_05_boiling_cold, + case_10_ten_streams, +) +from test_hxn_mer import APPROACH_TOL +import test_hxn_targets + +kW = 3600. # kJ/hr +_FLUID = [] + +def cp_units(name, rows, offset=0.): + """One simulated HXutility per constant heat capacity stream ``(ID, + T_in, T_out, CP [kW/K])``, temperatures + `offset` [K]; 1000*CP kmol/hr + of the `Fluid` pseudo-component has a heat capacity flow rate of CP + kW/K.""" + if not _FLUID: + fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., + Cn=3.6, default=True) + bst.settings.set_thermo([fluid], cache=True) + _FLUID.append(bst.settings.thermo) + bst.settings.set_thermo(_FLUID[0]) + bst.main_flowsheet.set_flowsheet(name) + units = [] + for ID, T_in, T_out, CP in rows: + inlet = bst.Stream(ID + '_in', Fluid=1000. * CP, T=T_in + offset, + units='kmol/hr') + hx = bst.HXutility(ID, ins=inlet, T=T_out + offset, rigorous=False) + hx.simulate() + units.append(hx) + return units + +def simulate_HXN(units, T_min_app, name='sys', **kwargs): + """Simulate the units with a HeatExchangerNetwork; a RuntimeWarning + (including a stream replaced in the registry, except biosteam's own + furnace-air stream) is an error.""" + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app, **kwargs) + sys = bst.System.from_units(name, units=[*units, HXN]) + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + # biosteam's HeatUtility.load_agent names a new 'oxygen_rich_inlet' + # stream for every fuel (furnace) utility; the network itself replaces + # nothing in the registry (test_synthesis_registers_no_intermediate_streams) + warnings.filterwarnings('ignore', category=RuntimeWarning, + message='.* has been replaced in registry') + sys.simulate() + return sys, HXN + +def assert_feasible(HXN, T_min_app, EB=1e-6): + """Balanced network whose every process exchanger keeps T_min_app inside + it on the exact states of its streams.""" + assert abs(HXN.energy_balance_percent_error) < EB + for hx in HXN.new_HXs: + assert internal_approach(hx) >= T_min_app - APPROACH_TOL, hx.ID + assert HXN.synthesis_info['min_approach'] is None or ( + HXN.synthesis_info['min_approach'] >= T_min_app - APPROACH_TOL) + +def total_duty(units): + return sum(abs(hx.heat_utilities[0].unit_duty) for hx in units) + +#: Problem r002: its only unsplit MER network matches H1 twice with C3 +#: (160 / 30 / 20 / 20 kW below the pinch); 80 kW hot and 450 kW cold +#: utility at T_min_app = 10 K (temperatures + 300 K). +R002 = [('C1', 140., 170., 1.), ('C2', 100., 120., 1.), + ('C3', 120., 250., 2.), ('H1', 220., 50., 4.)] +#: Linnhoff & Hindmarsh (1983): 20 kW hot, 60 kW cold at 10 K (degC). +KEMP = [('C1', 20., 135., 2.), ('H2', 170., 60., 3.), + ('C3', 80., 140., 4.), ('H4', 150., 30., 1.5)] + +def r002(name='r002', fillers=0): + """r002 plus `fillers` utility-only cold streams far above it (300 kW + each, so that they sort after C3 and push H1's index up).""" + rows = R002[:3] + [(f'F{i}', 500., 600., 3.) for i in range(fillers)] + R002[3:] + return cp_units(name, rows, 300.) + +def test_repeated_pair_IDs_and_life_cycles(): + # nine filler cold streams make H1 stream 12 and C1, C3 streams 1, 2 + # (heating duties ascending: C2 0, C1 1, C3 2, fillers 3-11), so a + # substring match on '_1_', '_2_' or 's_2_' would confuse them + units = r002('r002_12', fillers=9) + sys, HXN = simulate_HXN(units, 10.) + IDs = [hx.ID for hx in HXN.new_HXs + HXN.new_HX_utils] + assert len(IDs) == len(set(IDs)) + assert sorted(hx.ID for hx in HXN.new_HXs) == [ + 'HX_12_0_cs', 'HX_12_1_cs', 'HX_12_2_cs', 'HX_12_2_cs_2'] + assert all(_stream_ports(hx) is not None for hx in HXN.new_HXs + HXN.new_HX_utils) + cycles = {lc.index: lc for lc in HXN.stream_life_cycles} + stages = lambda i: [(s.unit.ID, s.index) for s in cycles[i].life_cycle] + # flow order: H1 passes C3's first match at its hot end, C3's second + # match after C1's; C3 meets its second match first (cold end) + assert stages(12) == [('HX_12_2_cs', 0), ('HX_12_1_cs', 0), ('HX_12_2_cs_2', 0), + ('HX_12_0_cs', 0), ('Util_12_cs', 0)] + assert stages(2) == [('HX_12_2_cs_2', 1), ('HX_12_2_cs', 1), ('Util_2_hs', 0)] + assert stages(1) == [('HX_12_1_cs', 1), ('Util_1_hs', 0)] + assert stages(5) == [('Util_5_hs', 0)] + assert_path_follows_streams(HXN) + # recomputed after the rewiring of `_cost` (inlets are now the previous + # stages' outlets, e.g. 'HX_12_2_cs_2__s_12'), the life cycles agree + for i, lc in cycles.items(): + again = StreamLifeCycle(i, lc.cold).get_life_cycle(HXN.new_HXs, HXN.new_HX_utils) + assert [(s.unit, s.index) for s in again] == [(s.unit, s.index) for s in lc.life_cycle] + assert [u for u in HXN.stream_HXs_dict[i]] == [s.unit for s in lc.life_cycle] + heat, cool = actual_loads(HXN) + assert_allclose([heat, cool], [80. * kW + 9 * 300. * kW, 450. * kW], rtol=1e-9) + assert HXN.synthesis_info['status'] == 'mer' + assert_feasible(HXN, 10.) + +def test_process_exchangers_reproduce_the_plan(): + units = r002() + hus = sorted([hx.heat_utilities[0] for hx in units], key=lambda hu: hu.duty) + info = {} + hs, cs, utils, *_ = synthesize_network(hus, 10., info=info) + assert not hs and [hx.ID for hx in cs] == ['HX_3_2_cs', 'HX_3_1_cs', 'HX_3_2_cs_2', 'HX_3_0_cs'] + assert_allclose([hx.Q / kW for hx in cs], [160., 30., 20., 20.], rtol=1e-9) + for hx in cs: + # both enthalpy limits are the planned outlets; dT only guards + assert_allclose([hx.H_lim0, hx.H_lim1], [hx.outs[0].H, hx.outs[1].H], rtol=1e-12) + assert hx.dT == 10. - 1e-6 + assert not info['deviations'] and not info['dropped'] and not info['repaired'] + assert info['refine_rounds'] == 0 + assert_allclose([info['Q_hot'], info['Q_cold']], [80. * kW, 450. * kW], rtol=1e-12) + +def test_internal_pinch_of_condenser_against_boiling_mixture(): + # a mixture boiling at 353-357 K must not take the latent heat of + # ethanol condensing at 351.57 K (the old synthesizer's -3.68 K cross); + # every exchanger keeps 5 K on the exact states, inside it too + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('internal_pinch') + ATM = 101325. + def rigorous(ID, T, P, phase, T_out, **flow): + s = bst.Stream(ID + '_in', T=T, P=P, phase=phase, units='kmol/hr', **flow) + hx = bst.HXutility(ID, ins=s, T=T_out, rigorous=True) + hx.simulate() + return hx + units = [rigorous('C1', 330., ATM, 'l', 365., Water=150., Ethanol=150.), + rigorous('C2', 340., ATM, 'l', 365., Water=360., Ethanol=40.), + utility_hx('C3', 300., ATM, 'l', 340., Water=600.), + utility_hx('H1', 420., 5e5, 'l', 330., Water=1200.), + utility_hx('H2', 390., ATM, 'g', 340., Ethanol=250.)] + sys, HXN = simulate_HXN(units, 5.) + assert_feasible(HXN, 5.) + heat, cool = actual_loads(HXN) + hot_target, cold_target = mer_targets(units, 5.) + assert heat >= hot_target - 1e-9 * total_duty(units) + status = HXN.synthesis_info['status'] + assert status == ('mer' if heat - hot_target <= 1e-6 * total_duty(units) else 'best_effort') + +def test_two_stream_boiler_reaches_its_target(): + # cold water boiled to saturated vapor at 1 atm (100 kmol/hr) against + # hot water 400 -> 330 K at 5 bar (1000 kmol/hr): the pinch is the + # boiler's T_sat; the old network crossed by 26.6 K inside an exchanger + units, T_min_app = test_hxn_targets.case_two_stream() + sys, HXN = simulate_HXN(units, T_min_app) + heat, cool = actual_loads(HXN) + assert_allclose(heat, 2395102., rtol=1e-5) + assert HXN.synthesis_info['status'] == 'mer' + assert_feasible(HXN, T_min_app) + +@pytest.mark.parametrize('builder', [case_05_boiling_cold, case_10_ten_streams]) +def test_non_equilibrium_inlets(builder): + # case 05 H1: vapor at 420 K and 5 bar, below its dew point; case 10 H4: + # liquid ethanol at 370 K, above its boiling point + units, T_min_app = builder() + sys, HXN = simulate_HXN(units, T_min_app) + assert_feasible(HXN, T_min_app) + assert HXN.synthesis_info['status'] == 'mer' + +def test_outlet_inside_a_non_equilibrium_jump(): + # the vapor fed below its dew point (420 K, 5 bar) is a flat at 420 K on + # its curve; the small cold stream takes part of it, so the hot stream + # leaves its first exchanger inside that jump: its enthalpy limit is left + # out (a flash there would leave the stream's range) and the cold + # stream's limit sets the duty + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('jump') + units = [utility_hx('H1', 420., 5e5, 'g', 330., Water=250.), + utility_hx('C1', 300., 101325., 'l', 350., Water=100.)] + sys, HXN = simulate_HXN(units, 5.) + hx, = HXN.new_HXs + assert hx.ID == 'HX_1_0_cs' and hx.H_lim0 is None and hx.H_lim1 is not None + assert_allclose(hx.Q, units[1].outs[0].H - units[1].ins[0].H, rtol=1e-9) + assert HXN.synthesis_info['status'] == 'mer' + assert_feasible(HXN, 5.) + +def test_exactness_refinement(monkeypatch): + # with chords 0.5 K off the exact curves, a pinch match inside the + # curved (ethanol) stream would cross by 0.007 K: the exact check finds + # it, the knots are refined and the network re-planned + curves = hxn_synthesis.stream_curves + monkeypatch.setattr(hxn_synthesis, 'stream_curves', + lambda *args, **kwargs: curves(*args, tol_T=0.5, **kwargs)) + units, T_min_app = test_hxn_targets.case_curvature() + sys, HXN = simulate_HXN(units, T_min_app) + info = HXN.synthesis_info + assert info['refine_rounds'] >= 1 + assert_feasible(HXN, T_min_app) + heat, cool = actual_loads(HXN) + assert_allclose([heat, cool], [info['Q_hot_plan'], info['Q_cold_plan']], + rtol=1e-9, atol=1e-9 * total_duty(units)) + +def test_loop_converges_exactly(): + # H4-C1 above and below the pinch form a recycle loop + units = cp_units('kemp', KEMP, 273.15) + sys, HXN = simulate_HXN(units, 10.) + assert assert_path_follows_streams(HXN) # a torn loop + assert_feasible(HXN, 10., EB=1e-8) + assert_allclose(actual_loads(HXN), [20. * kW, 60. * kW], rtol=1e-9) + assert HXN.synthesis_info['status'] == 'mer' + +def test_cached_network_with_repeated_pairs(): + units = r002('r002_cache') + sys, HXN = simulate_HXN(units, 10.) + fresh = [hx.Q for hx in HXN.new_HXs], actual_loads(HXN) + # (a) feeds unchanged: the cached network reproduces the fresh one + HXN.cache_network = True + HXN_sys = HXN.HXN_sys + sys.simulate() + assert HXN.HXN_sys is HXN_sys + assert_allclose([hx.Q for hx in HXN.new_HXs], fresh[0], rtol=1e-9) + assert_allclose(actual_loads(HXN), fresh[1], rtol=1e-9) + # (b) H1 5 % larger: below the pinch the cold streams are served as + # planned (each keeps its share) and H1 cools the rest; the network + # stays balanced and feasible + units[-1].ins[0].F_mass *= 1.05 + sys.simulate() + assert HXN.HXN_sys is HXN_sys + assert_allclose([hx.Q for hx in HXN.new_HXs], fresh[0], rtol=1e-9) + H1_duty = 4. * 170. * kW + assert_allclose(actual_loads(HXN), [fresh[1][0], fresh[1][1] + 0.05 * H1_duty], rtol=1e-9) + assert_feasible(HXN, 10.) + +@pytest.mark.parametrize('index, factor', [(0, 1.05), (1, 0.95)]) +def test_cached_network_caps_the_partner_at_its_outlet(index, factor): + # C1 5 % larger (H2 5 % smaller): below the pinch C1's share of HX_2_0_cs + # grows beyond what H2 has left for it; with no limit on H2 the match + # cooled H2 past its outlet and its "cooler" then heated it with steam + units = cp_units(f'kemp_cap_{index}', KEMP, 300.) + sys, HXN = simulate_HXN(units, 10.) + HXN_sys = HXN.HXN_sys + HXN.cache_network = True + units[index].ins[0].F_mol *= factor + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + # biosteam's HeatUtility.load_agent names a new 'oxygen_rich_inlet' + # stream for every fuel (furnace) utility; the network itself replaces + # nothing in the registry (test_synthesis_registers_no_intermediate_streams) + warnings.filterwarnings('ignore', category=RuntimeWarning, + message='.* has been replaced in registry') + sys.simulate() + assert HXN.HXN_sys is HXN_sys, 'cached network was not used' + assert abs(HXN.energy_balance_percent_error) < 1e-6 + tol = 1e-9 * total_duty(units) + for lc in HXN.stream_life_cycles: + hx = HXN.original_heat_exchangers[lc.index] + H_in, H_out = hx.ins[0].H, hx.outs[0].H + sign = 1. if H_out > H_in else -1. + for stage in lc.life_cycle: # every stage moves toward the outlet + assert sign * (stage.H_out - stage.H_in) >= -tol, stage.unit.ID + assert sign * (H_out - stage.H_out) >= -tol, stage.unit.ID + heat, cool = actual_loads(HXN) + net = sum(hx.heat_utilities[0].unit_duty for hx in units) + assert_allclose(heat - cool, net, rtol=1e-9) + for hx in HXN.new_HXs: + assert internal_approach(hx) >= 10. - APPROACH_TOL, hx.ID + +def test_C_flow_from_process_enthalpies(): + # a stream heated with high-pressure steam (heat transfer efficiency + # 0.85): its heat capacity flow rate is that of the process stream + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('C_flow') + cold = utility_hx('C1', 400., 20e5, 'l', 480., Water=100.) + hot = utility_hx('H1', 450., 20e5, 'l', 350., Water=100.) + hu = cold.heat_utilities[0] + assert hu.agent.heat_transfer_efficiency < 1. + result = synthesize_network([hu, hot.heat_utilities[0]], 5.) + C_flow = result[7] + dH = cold.outs[0].H - cold.ins[0].H + assert_allclose(C_flow[0], dH / 80., rtol=1e-12) + assert not np.isclose(C_flow[0], abs(hu.duty) / 80., rtol=1e-3) + +def test_synthesize_network_returns(): + units = r002('r002_returns') + hus = [hx.heat_utilities[0] for hx in units] # given order: C1, C2, C3, H1 + result = synthesize_network(hus, 10.) + assert len(result) == 13 + (hs, cs, utils, hxs, T_in, T_out, pinch_T, C_flow, hus_rearranged, + streams_inlet, stream_HXs, hot_indices, cold_indices) = result + assert all(isinstance(hx, bst.HXprocess) for hx in hs + cs) + assert all(isinstance(hx, bst.HXutility) for hx in utils) + assert [u.ID for u in utils] == ['Util_3_cs', 'Util_0_hs', 'Util_1_hs', 'Util_2_hs'] + assert hus_rearranged == hus and hxs == units + for array in (T_in, T_out, pinch_T, C_flow): + assert isinstance(array, np.ndarray) and array.shape == (4,) + assert hot_indices == [3] and cold_indices == [0, 1, 2] + assert sorted(stream_HXs) == [0, 1, 2, 3] + for i, stages in stream_HXs.items(): + assert isinstance(stages[-1], bst.HXutility) and stages[-1].ID.startswith(f'Util_{i}_') + assert all(isinstance(u, bst.HXprocess) for u in stages[:-1]) + assert [u.ID for u in stream_HXs[2]] == ['HX_3_2_cs_2', 'HX_3_2_cs', 'Util_2_hs'] + +def test_avoid_recycle_never_repeats_a_pair(): + units = r002('r002_avoid') + sys, HXN = simulate_HXN(units, 10., avoid_recycle=True) + pairs = [frozenset(_stream_ports(hx)) for hx in HXN.new_HXs] + assert len(pairs) == len(set(pairs)) + heat, cool = actual_loads(HXN) + assert heat >= 80. * kW * (1 - 1e-9) + assert HXN.synthesis_info['status'] == 'best_effort' # r002 needs the repeat + assert_feasible(HXN, 10.) + +def test_Qmin_drops_small_exchangers(): + units = r002('r002_Qmin') + Qmin = 25. * kW + sys, HXN = simulate_HXN(units, 10., Qmin=Qmin) + assert HXN.new_HXs and all(hx.Q >= Qmin for hx in HXN.new_HXs) + assert HXN.synthesis_info['qmin_dropped'] + assert HXN.synthesis_info['status'] == 'best_effort' + assert_feasible(HXN, 10.) + +def latent_heat_ranges(HXN, index): + """Stages of a stream on each side of the pinch: (cold-side, hot-side).""" + stages = HXN.stream_life_cycles[index].life_cycle + side = lambda s, name: isinstance(s.unit, bst.HXprocess) and s.unit.ID.split('_')[3] == name + return ([s for s in stages if side(s, 'cs')], [s for s in stages if side(s, 'hs')]) + +def test_point_loads_at_the_pinch_follow_the_cut(): + ATM = 101325. + # a boiler at the pinch (cut 'above'): its latent heat is heated above + # the pinch only + units, T_min_app = test_hxn_targets.case_two_stream() + sys, HXN = simulate_HXN(units, T_min_app) + table = mer_table(units, T_min_app) + assert _pinch_cut(table) == 'above' + i = HXN.original_heat_exchangers.index(units[1]) + sat_liquid = units[1].ins[0].copy(); sat_liquid.vle(V=0, P=ATM) + below, above = latent_heat_ranges(HXN, i) + tol = 1e-9 * abs(sat_liquid.H) + assert all(s.s_out.H <= sat_liquid.H + tol for s in below) + assert above and all(s.s_in.H >= sat_liquid.H - tol for s in above) + # a condenser at the pinch (cut 'below'): its latent heat is released + # below the pinch only + units, T_min_app = test_hxn_targets.case_condenser_at_pinch() + sys, HXN = simulate_HXN(units, T_min_app) + assert _pinch_cut(mer_table(units, T_min_app)) == 'below' + i = HXN.original_heat_exchangers.index(units[0]) + sat_vapor = units[0].ins[0].copy(); sat_vapor.vle(V=1, P=ATM) + below, above = latent_heat_ranges(HXN, i) + tol = 1e-9 * abs(sat_vapor.H) + assert all(s.s_out.H >= sat_vapor.H - tol for s in above) + assert below and all(s.s_in.H <= sat_vapor.H + tol for s in below) + assert HXN.synthesis_info['status'] == 'mer' + assert_feasible(HXN, T_min_app) + +def test_non_monotone_stream_is_a_point_load(): + # a superheated water/ethanol liquid (365 K) taken to its dew point + # (357.4 K) exits colder than it entered: a point load at its outlet + # temperature; its match takes it at equilibrium at its feed enthalpy + # (353.07 K, colder than its outlet), from which its planned outlet is + # a valid enthalpy limit (a flash to it from the 365 K feed would cool + # a heated stream), and stays feasible + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('non_monotone') + s = bst.Stream('nm_in', Water=150., Ethanol=150., T=365., P=101325., + phase='l', units='kmol/hr') + reboiler = bst.HXutility('NM', ins=s, V=1, rigorous=True) + reboiler.simulate() + assert reboiler.outs[0].T < reboiler.ins[0].T + units = [reboiler, utility_hx('H1', 420., 5e5, 'l', 330., Water=1000.)] + sys, HXN = simulate_HXN(units, 5.) + hx, = HXN.new_HXs + assert hx.ins[0].T < reboiler.outs[0].T + assert hx.H_lim0 is not None and hx.H_lim1 is not None + assert not HXN.synthesis_info['dropped'] + heat, cool = actual_loads(HXN) + # the network reaches MER here, so the bound holds with equality and the + # two sums (utility exchangers vs. problem table) differ by round-off + assert heat >= mer_targets(units, 5.)[0] - 1e-9 * total_duty(units) + assert_feasible(HXN, 5.) + +def point_load_units(kind): + """A point-load stream (port 1 of its only match) whose whole duty a + process stream can serve: 'cold', the superheated liquid of + test_non_monotone_stream_is_a_point_load boiled to its dew point, colder + than its feed; 'hot', water vapor fed below its dew point (420 K, 5 bar) + and cooled 1 K, whose quenched outlet (424.98 K) is hotter than its + feed.""" + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('point_load_' + kind) + if kind == 'cold': + s = bst.Stream('nm_in', Water=150., Ethanol=150., T=365., P=101325., + phase='l', units='kmol/hr') + point = bst.HXutility('NM', ins=s, V=1, rigorous=True) + point.simulate() + return [point, utility_hx('H1', 420., 5e5, 'l', 330., Water=5000.)] + s = bst.Stream('nm_in', Water=100., T=420., P=5e5, phase='g', units='kmol/hr') + point = bst.HXutility('NM', ins=s, T=419., rigorous=False) + point.simulate() + quenched = point.outs[0].copy() + quenched.vle(H=quenched.H, P=quenched.P) + assert quenched.T > point.ins[0].T + return [point, utility_hx('C1', 300., 101325., 'l', 350., Water=1000.)] + +@pytest.mark.parametrize('kind', ['cold', 'hot']) +def test_point_load_whole_duty_is_matched(kind): + # the match takes the point load's whole duty (port 1), with both + # limits: the point load enters at equilibrium at its feed enthalpy, on + # the plan's side of its outlet temperature, where its planned outlet is + # a valid HXprocess limit ('cold': 353.07 K, colder than its outlet, + # not the 365 K feed, from which the flash to its outlet failed and the + # match was dropped, losing MER; 'hot': at its outlet temperature, + # 424.98 K, not 420 K) + units = point_load_units(kind) + sys, HXN = simulate_HXN(units, 5.) + info = HXN.synthesis_info + hx, = HXN.new_HXs + point = units[0] + duty = abs(point.outs[0].H - point.ins[0].H) + assert hx.H_lim0 is not None and hx.H_lim1 is not None + assert info['point_loads'] == [HXN.original_heat_exchangers.index(point)] + if kind == 'cold': + assert hx.ins[1].T < point.outs[0].T < point.ins[0].T + else: + assert hx.ins[1].T > point.ins[0].T + 4. + assert abs(hx.ins[1].H - point.ins[0].H) <= 1e-9 * duty + assert_allclose(hx.Q, abs(point.outs[0].H - point.ins[0].H), rtol=1e-9) + assert not info['dropped'] and not info['deviations'] + assert info['status'] == 'mer' + total = total_duty(units) + assert_allclose(actual_loads(HXN), mer_targets(units, 5.), atol=1e-6 * total) + assert_feasible(HXN, 5.) + +def capped_point_load_units(kind): + """A mixture point-load stream (index 0 of the returned units) whose + real feed temperature, on the wrong side of its outlet temperature, + would cap its partner in HXprocess short of the plan (by feed -/+ dT), + and whose equilibrium state at its feed enthalpy is not at its outlet + temperature, plus that partner. 'ideal': 91 wt% ethanol vapor at its + dew point (364.02 K, 1.62 bar; the condenser of the oilcane + biorefinery's D302), 10 % condensed; under `force_ideal_thermo` its + ideal dew point is higher, so it is fed as a vapor below its dew point + and its quenched outlet (369.84 K) and equilibrium feed (370.26 K) are + hotter than its feed. 'superheated': the water/ethanol liquid at 365 K + of test_non_monotone_stream_is_a_point_load boiled to its dew point + (357.44 K; equilibrium feed 353.07 K).""" + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('capped_point_load_' + kind) + if kind == 'ideal': + P = 162120. + s = bst.Stream('D_in', Ethanol=19.69, Water=5.16, P=P, units='kmol/hr') + s.vle(V=1, P=P) + point = bst.HXutility('D', ins=s, V=0.9, rigorous=True) + point.simulate() + T = s.T + partner = utility_hx('C1', T - 8., 101325., 'l', T - 1.5, Water=100.) + else: + s = bst.Stream('nm_in', Water=150., Ethanol=150., T=365., P=101325., + phase='l', units='kmol/hr') + point = bst.HXutility('NM', ins=s, V=1, rigorous=True) + point.simulate() + partner = utility_hx('H1', 380., 5e5, 'l', 363., Water=300.) + return [point, partner] + +@pytest.mark.parametrize('kind', ['ideal', 'superheated']) +def test_point_load_enters_at_equilibrium_on_the_plan_side(kind): + # the plan puts a point load's whole duty at its outlet temperature, + # beyond its real feed temperature; the match kept the real feed unless + # its equilibrium state at the feed enthalpy lay exactly at the outlet + # temperature (a pure component), so HXprocess capped the partner at + # the feed temperature -/+ dT: 'ideal' delivered 22.7 of 49.2 MJ/hr + # (the oilcane O1 HX_8_5_cs under force_ideal_thermo: 1.38 of 3.38 + # MW), 'superheated' 228.0 of 387.2 MJ/hr. The point load now enters at + # equilibrium at its feed enthalpy, on the plan's side of its outlet + # temperature, and the network reaches MER as planned. + units = capped_point_load_units(kind) + ideal = kind == 'ideal' + sys, HXN = simulate_HXN(units, 5., force_ideal_thermo=ideal) + info = HXN.synthesis_info + point = units[0] + index = HXN.original_heat_exchangers.index(point) + assert info['point_loads'] == [index] + hx, = HXN.new_HXs + s_in = hx.ins[_stream_ports(hx).index(index)] + T_out = HXN.outlet_Ts[index] # the (ideal) quenched outlet + if ideal: # cooled, fed colder than its outlet: enters hotter than it + assert point.ins[0].T < T_out - 5. and s_in.T > T_out + else: # heated, fed hotter than its outlet: enters colder than it + assert point.ins[0].T > T_out + 7. and s_in.T < T_out + feed = point.ins[0].copy(thermo=s_in.thermo) + assert_allclose(s_in.H, feed.H, rtol=1e-12) + assert hx.H_lim0 is not None and hx.H_lim1 is not None + assert not info['deviations'] and not info['dropped'] and not info['repaired'] + assert info['status'] == 'mer' + targets = [info['Q_hot_target'], info['Q_cold_target']] + total = total_duty(units) + assert_allclose([info['Q_hot'], info['Q_cold']], targets, atol=1e-6 * total) + assert_allclose(actual_loads(HXN), targets, atol=1e-6 * total) + assert_feasible(HXN, 5.) + +def test_synthesis_registers_no_intermediate_streams(): + # the curves and the synthesis copy streams hundreds of times; a plain + # ``stream.copy()`` takes its ID from the source line (thermosteam's ID + # magic), so a multi-phase copy on a line that assigns no plain + # variable registered as '-', replacing the previous one with a + # RuntimeWarning (128 per synthesis of this system, 282 on the oilcane + # biorefinery O1, against 2 before the curves), and a single-phase copy + # took a registry ticket. Every internal copy is now unregistered. + sys, HXN, feed = build_system() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + sys.simulate() + replaced = [str(w.message) for w in caught if 'replaced in registry' in str(w.message)] + assert not replaced, replaced[:3] + assert HXN.synthesis_info['status'] == 'mer' + # the problem table (curves, flashes) registers nothing at all + streams = pinch_streams(HXN.original_heat_utils) + registered = lambda: {ID: id(s) for ID, s in bst.main_flowsheet.stream.data.items()} + before = registered() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + problem_table(*streams, 5.) + assert not [w for w in caught if 'registry' in str(w.message)] + assert registered() == before + +@pytest.mark.parametrize('T_min_app', [5., 30.]) +def test_reboiler_fed_above_saturation_against_steam(T_min_app): + # a column reboiler: water fed as a liquid at 396 K and half boiled at + # 1 atm leaves at 373.12 K, a point load colder than its feed, served by + # condensing saturated steam at 3 bar (406.67 K, also a point load). The + # match was lost, and MER with it, by an HXprocess limit at the + # reboiler's planned outlet, on the wrong side of its feed temperature, + # and, for T_min_app above 10.67 K, by HXprocess comparing the 396 K + # feed with the steam: the reboiler now enters the exchanger at + # equilibrium at its feed enthalpy (373.12 K), as the plan sees it. + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet(f'reboiler_{T_min_app:g}') + feed = bst.Stream('R_in', Water=100., T=396., P=101325., phase='l', + units='kmol/hr') + reboiler = bst.HXutility('R', ins=feed, V=0.5, rigorous=True) + steam = bst.Stream('S_in', Water=150., P=3e5, units='kmol/hr') + steam.vle(V=1, P=3e5) + condenser = bst.HXutility('S', ins=steam, V=0, rigorous=True) + for unit in (reboiler, condenser): unit.simulate() + assert reboiler.outs[0].T < reboiler.ins[0].T - 20. + units = [reboiler, condenser] + sys, HXN = simulate_HXN(units, T_min_app) + info = HXN.synthesis_info + assert sorted(info['point_loads']) == [0, 1] + hx, = HXN.new_HXs + cold_in = min(hx.ins, key=lambda s: s.T) + assert abs(cold_in.T - reboiler.outs[0].T) < 1e-6 + assert_allclose(cold_in.H, feed.H, rtol=1e-12) + duty = reboiler.outs[0].H - reboiler.ins[0].H + assert_allclose(hx.Q, duty, rtol=1e-9) # the reboiler's whole duty + assert not info['dropped'] and not info['deviations'] + assert info['status'] == 'mer' + assert_allclose(actual_loads(HXN), mer_targets(units, T_min_app), + atol=1e-9 * total_duty(units)) + assert_feasible(HXN, T_min_app) + +@pytest.mark.parametrize('T_cold_out', [354.87, 354.9, 354.93]) +def test_liquid_fed_above_its_bubble_point_keeps_the_approach(T_cold_out): + # the hot liquid (368 K) is at equilibrium at 359.84 K at its feed + # enthalpy; its curve used to reach it at 359.95 K, so the network + # crossed by up to 0.09 K at the hot end, undetected, on too low targets + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('clipped_glide') + s = bst.Stream('H1_in', Water=900., Ethanol=100., T=368., P=101325., + phase='l', units='kmol/hr') + hot = bst.HXutility('H1', ins=s, T=320., rigorous=True) + hot.simulate() + units = [hot, utility_hx('C1', 300., 5e5, 'l', T_cold_out, Water=400.)] + sys, HXN = simulate_HXN(units, 5.) + assert_feasible(HXN, 5.) + heat, cool = actual_loads(HXN) + hot_target, cold_target = mer_targets(units, 5.) + # one match: C1 is heated to the hot stream's equilibrium feed + # temperature less T_min_app, the rest by utility + feed = s.copy(); feed.vle(H=s.H, P=s.P) + C1_in = units[1].ins[0] + top = C1_in.copy(); top.T = feed.T - 5. + assert_allclose(hot_target, units[1].outs[0].H - top.H, rtol=1e-6) + assert_allclose([heat, cool], [hot_target, cold_target], atol=1e-6 * total_duty(units)) + assert HXN.synthesis_info['status'] == 'mer' + +def test_ideal_thermo_doctest_system_reaches_mer(): + # with ideal thermodynamics, hot stream 4 (338.00 K fed, quenched outlet + # 338.32 K) is a point load that one match serves completely + sys, HXN, feed = build_system() + HXN.force_ideal_thermo = True + sys.simulate() + info = HXN.synthesis_info + assert not info['dropped'] + assert abs(info['penalty']) <= 1e-9 * info['Q_hot_target'] + assert info['status'] == 'mer' + +def mer_table(units, T_min_app): + hus = heat_utilities(units) + return problem_table(*pinch_streams(hus), T_min_app) + +@pytest.mark.parametrize('rows, dT, targets', [ + # threshold, no hot utility + ([('H1', 200., 40., 2.), ('C1', 50., 150., 1.)], 10., (0., 220.)), + # threshold, no cold utility + ([('H1', 200., 100., 1.), ('C1', 50., 250., 1.)], 10., (100., 0.)), + # a pinch at 205 and zeros of the cascade at 200, 120, 100 and 20 + ([('C1', 20., 100., 1.), ('H1', 110., 20., 1.), ('C2', 120., 200., 1.), + ('H2', 210., 130., 1.), ('C3', 205., 215., 1.)], 10., (10., 10.)), +]) +def test_thresholds_and_multiple_pinches(rows, dT, targets): + units = cp_units('threshold', rows, 273.15) + table = mer_table(units, dT) + assert_allclose([table.hot_util_load / kW, table.cold_util_load / kW], targets, + atol=1e-9 * total_duty(units) / kW) + sys, HXN = simulate_HXN(units, dT) + assert_allclose(actual_loads(HXN), [Q * kW for Q in targets], + atol=1e-9 * total_duty(units)) + assert HXN.synthesis_info['status'] == 'mer' + assert_feasible(HXN, dT) + +def test_synthesis_info(): + units = r002('r002_info') + sys, HXN = simulate_HXN(units, 10.) + info = HXN.synthesis_info + assert info['status'] == 'mer' and info['penalty'] <= 1e-9 * total_duty(units) + assert info['sides']['below']['status'] == 'mer' and info['sides']['below']['proof'] is None + assert_allclose([info['Q_hot_target'], info['Q_cold_target']], [80. * kW, 450. * kW]) + # two hot streams reach the pinch against one cold stream above it: the + # number rule proves that MER needs a split + units = cp_units('split', [('H1', 150., 100., 1.), ('H2', 150., 100., 1.), + ('C1', 90., 140., 3.)], 273.15) + sys, HXN = simulate_HXN(units, 10.) + info = HXN.synthesis_info + assert info['status'] == 'best_effort' and info['penalty'] > 0. + proof = info['sides']['above']['proof'] + assert proof['side'] == 'above' and len(proof['musts']) == 2 and len(proof['flexes']) == 1 + heat, cool = actual_loads(HXN) + assert_allclose([heat - cool], [info['Q_hot_target'] - info['Q_cold_target']], rtol=1e-9) + assert heat >= info['Q_hot_target'] and heat == pytest.approx(info['Q_hot'], rel=1e-9) + assert_feasible(HXN, 10.) + +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 + modules = (hensmith._heat_exchanger_network, hensmith.hxn_synthesis, + hensmith._planner, hensmith._curves) + def docstrings(obj, seen): + if id(obj) in seen: return + seen.add(id(obj)) + if obj.__doc__: yield obj + for name, member in vars(obj).items(): + if isinstance(member, (staticmethod, classmethod)): member = member.__func__ + if isinstance(member, property): member = member.fget + if ((inspect.isfunction(member) or inspect.isclass(member)) + and member.__module__ == owner.__name__): + yield from docstrings(member, seen) + checked = 0 + for owner in modules: + for obj in docstrings(owner, set()): + doc = obj.__doc__ + for label in re.findall(r'^\s*\.\. \[([^\]]+)\]', doc, re.M): + checked += 1 + assert f'[{label}]_' in doc, f'{obj.__name__}: [{label}] not cited' + assert checked >= 9 # --- pinch diagram ----------------------------------------------------------- @@ -537,19 +1346,4 @@ def test_pinch_diagram_legend(): plt.close(fig) if __name__ == '__main__': - test_cache_network_matches_fresh_synthesis() - test_cache_network_perturbed_feed() - test_cache_network_duplicate_IDs() - test_energy_balance_error_contributions_ignored_none() - test_problem_table_energy_consistency_doctest_system() - for T_min_app in (5., 10., 20.): - test_problem_table_energy_consistency_synthetic(T_min_app) - test_problem_table_two_streams_closed_form() - test_problem_table_non_monotone_stream_is_point_load() - test_problem_table_point_load_cannot_heat_above_itself() - test_synthetic_network_reaches_MER() - test_pinch_diagram_column_order_follows_stream_direction() - test_pinch_diagram_doctest_system() - test_pinch_diagram_stream_labels() - test_pinch_diagram_legend() - test_pinch_diagram_requires_simulation() + pytest.main([__file__, '-q', '-p', 'no:cacheprovider']) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py new file mode 100644 index 0000000..c9d6cdc --- /dev/null +++ b/tests/test_hxn_mer.py @@ -0,0 +1,953 @@ +# -*- 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. +""" +Minimum-energy-requirement (MER) corpus tests for ``HeatExchangerNetwork``. + +The problems live in ``hxn_mer_cases.py`` (data only). Every network is +synthesized through the public facility exactly as a user would (one +simulated ``HXutility`` per process stream, a ``HeatExchangerNetwork`` in the +same ``System``; see ``test_hxn_regression.synthesize``), and hensmith's +results are compared with small, independent references written in this +module (they call no hensmith code). + +The two sets +------------ +NO_SPLIT + Problems for which an UNSPLIT network reaching the MER targets exists. + The synthesized network must reach the targets (``synthesis_info + ['status'] == 'mer'``), with every material and energy balance closed + and every exchanger feasible. Each case carries a certificate network + that proves the claim. +SPLIT + Problems for which the pinch design rules PROVE that MER needs stream + splitting. hensmith does not split streams, so the network must stay + feasible and balanced, never beat the targets, and report + ``'best_effort'``. A strict miss is not asserted: alternating repeated + matches can approach MER arbitrarily closely. +Each set holds at least five problems with more than ten streams. + +Provenance +---------- +Constant-CP literature problems (``kind='constant_cp'``; every case carries +its full citation and source URL): + +* the Furman & Sahinidis (2004) test set as digitized by Letsios, + Kouyialis & Misener (2018) (``4sp1``, ``7sp1``, ``7sp4``, ``8sp1_sargent``, + ``10sp1``, ``12sp1``, ``14sp1``, ``20sp1``, ``23sp1``, ``fs_*``); +* the Lee, Masso & Rudd (1970) problems as tabulated in degrees Fahrenheit + by Muraki & Hayakawa (1982) (``4sp2``, ``5sp1``, ``7sp3``, ``8sp1_dt20F``); +* the Chen, Grossmann & Miller (2015) and Grossmann random instances from + the same repository (``cgm_*``, ``grossmann_*``); +* industrial problems from the supplementary data of Caballero et al. + (2021) and Yang, Zhang & Smith (2022) (``nitric_acid``, ``bandar_imam``, + ``sorak_kravanja``, ``luo_20sp``, ``bjork_pettersson``, + ``crude_fractionation``); +* textbook and lecture-note problems: Linnhoff & Hindmarsh (1983), Smith + (2005) (``smith2005_*``), the NPTEL course "Process Integration" + (``nptel_*``), Turton & Shaeiwitz, Cornell's process design wiki and + Bagajewicz (``bagajewicz_*``). + +They were collected in a certified benchmark built for hensmith's MER +synthesizer, where an independent oracle (numpy and scipy only) labelled +each problem: an unsplit MER network found by a stage-wise superstructure +MILP, re-solved as an LP and re-verified by plain arithmetic (NO_SPLIT), or +a pinch-rule proof (SPLIT). Here they are modelled with a constant-heat- +capacity pseudo-component, so their networks are exact arithmetic up to +rounding. + +Real-thermodynamics problems (``kind='real_thermo'``, ``rtA*`` / ``rtB*``) +were designed for hensmith: water, ethanol, methanol and four of their +binaries at 1-10 bar, with sensible liquids and vapours, isothermal +condensers and boilers (several AT the pinch), binary glides, thresholds +and repeated pairs. Their reference targets come from an independent +dense-grid calculator (reproduced exactly on re-running it); their +certificates were designed by a pinch-outward planner, simulated with +biosteam ``HXprocess`` units and re-checked by a second independent +calculator, and three certificate duties were then lowered by at most +0.17 kJ/hr so that the exact planned states keep ``T_min_app`` (see each +case's ``note``). + +What each check proves +---------------------- +``test_mer_targets`` + hensmith's problem table, computed on the same streams the facility + sees, equals an independent reference: the closed-form constant-CP + cascade (``_cascade``) and the published values in source units, or the + stored dense-grid targets for real thermodynamics. The pinch is a zero + of the reference cascade (constant CP) or the reference pinch. +``test_no_split_certificate`` + An unsplit MER network exists, inside this suite: the stored certificate + is walked stream by stream with plain arithmetic (constant CP: + ``_verify_certificate``, a port of the oracle's checker) or with exact + stream states (real thermodynamics: ``_verify_rt_certificate``), and it + is feasible and meets the targets. +``test_split_proof`` + Splitting is necessary: the pinch-rule violation is re-derived here at + the reference pinch (number rule, or no one-to-one CP-feasible pinch + matching) and equals the stored proof. The rules are exact only for + streams with a finite heat capacity at the pinch (an isothermal condenser + or boiler AT the pinch can serve several partners in series), so the + test also asserts that no stream has a point load at the pinch. +``test_network_balanced_and_feasible`` + The synthesized network is physically valid: energy balance error, + heat - cool == net process duty, only ``HXprocess`` / ``HXutility`` + units (no splitter), every exchanger balances heat and mass, one life + cycle per process stream whose stages follow each other, cover every + exchanger port exactly once and never move the stream away from its + outlet (a cold stream is never cooled), every stream enters and leaves + the network in its original states, and the + EXACT internal minimum approach inside every exchanger, computed from + the streams' own states (``_Temperature``, ``_min_approach``), is at + least ``T_min_app``. Synthesis raises no ``RuntimeWarning``. +``test_no_split_network_reaches_mer`` / ``test_split_network_never_beats_mer`` + The utilities equal both hensmith's targets and the independent reference + (NO_SPLIT) or are never below them (SPLIT), and + ``synthesis_info['status']`` says so. + +Tolerances +---------- +* Constant-CP targets: 1e-9 of the total stream duty (both sides are exact + arithmetic on the same data; only rounding differs). Pinch temperatures: + 1e-6 degrees (grid points are stream end temperatures). Published values: + the tolerance stored with each value (one unit in the last printed digit). +* Certificates: 1e-6 degrees and 1e-6 of the total duty, the oracle's own + acceptance tolerance (certificates are stored to ~1e-12). +* Real-thermo targets vs the dense reference, per case + (``_reference_tolerance``): twice the reference's own interpolation error + at its pinch, plus 1e-9 of the total stream duty. Both targets are the + cascade at the pinch (the pinches agree to 1e-6 K, and Q_cold - Q_hot is + exact in both), so they differ by the sum of the streams' enthalpy + errors there. The reference interpolates each stream linearly between + grid nodes (uniform, ``grid_h`` apart at most, plus its phase-change + temperatures): for a single-phase stream whose pinch temperature T lies + in the grid interval [T_a, T_b] the error is, to leading order, + ``(T - T_a) (T_b - T) / 2 * |dC/dT|`` (zero on a node, and for a pure + component at its saturation temperature, which is a node). hensmith's + curves are exact there: single-phase stretches are evaluated exactly at + every grid temperature and latent heat is an exact flat; only binary + glides are chords (within 0.002 K, i.e. 0.002 K times the glide's slope), + and no glide crosses a reference pinch in this corpus (asserted). This + estimate matches the measured differences (0 to 0.204 kJ/hr) to 0.1 % + where one stream dominates and bounds them where errors cancel; the + factor 2 covers the neglected higher-order terms, and 1e-9 of the total + duty covers rounding and flash residuals (at most 5e-11 measured). The + stored simulated certificate utilities, which are exact thermodynamics + at the pinch, differ from the reference by the same amounts. +* Achieved utilities (NO_SPLIT) vs hensmith's targets: 1e-12 of the total + stream duty for constant CP and 1e-10 for real thermodynamics (plus the + reference tolerance above when compared with the reference): about 100 + times the largest deviation measured over the corpus (5e-15 and 1e-12, + sorak_kravanja_ph13c7 and rtA10), the rounding of the constant-CP + arithmetic and the residuals of the enthalpy flashes that realize a + real-thermo network. SPLIT networks may not beat the targets by more + than 1e-9 (rounding only). +* Balances: energy balance error < 1e-6 % (``test_hxn_regression``); + heat - cool == net duty within 1e-8 of the total duty; per exchanger hot + duty == cold duty, and per stream network end states == original ones, + within 1e-6 of the streams' own duties (flash convergence, as above). +* Internal approach: ``T_min_app - 1e-6 K`` on exact states, the + synthesizer's guarantee (its ``HXprocess.dT`` guard sits exactly there), + plus 1e-9 K so that a guard-limited terminal is not decided by evaluation + noise; temperatures come from single-phase Newton solves to 1e-10 K, + T_sat, or an exact inversion along the bubble curve. +* Split proofs (real thermo): no stream end (other than the supply that + creates the pinch) and no phase change within 0.5 K of the pinch. + +References +---------- +Linnhoff, B. & Flower, J. R. (1978). Synthesis of heat exchanger networks: I. +Systematic generation of energy optimal networks. AIChE J. 24, 633-642. +Linnhoff, B. & Hindmarsh, E. (1983). The pinch design method for heat +exchanger networks. Chem. Eng. Sci. 38, 745-763. +Kemp, I. C. (2007). Pinch Analysis and Process Integration, 2nd ed. +Butterworth-Heinemann. +""" +import math +import re +import time +import warnings +import numpy as np +import pytest +import biosteam as bst +import thermosteam as tmo +from hensmith import HeatExchangerNetwork +from hensmith.hxn_synthesis import problem_table +from hxn_mer_cases import NO_SPLIT, SPLIT, Q_UNITS, T_UNITS + +# --------------------------------------------------------------------------- +# Tolerances (justified in the module docstring) +# --------------------------------------------------------------------------- + +TARGET_RTOL = 1e-9 # constant-CP targets, x total stream duty +PINCH_TOL = 1e-6 # pinch temperatures [source degree or K] +CERT_TOL = 1e-6 # certificate temperatures, and x total duty for heat +REF_MARGIN = 2. # x the dense reference's interpolation error at its pinch ... +REF_FLOOR = 1e-9 # ... + this x total duty (real-thermo targets vs reference) +MER_TOL = dict(constant_cp=1e-12, real_thermo=1e-10) # achieved - targets, x total duty +BEAT_RTOL = 1e-9 # SPLIT utilities never below targets beyond this +EB_TOL = 1e-6 # |energy balance error| [%] +NET_RTOL = 1e-8 # heat - cool vs net duty, x total duty +DUTY_RTOL = 1e-6 # exchanger / stream enthalpies, x the streams' duties +APPROACH_TOL = 1e-6 + 1e-9 # K: the synthesizer's guard + T(H) evaluation noise +PINCH_MARGIN = 0.5 # K + +def _name(case): return case['name'] + +def _is_cp(case): return case['kind'] == 'constant_cp' + +# --------------------------------------------------------------------------- +# Building the process streams +# --------------------------------------------------------------------------- + +_FLUID_THERMO = [] + +def _fluid_thermo(): + """Thermo with one constant-heat-capacity liquid pseudo-component: 1000*CP + kmol/hr of it has a heat capacity flow rate of exactly 3600*CP kJ/hr/K, + i.e. CP in kW/K, and its enthalpy is linear in temperature.""" + if not _FLUID_THERMO: + fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., Cn=3.6, + default=True) + bst.settings.set_thermo([fluid], cache=True) + _FLUID_THERMO.append(bst.settings.thermo) + return _FLUID_THERMO[0] + +def _kelvin(case, T): + scale, offset = T_UNITS[case['T_unit']] + return (T + offset) * scale + +def _unit_ID(name): + """A valid biosteam ID for a literature stream name ('1', 'C-1', ...).""" + ID = re.sub(r'\W', '_', name) + return ID if ID[0].isalpha() else 'S' + ID + +def _build(case): + """Return (units, T_min_app [K]): one simulated HXutility per process + stream, i.e. what a user's flowsheet hands to HeatExchangerNetwork.""" + bst.main_flowsheet.set_flowsheet('mer_' + case['name']) + units = [] + if _is_cp(case): + bst.settings.set_thermo(_fluid_thermo()) + scale, _ = T_UNITS[case['T_unit']] + kW = Q_UNITS[case['Q_unit']] + for name, kind, T_in, T_out, CP in case['streams']: + ID = _unit_ID(name) + inlet = bst.Stream(ID + '_in', Fluid=1000. * CP * kW / scale, + T=_kelvin(case, T_in), P=101325., units='kmol/hr') + units.append(bst.HXutility(ID, ins=inlet, T=_kelvin(case, T_out), + rigorous=False)) + T_min_app = case['dTmin'] * scale + else: + bst.settings.set_thermo(case['chemicals'], cache=True) + for ID, flows, T_in, T_out, P, phase, rigorous in case['streams']: + inlet = bst.Stream(ID + '_in', P=P, units='kmol/hr', **flows) + if T_in in ('bubble', 'dew'): + inlet.vle(V=0 if T_in == 'bubble' else 1, P=P) + else: + inlet.T = T_in + inlet.phase = phase + if T_out in ('bubble', 'dew'): + hx = bst.HXutility(ID, ins=inlet, V=0 if T_out == 'bubble' else 1, + rigorous=True) + else: + hx = bst.HXutility(ID, ins=inlet, T=T_out, rigorous=rigorous) + units.append(hx) + T_min_app = case['T_min_app'] + for hx in units: hx.simulate() + return units, T_min_app + +def _hensmith_table(units, T_min_app): + """hensmith's problem table on the streams HeatExchangerNetwork sees + (inlets and re-flashed outlets of the utility exchangers).""" + hus = sorted((hx.heat_utilities[0] for hx in units), key=lambda hu: hu.duty) + 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 problem_table(inlets, outlets, [hu.duty < 0 for hu in hus], T_min_app) + +def _duties(units): + """(total, net) process duty [kJ/hr] of the utility exchangers.""" + duties = [hx.heat_utilities[0].unit_duty for hx in units] + return sum(map(abs, duties)), sum(duties) + +# --------------------------------------------------------------------------- +# Constant-CP references (source units, no hensmith code) +# --------------------------------------------------------------------------- + +def _total_duty(case): + return sum(CP * abs(T_out - T_in) for _, _, T_in, T_out, CP in case['streams']) + +def _cascade(case): + """Closed-form constant-CP problem table [Linnhoff & Flower 1978] in + source units, hensmith convention (hot streams shifted down by dTmin). + Returns (Q_hot, Q_cold, zeros, Ts): Ts are the shifted grid temperatures + (descending) and zeros those where the feasible cascade vanishes (every + pinch point; the top of the grid when Q_hot == 0).""" + dT = case['dTmin'] + spans = [(T_out - dT, T_in - dT, CP) if kind == 'hot' else (T_in, T_out, -CP) + for _, kind, T_in, T_out, CP in case['streams']] + 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)) + Q_hot = max(0., -min(heat)) + flow = [h + Q_hot for h in heat] + tol = TARGET_RTOL * _total_duty(case) + zeros = [T for T, f in zip(Ts, flow) if f <= tol] + return Q_hot, flow[-1], zeros, Ts + +def _finite(x): + return (isinstance(x, (int, float)) and not isinstance(x, bool) + and math.isfinite(x)) + +def _verify_certificate(case): + """Plain-arithmetic check of a constant-CP certificate (port of the + benchmark oracle's ``verify_certificate``). Every stream passes its + matches in series (hot streams in increasing stage, cold streams in + decreasing stage) and ends with its utility; walking from T_in with Q/CP + must reproduce the recorded temperatures, both ends of every match must + keep dTmin (linear profiles, so the ends bound the whole exchanger), every + stream must end at T_out, and the utilities must equal the closed-form + targets. Every number must be finite: NaN compares False with everything + and would otherwise pass. Returns a list of problems (empty: valid).""" + dT = case['dTmin'] + cert = case['certificate'] + streams = {name: (kind, T_in, T_out, CP) + for name, kind, T_in, T_out, CP in case['streams']} + Q_hot, Q_cold, *_ = _cascade(case) + qtol = CERT_TOL * max(1., _total_duty(case)) + problems = [] + matches = [dict(zip(('side', 'stage', 'hot', 'cold', 'Q', 'T_hot_in', + 'T_hot_out', 'T_cold_in', 'T_cold_out'), m)) + for m in cert['matches']] + for m in matches: + if streams.get(m['hot'], ('',))[0] != 'hot' or streams.get(m['cold'], ('',))[0] != 'cold': + problems.append(f"{m['hot']}-{m['cold']}: unknown stream or wrong kind") + bad = [k for k in ('stage', 'Q', 'T_hot_in', 'T_hot_out', 'T_cold_in', 'T_cold_out') + if not _finite(m[k])] + if bad: + problems.append(f"{m['hot']}-{m['cold']}: non-finite {bad}") + elif not m['Q'] >= -qtol: + problems.append(f"{m['hot']}-{m['cold']}: negative duty {m['Q']}") + for kind, utility in (('cold', cert['hot_utility']), ('hot', cert['cold_utility'])): + for name, Q in utility.items(): + if streams.get(name, ('',))[0] != kind or not _finite(Q) or not Q >= -qtol: + problems.append(f'utility on {name}: not a {kind} stream, non-finite or negative') + if problems: return problems + temperatures = {} # id(match) -> [T_hot_in, T_hot_out, T_cold_in, T_cold_out] + for name, (kind, T_in, T_out, CP) in streams.items(): + hot = kind == 'hot' + own = sorted((m for m in matches if m[kind] == name), + key=lambda m: m['stage'] if hot else -m['stage']) + if len({m['stage'] for m in own}) < len(own): + problems.append(f'{name}: two matches share a stage (a split)') + sign, i = (-1., 0) if hot else (1., 2) + T = T_in + for m in own: + t = temperatures.setdefault(id(m), [None] * 4) + t[i] = T + T += sign * m['Q'] / CP + t[i + 1] = T + recorded = (m['T_hot_in'], m['T_hot_out']) if hot else (m['T_cold_in'], m['T_cold_out']) + if not (abs(recorded[0] - t[i]) <= CERT_TOL + and abs(recorded[1] - t[i + 1]) <= CERT_TOL): + problems.append(f"{m['hot']}-{m['cold']}: recorded {name} temperatures " + f"{recorded} but the duties give {t[i:i + 2]}") + T += sign * (cert['cold_utility'] if hot else cert['hot_utility']).get(name, 0.) / CP + if not abs(T - T_out) <= CERT_TOL: + problems.append(f'{name}: ends at {T:.9g}, target {T_out:.9g}') + for m in matches: + Th_in, Th_out, Tc_in, Tc_out = temperatures[id(m)] + approach = min(Th_in - Tc_out, Th_out - Tc_in) + if not approach >= dT - CERT_TOL: + problems.append(f"{m['hot']}-{m['cold']}: approach {approach:.9g} < {dT}") + for kind, total, target in (('hot', sum(cert['hot_utility'].values()), Q_hot), + ('cold', sum(cert['cold_utility'].values()), Q_cold)): + if not abs(total - target) <= qtol: + problems.append(f'{kind} utility {total:.9g} != MER target {target:.9g}') + return problems + +def _max_matching(musts, partners, ok): + """Size of a maximum one-to-one matching (Kuhn's augmenting paths).""" + owner = {} + def augment(i, seen): + for j, p in enumerate(partners): + if j not in seen and ok(musts[i], p): + seen.add(j) + if j not in owner or augment(owner[j], seen): + owner[j] = i + return True + return False + return sum(augment(i, set()) for i in range(len(musts))) + +def _split_proofs(case): + """Every pinch-rule violation of a constant-CP problem, top pinch first, + above before below [Linnhoff & Hindmarsh 1983]. ABOVE a pinch each hot + stream at the pinch (T_out <= T_p,hot < T_in) must end its last match + against its own cold stream at the pinch (T_in <= T_p,cold < T_out) with + CP_hot <= CP_cold; BELOW, each cold stream at the pinch needs its own hot + stream with CP_hot >= CP_cold. Without splits a partner serves one such + match, so a one-to-one matching that covers the 'must' streams is + necessary; its absence proves that MER needs splitting.""" + dT = case['dTmin'] + *_, zeros, _ = _cascade(case) + def snap(T, P): return P if abs(T - P) <= 1e-9 * max(1., abs(P)) else T + proofs = [] + for pc in zeros: + ph = pc + dT + hot = [(n, snap(a, ph), snap(b, ph), CP) + for n, k, a, b, CP in case['streams'] if k == 'hot'] + cold = [(n, snap(a, pc), snap(b, pc), CP) + for n, k, a, b, CP in case['streams'] if k == 'cold'] + sides = ( + ('above', [s for s in hot if s[2] <= ph < s[1]], [s for s in cold if s[1] <= pc < s[2]], + lambda h, c: h[3] <= c[3] * (1. + 1e-12)), + ('below', [s for s in cold if s[1] < pc <= s[2]], [s for s in hot if s[2] < ph <= s[1]], + lambda c, h: h[3] >= c[3] * (1. - 1e-12)), + ) + for side, musts, partners, ok in sides: + if len(musts) > len(partners): rule = 'number' + elif _max_matching(musts, partners, ok) < len(musts): rule = 'cp' + else: continue + hot_at, cold_at = (musts, partners) if side == 'above' else (partners, musts) + proofs.append(dict(side=side, rule=rule, pinch_hot=ph, pinch_cold=pc, + hot_at_pinch=sorted(s[0] for s in hot_at), + cold_at_pinch=sorted(s[0] for s in cold_at))) + return proofs + +# --------------------------------------------------------------------------- +# Exact stream temperatures (real thermodynamics and constant CP) +# --------------------------------------------------------------------------- + +class _Temperature: + """Temperature [K] of one stream's material at its own pressure as a + function of its enthalpy flow H [kJ/hr], from forward property calls + only (no TP/PH flash, so nothing is shared with the flashes of the code + under test): a Newton solve on a single-phase copy outside the + saturation envelope, T_sat inside it for a pure component, and an exact + inversion along the bubble-point curve of the liquid for a binary glide + (T = T_bubble(x), vapour y = y_bubble(x), lever rule on the first + component). ``breaks`` are the saturated-liquid / saturated-vapour + enthalpies, where T(H) has kinks.""" + + def __init__(self, stream): + thermo, P = stream.thermo, stream.P + mol = np.array(stream.mol, dtype=float) + self.P = P + def single(phase, T): + return bst.Stream(None, flow=mol.copy(), T=T, P=P, phase=phase, + units='kmol/hr', thermo=thermo) + N = len(stream.vle_chemicals) + if N == 0: + self.kind, self.breaks = 'single', () + self.liquid = single(stream.phase if stream.phase in ('l', 'g') else 'l', stream.T) + return + if N > 2: raise NotImplementedError('only pure components and binaries') + ref = stream.copy() + self.kind = 'pure' if N == 1 else 'binary' + self.T_bubble = ref.bubble_point_at_P(P).T + self.T_dew = self.T_bubble if N == 1 else ref.dew_point_at_P(P).T + self.liquid, self.vapor = single('l', self.T_bubble), single('g', self.T_dew) + self.breaks = (self.liquid.H, self.vapor.H) + if N == 2: + self.bp = bp = ref.get_bubble_point() + self.IDs = bp.IDs + self.z = float(ref.get_normalized_mol(self.IDs)[0]) + self.F = float(ref.imol[self.IDs].sum()) + self._l = bst.Stream(None, P=P, phase='l', thermo=thermo) + self._g = bst.Stream(None, P=P, phase='g', thermo=thermo) + x_dew = float(ref.dew_point_at_P(P).x[0]) + self.xs = np.linspace(self.z, x_dew, 33) + self.Hs = np.array([self._state(x)[1] for x in self.xs]) + + def _state(self, x1): + """(T, H) of the two-phase state whose liquid has mole fraction x1.""" + x = np.array([x1, 1. - x1]) + r = self.bp(x, P=self.P) + y = np.asarray(r.y, float) + V = 1. if abs(y[0] - x1) < 1e-15 else min(max((self.z - x1) / (y[0] - x1), 0.), 1.) + self._l.imol[self.IDs] = self.F * (1. - V) * x + self._g.imol[self.IDs] = self.F * V * y + self._l.T = self._g.T = r.T + return r.T, self._l.H + self._g.H + + @staticmethod + def _newton(s, H): + for _ in range(100): + step = (H - s.H) / s.C + s.T += step + if abs(step) < 1e-10: break + return s.T + + def __call__(self, H): + if self.kind == 'single': return self._newton(self.liquid, H) + H_bubble, H_dew = self.breaks + tol = 1e-9 * max(1., H_dew - H_bubble) + if H <= H_bubble + tol: return self._newton(self.liquid, H) + if H >= H_dew - tol: return self._newton(self.vapor, H) + if self.kind == 'pure': return self.T_bubble + # binary glide: bracket on the stored bubble-curve states, then the + # Illinois variant of regula falsi on the liquid mole fraction + k = min(max(int(np.searchsorted(self.Hs, H)) - 1, 0), self.xs.size - 2) + a, b = self.xs[k], self.xs[k + 1] + fa, fb = self.Hs[k] - H, self.Hs[k + 1] - H + T = self.T_bubble + for _ in range(100): + x = b - fb * (b - a) / (fb - fa) if fb != fa else 0.5 * (a + b) + T, Hx = self._state(x) + fx = Hx - H + if abs(fx) <= 1e-12 * (H_dew - H_bubble): break + if (fx > 0) != (fb > 0): a, fa = b, fb + else: fa *= 0.5 + b, fb = x, fx + return T + +_TEMPERATURES = {} + +def _temperature(stream): + """Cached _Temperature for a stream's material (composition and P).""" + mol = np.array(stream.mol, dtype=float) + key = (id(stream.thermo), round(stream.P, 6), tuple(np.round(mol, 9))) + if key not in _TEMPERATURES: _TEMPERATURES[key] = _Temperature(stream) + return _TEMPERATURES[key] + +def _min_approach(hot, H_hot_in, H_hot_out, cold, H_cold_in, H_cold_out, n=21): + """Minimum hot-minus-cold temperature difference inside a counter-current + exchanger (hot enters at H_hot_in, cold leaves at H_cold_out; position = + duty q from the hot end), on exact states: n uniform duty points plus + every phase boundary of either stream, then a golden-section refinement + around the smallest sample (T(H) is smooth between the kinks, which are + sample points).""" + Q = H_hot_in - H_hot_out + if Q == 0.: return math.inf # no heat transferred + if not Q > 0.: return math.nan # reversed or NaN duty: never feasible + qs = set(np.linspace(0., Q, n).tolist()) + qs.update(q for q in [H_hot_in - H for H in hot.breaks] + + [H_cold_out - H for H in cold.breaks] if 0. < q < Q) + qs = sorted(qs) + def dT(q): return hot(H_hot_in - q) - cold(max(H_cold_out - q, H_cold_in)) + values = [dT(q) for q in qs] + k = int(np.argmin(values)) + best = values[k] + a, b = qs[max(k - 1, 0)], qs[min(k + 1, len(qs) - 1)] + g = (math.sqrt(5.) - 1.) / 2. + c, d = b - g * (b - a), a + g * (b - a) + fc, fd = dT(c), dT(d) + for _ in range(30): + if fc < fd: b, d, fd = d, c, fc; c = b - g * (b - a); fc = dT(c) + else: a, c, fc = c, d, fd; d = a + g * (b - a); fd = dT(d) + return min(best, fc, fd) + +# --------------------------------------------------------------------------- +# Real-thermodynamics references +# --------------------------------------------------------------------------- + +def _stream_ends(units): + """{ID: (inlet, re-flashed outlet, is_hot)} of the process streams.""" + ends = {} + for hx in units: + inlet, outlet = hx.ins[0].copy(), hx.outs[0].copy() + outlet.vle(H=outlet.H, P=outlet.P) + ends[hx.ID] = (inlet, outlet, outlet.H < inlet.H) + return ends + +def _grid_error(ID, inlet, outlet, T, h): + """Leading-order error [kJ/hr] of the dense reference's enthalpy of one + stream `ID` (end states `inlet`, re-flashed `outlet`) at real temperature `T`: + zero outside the stream's range or on a node of its grid (uniform with + spacing at most `h` between its end temperatures, without the nodes + inside a binary's two-phase range, plus its phase-change temperatures), + else ``(T - T_a) (T_b - T) / 2 * |dC/dT|`` inside the grid interval + [T_a, T_b] of a single-phase stretch (dC/dT by a central difference on + a phase-fixed copy). A binary glide at `T` is not covered (AssertionError).""" + lo, hi = sorted((inlet.T, outlet.T)) + if not lo + 1e-9 < T < hi - 1e-9: return 0. + n = max(math.ceil((hi - lo) / h), 1) + nodes = [float(x) for x in np.linspace(lo, hi, n + 1)] + N = len(inlet.vle_chemicals) + if N: + f = _temperature(inlet) + T_bubble, T_dew = f.T_bubble, f.T_dew + if N > 1: + nodes = [x for x in nodes if not T_bubble - 1e-6 <= x <= T_dew + 1e-6] + nodes += [x for x in (T_bubble, T_dew) if lo - 1e-7 <= x <= hi + 1e-7] + if T_bubble - 1e-6 <= T <= T_dew + 1e-6: + assert N == 1, f'{ID}: a binary glide crosses the reference pinch' + return 0. # a pure component's latent heat, at a node + phase = 'l' if T < T_bubble else 'g' + else: + phase = inlet.phase + a = max(x for x in nodes if x <= T) + b = min(x for x in nodes if x >= T) + if T - a <= 1e-9 or b - T <= 1e-9: return 0. + s = bst.Stream(None, flow=np.array(inlet.mol, dtype=float), T=T + 0.5, P=inlet.P, + phase=phase, units='kmol/hr', thermo=inlet.thermo) + C_hi = s.C + s.T = T - 0.5 + return (T - a) * (b - T) / 2. * abs(C_hi - s.C) + +_REFERENCE_TOLERANCES = {} + +def _reference_tolerance(case, units): + """Accuracy [kJ/hr] of a real-thermo case's dense-grid reference targets + (see the module docstring): REF_MARGIN times the sum of the streams' + `_grid_error` at the reference pinch, plus REF_FLOOR of the total duty.""" + name = case['name'] + if name not in _REFERENCE_TOLERANCES: + ref, dT = case['reference'], case['T_min_app'] + error = 0. + if ref['pinch_T_shifted'] is not None: + for ID, (inlet, outlet, hot) in _stream_ends(units).items(): + T = ref['pinch_T_shifted'] + (dT if hot else 0.) + error += _grid_error(ID, inlet, outlet, T, ref['grid_h']) + _REFERENCE_TOLERANCES[name] = REF_MARGIN * error + REF_FLOOR * _duties(units)[0] + return _REFERENCE_TOLERANCES[name] + +def _reference_targets(case, units): + """(Q_hot, Q_cold, tol_hot, tol_cold) [kJ/hr] of the case's independent + reference: the closed-form cascade (constant CP) or the stored dense-grid + targets (real thermodynamics), with the tolerance of that reference.""" + if _is_cp(case): + Q_hot, Q_cold, *_ = _cascade(case) + kJ_per_hr = 3600. * Q_UNITS[case['Q_unit']] + tol = TARGET_RTOL * _total_duty(case) * kJ_per_hr + return Q_hot * kJ_per_hr, Q_cold * kJ_per_hr, tol, tol + ref = case['reference'] + tol = _reference_tolerance(case, units) + return ref['Q_hot'], ref['Q_cold'], tol, tol + +def _verify_rt_certificate(case, units, T_min_app): + """Walk a real-thermo certificate: each stream passes its matches in flow + order (``hot_seq`` / ``cold_seq`` = 1..n) from its inlet enthalpy, every + match keeps the exact internal approach >= T_min_app, no stream is + over-heated or over-cooled by its matches, the far-end utilities + reproduce the certificate's simulated utilities, and those equal the + dense reference targets (a few duties were lowered by up to 0.17 kJ/hr + after the simulation, see the cases' notes, so the walked utilities + are compared with the simulated ones only). Returns (problems, worst + approach [K]).""" + ends = _stream_ends(units) + total = sum(abs(o.H - i.H) for i, o, _ in ends.values()) + problems, planned, last = [], {}, {} + for m in case['certificate']: + if not (_finite(m['Q']) and m['Q'] > 0. and m['hot'] in ends and m['cold'] in ends + and ends[m['hot']][2] and not ends[m['cold']][2]): + problems.append(f"{m['hot']}-{m['cold']}: bad duty {m['Q']!r}, " + "unknown stream or wrong kind") + if problems: return problems, math.nan + for ID, (inlet, outlet, hot) in ends.items(): + own = sorted((m[f"{'hot' if hot else 'cold'}_seq"], k) + for k, m in enumerate(case['certificate']) + if m['hot' if hot else 'cold'] == ID) + if [s for s, _ in own] != list(range(1, len(own) + 1)): + problems.append(f'{ID}: flow positions {[s for s, _ in own]} are not 1..n') + H = inlet.H + for _, k in own: + H_next = H + (-1. if hot else 1.) * case['certificate'][k]['Q'] + planned[k, ID] = (H, H_next) + H = H_next + last[ID] = H + worst = math.inf + for k, m in enumerate(case['certificate']): + (Hh_in, Hh_out), (Hc_in, Hc_out) = planned[k, m['hot']], planned[k, m['cold']] + approach = _min_approach(_temperature(ends[m['hot']][0]), Hh_in, Hh_out, + _temperature(ends[m['cold']][0]), Hc_in, Hc_out) + worst = min(worst, approach) + if not approach >= T_min_app - APPROACH_TOL: + problems.append(f"{m['hot']}-{m['cold']} ({m['side']}): approach {approach:.7f} K") + heat = cool = 0. + for ID, (inlet, outlet, hot) in ends.items(): + need = outlet.H - last[ID] # far-end utility: > 0 heating, < 0 cooling + if (need > 0 if hot else need < 0) and abs(need) > DUTY_RTOL * abs(outlet.H - inlet.H): + problems.append(f'{ID}: over-{"cooled" if hot else "heated"} by {abs(need):.6g} kJ/hr') + heat += max(need, 0.) + cool += max(-need, 0.) + stored = case['certificate_utilities'] + ref = case['reference'] + tol_ref = _reference_tolerance(case, units) + for label, Q, Q_cert, Q_ref in (('heating', heat, stored['Q_hot'], ref['Q_hot']), + ('cooling', cool, stored['Q_cold'], ref['Q_cold'])): + if not abs(Q - Q_cert) <= DUTY_RTOL * total: + problems.append(f'{label} {Q:.10g} != simulated certificate {Q_cert:.10g}') + if not abs(Q_cert - Q_ref) <= tol_ref: + problems.append(f'{label}: simulated certificate {Q_cert:.10g} ' + f'!= dense reference {Q_ref:.10g}') + return problems, worst + +def _rt_split_proof(case, units): + """Re-derive the pinch number rule of a real-thermo SPLIT case at the + reference pinch T_p (shifted scale; hot streams shifted down by + T_min_app) from the streams' own temperatures. Above: hot streams that + cross T_p vs cold streams that cross it or enter at it; below: cold + streams that cross T_p vs hot streams that cross it or enter at it (a + stream entering exactly at T_p is the one that creates the pinch). + Returns (violations {side: (hot IDs, cold IDs)}, problems), where + problems lists anything that would make the finite-CP rules + inapplicable: an outlet at T_p, any other end or an in-range phase change + (T_sat, bubble or dew point) within PINCH_MARGIN of T_p.""" + Tp, dT, tol = case['reference']['pinch_T_shifted'], case['T_min_app'], 1e-6 + rows, problems = [], [] + for ID, (inlet, outlet, hot) in _stream_ends(units).items(): + shift = dT if hot else 0. + T_in, T_out = inlet.T - shift, outlet.T - shift + lo, hi = sorted((T_in, T_out)) + rows.append((ID, hot, T_in, lo + tol < Tp < hi - tol)) + if abs(T_out - Tp) <= tol: + problems.append(f'{ID}: outlet at the pinch') + elif abs(T_out - Tp) < PINCH_MARGIN or (tol < abs(T_in - Tp) < PINCH_MARGIN): + problems.append(f'{ID}: end temperature within {PINCH_MARGIN} K of the pinch') + N = len(inlet.vle_chemicals) + changes = [] if N == 0 else [inlet.bubble_point_at_P(inlet.P).T] + if N == 2: changes.append(inlet.dew_point_at_P(inlet.P).T) + for T in changes: + if lo - tol <= T - shift <= hi + tol and abs(T - shift - Tp) < PINCH_MARGIN: + problems.append(f'{ID}: phase change at {T:.3f} K within ' + f'{PINCH_MARGIN} K of the pinch') + crossing = lambda hot: [ID for ID, h, _, x in rows if h == hot and x] + entering = lambda hot: [ID for ID, h, T_in, x in rows + if h == hot and not x and abs(T_in - Tp) <= tol] + above = (crossing(True), crossing(False) + entering(False)) + below = (crossing(True) + entering(True), crossing(False)) + violations = {} + if len(above[0]) > len(above[1]): violations['above'] = above + if len(below[1]) > len(below[0]): violations['below'] = below + return violations, problems + +# --------------------------------------------------------------------------- +# Synthesis (cached per case: three tests inspect the same network) +# --------------------------------------------------------------------------- + +_NETWORKS = {} + +def _network(case): + """Synthesize the case with the public facility, like a user would.""" + name = case['name'] + if name not in _NETWORKS: + try: + units, T_min_app = _build(case) + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app) + sys = bst.System.from_units('sys', units=[*units, HXN]) + t0 = time.perf_counter() + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + # biosteam's HeatUtility.load_agent names a new 'oxygen_rich_inlet' + # stream for every fuel (furnace) utility; the network itself replaces + # nothing in the registry (test_synthesis_registers_no_intermediate_streams) + warnings.filterwarnings('ignore', category=RuntimeWarning, + message='.* has been replaced in registry') + sys.simulate() + time_s = time.perf_counter() - t0 + hus = [hu for hx in HXN.new_HX_utils for hu in hx.heat_utilities] + total, net = _duties(units) + _NETWORKS[name] = dict( + units=units, HXN=HXN, T_min_app=T_min_app, time=time_s, + table=_hensmith_table(units, T_min_app), total=total, net=net, + heat=sum(hu.unit_duty for hu in hus if hu.unit_duty > 0), + cool=-sum(hu.unit_duty for hu in hus if hu.unit_duty < 0), + ) + except Exception as error: + _NETWORKS[name] = error + result = _NETWORKS[name] + if isinstance(result, Exception): + raise RuntimeError(f'{name}: synthesis failed: {result!r}') from result + return result + +def _network_problems(net): + """Everything physically wrong with a synthesized network (see the + module docstring, test_network_balanced_and_feasible).""" + HXN, T_min_app = net['HXN'], net['T_min_app'] + problems = [] + if not abs(HXN.energy_balance_percent_error) < EB_TOL: + problems.append(f'energy balance error {HXN.energy_balance_percent_error:.3g} %') + if not abs(net['heat'] - net['cool'] - net['net']) <= NET_RTOL * net['total']: + problems.append(f"heat - cool = {net['heat'] - net['cool']:.10g} " + f"!= net duty {net['net']:.10g}") + others = [u.ID for u in HXN.HXN_sys.units if not isinstance(u, (bst.HXprocess, bst.HXutility))] + if others: problems.append(f'units other than HXprocess/HXutility (splits?): {others}') + originals, cycles = HXN.original_heat_exchangers, HXN.stream_life_cycles + if not (len(originals) == len(cycles) == len(net['units']) + and set(originals) == set(net['units'])): + problems.append(f'{len(cycles)} life cycles for {len(originals)} original exchangers; ' + f"expected one per process stream ({len(net['units'])})") + def same_state(s, t, duty): + return (np.allclose(s.mol, t.mol, rtol=1e-12, atol=1e-12) + and abs(s.P - t.P) <= 1e-9 * t.P and abs(s.H - t.H) <= DUTY_RTOL * duty) + stream_duty = {} # (unit, port) -> total duty of the process stream it carries + stages_on = {} # (unit, port) -> number of life-cycle stages on it + for hx, life_cycle in zip(originals, cycles): + duty = abs(hx.outs[0].H - hx.ins[0].H) + sign = 1. if hx.outs[0].H >= hx.ins[0].H else -1. + name = f'stream {life_cycle.index} ({hx.ID})' + stages = life_cycle.life_cycle + for stage in stages: + key = stage.unit, stage.index + stream_duty[key] = duty + stages_on[key] = stages_on.get(key, 0) + 1 + # every stage moves the stream towards its outlet: a cold stream + # is never cooled, a hot stream never heated + change = sign * (stage.s_out.H - stage.s_in.H) + if not change >= -DUTY_RTOL * duty: + problems.append(f'{name}: {"cooled" if sign > 0 else "heated"} by ' + f'{-change:.10g} kJ/hr in {stage.unit.ID}') + for a, b in zip(stages, stages[1:]): + if not (a.s_out is b.s_in or same_state(a.s_out, b.s_in, duty)): + problems.append(f'{name}: {b.unit.ID} inlet (H={b.s_in.H:.10g}) is not ' + f'the {a.unit.ID} outlet (H={a.s_out.H:.10g})') + for label, s, original in (('inlet', stages[0].s_in, hx.ins[0]), + ('outlet', stages[-1].s_out, hx.outs[0])): + if not same_state(s, original, duty): + problems.append(f'{name}: network {label} ' + f'(H={s.H:.10g}, P={s.P:.8g}) != original (H={original.H:.10g}, ' + f'P={original.P:.8g})') + for unit, ports in [(u, (0, 1)) for u in HXN.new_HXs] + [(u, (0,)) for u in HXN.new_HX_utils]: + for port in ports: + n = stages_on.get((unit, port), 0) + if n != 1: + problems.append(f'{unit.ID} port {port}: on {n} life-cycle stages, expected 1') + for hx in HXN.new_HXs: + dH = [s_in.H - s_out.H for s_in, s_out in zip(hx.ins, hx.outs)] + for s_in, s_out in zip(hx.ins, hx.outs): + if not np.allclose(s_in.mol, s_out.mol, rtol=1e-12, atol=1e-12): + problems.append(f'{hx.ID}: mass balance') + scale = (stream_duty.get((hx, 0), 0.) + stream_duty.get((hx, 1), 0.) + or sum(map(abs, dH))) + h = int(np.argmax(dH)) + c = 1 - h + if not abs(dH[h] + dH[c]) <= DUTY_RTOL * scale: + problems.append(f'{hx.ID}: heat released {dH[h]:.10g} != ' + f'heat absorbed {-dH[c]:.10g} kJ/hr') + if dH[h] <= 1e-12 * scale: continue # no heat transferred + approach = _min_approach( + _temperature(hx.ins[h]), hx.ins[h].H, hx.outs[h].H, + _temperature(hx.ins[c]), hx.ins[c].H, hx.outs[c].H, + ) + if not approach >= T_min_app - APPROACH_TOL: + problems.append(f'{hx.ID}: internal approach {approach:.7f} K < {T_min_app} K') + return problems + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_corpus_composition(): + names = [case['name'] for case in NO_SPLIT + SPLIT] + assert len(names) == len(set(names)) + for cases in (NO_SPLIT, SPLIT): + assert 30 <= len(cases) <= 45 + assert sum(len(case['streams']) > 10 for case in cases) >= 5 + for case in NO_SPLIT: assert 'certificate' in case and 'proof' not in case + for case in SPLIT: assert 'proof' in case and 'certificate' not in case + for case in NO_SPLIT + SPLIT: + assert case['kind'] in ('constant_cp', 'real_thermo') + IDs = [_unit_ID(s[0]) for s in case['streams']] + assert len(IDs) == len(set(IDs)), case['name'] + +@pytest.mark.parametrize('case', NO_SPLIT + SPLIT, ids=_name) +def test_mer_targets(case): + units, T_min_app = _build(case) + table = _hensmith_table(units, T_min_app) + if not _is_cp(case): + ref = case['reference'] + tol = _reference_tolerance(case, units) + for label, got, want in (('Q_hot', table.hot_util_load, ref['Q_hot']), + ('Q_cold', table.cold_util_load, ref['Q_cold'])): + assert abs(got - want) <= tol, (label, got, want, tol) + if ref['pinch_T_shifted'] is None: # no hot utility: pinch at the top + assert table.pinch_T == table.Ts[0] + else: + assert abs(table.pinch_T - ref['pinch_T_shifted']) <= PINCH_TOL, table.pinch_T + return + Q_hot, Q_cold, zeros, Ts = _cascade(case) + tol = TARGET_RTOL * _total_duty(case) + kJ_per_hr = 3600. * Q_UNITS[case['Q_unit']] + scale, offset = T_UNITS[case['T_unit']] + got_hot = table.hot_util_load / kJ_per_hr + got_cold = table.cold_util_load / kJ_per_hr + pinch = table.pinch_T / scale - offset + assert abs(got_hot - Q_hot) <= tol, (got_hot, Q_hot) + assert abs(got_cold - Q_cold) <= tol, (got_cold, Q_cold) + assert min(abs(pinch - z) for z in zeros) <= PINCH_TOL, (pinch, zeros) + if Q_hot <= tol: assert abs(pinch - Ts[0]) <= PINCH_TOL, (pinch, Ts[0]) + # the benchmark oracle's stored targets agree with the closed form + stored = case['targets'] + assert abs(stored['Q_hot'] - Q_hot) <= tol and abs(stored['Q_cold'] - Q_cold) <= tol + assert stored['pinch_cold'] in zeros + published = case['published'] or {} + for key, got in (('Q_hot', got_hot), ('Q_cold', got_cold)): + if published.get(key): + value, value_tol = published[key] + assert abs(got - value) <= value_tol + tol, (key, got, value) + for key, shift in (('pinch_cold', 0.), ('pinch_hot', case['dTmin'])): + if published.get(key): + value, _ = published[key] + assert min(abs(value - shift - z) for z in zeros) <= PINCH_TOL, (key, value) + +@pytest.mark.parametrize('case', NO_SPLIT, ids=_name) +def test_no_split_certificate(case): + if _is_cp(case): + problems = _verify_certificate(case) + else: + units, T_min_app = _build(case) + problems, worst = _verify_rt_certificate(case, units, T_min_app) + assert not problems, '\n'.join(problems) + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_proof(case): + proof = case['proof'] + if _is_cp(case): + # no point loads anywhere: every stream has a finite, positive CP + assert all(CP > 0 and T_in != T_out for _, _, T_in, T_out, CP in case['streams']) + proofs = _split_proofs(case) + assert proofs, 'no pinch-rule violation' + first = proofs[0] # the stored proof is the first violation + assert (first['side'], first['rule']) == (proof['side'], proof['rule']), first + assert first['hot_at_pinch'] == sorted(proof['hot_at_pinch']), first + assert first['cold_at_pinch'] == sorted(proof['cold_at_pinch']), first + assert abs(first['pinch_cold'] - proof['pinch_cold']) <= PINCH_TOL, first + assert abs(first['pinch_hot'] - proof['pinch_hot']) <= PINCH_TOL, first + else: + units, _ = _build(case) + violations, problems = _rt_split_proof(case, units) + assert not problems, '\n'.join(problems) + assert proof['side'] in violations, violations + hot, cold = violations[proof['side']] + assert sorted(hot) == sorted(proof['hot_at_pinch']) + assert sorted(cold) == sorted(proof['cold_at_pinch']) + +@pytest.mark.parametrize('case', NO_SPLIT + SPLIT, ids=_name) +def test_network_balanced_and_feasible(case): + problems = _network_problems(_network(case)) + assert not problems, '\n'.join(problems) + +def _utilities(case, net): + """(label, network utility, hensmith target, reference, reference + tolerance) for heating and cooling [kJ/hr].""" + ref_hot, ref_cold, tol_hot, tol_cold = _reference_targets(case, net['units']) + table = net['table'] + return [('heating', net['heat'], table.hot_util_load, ref_hot, tol_hot), + ('cooling', net['cool'], table.cold_util_load, ref_cold, tol_cold)] + +@pytest.mark.parametrize('case', NO_SPLIT, ids=_name) +def test_no_split_network_reaches_mer(case): + net = _network(case) + atol = MER_TOL[case['kind']] * net['total'] + for label, got, target, ref, ref_tol in _utilities(case, net): + assert abs(got - target) <= atol, (label, got, target) + assert abs(got - ref) <= atol + ref_tol, (label, got, ref) + assert net['HXN'].synthesis_info['status'] == 'mer' + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_network_never_beats_mer(case): + net = _network(case) + atol = BEAT_RTOL * net['total'] + for label, got, target, ref, ref_tol in _utilities(case, net): + assert got >= target * (1. - BEAT_RTOL) - atol, (label, got, target) + assert got >= ref - ref_tol - atol, (label, got, ref) + assert net['HXN'].synthesis_info['status'] == 'best_effort' diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py new file mode 100644 index 0000000..1b997d9 --- /dev/null +++ b/tests/test_hxn_planner.py @@ -0,0 +1,949 @@ +# -*- 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 for the MER network planner (`hensmith._planner`) on numbers only: +constant-CP problems through `_plan_numeric` and piecewise-linear curves +(flats, collapsed vertical stretches) through `plan_network`. Everything is +checked against helpers written here: a constant-CP problem table, the pinch +design rules, a network walk, residual feasibility and match feasibility by +direct evaluation. +""" +import math +import random + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from hensmith import _planner as P + + +# %% Independent helpers + +def streams_from(rows): + return [dict(name=str(n), kind='hot' if k == 'h' else 'cold', + T_in=float(a), T_out=float(b), CP=float(cp)) + for n, k, a, b, cp in rows] + + +def duty_scale(streams): + return max(1., sum(abs(s['CP'] * (s['T_out'] - s['T_in'])) + for s in streams)) + + +def cascade(streams, dT): + """Constant-CP problem table: (Qh, Qc, shifted temperatures where the + feasible cascade is zero). Hot streams are shifted down by dT.""" + spans = [(s['T_out'] - dT, s['T_in'] - dT, s['CP']) if s['kind'] == 'hot' + else (s['T_in'], s['T_out'], -s['CP']) for s in streams] + Ts = sorted({t for lo, hi, _ in spans for t in (lo, hi)}, reverse=True) + r = [0.] + for hi, lo in zip(Ts, Ts[1:]): + r.append(r[-1] + sum(cp * (hi - lo) for a, b, cp in spans + if a <= lo and b >= hi)) + Qh = max(0., -min(r)) + tol = 1e-9 * duty_scale(streams) + return Qh, r[-1] + Qh, [T for T, v in zip(Ts, r) if v + Qh <= tol] + + +def _matching(left, right, ok): + owner = {} + + def augment(i, seen): + for j in range(len(right)): + if j not in seen and ok(left[i], right[j]): + seen.add(j) + if j not in owner or augment(owner[j], seen): + owner[j] = i + return True + return False + return all(augment(i, set()) for i in range(len(left))) + + +def needs_split(streams, dT): + """Pinch design method: at every zero of the cascade each hot stream at + the pinch (above) needs its own cold stream at the pinch with CP_hot <= + CP_cold, and each cold stream at the pinch (below) its own hot stream + with CP_hot >= CP_cold. A violation proves that MER needs splits.""" + for pc in cascade(streams, dT)[2]: + ph = pc + dT + hu = [s['CP'] for s in streams if s['kind'] == 'hot' + and s['T_out'] <= ph < s['T_in']] + cu = [s['CP'] for s in streams if s['kind'] == 'cold' + and s['T_in'] <= pc < s['T_out']] + cd = [s['CP'] for s in streams if s['kind'] == 'cold' + and s['T_in'] < pc <= s['T_out']] + hd = [s['CP'] for s in streams if s['kind'] == 'hot' + and s['T_out'] < ph <= s['T_in']] + if not _matching(hu, cu, lambda h, c: h <= c * (1. + 1e-12)): + return True + if not _matching(cd, hd, lambda c, h: h >= c * (1. - 1e-12)): + return True + return False + + +def check_network(streams, dT, net, tol=1e-6): + """Walk every stream through its matches in flow order (hot_seq / + cold_seq), recompute temperatures from Q / CP and assert: both ends of + every match keep dT (constant CP: linear profiles), positive duties, + utilities at the far end close every stream, global balance. Returns the + hot and cold utility.""" + S = {s['name']: s for s in streams} + qtol = tol * duty_scale(streams) + temps = {} + for name, s in S.items(): + hot = s['kind'] == 'hot' + key = 'hot_seq' if hot else 'cold_seq' + own = sorted((m for m in net['matches'] + if m['hot' if hot else 'cold'] == name), + key=lambda m: m[key]) + assert [m[key] for m in own] == list(range(1, len(own) + 1)) + T = s['T_in'] + for m in own: + assert m['Q'] > 0. + T2 = T - m['Q'] / s['CP'] if hot else T + m['Q'] / s['CP'] + recorded = ((m['T_hot_in'], m['T_hot_out']) if hot + else (m['T_cold_in'], m['T_cold_out'])) + assert_allclose(recorded, (T, T2), rtol=1e-9, atol=tol) + temps.setdefault(id(m), {})[hot] = (T, T2) + T = T2 + need = s['CP'] * ((T - s['T_out']) if hot else (s['T_out'] - T)) + u = (net['cold_utility'] if hot else net['hot_utility']).get(name, 0.) + assert need >= -qtol + assert abs(u - need) <= qtol + for m in net['matches']: + (hi, ho), (ci, co) = temps[id(m)][True], temps[id(m)][False] + assert min(hi - co, ho - ci) >= dT - tol + Qh = sum(net['hot_utility'].values()) + Qc = sum(net['cold_utility'].values()) + dh = sum(s['CP'] * (s['T_in'] - s['T_out']) for s in streams + if s['kind'] == 'hot') + dc = sum(s['CP'] * (s['T_out'] - s['T_in']) for s in streams + if s['kind'] == 'cold') + assert abs(dh + Qh - dc - Qc) <= 10. * qtol + return Qh, Qc + + +def assert_mer(rows, dT, **kw): + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **kw) + Qh, Qc = check_network(streams, dT, net) + Qh_t, Qc_t, _ = cascade(streams, dT) + tol = 1e-6 * duty_scale(streams) + assert net['status'] == 'mer' + assert abs(Qh - Qh_t) <= tol and abs(Qc - Qc_t) <= tol + return net + + +def root_proof(plan): + return any(s['proof'] is not None + and s['proof'].get('rule') in ('outward', 'inward') + for s in plan.info['sides'].values()) + + +def random_problem(rng, n_lo=2, n_hi=6): + """Random constant-CP problem on a 5 K grid (hot and cold streams).""" + n = rng.randint(n_lo, n_hi) + nh = rng.randint(1, n - 1) + rows = [] + for k in range(n): + hot = k < nh + a, b = sorted(rng.sample(range(40 if hot else 20, 260 if hot else 240, + 5), 2)) + cp = rng.choice([.5, 1., 1.5, 2., 3., 4.]) + rows.append((f'H{k}' if hot else f'C{k}', 'h' if hot else 'c', + b if hot else a, a if hot else b, cp)) + return rows, float(rng.choice([5, 10, 20])) + + +def pinch_problem(rng): + """Random constant-CP problem built around a pinch at P (hot streams + ending or crossing at P + dT, cold streams starting or crossing at P), + where the pinch design rules often fail.""" + dT = 10. + P0 = float(rng.choice(range(100, 200, 10))) + cps = [1., 1.5, 2., 3., 4.] + rows = [] + for k in range(rng.randint(1, 4)): + T_out = P0 + dT - rng.choice([0, 0, 10, 20, 40]) + rows.append((f'H{k}', 'h', P0 + dT + rng.choice(range(10, 100, 5)), + T_out, rng.choice(cps))) + for k in range(rng.randint(1, 4)): + T_in = P0 - rng.choice([0, 0, 10, 20, 40]) + rows.append((f'C{k}', 'c', T_in, P0 + rng.choice(range(10, 100, 5)), + rng.choice(cps))) + return rows, dT + + +def random_level_curve(rng, flats=True, pieces=(1, 4), grid=False): + """Random level curve; on an integer `grid` knots and levels coincide + between curves (flats at the other curve's levels, shared knots).""" + n = rng.randint(*pieces) + if grid: + q, y = [0.], [float(rng.randint(0, 6))] + for _ in range(n): + q.append(q[-1] + rng.randint(1, 5)) + y.append(y[-1] + (0. if flats and rng.random() < 0.35 + else rng.randint(1, 4))) + return P._LevelCurve(q, y) + q, y = [0.], [rng.uniform(0., 20.)] + for _ in range(n): + q.append(q[-1] + rng.uniform(0.5, 10.)) + y.append(y[-1] + (0. if flats and rng.random() < 0.3 + else rng.uniform(0.5, 15.))) + return P._LevelCurve(q, y) + + +def random_curve_problem(rng, n_hi=6, flat_p=0.3): + """Random piecewise-linear streams with flats on a 5 K grid (knots, + is_hot, T_min_app).""" + kn, hot = [], [] + for k in range(rng.randint(2, n_hi)): + T, H = [float(rng.choice(range(20, 200, 5)))], [0.] + for _ in range(rng.randint(1, 3)): + flat = rng.random() < flat_p + T.append(T[-1] + (0. if flat else rng.choice(range(5, 60, 5)))) + H.append(H[-1] + rng.choice(range(5, 100, 5))) + kn.append((T, H)) + hot.append(k == 0 or (k > 1 and rng.random() < 0.5)) + return kn, hot, float(rng.choice([5, 10])) + + +def match_ok(cm, a, cf, b, x, tol=1e-9): + """Direct check of phi_m(a + t) >= phi_f(b + t) on [0, x] at every knot + of both curves inside the window (exact for piecewise-linear curves).""" + ts = np.concatenate(([0., x], cm.qa - a, cf.qa - b)) + ts = ts[(ts >= 0.) & (ts <= x)] + g = (np.interp(a + ts, cm.qa, cm.ya) - np.interp(b + ts, cf.qa, cf.ya)) + return bool(g.min() >= -tol) + + +def residual_slack(side, a, b): + """min over levels of S - D (inclusive and exclusive) by direct + evaluation of every curve at every knot and frontier level.""" + levels = set() + for curves, front in ((side.musts, a), (side.flexes, b)): + for c, f in zip(curves, front): + levels.add(c.at(f)) + levels.update(y for x, y in zip(c.q, c.y) if x > f) + worst = math.inf + for L in levels: + for le in (True, False): + S = sum(max(0., (c.x_le(L) if le else c.x_lt(L)) - f) + for c, f in zip(side.flexes, b)) + D = sum(max(0., (c.x_le(L) if le else c.x_lt(L)) - f) + for c, f in zip(side.musts, a)) + worst = min(worst, S - D) + return worst + + +def bisect(ok, lo, hi, largest=True, it=80): + """Largest (or smallest) x in [lo, hi] with ok(x), ok monotone.""" + for _ in range(it): + mid = 0.5 * (lo + hi) + if ok(mid) == largest: + lo = mid + else: + hi = mid + return lo if largest else hi + + +# %% Stream data (constant CP): (name, 'h' | 'c', T_in, T_out, CP) + +R002 = [('H1', 'h', 220, 50, 4), ('C1', 'c', 140, 170, 1), + ('C2', 'c', 100, 120, 1), ('C3', 'c', 120, 250, 2)] +LINNHOFF4 = [('C1', 'c', 20, 135, 2), ('H2', 'h', 170, 60, 3), + ('C3', 'c', 80, 140, 4), ('H4', 'h', 150, 30, 1.5)] +REPEATED_PAIR = { # literature cases that need a repeated pair + '4sp2_dt20F': (20, [ + ('H1', 'h', 500, 110, 20000), ('H2', 'h', 430, 230, 50000), + ('H3', 'h', 400, 110, 30000), ('C1', 'c', 25, 420, 70000)]), + 'smith2005_exr18_9_threshold': (20, [ + ('1', 'h', 500, 100, 4.0), ('2', 'c', 50, 450, 1.0), + ('3', 'c', 60, 400, 1.0), ('4', 'c', 40, 420, 0.75)]), + 'nptel_t5_5_threshold_dT5': (5, [ + ('Hot-1', 'h', 180, 60, 3.0), ('Hot-2', 'h', 140, 30, 1.5), + ('Cold-1', 'c', 20, 135, 2.0), ('Cold-2', 'c', 80, 140, 4.0)]), +} +HARD = { # benchmark and fresh held-out problems that defeated a prototype + 'rnd_nested_n2-6_s183': (5, [ + ('H1', 'h', 240, 115, 6.5), ('C1', 'c', 165, 270, 2.0), + ('C2', 'c', 190, 220, 3.2), ('C3', 'c', 205, 220, 1.2), + ('C4', 'c', 190, 220, 2.2)]), + 'rnd_nested_n2-6_s237': (5, [ + ('H1', 'h', 195, 50, 4.4), ('C1', 'c', 130, 200, 1.9), + ('C2', 'c', 140, 150, 3.4), ('C3', 'c', 130, 160, 1.3), + ('C4', 'c', 150, 160, 4.0), ('C5', 'c', 140, 150, 2.1)]), + 's13_111_3x9': (10, [ + ('H1', 'h', 145, 75, 1.5), ('H2', 'h', 205, 75, 1), + ('H3', 'h', 115, 55, 2), ('C1', 'c', 90, 155, 2), + ('C2', 'c', 45, 120, 3), ('C3', 'c', 105, 235, 3), + ('C4', 'c', 175, 220, 3), ('C5', 'c', 90, 195, 2), + ('C6', 'c', 155, 165, 4), ('C7', 'c', 55, 220, 0.5), + ('C8', 'c', 75, 90, 0.5), ('C9', 'c', 140, 205, 4)]), + 'rnd_pinch_design_n7-12_s54': (5, [ + ('H1', 'h', 190, 80, 15.2), ('H2', 'h', 230, 160, 9.0), + ('C1', 'c', 155, 240, 30.4), ('C2', 'c', 110, 250, 7.6), + ('C3', 'c', 155, 200, 0.84), ('H3', 'h', 160, 140, 6.7), + ('C4', 'c', 155, 210, 9.0), ('C5', 'c', 200, 240, 3.5), + ('H4', 'h', 210, 160, 0.7), ('C6', 'c', 130, 250, 6.7)]), + 'hold_threshold_n13-20_s100208': (5, [ + ('C1', 'c', 110, 225, 6.0), ('C2', 'c', 115, 200, 7.5), + ('C3', 'c', 175, 180, 2.5), ('C4', 'c', 90, 175, 30.0), + ('C5', 'c', 20, 215, 1.5), ('C6', 'c', 85, 165, 5.0), + ('C7', 'c', 155, 240, 10.0), ('C8', 'c', 105, 190, 6.0), + ('H1', 'h', 205, 170, 1.0), ('H2', 'h', 305, 125, 4.0), + ('H3', 'h', 255, 120, 0.5), ('H4', 'h', 210, 120, 10.0), + ('H5', 'h', 250, 185, 2.0), ('H6', 'h', 280, 180, 3.0), + ('H7', 'h', 315, 60, 8.0), ('H8', 'h', 275, 65, 1.0), + ('H9', 'h', 280, 205, 3.0), ('H10', 'h', 225, 150, 4.0), + ('H11', 'h', 290, 125, 1.0), ('H12', 'h', 320, 210, 10.0)]), + 'hold_pinch_design_n13-20_s100271': (20, [ + ('H1', 'h', 166, 78, 4.9), ('H2', 'h', 317, 197, 3.9), + ('C1', 'c', 79, 204, 0.65), ('H3', 'h', 239, 151, 7.9), + ('C2', 'c', 209, 277, 1.2), ('C3', 'c', 110, 157, 1.4), + ('H4', 'h', 192, 162, 18.96), ('H5', 'h', 242, 170, 1.9), + ('C4', 'c', 44, 145, 3.1), ('C5', 'c', 172, 225, 2.28), + ('C6', 'c', 80, 109, 9.4), ('H6', 'h', 215, 119, 1.4), + ('C7', 'c', 124, 258, 15.8), ('C8', 'c', 103, 165, 0.99), + ('C9', 'c', 172, 246, 2.8), ('C10', 'c', 119, 148, 0.5)]), + 'hold_nested_n21-40_s100233': (5, [ + ('C1', 'c', 135, 262, 3.8), ('C2', 'c', 135, 296, 4.3), + ('H1', 'h', 229, 140, 1.1), ('H2', 'h', 210, 133, 2.1), + ('H3', 'h', 225, 207, 2.3), ('H4', 'h', 222, 187, 3.0), + ('H5', 'h', 222, 213, 0.6), ('H6', 'h', 209, 195, 2.1), + ('H7', 'h', 216, 177, 2.5), ('H8', 'h', 185, 178, 2.7), + ('H9', 'h', 235, 207, 0.3), ('C3', 'c', 148, 186, 2.9), + ('H10', 'h', 379, 265, 9.7), ('H11', 'h', 379, 268, 3.3), + ('C4', 'c', 256, 366, 8.4), ('C5', 'c', 33, 89, 7.2), + ('H12', 'h', 380, 337, 6.3), ('H13', 'h', 274, 249, 2.3), + ('C6', 'c', 72, 231, 5.2), ('H14', 'h', 244, 93, 4.9), + ('C7', 'c', 20, 127, 8.1), ('C8', 'c', 35, 55, 2.7), + ('C9', 'c', 310, 354, 8.8), ('C10', 'c', 371, 376, 0.8), + ('C11', 'c', 42, 59, 8.0), ('H15', 'h', 124, 63, 6.7), + ('H16', 'h', 310, 277, 5.0), ('C12', 'c', 262, 318, 3.4), + ('H17', 'h', 93, 25, 6.1), ('H18', 'h', 138, 121, 1.3), + ('H19', 'h', 207, 106, 9.3)]), +} +SPLIT = { # the pinch design rules prove that MER needs stream splits + 'smith2005_exr18_4': (10, [ + ('1', 'c', 18, 123, 0.0933), ('2', 'c', 118, 193, 0.1961), + ('3', 'c', 189, 286, 0.1796), ('4', 'h', 159, 77, 0.2285), + ('5', 'h', 267, 80, 0.0204), ('6', 'h', 343, 90, 0.0538)]), + 'smith2005_ex16_5_five_stream': (20, [ + ('1', 'h', 450, 50, 0.25), ('2', 'h', 50, 40, 1.5), + ('3', 'c', 30, 400, 0.22), ('4', 'c', 30, 400, 0.05), + ('5', 'c', 120, 121, 22.0)]), + 'rnd_uniform_n2-6_s327': (5, [ + ('C1', 'c', 111, 159, 2.4), ('C2', 'c', 91, 171, 3.3), + ('H1', 'h', 162, 46, 9.8)]), +} + + +# %% 1. Level curves + +def test_level_curve_flats_and_verticals(): + # a vertical stretch at q = 5 (levels 12 -> 13) collapses to the + # conservative level: the lowest for a must, the highest for a flex + c = P._LevelCurve([0, 2, 5, 5, 8], [10, 12, 12, 13, 16], role='must') + assert c.q == [0., 2., 5., 8.] and c.y == [10., 12., 12., 16.] + f = P._LevelCurve([0, 2, 5, 5, 8], [10, 12, 12, 13, 16], role='flex') + assert f.q == [0., 2., 5., 8.] and f.y == [10., 12., 13., 16.] + assert c.flats == (12.,) and c.Q == 8. + assert c.at(1.) == 11. and c.at(3.) == 12. and c.at(-1.) == 10. + assert c.at(99.) == 16. + assert c.x_le(12.) == 5. and c.x_lt(12.) == 2. # both ends of the flat + assert c.x_le(9.) == 0. and c.x_lt(10.) == 0. and c.x_le(20.) == 8. + assert c.x_lt(20.) == 8. + assert c.slope_right(2.) == 0. and c.slope_left(2.) == 1. + assert c.slope_right(5.) == pytest.approx(4. / 3.) + assert c.slope_left(5.) == 0. + # round trips off the flat, and the vectorised versions + for q in (0.5, 1.7, 5.5, 7.9): + assert c.x_le(c.at(q)) == pytest.approx(q) + assert c.x_lt(c.at(q)) == pytest.approx(q) + L = np.linspace(8., 18., 41) + assert_allclose(c.x_le_many(L), [c.x_le(v) for v in L]) + assert_allclose(c.x_lt_many(L), [c.x_lt(v) for v in L]) + assert_allclose(c.at_many(np.linspace(0., 8., 17)), + [c.at(v) for v in np.linspace(0., 8., 17)]) + + +def test_stream_knots_simplified(): + # collinear interior knots are dropped (constant CP -> one segment); + # a flat is kept; a vertical end stretch is collapsed conservatively + T = np.linspace(300., 400., 65) + curves = P._stream_curves( + [(T, 2. * (T - 300.)), + ([300., 350., 350., 400.], [0., 50., 80., 130.]), + ([300., 310., 400.], [0., 0., 90.])], [False, False, True], 10.) + assert curves[0].T.tolist() == [300., 400.] + assert curves[0].H.tolist() == [0., 200.] + assert curves[1].T.tolist() == [300., 350., 350., 400.] + # hot stream: lowest temperature at the vertical stretch (shifted by 10) + assert curves[2].T.tolist() == [290., 390.] + assert curves[2].H.tolist() == [0., 90.] + # round-off below the tolerances is absorbed ... + curves = P._stream_curves( + [([300., 350., 350. - 1e-10, 400.], [0., 50., 60., 100.])], [False], + 10.) + assert curves[0].T.tolist() == [300., 350., 350., 400.] + # ... but knots that really decrease are rejected, not silently + # flattened into a different stream (here a point load at 400 K) + for kn in (([400., 300.], [0., 100.]), ([300., 400.], [100., 0.]), + ([300., 350., 340., 400.], [0., 10., 20., 30.])): + with pytest.raises(ValueError, match='non-decreasing'): + P.plan_network([kn, ([280., 380.], [0., 100.])], [True, False], + 10.) + with pytest.raises(ValueError, match='finite'): + P.plan_network([([300., 400.], [0., 100.]), + ([280., 380.], [0., 100.])], [True, False], math.nan) + + +# %% 2. Internal pinch + +def test_max_duty_stops_at_internal_crossing(): + # a condensing must (flat at 105 on the shifted scale) against a cold + # flex: both terminals are feasible, the knot at q = 80 is not + cm = P._LevelCurve([0, 20, 80, 110], [95, 105, 105, 135]) + cf = P._LevelCurve([0, 110], [90, 125]) + assert cm.at(0.) - cf.at(0.) >= 0. and cm.at(110.) - cf.at(110.) >= 0. + x = P._max_duty(cm, 0., cf, 0., 110., 1e-12) + assert x == pytest.approx(15. * 110. / 35.) + assert match_ok(cm, 0., cf, 0., x) and not match_ok(cm, 0., cf, 0., 80.) + + +# %% 3. x_res + +def test_xres_closed_form_matches_bisection(): + rng = random.Random(3) + checked = 0 + for _ in range(400): + M, F = rng.randint(1, 3), rng.randint(1, 3) + side = P._Side('above', [random_level_curve(rng) for _ in range(M)], + [random_level_curve(rng) for _ in range(F)], + 1e-11 * 100., 1e-11 * 100.) + a = [rng.choice((0., rng.uniform(0., 0.6 * q))) for q in side.Qm] + b = [rng.choice((0., rng.uniform(0., 0.6 * q))) for q in side.Qf] + if residual_slack(side, a, b) < 0.: + continue + d = side.analyse(a, b) + assert d.slack == pytest.approx(residual_slack(side, a, b), abs=1e-9) + for i in range(M): + xres = side.xres_for(i, d) + for j in range(F): + lim = min(side.Qm[i] - a[i], side.Qf[j] - b[j]) + + def ok(x): + a2, b2 = list(a), list(b) + a2[i] += x + b2[j] += x + return residual_slack(side, a2, b2) >= -1e-9 + expected = lim if ok(lim) else bisect(ok, 0., lim) + assert min(xres[j], lim) == pytest.approx(expected, abs=1e-6) + checked += 1 + if checked >= 200: + break + assert checked >= 200 + + +# %% 4. Closed-form events + +@pytest.mark.parametrize('flats', [False, True]) +def test_events_match_bisection(flats): + rng = random.Random(4 + flats) + n = {'e1b': 0, 'e2b': 0, 'e3': 0} + for _ in range(300): + pieces = (1, 1) if rng.random() < 0.3 else (1, 4) + cm = random_level_curve(rng, flats, pieces) + cf = random_level_curve(rng, flats, pieces) + a = rng.choice((0., rng.uniform(0., 0.5 * cm.Q))) + b = rng.choice((0., rng.uniform(0., 0.5 * cf.Q))) + rem, cap = cm.Q - a, cf.Q - b + # E1b: largest flex advance after which the flex still finishes + # the must in one match (feasibility falls with the advance) + x = P._flex_stop_for(cm, a, rem, cf, b, cap) + if cap < rem: + assert x is None + else: + def ok(x): + return match_ok(cm, a, cf, b + x, rem) + if not ok(0.): + assert x < 1e-7 + else: + hi = cap - rem + expected = hi if ok(hi) else bisect(ok, 0., hi) + assert x == pytest.approx(expected, abs=1e-6) + n['e1b'] += 1 + # E2b: smallest must advance after which the flex finishes it + # (feasibility rises with the advance) + x = P._must_stop_for(cm, a, rem, cf, b, cap, rem + 1.) + + def ok2(x): + return rem - x <= cap + 1e-12 and match_ok(cm, a + x, cf, b, + rem - x) + if not ok2(rem): # the flex starts above the must's last level + assert x is None or x >= rem - 1e-7 + else: + expected = 0. if ok2(0.) else bisect(ok2, 0., rem, largest=False) + assert x == pytest.approx(expected, abs=1e-6) + n['e2b'] += 1 + # E3: the first return of the flex to the must's level (within a + # window where neither curve runs out) + R = rng.uniform(0., 0.5 * cap) + xt = min(rem, cap - R) + x = P._return_duty(cm, a, cf, b, R, xt) + ts = np.linspace(0., xt, 4001) + h = np.interp(a + ts, cm.qa, cm.ya) - np.interp(b + R + ts, cf.qa, + cf.ya) + if b + R >= cf.Q or h[0] >= 0.: + assert x is None + continue + up = np.flatnonzero(h >= 0.) + if up.size == 0: + assert x is None or x >= xt - 1e-6 + continue + k = int(up[0]) + expected = bisect(lambda t: np.interp(a + t, cm.qa, cm.ya) + - np.interp(b + R + t, cf.qa, cf.ya) < 0., + ts[k - 1], ts[k]) + assert x == pytest.approx(expected, abs=1e-6) + n['e3'] += 1 + assert min(n.values()) >= 30 + + +def test_events_match_bisection_on_coincident_levels(): + # integer knots and levels: flats of one curve sit exactly at the + # levels of the other, frontiers sit on knots (exactness of E1b/E2b at + # a flat's left limit, E3 on touching knots) + rng = random.Random(44) + n = {'e1b': 0, 'e2b': 0, 'e3': 0} + for _ in range(1500): + cm = random_level_curve(rng, True, (1, 5), grid=True) + cf = random_level_curve(rng, True, (1, 5), grid=True) + a = min(float(rng.choice((0., rng.randint(0, int(cm.Q) - 1), + rng.uniform(0., 0.7 * cm.Q)))), cm.Q - .5) + b = min(float(rng.choice((0., rng.randint(0, int(cf.Q) - 1), + rng.uniform(0., 0.7 * cf.Q)))), cf.Q - .5) + rem, cap = cm.Q - a, cf.Q - b + x = P._flex_stop_for(cm, a, rem, cf, b, cap) + if cap >= rem and match_ok(cm, a, cf, b, rem): + def ok(x): + return match_ok(cm, a, cf, b + x, rem) + hi = cap - rem + expected = hi if ok(hi) else bisect(ok, 0., hi) + assert x == pytest.approx(expected, abs=1e-7) + n['e1b'] += 1 + x = P._must_stop_for(cm, a, rem, cf, b, cap, rem + 1.) + + def ok2(x): + return rem - x <= cap + 1e-12 and match_ok(cm, a + x, cf, b, + rem - x) + if ok2(rem): + expected = 0. if ok2(0.) else bisect(ok2, 0., rem, largest=False) + if expected >= rem - 1e-6: # only the empty match finishes it + assert x is None or x >= rem - 1e-6 + else: + assert x == pytest.approx(expected, abs=1e-7) + n['e2b'] += 1 + R = float(rng.choice((0., 1., rng.uniform(0., 0.6 * cap)))) + xt = min(rem, cap - R) + if xt <= 0. or b + R >= cf.Q: + continue + x = P._return_duty(cm, a, cf, b, R, xt) + ts = np.unique(np.concatenate(([0., xt], cm.qa - a, + cf.qa - b - R))) + ts = ts[(ts >= 0.) & (ts <= xt)] + h = (np.interp(a + ts, cm.qa, cm.ya) + - np.interp(b + R + ts, cf.qa, cf.ya)) + if h[0] >= 0.: + assert x is None + continue + up = np.flatnonzero(h >= 0.) # h is linear between these points + if up.size == 0: + assert x is None or x >= xt - 1e-9 + continue + k = int(up[0]) + expected = ts[k - 1] + (ts[k] - ts[k - 1]) * (-h[k - 1]) / ( + h[k] - h[k - 1]) + assert x == pytest.approx(expected, abs=1e-9) + n['e3'] += 1 + assert min(n.values()) >= 100 + + +def test_pruning_is_sound_on_curves_with_flats(monkeypatch): + # every pruning test (pinch rules at tight levels, dead must) must be a + # necessary condition: with both switched off the search may only get + # slower. Pruning removes nodes from the same depth-first order, so if + # the relaxed search finds an MER plan the pruned one must find it too; + # a root proof it contradicts would be a false split proof. + rng = random.Random(77) + problems = [random_curve_problem(rng) for _ in range(400)] + problems = [p for p in problems if any(p[1]) and not all(p[1])] + normal = [P.plan_network(*p) for p in problems] + + def no_dead_must(self, a, b, d, open_m, open_f): + s = self.side + lam = {i: s.musts[i].at(a[i]) for i in open_m} + mu = {j: s.flexes[j].at(b[j]) for j in open_f} + rem_m = {i: s.Qm[i] - a[i] for i in open_m} + rem_f = {j: s.Qf[j] - b[j] for j in open_f} + return self._moves(a, b, d, open_m, open_f, lam, mu, rem_m, rem_f) + monkeypatch.setattr(P._Side, 'rules_violation', lambda *args: None) + monkeypatch.setattr(P._Search, '_candidates', no_dead_must) + n_mer = 0 + for p, plan in zip(problems, normal): + assert plan.info['dropped'] == [] + relaxed = P.plan_network(*p) + assert relaxed.info['dropped'] == [] + if relaxed.status == 'mer': + assert plan.status == 'mer' + n_mer += 1 + assert n_mer >= 300 + + +def test_vectorised_events_match_per_pair_events(): + # constant CP sides use array versions of E1, E1b, E2 and E2b + rng = random.Random(5) + for _ in range(60): + side = P._Side('above', [random_level_curve(rng, False, (1, 1)) + for _ in range(rng.randint(2, 4))], + [random_level_curve(rng, False, (1, 1)) + for _ in range(rng.randint(2, 4))], 1e-9, 1e-9) + assert side.linear + a = [rng.uniform(0., 0.5 * q) for q in side.Qm] + b = [rng.uniform(0., 0.5 * q) for q in side.Qf] + lam = {i: c.at(a[i]) for i, c in enumerate(side.musts)} + mu = {j: c.at(b[j]) for j, c in enumerate(side.flexes)} + rem_m = {i: side.Qm[i] - a[i] for i in lam} + rem_f = {j: side.Qf[j] - b[j] for j in mu} + srch = P._Search(side, 'full', None, True, 1e9) + args = (a, b, lam, mu, rem_m, rem_f, list(lam), list(mu)) + for i in lam: + for j in mu: + xt = min(rem_m[i], rem_f[j]) + vec = srch._duties(i, j, xt, *args) + side.linear = False + ref = srch._duties(i, j, xt, *args) + side.linear = True + assert_allclose(vec, ref, rtol=1e-9, atol=1e-9) + + +# %% 5. Flat-aware pinch rules + +def flat_case_plan(case, dT): + names = list(case) + plan = P.plan_network([(case[n][1], case[n][2]) for n in names], + [case[n][0] for n in names], dT) + pairs = sorted((e.side, names[e.hot], names[e.cold], round(e.Q, 9)) + for e in plan.exchangers) + return plan, pairs + + +def test_flat_rules_boiler_and_condenser_at_the_pinch(): + # judge 1, case A: a cold stream boiling at the pinch serves two hot + # musts in series above it; case B: a hot stream condensing at the + # pinch serves two cold musts below it (name: (hot, T, H) knots) + A = dict(H1=(True, [150, 200], [0, 50]), H2=(True, [150, 200], [0, 50]), + H3=(True, [50, 150], [0, 100]), Cb=(False, [140, 140], [0, 100]), + C2=(False, [150, 250], [0, 100])) + plan, pairs = flat_case_plan(A, 10.) + assert plan.status == 'mer' and plan.cut == 'above' + assert pairs == [('above', 'H1', 'Cb', 50.), ('above', 'H2', 'Cb', 50.)] + assert plan.info['sides']['above']['proof'] is None + assert (plan.Q_hot, plan.Q_cold) == (100., 100.) + B = dict(Hc=(True, [150, 150], [0, 100]), C1=(False, [100, 140], [0, 40]), + C2=(False, [100, 140], [0, 60]), C3=(False, [140, 200], [0, 60])) + plan, pairs = flat_case_plan(B, 10.) + assert plan.status == 'mer' and plan.cut == 'below' + assert pairs == [('below', 'Hc', 'C1', 40.), ('below', 'Hc', 'C2', 60.)] + assert (plan.Q_hot, plan.Q_cold) == (60., 0.) + # d1 pw_demo: a flex boiling exactly at the pinch level + PW = dict(H1=(True, [105, 130], [0, 50]), H2=(True, [105, 120], [0, 30]), + Cb=(False, [100, 100, 110], [0, 60, 70]), + C2=(False, [100, 140], [0, 40])) + plan, pairs = flat_case_plan(PW, 5.) + assert plan.status == 'mer' and (plan.Q_hot, plan.Q_cold) == (30., 0.) + assert ('above', 'H1', 'Cb', 50.) in pairs + assert plan.info['min_approach'] >= 5. + + +def test_number_rule_without_flats_is_a_root_proof(): + # two hot streams at the pinch above it but only one cold stream + streams = streams_from(SPLIT['smith2005_exr18_4'][1]) + net = P._plan_numeric(streams, 10.) + proof = net['plan'].info['sides']['above']['proof'] + assert net['status'] == 'best_effort' + assert proof['rule'] == 'outward' and sorted(proof['musts']) == [4, 5] + assert proof['flexes'] == [1] + + +# %% 6. Root proofs = pinch design rules (constant CP) + +def test_root_proof_agrees_with_pinch_rules(): + rng = random.Random(6) + n_split = 0 + for k in range(200): + rows, dT = random_problem(rng) if k % 2 else pinch_problem(rng) + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, work_scale=0.2) + assert root_proof(net['plan']) == needs_split(streams, dT) + n_split += needs_split(streams, dT) + for dT, rows in list(SPLIT.values()) + list(REPEATED_PAIR.values()): + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, work_scale=0.2) + assert root_proof(net['plan']) == needs_split(streams, dT) + assert n_split >= 10 + + +# %% 7-9. Unique, repeated-pair and hard networks + +def test_r002_unique_network_with_repeated_pair(): + net = assert_mer(R002, 10.) + got = sorted((m['side'], m['hot'], m['cold'], round(m['Q'], 9), + m['pair_index']) for m in net['matches']) + assert got == [('below', 'H1', 'C1', 30., 1), + ('below', 'H1', 'C2', 20., 1), + ('below', 'H1', 'C3', 20., 2), + ('below', 'H1', 'C3', 160., 1)] + assert net['targets'] == dict(Qh=80., Qc=450.) + + +@pytest.mark.parametrize('name', sorted(REPEATED_PAIR)) +def test_repeated_pair_literature_cases(name): + dT, rows = REPEATED_PAIR[name] + net = assert_mer(rows, dT) + pairs = [(m['side'], m['hot'], m['cold']) for m in net['matches']] + assert len(set(pairs)) < len(pairs) + + +@pytest.mark.parametrize('name', sorted(HARD)) +def test_hard_cases_reach_mer(name): + dT, rows = HARD[name] + assert_mer(rows, dT) + + +# %% 10-12. Determinism, budgets and best effort + +def fingerprint(net): + return ([(m['side'], m['hot'], m['cold'], m['Q'], m['hot_seq'], + m['cold_seq']) for m in net['matches']], + sorted(net['hot_utility'].items()), + sorted(net['cold_utility'].items())) + + +def test_deterministic_and_budget_scale(): + cases = [(10., R002), (10., LINNHOFF4), HARD['rnd_nested_n2-6_s183'], + HARD['hold_threshold_n13-20_s100208'], + SPLIT['rnd_uniform_n2-6_s327']] + for dT, rows in cases: + streams = streams_from(rows) + one = P._plan_numeric(streams, dT) + two = P._plan_numeric(streams, dT) + assert fingerprint(one) == fingerprint(two) # bitwise identical + ten = P._plan_numeric(streams, dT, work_scale=10.) + assert ten['status'] == one['status'] + if one['status'] == 'mer': + check_network(streams, dT, ten) + + +def test_budget_exhaustion_gives_feasible_best_effort(monkeypatch): + monkeypatch.setattr(P, '_SCHEDULE', tuple(p[:3] + (1.,) + for p in P._SCHEDULE)) + status = {} + for name, (dT, rows) in [('r002', (10., R002)), + ('linnhoff4', (10., LINNHOFF4)), + ('s237', HARD['rnd_nested_n2-6_s237'])]: + streams = streams_from(rows) + net = P._plan_numeric(streams, dT) + Qh, Qc = check_network(streams, dT, net) + Qh_t, Qc_t, _ = cascade(streams, dT) + tol = 1e-9 * duty_scale(streams) + sides = net['plan'].info['sides'].values() + assert all(s['status'] in ('best_effort', 'trivial') for s in sides) + assert Qh >= Qh_t - tol and Qc >= Qc_t - tol + assert Qh - Qh_t == pytest.approx(Qc - Qc_t, abs=tol) + assert net['penalty'] == pytest.approx(Qh - Qh_t, abs=tol) + # the status comes from the achieved utilities, not from the sides + assert net['status'] == ('mer' if Qh - Qh_t <= tol else 'best_effort') + status[name] = net['status'] + # a greedy dive reaches the targets of r002 although the MER search ran + # out of budget: that network is MER and must be reported as such (the + # side flags alone would call it best effort) + assert status['r002'] == 'mer' and 'best_effort' in status.values() + + +@pytest.mark.parametrize('name', sorted(SPLIT)) +def test_best_effort_on_split_cases(name): + dT, rows = SPLIT[name] + streams = streams_from(rows) + net = P._plan_numeric(streams, dT) + plan = net['plan'] + Qh, Qc = check_network(streams, dT, net) + Qh_t, Qc_t, _ = cascade(streams, dT) + tol = 1e-9 * duty_scale(streams) + assert net['status'] == 'best_effort' and root_proof(plan) + assert Qh - Qh_t > tol # never beats MER + assert Qh - Qh_t == pytest.approx(Qc - Qc_t, abs=tol) + # every best-effort side stays within the units cap and is no worse + # than the best greedy dive within the cap + curves = P._stream_curves( + [([min(s['T_in'], s['T_out']), max(s['T_in'], s['T_out'])], + [0., s['CP'] * abs(s['T_in'] - s['T_out'])]) for s in streams], + [s['kind'] == 'hot' for s in streams], dT) + scale = sum(c.duty for c in curves) + span = max(c.T[-1] for c in curves) - min(c.T[0] for c in curves) + sides = P._sides(curves, P._cascade(curves, scale), + P._REL_Q * max(scale, 1.), P._REL_T * max(span, 1.)) + for sname, info in plan.info['sides'].items(): + if info['status'] != 'best_effort': + continue + side = sides[sname] + cap = math.ceil(P._BE_UNITS_CAP * (side.M + side.F)) + assert info['units'] <= cap + diver = P._Diver(side, False, frozenset()) + best = sum(side.Qm) + for use_rules, rest in ((True, False), (False, False), (True, True)): + for order in P._BE_ORDERS: + pieces, gaps = diver.dive(order, use_rules, rest) + if P._count_units(pieces) <= cap: + best = min(best, sum(gaps)) + assert sum(info['gaps'].values()) <= best + tol + + +# %% 13. Mini-fuzz with an independent checker + +def test_fuzz_small_problems(monkeypatch): + monkeypatch.setattr(P, '_BE_WORK', 3000.) # keep best effort short + rng = random.Random(13) + n_mer = 0 + for _ in range(200): + rows, dT = random_problem(rng) + streams = streams_from(rows) + net = P._plan_numeric(streams, dT) + Qh, Qc = check_network(streams, dT, net) + Qh_t, Qc_t, _ = cascade(streams, dT) + tol = 1e-9 * duty_scale(streams) + assert Qh >= Qh_t - tol and Qc >= Qc_t - tol + assert net['plan'].info['dropped'] == [] + if needs_split(streams, dT): + assert net['status'] == 'best_effort' + else: + assert net['status'] == 'mer' + assert Qh == pytest.approx(Qh_t, abs=tol) + n_mer += 1 + assert n_mer >= 100 + + +# %% 14. The planner's cascade + +def knot_cascade(knots, is_hot, dT): + """Problem table of piecewise-linear curves by direct evaluation: + (Qh, Qc) from the heat flow arriving at and leaving every level.""" + curves = [] + for (T, H), hot in zip(knots, is_hot): + curves.append(([t - (dT if hot else 0.) for t in T], list(H), hot)) + + def H_le(T, H, L, strict): + # sup of the enthalpies at which T*(H) <= L (< L if strict) + below = (lambda t: t < L) if strict else (lambda t: t <= L) + if not below(T[0]): + return H[0] + for k in range(len(T) - 1): + if not below(T[k + 1]): # T[k] below L, T[k + 1] not: cross + return H[k] + (H[k + 1] - H[k]) * (L - T[k]) / ( + T[k + 1] - T[k]) + return H[-1] + levels = sorted({t for T, _, _ in curves for t in T}, reverse=True) + flows = [] + for L in levels: + for strict in (False, True): # arriving at L, then leaving it + flows.append(sum((1. if hot else -1.) * (H[-1] - H_le(T, H, L, + strict)) + for T, H, hot in curves)) + Qh = max(0., -min(flows)) + return Qh, flows[-1] + Qh + + +def test_planner_cascade_equals_problem_table(): + rng = random.Random(14) + problems = [random_problem(rng, 2, 12) for _ in range(60)] + problems += [(rows, dT) for dT, rows in (list(HARD.values()) + + list(SPLIT.values()) + + list(REPEATED_PAIR.values()))] + for rows, dT in problems: + streams = streams_from(rows) + kn = [([min(s['T_in'], s['T_out']), max(s['T_in'], s['T_out'])], + [0., s['CP'] * abs(s['T_in'] - s['T_out'])]) for s in streams] + hot = [s['kind'] == 'hot' for s in streams] + plan = P.plan_network(kn, hot, dT, work_scale=0.05) + Qh, Qc, zeros = cascade(streams, dT) + scale = duty_scale(streams) + assert plan.Q_hot_target == pytest.approx(Qh, abs=1e-9 * scale) + assert plan.Q_cold_target == pytest.approx(Qc, abs=1e-9 * scale) + assert min(abs(plan.pinch_T - z) for z in zeros) <= 1e-9 + # piecewise-linear curves with flats and point loads + for _ in range(60): + kn, hot = [], [] + for _ in range(rng.randint(2, 6)): + T = [rng.choice(range(20, 300, 5))] + H = [0.] + for _ in range(rng.randint(1, 3)): + flat = rng.random() < 0.3 + T.append(T[-1] + (0. if flat else rng.choice(range(5, 60, 5)))) + H.append(H[-1] + rng.choice(range(5, 100, 5))) + kn.append((T, H)) + hot.append(rng.random() < 0.5) + if all(hot) or not any(hot): + continue + dT = float(rng.choice([5, 10])) + plan = P.plan_network(kn, hot, dT, work_scale=0.05) + Qh, Qc = knot_cascade(kn, hot, dT) + scale = sum(H[-1] for _, H in kn) + assert plan.Q_hot_target == pytest.approx(Qh, abs=1e-9 * scale) + assert plan.Q_cold_target == pytest.approx(Qc, abs=1e-9 * scale) + # and the plan itself is feasible on the curves + assert plan.info['dropped'] == [] + assert plan.Q_hot >= Qh - 1e-9 * scale + + +# %% avoid_recycle and Qmin + +def test_avoid_recycle_never_repeats_a_pair(): + rng = random.Random(25) + cases = [(R002, 10.), (LINNHOFF4, 10.)] + [random_problem(rng) + for _ in range(40)] + for rows, dT in cases: + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, avoid_recycle=True) + Qh, Qc = check_network(streams, dT, net) + pairs = [(m['hot'], m['cold']) for m in net['matches']] + assert len(pairs) == len(set(pairs)) # not even across the pinch + Qh_t = cascade(streams, dT)[0] + assert Qh >= Qh_t - 1e-9 * duty_scale(streams) + # r002 needs its repeated pair: best effort, still >= MER + net = P._plan_numeric(streams_from(R002), 10., avoid_recycle=True) + assert net['status'] == 'best_effort' and net['penalty'] > 0. + + +@pytest.mark.parametrize('Qmin', [25., 100., 1e9]) +def test_qmin_drops_small_exchangers(Qmin): + for rows in (R002, LINNHOFF4): + streams = streams_from(rows) + net = P._plan_numeric(streams, 10., Qmin=Qmin) + check_network(streams, 10., net) + assert all(m['Q'] >= Qmin for m in net['matches']) + dropped = net['plan'].info['qmin_dropped'] + assert all(q < Qmin for *_, q in dropped) + full = P._plan_numeric(streams, 10.) + kept = sum(m['Q'] for m in net['matches']) + assert kept + sum(q for *_, q in dropped) == pytest.approx( + sum(m['Q'] for m in full['matches'])) + if dropped: + assert net['status'] == 'best_effort' diff --git a/tests/test_hxn_regression.py b/tests/test_hxn_regression.py index 3bec24c..c392494 100644 --- a/tests/test_hxn_regression.py +++ b/tests/test_hxn_regression.py @@ -14,35 +14,64 @@ (i) close its energy balance (|error| < 1e-6 %) without RuntimeWarnings, (ii) never beat the minimum-energy-requirement (MER) targets of the problem - table computed on the same streams, and -(iii) recover at least as much heat as documented in ``CASES`` below, so that + table computed on the same streams, and report status 'mer' exactly + when it reaches them, +(iii) keep the minimum approach temperature INSIDE every process exchanger, + on the exact states of its streams (``T_min_app - 1e-6`` K, the + synthesizer's guarantee; the check of ``test_hxn_mer``, which + evaluates temperatures without the flashes of the code under test), +(iv) plan on the problem table's own cascade (the planner's targets, pinch + and pinch cut equal the table's), and +(v) recover at least as much heat as documented in ``CASES`` below, so that no future change to ``hensmith`` silently makes the synthesizer perform worse. The documented utility loads were recorded by running this file directly -(``python tests/test_hxn_regression.py`` prints them): cases 1-4 and 6-9 -at commit ``1ab689ff`` (branch ``hxn-pinch-diagram``); case 10 after the -synthesizer fixes on ``hxn-regression-tests`` (non-equilibrium inlets -clipped to the stream's enthalpy range; network path ordered by its -connections; H_lim honored at the bubble point); case 5 lowered after the -pinch state at an end temperature became the equilibrium state at that -end enthalpy (its 420 K, 5 bar vapor feed is below water's boiling point -there, a non-equilibrium inlet); case 6 lowered to its MER targets after -the hot-side offset pass stopped abandoning a hot stream once the first -cold stream it matched was fully heated (branch -``fix-hxn-hot-side-offset-break``). Improvements leave slack; a -maintainer lowers the numbers deliberately when a better network is -intended. Never raise them to make a failing test pass. +(``python tests/test_hxn_regression.py`` prints them) with the pinch-outward +MER planner (``hensmith._planner``) and the curve-based problem table +(``hensmith._curves``), which replaced the four-pass heuristic synthesizer +and the grid of stream end temperatures. Cases 1-3, 6, 7 and 10 reach their +MER targets; cases 4, 8 and 9 need stream splits for MER (the pinch design +rules fail at the pinch: case 4 above it, where the superheat of the +ethanol vapor and the hot water both reach the pinch against a single cold +stream; case 8 above and below it; case 9 above it), so their loads are +best-effort networks 0.12 %, 0.55 % and 0.06 % above the hot-utility +target. Relative to the previous baselines (recorded with the heuristic +synthesizer at commits ``1ab689ff`` and later on branches +``hxn-regression-tests`` and ``fix-hxn-hot-side-offset-break``): + +* cases 4 and 10 were RAISED (2.37319e6 -> 2.40548e6 and 1.40742e7 -> + 1.44713e7 kJ/hr heating): the old loads lay below the corrected MER + targets (2.40262e6 and 1.44713e7) and were reachable only because + exchangers of the old networks crossed internally (exact internal + approaches of 4.63 K and 2.07 K, below T_min_app = 5 K), which the + terminal checks of HXprocess do not see; both new networks keep 5 K + everywhere; +* cases 5, 8 and 9 were LOWERED (case 5 from 3.2224e6 to 0 kJ/hr heating: + the new network reaches its threshold target); +* cases 1-3, 6 and 7 are unchanged. + +A documented load of zero is met to within 1e-9 of the total stream duty +(the problem table's own threshold tolerance): the utilities come from +enthalpy flashes, which leave residuals of ~1e-12 of the duty. +Improvements leave slack; a maintainer lowers the numbers deliberately when +a better network is intended. Never raise them to make a failing test pass +(cases 4 and 10 were raised because the old networks were infeasible). """ import warnings import pytest import biosteam as bst from numpy.testing import assert_allclose from hensmith import HeatExchangerNetwork -from hensmith.hxn_synthesis import problem_table +from hensmith.hxn_synthesis import problem_table, _pinch_cut +# exact stream temperatures from forward property calls only (no flashes) +from test_hxn_mer import _temperature, _min_approach, APPROACH_TOL EB_TOLERANCE = 1e-6 # percent; converged networks close to ~1e-10 % -MER_RTOL = 1e-3 # network may not beat the MER target by more than this +MER_RTOL = 1e-9 # network may not beat the MER target by more than this x total duty DOC_RTOL = 1e-3 # network may not be worse than documented by more than this +ZERO_RTOL = 1e-9 # a documented zero load, x total stream duty (see above) +STATUS_RTOL = 1e-6 # 'mer' iff the loads equal the targets, x total duty +CASCADE_RTOL = 1e-9 # planner targets vs problem table, x total duty def utility_hx(ID, T, P, phase, T_out, rigorous=None, **flow): """A simulated HXutility acting as one process stream (kmol/hr flows).""" @@ -163,13 +192,13 @@ def case_10_ten_streams(): 'case_01_two_liquids': (case_01_two_liquids, 0, 1.53912e+06), 'case_02_pinch_limited': (case_02_pinch_limited, 1.81522e+06, 0), 'case_03_condenser_two_colds': (case_03_condenser_two_colds, 0, 9.49905e+06), - 'case_04_report_case': (case_04_report_case, 2.37319e+06, 3.56871e+06), - 'case_05_boiling_cold': (case_05_boiling_cold, 3.2224e+06, 7.81466e+06), + 'case_04_report_case': (case_04_report_case, 2.40548e+06, 3.601e+06), + 'case_05_boiling_cold': (case_05_boiling_cold, 0, 4.59225e+06), 'case_06_mixed_pressures': (case_06_mixed_pressures, 2.12463e+06, 0), 'case_07_threshold': (case_07_threshold, 1.85977e+07, 0), - 'case_08_two_condensers': (case_08_two_condensers, 3.02237e+06, 7.36431e+06), - 'case_09_near_degenerate': (case_09_near_degenerate, 1.40965e+07, 9.66427e+06), - 'case_10_ten_streams': (case_10_ten_streams, 1.40742e+07, 8.06488e+06), + 'case_08_two_condensers': (case_08_two_condensers, 3.01517e+06, 7.35711e+06), + 'case_09_near_degenerate': (case_09_near_degenerate, 1.40002e+07, 9.56801e+06), + 'case_10_ten_streams': (case_10_ten_streams, 1.44713e+07, 8.46199e+06), } # --------------------------------------------------------------------------- @@ -182,20 +211,27 @@ def synthesize(builder): sys = bst.System.from_units('sys', units=[*units, HXN]) with warnings.catch_warnings(): warnings.simplefilter('error', RuntimeWarning) - # thermosteam registry bookkeeping on temporary stream copies; not numerical - warnings.filterwarnings('ignore', message='.*has been replaced in registry', - category=RuntimeWarning) + # biosteam's HeatUtility.load_agent names a new 'oxygen_rich_inlet' + # stream for every fuel (furnace) utility; the network itself replaces + # nothing in the registry (test_synthesis_registers_no_intermediate_streams) + warnings.filterwarnings('ignore', category=RuntimeWarning, + message='.* has been replaced in registry') sys.simulate() return units, HXN, T_min_app -def mer_targets(units, T_min_app): +def HXN_table(units, T_min_app): + """The problem table of the process streams, prepared as the facility + prepares them.""" hus = [hx.heat_utilities[0] for hx in units] hus.sort(key=lambda hu: hu.duty) streams_inlet = [hu.unit.ins[0].copy() for hu in hus] streams_quenched = [hu.unit.outs[0].copy() for hu in hus] for s in streams_quenched: s.vle(H=s.H, P=s.P) is_hot = [hu.duty < 0 for hu in hus] - table = problem_table(streams_inlet, streams_quenched, is_hot, T_min_app) + return problem_table(streams_inlet, streams_quenched, is_hot, T_min_app) + +def mer_targets(units, T_min_app): + table = HXN_table(units, T_min_app) return table.hot_util_load, table.cold_util_load def actual_loads(HXN): @@ -204,29 +240,61 @@ def actual_loads(HXN): cool = -sum(hu.unit_duty for hu in hus if hu.unit_duty < 0) return heat, cool +def internal_approach(hx): + """Exact minimum approach [K] inside a process exchanger, on the states of + its own streams (inf if it transfers no heat).""" + 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 + c = 1 - h + if dH[h] <= 0.: return float('inf') + return _min_approach(_temperature(hx.ins[h]), hx.ins[h].H, hx.outs[h].H, + _temperature(hx.ins[c]), hx.ins[c].H, hx.outs[c].H) + @pytest.mark.parametrize('name', list(CASES)) def test_hxn_regression(name): builder, doc_heat, doc_cool = CASES[name] units, HXN, T_min_app = synthesize(builder) + total = sum(abs(hx.heat_utilities[0].unit_duty) for hx in units) # (i) energy balance assert abs(HXN.energy_balance_percent_error) < EB_TOLERANCE, name - # (ii) MER targets are a lower bound; energy identity holds + # (ii) MER targets are a lower bound, reached iff the status says so; + # energy identity holds heat, cool = actual_loads(HXN) hot_target, cold_target = mer_targets(units, T_min_app) net_duty = sum(hx.heat_utilities[0].unit_duty for hx in units) - assert heat >= hot_target * (1 - MER_RTOL), (name, heat, hot_target) - assert cool >= cold_target * (1 - MER_RTOL), (name, cool, cold_target) + assert heat >= hot_target - MER_RTOL * total, (name, heat, hot_target) + assert cool >= cold_target - MER_RTOL * total, (name, cool, cold_target) assert_allclose(heat - cool, net_duty, rtol=1e-8, err_msg=name) - # (iii) never worse than documented + info = HXN.synthesis_info + at_mer = (abs(heat - hot_target) <= STATUS_RTOL * total + and abs(cool - cold_target) <= STATUS_RTOL * total) + assert info['status'] == ('mer' if at_mer else 'best_effort'), (name, info['status']) + # (iii) exact internal approach inside every process exchanger + for hx in HXN.new_HXs: + approach = internal_approach(hx) + assert approach >= T_min_app - APPROACH_TOL, (name, hx.ID, approach) + assert info['min_approach'] >= T_min_app - APPROACH_TOL, (name, info['min_approach']) + # (iv) the planner's cascade is the problem table's + table = HXN_table(units, T_min_app) + planned = info['plan_targets'] + assert abs(planned['Q_hot'] - table.hot_util_load) <= CASCADE_RTOL * total, name + assert abs(planned['Q_cold'] - table.cold_util_load) <= CASCADE_RTOL * total, name + assert abs(planned['pinch_T'] - table.pinch_T) <= 1e-9, name + assert planned['cut'] == _pinch_cut(table), name + # (v) never worse than documented assert doc_heat is not None and doc_cool is not None, f'{name}: baseline not recorded' - assert heat <= doc_heat * (1 + DOC_RTOL) + 1e-9, (name, heat, doc_heat) - assert cool <= doc_cool * (1 + DOC_RTOL) + 1e-9, (name, cool, doc_cool) + atol = ZERO_RTOL * total + assert heat <= doc_heat * (1 + DOC_RTOL) + atol, (name, heat, doc_heat) + assert cool <= doc_cool * (1 + DOC_RTOL) + atol, (name, cool, doc_cool) if __name__ == '__main__': for name, (builder, *_) in CASES.items(): units, HXN, T_min_app = synthesize(builder) heat, cool = actual_loads(HXN) hot_target, cold_target = mer_targets(units, T_min_app) + approach = min(map(internal_approach, HXN.new_HXs), default=float('inf')) print(f"{name}: heat={heat:.6g} cool={cool:.6g} " f"(MER hot={hot_target:.6g} cold={cold_target:.6g}; " - f"EB error={HXN.energy_balance_percent_error:.4f}%)") + f"status={HXN.synthesis_info['status']}; " + f"min internal approach={approach:.6f} K; " + f"EB error={HXN.energy_balance_percent_error:.2e}%)") diff --git a/tests/test_hxn_targets.py b/tests/test_hxn_targets.py new file mode 100644 index 0000000..f9985b5 --- /dev/null +++ b/tests/test_hxn_targets.py @@ -0,0 +1,787 @@ +# -*- 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 the minimum-energy-requirement (MER) targets: the per-stream +temperature-enthalpy curves (`hensmith._curves.StreamCurve`), the problem +table built on them, the pinch cut and split, `pinch_state`, and the exact +internal-approach check of the synthesis, `_exchanger_approach`. + +Reference targets come from an INDEPENDENT dense-grid calculator that does +not use hensmith: every stream's enthalpy is sampled every 0.25 K with TP +flashes outside two-phase regions, a pure component's latent heat is a jump +at T_sat between V=0 and V=1 enthalpies, binary glides are traced along the +bubble-point curve of the liquid (thermosteam BubblePoint only, 201 + 101 +states), and the cascade of the resulting piecewise-linear profiles is +evaluated with left and right limits at every node (scratchpad +``realthermo/dense_targets.py`` of the MER work, ``stream_profile(h=0.25, +nV=201)`` + ``cascade``; biosteam 7ff69657, thermosteam f768d38). The numbers +below were recomputed with it on exactly the builders in this module. The +calculator is accurate to ~0.2 kJ/hr on these cases; the curves are within +their documented linearization bound (0.002 K times the heat-capacity flow +rates at the pinch) of it. +""" +import functools +import numpy as np +import pytest +import biosteam as bst +import thermosteam as tmo +from numpy.testing import assert_allclose +from hensmith.hxn_synthesis import ( + problem_table, _problem_table, _pinch_cut, pinch_state, + _exchanger_approach, _knot_T, _curve_tol_T, +) +from hensmith._curves import ( + StreamCurve, GLIDE_TOL_T, FLAT, SENSIBLE, GLIDE, +) + +ATM = 101325. + +# --------------------------------------------------------------------------- +# Stream builders (simulated HXutility units, as in test_hxn_regression.py) +# --------------------------------------------------------------------------- + +def setup(name, chemicals=('Water', 'Ethanol')): + bst.settings.set_thermo(list(chemicals), cache=True) + bst.main_flowsheet.set_flowsheet('test_hxn_targets_' + name) + +def utility_hx(ID, T, P, phase, T_out, rigorous=None, **flow): + """A simulated HXutility acting as one process stream (kmol/hr flows).""" + s = bst.Stream(ID + '_in', T=T, P=P, phase=phase, units='kmol/hr', **flow) + if rigorous is None: rigorous = phase == 'g' + hx = bst.HXutility(ID, ins=s, T=T_out, rigorous=rigorous) + hx.simulate() + return hx + +def boiling_hx(ID, T, P, T_out, **flow): + """A cold liquid heated past its bubble point (rigorous VLE).""" + return utility_hx(ID, T, P, 'l', T_out, rigorous=True, **flow) + +def vapor_fraction_hx(ID, T, P, V, **flow): + """A liquid at T heated to vapor fraction V (V=0: bubble, V=1: dew point).""" + s = bst.Stream(ID + '_in', T=T, P=P, phase='l', units='kmol/hr', **flow) + hx = bst.HXutility(ID, ins=s, V=V, rigorous=True) + hx.simulate() + return hx + +def xE(total, x): + """Water/ethanol flows with ethanol mole fraction x.""" + return dict(Water=total * (1. - x), Ethanol=total * x) + +def case_two_stream(): + """Cold water boiled to saturated vapor at 1 atm against hot liquid + water: the pinch is the cold stream's T_sat (its latent heat at the + pinch, cut 'above'). The old table had no T_sat on its grid and gave 0 + hot utility.""" + setup('two_stream') + return [utility_hx('H1', 400., 5e5, 'l', 330., Water=1000.), + vapor_fraction_hx('C1', 300., ATM, 1, Water=100.)], 5. + +def case_curvature(): + """Liquids only: water cooled against ethanol, whose Cp rises ~40 % + over the range; the composite curves touch INSIDE the stream range + (terminal approaches 5 K, internal < 1 K), so the pinch is found only + with breakpoints that follow the curvature. The old table gave 0.""" + setup('curvature') + return [utility_hx('H1', 400., 10e5, 'l', 300., Water=1000.), + utility_hx('C1', 295., 10e5, 'l', 395., Ethanol=569.1)], 5. + +def case_condenser_at_pinch(): + """Ethanol desuperheated, condensed and subcooled (set B): the pinch is + the hot stream's shifted T_sat (its latent heat at the pinch, cut + 'below'). The old table gave 0 hot utility.""" + setup('condenser_at_pinch') + return [utility_hx('H1', 385., ATM, 'g', 320., Ethanol=400.), + utility_hx('H2', 350., 5e5, 'l', 310., Water=300.), + utility_hx('C1', 300., ATM, 'l', 358., Water=1500.), + utility_hx('C2', 330., ATM, 'l', 345., Ethanol=200.)], 5. + +def case_Tsat_coincidence(): + """Boiling water C1 whose T_sat is also C2's outlet (heated to its + bubble point at the same pressure), so the saturation temperature is a + shared grid point; the pinch is there (set J).""" + setup('Tsat_coincidence') + return [boiling_hx('C1', 340., ATM, 390., Water=200.), + vapor_fraction_hx('C2', 300., ATM, 0, Water=300.), + utility_hx('H1', 423., 5e5, 'l', 330., Water=1500.), + utility_hx('H2', 385., 5e5, 'l', 340., Water=300.)], 5. + +def case_binary_glide(): + """Water/ethanol (ethanol mole fraction 0.1, 1 atm; a composition whose + flashes are reliable) boiled from subcooled to superheated through its + glide (bubble 359.53 K, dew 370.44 K), against ethanol condensing at + 2 bar (T_sat 369.86 K) and hot water: the pinch is the condenser's + shifted T_sat, INSIDE the cold stream's glide.""" + setup('binary_glide') + return [boiling_hx('C1', 330., ATM, 380., **xE(200., 0.1)), + utility_hx('H1', 385., 2e5, 'g', 355., Ethanol=150.), + utility_hx('H2', 400., 5e5, 'l', 340., Water=300.)], 5. + +K = 273.15 +LINNHOFF = [('C1', 20., 135., 2.), ('H2', 170., 60., 3.), + ('C3', 80., 140., 4.), ('H4', 150., 30., 1.5)] # degC, degC, kW/K + +def case_linnhoff(): + """Constant-Cp four-stream problem of Linnhoff & Hindmarsh (1983), + T_min_app 10 K: 20 kW hot and 60 kW cold utility, pinch 90/80 degC. The + `Fluid` pseudo-component has Cn = 3.6 kJ/kmol/K, so 1000*CP kmol/hr is a + heat-capacity flow rate of CP kW/K.""" + Fluid = tmo.Chemical('Fluid', search_db=False, phase='l', MW=1., Cn=3.6, + default=True) + bst.settings.set_thermo([Fluid], cache=True) + bst.main_flowsheet.set_flowsheet('test_hxn_targets_linnhoff') + units = [] + for name, T_in, T_out, CP in LINNHOFF: + s = bst.Stream(name + '_in', Fluid=1000. * CP, T=T_in + K, P=101325., + units='kmol/hr') + hx = bst.HXutility(name, ins=s, T=T_out + K, rigorous=False) + hx.simulate() + units.append(hx) + return units, 10. + +# name -> builder +CASES = { + 'two_stream': case_two_stream, + 'curvature': case_curvature, + 'condenser_at_pinch': case_condenser_at_pinch, + 'Tsat_coincidence': case_Tsat_coincidence, + 'binary_glide': case_binary_glide, + 'linnhoff': case_linnhoff, +} + +# name -> (hot utility, cold utility [kJ/hr], shifted pinch temperature [K]) +# from the independent dense calculator (see module docstring). The old +# problem table (end temperatures only, TP flashes at grid points) missed +# the pinch of the first four (hot utility 0) and gave 24 % of the target on +# the binary glide; it was exact only on the constant-Cp problem. +DENSE = { + 'two_stream': (2395101.84, 3088578.56, 373.124295848), + # identical at 0.05 K spacing; minimum between grid nodes of hensmith + 'curvature': (319444.38, 327619.16, 347.0), + 'condenser_at_pinch': (276488.61, 12523800.52, 346.570441659), + 'Tsat_coincidence': (2923386.93, 4169525.67, 373.124295848), + 'binary_glide': (5386912.87, 4109097.31, 364.8572114718511), + 'linnhoff': (72000., 216000., 353.15), # 20 / 60 kW, 80 degC +} + +def prepare(units): + """Stream copies exactly as the synthesizer prepares them (heat + utilities sorted by duty; outlets quenched at their own enthalpy).""" + hus = [hx.heat_utilities[0] for hx in units] + hus.sort(key=lambda hu: hu.duty) + names = [hu.unit.ID for hu in hus] + streams_inlet = [hu.unit.ins[0].copy() for hu in hus] + streams_quenched = [hu.unit.outs[0].copy() for hu in hus] + for s in streams_quenched: s.vle(H=s.H, P=s.P) + is_hot = [hu.duty < 0 for hu in hus] + return names, streams_inlet, streams_quenched, is_hot + +@functools.lru_cache(maxsize=None) +def analyzed(name): + """(names, streams_inlet, streams_quenched, is_hot, T_min_app, table, + curves, grid) of a case, built once per test session (read-only).""" + units, T_min_app = CASES[name]() + names, ins, outs, is_hot = prepare(units) + table, curves, grid = _problem_table(ins, outs, is_hot, T_min_app) + return names, ins, outs, is_hot, T_min_app, table, curves, grid + +def heat_capacity_flows(curves): + """Mean heat-capacity flow rate of each monotone stream [kJ/hr/K].""" + return [(c.H_hi - c.H_lo) / (c.T_hi - c.T_lo) for c in curves if c.monotone] + +def closed_form_table(streams_inlet, streams_quenched, is_hot, T_min_app): + """The problem table as computed before stream curves: grid of the + shifted end temperatures only, each monotone stream's enthalpy at every + grid temperature inside its range (phases fixed, clipped), isothermal + and non-monotone streams as point loads at their outlet. Exact for + constant-Cp streams, for which the new table must be bit-identical.""" + 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.) + T_in = np.array([s.T for s in streams_inlet]) + T_out = np.array([s.T for s in streams_quenched]) + H_in = np.array([s.H for s in streams_inlet]) + H_out = np.array([s.H for s in streams_quenched]) + monotone = (sign * (T_in - T_out)) > 0. + T_hi = np.where(monotone, np.maximum(T_in, T_out), T_out) - shift + T_lo = np.where(monotone, np.minimum(T_in, T_out), T_out) - shift + Ts = np.unique(np.concatenate([T_hi, T_lo]))[::-1] + n = Ts.size + interval_H = np.zeros((N, n - 1)) + point_H = np.zeros((N, n)) + for j in range(N): + if monotone[j]: + idx = np.flatnonzero((Ts <= T_hi[j]) & (Ts >= T_lo[j])) + H_lo, H_hi = sorted((H_in[j], H_out[j])) + Hs = np.empty(idx.size) + Hs[0], Hs[-1] = H_hi, H_lo + s = streams_inlet[j].copy() + for m in range(1, idx.size - 1): + s.T = Ts[idx[m]] + shift[j] + Hs[m] = min(max(s.H, H_lo), H_hi) + interval_H[j, idx[:-1]] = sign[j] * (Hs[:-1] - Hs[1:]) + else: + k = np.searchsorted(-Ts, -T_hi[j]) + point_H[j, k] = sign[j] * abs(H_out[j] - H_in[j]) + point_total = point_H.sum(axis=0) + residual = np.cumsum(point_total + np.concatenate([[0.], interval_H.sum(axis=0)])) + flow = np.minimum(residual, residual - point_total) + k_pinch = int(np.argmin(flow)) + if -flow[k_pinch] <= 1e-9 * np.abs(H_out - H_in).sum(): + hot, k_pinch = 0., 0 + else: + hot = -flow[k_pinch] + cold = residual[-1] + hot + if cold < 0.: hot, cold = hot - cold, 0. + return Ts, interval_H, point_H, residual, hot, cold, Ts[k_pinch] + +# --------------------------------------------------------------------------- +# Targets +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('name', list(CASES)) +def test_targets_match_dense_reference(name): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed(name) + Q_hot, Q_cold, pinch_T = DENSE[name] + if name == 'curvature': + # linearization bound: every stream within GLIDE_TOL_T of its curve + tol = GLIDE_TOL_T * sum(heat_capacity_flows(curves)) + assert abs(table.pinch_T - pinch_T) < 1. # the minimum is flat there + else: + tol = max(2e-5 * Q_hot, 2.) + assert_allclose(table.pinch_T, pinch_T, rtol=0, atol=1e-6) + assert abs(table.hot_util_load - Q_hot) <= tol, (table.hot_util_load, Q_hot) + assert abs(table.cold_util_load - Q_cold) <= tol, (table.cold_util_load, Q_cold) + # the public function returns the same table + public = problem_table(ins, outs, is_hot, dT, curves=curves) + assert public.hot_util_load == table.hot_util_load + assert np.array_equal(public.residual, table.residual) + +def test_curvature_pinch_needs_interior_breakpoints(): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('curvature') + # an end-temperature grid (4 points) would miss the pinch entirely + assert table.Ts.size > 4 + assert table.hot_util_load > 0.9 * DENSE['curvature'][0] + assert {c.method for c in curves} == {'pure, liquid'} + +def test_constant_cp_identity(): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('linnhoff') + reference = closed_form_table(ins, outs, is_hot, dT) + for field, value in zip(table._fields, reference): + assert np.array_equal(getattr(table, field), value), field + assert_allclose([table.hot_util_load / 3600., table.cold_util_load / 3600.], + [20., 60.], rtol=1e-12) + assert table.pinch_T == 80. + K + assert all(c.method == 'no VLE' and c.kinds == (SENSIBLE,) for c in curves) + +@pytest.mark.parametrize('name', list(CASES)) +def test_table_invariants(name): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed(name) + sign = np.where(is_hot, 1., -1.) + duty = np.array([c.H_hi - c.H_lo for c in curves]) + scale = duty.sum() + # (a) every stream's contributions telescope to its duty + per_stream = table.interval_H.sum(axis=1) + table.point_H.sum(axis=1) + assert_allclose(per_stream, sign * duty, rtol=1e-12, atol=0) + # (b) hot - cold utility is the net heating demand + assert table.hot_util_load >= 0. and table.cold_util_load >= 0. + assert abs((table.hot_util_load - table.cold_util_load) + + (sign * duty).sum()) <= 1e-12 * scale + # (c) contributions have the stream's sign + assert (sign[:, None] * table.interval_H >= 0.).all() + assert (sign[:, None] * table.point_H >= 0.).all() + # (d) the grid enthalpies reproduce the table exactly + Ts, Hl, Hr, shift = grid['Ts'], grid['Hl'], grid['Hr'], grid['shift'] + assert Ts is table.Ts and Hl.shape == Hr.shape == (len(curves), Ts.size) + assert np.array_equal(table.point_H, sign[:, None] * (Hr - Hl)) + assert np.array_equal(table.interval_H, sign[:, None] * (Hl[:, :-1] - Hr[:, 1:])) + assert (np.diff(Ts) < 0.).all() + for j, c in enumerate(curves): + k_hi, k_lo = grid['k_hi'][j], grid['k_lo'][j] + assert_allclose([Ts[k_hi] + shift[j], Ts[k_lo] + shift[j]], + [c.T_hi, c.T_lo], rtol=0, atol=1e-8) + assert (Hr[j, :k_hi + 1] == c.H_hi).all() + assert (Hl[j, :k_hi] == c.H_hi).all() + assert (Hl[j, k_lo:] == c.H_lo).all() + assert (Hr[j, k_lo + 1:] == c.H_lo).all() + assert (np.diff(Hl[j]) <= 0.).all() and (Hl[j] <= Hr[j]).all() + +@pytest.mark.parametrize('name', list(CASES)) +def test_pinch_split_reproduces_targets(name): + """Splitting every stream at the pinch on the side `_pinch_cut` names + puts exactly the hot utility target above the pinch and the cold one + below it, whether the split comes from the grid, the curve or + `pinch_state`.""" + names, ins, outs, is_hot, dT, table, curves, grid = analyzed(name) + cut = _pinch_cut(table) + side = 'right' if cut == 'below' else 'left' + k = int(np.flatnonzero(table.Ts == table.pinch_T)[0]) + above = below = 0. + for j, (si, so, hot, c) in enumerate(zip(ins, outs, is_hot, curves)): + H_split = (grid['Hr'] if side == 'right' else grid['Hl'])[j, k] + T_p = table.pinch_T + (dT if hot else 0.) + assert c.H_at(T_p, side) == H_split + H_state = pinch_state(si, so, T_p, side=side, curve=c).H + assert abs(H_state - H_split) <= 1e-9 * (c.H_hi - c.H_lo) + 1e-6 + sgn = -1. if hot else 1. + above += sgn * (c.H_hi - H_split) + below -= sgn * (H_split - c.H_lo) + scale = sum(c.H_hi - c.H_lo for c in curves) + assert abs(above - table.hot_util_load) <= 1e-12 * scale + assert abs(below - table.cold_util_load) <= 1e-12 * scale + +def test_pinch_cut_sides(): + # cold latent heat at the pinch: boiling needs heat from above + for name in ('two_stream', 'Tsat_coincidence'): + table = analyzed(name)[5] + assert _pinch_cut(table) == 'above', name + # hot latent heat at the pinch: condensing heat goes below + table = analyzed('condenser_at_pinch')[5] + assert _pinch_cut(table) == 'below' + # the boiling stream's flat sits exactly at the pinch + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('two_stream') + j = names.index('C1') + k = int(np.flatnonzero(table.Ts == table.pinch_T)[0]) + satL = ins[j].copy(); satL.vle(V=0, P=ATM) + assert_allclose(grid['Hr'][j, k] - grid['Hl'][j, k], outs[j].H - satL.H, rtol=1e-9) + # a threshold problem has everything below its pinch at Ts[0] + setup('threshold') + units = [utility_hx('H1', 400., 5e5, 'l', 300., Water=1000.), + utility_hx('C1', 300., 5e5, 'l', 390., Water=900.)] + table = problem_table(*prepare(units)[1:], 5.) + assert table.hot_util_load == 0. and table.pinch_T == table.Ts[0] + assert _pinch_cut(table) == 'below' + +def test_problem_table_accepts_prebuilt_curves(): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('condenser_at_pinch') + fresh = problem_table(ins, outs, is_hot, dT) + shared = problem_table(ins, outs, is_hot, dT, curves=curves) + for a, b, c in zip(table, fresh, shared): + assert np.array_equal(a, b) and np.array_equal(a, c) + with pytest.raises(ValueError): + problem_table(ins, outs, is_hot, dT, curves=curves[:-1]) + +# --------------------------------------------------------------------------- +# StreamCurve +# --------------------------------------------------------------------------- + +def _interior_enthalpies(c, n_max=12): + """A few enthalpies strictly inside the curve: segment midpoints (evenly + chosen) and uniform points.""" + mids = 0.5 * (c.H[:-1] + c.H[1:]) + mids = mids[(c.H[1:] > c.H[:-1])] + if mids.size > n_max: mids = mids[np.linspace(0, mids.size - 1, n_max).astype(int)] + Hs = np.concatenate([mids, np.linspace(c.H_lo, c.H_hi, 7)[1:-1]]) + return [H for H in Hs if c.H_lo + c.tol_H < H < c.H_hi - c.tol_H] + +def _segment(c, H): + return min(int(np.searchsorted(c.H, H, 'right')) - 1, len(c.kinds) - 1) + +@pytest.mark.parametrize('name', list(CASES)) +def test_stream_curve_invariants_and_round_trips(name): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed(name) + for c in curves: + T, H = c.T, c.H + assert H[0] == c.H_lo and H[-1] == c.H_hi + assert len(c.kinds) == T.size - 1 == H.size - 1 + assert (np.diff(T) >= 0.).all() and (np.diff(H) >= 0.).all() + assert c.T_lo <= T.min() and T.max() <= c.T_hi + for j, kind in enumerate(c.kinds): + assert kind in (FLAT, SENSIBLE, GLIDE) + if kind == FLAT: assert T[j] == T[j + 1] + duty = c.H_hi - c.H_lo + for H_ in _interior_enthalpies(c): + s = c.state_at_H(H_) + assert abs(s.H - H_) <= 1e-9 * duty, (c, H_) + kind = c.kinds[_segment(c, H_)] + T_lin, T_ex = c.T_at(H_), c.T_exact(H_) + if kind == GLIDE: + assert abs(T_ex - s.T) <= 1e-9 + assert abs(T_lin - T_ex) <= 2. * GLIDE_TOL_T + else: + assert T_ex == T_lin + assert abs(s.T - T_lin) <= 1e-9 + if kind == SENSIBLE: + assert abs(c.H_at(T_lin) - H_) <= 1e-9 * duty + +def test_jumps_mark_non_equilibrium_end_states(): + setup('jumps') + # regression case 05's H1: water vapor fed at 420 K and 5 bar, below its + # 425 K saturation temperature; its equilibrium states at the feed + # enthalpy lie outside the stream's own range [330, 420] K + H1 = utility_hx('H1', 420., 5e5, 'g', 330., Water=250.) + s_in = H1.ins[0].copy() + s_out = H1.outs[0].copy(); s_out.vle(H=s_out.H, P=s_out.P) + c = StreamCurve(s_in, s_out, True) + liquid = s_in.copy(); liquid.phase = 'l' # equilibrium at the feed T + assert c.method == 'pure, liquid' + assert c.kinds[-1] == FLAT and c.T[-1] == c.T[-2] == 420. + assert len(c.jumps) == 1 + H_a, H_b = c.jumps[0] + assert H_b == c.H_hi == s_in.H + assert_allclose(H_a, liquid.H, rtol=1e-12) + H_mid = 0.5 * (H_a + H_b) + flashed = s_in.copy(); flashed.vle(H=H_mid, P=5e5) + assert flashed.T > 424. + # inside the jump the curve's state stays at the feed temperature + s = c.state_at_H(H_mid) + assert_allclose([s.T, s.H], [420., H_mid], rtol=1e-12) + assert c.T_exact(H_mid) == c.T_at(H_mid) == 420. + # a latent-heat flat at an end is not a jump (C1 boils to its dew point) + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('two_stream') + boiler = curves[names.index('C1')] + assert boiler.kinds[-1] == FLAT and boiler.jumps == () + assert all(c.jumps == () for c in curves) + # a non-monotone stream (a reboiler outlet colder than its inlet) is a + # point load: its single flat is its whole duty, not a jump + setup('jumps_point_load') + a = bst.Stream('nm_in', Water=100., T=372., P=ATM, phase='l', units='kmol/hr') + b = bst.Stream('nm_out', Water=100., T=371., P=ATM, phase='g', units='kmol/hr') + point = StreamCurve(a, b, False) + assert not point.monotone and point.kinds == (FLAT,) and point.jumps == () + # a superheated-liquid feed heated to vapor: the jump is at its inlet + # (the low end), from the liquid's enthalpy up to the vapor's at 380 K + s_in = bst.Stream('shl_in', Water=100., T=380., P=ATM, phase='l', units='kmol/hr') + s_out = bst.Stream('shl_out', Water=100., T=400., P=ATM, phase='g', units='kmol/hr') + c = StreamCurve(s_in, s_out, False) + vapor = s_in.copy(); vapor.phase = 'g' + assert c.kinds[0] == FLAT and c.T[0] == c.T[1] == 380. + assert len(c.jumps) == 1 and c.jumps[0][0] == s_in.H + assert_allclose(c.jumps[0][1], vapor.H, rtol=1e-12) + +def test_T_exact_on_flats_and_vertical_stretches(): + setup('T_exact') + # superheated liquid ethanol (370 K, 1 atm; bubble point 351.4 K) cooled + # to 310 K: its curve ends with a vertical (clipped) stretch at H_in + s_in = bst.Stream('sh_in', Ethanol=700., T=370., P=ATM, phase='l', units='kmol/hr') + s_out = bst.Stream('sh_out', Ethanol=700., T=310., P=ATM, phase='l', units='kmol/hr') + c = StreamCurve(s_in, s_out, True) + T_sat = c.boundaries['T_sat'] + assert c.T[-1] == 370. and c.T[-2] == T_sat and c.H[-1] == c.H[-2] == s_in.H + assert c.T_exact(c.H_hi, 'low') == T_sat + assert c.T_exact(c.H_hi, 'high') == 370. + j = c.kinds.index(FLAT) + H_flat = 0.5 * (c.H[j] + c.H[j + 1]) + assert c.T_exact(H_flat) == c.T_at(H_flat) == T_sat + assert c.jumps == () # the flat is interior latent heat + +def clipped_end_streams(kind): + """(inlet, outlet, is_hot, end) of a stream fed off equilibrium whose + curve reaches its feed enthalpy strictly inside a piece and is clipped + beyond it ('hi': H_hi, 'lo': H_lo).""" + if kind == 'glide top': # water/ethanol liquid fed above its bubble point + s = bst.Stream('ge_in', Water=900., Ethanol=100., T=368., P=ATM, + phase='l', units='kmol/hr') + o = s.copy('ge_out'); o.T = 320. + return s, o, True, 'hi' + if kind == 'glide bottom': # the same mixture fed as vapor below its dew point + s = bst.Stream('gb_in', Water=900., Ethanol=100., T=362., P=ATM, + phase='g', units='kmol/hr') + o = s.copy('gb_out'); o.T = 385. + return s, o, False, 'lo' + # pure water fed two-phase at 400 K, above T_sat: the equilibrium vapor + # reaches its enthalpy at 388.4 K + s = bst.MultiStream('sv_in', T=400., P=ATM, l=[('Water', 1.)], + g=[('Water', 99.)], units='kmol/hr') + o = bst.Stream('sv_out', Water=100., T=320., P=ATM, phase='l', units='kmol/hr') + return s, o, True, 'hi' + +@pytest.mark.parametrize('kind', ['glide top', 'glide bottom', 'sensible top']) +def test_clipped_curve_ends_at_the_exact_crossing(kind): + # the breakpoint where the curve reaches the feed enthalpy is the exact + # equilibrium state there (a PH flash, independent of the curve's + # samplers); it used to keep the temperature of the first clipped sample + # (0.108 K, 0.036 K and 0.0024 K off), which the exact-state check + # trusts at breakpoints + setup('clipped_' + kind.replace(' ', '_')) + s_in, s_out, is_hot, end = clipped_end_streams(kind) + c = StreamCurve(s_in, s_out, is_hot) + H_end, side = (c.H_hi, 'low') if end == 'hi' else (c.H_lo, 'high') + assert H_end == s_in.H + duty = c.H_hi - c.H_lo + for d in (0., 1e-6, 1e-3, 1e-2, 3e-2): + H = H_end - d * duty if end == 'hi' else H_end + d * duty + flash = s_in.copy(); flash.vle(H=H, P=s_in.P) + tol = 1e-6 if d == 0. else GLIDE_TOL_T + assert abs(c.T_at(H, side) - flash.T) <= tol, (d, c.T_at(H, side), flash.T) + assert abs(c.T_exact(H, side) - flash.T) <= 1e-6, (d, c.T_exact(H, side), flash.T) + +def test_T_exact_in_binary_glide(): + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('binary_glide') + c = curves[names.index('C1')] + assert c.method == 'mixture, binary bubble-curve glide' + glide = [j for j, kind in enumerate(c.kinds) if kind == GLIDE] + assert len(glide) > 10 + T_b, T_d = c.boundaries['T_bubble'], c.boundaries['T_dew'] + previous = -np.inf + for j in glide[::max(1, len(glide) // 8)]: + H_ = 0.5 * (c.H[j] + c.H[j + 1]) + T_ex = c.T_exact(H_) + assert c.T[j] <= T_ex <= c.T[j + 1] and T_b <= T_ex <= T_d + assert abs(T_ex - c.T_at(H_)) <= 2. * GLIDE_TOL_T + assert T_ex > previous + previous = T_ex + # the state is on the bubble-point curve: its liquid boils at T + s = c.state_at_H(H_) + assert abs(s.T - T_ex) <= 1e-9 + liquid = bst.Stream(None, T=s.T, P=ATM, phase='l', units='kmol/hr', + thermo=s.thermo) + liquid.imol['Water', 'Ethanol'] = s.imol['l', ('Water', 'Ethanol')] + assert_allclose(liquid.bubble_point_at_P(ATM).T, T_ex, rtol=0, atol=1e-6) + +def reboiler_curve(name): + """Curve of the reboiler stream of the `HeatExchangerNetwork` doctest + system (water with 1.4 % methanol and 0.3 % glycerol boiled at 1 atm): + a ternary glide sampled with TP flashes.""" + setup(name, ('Water', 'Methanol', 'Glycerol')) + hx = boiling_hx('C1', 306.37, ATM, 372.6, Water=8487., Methanol=118.85, + Glycerol=25.01) + s_in = hx.ins[0].copy() + s_out = hx.outs[0].copy(); s_out.vle(H=s_out.H, P=s_out.P) + return StreamCurve(s_in, s_out, False) + +def test_tp_glide_states_next_to_the_glide_ends(): + """A ternary glide is sampled with TP flashes, which cannot resolve the + bubble point to the last digits: a flash a hair inside the glide can + come back single phase. `state_at_H` and `T_exact` must still return + the state with the requested enthalpy there (an exchanger stage starting + just past a bubble point), not fail on that flash.""" + c = reboiler_curve('tp_glide_ends') + assert c.method == 'mixture, TP-flash glide' + glide = [j for j, kind in enumerate(c.kinds) if kind == GLIDE] + first, last = glide[0], glide[-1] + assert c.T[first] == c.boundaries['T_bubble'] + assert c.T[last + 1] == c.T_hi + duty = c.H_hi - c.H_lo + for j, f in ((first, 1e-9), (first, 1e-6), (last, 1. - 1e-6), (last, 1. - 1e-9)): + H = c.H[j] + f * (c.H[j + 1] - c.H[j]) + s = c.state_at_H(H) + assert abs(s.H - H) <= 1e-9 * duty + assert c.T[j] - 1e-6 <= s.T <= c.T[j + 1] + 1e-6 + assert c.T[j] <= c.T_exact(H) <= c.T[j + 1] + +def test_tp_glide_states_where_tp_flashes_are_spurious(): + """Between 372.0 and 372.5 K, thermosteam's TP flash of the reboiler + stream returns near-total vaporization at isolated temperatures (the + other flashes, the PH flash and the curve agree on ~20-45 % vapor). The + glide sampler drops those flashes, but a root solve inside a segment + cannot avoid them: its result must be rejected, not returned as a + state whose enthalpy is off by up to the whole duty (or a crash). The + curve is monotone through exact samples, so the true state with an + enthalpy between two samples lies between their temperatures.""" + c = reboiler_curve('tp_glide_spurious') + duty = c.H_hi - c.H_lo + glide = [j for j, kind in enumerate(c.kinds) + if kind == GLIDE and c.T[j + 1] > 372. and c.T[j] < 372.5] + assert len(glide) >= 4 + for j in glide[1::2]: + H = c.H[j] + 0.37 * (c.H[j + 1] - c.H[j]) + s = c.state_at_H(H) + assert abs(s.H - H) <= 1e-9 * duty + assert c.T[j] <= s.T <= c.T[j + 1] + assert abs(c.T_exact(H) - s.T) <= 1e-6 + assert s.imol['g'].sum() < 0.5 * s.F_mol + +def test_glide_state_falls_back_to_the_curve(monkeypatch): + """Where neither the root solve nor a PH flash gives a state between + the two glide samples that bracket an enthalpy, the glide state is the + curve's own: the lever-rule mix of the two sampled states at the + linearized temperature.""" + c = reboiler_curve('glide_fallback') + duty = c.H_hi - c.H_lo + + class NoFlash: + ID = 'no_flash' + def copy(self, ID=None, thermo=None): return self + def vle(self, **kwargs): raise RuntimeError('flash failed') + + monkeypatch.setattr(StreamCurve, '_glide_root', lambda self, H, state=False: None) + monkeypatch.setattr(c, 'stream_in', NoFlash()) + glide = [j for j, kind in enumerate(c.kinds) if kind == GLIDE] + for j in (glide[1], glide[len(glide) // 2], glide[-2]): + H = c.H[j] + 0.37 * (c.H[j + 1] - c.H[j]) + s = c._glide_state(H) + assert abs(s.H - H) <= 1e-9 * duty + assert abs(s.T - c.T_at(H)) <= 1e-6 + +# --------------------------------------------------------------------------- +# pinch_state +# --------------------------------------------------------------------------- + +def test_pinch_state_deterministic_at_T_sat(): + """A pinch exactly at a pure stream's saturation temperature: the state + is the saturated liquid ('left') or vapor ('right') by construction, not + whatever phase split a flash at T_sat happens to keep.""" + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('Tsat_coincidence') + j = names.index('C1') + si, so, c = ins[j], outs[j], curves[j] + T_sat = c.boundaries['T_sat'] + assert table.pinch_T == T_sat and c.T_lo < T_sat < c.T_hi + satL = si.copy(); satL.vle(V=0, P=si.P) + satV = si.copy(); satV.vle(V=1, P=si.P) + for side, sat, phase in (('left', satL, 'l'), ('right', satV, 'g')): + states = [pinch_state(si, so, T_sat, side=side), + pinch_state(si, so, T_sat, side=side, curve=c), + pinch_state(si, so, T_sat, side=side, curve=c)] + # one curve: bit-identical; a curve rebuilt later can differ in the + # last digits only (thermosteam's cached bubble/dew point solvers + # warm-start from their previous call) + assert states[1].H == states[2].H + assert_allclose(states[0].H, states[1].H, rtol=1e-11) + assert_allclose(states[0].H, sat.H, rtol=1e-10) + assert_allclose(states[0].T, T_sat, rtol=0, atol=1e-6) + assert_allclose(states[0].imol[phase, 'Water'], 200., rtol=1e-12) + # without a side, a cold stream's interior pinch takes the left branch + assert (pinch_state(si, so, T_sat, curve=c).H + == pinch_state(si, so, T_sat, side='left', curve=c).H) + # at an end temperature without a side: that end's state (old contract) + assert_allclose(pinch_state(si, so, si.T).H, si.H, rtol=1e-12) + assert_allclose(pinch_state(si, so, so.T).H, so.H, rtol=1e-12) + +# --------------------------------------------------------------------------- +# Exact internal approach (the synthesizer's check, `_exchanger_approach`) +# --------------------------------------------------------------------------- + +def exact_min_approach(ch, cc, Q): + """ + Minimum approach [K] inside a counter-current exchanger of duty `Q` that + takes curve `ch` down from its top and `cc` up to its top, its duty + position q (0 at the hot inlet / cold outlet end), and the minimum + approach dT_lin of the linear knot curves at the curves' breakpoints + (which lies at a knot), from the synthesizer's exact-state check + `_exchanger_approach` on those knots. The knot curves are within + ``tol = _curve_tol_T(ch) + _curve_tol_T(cc)`` of the exact approach, so + the exact minimum is at most ``dT_lin + tol``, and an interval that the + check skips at ``T_min_app = dT_lin + tol`` (+ 1e-5) has an exact + approach above that: the smallest approach it returns is the exact + minimum (as with ``T_min_app = inf``, which checks every interval). + """ + curves = [ch, cc] + knots = [(c.T, c.H - c.H_lo) for c in curves] + H_hot_in, H_cold_out = ch.H_hi - ch.H_lo, cc.H_hi - cc.H_lo + qs = np.concatenate(([0., Q], H_hot_in - knots[0][1], + H_cold_out - knots[1][1])) + qs = np.unique(qs[(qs >= 0.) & (qs <= Q)]) + dT_lin = float(np.min(_knot_T(knots[0], H_hot_in - qs, True) + - _knot_T(knots[1], H_cold_out - qs, False))) + T_min_app = dT_lin + _curve_tol_T(ch) + _curve_tol_T(cc) + 1e-5 + dT, states = _exchanger_approach(curves, knots, 0, 1, H_hot_in, + H_cold_out - Q, Q, T_min_app) + T_hot, H_hot, T_cold, H_cold = min(states, key=lambda s: s[0] - s[2]) + assert T_hot - T_cold == dT + return dT, H_hot_in - H_hot, dT_lin + +def test_exact_approach_finds_internal_pinch_of_condenser(): + """Ethanol vapor desuperheated and condensed against water: 10 K and + ~18 K at the terminals, but only ~1.8 K where condensation starts.""" + setup('exact_approach') + hot = utility_hx('H', 360., ATM, 'g', 340., Ethanol=100.) + cold = utility_hx('C', 320., ATM, 'l', 350., Water=2000.) + h_in, h_out = hot.ins[0].copy(), hot.outs[0].copy() + c_in, c_out = cold.ins[0].copy(), cold.outs[0].copy() + ch = StreamCurve(h_in, h_out, True) + cc = StreamCurve(c_in, c_out, False) + Q = ch.H_hi - ch.H_lo + assert Q < cc.H_hi - cc.H_lo + dT_min, q, dT_lin = exact_min_approach(ch, cc, Q) + # independent: condensation starts at the saturated-vapor enthalpy + satV = h_in.copy(); satV.vle(V=1, P=ATM) + q_sat = h_in.H - satV.H + water = c_out.copy(); water.H = c_out.H - q_sat # liquid, phase fixed + assert_allclose(q, q_sat, rtol=1e-9) + assert_allclose(dT_min, satV.T - water.T, rtol=0, atol=1e-6) + assert 1. < dT_min < 3. + # the terminals alone look comfortable + water.H = c_out.H - Q + assert h_in.T - c_out.T == 10. and h_out.T - water.T > 15. + # the knot curves are within their tolerances of the exact states + assert abs(dT_lin - dT_min) <= _curve_tol_T(ch) + _curve_tol_T(cc) + +def test_exact_approach_curved_heat_capacity(): + """Water against ethanol liquid over their full ranges: both terminal + approaches are ~5 K but the curves come within ~1 K inside; checked + against a dense scan of independent phase-fixed temperature solves, + none of which is closer (the dip between breakpoints is searched).""" + names, ins, outs, is_hot, dT, table, curves, grid = analyzed('curvature') + jh, jc = names.index('H1'), names.index('C1') + ch, cc = curves[jh], curves[jc] + Q = min(ch.H_hi - ch.H_lo, cc.H_hi - cc.H_lo) + dT_min, q, dT_lin = exact_min_approach(ch, cc, Q) + hs, cs = ins[jh].copy(), outs[jc].copy() + dTs = [] + for x in np.linspace(0., Q, 401): + hs.H = ch.H_hi - x + cs.H = cc.H_hi - x + dTs.append(hs.T - cs.T) + assert min(dTs[0], dTs[-1]) > 4.99 + assert dT_min < 1.5 + assert min(dTs) - 2e-3 < dT_min <= min(dTs) + 1e-9 + assert 0. < q < Q + +def test_exact_approach_inside_glides(): + """Two water/ethanol glides (ethanol mole fraction 0.1, whose flashes are + reliable) at 2.5 bar and 1 atm: at the minimum, each stream sits at a + breakpoint of one curve and inside a glide segment of the other, where + the linearized temperature is off by up to the sampling tolerance. The + exact minimum equals independent PH flashes at its position.""" + setup('exact_approach_glides') + P = 2.5e5 + hot = utility_hx('H1', 400., P, 'g', 340., rigorous=True, **xE(180., 0.1)) + cold = boiling_hx('C1', 330., ATM, 380., **xE(200., 0.1)) + h_in, h_out = hot.ins[0].copy(), hot.outs[0].copy() + c_in, c_out = cold.ins[0].copy(), cold.outs[0].copy() + for s in (h_out, c_out): s.vle(H=s.H, P=s.P) + ch = StreamCurve(h_in, h_out, True) + cc = StreamCurve(c_in, c_out, False) + assert GLIDE in ch.kinds and GLIDE in cc.kinds + Q = min(ch.H_hi - ch.H_lo, cc.H_hi - cc.H_lo) + dT, q, dT_lin = exact_min_approach(ch, cc, Q) + h, c = h_in.copy(), c_in.copy() + + def flashed(x): + h.vle(H=ch.H_hi - x, P=P) + c.vle(H=cc.H_hi - x, P=ATM) + return h.T - c.T + + assert abs(dT - flashed(q)) < 1e-6 + assert abs(dT_lin - dT) > 1e-5 # the exact evaluation mattered + assert abs(dT_lin - dT) <= _curve_tol_T(ch) + _curve_tol_T(cc) + # no position nearby is closer (dense independent scan around q) + xs = np.linspace(max(0., q - 0.01 * Q), min(Q, q + 0.01 * Q), 41) + dense = [flashed(x) for x in xs] + assert min(dense) >= dT - 1e-6 + +# --------------------------------------------------------------------------- +# Legacy entry points on the new table +# --------------------------------------------------------------------------- + +def test_pinch_analysis_builds_curves_once(): + from hensmith.hxn_synthesis import ( + _pinch_analysis, temperature_interval_pinch_analysis, load_duties, + ) + units = case_condenser_at_pinch()[0] + hus = [hx.heat_utilities[0] for hx in units] + hus.sort(key=lambda hu: hu.duty) + full = _pinch_analysis(hus, 5.) + legacy = temperature_interval_pinch_analysis(hus, 5.) + assert len(full) == 15 and len(legacy) == 12 + table, curves, grid = full[12:] + assert_allclose([legacy[1], legacy[2]], [table.hot_util_load, table.cold_util_load], + rtol=1e-12) + assert len(curves) == len(hus) and grid['Hl'].shape == (len(hus), table.Ts.size) + pinch_T_arr, T_out_arr, indices = full[0], full[4], full[8] + streams_inlet, streams_quenched = full[9], full[11] + cold = set(full[7]) + Q_hot_side, Q_cold_side = {}, {} + load_duties(streams_inlet, streams_quenched, pinch_T_arr, T_out_arr, + indices, lambda i: i in cold, Q_hot_side, Q_cold_side) + for i in indices: + total = Q_hot_side[i][1] + Q_cold_side[i][1] + assert_allclose(total, abs(streams_quenched[i].H - streams_inlet[i].H), + rtol=0, atol=0.02)