diff --git a/README.md b/README.md index 0db04f6..fcff57f 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,12 @@ 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. +everywhere inside every exchanger; by default, where MER provably needs a +split, the network is a best-effort one close to the targets. With +`HeatExchangerNetwork(..., stream_splitting=True)`, streams are instead +split into parallel branches there (BioSTEAM splitters and rigorous mixers), +and the network reaches the MER targets (not guaranteed with `avoid_recycle`; +see the documentation for the limits with real thermodynamics). ```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 f5eaeff..0674c02 100644 --- a/docs/_demo_src/README.md +++ b/docs/_demo_src/README.md @@ -25,7 +25,7 @@ directories themselves. | `examples/ch01_quickstart.py` | `_static/images/examples/tutorial_01_quickstart_flowsheet_light.png`, `…_flowsheet_dark.png`, `_static/images/examples/tutorial_01_quickstart_pinch_diagram.png`; `_generated/ch01_results.txt`, `ch01_loads.txt`, `ch01_life_cycles.txt`, `ch01_summary.txt` | | `examples/ch02_pinch_analysis.py` | `_static/images/examples/tutorial_02_composite_curves.png`, `tutorial_02_grand_composite.png`; `_generated/ch02_threshold.txt`, `ch02_table.txt`, `ch02_compare.txt` | | `examples/ch03_network_anatomy.py` | `_static/images/examples/tutorial_03_hxn_flowsheet_light.png`, `…_hxn_flowsheet_dark.png`, `tutorial_03_pinch_diagram_minimal.png`; `_generated/ch03_flowsheet.txt`, `ch03_life_cycles.txt`, `ch03_stage.txt`, `ch03_pinch_Ts.txt`, `ch03_accounting.txt` | -| `examples/ch04_configuring.py` | `_static/images/examples/tutorial_04_T_min_app_sweep.png`, `tutorial_04_ten_streams_pinch_diagram.png`; `_generated/ch04_sweep.txt`, `ch04_ignored.txt`, `ch04_ten_streams.txt` | +| `examples/ch04_configuring.py` | `_static/images/examples/tutorial_04_T_min_app_sweep.png`, `tutorial_04_ten_streams_pinch_diagram.png`; `_generated/ch04_sweep.txt`, `ch04_splitting.txt`, `ch04_ignored.txt`, `ch04_ten_streams.txt` | | `make_hero_gif.py` | `_static/images/demo/hero_light.gif`, `hero_dark.gif` (8 s loop, 20 fps, 2000 × 720), `hero_light_still.png`, `hero_dark_still.png` (the frame-0 stills served under `prefers-reduced-motion`) | | `build_demo.py` | `_static/quickstart_demo.html` — the interactive quickstart demo, filled in from `quickstart_demo_template.html` | | `make_poster.py` | `_static/images/examples/quickstart_demo_poster.png` — the README poster that links to the demo (2400 × 1260) | diff --git a/docs/_demo_src/examples/ch04_configuring.py b/docs/_demo_src/examples/ch04_configuring.py index d73dd56..dd2a18b 100644 --- a/docs/_demo_src/examples/ch04_configuring.py +++ b/docs/_demo_src/examples/ch04_configuring.py @@ -8,7 +8,8 @@ # for license details. """Tutorial chapter 04 (docs/source/tutorial/04_configuring.rst): configuring a HeatExchangerNetwork -- sweeping the minimum approach temperature to expose the -utility/capital trade-off, scoping the analysis with ``ignored=``, and +utility/capital trade-off, splitting streams where the targets need it +(``stream_splitting=True``), scoping the analysis with ``ignored=``, and synthesizing a larger ten-stream system (the ten-stream case of the regression suite, inlined here rather than imported from ``tests``). Regions between ``# [start:x]`` / ``# [end:x]`` are literalinclude'd by the page; everything @@ -109,6 +110,24 @@ def main(): print(f'cooling utility: {HXN.original_cool_util_load:.4g} -> {HXN.actual_cool_util_load:.4g} kJ/hr') HXN.ignored = None # [end:ignored] + with capturing('ch04_splitting'): + # [start:splitting] + HXN.T_min_app = 15. # the sweep's first best-effort network + for stream_splitting in (False, True): + HXN.stream_splitting = stream_splitting + sys.simulate() + info = HXN.synthesis_info + print(f'stream_splitting={stream_splitting}: {info["status"]}, ' + f'{len(HXN.new_HXs)} process exchangers, ' + f'{HXN.installed_costs["Heat exchangers"]:.4g} USD added installed cost') + print('splitters:', [u.ID for u in HXN.new_splitters]) + print('mixers: ', [u.ID for u in HXN.new_mixers]) + print('process exchangers:', [hx.ID for hx in HXN.new_HXs]) + print(info['splits']) + print(HXN.stream_life_cycles[3]) + HXN.stream_splitting = False + HXN.T_min_app = 5. + # [end:splitting] with capturing('ch04_ten_streams'): # [start:ten_streams] bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) diff --git a/docs/source/API/heat_exchanger_network.rst b/docs/source/API/heat_exchanger_network.rst index 25f7235..8804657 100644 --- a/docs/source/API/heat_exchanger_network.rst +++ b/docs/source/API/heat_exchanger_network.rst @@ -7,7 +7,8 @@ HeatExchangerNetwork 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 -- at the minimum energy requirement -(MER) targets whenever it finds such a network without stream splits -- and +(MER) targets whenever it finds such a network without stream splits, or, +with ``stream_splitting=True``, with stream splits where MER needs them -- 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 @@ -45,7 +46,7 @@ shows what each of them changes. - Run the analysis on stream copies with ideal thermodynamics; the synthesized exchangers inherit that thermo. Defaults to False. * - ``cache_network`` - bool - - Reuse the network configuration of the previous simulation when the set of units contributing heat utilities 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. + - Reuse the network configuration of the previous simulation when the set of units contributing heat utilities and ``stream_splitting`` are unchanged, updating only stream states and exchanger specifications: each process exchanger keeps the fraction of its stream's duty at which its enthalpy limit sat at synthesis (the splitters keep their branch fractions), and the utility exchangers bring every stream to its new outlet. The reused network is not planned again, so it need not be at MER for the new duties. Defaults to False. * - ``avoid_recycle`` - bool - Never match the same hot/cold stream pair twice anywhere (on one side of the pinch or across the two), so that no two exchangers connect the same pair and form a recycle loop; this forbids the repeated matches some unsplit MER networks need. Defaults to False. @@ -58,6 +59,9 @@ shows what each of them changes. * - ``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 stream indices, which break ties in the planner's search. Defaults to False. + * - ``stream_splitting`` + - bool + - Allow a process stream to be split into parallel branches that re-join. A side of the pinch that no unsplit network serves at MER is planned with splits and reaches the targets exactly on the planner's knots; sides that an unsplit network serves are never split, so a problem that needs no split gets the same network as with the default. Each split is a chain of ``Splitter`` units and a rigorous ``Mixer`` (adiabatic, no cost), listed in ``new_splitters`` and ``new_mixers``. With ``avoid_recycle``, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Stored as an attribute of the same name, and part of the ``cache_network`` key. Defaults to False. Class attributes ---------------- @@ -119,10 +123,10 @@ the cached network. - Percent deviation from one of the ratio (twice the duty of each process exchanger, plus the new utility duties weighted by their agents' heat-transfer efficiency) / (the original utility duties weighted the same way), as computed in ``_cost``. * - ``synthesis_info`` - dict - - The synthesis report (see the ``info`` keyword of :func:`synthesize_network`): ``'status'`` is ``'mer'`` when the network's utilities equal the MER targets and ``'best_effort'`` otherwise; next to it the targets, the planned and realized utilities, the penalty, per side of the pinch any proof that a split is needed, and the smallest approach inside any process exchanger. Kept from the synthesis that produced a cached network. + - The synthesis report (see the ``info`` keyword of :func:`synthesize_network`): ``'status'`` is ``'mer'`` when the network's utilities equal the MER targets and ``'best_effort'`` otherwise; next to it the targets, the planned and realized utilities, the penalty, per side of the pinch any proof that a split is needed, and the smallest approach inside any process exchanger. With ``stream_splitting``, also ``'stream_splitting'``, ``'splits'`` (the realized splits, :class:`~hensmith.hxn_synthesis.StreamSplit`), ``'split_deviations'`` and, per side, the split candidate chosen (``'split'``). Kept from the synthesis that produced a cached network. * - ``stream_life_cycles`` - list[StreamLifeCycle] - - Ordered sequence of exchangers each stream passes through, aligned with ``original_heat_exchangers``. + - Ordered sequence of exchangers each stream passes through, aligned with ``original_heat_exchangers``; a split stream's life cycle also lists its splits and marks each branch stage with its branch and flow fraction. * - ``new_HXs`` - list[HXprocess] - All synthesized process exchangers, the hot-side ones followed by the cold-side ones. @@ -135,6 +139,12 @@ the cached network. * - ``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. + * - ``new_splitters`` + - list[Splitter] + - The splitters of the network's stream splits, every split's chain in order (IDs ``Split__``, with ``_`` for a stream's *n*-th split on that side and ``_b`` for the chain's element *c* >= 2); empty without a split, and always without ``stream_splitting``. + * - ``new_mixers`` + - list[Mixer] + - One rigorous, adiabatic mixer per stream split, where its branches re-join (IDs ``Mix__``, with ``_`` as for the splitters); empty without a split. * - ``original_heat_exchangers`` - list[Unit] - The original heat exchangers behind the analyzed heat utilities, in stream order. @@ -143,7 +153,7 @@ the cached network. - The original heat utilities rearranged into stream order, so that they align with ``stream_life_cycles``. * - ``HXN_sys`` - System - - The system built from the synthesized exchangers, named ``_HXN`` and registered in ``HXN_flowsheet``; converged and summarized during costing. + - The system built from the synthesized exchangers (and the splitters and mixers of any stream split), named ``_HXN`` and registered in ``HXN_flowsheet``; converged and summarized during costing. * - ``HXN_flowsheet`` - Flowsheet - The flowsheet ``_HXN`` holding the network's stream copies and exchangers. @@ -161,7 +171,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 that each stream index passes through: its process exchangers in flow order, then its utility exchanger. + - Exchangers that each stream index passes through: its process exchangers in flow order, then its utility exchanger. Where the stream splits, the order is topological: the exchangers before the split, those of its branches (branch by branch, each in flow order), then those after it. * - ``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 bd62cc1..73b52b0 100644 --- a/docs/source/API/hxn_synthesis.rst +++ b/docs/source/API/hxn_synthesis.rst @@ -9,7 +9,10 @@ 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` +one, or, with ``stream_splitting=True``, a network with stream splits where a +side of the pinch needs them -- and realizes it as BioSTEAM exchangers (and +the splits as :class:`~hensmith.hxn_synthesis.StreamSplit` splitter chains and +mixers), :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 @@ -28,6 +31,9 @@ the method. .. autoclass:: hensmith.hxn_synthesis.LifeStage :no-members: +.. autoclass:: hensmith.hxn_synthesis.StreamSplit + :no-members: + .. autofunction:: plot_pinch_diagram .. note:: @@ -39,6 +45,11 @@ the method. 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. + private modules ``hensmith._curves`` (the stream temperature-enthalpy curves), + ``hensmith._planner`` (the MER planner) and ``hensmith._splitting`` (stream + splitting: the split candidates and their theory, in its module docstring). + Their signatures and behavior may change without notice. + :class:`~hensmith.hxn_synthesis.StreamSplit` and + :class:`~hensmith.hxn_synthesis.LifeStage` are not exported by ``hensmith`` + either; they are documented because synthesis results and life cycles hold + them. diff --git a/docs/source/_generated/ch04_splitting.txt b/docs/source/_generated/ch04_splitting.txt new file mode 100644 index 0000000..f28746b --- /dev/null +++ b/docs/source/_generated/ch04_splitting.txt @@ -0,0 +1,14 @@ +stream_splitting=False: best_effort, 8 process exchangers, 6.684e+05 USD added installed cost +stream_splitting=True: mer, 5 process exchangers, 5.893e+05 USD added installed cost +splitters: ['Split_3_cs'] +mixers: ['Mix_3_cs'] +process exchangers: ['HX_0_2_hs', 'HX_3_0_cs', 'HX_3_1_cs', 'HX_2_1_cs', 'HX_3_0_cs_2'] +[] +, branch (0, 0), fraction 0.5624, H_in = 1.15e+07 kJ/hr, H_out = 1.39e+06 kJ/hr> + , branch (0, 1), fraction 0.4376, H_in = 8.93e+06 kJ/hr, H_out = 2.23e+06 kJ/hr> + , branch (0, 1), fraction 0.4376, H_in = 2.23e+06 kJ/hr, H_out = 1.08e+06 kJ/hr> + , H_in = 2.47e+06 kJ/hr, H_out = 2.47e+06 kJ/hr> + ] + split 0: 2 branches (0.5624, 0.4376)> diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst index dfd4d79..0d24b7a 100644 --- a/docs/source/concepts.rst +++ b/docs/source/concepts.rst @@ -223,7 +223,9 @@ From targets to a network: the pinch-outward MER planner :func:`~hensmith.synthesize_network` takes the heat utilities of the process, 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 +reaches the MER targets whenever its search finds one (by default; with +``stream_splitting=True`` it splits streams where MER needs them, see `Stream +splitting`_ below), 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 @@ -282,7 +284,8 @@ 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 +and some unsplit MER networks need it (a real split is optional, see `Stream +splitting`_). 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, @@ -293,9 +296,10 @@ counted in the order the hot stream meets them, gets the suffix ``_`` for 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 +**Best effort when splitting is needed.** By default, a side whose pinch rules +prove that MER needs a split, or whose search runs out of budget, gets a +best-effort plan instead (with ``stream_splitting=True`` it is planned with +splits, see `Stream splitting`_). 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 @@ -354,9 +358,73 @@ planned and the realized utilities, the ``'penalty'``, per side of the pinch 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`. +deviated from their plan (``'deviations'``), and the point-load streams. With +``stream_splitting=True`` it also holds the realized splits (``'splits'``), any +mixer whose outlet is off its planned state (``'split_deviations'``) and, per +side, the split candidate chosen (``'split'``). The full list is under the +``info`` keyword of :func:`~hensmith.synthesize_network`. + +Stream splitting +---------------- + +By default hensmith does not split streams, so a side of the pinch whose +pinch design rules prove that MER needs a split gets a best-effort network. +``HeatExchangerNetwork(..., stream_splitting=True)`` (or +``synthesize_network(..., stream_splitting=True, info={})``) lets the planner +split a process stream into parallel branches that re-join. Only a side that +no unsplit network serves at MER -- one with a pinch-rule proof, or whose +unsplit search leaves a utility penalty -- is planned with splits; a side that +an unsplit network serves is planned exactly as without the option, so a +problem that needs no split gets the same network either way. + +**Branches.** A branch of flow fraction :math:`f` carries the parent stream's +material at the parent's pressure, so at branch heat :math:`q` it is in the +parent's state at heat :math:`q/f`: its temperature-enthalpy curve is the +parent's with every heat times :math:`f`, and its heat capacity flow rate is +:math:`f` times the parent's. Splitting a stream into branches whose fractions +sum to one leaves the problem table unchanged, so a split never changes the +targets; it changes only what the pinch design rules count (one stream per +branch, each with :math:`f\,C`) and the approach inside each exchanger. The +planner builds a split side from a portfolio of candidates -- splits at the +pinch that repair the number and heat-capacity-flow rules, completed by the +ordinary search, and a *vertical* construction that matches every stream's +branches at the same position of the composite curves -- verifies every +exchanger at every knot of both branch curves, and keeps the best candidate +by a key that first avoids tiny exchangers or branches and re-joins at +different temperatures ahead of another exchanger, then counts exchangers, +extra branches and split stages. The theory (the lemmas, the constructions +and their proofs) is in the module docstring of ``hensmith._splitting``. + +**Realization.** Each split becomes a chain of BioSTEAM ``Splitter`` units, +one fewer than its branches (IDs ``Split__``, with ``_`` for +a stream's *n*-th split on that side and ``_b`` for the chain's element +:math:`c \ge 2`), and a rigorous, adiabatic ``Mixer`` (``Mix__``) +where the branches re-join; neither is costed. A branch exchanger runs its +fraction of the flow in the parent's states, with an enthalpy limit +:math:`f` times the parent's, and every mixer starts at its planned state. The +facility lists them in ``new_splitters`` and ``new_mixers`` and simulates them +in ``HXN_sys`` with the exchangers; ``synthesis_info['splits']`` describes +every split (:class:`~hensmith.hxn_synthesis.StreamSplit`), and a split +stream's life cycle marks each branch stage with its branch and fraction. + +**The guarantee and its limits.** Without ``avoid_recycle``, every side that +needs a split gets a split plan at MER on the planner's knots: the vertical +construction always succeeds (up to a deficit of about 1e-9 of the total duty +that the problem table's own tolerances already absorbed into the targets). +With constant heat capacities the knots are exact and the realized network +reaches the targets to round-off. With real thermodynamics the knots are +chords of the exact curves, so a split exchanger can fall short of +``T_min_app`` on the exact states by up to the chords' tolerance; the same +refinement rounds as for unsplit networks close that, plus up to two rounds +that try another candidate, and only an exchanger still short after them is +shrunk (``'repaired'``, status ``'best_effort'``). With ``avoid_recycle``, a +split that would repeat a stream pair is not used, and MER is then not +guaranteed. The number of exchangers is minimized only among the candidates a +side generates, not globally. The test suite reaches ``'mer'`` with splits on +all 38 split-needing problems of its corpus and on the three regression +systems that need splits, each checked on its actual stream graph: splitters, +mixers at equilibrium, balances and the exact approach inside every exchanger. +:doc:`tutorial/04_configuring` shows an example. Rigor and phase change ---------------------- @@ -426,10 +494,11 @@ 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.** 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, +**Convergence.** After synthesis each stream's stages are rewired in series +(the branches of a split in parallel, from its splitter chain to its mixer), +and the new units 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 @@ -532,8 +601,14 @@ 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 +that MER needs stream splitting, a proof re-derived in the test module; by +default the network must never beat the targets and must report +``'best_effort'``, and with ``stream_splitting=True`` it must reach them and +report ``'mer'``, checked on its actual stream graph (splitters, mixers at +equilibrium, every stream's closure). The 40 problems of the first set must +also plan exactly as without the option (18 of them are synthesized both +ways, and must give the same network). 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 @@ -549,7 +624,9 @@ 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. +make a failing test pass. Every case is synthesized again with +``stream_splitting=True`` and held to the same checks, and the three cases +that need stream splits for MER must then reach their targets. **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 @@ -579,11 +656,16 @@ What it is not: 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 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. +- **By default, streams are not split.** Every stream stays a single branch + through the 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. With + ``stream_splitting=True`` such a side is split and reaches MER, within the + limits given under `Stream splitting`_: exact on constant heat capacity + streams, closed by refinement on real thermodynamics, not guaranteed with + ``avoid_recycle``, and with the number of exchangers minimized only among + the candidates the planner generates. - **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 @@ -591,11 +673,12 @@ What it is not: - **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. + step (or the split attempt), 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']``. + plan, and is then reported in ``synthesis_info['deviations']`` (a split's + mixer, in ``synthesis_info['split_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; diff --git a/docs/source/conf.py b/docs/source/conf.py index a530229..314d994 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -38,9 +38,13 @@ ] autosummary_generate = False # API pages are hand-written +# Flag options such as 'undoc-members' are on whenever their key is present, +# whatever the value (False included), so undocumented members are left out +# by omitting the key. With it on, a class default such as +# StreamLifeCycle.splits = () was documented a second time next to its entry +# in the class's Attributes section, and -W failed on the duplicate. autodoc_default_options = { 'members': True, - 'undoc-members': False, 'show-inheritance': True, } autodoc_member_order = 'bysource' diff --git a/docs/source/contributing/contributing.rst b/docs/source/contributing/contributing.rst index 7943049..7fb1201 100644 --- a/docs/source/contributing/contributing.rst +++ b/docs/source/contributing/contributing.rst @@ -9,7 +9,7 @@ change has to respect. Where the code lives -------------------- -The library is two public modules under ``hensmith/``, and two private ones +The library is two public modules under ``hensmith/``, and three private ones behind them: ``hensmith/_heat_exchanger_network.py`` @@ -20,9 +20,9 @@ behind them: ``hensmith/hxn_synthesis.py`` 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``. + the planner's network as BioSTEAM exchangers, and its stream splits as + splitters and mixers, 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, @@ -30,10 +30,17 @@ behind them: 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. + The pinch-outward planner of networks at minimum energy requirement, + unsplit unless ``stream_splitting`` is on, on numbers only (numpy; no + BioSTEAM objects). Its module docstring documents the model, the lemmas + the search relies on and its guarantees. + +``hensmith/_splitting.py`` (private) + Stream splitting for the planner (``stream_splitting=True``): the split + candidates of a side that no unsplit network serves at MER, verified cell + by cell, on numbers only. Its module docstring holds the theory: the + lemmas, the constructions and the proof that a split side always has an + MER candidate. Nothing in it runs when the option is off. ``hensmith/__init__.py`` re-exports the ``__all__`` of the two public modules and holds the biosteam registration block described in `The import @@ -59,9 +66,11 @@ Tests live in ``tests/``: 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. + beat them by default and must reach them with ``stream_splitting=True``, + checked on the network's actual stream graph (splitters, mixers, every + stream's closure); 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 @@ -69,7 +78,9 @@ Tests live in ``tests/``: 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. + least as much heat as the utility loads documented in the file. Every + system is synthesized again with ``stream_splitting=True``, and the + three that need stream splits must then reach their targets. Development environment ----------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 935cb1f..f1f5bf0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,8 +39,9 @@ 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 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 +requirement (MER) whenever it finds one without stream splits -- or, with +``stream_splitting=True``, splits streams where MER needs it -- and reports +the utility savings and added capital cost as part of the system's techno-economic analysis. Watch it run in the `Quickstart`_ demo below. diff --git a/docs/source/tutorial/02_pinch_analysis.rst b/docs/source/tutorial/02_pinch_analysis.rst index 8216e03..8fd3970 100644 --- a/docs/source/tutorial/02_pinch_analysis.rst +++ b/docs/source/tutorial/02_pinch_analysis.rst @@ -276,15 +276,18 @@ 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. +does not make by default, the status is ``best_effort`` and the network lies +slightly above the targets instead; with ``stream_splitting=True`` hensmith +splits streams there and reaches the targets. :doc:`04_configuring` shows all +three 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``. +which by default must never beat their targets and must report +``best_effort``, and with ``stream_splitting=True`` must reach them and report +``mer``. ``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 diff --git a/docs/source/tutorial/03_network_anatomy.rst b/docs/source/tutorial/03_network_anatomy.rst index 2b55629..2ec1886 100644 --- a/docs/source/tutorial/03_network_anatomy.rst +++ b/docs/source/tutorial/03_network_anatomy.rst @@ -56,7 +56,8 @@ 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 +next (where a stream splits, its branches run in parallel from the splitter +chain to the mixer), 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 @@ -81,7 +82,13 @@ 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. +``__s_`` on the way out. This network has no stream split; +one synthesized with ``stream_splitting=True`` can also hold, for each split, +a chain of ``Splitter`` units named ``Split__`` (with ``_b`` +for the chain's element *c* >= 2, and ``_`` before it for a stream's +*n*-th split on that side) and a ``Mixer`` named ``Mix__`` where +the branches re-join, both in ``HXN_sys`` and listed in ``HXN.new_splitters`` +and ``HXN.new_mixers`` (:doc:`04_configuring` shows one). 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 @@ -156,7 +163,12 @@ 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). +last). A stream split with ``stream_splitting=True`` changes this only where +it splits: its life cycle also holds the stream's splits (``splits``) and the +port where the whole stream enters (``entry``, the first splitter if it splits +at its inlet), and the stages of its branches, which run in parallel, follow +the stages before the split branch by branch, each in flow order and marked +with its branch and flow fraction. 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``, diff --git a/docs/source/tutorial/04_configuring.rst b/docs/source/tutorial/04_configuring.rst index cb082f4..caa26d8 100644 --- a/docs/source/tutorial/04_configuring.rst +++ b/docs/source/tutorial/04_configuring.rst @@ -5,8 +5,9 @@ The three chapters before this one held everything fixed: a minimum approach temperature of 5 K, every unit of the system in scope, and one small five-stream flowsheet. This chapter varies all three. It sweeps ``T_min_app`` over the quickstart system to expose the trade-off between recovered heat and -added area, narrows the analysis with ``ignored=``, goes through the remaining -constructor options of :class:`~hensmith.HeatExchangerNetwork` one by one, and +added area, lets hensmith split streams where the targets need it +(``stream_splitting=True``), narrows the analysis with ``ignored=``, goes +through the remaining constructor options of :class:`~hensmith.HeatExchangerNetwork` one by one, and finishes by synthesizing a ten-stream network with ten process exchangers. Every number and figure below is output of the code shown on this page. The @@ -90,14 +91,16 @@ 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: +that the targets need a stream split. By default 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'``. +including the pinch-rule proof, under ``'sides'``. `Splitting streams`_ below +makes the split instead. .. literalinclude:: /../_demo_src/examples/ch04_configuring.py :language: python @@ -124,6 +127,46 @@ including the pinch-rule proof, under ``'sides'``. on utility prices and on the cost of exchanger area, neither of which the network optimizes for you. +Splitting streams +----------------- + +``stream_splitting=True`` lets the planner split a process stream into +parallel branches that re-join, on any side of the pinch that no network +without splits serves at MER. Like ``T_min_app`` it is a plain attribute of the +facility, so the block below synthesizes the 15 K network of the sweep twice, +without and with it. + +.. literalinclude:: /../_demo_src/examples/ch04_configuring.py + :language: python + :start-after: # [start:splitting] + :end-before: # [end:splitting] + :dedent: + +.. literalinclude:: /_generated/ch04_splitting.txt + :language: text + +With the option, the network reaches the targets -- status ``mer`` -- with five +process exchangers instead of eight, and its added installed cost falls from +6.684e+05 to 5.893e+05 USD. One stream is split: stream 3, the column's +condenser, which enters at the pinch and condenses over about half a kelvin, +so that its heat capacity flow rate is far larger than that of either cold +stream. Below the pinch, the splitter ``Split_3_cs`` sends a fraction 0.5624 +of it to ``HX_3_0_cs``, against cold stream 0, and the other 0.4376 to +``HX_3_1_cs``, against cold stream 1, and then on to ``HX_3_0_cs_2``: each of +the two cold streams reaching the pinch meets a branch there. The mixer +``Mix_3_cs`` re-joins the two branches before the stream's cooler. The life +cycle marks each branch stage with its branch, ``(0, b)`` for branch *b* of +the stream's first split, and its fraction of the flow; a branch stage's +enthalpies are its +branch's, so the cooler's inlet, 2.47e+06 kJ/hr, is the sum of the two branch +outlets, 1.39e+06 and 1.08e+06 kJ/hr. The splitters and mixers are adiabatic +and add no cost; they are listed in ``HXN.new_splitters`` and +``HXN.new_mixers``, simulated in ``HXN.HXN_sys`` with the exchangers, and +described in ``HXN.synthesis_info['splits']``. A side that a network without +splits serves is planned exactly as without the option, so the MER networks of +the sweep at 2, 5 and 10 K would come out the same. :doc:`../concepts` +describes the method and its guarantee. + Scoping the analysis -------------------- @@ -174,8 +217,9 @@ Other options The remaining keyword arguments of :class:`~hensmith.HeatExchangerNetwork` are listed in the :doc:`../API/api` reference; what each of them does to the -synthesis is described below. Four of them -- ``Qmin``, ``force_ideal_thermo``, -``avoid_recycle`` and ``sort_hus_by_T`` -- are passed straight through to +synthesis is described below (``stream_splitting`` in `Splitting streams`_ +above). Five of them -- ``Qmin``, ``force_ideal_thermo``, ``avoid_recycle``, +``sort_hus_by_T`` and ``stream_splitting`` -- are passed straight through to :func:`~hensmith.synthesize_network`, which can also be called directly on a list of heat utilities. @@ -238,7 +282,9 @@ 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. +same pair of streams. With ``stream_splitting`` as well, a split whose +branches would match the same pair twice is not used either, so MER is then +not guaranteed. ``force_ideal_thermo`` (default ``False``) runs the analysis on copies of the streams made with ideal thermodynamics (``i.thermo.ideal()``), and the diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 2d5e98c..6a257c0 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -7,6 +7,12 @@ # github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt # for license details. """ +The `HeatExchangerNetwork` facility: a pinch analysis on the heat utilities +of a whole BioSTEAM system, the synthesis of a heat exchanger network at the +minimum energy requirement (`hensmith.hxn_synthesis.synthesize_network`; +unsplit by default, with stream splits where a side of the pinch needs them +if `stream_splitting`), and its convergence and costing as a `System`. + Created on Sat Aug 22 21:58:19 2020 @author: sarangbhagwat and yoelcp """ @@ -61,31 +67,107 @@ def _load_utility_costs(unit): owner = unit.owner if owner is not unit: owner._load_operation_costs() +def _phase_flows(stream): + """Return the molar flows of `stream` by phase, {phase: array}.""" + if isinstance(stream, bst.MultiStream): + return {phase: row.to_array() + for phase, row in zip(stream.phases, stream.imol.data.rows)} + return {stream.phase: stream.mol.to_array()} + +def _move_phase_flows(stream, inlets): + """ + Give the multiphase `stream` the flows that `inlets` carry together, + keeping its temperature, pressure and phase split, without a flash. + + Each chemical that `stream` carries keeps the fraction of its flow in + each phase (its phase flows are scaled by its new total over its old + one), so when every inlet scales by a common factor, every phase does, + to rounding; a chemical that it does not carry enters in the phases in + which it arrives (adding a phase that `stream` lacks). No float is + compared for equality and nothing is flashed, so the split does not + depend on the last bits of the flash that made it (a TP flash at the + stream's own temperature and pressure can even miss its two phases). + """ + before = _phase_flows(stream) + carried = sum(before.values()) + total = sum([s.mol.to_array() for s in inlets]) + arriving = {} + for s in inlets: + for phase, flows in _phase_flows(s).items(): + arriving[phase] = (arriving[phase] + flows if phase in arriving + else flows) + new_phases = [i for i in arriving if i not in stream.phases] + if new_phases: stream.phases = (*stream.phases, *new_phases) + held = carried > 0. + scale = np.divide(total, carried, out=np.zeros_like(total), where=held) + none = np.zeros_like(total) + for phase in stream.phases: + stream.imol[phase] = np.where(held, before.get(phase, none) * scale, + arriving.get(phase, none)) + +def _move_flows(unit): + """ + Give the outlets of `unit`, a unit of a cached network, the flows that + its inlets carry now, so that the network's convergence starts from + the new flows (`HeatExchangerNetwork._cost` moves them in path order). + Nothing is flashed here (only the convergence, which the facility + guards, runs the rigorous units), and every outlet keeps its + temperature and pressure. + + - A splitter splits its feed at its fixed fractions (`Splitter._run` + copies the feed's intensive state and flashes nothing). + - A mixer's outlet takes the summed flows of its inlets (running the + rigorous mixer would flash). + - Any other unit's outlet ``k`` takes the flows of its inlet ``k``. + + A single-phase outlet takes the flows in its phase. A multiphase + outlet keeps its phase split (`_move_phase_flows`): each chemical that + it carries keeps the fraction of its flow in each phase, and a + chemical new to it enters in the phase in which it arrives. + """ + if isinstance(unit, bst.Splitter): + unit._run() + elif isinstance(unit, bst.Mixer): + s_out, = unit.outs + ins = unit.ins + if isinstance(s_out, bst.MultiStream): + _move_phase_flows(s_out, ins) + else: + s_out.mol[:] = sum([s.mol for s in ins]) + else: + for s_in, s_out in zip(unit.ins, unit.outs): + if isinstance(s_out, bst.MultiStream): + _move_phase_flows(s_out, [s_in]) + else: + s_out.mol[:] = s_in.mol + def _network_path(units, stream_life_cycles): """ Return the simulation path of a synthesized network and its recycle (tear) streams. - 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.) + A stream's stages form a series-parallel chain (in series, except that + the branches of a split run in parallel between its splitter chain and + its mixer), so the network is a directed graph with an edge along every + connection of every life cycle (`StreamLifeCycle.connections`; for an + unsplit stream, from each stage to the next one). The path is a + topological order of that graph (Kahn's algorithm, ties broken by the + order of `units`); where the graph has a cycle (e.g. a pair of streams + matched both above and below the pinch, or repeated matches in + alternating order), the unit with the fewest unplaced predecessors + comes next and its inlets from later units become recycle streams + (possibly a splitter outlet). Every unit then runs after all its + feeders except across a declared recycle, and the fixed-point iteration + of the resulting `System` converges the loops. (Deterministic, unlike a + general network sort, which need not settle on intertwined loops.) """ position = {u: i for i, u in enumerate(units)} successors = {u: [] for u in units} N_waiting = {u: 0 for u in units} for life_cycle in stream_life_cycles: - 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 + for up, up_port, down, _ in life_cycle.connections(): + successors[up].append((down, up.outs[up_port])) + N_waiting[down] += 1 ready = [position[u] for u in units if not N_waiting[u]] heapq.heapify(ready) placed = {} @@ -123,44 +205,79 @@ class HeatExchangerNetwork(bst.Facility): units : Iterable[Unit], optional All unit operations available to the heat exchanger network. Defaults to all unit operations in the system. - + stream_splitting : bool, optional + Allow a process stream to be split into parallel branches that + re-join. A side of the pinch that no unsplit network serves at the + minimum energy requirement (a pinch-rule proof, or an unsplit + search that leaves a utility penalty) is planned with splits + instead, and then reaches the MER targets exactly on the planner's + knots (see Notes, "Stream splitting"). Sides that an unsplit + network serves are never split, so a problem that needs no split + gets the same network as with the default. The branches are + realized with `biosteam.Splitter` chains and rigorous + `biosteam.Mixer` units, which are adiabatic and cost nothing; their + structure is reported in ``info['splits']`` (`synthesize_network`) + and ``synthesis_info['splits']`` (`HeatExchangerNetwork`). With + `avoid_recycle`, a split that would repeat a stream pair is not + used, and MER is then not guaranteed. Defaults to False. + Notes ----- - The network is synthesized without stream splits by - :func:`~hensmith.hxn_synthesis.synthesize_network`: a problem table on - the streams' temperature-enthalpy curves gives the minimum energy - requirement (MER) targets, and a planner builds each side of the pinch - from the pinch outward [1]_ [2]_, keeping `T_min_app` everywhere inside - every exchanger on the exact stream states. It reaches the targets - whenever its search finds an unsplit network that does; the same pair - of streams may then be matched more than once (IDs with a suffix + Unless `stream_splitting`, the network is synthesized without stream + splits by :func:`~hensmith.hxn_synthesis.synthesize_network`: a problem + table on the streams' temperature-enthalpy curves gives the minimum + energy requirement (MER) targets, and a planner builds each side of the + pinch from the pinch outward [1]_ [2]_, keeping `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 + splitting, the network is a best-effort one close to the targets, or, + with `stream_splitting`, one that splits streams to reach them. The outcome is recorded in `synthesis_info` (a dict; see the `info` keyword of `synthesize_network`): 'status' is 'mer' when the network's utilities equal the targets and 'best_effort' otherwise, with the targets, the planned utilities and, per side of the pinch, any proof that a split is needed. + *Stream splitting.* With `stream_splitting`, a side of the pinch that + no unsplit network serves at MER is planned with stream splits and + reaches the targets exactly on the planner's knots (see + `synthesize_network`, Notes, for the guarantee and its limits). Each + split is realized as a chain of `biosteam.Splitter` units, IDs + ``Split__`` (with ``_`` for the n-th split of the + stream on that side, n >= 2, and ``_b`` for the chain's element + c >= 2), and a rigorous `biosteam.Mixer`, ID + ``Mix__[_]``, listed in `new_splitters` and + `new_mixers` (both empty without a split) and simulated in `HXN_sys` + with the exchangers. They are adiabatic and add no cost. + ``synthesis_info['splits']`` holds the splits + (:class:`~hensmith.hxn_synthesis.StreamSplit`: the branch fractions and + the exchangers of every branch), and a split stream's life cycle lists + its branch stages with their branch and fraction. + 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). + series (where it splits, its branches run in parallel from a splitter + chain to a mixer); the network is simulated as a `System` (`HXN_sys`) + whose path follows the streams, with the loops that repeated matches can + form torn and converged to a tight tolerance (every exchanger starts at + its planned state, so the loops are at their fixed point after one + pass). With `cache_network`, a network is reused while the 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. + exchangers and `stream_splitting` are the same: each process exchanger + keeps, as its enthalpy limit, the share of the stream's duty it had at + synthesis (on the stream that the plan serves completely on that side of + the pinch; its partner transfers that share, but never past its own + outlet, and takes the rest to its utility), and the utility exchangers + bring every stream to its new outlet. The splitters keep their + fractions, so a branch exchanger's limit is its branch's fraction of the + whole stream's. If the cached network does not reproduce the outlets, or + its energy balance is off, the network is synthesized again. Every utility exchanger is designed and costed by biosteam as usual. A stream that its process exchangers bring to its outlet (within 1e-9 of @@ -237,11 +354,45 @@ class HeatExchangerNetwork(bst.Facility): , H_in = 7.51e+05 kJ/hr, H_out = 7.18e+05 kJ/hr> , H_in = 7.18e+05 kJ/hr, H_out = 7.18e+05 kJ/hr> ]>] - + + Two hot liquids that reach the pinch against one cold liquid break the + number rule above it (case rtB05 of the test suite), so no unsplit + network reaches the MER targets; with `stream_splitting`, the cold + stream is split into two branches, one per hot stream: + + >>> bst.main_flowsheet.set_flowsheet('two_hot_one_cold') + >>> bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + >>> def process_stream(ID, T_in, T_out, P, **kmol_hr): + ... inlet = bst.Stream(ID + '_in', T=T_in, P=P, phase='l', + ... units='kmol/hr', **kmol_hr) + ... return bst.HXutility(ID, ins=inlet, T=T_out, rigorous=False) + >>> units = [process_stream('H1', 400., 320., 5e5, Water=60.), + ... process_stream('H2', 390., 330., 5e5, Ethanol=30.), + ... process_stream('C1', 310., 410., 5e5, Water=150.), + ... process_stream('H3', 360., 300., 101325., Water=200.)] + >>> HXN = bst.HeatExchangerNetwork('HXN', T_min_app=10.) + >>> sys = bst.System.from_units('sys', units=[*units, HXN]) + >>> sys.simulate() + >>> HXN.synthesis_info['status'] + 'best_effort' + >>> HXN.stream_splitting = True + >>> sys.simulate() + >>> HXN.synthesis_info['status'] + 'mer' + >>> [u.ID for u in HXN.new_splitters], [u.ID for u in HXN.new_mixers] + (['Split_0_hs'], ['Mix_0_hs']) + >>> [hx.ID for hx in HXN.new_HXs] + ['HX_0_2_hs', 'HX_0_3_hs', 'HX_1_0_cs'] + >>> HXN.synthesis_info['splits'] + [] + """ ticket_name = 'HXN' acceptable_energy_balance_error = 0.02 raise_energy_balance_error = False + # Default of an instance without it (e.g. one from before stream + # splitting); `__init__` sets it on every new facility. + stream_splitting = False network_priority = -2 _N_ins = 0 _N_outs = 0 @@ -251,7 +402,7 @@ class HeatExchangerNetwork(bst.Facility): def __init__(self, ID='', T_min_app=5., units=None, ignored=None, Qmin=1e-3, force_ideal_thermo=False, cache_network=False, avoid_recycle=False, acceptable_energy_balance_error=None, replace_unit_heat_utilities=False, - sort_hus_by_T=False): + sort_hus_by_T=False, stream_splitting=False): bst.Facility.__init__(self, ID, None, None) self.T_min_app = T_min_app self.units = units @@ -262,6 +413,7 @@ def __init__(self, ID='', T_min_app=5., units=None, ignored=None, Qmin=1e-3, self.avoid_recycle = avoid_recycle self.replace_unit_heat_utilities = replace_unit_heat_utilities self.sort_hus_by_T = sort_hus_by_T + self.stream_splitting = stream_splitting if acceptable_energy_balance_error is not None: self.acceptable_energy_balance_error = acceptable_energy_balance_error @@ -285,10 +437,13 @@ 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 + which enters the network at `stage.unit`: its life cycle's entry + port, or its first stage) to the state in which its first process + exchanger takes it, directly or, where the stream splits at its + inlet, through the splitter chain (which keeps the intensive state): + at equilibrium at its inlet enthalpy for a point-load stream (see + `hensmith.hxn_synthesis._first_inlet`).""" + if not isinstance(stage.unit, (bst.HXprocess, bst.Splitter)): return point_load = index in self.synthesis_info.get('point_loads', ()) _first_inlet(stream, point_load, self.outlet_Ts[index], index not in self.cold_indices) @@ -344,8 +499,13 @@ def _cost(self): self._restore_unit_heat_utilities() hx_utils = self._get_original_heat_utilties() use_cached_network = False + # A network synthesized with other options (stream splitting on or + # off), or by a facility from before stream splitting (no options + # recorded; its life cycles have no entry port), is never reused. if (self.cache_network and hasattr(self, 'original_heat_utils') - and hasattr(self, '_stage_fractions')): + and hasattr(self, '_stage_fractions') + and getattr(self, '_synthesis_options', None) + == (self.stream_splitting,)): # Units are a stable key to compare whether system has changed configuration. hu_by_unit = {hu.unit: hu for hu in hx_utils} use_cached_network = ( @@ -362,13 +522,15 @@ def _cost(self): new_HXs = self.new_HXs new_HX_utils = self.new_HX_utils stage_fractions = self._stage_fractions + stage_scales = self._stage_scales for i, life_cycle in enumerate(stream_life_cycles): hx = hxs[i] s_util_in = hx.ins[0] - stage = life_cycle.life_cycle[0] - s_lc = stage.unit.ins[stage.index] + # at its entry port, as in a fresh synthesis + entry = life_cycle.entry + s_lc = entry.unit.ins[entry.index] s_lc.copy_like(s_util_in) - self._enter_network(i, stage, s_lc) + self._enter_network(i, entry, s_lc) H_in = s_util_in.H H_out = hx.outs[0].H for lc in life_cycle.life_cycle: @@ -387,24 +549,25 @@ def _cost(self): # 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. + # none. A branch stage (fraction f of the flow) takes + # f times the whole stream's limit at its share (the + # partner: f times the stream's outlet), since the + # splits keep their fractions. index = lc.index - fraction = stage_fractions.get((unit.ID, index)) + key = unit.ID, index + fraction = stage_fractions.get(key) if (index == 0 and fraction is not None and (unit.ID, 1) in stage_fractions): fraction = 1. - setattr(unit, f'H_lim{index}', None if fraction is None - else H_in + fraction * (H_out - H_in)) - sys = self.HXN_sys - for unit in sys.units: - for s_in, s_out in zip(unit.ins, unit.outs): - if isinstance(s_out, bst.MultiStream): - s_out.F_mol = s_in.F_mol - if not s_out.mol.sparse_equal(s_in.mol): - s_out.copy_flow(s_in) - s_out.vle(T=s_out.T, P=s_out.P) + if fraction is None: + H_lim = None else: - s_out.mol[:] = s_in.mol + H_lim = H_in + fraction * (H_out - H_in) + f = stage_scales.get(key, 1.) + if f != 1.: H_lim *= f + setattr(unit, f'H_lim{index}', H_lim) + sys = self.HXN_sys + for unit in sys.units: _move_flows(unit) else: # Signed-duty order is the default matching priority of the # synthesis passes: smallest heating duty first among the cold @@ -418,8 +581,15 @@ def _cost(self): hot_indices, cold_indices = \ synthesize_network(hx_utils, self.T_min_app, self.Qmin, self.force_ideal_thermo, self.avoid_recycle, - self.sort_hus_by_T, info=synthesis_info) + self.sort_hus_by_T, info=synthesis_info, + stream_splitting=self.stream_splitting) new_HXs = HXs_hot_side + HXs_cold_side + # the realized splits (none without stream splitting) + splits = synthesis_info.get('splits', ()) + self.new_splitters = new_splitters = [ + u for split in splits for u in split.splitters + ] + self.new_mixers = new_mixers = [split.mixer for split in splits] self.new_HXs_hot_side = HXs_hot_side self.new_HXs_cold_side = HXs_cold_side self.cold_indices = cold_indices @@ -432,26 +602,34 @@ def _cost(self): self.pinch_Ts = pinch_T_arr self.inlet_Ts = T_in_arr self.outlet_Ts = T_out_arr - all_units = new_HXs + new_HX_utils + all_units = new_HXs + new_HX_utils + new_splitters + new_mixers IDs = set([i.ID for i in all_units]) assert len(all_units) == len(IDs) + # Each stream enters at its entry port (its first stage, or + # the splitter chain of a split at its inlet) as a copy of + # its real inlet, and every connection of its life cycle + # (for an unsplit stream, from each stage to the next) is + # wired. for i, life_cycle in enumerate(stream_life_cycles): - stage = life_cycle.life_cycle[0] + entry = life_cycle.entry s_util = hx_heat_utils_rearranged[i].unit.ins[0] - s_lc = stage.unit.ins[stage.index] + s_lc = entry.unit.ins[entry.index] s_lc.copy_like(s_util) - self._enter_network(i, stage, s_lc) + self._enter_network(i, entry, s_lc) for life_cycle in stream_life_cycles: - s_out = None - for i in life_cycle.life_cycle: - unit = i.unit - if s_out: unit.ins[i.index] = s_out - s_out = unit.outs[i.index] + for up, up_port, down, down_port in life_cycle.connections(): + down.ins[down_port] = up.outs[up_port] # For the cached network: the share of its stream's duty at # which each process stage's enthalpy limit sits, so that a # changed feed rescales every stage instead of letting the - # first one take the whole duty. + # first one take the whole duty. A branch stage (fraction f + # of the flow) has f times the whole stream's limit: its + # share is that of the whole stream's limit (on the parent + # basis), and f is kept in `_stage_scales`. The options the + # network was synthesized with are the cache's key. self._stage_fractions = stage_fractions = {} + self._stage_scales = stage_scales = {} + self._synthesis_options = (self.stream_splitting,) for i, life_cycle in enumerate(stream_life_cycles): hx = hx_heat_utils_rearranged[i].unit H_in = hx.ins[0].H @@ -461,6 +639,10 @@ def _cost(self): if isinstance(unit, bst.HXutility): continue H_lim = getattr(unit, f'H_lim{lc.index}') if H_lim is None: continue + f = lc.fraction + if f != 1.: + H_lim /= f + stage_scales[unit.ID, lc.index] = f stage_fractions[unit.ID, lc.index] = ( (H_lim - H_in) / span if span else 0. ) @@ -616,9 +798,18 @@ def _get_stream_life_cycles(self): new_HX_utils = self.new_HX_utils streams = self.streams_inlet indices = [i for i in range(len(streams))] + # each stream's own splits (``synthesis_info['splits']``); a stream + # without any gets its life cycle exactly as without splitting + splits = {} + for split in self.synthesis_info.get('splits', ()): + splits.setdefault(split.stream, []).append(split) SLCs = [StreamLifeCycle(index, index in cold_indices) for index in indices] for SLC in SLCs: - SLC.get_life_cycle(new_HXs, new_HX_utils) + if SLC.index in splits: + SLC.get_life_cycle(new_HXs, new_HX_utils, + splits=splits[SLC.index]) + else: + SLC.get_life_cycle(new_HXs, new_HX_utils) stream_life_cycles = SLCs self.stream_life_cycles = stream_life_cycles return stream_life_cycles @@ -671,7 +862,7 @@ def get_original_hxs_associated_with_streams(self): # pragma: no cover self.original_hxs = original_hxs return original_hxs - def save_stream_life_cycles_as_csv(self): # pragma: no cover + def save_stream_life_cycles_as_csv(self): if not hasattr(self, 'stream_life_cycles'): self.stream_life_cycles = self._get_stream_life_cycles() stream_life_cycles = self.stream_life_cycles @@ -684,36 +875,32 @@ def save_stream_life_cycles_as_csv(self): # pragma: no cover filename = 'HXN-%s_%s.%s.%s.%s.%s.csv'%(self.system.ID, dateTimeObj.year, dateTimeObj.month, dateTimeObj.day, dateTimeObj.hour, dateTimeObj.minute) - csvWriter = csv.writer(open(filename, 'w'), delimiter=',') - csvWriter.writerow(['Stream', 'Type', 'Original unit', 'HXN unit', 'H_in (kJ/hr)', - 'H_out (kJ/hr)', 'T_in (C)', 'T_out (C)']) - stream, streamtype, original_unit, hxn_unit, H_in, H_out, T_in, T_out =\ - 0, 0, 0, 0, 0, 0, 0, 0 - inlet_Ts = self.inlet_Ts outlet_Ts = self.outlet_Ts - for life_cycle in stream_life_cycles: - stream = life_cycle.index - streamtype = 'Cold' if life_cycle.cold else 'Hot' - stage_no = 0 - stages = life_cycle.life_cycle - len_stages = len(stages) - for stage in stages: + with open(filename, 'w', newline='') as file: + csvWriter = csv.writer(file, delimiter=',') + csvWriter.writerow(['Stream', 'Type', 'Original unit', 'HXN unit', + 'H_in (kJ/hr)', 'H_out (kJ/hr)', 'T_in (C)', + 'T_out (C)']) + for life_cycle in stream_life_cycles: + stream = life_cycle.index + streamtype = 'Cold' if life_cycle.cold else 'Hot' original_unit = original_hxs[stream][0].ID if original_hxs[stream][1]: - original_unit+= ' - ' + original_hxs[stream][1] - - hxn_unit = stage.unit - hxn_unit_ID = hxn_unit.ID - H_in = stage.H_in - H_out = stage.H_out - T_in, T_out = None, None - if stage_no == 0: - T_in = inlet_Ts[stream] - 273.15 - if stage_no == len_stages - 1: - T_out = outlet_Ts[stream] - 273.15 - - row = [stream, streamtype, original_unit, hxn_unit_ID, - H_in, H_out, T_in, T_out] - csvWriter.writerow(row) - stage_no += 1 + original_unit += ' - ' + original_hxs[stream][1] + # One row per stage, at its own enthalpies. A stream that + # splits at its inlet enters its splitter chain first, which + # gets a row at the whole stream's inlet enthalpy (its first + # stage is then a branch: a fraction of the flow). + rows = [(stage.unit.ID, stage.H_in, stage.H_out) + for stage in life_cycle.life_cycle] + entry = life_cycle.entry # None if assigned by hand + if entry is not None and isinstance(entry.unit, bst.Splitter): + H_in = life_cycle.H_in + rows.insert(0, (entry.unit.ID, H_in, H_in)) + last = len(rows) - 1 + for n, (ID, H_in, H_out) in enumerate(rows): + T_in = inlet_Ts[stream] - 273.15 if n == 0 else None + T_out = outlet_Ts[stream] - 273.15 if n == last else None + csvWriter.writerow([stream, streamtype, original_unit, ID, + H_in, H_out, T_in, T_out]) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index e120685..a4374bb 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -7,8 +7,10 @@ # github.com/BioSTEAMDevelopmentGroup/hensmith/blob/master/LICENSE.txt # for license details. """ -Planner for heat exchanger networks at minimum energy requirement (MER) -without stream splits. +Planner for heat exchanger networks at minimum energy requirement (MER): +without stream splits by default, and with them, where a side of the pinch +needs them, if `stream_splitting` (see "Stream splitting (optional)" +below). The planner works on numbers only (numpy; no BioSTEAM objects). The caller, `hensmith.hxn_synthesis.synthesize_network`, describes each process stream @@ -129,7 +131,9 @@ 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. +emulates a split (the unsplit search's only way to approach one; with +`stream_splitting`, a side that needs a split gets real ones instead). +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 @@ -145,7 +149,9 @@ 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: +ran out of budget (with `stream_splitting`, only if the side's split +attempt finds no candidate, which Theorem M of `hensmith._splitting` rules +out without `avoid_recycle`): 1. Twelve greedy dives give an incumbent. Each dive leaks must heat when no partner fits. @@ -179,9 +185,37 @@ 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. + alternation approaches a split only in the limit (unless + `stream_splitting`: see below). - A side that needs splits but has no rules proof spends its whole MER - budget before best effort starts. + budget before best effort (or, with `stream_splitting`, the split + attempt) starts. +- With `stream_splitting` and without `avoid_recycle`, every side that no + unsplit plan serves gets a split plan at MER on the knots (Theorem M of + `hensmith._splitting`), so the plan's status is 'mer'. + +Stream splitting (optional) +--------------------------- +With ``stream_splitting=True``, :func:`plan_network` hands every side that +has a root proof, or whose best effort leaves a penalty, to +`hensmith._splitting._split_side`. Sides the unsplit search serves are +planned exactly as without the option, so a problem that needs no split +gets the same plan. A split side is planned as verified *cells* in parent +coordinates (a branch of flow fraction ``f`` is the parent's curve with +every heat times ``f``), by a portfolio of generators: Stage S (splits at +the pinch, one candidate per transport rule, completed by the unsplit +search) and the core strategies V, LV, VT and LVT (vertical blocks, pinch +blocks and tails), of which V always yields an MER candidate (without +`avoid_recycle`). The candidate with the smallest key (feasibility +markers, then units plus extra branches plus split stages) wins, and +`_split_records` turns its cells into the plan's records: every split +stage becomes a `hensmith._splitting.Split` in ``Plan.splits``, a branch +exchanger carries its fractions (``Exchanger.hot_frac``/``cold_frac``) +and parent-equivalent enthalpies, and ``Plan.paths`` lists each stream's +flow order with a split as one item (``Plan.stages`` flattened). The side's +``info['sides'][side]['split']`` reports the candidates and the pick. The +module docstring of `hensmith._splitting` holds the theory: the lemmas, +Theorems V' and M, the generators, the key and the signature. References ---------- @@ -676,6 +710,8 @@ class _Side: tolQ, tolP : float Heat and level tolerances. """ + #: x_dT of a pair, as the search (`_Search`) computes it + max_duty = staticmethod(_max_duty) def __init__(self, name, musts, flexes, tolQ, tolP): self.name = name @@ -967,11 +1003,14 @@ class _Search: (i, j) pairs that may not be used. a0 : list[float], optional Initial must frontiers (gaps at the pinch end; best effort). + b0 : list[float], optional + Initial flex frontiers (default: the pinch). With `a0`, a node of + a stream-splitting core candidate that the search completes. """ def __init__(self, side, mode='restricted', cap=None, extra=False, budget=1000., unit_bound=math.inf, min_piece=None, - forbid=frozenset(), a0=None): + forbid=frozenset(), a0=None, b0=None): self.side = side self.mode = mode self.cap = cap @@ -981,6 +1020,7 @@ def __init__(self, side, mode='restricted', cap=None, extra=False, self.min_piece = min_piece self.forbid = forbid self.a0 = a0 + self.b0 = b0 self.work = 0. self.exhausted = False self.failed = {} @@ -990,7 +1030,7 @@ 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 + b = list(self.b0) if self.b0 is not None else [0.] * s.F self.last_m = [None] * s.M self.last_f = [None] * s.F self.pairs = defaultdict(int) @@ -1152,7 +1192,7 @@ def _candidates(self, a, b, d, open_m, open_f): 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: + if s.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 @@ -1176,8 +1216,8 @@ def _moves(self, a, b, d, open_m, open_f, lam, mu, rem_m, rem_f): 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) + xd = s.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]) @@ -1336,18 +1376,26 @@ def _merge(pieces): class _SidePlan: """Plan of one side: pieces (i, j, a0, b0, x), per-must gaps, - status ('trivial' | 'mer' | 'best_effort') and diagnostics.""" + status ('trivial' | 'mer' | 'best_effort') and diagnostics. A split + side (`hensmith._splitting`) has no pieces but `cells` (branch + exchangers in parent coordinates), its `split` info, and `units` given + by the caller; a split attempt without a candidate has status 'failed' + and only its `split` info and `work`, which `_plan_side` moves to its + best-effort plan.""" __slots__ = ('pieces', 'gaps', 'status', 'proof', 'method', 'work', - 'units') + 'units', 'cells', 'split') - def __init__(self, pieces, gaps, status, method, work=0., proof=None): + def __init__(self, pieces, gaps, status, method, work=0., proof=None, + cells=None, split=None, units=None): self.pieces = pieces self.gaps = gaps self.status = status self.method = method self.work = work self.proof = proof - self.units = _count_units(pieces) + self.units = _count_units(pieces) if units is None else units + self.cells = [] if cells is None else cells + self.split = split @property def penalty(self): @@ -1360,9 +1408,14 @@ def _combine_cap(cap, cap1): return cap -def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1.): +def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1., + split=None): """MER search on one side, then units; best effort when there is a root - proof or the search runs out of budget.""" + proof or the search runs out of budget. With `split` (stream splitting: + ``dict(Qmin, exclude, prefer)``), a side with a root proof, or whose + best effort leaves a penalty, is planned with stream splits first + (`hensmith._splitting._split_side`); a side the search serves returns + exactly as without it.""" M, F = side.M, side.F if M == 0: return _SidePlan([], [], 'trivial', 'trivial') @@ -1374,6 +1427,21 @@ def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1.): 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: + if split: + from . import _splitting + # a 'cascade' root splits only if its deficit is the cascade's + # own tolerance (Lemma P); beyond it the targets cannot be met + if (proof['rule'] != 'cascade' + or -d.slack <= _splitting._preleak_max(side)): + sp = _splitting._split_side(side, proof, cap1, forbid, + work_scale, 0., split) + if sp.status == 'mer': + return sp + # no candidate: best effort, keeping the attempt's info + be = _best_effort(side, proof, cap1, forbid, work_scale, + sp.work) + be.split = sp.split + return be return _best_effort(side, proof, cap1, forbid, work_scale, 0.) work = 0. pieces = method = None @@ -1391,7 +1459,19 @@ def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1.): method = f'{mode}-cap{cap}' + ('-extra' if extra else '') break if pieces is None: - return _best_effort(side, None, cap1, forbid, work_scale, work) + be = _best_effort(side, None, cap1, forbid, work_scale, work) + if split: + from . import _splitting + # 1e-3 tolQ, far below the heat closure the plan is held to + # (1e-12 of the scale): no best-effort penalty that closure + # would notice is accepted without trying the split path + if be.penalty > _splitting._SPLIT_R_TOL * side.tolQ: + sp = _splitting._split_side(side, None, cap1, forbid, + work_scale, be.work, split) + if sp.status == 'mer': + return sp + be.work, be.split = sp.work, sp.split + return be first = work pieces, w = _improve_units(side, pieces, first, cap1, forbid, work_scale) work += w @@ -1400,9 +1480,11 @@ def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1.): return _SidePlan(pieces, [0.] * M, 'mer', method, work) -def _improve_units(side, pieces, first, cap1, forbid, work_scale): +def _improve_units(side, pieces, first, cap1, forbid, work_scale, a0=None, + b0=None): """Branch and bound on the number of exchangers: the same search with a - unit bound one below the incumbent, restricted then full mode.""" + unit bound one below the incumbent, restricted then full mode, from the + must and flex frontiers `a0` and `b0` (default: the pinch).""" U = _count_units(pieces) step = min(_UNITS_WORK_MAX, max(_UNITS_WORK_MIN, 3. * first)) * work_scale total = _UNITS_WORK_TOTAL * work_scale @@ -1412,7 +1494,7 @@ def _improve_units(side, pieces, first, cap1, forbid, work_scale): found = None for mode in ('restricted', 'full'): srch = _Search(side, mode, cap, False, min(step, total - used), - unit_bound=U - 1, forbid=forbid) + unit_bound=U - 1, forbid=forbid, a0=a0, b0=b0) res = srch.run() used += srch.work if res is not None: @@ -1426,10 +1508,12 @@ def _improve_units(side, pieces, first, cap1, forbid, work_scale): return pieces, used -def _units_guard(side, pieces, cap1, forbid, work_scale): +def _units_guard(side, pieces, cap1, forbid, work_scale, a0=None, b0=None): """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).""" + MER plan with the fewest units (MER always wins over the unit count). + The searches start from the must and flex frontiers `a0` and `b0` + (default: the pinch).""" U = _count_units(pieces) if U <= _GUARD_FACTOR * (side.M + side.F): return pieces, 0. @@ -1441,7 +1525,8 @@ def _units_guard(side, pieces, cap1, forbid, work_scale): 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) + min_piece=lambda i, j, thr=thr: thr, forbid=forbid, + a0=a0, b0=b0) res = srch.run() used += srch.work if res is not None: @@ -1884,18 +1969,20 @@ def _sides(curves, table, tolQ, tolP): for name, (m, f) in parts.items()} -def _plan_sides(sides, avoid_recycle, work_scale): +def _plan_sides(sides, avoid_recycle, work_scale, split=None): if not avoid_recycle: - return {name: _plan_side(side, work_scale=work_scale) + return {name: _plan_side(side, work_scale=work_scale, split=split) 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) + sp = _plan_side(side, True, side.local_pairs(used), work_scale, + split) plans[name] = sp used.update(side.hot_cold(i, j) for i, j, *_ in sp.pieces) + used.update(side.hot_cold(c.i, c.j) for c in sp.cells) return plans def score(plans): @@ -1916,10 +2003,23 @@ class Exchanger: 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).""" + stream meets them). + + With stream splitting, `hot_frac` and `cold_frac` are the flow + fractions of the branches the exchanger is on (1 on a trunk), and + `hot_branch` and `cold_branch` are ``(index into Plan.splits, branch)`` + or None on a trunk. The enthalpies are *parent-equivalent* (the + parent's state at that enthalpy is the branch's state), so + ``H_hot_in - H_hot_out == Q / hot_frac``; on a trunk they are the + stream's enthalpies, as without splits.""" __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') + 'H_cold_out', '_kh', '_kc', 'hot_frac', 'cold_frac', + 'hot_branch', 'cold_branch') + + def __init__(self): + self.hot_frac = self.cold_frac = 1. + self.hot_branch = self.cold_branch = None def __repr__(self): return (f'= 0). @@ -1959,7 +2067,14 @@ class Plan: penalty : float ``Q_hot - Q_hot_target`` (>= 0). info : dict - ``sides`` (per side: status, method, work, proof, gaps, units), + ``sides`` (per side: status, method, work, proof, gaps, units, and + with `stream_splitting` also ``split``: None for a side that did + not try to split, else a dict with the chosen ``candidate``, its + network ``signature``, every ``candidates`` key or failure reason, + ``stages``, ``branches``, ``preleak``, ``leak``, ``small`` (split + exchangers below `Qmin`, kept) and ``errors``; a side whose + attempt found no candidate keeps its best effort, with + ``candidate`` and ``signature`` None), ``qmin_dropped`` (list of (side, hot, cold, Q)), ``dropped`` (matches removed by the safety net; always empty unless there is a bug), ``min_approach`` (on the knot curves), ``work``, ``scale``, @@ -1967,7 +2082,7 @@ class Plan: """ __slots__ = ('status', 'Q_hot_target', 'Q_cold_target', 'pinch_T', 'cut', 'exchangers', 'stages', 'utility', 'Q_hot', 'Q_cold', - 'penalty', 'info') + 'penalty', 'info', 'splits', 'paths') def __repr__(self): return (f'>> len(plan.exchangers) 4 + Two hot streams that reach the pinch against one cold stream break the + number rule above it (stream 0 is cold, 1 and 2 are hot), so no unsplit + network reaches the targets: + + >>> knots = [linear(90., 190., 2.5), linear(60., 200., 1.), + ... linear(100., 200., 1.)] + >>> is_hot = [False, True, True] + >>> plan_network(knots, is_hot, 10.).status + 'best_effort' + + With `stream_splitting`, the cold stream is split into two branches + above the pinch, one per hot stream (a branch of flow fraction f has f + times the stream's heat capacity flow rate), and the plan reaches the + targets: + + >>> plan = plan_network(knots, is_hot, 10., stream_splitting=True) + >>> plan.status, plan.Q_hot, plan.Q_cold + ('mer', 50.0, 40.0) + >>> split = plan.splits[0] + >>> split.stream, split.side, [round(f, 4) for f in split.fractions] + (0, 'above', [0.5, 0.5]) + >>> [(e.hot, e.cold, e.Q, e.cold_frac) for e in plan.exchangers] + [(1, 0, 100.0, 0.5), (2, 0, 100.0, 0.5)] + """ is_hot = [bool(h) for h in is_hot] N = len(knots) @@ -2098,41 +2274,53 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., 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)) + split = (dict(Qmin=Qmin, exclude=dict(_split_exclude or {}), + prefer=dict(_split_prefer or {})) + if stream_splitting else None) + plans = _plan_sides(sides, avoid_recycle, work_scale, split) if act else {} + split_mode = any(p.cells for p in plans.values()) + if split_mode: + from . import _splitting + (recs, qmin_dropped, dropped, stages, utility, min_dT, splits, + paths) = _splitting._split_records(sides, plans, curves, N, Qmin, + tolQ) + 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 - 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 + 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 + e._kh, e._kc = (0, -a0), (1, b0) + else: # cold is the must (a0), hot the flex + 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 - 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])) + 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])) + splits = [] + paths = {j: list(s) for j, s in stages.items()} # 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] @@ -2165,6 +2353,8 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., plan.utility = utility plan.Q_hot, plan.Q_cold = Q_hot, Q_cold plan.penalty = max(0., penalty) + plan.splits = splits + plan.paths = paths side_info = {} for name, p in plans.items(): side = sides[name] @@ -2173,6 +2363,8 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., 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.}) + if stream_splitting: + side_info[name]['split'] = p.split plan.info = dict(sides=side_info, qmin_dropped=qmin_dropped, dropped=dropped, min_approach=(dT + min_dT) if recs else None, @@ -2192,9 +2384,11 @@ def _plan_numeric(streams, dTmin, knots=1, **kwargs): 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``. + T_cold_out, hot_seq, cold_seq, pair_index; with splits also hot_frac, + cold_frac, hot_branch and cold_branch, and branch temperatures), + ``hot_utility`` ({cold name: Q}), ``cold_utility`` ({hot name: Q}), + ``dTmin``, ``status``, ``penalty``, ``targets`` (Qh, Qc), ``n_units`` + and ``plan``. Examples -------- @@ -2235,6 +2429,10 @@ def _plan_numeric(streams, dTmin, knots=1, **kwargs): 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)) + if plan.splits: + matches[-1].update(hot_frac=e.hot_frac, cold_frac=e.cold_frac, + hot_branch=e.hot_branch, + cold_branch=e.cold_branch) 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) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py new file mode 100644 index 0000000..0eaa291 --- /dev/null +++ b/hensmith/_splitting.py @@ -0,0 +1,3116 @@ +# -*- 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. +""" +Stream splitting for the MER planner (`hensmith._planner`). + +When a side of the pinch has no minimum energy requirement (MER) network +without stream splits (the pinch design rules prove it, or the unsplit +search leaves a utility penalty), the planner, with `stream_splitting`, +splits streams into parallel branches. This module holds the numeric +machinery. Like the planner, it works on numbers only (numpy; no BioSTEAM +objects). The planner imports it only inside the functions that run when +splitting is on, so nothing here runs, and nothing changes, when it is off. + +The notation is the planner's (see its module docstring). A side has musts +``i`` with level curves ``phi_i`` on ``[0, Qm_i]`` and flexes ``j`` with +level curves ``psi_j`` on ``[0, Qf_j]``: non-decreasing, piecewise linear, +flats included, heat measured from the pinch. A node is a pair of frontier +vectors ``(a, b)``; ``side.analyse(a, b)`` gives the level set and the +inclusive and exclusive residual composites ``Di, De`` (musts) and +``Si, Se`` (flexes). Condition (R) is ``S >= D`` at every level, inclusive +and exclusive. + +Cells +----- +A cell (:class:`_Cell`) is one exchanger in *parent coordinates*. It has a +duty ``x``. Must ``i``'s branch of flow fraction ``f`` covers the parent +range ``[a, a + x/f]``, and flex ``j``'s branch of fraction ``g`` covers +``[b, b + x/g]``. A trunk has fraction 1. In the counter-current coordinate +``tau`` in ``[0, x]``, the must is at ``a + tau/f`` (its pinch-side outlet +at ``tau = 0``) and the flex at ``b + tau/g``. The cell is feasible iff + + phi_i(a + tau/f) - psi_j(b + tau/g) >= -tolP for all tau in [0, x], + and b + x/g <= Qf_j. (C) + +**Lemma B (a branch is the scaled parent).** A branch of fraction ``f`` +carries the parent's material at the parent's pressure. Enthalpy is +homogeneous of degree 1 in the flows and states are intensive, so at branch +heat ``tau`` the branch is in the parent's state at parent heat ``tau/f``. +Its level curve is the parent's with every heat times ``f`` +(:func:`_branch_curve`): the same levels, slopes divided by ``f``, flats +kept. + +**Lemma R (splitting preserves the cascade).** Replacing a curve by +branches whose fractions sum to 1 (each starting at ``f`` times the +parent's frontier) leaves every composite ``D(L)`` and ``S(L)`` unchanged. +So the slack of (R) is unchanged: only the pinch rules (stream counts, and +branch CP = ``f`` CP) and the approach limits see a split. + +**Lemma 1 (knot exactness).** (C) holds iff it holds at ``tau = 0``, at +``tau = x``, at ``f (q - a)`` for every knot ``q`` of ``phi_i`` inside the +must range and at ``g (q - b)`` for every knot ``q`` of ``psi_j`` inside the +flex range: between these points both levels are affine in ``tau``. +:func:`_cell_margin` evaluates the approach there, and every cell of a split +plan is verified this way before the plan is accepted. It establishes (C) +at ``tolP``; the planner's ``_max_duty`` does not quite: it takes linear +slopes within a relative ``_SLOPE_EQ`` as parallel, so a converging pair may +close by ``_SLOPE_EQ`` times its level span. The split path's searches +therefore run on a `_StrictSide`, whose `_max_duty_strict` applies that +shortcut only where the closure stays within ``tolP``: on branch-scaled +curves, every piece they place passes `_cell_margin`. + +Vertical coupling +----------------- +:class:`_Coupling` pairs the residual problem at a node level by level. +With ``X = sum_i (Qm_i - a_i)`` and a composite heat ``t`` in ``[0, X]``, +the must positions ``P_i(t)`` take the lowest ``t`` of the must composite +(musts at the same level in proportion: a flat's heat is shared) and the +flex positions ``R_j(t)`` the lowest ``t`` of the flex composite. Both are +closed-form, piecewise-linear interpolations of ``side.analyse``'s +residual arrays, with breakpoints at every composite knot (the values of +``De, Di, Se, Si`` in ``[0, X]``). Between two consecutive breakpoints every +present must follows the same normalized profile, and so does every present +flex, which is what makes a fixed-fraction block between them feasible +under (R). + +Round-off, never tolerance +-------------------------- +Breakpoints are merged, and positions snapped to the curve ends, only +within round-off, ``_SPLIT_ULP * max(X, 1)`` of heat, never within the heat +tolerance ``tolQ``: ``tolQ`` of heat can hide a knot ``tolQ/CP`` away in +level, far more than ``tolP``. An interval over which no must position +changes in floating point carries no must heat; it is folded into the next +one (or, at the end, into the previous one). + +Stage S (pinch splits) +---------------------- +At a tight cut where the pinch rules fail (`_cut_sets`), each demand (a +must leaving the cut outward, or a flex reaching it from below) needs its +own capacity branch with ``CP_cap >= CP_dem``. `_cut_transport` splits the +demands and capacities there by one of `_SPLIT_RULES`: demands whole, +capacities whole, Linnhoff and Hindmarsh's minimum-cell structure, or the +north-west corner with the CP slack spread uniformly or not at all. A +branch below `_SPLIT_MIN_FRACTION` is raised to it from its siblings +where the CP slack covers that (`_cut_fractions`): a demand branch where +the capacity it faces has room for the larger load, a capacity branch +where its parent's slack keeps ``CP_cap >= CP_dem``; it merges into its +largest sibling where not. The branches are whole-side branches, so by +Lemma R the branched side keeps the cascade, and one `rules_violation` +call at its pre-leaked root screens the split (`_pinch_split`); on a +double pinch the same rule is applied at the cut that still fails. The +branched side is an ordinary side for the planner's search: `_stage_s` +plans it from its pre-leaked root with the first passes of the planner's +DFS, unchanged but for the strict max duty of Lemma 1 (a `_StrictSide`), +under a unit bound from the best candidate so far and one work budget for +all rules (`_SPLIT_S_WORK`). Its pieces become cells in parent coordinates +(`_s_cells`); a flex branch the DFS left unused is folded into its used +siblings (**Lemma F**: a larger fraction moves every flex position toward +the pinch, where the flex levels are no higher, so every cell stays +feasible), and each must's residual of at most ``tolQ`` (the DFS's own +tolerance) is swept into its far-end cell, so every must is served +exactly; Stage S books no leak. Must branches split at the must's inlet +and remix isothermally at the pinch; flex branches split at the pinch and +remix at the side's far end, before the stream's utility only. The best +Stage S candidate gets the planner's unit improvements. + +The core (the provable backstop) +-------------------------------- +**Theorem V'.** Let a node satisfy (R) and let ``[t_s, t_e]`` be two +consecutive breakpoints of its coupling. Take the must heats ``h_i = +P_i(t_e) - P_i(t_s)``, the flex heats ``g_j = R_j(t_e) - R_j(t_s)`` and any +transport ``q_ij >= 0`` with these row and column sums, and give cell +``(i, j)`` the duty ``q_ij`` from ``(P_i(t_s), R_j(t_s))`` with fractions +``q_ij / h_i`` and ``q_ij / g_j``. This elementary block is feasible and +the node after it satisfies (R). *Proof.* No knot lies inside the interval, +so every present must follows the same affine normalized profile, the must +composite's quantile ``lam_M``, and every present flex follows ``lam_F``. +In a cell both branches are at the same composite coordinate, so (C) reads +``lam_M >= lam_F``: true at both ends by (R), hence throughout. The block +consumes the lowest ``t_e - t_s`` of both composites, ``D' = max(0, D - +Delta)`` and ``S' = max(0, S - Delta)``, and that map is monotone, so (R) +holds after it. Flex heat is taken as prefixes. All branches of a stream +span the same parent range, so every remix is isothermal. + +**Corollary C (coarsening).** A fixed-fraction block over several +breakpoints consumes the same heats as the elementary chain it replaces, +so it ends at the same node, where (R) is automatic. A cell is feasible iff +its normalized profiles are, independently of its duty, so the block +exists iff a transport exists on that compatibility graph; it is found by +a search (feasibility is not monotone in ``t_e``) and verified cell by +cell. + +**Node discipline.** A chain of vertical blocks runs on the breakpoints +of one coupling, and every node is the coupling's closed-form positions at +a breakpoint, never the start plus the duties, so round-off does not +accumulate: the column imbalance of a transport (O(eps) of its duty) is a +sub-``tolP`` level offset that the cells are verified with. Each node is +checked, ``slack >= -_SPLIT_R_TOL tolQ``, by an exact analysis (`_exact`: +the search's analysis omits a stream's last ``tolQ`` of residual heat). + +**Lemma P (pre-leak).** `_cascade`'s own tolerances can leave a root slack +down to ``-(_THRESHOLD_TOL / _REL_Q + 0.1) tolQ``: a threshold deficit of +up to ``_THRESHOLD_TOL`` of the scale, and 0.1 tolQ between near-equal +minima. Removing the lowest ``delta = -slack`` of the must composite gives +``D' = max(0, D - delta)`` with ``S`` unchanged, and ``S - D >= -delta``, +so (R) holds at ``P(delta)`` (`_preleak_root`). That heat goes to the +musts' utilities, consistent with the targets `_cascade` already set. + +**Recovery.** If a cell of an elementary block fails (C), a composite +breakpoint hidden inside the interval is inserted and the block rebuilt. +Otherwise the failure is position round-off (one ulp of heat moving a +tiny-CP stream by more than ``tolP``): the failing must heat is attached +to the must's series cell of the previous block if that re-verifies, else +leaked to the must's utility if at most ``_SPLIT_R_TOL tolQ``, and recorded +(a leak event, ``1e-14`` of the scale, is 100 times inside the planner's +``1e-12`` heat closure); anything larger raises `_SplitInvariantError`. + +**Strategies.** The driver (`_drive`) builds one candidate per strategy of +`_CORE_STRATEGIES`. 'V' chains vertical blocks. 'L' adds pinch blocks +(`_pinch_block`) where the rules fail outward: Linnhoff and Hindmarsh's +pinch split of the cut (the 'mincell' CP transport). Each must advances by +one extent on all of its branches, the largest its cells allow, and the +block is scaled back by the largest lambda (bisected) whose end node +satisfies (R), by the same exact check as every node. 'T' completes the +side with a DFS tail (`_tail`) from a node where the rules hold: the +planner's own search from ``(a, b)`` at a fraction of its budgets (on a +`_StrictSide`, Lemma 1), its must residuals swept as in Stage S. A tail +never yields a node, so every node of a core candidate is the pre-leaked +root, a vertical block's closed-form end or a pinch block's accepted end. + +**Theorem M (the portfolio always contains MER).** Without `avoid_recycle` +(no forbidden pairs, no pair cap) and with a root deficit ``delta <= +_preleak_max(side)`` (every deficit `_cascade`'s own tolerances can leave, +Lemma P), `_split_side` returns an MER side plan whose every cell is +feasible at every knot, up to a recorded leak of position round-off size. +*Proof.* 'V' runs from the pre-leaked root ``P(delta)``, which satisfies +(R) (Lemma P); every node it visits is the root or a vertical block's +closed-form end, and at each one an elementary block exists over the first +float-nonempty interval (Theorem V', no knot hiding inside it since +breakpoints merge only at round-off), up to the recovery above. Every +block advances some must by a float-nonempty amount or books a leak, so +the loop ends, with every must served exactly from its root position to +its end and every flex used as a prefix: the side's leftover is its +target plus the pre-leak, which the targets already absorbed. So 'V' +yields a candidate, the side's last candidate is never excluded, and the +winner is MER, since every candidate is (Stage S serves every must +exactly and books no leak; LV, VT and LVT by the same argument as 'V'). +The realization closes the rest (see +`hensmith.hxn_synthesis.synthesize_network`, Notes): exact on constant +heat capacity knots; on real-thermodynamics chords, by the refine rounds +and the split retry. + +Candidates +---------- +A candidate (:class:`_Candidate`) is one verified split plan of a side. Its +key, smaller is better: exchangers below ``Qmin`` or with a fraction below +`_SPLIT_MIN_FRACTION`; non-isothermal remixes that feed a process exchanger +of the same stream, and curved streams with more than `_SPLIT_MIX_CAP` +mixers; exchangers parallel at the minimum approach on sides with a curved +stream; units + extra branches + split stages; the most mixers on one +stream; the candidate order. Its signature holds no duty or position, and +`_same_network` compares signatures with the split fractions within +`_SPLIT_SAME_FRACTION` (fractions are CP ratios on the knots, which a refine +round moves by ~1e-6), so it identifies the same network across knot +refinements and generators. + +Selection and records +--------------------- +`_split_side` generates the candidates of a side from the pre-leaked root. +The previous refine round's pick is tried first and kept if it plans the +same network (`_same_network`) and that network is not excluded; +otherwise the smallest key wins among the candidates not excluded (a +side's last candidate is never excluded). Each must's gap is its pre-leak +plus its leak, so the side's penalty is truthful. A side with no candidate +falls back to the planner's best effort, which keeps the attempt's info +(candidate None, the reasons and the errors). +`_split_records` turns the side plans into the plan's records: every +split stage becomes a `Split`, branch enthalpies are parent-equivalent (a +branch exchanger moves its branch by ``Q/f``), a mix is written with the +duties (``H_mix = H_split -/+ sum Q``), and a stream's flow order lists +each split as one item (``Plan.paths``) or flattened (``Plan.stages``). +""" +import math +from collections import Counter, defaultdict, deque +from itertools import accumulate, combinations + +import numpy as np + +from ._planner import (Exchanger, _APPROACH_TOL, _LevelCurve, _REL_Q, + _SCHEDULE, _SLOPE_EQ, _Search, _Side, _SidePlan, + _THRESHOLD_TOL, _WORK_EVENT, _combine_cap, + _improve_units, _max_duty, _merge, _slope1, + _units_guard) + +__all__ = () + +_SPLIT_ULP = 8. * np.finfo(float).eps # round-off, relative to max(X, 1) +_SPLIT_R_TOL = 1e-3 # core nodes: (R) slack and leak cap, x tolQ +_SPLIT_MIN_FRACTION = 1e-3 # smallest branch fraction of a coarsened block +_SPLIT_COARSEN = True # False: elementary vertical blocks only +_SPLIT_MIX_CAP = 2 # mixers per curved stream and side (the key) +_ISO_TOL = 10. # isothermal remix, x tolQ +_SPLIT_SAME_FRACTION = 1e-3 # one network: fractions this close (the + # refine drift is ~1e-6; distinct networks + # differ by >= 1.6e-2 on the corpus) +_CORE_ORDER = ('V', 'LV', 'VT', 'LVT') # candidate order of the core +_CORE_STRATEGIES = _CORE_ORDER # the core strategies `_drive` runs +_SPLIT_BISECT = 30 # pinch block: bisection steps on lambda +_SPLIT_LAMBDA_MIN = 1e-3 # pinch block: smallest lambda accepted +_SPLIT_TAIL_WORK = 0.2 # core tail: share of the search budgets +_SPLIT_TAIL_TRIES = 3 # core tails per strategy +_SPLIT_RULES = ('partner', 'demand', 'mincell', 'nw-rho-desc', 'nw-rho-asc', + 'nw-exact-desc', 'nw-exact-asc') # Stage S, in order +_SPLIT_CUTS = 3 # cuts per Stage S rule (double pinches) +_SPLIT_PASSES = 3 # Stage S and tails: first `_SCHEDULE` passes +_SPLIT_S_WORK = 30000. # Stage S: work of all rules on a side +_SPLIT_FIRST_WINS = False # True: the first live candidate wins (runtime) +_MINCELL_WORK = 20000 # search nodes of one 'mincell' transport +_CP_TOL = 1e-12 # CP compatibility, as in `rules_violation` + + +# %% Cells and branch curves + +def _branch_curve(c, f, role='must'): + """ + Level curve of a branch of flow fraction `f` of curve `c` (Lemma B). + + Parameters + ---------- + c : _LevelCurve + The parent curve. + f : float + Flow fraction of the branch, in ``(0, 1]``. + role : {'must', 'flex'}, optional + Passed to `_LevelCurve`. + + Returns + ------- + _LevelCurve + The parent's levels at the parent's heats times `f`. + """ + return _LevelCurve([f * q for q in c.q], c.y, c.stream, c.H0, c.sgn, + role) + + +class _Cell: + """ + One exchanger of a split plan, in parent coordinates. + + Parameters + ---------- + i, j : int + Must and flex (local indices on the side). + x : float + Duty. + a, b : float + Parent positions where the must branch and the flex branch start + (at the pinch-side end of the exchanger). + f, g : float, optional + Flow fractions of the must and flex branches (1 on a trunk). + km, kf : tuple or None, optional + Stage keys ``(stage id, branch)`` of the must and flex branches, or + None on a trunk. + + Notes + ----- + The must branch covers the parent range ``[a, a + x/f]`` and the flex + branch ``[b, b + x/g]``. + """ + __slots__ = ('i', 'j', 'x', 'a', 'b', 'f', 'g', 'km', 'kf') + + def __init__(self, i, j, x, a, b, f=1., g=1., km=None, kf=None): + self.i, self.j, self.x = i, j, x + self.a, self.b, self.f, self.g = a, b, f, g + self.km, self.kf = km, kf + + @property + def a_end(self): + """Parent position where the must branch leaves the cell.""" + return self.a + self.x / self.f + + @property + def b_end(self): + """Parent position where the flex branch leaves the cell.""" + return self.b + self.x / self.g + + def __repr__(self): + return (f'<_Cell must={self.i} flex={self.j} x={self.x:.6g} ' + f'a={self.a:.6g} f={self.f:.6g} b={self.b:.6g} ' + f'g={self.g:.6g}>') + + +def _cell_margin(side, cell): + """ + Smallest approach of a cell over its duty (Lemma 1). + + Parameters + ---------- + side : _Side + The (parent) side. + cell : _Cell + The cell. + + Returns + ------- + margin : float + ``min phi_i(a + tau/f) - psi_j(b + tau/g)`` over ``tau`` in + ``[0, x]``, exact on the piecewise-linear curves; ``-inf`` if a + fraction is not positive or a branch runs past its curve's end by + more than ``tolQ``. The cell satisfies (C) iff ``margin >= -tolP``. + touch : bool + True if the approach is within ``tolP`` of zero at two consecutive + evaluation points more than ``tolQ`` of duty apart: the cell runs + parallel at the minimum approach along a segment (on the pinch, a + cell without CP slack). + + Notes + ----- + The evaluation points are ``tau = 0``, ``tau = x`` and the knots of + both curves strictly inside their ranges. A must knot is evaluated at + its own position (and the flex at the matching ``tau``), and likewise a + flex knot, so no knot level is perturbed by the change of coordinates. + """ + cm, cf = side.musts[cell.i], side.flexes[cell.j] + x, a, b, f, g = cell.x, cell.a, cell.b, cell.f, cell.g + tolQ = side.tolQ + if not (f > 0. and g > 0.): + return -math.inf, False + a1, b1 = a + x / f, b + x / g + if a1 > cm.Q + tolQ or b1 > cf.Q + tolQ: + return -math.inf, False + wm = np.asarray(cm.window(a, a1), float) + wf = np.asarray(cf.window(b, b1), float) + tm = (wm - a) * f + tf = (wf - b) * g + tau = np.concatenate(([0., x], tm, tf)) + pm = np.concatenate(([a, a1], wm, a + tf / f)) + pf = np.concatenate(([b, b1], b + tm / g, wf)) + gap = cm.at_many(pm) - cf.at_many(pf) + margin = float(gap.min()) + order = np.argsort(tau, kind='stable') + low = np.abs(gap[order]) <= side.tolP + touch = bool((low[:-1] & low[1:] + & (np.diff(tau[order]) > tolQ)).any()) + return margin, touch + + +def _max_duty_strict(cm, a, cf, b, limit, tolP): + """ + The planner's `_max_duty`, with its parallel shortcut only where it + holds: every duty satisfies (C) at `tolP`. + + `_max_duty` takes two linear curves whose slopes agree within a + relative `_SLOPE_EQ` as parallel and returns `limit`, so that equal + slopes are not separated by rounding. A pair that converges (flex + slope ``sf`` above must slope ``sm``) then closes by up to ``_SLOPE_EQ + sf limit`` of level, beyond `tolP` over a long enough span (a branch + whose CP a cut rule set to its partner's, on data with CPs equal to + 1e-9). Here the shortcut applies only if the gap at `limit`, ``g0 - + (sf - sm) limit``, is at least ``-tolP``; otherwise the duty is that of + any converging pair, ``max(g0, 0)/(sf - sm)`` (the gap closes to zero). + """ + if limit > 0. and cm.linear and cf.linear: + sm, sf = _slope1(cm), _slope1(cf) + if sf * (1. - _SLOPE_EQ) <= sm < sf: + g0 = cm.at(a) - cf.at(b) + if g0 < -tolP: + return 0. + if g0 - (sf - sm) * limit >= -tolP: + return limit + return min(limit, max(g0, 0.) / (sf - sm)) + return _max_duty(cm, a, cf, b, limit, tolP) + + +class _StrictSide(_Side): + """A `_Side` whose search places only pieces that satisfy (C) at tolP + (`_max_duty_strict`).""" + max_duty = staticmethod(_max_duty_strict) + + +def _strict(side): + """`side` for the split path's searches (`_StrictSide`).""" + if isinstance(side, _StrictSide): + return side + return _StrictSide(side.name, side.musts, side.flexes, side.tolQ, + side.tolP) + + +# %% Vertical coupling + +class _Coupling: + """ + Vertical coupling of the residual problem of a side at a node. + + Parameters + ---------- + side : _Side + The side. + a, b : sequence[float] + Must and flex frontiers (parent positions) of the node. + d : _Residual, optional + ``side.analyse(a, b)``, if already computed. + + Attributes + ---------- + X : float + Must heat left, ``sum_i (Qm_i - a_i)``. + ulp : float + Round-off of heat, ``_SPLIT_ULP * max(X, 1)``. + t : numpy.ndarray + Breakpoints ``t[0] = 0 < ... < t[K] = X`` (composite heat). + Pm, Pf : numpy.ndarray + Must and flex positions at every breakpoint, shape ``(K + 1, M)`` + and ``(K + 1, F)``: non-decreasing, ``Pm[0] = a``, ``Pf[0] = b`` and + ``Pm[K] = Qm`` exactly. + K : int + Number of intervals (0 when ``X = 0``). + slack : float + Slack of (R) at the node. + folded : int + Number of breakpoints removed because no must position changes in + floating point over their interval. + + Notes + ----- + Let k be the last level with ``De[k] <= t``. On the flat part + (``t < Di[k]``), ``P_i(t) = a_i + Rme[i, k] + theta (Rmi[i, k] - + Rme[i, k])`` with ``theta = (t - De[k]) / (Di[k] - De[k])``: a flat's + heat is shared in proportion. On the sloped part, ``P_i(t) = a_i + + Rmi[i, k] + theta (Rme[i, k + 1] - Rmi[i, k])`` with ``theta = (t - + Di[k]) / (De[k + 1] - Di[k])``; past the last level it is the whole + residual. Both parts give the residual arrays exactly at their ends + (``theta = 0``). Flexes use the same formulas with ``S, Rfi, Rfe, b``. + The breakpoints are ``0``, ``X`` and every value of ``De, Di, Se, Si`` + clipped to ``[0, X]``; one is dropped iff it is within `ulp` of the + previous kept one. Positions are made monotone along ``t``, clipped to + the curve ends and snapped to them within `ulp`. + """ + __slots__ = ('side', 'd', 'a', 'b', 'X', 'ulp', 't', 'Pm', 'Pf', 'K', + 'slack', 'folded') + + def __init__(self, side, a, b, d=None): + if d is None: + d = side.analyse(a, b) + self.side, self.d = side, d + self.a = np.array(a, float).reshape(side.M) + self.b = np.array(b, float).reshape(side.F) + self.slack = d.slack + self.X = X = math.fsum(q - x for q, x in zip(side.Qm, a)) + self.ulp = ulp = _SPLIT_ULP * max(X, 1.) + self.folded = 0 + if not X > 0.: + self.t = np.zeros(1) + self.Pm, self.Pf = self.a[None, :].copy(), self.b[None, :].copy() + self.K = 0 + return + raw = np.unique(np.clip(np.concatenate( + ([0., X], d.De, d.Di, d.Se, d.Si)), 0., X)) + ts = [0.] + for v in raw.tolist(): + if v - ts[-1] > ulp: + ts.append(v) + if ts[-1] != X: + if len(ts) > 1 and X - ts[-1] <= ulp: + ts[-1] = X + else: + ts.append(X) + t = np.array(ts) + Pm = np.maximum.accumulate(self._musts(t), axis=0) + Pf = np.maximum.accumulate(self._flexes(t), axis=0) + Pm[-1] = side.Qm + keep = [0] + last = t.size - 1 + for k in range(1, last + 1): + if (Pm[k] == Pm[keep[-1]]).all(): # no must heat in between + if k < last: + continue # fold into the next one + if len(keep) > 1: + keep[-1] = k # fold into the previous + continue + keep.append(k) + self.folded = last + 1 - len(keep) + self.t, self.Pm, self.Pf = t[keep], Pm[keep], Pf[keep] + self.K = len(keep) - 1 + + @staticmethod + def _at(ts, Ci, Ce, Ri, Re, front, Q, ulp): + """Positions at composite heats `ts` (closed form, snapped).""" + out = np.repeat(front[None, :], ts.size, axis=0) + nL = Ci.size + if nL and front.size: + k = np.clip(np.searchsorted(Ce, ts, side='right') - 1, 0, nL - 1) + k1 = np.minimum(k + 1, nL - 1) + flat = ts < Ci[k] + slope = ~flat & (k < nL - 1) + den = np.where(flat, Ci[k] - Ce[k], 1.) + th = np.clip(np.where(flat, (ts - Ce[k]) / den, 0.), 0., 1.) + den = np.where(slope, Ce[k1] - Ci[k], 1.) + th2 = np.clip(np.where(slope, (ts - Ci[k]) / den, 0.), 0., 1.) + h = np.where(flat, Re[:, k] + th * (Ri[:, k] - Re[:, k]), + Ri[:, k] + th2 * (Re[:, k1] - Ri[:, k])) + out = out + h.T + Q = np.asarray(Q, float) + return np.where(out >= Q - ulp, Q, out) + + def _musts(self, ts): + d = self.d + return self._at(ts, d.Di, d.De, d.Rmi, d.Rme, self.a, self.side.Qm, + self.ulp) + + def _flexes(self, ts): + d = self.d + return self._at(ts, d.Si, d.Se, d.Rfi, d.Rfe, self.b, self.side.Qf, + self.ulp) + + def must_at(self, t): + """Must positions (list) at composite heat `t` in ``[0, X]``, by the + closed form; ``Qm`` exactly at ``t >= X``.""" + if t >= self.X: + return [float(q) for q in self.side.Qm] + if t <= 0.: + return self.a.tolist() + return self._musts(np.array([float(t)]))[0].tolist() + + def flex_at(self, t): + """Flex positions (list) at composite heat `t` in ``[0, X]``, by the + closed form.""" + if t <= 0.: + return self.b.tolist() + return self._flexes(np.array([float(min(t, self.X))]))[0].tolist() + + def insert(self, t): + """Insert the breakpoint `t`, strictly between two breakpoints, with + its closed-form positions (kept monotone); returns its index.""" + k = int(np.searchsorted(self.t, t)) + if not (0 < k <= self.K and self.t[k - 1] < t < self.t[k]): + raise ValueError(f'{t!r} is not strictly inside an interval') + ts = np.array([float(t)]) + pm = np.clip(self._musts(ts)[0], self.Pm[k - 1], self.Pm[k]) + pf = np.clip(self._flexes(ts)[0], self.Pf[k - 1], self.Pf[k]) + self.t = np.insert(self.t, k, float(t)) + self.Pm = np.insert(self.Pm, k, pm, axis=0) + self.Pf = np.insert(self.Pf, k, pf, axis=0) + self.K += 1 + return k + + +# %% Transport + +def _transport(h, g, comp, prefer=(), forbid=frozenset(), tol=0.): + """ + Transport of the must heats `h` to the flexes `g` on compatible pairs. + + Parameters + ---------- + h : dict[int, float] + Must heats (row sums), in north-west order (by frontier level). + Musts with no heat are ignored. + g : dict[int, float] + Flex heats (column capacities), in north-west order. Flexes with no + heat are ignored. + comp : collection[tuple[int, int]] + Compatible ``(must, flex)`` pairs. + prefer : sequence[tuple[int, int]], optional + Pairs tried first, in order (continuations of the previous block). + forbid : collection[tuple[int, int]], optional + Pairs never used. + tol : float, optional + Must heat that may be left unplaced by the round-off imbalance of + `h` and `g`; it is added to the row's largest cell. + + Returns + ------- + dict[tuple[int, int], float] or None + Positive duties on allowed pairs. Every row sums to its heat up to + float summation, columns stay within their heats plus `tol`, and + the cells form a forest (at most ``m + f - 1`` of them). None if no + such transport exists. + + Notes + ----- + A greedy pass assigns ``min`` of the remaining row and column heats to + the pairs in order (`prefer`, then north-west). Every assignment + exhausts its row or its column, so its cells never form a cycle. If it + leaves more than `tol` of some row unplaced, an Edmonds-Karp maximum + flow on the compatibility graph decides; its cycles are then cancelled. + Finally, the largest cell of each row takes the row's heat minus the + others. + """ + rows = [i for i, v in h.items() if v > 0.] + cols = [j for j, v in g.items() if v > 0.] + rset, cset = set(rows), set(cols) + order, seen = [], set() + for c in (*prefer, *[(i, j) for i in rows for j in cols]): + if (c not in seen and c[0] in rset and c[1] in cset and c in comp + and c not in forbid): + seen.add(c) + order.append(c) + rh, rg = {i: h[i] for i in rows}, {j: g[j] for j in cols} + q = {} + for i, j in order: + x = min(rh[i], rg[j]) + if x > 0.: + q[i, j] = x + rh[i] -= x + rg[j] -= x + if any(v > tol for v in rh.values()): + q = _max_flow(h, g, rows, cols, order) + placed = dict.fromkeys(rows, 0.) + for (i, j), x in q.items(): + placed[i] += x + if any(h[i] - placed[i] > tol for i in rows): + return None + q = _forest(q, order) + for i in rows: + cells = [c for c in order if c[0] == i and c in q] + if not cells: # round-off heat only, on exhausted columns + cells = [c for c in order if c[0] == i][:1] + if not cells: + return None + q[cells[0]] = h[i] + continue + big = max(cells, key=q.__getitem__) + x = h[i] - math.fsum(q[c] for c in cells if c != big) + if not x > 0.: + return None + q[big] = x + return {c: q[c] for c in order if c in q} + + +def _max_flow(h, g, rows, cols, order): + """Edmonds-Karp maximum flow from the musts (capacities `h`) to the + flexes (capacities `g`) over the pairs in `order` (unbounded); returns + the positive pair flows. Adjacency lists keep `order`, so the result is + deterministic.""" + m = len(rows) + ri = {i: 1 + k for k, i in enumerate(rows)} + cj = {j: 1 + m + k for k, j in enumerate(cols)} + src, snk = 0, 1 + m + len(cols) + adj = [[] for _ in range(snk + 1)] + cap = {} + + def add(u, v, c): + if (u, v) not in cap: + cap[u, v] = 0. + cap.setdefault((v, u), 0.) + adj[u].append(v) + adj[v].append(u) + cap[u, v] += c + for i in rows: + add(src, ri[i], h[i]) + for i, j in order: + add(ri[i], cj[j], math.inf) + for j in cols: + add(cj[j], snk, g[j]) + while True: + prev = {src: None} + queue = deque([src]) + while queue and snk not in prev: + u = queue.popleft() + for v in adj[u]: + if v not in prev and cap[u, v] > 0.: + prev[v] = u + queue.append(v) + if snk not in prev: + break + aug, v = math.inf, snk + while prev[v] is not None: + aug = min(aug, cap[prev[v], v]) + v = prev[v] + v = snk + while prev[v] is not None: + u = prev[v] + cap[u, v] -= aug # the bottleneck edge becomes exactly 0 + cap[v, u] += aug + v = u + q = {} + for i, j in order: + x = cap[cj[j], ri[i]] + if x > 0.: + q[i, j] = x + return q + + +def _forest(q, order): + """Cancel the cycles of the support of `q` (bipartite: musts and + flexes) keeping every row and column sum; returns a forest. + + Cells are added in `order`. A cell closing a cycle with the forest built + so far gets alternating signs around the cycle and moves the smaller of + the two minima, which empties one cell (the new one on a tie).""" + q = {c: q[c] for c in order if c in q} + tree = {} # node -> {neighbour: cell} + + def path(u, v): + prev = {u: None} + queue = deque([u]) + while queue: + w = queue.popleft() + if w == v: + break + for z, c in tree.get(w, {}).items(): + if z not in prev: + prev[z] = (w, c) + queue.append(z) + if v not in prev: + return None + cells = [] + while prev[v] is not None: + v, c = prev[v] + cells.append(c) + return cells[::-1] + + def link(c, on): + u, v = ('m', c[0]), ('f', c[1]) + if on: + tree.setdefault(u, {})[v] = c + tree.setdefault(v, {})[u] = c + else: + del tree[u][v], tree[v][u] + for c in list(q): + while c in q: + cyc = path(('f', c[1]), ('m', c[0])) + if cyc is None: + link(c, True) + break + odd, even = cyc[0::2], [c, *cyc[1::2]] + da, db = min(q[e] for e in odd), min(q[e] for e in even) + plus, minus, dx = (odd, even, db) if db <= da else (even, odd, da) + for e in plus: + q[e] += dx + for e in minus: + q[e] -= dx + if not q[e] > 0.: + del q[e] + if e != c: + link(e, False) + return q + + +# %% Vertical core + +class _SplitInvariantError(Exception): + """ + A core invariant failed: a node violates (R) beyond round-off, or a + vertical block cannot be completed within round-off. The strategy fails + and the other candidates remain. It is raised, never asserted, so that + the driver's catch sees it and ``python -O`` cannot strip it. + """ + + +class _ExactSide(_Side): + """A `_Side` whose residual arrays are exact to round-off (`_exact`).""" + + @staticmethod + def _R(pieces, n, levels): + """Inclusive (level <= L) and exclusive (level < L) residual heat of + every stream at every level, piece by piece: a sloped piece adds + ``clip((L - lo)/(hi - lo), 0, 1) ln``, a flat one ``ln`` from its + level on (inclusive) or above it (exclusive). Each share lies in + ``[0, ln]``, so a stream's residual never exceeds its heat.""" + own, lo, hi, ln = pieces + nL = levels.size + Ri, Re = np.zeros((n, nL)), np.zeros((n, nL)) + if own.size == 0 or nL == 0: + return Ri, Re + w = hi - lo + flat = w <= 0. + with np.errstate(divide='ignore', invalid='ignore'): + fi = np.clip((levels - lo[:, None]) / w[:, None], 0., 1.) + fe = fi.copy() + fi[flat] = levels >= lo[flat, None] + fe[flat] = levels > lo[flat, None] + np.add.at(Ri, own, fi * ln[:, None]) + np.add.at(Re, own, fe * ln[:, None]) + return Ri, Re + + +def _exact(side): + """ + `side` with no heat tolerance in its residual arrays, which are exact + to round-off. + + `_Side.analyse` omits every stream with at most ``tolQ`` of residual + heat: the search's tolerance. At a core node that would drop a stream's + last sliver of heat (up to ``tolQ``, far more than round-off), move the + coupling's breakpoints and misreport the slack of (R) by that much, so + the core analyses its nodes exactly. + + The search's residual arrays of a stream with several pieces are + cumulative sums of the pieces' slopes and offsets, evaluated as + ``slope sum * (L - offset sum)``. On a near-flat piece (a glide of + 1e-7 K over a heat of 10 has a slope of 1e8) those sums cancel + catastrophically and the residual drifts with the level, beyond the + stream's own heat. The core's side (`_ExactSide`) adds each piece's + clipped share directly instead. The search keeps its arrays, so the + unsplit planner is unchanged. + """ + return _ExactSide(side.name, side.musts, side.flexes, 0., side.tolP) + + +def _preleak_max(side): + """Largest root deficit the core absorbs by a pre-leak: the cascade's + own tolerances (threshold ``_THRESHOLD_TOL`` of the scale, and 0.1 tolQ + between near-equal minima) plus round-off, ``_SPLIT_R_TOL tolQ``.""" + return (_THRESHOLD_TOL / _REL_Q + 0.1 + _SPLIT_R_TOL) * side.tolQ + + +def _preleak_root(side, d=None): + """ + The pre-leaked root of a side (Lemma P). + + Parameters + ---------- + side : _Side + The side. + d : _Residual, optional + The exact analysis of the root, ``_exact(side).analyse(0, 0)``. + + Returns + ------- + delta : float + ``max(0, -slack)`` at the root: the must heat the core leaves to + the musts' utilities (compare with `_preleak_max`). + a0 : list[float] + The must positions ``P(delta)`` of the vertical coupling at the + root: the lowest `delta` of the must composite (zeros if + ``delta = 0``). + """ + z_m, z_f = [0.] * side.M, [0.] * side.F + if d is None: + d = _exact(side).analyse(z_m, z_f) + delta = max(0., -d.slack) + if not delta > 0.: + return 0., z_m + return delta, _Coupling(side, z_m, z_f, d).must_at(delta) + + +class _Block: + """ + One block of a core candidate. + + Parameters + ---------- + kind : {'vertical', 'pinch', 'tail'} + How the block was built (`_vertical_block`, `_pinch_block`, + `_tail`). + start, end : tuple[list[float], list[float]] + The nodes ``(a, b)`` before and after the block; `end` holds the + coupling's closed-form positions (never start plus sums). + cells : list[_Cell] + The block's cells. + cp : _Coupling, optional + The coupling of a vertical block; the next vertical block continues + on it from breakpoint `k`. + k : int, optional + Breakpoint of `cp` where the block ends. + span : int, optional + Number of coupling intervals the block covers (1: elementary). + leak : dict[int, float], optional + Must heat left to the must's utility (position round-off). + knots : int, optional + Hidden breakpoints inserted to complete the block. + work : float, optional + Work spent on the block. + """ + __slots__ = ('kind', 'start', 'end', 'cells', 'pairs', 'cp', 'k', 'span', + 'leak', 'knots', 'work') + + def __init__(self, kind, start, end, cells, cp=None, k=0, span=1, + leak=None, knots=0, work=0.): + self.kind, self.start, self.end = kind, start, end + self.cells = cells + self.pairs = frozenset((c.i, c.j) for c in cells) + self.cp, self.k, self.span = cp, k, span + self.leak = {} if leak is None else leak + self.knots, self.work = knots, work + + +class _Vertical: + """Vertical blocks on one coupling (see `_vertical_block`).""" + + def __init__(self, side, cp, prev, forbid, used): + self.side, self.cp, self.prev = side, cp, prev + # continuations: the previous block's series cells, which merge + self.conts = ([(c.i, c.j) for c in prev.cells + if c.f == 1. and c.g == 1.] if prev is not None else []) + self.used = frozenset(used) + self.forbid = frozenset(forbid) | (self.used - frozenset(self.conts)) + self.tol = max(_SPLIT_R_TOL * side.tolQ, + 4. * (side.M + side.F) * cp.ulp) + self.work = 0. + self.knots = 0 + + def _margin(self, cell): + self.work += _WORK_EVENT + return _cell_margin(self.side, cell)[0] + + def cells(self, ks, ke, coarse): + """ + Cells of the block over breakpoints ``[ks, ke]``. + + Returns ``(cells, bad)`` (`bad`: the cells failing (C)), or None if + forbidden or used pairs leave no transport or, for a `coarse` block, + if no transport exists on the compatible pairs, the block does not + verify or it has a fraction below `_SPLIT_MIN_FRACTION`. An + elementary block with no transport on all pairs contradicts Theorem + V' and raises `_SplitInvariantError`. + """ + side, cp = self.side, self.cp + tolP = side.tolP + am, bf = cp.Pm[ks], cp.Pf[ks] + hm, gf = cp.Pm[ke] - am, cp.Pf[ke] - bf + musts, flexes = side.musts, side.flexes + rows = sorted((i for i in range(side.M) if hm[i] > 0.), + key=lambda i: (musts[i].at(am[i]), i)) + cols = sorted((j for j in range(side.F) if gf[j] > cp.ulp), + key=lambda j: (flexes[j].at(bf[j]), j)) + if rows and not cols and side.F: + # a sliver: the flex heat, equal to the must heat up to + # round-off, is shared by flexes with at most `ulp` each. It + # is round-off per flex, not in total, so the largest flex + # carries it (and the balance below) + cols = [int(np.argmax(gf))] + h = {i: float(hm[i]) for i in rows} + g = {j: float(gf[j]) for j in cols} + # the must heats are exact; round-off of the flex heats is balanced + # on the largest flex + gap = math.fsum(h.values()) - math.fsum(g.values()) + if gap > self.tol: + if coarse: + return None + raise _SplitInvariantError( + f'vertical block: the flexes lack {gap!r} of heat') + if gap > 0. and g: + j = max(g, key=g.get) + g[j] += gap + a = {i: float(am[i]) for i in rows} + b = {j: float(bf[j]) for j in cols} + if coarse: # Corollary C: normalized profiles, independent of duty + comp = {(i, j) for i in rows for j in cols + if (i, j) not in self.forbid and self._margin(_Cell( + i, j, h[i], a[i], b[j], 1., h[i] / g[j])) >= -tolP} + else: # Theorem V': every pair + comp = {(i, j) for i in rows for j in cols} + forbid = self.forbid + while True: + q = _transport(h, g, comp, self.conts, forbid, self.tol) + if q is None: + if coarse or forbid and _transport( + h, g, comp, self.conts, tol=self.tol) is not None: + return None # forbidden or used pairs removed it + # Theorem V': on all pairs, a transport always exists + raise _SplitInvariantError( + f'vertical block: no transport over breakpoints ' + f'[{ks}, {ke}] (must heats {h!r}, flex heats {g!r})') + nm = Counter(i for i, _ in q) + nf = Counter(j for _, j in q) + # avoid_recycle: a used pair returns only as a series cell + again = {c for c in q if c in self.used + and (nm[c[0]] > 1 or nf[c[1]] > 1)} + if not again: + break + forbid = forbid | again + col = defaultdict(list) + for (i, j), x in q.items(): + col[j].append(x) + col = {j: math.fsum(xs) for j, xs in col.items()} + cells = [_Cell(i, j, x, a[i], b[j], + 1. if nm[i] == 1 else x / h[i], + 1. if nf[j] == 1 else x / col[j]) + for (i, j), x in q.items()] + bad = [c for c in cells if not self._margin(c) >= -tolP] + if coarse and (bad or any(min(c.f, c.g) < _SPLIT_MIN_FRACTION + for c in cells)): + return None + return cells, bad + + def coarse(self, ks): + """The longest verified block from `ks` the search finds: ``t_e = + X`` first, then galloping (2, 4, 8, ... intervals) and bisection + between the last success and the first failure. Returns ``(cells, + ke)`` or None.""" + K = self.cp.K + out = self.cells(ks, K, True) + if out is not None: + return out[0], K + good, lo, hi, step = None, ks + 1, K, 2 + while ks + step < hi: + out = self.cells(ks, ks + step, True) + if out is None: + hi = ks + step + break + good, lo = (out[0], ks + step), ks + step + step *= 2 + while hi - lo > 1: + mid = (lo + hi) // 2 + out = self.cells(ks, mid, True) + if out is None: + hi = mid + else: + good, lo = (out[0], mid), mid + return good + + def elementary(self, ks): + """The elementary block ``[ks, ks + 1]`` with the recovery of a + failing cell: ``(cells, leak)``, or None if forbidden or used pairs + leave no transport.""" + while True: + out = self.cells(ks, ks + 1, False) + if out is None: + return None + cells, bad = out + if not bad: + return cells, {} + t = self._hidden(ks) + if t is None: + return self._round_off(ks, cells, bad) + self.cp.insert(t) + self.knots += 1 + + def _hidden(self, ks): + """The first composite breakpoint hidden strictly inside the + interval ``ks`` with must heat on both sides of it, or None.""" + cp, d = self.cp, self.cp.d + t0, t1 = cp.t[ks], cp.t[ks + 1] + v = np.concatenate((d.De, d.Di, d.Se, d.Si)) + for t in np.unique(v[(v > t0) & (v < t1)]).tolist(): + pm = cp._musts(np.array([t]))[0] + if (pm > cp.Pm[ks]).any() and (pm < cp.Pm[ks + 1]).any(): + return t + return None + + def _round_off(self, ks, cells, bad): + """Position round-off: the must heat of the failing cells goes to the + must's series cell of the previous block (if that re-verifies), else + to the must's utility (a leak, at most ``_SPLIT_R_TOL tolQ``); + anything larger raises.""" + side, cp = self.side, self.cp + drop = {id(c) for c in bad} + keep = [c for c in cells if id(c) not in drop] + lost = defaultdict(list) + for c in bad: + lost[c.i].append(c) + leak = {} + for i, cs in lost.items(): + dl = math.fsum(c.x for c in cs) + if self._attach(ks, i, cs, dl, keep): + continue + if not dl <= _SPLIT_R_TOL * side.tolQ: + raise _SplitInvariantError( + f'vertical block: must {i} fails (C) on {dl!r} of heat') + leak[i] = dl + rows, cols = defaultdict(list), defaultdict(list) + for c in keep: + rows[c.i].append(c) + cols[c.j].append(c) + for i, cs in rows.items(): + if i in lost: # the kept branches stop dl short of P_i(t_s) + dl = math.fsum(c.x for c in lost[i]) + hi = math.fsum(c.x for c in cs) + for c in cs: + c.a = float(cp.Pm[ks, i]) + dl + c.f = 1. if len(cs) == 1 else c.x / hi + for j, cs in cols.items(): + s = math.fsum(c.x for c in cs) + for c in cs: + c.g = 1. if len(cs) == 1 else c.x / s + for c in keep: + if not self._margin(c) >= -side.tolP: + raise _SplitInvariantError( + f'vertical block: {c!r} fails (C) after a leak') + return keep, leak + + def _attach(self, ks, i, cs, dl, keep): + """Attach must `i`'s failing heat `dl` (all of its cells here, on one + flex that keeps no other cell here) to its series cell of the + previous block, contiguous on both streams, if that re-verifies.""" + p, cp = self.prev, self.cp + js = {c.j for c in cs} + if p is None or len(js) != 1 or any(c.i == i or c.j in js + for c in keep): + return False + mine = [c for c in p.cells if c.i == i] + if len(mine) != 1: + return False + pc = mine[0] + if not (pc.j in js and pc.f == 1. and pc.g == 1. + and abs(pc.a_end - cp.Pm[ks, i]) <= cp.ulp + and abs(pc.b_end - cp.Pf[ks, pc.j]) <= self.tol): + return False + x = pc.x + pc.x = x + dl + if self._margin(pc) >= -self.side.tolP: + return True + pc.x = x + return False + + +def _vertical_block(side, a, b, prev=(), forbid=frozenset(), used=frozenset(), + d=None): + """ + The next vertical block from node ``(a, b)`` (Theorem V', Corollary C). + + Parameters + ---------- + side : _Side + The side. + a, b : sequence[float] + The node; it satisfies (R). + prev : sequence[_Block], optional + The previous block, if any (a list of at most one). A vertical block + ending at ``(a, b)`` hands over its coupling, so a chain of vertical + blocks runs on the breakpoints of one coupling; its series cells are + preferred (continuations). + forbid : collection[tuple[int, int]], optional + Pairs never used. + used : collection[tuple[int, int]], optional + Pairs of earlier blocks (avoid_recycle): used again only as series + continuations of the previous block. + d : _Residual, optional + ``_exact(side).analyse(a, b)``, if already computed. + + Returns + ------- + _Block or None + None only if forbidden or used pairs leave no transport. + + Raises + ------ + _SplitInvariantError + If the elementary block has no transport on all pairs or cannot be + completed within round-off, or the block would make no progress. + + Notes + ----- + With `_SPLIT_COARSEN`, the block ending at ``X`` is tried first, then + a gallop and bisection over the breakpoints. Feasibility is not + monotone in the end, so this is a search: every coarsened block is + verified cell by cell and needs every fraction >= `_SPLIT_MIN_FRACTION`. + The elementary block needs no search. If one of its cells fails (C), a + composite breakpoint hidden inside the interval is inserted and the + block rebuilt; otherwise the failure is position round-off, handled by + `_Vertical._round_off`. + """ + a = [float(x) for x in a] + b = [float(x) for x in b] + p = prev[-1] if len(prev) else None + if (p is not None and p.cp is not None and p.k < p.cp.K + and p.end[0] == a and p.end[1] == b): + cp, ks = p.cp, p.k + else: + if d is None: + d = _exact(side).analyse(a, b) + cp, ks = _Coupling(side, a, b, d), 0 + if ks >= cp.K: + raise _SplitInvariantError('vertical block: no must heat at the node') + v = _Vertical(side, cp, p, forbid, used) + out = v.coarse(ks) if _SPLIT_COARSEN and cp.K - ks > 1 else None + if out is not None: + (cells, ke), leak = out, {} + else: + out = v.elementary(ks) + if out is None: + return None + (cells, leak), ke = out, ks + 1 + if not leak and not (cp.Pm[ke] > cp.Pm[ks]).any(): + raise _SplitInvariantError('vertical block: no progress') + return _Block('vertical', (a, b), (cp.Pm[ke].tolist(), cp.Pf[ke].tolist()), + cells, cp, ke, ke - ks, leak, v.knots, v.work) + + +def _pinch_block(side, a, b, forbid=frozenset(), used=frozenset(), + viol=None): + """ + A pinch block from node ``(a, b)`` (strategy 'L', see 'The core' in + the module docstring): Linnhoff and Hindmarsh's pinch split at an + outward violation of the rules, as one block of the core. + + Parameters + ---------- + side : _Side + The side. + a, b : sequence[float] + The node; it satisfies (R). + forbid : collection[tuple[int, int]], optional + Pairs never used. + used : collection[tuple[int, int]], optional + Pairs of earlier blocks (avoid_recycle), not used again. + viol : dict, optional + ``side.rules_violation`` at the node (on the search's analysis), if + already computed. + + Returns + ------- + _Block or None + None if the rules do not fail outward at the node, a demand at the + cut is flat, no transport serves the cut with every pair allowed + and every demand moving by more than ``tolQ``, or no lambda of at + least `_SPLIT_LAMBDA_MIN` is accepted. + + Notes + ----- + At the violating cut (`_cut_sets`) the CP transport is 'mincell', else + 'nw-rho-desc' (`_cut_transport`). A cell ``(i, j, load)`` is a branch + of must ``i`` of fraction ``f = load / m_i`` facing a branch of flex + ``j`` of fraction ``g = load / sum load_j`` (`_cut_fractions`), so the + flex branch's CP is at least the must branch's. Every cell starts at + the node. Must ``i`` advances by one extent ``h_i`` on all of its + branches, so its remix is isothermal: the smallest over its cells of + ``x_max / f``, with ``x_max`` the planner's `_max_duty` on the + branch-scaled curves (Lemma B), capped by the must's residual and the + flex branch's room ``g (Qf_j - b_j)``. A must within ``tolQ`` of its + end ticks off: ``h_i`` is its residual exactly, its last cell takes + the residual less its other cells, and its node is its end. The flex + branches end at different positions: a non-isothermal remix, which + the key counts if the flex has a later exchanger. + + The block is accepted at lambda = 1, else at the largest lambda found + by `_SPLIT_BISECT` bisection steps, with the cells scaled to ``lambda + f h_i``, if every cell passes (C) (`_cell_margin`) and the end node, + in parent coordinates (a split leaves the composites unchanged, Lemma + R), satisfies (R) within ``_SPLIT_R_TOL tolQ`` by an exact analysis + (`_exact`, as the driver checks its nodes). Every accepted lambda is + checked, so nothing depends on monotonicity in lambda. + """ + a = [float(x) for x in a] + b = [float(x) for x in b] + if viol is None: + viol = side.rules_violation(a, b, side.analyse(a, b)) + if viol is None or viol['rule'] != 'outward': + return None + sets = _cut_sets(side, a, b, viol['level'], viol['cut'], 'outward') + if sets is None: + return None + tolQ, tolP = side.tolQ, side.tolP + Qm, Qf = side.Qm, side.Qf + ex = _exact(side) + lim = -_SPLIT_R_TOL * tolQ + no = frozenset(forbid) | frozenset(used) + work = 0. + + def extents(rows): + # h_i (tick-offs exact) or None if a demand cannot move + nonlocal work + h, tick = {}, {} + for i, cs in rows.items(): + cm, rem = side.musts[i], Qm[i] - a[i] + hi = rem + for j, f, g in cs: + cf = side.flexes[j] + bm = cm if f == 1. else _branch_curve(cm, f, 'must') + bf = cf if g == 1. else _branch_curve(cf, g, 'flex') + work += _WORK_EVENT + x = _max_duty_strict(bm, f * a[i], bf, g * b[j], + min(f * rem, g * (Qf[j] - b[j])), tolP) + hi = min(hi, x / f) + if not hi > tolQ: + return None + tick[i] = hi >= rem - tolQ + h[i] = rem if tick[i] else hi + return h, tick + + def at(rows, h, tick, lam): + # the cells and end node at `lam`, or None if not accepted + nonlocal work + cells, a2, b2 = [], list(a), list(b) + for i, cs in rows.items(): + if lam == 1. and tick[i]: + xs = [f * h[i] for _, f, _ in cs] + xs[-1] = h[i] - math.fsum(xs[:-1]) + a2[i] = Qm[i] + else: + t = lam * h[i] + xs = [f * t for _, f, _ in cs] + a2[i] = min(a[i] + t, Qm[i]) + cells += [_Cell(i, j, x, a[i], b[j], f, g) + for (j, f, g), x in zip(cs, xs)] + for j in {c.j for c in cells}: + b2[j] = min(b[j] + math.fsum(c.x for c in cells if c.j == j), + Qf[j]) + work += 1. + _WORK_EVENT * len(cells) + if not all(c.x > 0. and _cell_margin(side, c)[0] >= -tolP + for c in cells): + return None + if not ex.analyse(a2, b2).slack >= lim: + return None + return cells, a2, b2 + tried = [] + for rule in ('mincell', 'nw-rho-desc'): + loads = _cut_transport(*sets, rule) + if not loads or loads in tried: + continue + tried.append(loads) + if any((i, j) in no for i, j, _ in loads): + continue + rows = defaultdict(list) # must -> [(flex, f, g)], in order + for (i, j, _), (f, g) in zip(loads, _cut_fractions(loads)): + rows[i].append((j, f, g)) + ext = extents(rows) + if ext is None: + continue + res = at(rows, *ext, 1.) + if res is None: + lo, hi = 0., 1. + for _ in range(_SPLIT_BISECT): + mid = 0.5 * (lo + hi) + r = at(rows, *ext, mid) + if r is None: + hi = mid + else: + lo, res = mid, r + if res is None or lo < _SPLIT_LAMBDA_MIN: + continue + cells, a2, b2 = res + return _Block('pinch', (a, b), (a2, b2), cells, work=work) + return None + + +def _tail(side, a, b, cap1=False, forbid=frozenset(), work_scale=1.): + """ + A DFS tail from core node ``(a, b)`` (strategy 'T', see 'The core' in + the module docstring): the planner's unsplit search completes the side + from there, with the strict max duty of Lemma 1 (`_StrictSide`). + + Parameters + ---------- + side : _Side + The side. + a, b : sequence[float] + The node: it satisfies (R) and the pinch rules. + cap1 : bool, optional + avoid_recycle: every pair in at most one exchanger. + forbid : collection[tuple[int, int]], optional + Pairs never used (with `cap1`, also those of the earlier blocks). + work_scale : float, optional + Scale of the search budgets. + + Returns + ------- + block : _Block or None + The tail as the candidate's last block (kind 'tail', unsplit cells, + ending with every must at its end), or None. + work : float + Work spent. + + Notes + ----- + As `_plan_side`, but from ``(a, b)`` and at `_SPLIT_TAIL_WORK` of the + budgets: the first `_SPLIT_PASSES` passes of `_SCHEDULE`, then + `_improve_units` and `_units_guard` on the first success. The DFS + completes a must within ``tolQ``; its residual is swept into its + far-end cell as in Stage S (`_s_cells`, with every item a trunk), so + every must is served exactly, or the tail is rejected. A tail returns + a complete plan or nothing, never a node. + """ + a, b = [float(x) for x in a], [float(x) for x in b] + forbid = frozenset(forbid) + scale = _SPLIT_TAIL_WORK * work_scale + work, seen, pieces = 0., set(), None + ss = _strict(side) + for mode, cap, extra, budget in _SCHEDULE[:_SPLIT_PASSES]: + cap = _combine_cap(cap, cap1) + if (mode, cap, extra) in seen: + continue + seen.add((mode, cap, extra)) + srch = _Search(ss, mode, cap, extra, budget * scale, forbid=forbid, + a0=a, b0=b) + pieces = srch.run() + work += srch.work + if pieces is not None: + break + if pieces is None: + return None, work + pieces, w = _improve_units(ss, pieces, work, cap1, forbid, scale, + a0=a, b0=b) + work += w + pieces, w = _units_guard(ss, pieces, cap1, forbid, scale, a0=a, b0=b) + work += w + items = ([('must', i, 1., None) for i in range(side.M)] + + [('flex', j, 1., None) for j in range(side.F)]) + cells, _ = _s_cells(side, items, a, pieces) + if cells is None: + return None, work + b2 = [min(b[j] + math.fsum(c.x for c in cells if c.j == j), side.Qf[j]) + for j in range(side.F)] + return _Block('tail', (a, b), (list(side.Qm), b2), cells, + work=work), work + + +def _key_block(blk, n): + """Stage keys ``('B', n, stream, branch)`` of the streams split in core + block `n` (a stream with one cell in the block is a trunk: None).""" + nm = Counter(c.i for c in blk.cells) + nf = Counter(c.j for c in blk.cells) + bm, bf = Counter(), Counter() + for c in blk.cells: + if nm[c.i] > 1: + c.km = ('B', n, c.i, bm[c.i]) + bm[c.i] += 1 + if nf[c.j] > 1: + c.kf = ('B', n, c.j, bf[c.j]) + bf[c.j] += 1 + + +def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., + Qmin=0.): + """ + One core candidate from the pre-leaked root. + + Parameters + ---------- + side : _Side + The side. + a0 : sequence[float] + The pre-leaked root (`_preleak_root`); the flexes start at 0. + strategy : str + One of `_CORE_STRATEGIES`. + cap1 : bool, optional + avoid_recycle: every pair in at most one exchanger. + forbid : collection[tuple[int, int]], optional + Pairs never used. + work_scale : float, optional + Scale of the search budgets. + Qmin : float, optional + Exchangers below this duty count as small in the key. + + Returns + ------- + _Candidate or None + None only if forbidden or used pairs leave no block. + + Raises + ------ + _SplitInvariantError + If a node violates (R) by more than ``_SPLIT_R_TOL tolQ`` (never an + assert) or a block cannot be completed. + + Notes + ----- + 'V' is the chain of vertical blocks. Each node is the closed-form end + of the previous block, so (R) is inherited (Theorem V', Corollary C) + and round-off does not accumulate; it is checked anyway, exactly. + + 'L' adds pinch blocks (`_pinch_block`) at nodes where the pinch rules + fail outward, at most ``M + F`` of them; a vertical block serves every + node without one. A pinch block ends at a node accepted by the same + exact check. 'T' tries a DFS tail (`_tail`) at nodes where the rules + hold, other than the unleaked root (where the unsplit search already + failed; a pre-leaked root qualifies), at most `_SPLIT_TAIL_TRIES` + times; a tail that succeeds completes the candidate as its last block. + The rules are checked on the search's analysis, as the DFS sees them. + """ + if strategy not in _CORE_STRATEGIES: + raise ValueError(f'core strategy {strategy!r} is not available') + ex = _exact(side) + lim = -_SPLIT_R_TOL * side.tolQ + a, b = [float(x) for x in a0], [0.] * side.F + blocks, used, leak = [], set(), [0.] * side.M + pinch, tail = 'L' in strategy, 'T' in strategy + n_pinch = tries = 0 + lost = 0. # work of the failed tails + while any(x < q for x, q in zip(a, side.Qm)): + d = ex.analyse(a, b) + if d.slack < lim: + raise _SplitInvariantError( + f'core node (R): slack {d.slack!r} after ' + f'{len(blocks)} blocks') + viol = (side.rules_violation(a, b, side.analyse(a, b)) + if pinch or tail else None) + if (tail and viol is None and (blocks or any(a0)) + and tries < _SPLIT_TAIL_TRIES): + tries += 1 + blk, w = _tail(side, a, b, cap1, frozenset(forbid) | used, + work_scale) + if blk is not None: + blocks.append(blk) + break + lost += w + blk = None + if (pinch and viol is not None and viol['rule'] == 'outward' + and n_pinch < side.M + side.F): + blk = _pinch_block(side, a, b, forbid, used, viol) + n_pinch += blk is not None + if blk is None: + blk = _vertical_block(side, a, b, blocks[-1:], forbid, used, d) + if blk is None: + return None + _key_block(blk, len(blocks)) + blocks.append(blk) + a, b = blk.end + if cap1: + used |= blk.pairs + for i, x in blk.leak.items(): + leak[i] += x + return _Candidate(strategy, (1, _CORE_ORDER.index(strategy)), side, + [c for blk in blocks for c in blk.cells], leak, + math.fsum(blk.work for blk in blocks) + lost, Qmin, + blocks) + + +# %% Candidates + +def _merge_cells(cells, tolQ): + """ + Exchangers of a list of cells: consecutive cells with the same must, + flex and stage keys, contiguous on both streams (within `tolQ`), merge + into one (the planner's continuation rule). The cells are not modified. + """ + out, last_m, last_f = [], {}, {} + for c in cells: + e = last_m.get((c.i, c.km)) + if (e is not None and e is last_f.get((c.j, c.kf)) + and abs(c.a - e.a_end) <= tolQ and abs(c.b - e.b_end) <= tolQ): + e.x += c.x + continue + e = _Cell(c.i, c.j, c.x, c.a, c.b, c.f, c.g, c.km, c.kf) + out.append(e) + last_m[c.i, c.km] = last_f[c.j, c.kf] = e + return out + + +def _stages(cells): + """Split stages: ``{(role, stream, stage): {branch: [cells]}}`` with + role 'm' (must) or 'f' (flex).""" + out = {} + for c in cells: + if c.km is not None: + out.setdefault(('m', c.i, c.km[:-1]), {}).setdefault( + c.km[-1], []).append(c) + if c.kf is not None: + out.setdefault(('f', c.j, c.kf[:-1]), {}).setdefault( + c.kf[-1], []).append(c) + return out + + +def _remix(role, branches, tolQ): + """Split position, mix position and isothermality of a stage (parent + positions). A must splits at its far end and mixes toward the pinch, a + flex splits at its pinch-side start and mixes at the far end; the mix + is isothermal if every branch ends within ``_ISO_TOL tolQ`` of it.""" + cs = [c for cells in branches.values() for c in cells] + D = math.fsum(c.x for c in cs) + if role == 'm': + p0 = max(c.a_end for c in cs) + pm = p0 - D + ends = [p0 - math.fsum(c.x for c in cells) / cells[0].f + for cells in branches.values()] + else: + p0 = min(c.b for c in cs) + pm = p0 + D + ends = [p0 + math.fsum(c.x for c in cells) / cells[0].g + for cells in branches.values()] + return p0, pm, all(abs(e - pm) <= _ISO_TOL * tolQ for e in ends) + + +def _sig(v): + """`v` rounded to 9 significant digits.""" + return float(f'{v:.9g}') + + +def _signature(side, merged, stages): + """Identity of a split network (see `_Candidate`): its structure, which + no knot refinement changes, and its split fractions to 9 significant + digits, which a refinement moves (compare with `_same_network`).""" + musts, flexes = side.musts, side.flexes + by_stream = defaultdict(list) + for (role, s, sid), br in stages.items(): + cs = [c for cells in br.values() for c in cells] + start = min(c.a if role == 'm' else c.b for c in cs) + by_stream[role, s].append((start, sid)) + ordinal = {} + for (role, s), lst in by_stream.items(): + for n, (_, sid) in enumerate(sorted(lst)): + ordinal[role, s, sid] = n + + def canon(role, item): + # a branch by its partners in position order and its fraction + bk, cs = item + if role == 'm': + return (tuple(flexes[c.j].stream for c in + sorted(cs, key=lambda c: c.a)), _sig(cs[0].f), bk) + return (tuple(musts[c.i].stream for c in + sorted(cs, key=lambda c: c.b)), _sig(cs[0].g), bk) + branch, fracs = {}, [] + for (role, s, sid), br in stages.items(): + ranked = sorted(br.items(), key=lambda item: canon(role, item)) + for n, (bk, _) in enumerate(ranked): + branch[role, s, sid, bk] = n + stream = (musts if role == 'm' else flexes)[s].stream + fracs.append((role, stream, ordinal[role, s, sid], + tuple(canon(role, item)[1] for item in ranked))) + + def path(role, s, key): + if key is None: + return None + sid = key[:-1] + return ordinal[role, s, sid], branch[role, s, sid, key[-1]] + cells = [(musts[c.i].stream, flexes[c.j].stream, path('m', c.i, c.km), + path('f', c.j, c.kf)) for c in merged] + cells.sort(key=lambda r: (r[0], r[1], r[2] or (), r[3] or ())) + return tuple(cells), tuple(sorted(fracs)) + + +def _same_network(a, b): + """ + True if signatures `a` and `b` (`_signature`) are one network: the same + exchangers on the same branches, and every split fraction within + `_SPLIT_SAME_FRACTION`. + + A split's fractions are CP ratios on the knots, so a refine round moves + them (by ~1e-6 on real thermo) while the network stays the same; an + exact comparison would make every refined network new. Distinct + networks of one structure differ by 1.6e-2 to 0.45 in some fraction on + the corpus, and a difference below `_SPLIT_MIN_FRACTION` (the smallest + branch planned) is not a branch of its own. + """ + if a is None or b is None: + return a is b + if a[0] != b[0] or len(a[1]) != len(b[1]): + return False + for (*key_a, fa), (*key_b, fb) in zip(a[1], b[1]): + if key_a != key_b or len(fa) != len(fb) or any( + abs(x - y) > _SPLIT_SAME_FRACTION for x, y in zip(fa, fb)): + return False + return True + + +class _Candidate: + """ + A split plan of one side: its cells, verified, with the selection key + and the network signature. + + Parameters + ---------- + name : str + Generator, e.g. 'V' or 'S:partner'. + order : tuple + Tie-break rank: ``(0, rule index)`` for Stage S, ``(1, index in + _CORE_ORDER)`` for the core. + side : _Side + The side. + cells : list[_Cell] + The cells (stage keys set). + leak_by_must : sequence[float], optional + Must heat leaked by position round-off, per must. + work : float, optional + Work spent. + Qmin : float, optional + Exchangers below this duty count as small. + blocks : sequence[_Block], optional + The core blocks, if any. + + Attributes + ---------- + units : int + Exchangers (`_merge_cells`). + stages, branches : int + Split stages and their branches (one splitter and one mixer each). + mixers : int + The most split stages (mixers) on one stream. + small : int + Exchangers below `Qmin` or with a fraction below + `_SPLIT_MIN_FRACTION`. + mixbad : int + Non-isothermal remixes followed by a process exchanger of the same + stream (a must mixes toward the pinch, a flex away from it; a + utility does not count), plus curved streams (more than two knots) + with more than `_SPLIT_MIX_CAP` mixers on the side. + touch : int + Exchangers parallel at the minimum approach along a segment, + counted only on sides with a curved stream. + meta : dict + ``candidate``, ``stages``, ``branches``, ``leak`` and ``small`` + (``[(hot, cold, Q)]`` below `Qmin`) for the side's info. + signature : tuple + The sorted exchangers ``(must stream, flex stream, must branch, + flex branch)`` (a branch is ``(split ordinal on the stream, branch + ordinal)`` or None), and each split's fractions to 9 significant + digits. It holds no duty or position; with the fractions compared + within `_SPLIT_SAME_FRACTION` (`_same_network`), it identifies the + same network across knot refinements and generators. + excluded : bool + Set by the caller (exclusion by signature). + + Raises + ------ + _SplitInvariantError + If an exchanger fails (C) (`_cell_margin`). + """ + __slots__ = ('name', 'order', 'cells', 'leak_by_must', 'work', + 'blocks', 'excluded', 'units', 'stages', 'branches', + 'mixers', 'small', 'mixbad', 'touch', 'meta', 'signature') + + def __init__(self, name, order, side, cells, leak_by_must=None, + work=0., Qmin=0., blocks=()): + self.name, self.order = name, order + self.cells = list(cells) + self.leak_by_must = ([0.] * side.M if leak_by_must is None + else [float(x) for x in leak_by_must]) + self.work = float(work) + self.blocks = list(blocks) + self.excluded = False + tolQ, tolP = side.tolQ, side.tolP + merged = _merge_cells(self.cells, tolQ) + curved = any(c.n > 2 for c in side.musts + side.flexes) + small, n_small, touch = [], 0, 0 + for c in merged: + margin, t = _cell_margin(side, c) + if not margin >= -tolP: + raise _SplitInvariantError( + f'{c!r} fails (C) by {margin!r}') + if c.x < Qmin: + small.append((*side.hot_cold(c.i, c.j), c.x)) + n_small += c.x < Qmin or min(c.f, c.g) < _SPLIT_MIN_FRACTION + touch += curved and t + stages = _stages(merged) + mixers = Counter((role, s) for role, s, _ in stages) + mixbad = 0 + for (role, s, _), br in stages.items(): + p0, pm, iso = _remix(role, br, tolQ) + if iso: + continue + # an exchanger of the stream after the mix (a utility is not + # one): a must mixes toward the pinch, at `pm`, so a cell of it + # below `pm`; a flex mixes at its far end, so a cell of it + # outside the stage beyond its split start `p0` + inside = {id(c) for cs in br.values() for c in cs} + mixbad += any(id(c) not in inside and ( + c.i == s and c.a_end <= pm + tolQ if role == 'm' + else c.j == s and c.b > p0) for c in merged) + for (role, s), n in mixers.items(): + curve = (side.musts if role == 'm' else side.flexes)[s] + mixbad += curve.n > 2 and n > _SPLIT_MIX_CAP + self.units = len(merged) + self.stages = len(stages) + self.branches = sum(len(br) for br in stages.values()) + self.mixers = max(mixers.values(), default=0) + self.small, self.mixbad, self.touch = n_small, mixbad, touch + self.meta = dict(candidate=name, stages=self.stages, + branches=self.branches, + leak=math.fsum(self.leak_by_must), small=small) + self.signature = _signature(side, merged, stages) + + def key(self): + """Selection key, smaller is better: small exchangers, bad remixes, + touching exchangers, units + extra branches + split stages, the most + mixers on one stream, candidate order.""" + return (self.small, self.mixbad, self.touch, + self.units + self.branches, # units + extra branches + stages + self.mixers, self.order) + + def __repr__(self): + return f'<_Candidate {self.name} key={self.key()}>' + + +# %% Stage S: cut transports and the branched side + +def _fits(c, m): + """True if a capacity of CP `c` can take a demand of CP `m`: the + compatibility of `_Side.rules_violation`, with its relative tolerance.""" + return c >= m * (1. - _CP_TOL) + + +def _cut_sets(side, a, b, L, cut, rule): + """ + Demands and capacities of a pinch rule at a tight level. + + It repeats `_Side.rules_violation`'s selection at the cut, on the + side's local indices. Outward, the demands are the musts that leave + level `L` outward and the capacities the flexes at `L`, with ``CP = + 1/slope_right``; inward, the demands are the flexes that reach `L` + from below and the capacities the musts, with ``CP = 1/slope_left``. + Either way the rule is that each demand needs its own capacity branch + with ``CP_cap >= CP_dem``. + + Parameters + ---------- + side : _Side + The side. + a, b : sequence[float] + The frontiers. + L : float + The level of the cut. + cut : {'+', '-'} + As in `_Side.tight_cuts`. + rule : {'outward', 'inward'} + The rule. + + Returns + ------- + dem, cap : list[tuple[int, float]] + ``(local index, CP)`` of the demands and of the sloped capacities, + in index order. A flat capacity is left out: its level curve is + split-invariant and it already has unlimited series capacity (a + cut with one violates no rule). + None + If a demand is flat: a flat demand accepts only a flat partner, so + no split serves it. + """ + tolQ, tolP = side.tolQ, side.tolP + above = cut == '-' + + def outward(curves, front): + out = [] + for k, c in enumerate(curves): + if c.Q - front[k] <= tolQ or c.at(front[k]) > L + tolP: + continue + q = max(c.x_lt(L) if above else c.x_le(L), front[k]) + if q < c.Q - tolQ: + out.append((k, c.slope_right(q))) + return out + + def inward(curves, front): + out = [] + for k, c in enumerate(curves): + if c.Q - front[k] <= tolQ or c.y[-1] < L - tolP: + continue + y = c.at(front[k]) + if above: + if y >= L - tolP: + continue + q = c.x_lt(L) + else: + if y > L + tolP: + continue + q = c.x_le(L) + if q > front[k] + tolQ: + out.append((k, c.slope_left(q))) + return out + if rule == 'outward': + dem, cap = outward(side.musts, a), outward(side.flexes, b) + else: + dem, cap = inward(side.flexes, b), inward(side.musts, a) + if any(s == 0. for _, s in dem): + return None + return ([(k, 1. / s) for k, s in dem], + [(k, 1. / s) for k, s in cap if s > 0.]) + + +def _cut_transport(dem, cap, rule): + """ + Branches satisfying the pinch rule at a cut (module docstring, 'Stage S'). + + Parameters + ---------- + dem, cap : sequence[tuple[object, float]] + ``(id, CP)`` of the demands and capacities (`_cut_sets`), CP > 0. + rule : str + One of `_SPLIT_RULES`: + + * 'partner' (demands stay whole): a best-fit maximum matching, + demands by CP descending each taking the smallest free capacity + that fits (optimal, as the neighbourhoods are nested); each + unmatched demand then joins the capacity with the most room left, + if that is enough. + * 'demand' (capacities stay whole): the same matching; each + unmatched demand then takes the largest free capacities until + their CPs add up to its own, in proportion to them. + * 'mincell' (the minimum-cell structure, `_mincell`). + * 'nw-rho-desc', 'nw-rho-asc': the north-west corner, CP descending + (ascending), against the capacities scaled to ``c/rho``, ``rho = + sum c / sum m``: every cell has branch-CP ratio `rho`, the CP + slack spread uniformly. + * 'nw-exact-desc', 'nw-exact-asc': the north-west corner against + the capacities themselves; a capacity never reached stays out. + + Returns + ------- + list[tuple] or None + Cells ``(d, c, load)``: a branch of demand ``d`` of CP ``load`` + faces a branch of capacity ``c``; a demand's loads add up to its CP + and a capacity's to at most its CP (`_cut_fractions` gives the + branch fractions). None if the rule finds no such branches, and + always if ``sum c < sum m`` (then none exist). + """ + if not dem: + return [] + if not _fits(math.fsum(c for _, c in cap), math.fsum(m for _, m in dem)): + return None + if rule in ('partner', 'demand'): + return _cut_match(dem, cap, rule == 'partner') + if rule == 'mincell': + return _mincell(dem, cap) + desc = rule.endswith('-desc') + + def order(items): + return sorted(items, key=lambda t: -t[1] if desc else t[1]) + return _north_west(order(dem), order(cap), rule.startswith('nw-rho')) + + +def _cut_match(dem, cap, partner): + """The 'partner' (`partner`) and 'demand' rules of `_cut_transport`. + Ties go to the lower index.""" + free = sorted(cap, key=lambda t: t[1]) + cells, rest = [], [] + for d, m in sorted(dem, key=lambda t: -t[1]): + n = next((n for n, (_, c) in enumerate(free) if _fits(c, m)), None) + if n is None: + rest.append((d, m)) + continue + cells.append((d, free.pop(n)[0], m)) + if partner: + room = dict(cap) + for d, k, m in cells: + room[k] -= m + for d, m in rest: + k = max(room, key=room.get) + if not _fits(room[k], m): + return None + room[k] -= m + cells.append((d, k, m)) + return cells + free.sort(key=lambda t: -t[1]) + for d, m in rest: + take, tot = [], 0. + while free and not _fits(tot, m): + take.append(free.pop(0)) + tot += take[-1][1] + if not _fits(tot, m): + return None + cells += [(d, k, m * c / tot) for k, c in take] + return cells + + +def _north_west(dem, cap, inflate): + """ + North-west corner: the demands, in order, fill the capacities, in + order. With `inflate`, the capacities are scaled to ``c/rho`` (``rho = + sum c / sum m``) and end with the demands; otherwise a capacity never + reached stays out, whole. Each cell is the overlap of a demand and a + capacity interval on the prefix sums, so no subtraction chain carries + round-off; breakpoints within ``_SPLIT_ULP`` of the total coincide. + """ + D = list(accumulate(m for _, m in dem)) + C = list(accumulate(c for _, c in cap)) + total = D[-1] + if inflate: + s = total / C[-1] + C = [x * s for x in C] + C[-1] = total + ulp = _SPLIT_ULP * total + cells, x, n, k = [], 0., 0, 0 + while n < len(D) and k < len(C): + e = min(D[n], C[k]) + if e - x > ulp: + cells.append((dem[n][0], cap[k][0], e - x)) + x = max(x, e) + done_d, done_c = D[n] <= e + ulp, C[k] <= e + ulp + n += done_d + k += done_c + return cells + + +def _merge_sets(n, k): + """Families of disjoint subsets of ``range(n)``, each of two or more, + with ``sum(|S| - 1) == k``; each family once, its sets in increasing + order.""" + def rec(rem, avail, acc): + if rem == 0: + yield list(acc) + return + for size in range(rem + 1, 1, -1): + for S in combinations(avail, size): + if acc and S < acc[-1]: + continue + yield from rec(rem - size + 1, + [x for x in avail if x not in S], acc + [S]) + return rec(k, list(range(n)), []) + + +def _pack(ms, size, work): + """ + Exact bin packing by branch and bound: the demands `ms` (CP + descending) into bins of CP `size`. The score, maximized, is (bins + used, the smallest bin CP over its load): more groups mean fewer cells, + and a larger ratio more CP slack. Capacity pruning, identical-bin + symmetry breaking and the bound (the bins still reachable, the current + smallest ratio: a load only grows); at most `_MINCELL_WORK` nodes in + all (`work`, shared). Returns ``(score, bin of every demand)`` or None. + """ + n, nb = len(ms), len(size) + loads = [0.] * nb + assign = [0] * n + best = None + + def rec(k, used): + nonlocal best + if work[0] >= _MINCELL_WORK: + return + work[0] += 1 + ratio = min((size[t] / loads[t] for t in range(nb) if loads[t] > 0.), + default=math.inf) + if k == n: + if best is None or (used, ratio) > best[0]: + best = ((used, ratio), list(assign)) + return + if best is not None and (min(nb, used + n - k), ratio) <= best[0]: + return + m = ms[k] + seen = set() + for t in range(nb): + old = loads[t] + if (old, size[t]) in seen or not _fits(size[t], old + m): + continue + seen.add((old, size[t])) + loads[t] = old + m + assign[k] = t + rec(k + 1, used + (old == 0.)) + loads[t] = old + rec(0, 0) + return best + + +def _mincell(dem, cap): + """ + The 'mincell' rule of `_cut_transport`: Linnhoff and Hindmarsh's + minimum-cell pinch design. For k = 0..3, disjoint sets of capacities + merge into super-bins with ``sum(|S| - 1) = k`` and the demands are + packed into the bins (`_pack`); the best packing at the smallest + feasible k gives the groups, and each group is served by the uniform-rho + north-west corner (CP descending). k = 0 is the number-rule fix (each + group has one capacity, the demands stay whole) and each unit of k one + demand split (the CP-rule fix). At the smallest feasible k every merged + set is used: a packing that left one empty would be feasible at a + smaller k, which was searched in full. Without a structure (or when the + work, `_MINCELL_WORK` nodes and families in all, runs out before one is + found) all of the cut is one group. + """ + ms = sorted(dem, key=lambda t: -t[1]) + work = [0] + best = None + for k in range(4): + for sets in _merge_sets(len(cap), k): + work[0] += 1 + merged = {n for S in sets for n in S} + bins = sets + [(n,) for n in range(len(cap)) if n not in merged] + size = [math.fsum(cap[n][1] for n in S) for S in bins] + r = _pack([m for _, m in ms], size, work) + if r is not None and (best is None or r[0] > best[0]): + best = (r[0], bins, r[1]) + if work[0] >= _MINCELL_WORK: + break + if best is not None or work[0] >= _MINCELL_WORK: + break + if best is None: + return _north_west(ms, sorted(cap, key=lambda t: -t[1]), True) + _, bins, assign = best + cells = [] + for t in dict.fromkeys(assign): # the groups, by their largest demand + group = [ms[n] for n in range(len(ms)) if assign[n] == t] + caps = sorted((cap[n] for n in bins[t]), key=lambda u: -u[1]) + cells += _north_west(group, caps, True) + return cells + + +def _cut_fractions(cells, cap=None, dem=None): + """ + Branch fractions ``(f, g)`` of every cell of a cut transport: the + cell's load over the sum of the loads of its demand (capacity). A + demand's loads add up to its CP, so ``f = load / m``; a capacity is + split whole, with no bypass, so its branch has CP ``g c >= load``. An + item with one cell has fraction 1 (no split). + + A branch below `_SPLIT_MIN_FRACTION` of its parent would merge into a + sibling (`_split_items`); the CP slack raises it where it can. With + `cap` (``{capacity: (c, f)}``: its CP and its own fraction of its + parent) and `dem` (``{demand: f}``: its own fraction): + + * a demand with such a branch moves load to it from its other branches + (`_water_fill`) if every capacity it raises a load on keeps ``sum + load <= c``; the demands go in the order of `dem`, each within the + room the ones before left; + * then a capacity with such a branch spreads its CP slack over the + loads so settled (`_raised_fractions`), as a capacity branch only + needs ``g c >= load``. + + An item the slack cannot raise keeps the loads' shares, bit for bit. + """ + by_d, by_c = defaultdict(list), defaultdict(list) + for d, c, x in cells: + by_d[d].append(x) + by_c[c].append(x) + sd = {d: math.fsum(v) for d, v in by_d.items()} + sc = {c: math.fsum(v) for c, v in by_c.items()} + fractions = [(x / sd[d], x / sc[c]) for d, c, x in cells] + loads, raised = [x for *_, x in cells], {} + for d, f in (dem.items() if cap and dem else ()): + at = [n for n, cell in enumerate(cells) if cell[0] == d] + if len(at) < 2 or all(f * fractions[n][0] >= _SPLIT_MIN_FRACTION + for n in at): + continue + g = _water_fill([loads[n] for n in at], _min_share(f)) + if g is None: + continue + new = list(loads) + for n, gn in zip(at, g): + new[n] = gn * sd[d] + up = {cells[n][1] for n in at if new[n] > loads[n]} + if all(c in cap and _fits(cap[c][0], math.fsum( + x for (_, k, _), x in zip(cells, new) if k == c)) + for c in up): + loads = new + raised.update(zip(at, g)) + if raised: + by_c = defaultdict(list) + for (_, c, _), x in zip(cells, loads): + by_c[c].append(x) + sc = {c: math.fsum(v) for c, v in by_c.items()} + fractions = [(raised.get(n, fractions[n][0]), x / sc[c]) + for n, ((_, c, _), x) in enumerate(zip(cells, loads))] + for c, (cp, f) in (cap or {}).items(): + at = [n for n, cell in enumerate(cells) if cell[1] == c] + if len(at) < 2 or all(f * fractions[n][1] >= _SPLIT_MIN_FRACTION + for n in at): + continue + g = _raised_fractions(by_c[c], cp, _min_share(f)) + if g is not None: + for n, gn in zip(at, g): + fractions[n] = (fractions[n][0], gn) + return fractions + + +def _min_share(f): + """The smallest branch fraction ``g`` of an item of fraction `f` of its + parent with ``f g >= _SPLIT_MIN_FRACTION`` in floating point, the test + `_split_items` applies.""" + g = _SPLIT_MIN_FRACTION / f + while f * g < _SPLIT_MIN_FRACTION: + g = math.nextafter(g, 2.) + return g + + +def _water_fill(loads, g_min): + """ + Branch fractions over `loads`, each at least `g_min`: ``g_k = max(g_min, + lam x_k)``, ``sum g_k = 1``. + + The branches below `g_min` at the loads' shares (``lam = 1 / sum x``) + are raised to it, and the others share the rest in proportion to their + loads. With the loads ascending, the raised branches are the first + `t`, ``lam = (1 - t g_min) / sum_{k >= t} x_k``, and the first `t` for + which ``lam x_t >= g_min`` is the one: raising branch t lowers `lam` + exactly when ``lam x_t < g_min``, so the branches raised before stay + below `g_min` and `lam` only falls. + + Returns + ------- + list[float] or None + The fractions, in the order of `loads`; None if ``n g_min > 1``. + """ + order = sorted(range(len(loads)), key=loads.__getitem__) + for t, k in enumerate(order): + lam = (1. - t * g_min) / math.fsum(loads[j] for j in order[t:]) + if lam * loads[k] >= g_min: + break + else: + return None + return [max(g_min, lam * x) for x in loads] + + +def _raised_fractions(loads, c, g_min): + """ + Branch fractions of a capacity of CP `c` over its `loads`, each at + least `g_min` (`_water_fill`): the unraised branches keep one CP ratio + ``lam c`` (uniform, as the loads' shares do). Every branch needs ``g_k + c >= x_k``: a raised one has it when ``lam c >= 1``, and the others + have it iff ``lam c >= 1``. + + Returns + ------- + list[float] or None + The fractions, in the order of `loads`; None if they do not exist + (``n g_min > 1``, or the CP slack ``c - sum x`` cannot cover the + raise: ``lam c < 1``). + """ + g = _water_fill(loads, g_min) + if g is not None and all(_fits(gk * c, x) for gk, x in zip(g, loads)): + return g + return None + + +def _split_items(items, M, split): + """ + The items (`_pinch_split`) after one cut: every item in `split` + (``(role, local index) -> branch fractions``) becomes branches of its + fraction times those. A branch below `_SPLIT_MIN_FRACTION` of its + parent (one the CP slack could not raise, `_cut_fractions`) merges + into its largest sibling (a realizable split), and a parent left with + one branch is a trunk again. Keys are ``('S', parent, n)``, numbered + per parent in item order. + """ + parent, frac = [], [] + for n, (role, p, f, _) in enumerate(items): + for g in split.get((role, n if role == 'must' else n - M), (1.,)): + parent.append((role, p)) + frac.append(f * g) + alive = [True] * len(frac) + siblings = defaultdict(list) + for n, rp in enumerate(parent): + siblings[rp].append(n) + for sib in siblings.values(): + while len(sib) > 1: + s = min(sib, key=frac.__getitem__) + if frac[s] >= _SPLIT_MIN_FRACTION: + break + sib.remove(s) + frac[max(sib, key=frac.__getitem__)] += frac[s] + alive[s] = False + count = Counter(rp for rp, live in zip(parent, alive) if live) + seen = Counter() + out = [] + for (role, p), f, live in zip(parent, frac, alive): + if not live: + continue + if count[role, p] == 1: + out.append((role, p, 1., None)) + continue + out.append((role, p, f, ('S', p, seen[role, p]))) + seen[role, p] += 1 + return out + + +def _branched_side(side, items, a0): + """ + The side of `items` (`_pinch_split`), and its pre-leaked root: a must + branch of fraction ``f`` of parent ``i`` starts at branch heat ``f + a0_i`` (Lemma B), a flex branch at 0. + """ + musts, flexes, a = [], [], [] + for role, p, f, _ in items: + if role == 'must': + c = side.musts[p] + musts.append(c if f == 1. else _branch_curve(c, f, 'must')) + a.append(f * a0[p]) + else: + c = side.flexes[p] + flexes.append(c if f == 1. else _branch_curve(c, f, 'flex')) + return _StrictSide(side.name, musts, flexes, side.tolQ, side.tolP), a + + +def _pinch_split(side, a0, proof, rule): + """ + The branched side of one Stage S rule (module docstring, 'Stage S'). + + The rule's transport (`_cut_transport`) at the proof's cut splits the + demands and capacities there into whole-side branches, the CP slack + raising branches to `_SPLIT_MIN_FRACTION` where it can + (`_cut_fractions`). By Lemma R the branched side keeps the parent's + cascade, so one exact screen at its pre-leaked root decides the split: + its slack of (R) must be the parent's, and `rules_violation` there + checks the pinch rules at every tight cut. If a cut still violates them + (a double pinch), the same rule is applied there, to the branches as + items (a branch of a branch is a branch of the parent with the product + fraction), at most `_SPLIT_CUTS` cuts in all. + + Parameters + ---------- + side : _Side + The side. + a0 : sequence[float] + Its pre-leaked root (`_preleak_root`). + proof : dict + The root proof: an 'outward' or 'inward' violation. + rule : str + One of `_SPLIT_RULES`. + + Returns + ------- + items : list[tuple] + ``(role, parent, fraction, key)`` of every curve of the branched + side (`_branched_side`), the musts ('must') first, then the flexes + ('flex'). A trunk has fraction 1 and key None, a branch a fraction + of at least `_SPLIT_MIN_FRACTION` and key ``('S', parent, n)``. + extra : int + The extra branches, ``sum(branches - 1)`` over the split parents. + None + If the rule finds no transport at a cut, the split changes nothing, + or a violation is left after `_SPLIT_CUTS` cuts. + + Raises + ------ + _SplitInvariantError + If the slack of (R) at the branched root differs from the side's by + more than ``10 tolQ`` (Lemma R broken). + """ + M, F = side.M, side.F + slack0 = side.analyse(list(a0), [0.] * F).slack + items = ([('must', i, 1., None) for i in range(M)] + + [('flex', j, 1., None) for j in range(F)]) + bside, a, v = side, list(a0), proof + for _ in range(_SPLIT_CUTS): + sets = _cut_sets(bside, a, [0.] * bside.F, v['level'], v['cut'], + v['rule']) + cells = None if sets is None else _cut_transport(*sets, rule) + if cells is None: + return None + dem, cap = ('must', 'flex') if v['rule'] == 'outward' else ( + 'flex', 'must') + # each capacity's CP and each item's fraction of its parent + first = 0 if cap == 'must' else bside.M + room = {c: (cp, items[first + c][2]) for c, cp in sets[1]} + first = 0 if dem == 'must' else bside.M + own = {d: items[first + d][2] for d, _ in sets[0]} + split = defaultdict(list) + for (d, c, _), (f, g) in zip(cells, _cut_fractions(cells, room, own)): + split[dem, d].append(f) + split[cap, c].append(g) + new = _split_items(items, bside.M, split) + if new == items: + return None + items = new + bside, a = _branched_side(side, items, a0) + d = bside.analyse(a, [0.] * bside.F) + if abs(d.slack - slack0) > 10. * side.tolQ: + raise _SplitInvariantError( + f'the branched root has slack {d.slack!r}, the ' + f'side {slack0!r}') + v = bside.rules_violation(a, [0.] * bside.F, d) + if v is None: + return items, sum(n - 1 for n in Counter( + it[:2] for it in items).values()) + return None + + +# %% Stage S: the plan + +def _repeats_a_pair(cells, tolQ): + """True if two exchangers of `cells` (`_merge_cells`) have the same + (must, flex) pair: avoid_recycle allows every pair once, and two + branches of a stream with one partner are two exchangers.""" + pairs = Counter((c.i, c.j) for c in _merge_cells(cells, tolQ)) + return any(n > 1 for n in pairs.values()) + + +def _fold(items, M, used): + """ + Lemma F: the fraction and stage key of every used flex of a branched + side, after folding its unused siblings. + + Parameters + ---------- + items : list[tuple] + The branched side's items (`_pinch_split`). + M : int + Its number of musts. + used : collection[int] + Local indices of the flexes with cells. + + Returns + ------- + dict + ``{local flex index: (g, key)}``. A parent whose branches are all + used keeps them. Otherwise each used branch gets ``g / G``, with + ``G`` the sum over its used siblings (the unused fractions go to + them in proportion), and keys renumbered in item order; a parent + with one used branch is a trunk again, ``(1., None)``. + """ + siblings = defaultdict(list) + for jj, (_, p, _, _) in enumerate(items[M:]): + siblings[p].append(jj) + out = {} + for p, br in siblings.items(): + live = [jj for jj in br if jj in used] + if len(live) == len(br): + for jj in live: + out[jj] = items[M + jj][2:] + elif len(live) == 1: + out[live[0]] = (1., None) + elif live: + G = math.fsum(items[M + jj][2] for jj in live) + for n, jj in enumerate(live): + out[jj] = (items[M + jj][2] / G, ('S', p, n)) + return out + + +def _s_cells(side, items, a0, pieces): + """ + Cells of a Stage S plan (module docstring, 'Stage S'): the DFS `pieces` of + the branched side, merged (`_merge`), in parent coordinates, with the + unused flex branches folded (`_fold`) and the residual swept. + + Parameters + ---------- + side : _Side + The parent side. + items : list[tuple] + The branched side's items (`_pinch_split`). + a0 : sequence[float] + The parent side's pre-leaked root. + pieces : list[tuple] + ``(i', j', a', b', x)`` on the branched side, from its pre-leaked + root (`_branched_side`). + + Returns + ------- + cells : list[_Cell] or None + The cells, or None if the sweep fails. + reason : None or 'sweep' + + Notes + ----- + A piece ``(i', j', a', b', x)`` becomes ``_Cell(parent(i'), parent(j'), + x, a'/f, b'/g, f, g, key(i'), key(j'))``: a branch is the scaled parent + (Lemma B). Folding raises a flex branch's fraction ``g``, so its + parent positions ``b'/g`` move toward the pinch, where the flex levels + are no higher, and every cell stays feasible (Lemma F); the must side + is unchanged. + + The DFS completes a must within ``tolQ`` of heat. The residual of must + item ``i'``, ``r = f (Qm_i - a0_i)`` less its duties, is swept so that + every must is served exactly: if ``|r| <= tolQ``, `r` is added to + the item's far-end cell, the later cells of that cell's flex item move + with it (the flex stays a prefix), and the cells changed are verified + (`_cell_margin`); otherwise, or if that fails, the plan is rejected. + Stage S never books a leak. + """ + M = sum(it[0] == 'must' for it in items) + tolQ, tolP = side.tolQ, side.tolP + ex = _merge(pieces) + fold = _fold(items, M, {e[1] for e in ex}) + moved = set() + for i2, (_, p, f, _) in enumerate(items[:M]): + rows = [e for e in ex if e[0] == i2] + r = f * (side.Qm[p] - a0[p]) - math.fsum(e[4] for e in rows) + if r == 0.: + continue + if not rows or not abs(r) <= tolQ: + return None, 'sweep' + e = max(rows, key=lambda e: e[2] + e[4]) + if not e[4] + r > 0.: + return None, 'sweep' + for e2 in ex: + if e2[1] == e[1] and e2[3] > e[3]: + e2[3] += r + moved.add(id(e2)) + e[4] += r + moved.add(id(e)) + cells = [] + for e in ex: + i2, j2, a, b, x = e + _, p, f, km = items[i2] + g, kf = fold[j2] + c = _Cell(p, items[M + j2][1], x, a / f, b / g, f, g, km, kf) + if id(e) in moved and not _cell_margin(side, c)[0] >= -tolP: + return None, 'sweep' + cells.append(c) + return cells, None + + +def _s_search(bside, a0, cap1, forbid, work_scale, bound, room): + """ + The first `_SPLIT_PASSES` passes of `_SCHEDULE` on a branched side from + its pre-leaked root, as `_plan_side` runs them but for the unit bound + and the shared budget `room`. + + Returns + ------- + pieces : list[tuple] or None + The first success. + work : float + Work spent. + cut : bool + True if the shared budget stopped a pass. + """ + work, seen = 0., set() + for mode, cap, extra, budget in _SCHEDULE[:_SPLIT_PASSES]: + cap = _combine_cap(cap, cap1) + if (mode, cap, extra) in seen: + continue + seen.add((mode, cap, extra)) + left = room - work + if left <= 0.: + return None, work, True + budget *= work_scale + srch = _Search(bside, mode, cap, extra, min(budget, left), + unit_bound=bound, forbid=forbid, a0=a0) + pieces = srch.run() + work += srch.work + if pieces is not None: + return pieces, work, False + if srch.exhausted and budget > left: + return None, work, True + return None, work, False + + +def _s_candidate(name, n, side, items, a0, pieces, cap1, work, split, + errors, reasons): + """The Stage S candidate of DFS `pieces` (converted by `_s_cells` and + verified by `_Candidate`), or None; its key or the reason is recorded + in `reasons`.""" + cells, why = _s_cells(side, items, a0, pieces) + if cells is None: + reasons[name] = why + return None + if cap1 and _repeats_a_pair(cells, side.tolQ): + reasons[name] = 'repeated pair' + return None + try: + c = _Candidate(name, (0, n), side, cells, None, work, + split['Qmin']) + except _SplitInvariantError as e: + errors.append(f'{name}: {e}') + reasons[name] = 'error' + return None + c.excluded = _excluded(split, side.name, c) + reasons[name] = 'excluded' if c.excluded else c.key() + return c + + +def _stage_s(side, a0, proof, cap1, forbid, work_scale, split, errors, + reasons, only=None, skip=None): + """ + Stage S candidates of a side: pinch splits planned by the unchanged + DFS (module docstring, 'Stage S'). + + Parameters + ---------- + side : _Side + The side. + a0 : sequence[float] + Its pre-leaked root (`_preleak_root`). + proof : dict + The root proof, an 'outward' or 'inward' violation. + cap1 : bool + avoid_recycle: every pair in at most one exchanger. + forbid : collection[tuple[int, int]] + Pairs never used (local indices). + work_scale : float + Scale of the search budgets. + split : dict + ``Qmin`` and ``exclude`` (`_split_side`). + errors : list[str] + `_SplitInvariantError` messages are appended. + reasons : dict + Set for every rule run, ``'S:'``: the candidate's key, or + 'excluded', or why there is no candidate: 'no split' + (`_pinch_split` found none), 'same split' (an earlier rule's + branching), 'budget' (the shared budget ran out), 'bound' (no plan + within the unit bound), 'no plan', 'sweep' (`_s_cells`), 'repeated + pair' (avoid_recycle) or 'error'. + only : str, optional + Run this candidate name only (the preferred one). + skip : str, optional + Do not run this candidate name (the preferred one already ran). + + Returns + ------- + cands : list[_Candidate] + The candidates, excluded ones included (`_excluded`). + work : float + Work spent: the DFS passes of every rule (with or without a + candidate) and the winner's unit improvements. + + Notes + ----- + For each rule of `_SPLIT_RULES`, in order, `_pinch_split` gives the + branched side, and the first `_SPLIT_PASSES` passes of `_SCHEDULE` + plan it from its pre-leaked root (`_s_search`), with the pairs of + `forbid` forbidden on every branch pair of their parents. Once a live + candidate has no small exchanger, bad remix or touch, the DFS runs + with the unit bound ``score - extra - stages`` (``score`` the + incumbent's units + extra branches + split stages; this rule's extra + branches and split stages), which keeps ties. All rules share + ``_SPLIT_S_WORK work_scale`` of work (deterministic); rules not + reached are 'budget'. Each plan is converted (`_s_cells`: folding, + the residual sweep) and verified (`_Candidate`); with `cap1`, a plan + repeating a pair is rejected. With `_SPLIT_FIRST_WINS`, the first live + candidate ends the rules. The winner (the smallest key among the live + candidates) then gets the planner's `_improve_units` and + `_units_guard` on its branched side, and the result replaces it if + it is live and its key is not worse. + """ + budget = _SPLIT_S_WORK * work_scale + used = 0. + out, seen, runs = [], [], {} + for n, rule in enumerate(_SPLIT_RULES): + name = 'S:' + rule + if name == skip or (only is not None and name != only): + continue + if _SPLIT_FIRST_WINS and any(not c.excluded for c in out): + break + if used >= budget: + reasons[name] = 'budget' + continue + try: + res = _pinch_split(side, a0, proof, rule) + except _SplitInvariantError as e: + errors.append(f'{name}: {e}') + reasons[name] = 'error' + continue + if res is None: + reasons[name] = 'no split' + continue + items, extra = res + if items in seen: + reasons[name] = 'same split' + continue + seen.append(items) + bside, a0b = _branched_side(side, items, a0) + M = bside.M + bforbid = frozenset( + (i2, j2) for i2, it in enumerate(items[:M]) + for j2, jt in enumerate(items[M:]) if (it[1], jt[1]) in forbid) + stages = len({it[:2] for it in items if it[3] is not None}) + live = [c for c in out if not c.excluded] + inc = min(live, key=_Candidate.key) if live else None + bound = math.inf + if inc is not None and inc.small == inc.mixbad == inc.touch == 0: + bound = inc.key()[3] - extra - stages + pieces, work, cut = _s_search(bside, a0b, cap1, bforbid, work_scale, + bound, budget - used) + used += work + if pieces is None: + reasons[name] = ('budget' if cut else 'no plan' + if bound == math.inf else 'bound') + continue + c = _s_candidate(name, n, side, items, a0, pieces, cap1, work, split, + errors, reasons) + if c is not None: + out.append(c) + runs[id(c)] = (bside, a0b, bforbid, items, pieces) + live = [c for c in out if not c.excluded] + if not live: + return out, used + # the winner's units, as `_plan_side` improves an unsplit plan + best = min(live, key=_Candidate.key) + bside, a0b, bforbid, items, pieces = runs[id(best)] + new, w1 = _improve_units(bside, pieces, best.work, cap1, bforbid, + work_scale, a0=a0b) + new, w2 = _units_guard(bside, new, cap1, bforbid, work_scale, a0=a0b) + best.work += w1 + w2 + if new is not pieces: + c = _s_candidate(best.name, best.order[1], side, items, a0, new, + cap1, best.work, split, errors, reasons) + if c is not None and not c.excluded and c.key() <= best.key(): + out[out.index(best)] = c + else: + reasons[best.name] = best.key() + return out, used + w1 + w2 + + +# %% Portfolio and selection + +def _excluded(split, side_name, cand): + """True if the network of `cand` is excluded on its side: one of the + side's excluded signatures is the same network (`_same_network`). + Exclusion is by network, never by generator name.""" + return any(_same_network(cand.signature, signature) + for signature in split['exclude'].get(side_name, ())) + + +def _side_plan(side, best, reasons, a0, delta, errors, work, proof): + """The side plan of the chosen candidate `best` (None: no candidate). + Each must's gap is its pre-leak plus its leak, so the penalty is + truthful.""" + if best is None: + info = dict(candidate=None, signature=None, candidates=reasons, + stages=0, branches=0, preleak=delta, leak=0., small=[], + errors=errors) + return _SidePlan([], list(side.Qm), 'failed', 'split-none', work, + proof, split=info) + gaps = [a0[i] + best.leak_by_must[i] for i in range(side.M)] + info = dict(best.meta, signature=best.signature, candidates=reasons, + preleak=delta, errors=errors) + return _SidePlan([], gaps, 'mer', 'split-' + best.name, work, proof, + cells=best.cells, split=info, units=best.units) + + +def _split_side(side, proof, cap1, forbid, work_scale, work, split): + """ + Split plan of a side that no unsplit network serves at MER. + + Parameters + ---------- + side : _Side + The side. The core analyses its root exactly (`_preleak_root`). + proof : dict or None + The root proof, kept in the side plan (it says why the side + split), or None if the unsplit search left a penalty. + cap1 : bool + avoid_recycle: every pair in at most one exchanger. + forbid : collection[tuple[int, int]] + Pairs never used (avoid_recycle: those of the other side). + work_scale : float + Scale of the search budgets. + work : float + Work already spent on the side. + split : dict + ``Qmin`` (exchangers below it count as small in the key; none is + dropped), ``exclude`` (per side, excluded network signatures) and + ``prefer`` (per side, the previous round's pick as ``(candidate + name, network signature)``, tried first). + + Returns + ------- + _SidePlan + If a candidate exists, a side plan with status 'mer', method + ``'split-'``, the cells of the chosen candidate and the + ``split`` info. Otherwise (the root deficit exceeds `_preleak_max`, + or every generator failed, raised `_SplitInvariantError` or was + rejected by avoid_recycle's pairs) status 'failed', method + 'split-none', no cells, and the ``split`` info of the attempt with + ``candidate`` None: the caller keeps it on its best-effort plan. + Either way the work is `work` plus the work spent here. + + Notes + ----- + Every generator starts from the pre-leaked root ``a0 = P(delta)`` + (Lemma P): Stage S (`_stage_s`, one candidate 'S:' per rule of + `_SPLIT_RULES`) if the root proof is a pinch rule ('outward' or + 'inward'), then the core (`_CORE_STRATEGIES`). The preferred candidate + (the previous refine round's pick) is generated first and taken as is + while it plans the same network (`_same_network` with the preferred + signature: the knots of a refine round move the fractions), unless that + network is excluded. Otherwise every generator runs (the + preferred one is not run again) and the smallest `_Candidate.key` wins + among the candidates not excluded or, if all are excluded, among all + of them: a side's last candidate is never excluded. With + `_SPLIT_FIRST_WINS`, the first live candidate wins. A generator that + raises `_SplitInvariantError` is recorded in ``errors`` and the others + remain. With `cap1`, a candidate repeating a pair is rejected + (`_repeats_a_pair`). The work spent is that of Stage S (every rule's + DFS passes, `_stage_s`) and of every core candidate. + """ + delta, a0 = _preleak_root(side) + core = delta <= _preleak_max(side) + s_ok = core and proof is not None and proof['rule'] in ('outward', + 'inward') + prefer, signature = split['prefer'].get(side.name, (None, None)) + cands, errors, reasons, spent = [], [], {}, [work] + + def generate(name): + try: + c = _drive(side, a0, name, cap1, forbid, work_scale, + split['Qmin']) + except _SplitInvariantError as e: + errors.append(f'{name}: {e}') + reasons[name] = 'error' + return None + if c is None: + reasons[name] = 'forbidden pairs' + return None + spent.append(c.work) + if cap1 and _repeats_a_pair(c.cells, side.tolQ): + reasons[name] = 'repeated pair' + return None + c.excluded = _excluded(split, side.name, c) + reasons[name] = 'excluded' if c.excluded else c.key() + cands.append(c) + return c + + def stage_s(**kw): + cs, w = _stage_s(side, a0, proof, cap1, forbid, work_scale, split, + errors, reasons, **kw) + spent.append(w) + cands.extend(cs) + return cs + + def plan(best): + return _side_plan(side, best, reasons, a0, delta, errors, + math.fsum(spent), proof) + # the preferred candidate (stickiness), taken as is if it is live and + # its network unchanged (a generator can plan another network on + # refined knots, which competes with the whole portfolio) + first = [] + if s_ok and prefer is not None and prefer[2:] in _SPLIT_RULES and ( + prefer.startswith('S:')): + first = stage_s(only=prefer) + elif core and prefer in _CORE_STRATEGIES: + first = [generate(prefer)] + for c in first: + if (c is not None and not c.excluded + and _same_network(c.signature, signature)): + return plan(c) + # the portfolio: Stage S, then the core + if s_ok: + stage_s(skip=prefer) + if core and not (_SPLIT_FIRST_WINS and any(not c.excluded + for c in cands)): + for strategy in _CORE_STRATEGIES: + if strategy != prefer: + c = generate(strategy) + if _SPLIT_FIRST_WINS and c is not None and not c.excluded: + break + if not cands: + return plan(None) + live = [c for c in cands if not c.excluded] or cands + return plan(min(live, key=_Candidate.key)) + + +# %% Plan records + +class Split: + """ + One split of a stream in a plan (`hensmith._planner.Plan.splits`): the + stream divides into parallel branches that re-join in a mixer. + + Attributes + ---------- + stream : int + The stream (index into the plan's streams). + side : {'above', 'below'} + The side of the pinch. + key : tuple + The planner's stage id, e.g. ``('B', block, local stream)``. + fractions : tuple[float] + Flow fraction of every branch; they sum to 1 within round-off. + branches : list[list[int]] + The exchangers of every branch (indices into ``Plan.exchangers``), + in flow order. A branch whose exchangers were all dropped by the + safety net (a bug) is empty: its flow bypasses. + H_split, H_mix : float + Stream enthalpies (the scale of the exchangers' enthalpies) where + the stream splits and where the branches re-join: ``H_mix = H_split + -/+ sum of the branch duties`` (hot/cold), so the fractions add no + round-off to the stream's heat balance. + isothermal : bool + Every branch ends within ``_ISO_TOL tolQ`` (parent-equivalent) of + ``H_mix``: the branches re-join at one temperature. + """ + __slots__ = ('stream', 'side', 'key', 'fractions', 'branches', + 'H_split', 'H_mix', 'isothermal') + + def __init__(self, stream, side, key, fractions, branches): + self.stream, self.side, self.key = stream, side, key + self.fractions, self.branches = fractions, branches + self.H_split = self.H_mix = math.nan + self.isothermal = None + + def __repr__(self): + return (f'') + + +def _record(side, c): + """ + The plan record of cell `c` of `side`. Its place on each stream, + ``(rank, role, stage key, start, end)`` in `_kh` and `_kc`, holds the + side's rank in that stream's flow (a hot stream runs above, then + below; a cold stream below, then above), the stream's role ('m' must, + 'f' flex), its stage key (None on a trunk) and its parent range. + """ + e = Exchanger() + e.side = side.name + e.hot, e.cold = side.hot_cold(c.i, c.j) + e.Q = c.x + must = ('m', c.km, c.a, c.a_end) + flex = ('f', c.kf, c.b, c.b_end) + if side.name == 'above': # the hot stream is the must + e._kh, e._kc = (0,) + must, (1,) + flex + e.hot_frac, e.cold_frac = c.f, c.g + else: # the cold stream is the must + e._kh, e._kc = (1,) + flex, (0,) + must + e.hot_frac, e.cold_frac = c.g, c.f + return e + + +def _paths(recs, N, ghosts=()): + """ + Flow order of every stream from the records' places. + + Per stream and side, the trunk records and the stages (one `Split` + each) occupy disjoint parent ranges, so one sort by the midpoint puts + them pinch outward; musts flow toward the pinch (the order reversed), + flexes away from it. Within a split, branches run in stage-key order, + each branch's records in flow order. `ghosts` (records dropped by the + safety net) keep their branches, empty, so the fractions still sum to + 1. Sets the records' `hot_branch` and `cold_branch`. + + Returns + ------- + stages : dict[int, list[int]] + Flat flow order of every stream. + paths : dict[int, list[int or Split]] + Flow order with each split as one item. + splits : list[Split] + In stream order, then flow order. + """ + trunks = defaultdict(list) # stream -> [(order, record)] + stages_ = defaultdict(dict) # stream -> {(rank, role, stage id, side): + # {branch: [fraction, [(lo, hi, Q, n)]]}} + for live, lst in ((True, recs), (False, ghosts)): + for n, e in enumerate(lst): + e.hot_branch = e.cold_branch = None + for j, (rank, role, key, lo, hi), f in ( + (e.hot, e._kh, e.hot_frac), (e.cold, e._kc, e.cold_frac)): + sg = -1. if role == 'm' else 1. + if key is None: + if live: + trunks[j].append(((rank, sg * (lo + hi), 0, n), n)) + continue + br = stages_[j].setdefault((rank, role, key[:-1], e.side), {}) + br.setdefault(key[-1], [f, []])[1].append( + (lo, hi, e.Q, n if live else None)) + stages, paths, splits = {}, {}, [] + for j in range(N): + items = list(trunks[j]) + for k, ((rank, role, sid, side), br) in enumerate( + stages_[j].items()): + # the stage's parent range: a must splits at its far end and + # mixes toward the pinch, a flex splits at its start + members = [m for _, ms in br.values() for m in ms] + D = math.fsum(m[2] for m in members) + if role == 'm': + hi = max(m[1] for m in members) + mid, sg = 2. * hi - D, -1. + else: + mid, sg = 2. * min(m[0] for m in members) + D, 1. + branches, fr = [], [] + for b in sorted(br): + f, ms = br[b] + ms.sort(key=lambda m: sg * (m[0] + m[1])) + branches.append([m[3] for m in ms if m[3] is not None]) + fr.append(f) + items.append(((rank, sg * mid, 1, k), + Split(j, side, sid, tuple(fr), branches))) + items.sort(key=lambda it: it[0]) + path, flat = [], [] + for _, item in items: + if isinstance(item, Split): + for b, ns in enumerate(item.branches): + for n in ns: + e = recs[n] + if e.hot == j: + e.hot_branch = (len(splits), b) + else: + e.cold_branch = (len(splits), b) + flat += ns + splits.append(item) + else: + flat.append(item) + path.append(item) + stages[j], paths[j] = flat, path + return stages, paths, splits + + +def _walk_paths(curves, recs, paths, N, tolQ): + """ + Flow-order walk of a plan with splits (the split-aware `_walk`). + + A trunk record moves the stream's enthalpy by its duty. A split + records ``H_split``, walks every branch from it by ``Q / f`` per record + (parent-equivalent enthalpies), sets ``H_mix = H_split -/+ sum Q`` and + whether the remix is isothermal. Fills the records' enthalpies and + 1-based positions in the flat flow order; returns the utility of every + stream (at its outlet end). + """ + utility = [0.] * N + for j in range(N): + c = curves[j] + if c is None: + continue + hot = c.hot + H = c.H_hi if hot else c.H_lo + pos = 0 + for item in paths[j]: + if not isinstance(item, Split): + pos += 1 + H = _step(recs[item], hot, H, 1., pos) + continue + item.H_split = H + ends = [] + for f, ns in zip(item.fractions, item.branches): + Hb = H + for n in ns: + pos += 1 + Hb = _step(recs[n], hot, Hb, f, pos) + ends.append(Hb) + D = math.fsum(recs[n].Q for ns in item.branches for n in ns) + H = H - D if hot else H + D + item.H_mix = H + item.isothermal = all(abs(x - H) <= _ISO_TOL * tolQ for x in ends) + utility[j] = H - c.H_lo if hot else c.H_hi - H + return utility + + +def _step(e, hot, H, f, pos): + """Walk record `e` on its hot or cold stream from enthalpy `H` on a + branch of fraction `f`; returns the enthalpy after it.""" + if hot: + e.H_hot_in = H + H = H - e.Q / f + e.H_hot_out = H + e.hot_seq = pos + else: + e.H_cold_in = H + H = H + e.Q / f + e.H_cold_out = H + e.cold_seq = pos + return H + + +def _approach_violation_split(ch, cc, e): + """ + Smallest ``T*_hot - T*_cold`` inside branch exchanger `e` (shifted + scale: >= 0 is feasible), like `_approach_violation`: duty ``t`` in + ``[0, Q]`` is at the parent-equivalent enthalpies ``H_hot_in - + t/hot_frac`` and ``H_cold_out - t/cold_frac``; a knot ``H`` of either + curve inside the exchanger at ``t = (H_hot_in - H) hot_frac`` or + ``(H_cold_out - H) cold_frac``. + """ + Q, fh, fc = e.Q, e.hot_frac, e.cold_frac + hin, cout = e.H_hot_in, e.H_cold_out + 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(([0., Q], (hin - Hh) * fh, (cout - Hc) * fc)) + return float((np.interp(hin - t / fh, ch.H, ch.T) + - np.interp(cout - t / fc, cc.H, cc.T)).min()) + + +def _split_records(sides, plans, curves, N, Qmin, tolQ): + """ + Plan records of a network with split sides (`plan_network`). + + A side without cells converts its pieces as `plan_network` does, + including the `Qmin` filter; a split side's cells merge into its + exchangers (`_merge_cells`) and are never dropped for `Qmin`. The walk + (`_paths`, `_walk_paths`) and the safety net are those of + `plan_network` with fractions (`_approach_violation_split`); a record + the safety net drops (a bug) is reported in `dropped`. + + Returns + ------- + recs, qmin_dropped, dropped, stages, utility, min_dT, splits, paths + As in `plan_network`, plus the splits and the paths. + """ + recs, qmin_dropped = [], [] + for name in ('above', 'below'): + if name not in plans: + continue + side, p = sides[name], plans[name] + if p.cells: + cells = _merge_cells(p.cells, side.tolQ) + else: + cells = [] + for i, j, a0, b0, Q in _merge(p.pieces): + if Q < Qmin: + qmin_dropped.append((name, *side.hot_cold(i, j), Q)) + continue + cells.append(_Cell(i, j, Q, a0, b0)) + recs += [_record(side, c) for c in cells] + dropped, ghosts = [], [] + while True: + stages, paths, splits = _paths(recs, N, ghosts) + utility = _walk_paths(curves, recs, paths, N, tolQ) + worst = None + min_dT = math.inf + for n, e in enumerate(recs): + v = _approach_violation_split(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]) + ghosts.append(e) + dropped.append((e.side, e.hot, e.cold, e.Q, worst[0])) + return (recs, qmin_dropped, dropped, stages, utility, min_dT, splits, + paths) diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 74d04d0..fb3907e 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -8,13 +8,15 @@ # for license details. """ 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`). +(`problem_table`), the synthesis of a network at minimum energy requirement +(`synthesize_network`, on the planner of `hensmith._planner`), unsplit by +default and with stream splits where needed if `stream_splitting` (on +`hensmith._splitting`; realized splits are `StreamSplit`), stream life +cycles (`StreamLifeCycle`) and pinch diagrams (`plot_pinch_diagram`). """ from collections import namedtuple import heapq +import math import re import numpy as np import biosteam as bst @@ -22,6 +24,7 @@ from ._curves import (StreamCurve, stream_curves, _end_state, _T_EQ, _T_SIDE, _copy, _point_load_inlet) from ._planner import plan_network +from ._splitting import Split, _ISO_TOL, _same_network __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', 'synthesize_network', 'plot_pinch_diagram') @@ -44,6 +47,61 @@ def _stream_ports(unit): if match: return (int(match.group(1)),) return None +#: An inlet port, ``unit.ins[index]`` (see `StreamLifeCycle.entry`). +_Port = namedtuple('_Port', ('unit', 'index')) + +def _flow_order(trunk, splits, branches): + """ + A life cycle with splits in flow order: ``(None, stage)`` for every + stage of `trunk` (the stages on the whole flow, in flow order, the + utility last) and ``(split, branches[k])`` for split k of `splits` (in + flow order; `branches[k]` holds the stages of each of its branches), + placed after ``split.position`` trunk process stages and before the + utility. + """ + flow, k = [], 0 + for t, stage in enumerate(trunk): + utility = isinstance(stage.unit, bst.HXutility) + while k < len(splits) and (utility or splits[k].position <= t): + flow.append((splits[k], branches[k])) + k += 1 + flow.append((None, stage)) + flow.extend(zip(splits[k:], branches[k:])) + return flow + +def _split_flow(stages, splits, flow_order, index): + """ + Tag the `stages` of stream `index` on the branches of its `splits` (in + flow order) with their branch and fraction, sort the others (the + trunk) by `flow_order` with the utility last, and return + `_flow_order`'s flow of them. + """ + tags = {id(hx): (k, b, f) for k, split in enumerate(splits) + for b, (f, hxs) in enumerate(zip(split.fractions, split.branches)) + for hx in hxs} + tagged, trunk = {}, [] + for stage in stages: + if id(stage.unit) in tags: tagged[id(stage.unit)] = stage + else: trunk.append(stage) + branches = [] + for split in splits: + branches.append([]) + for hxs in split.branches: + branch = [] + for hx in hxs: + stage = tagged.get(id(hx)) + if stage is None: + raise ValueError(f'{hx.ID} of split {split!r} does ' + f'not carry stream {index}') + k, b, stage.fraction = tags[id(hx)] + stage.branch = (k, b) + branch.append(stage) + branches[-1].append(branch) + trunk.sort(key=flow_order) + utilities = [s for s in trunk if isinstance(s.unit, bst.HXutility)] + trunk = [s for s in trunk if not isinstance(s.unit, bst.HXutility)] + return _flow_order(trunk + utilities, splits, branches) + class LifeStage: """ One stage of a stream's passage through the synthesized network: the @@ -57,6 +115,13 @@ class LifeStage: index : int Position of the stream in `unit.ins` / `unit.outs` (0 or 1 for an `HXprocess`; always 0 for an `HXutility`). + branch : tuple[int, int], optional + ``(k, b)`` for a stage on branch b of the stream's split k (see + `StreamLifeCycle.splits`); None (the default) for a stage on the + whole flow. + fraction : float, optional + Fraction of the stream's flow through this stage: that of its + branch, 1 (the default) on the whole flow. Attributes ---------- @@ -65,15 +130,18 @@ class LifeStage: s_out : Stream `unit.outs[index]`, the stream leaving this stage. H_in : float - Enthalpy of `s_in` [kJ/hr], read from the stream when accessed. + Enthalpy of `s_in` [kJ/hr], read from the stream when accessed + (on a branch, the branch's: `fraction` of the whole flow's). H_out : float Enthalpy of `s_out` [kJ/hr], read from the stream when accessed. """ - - def __init__(self, unit, index): + + def __init__(self, unit, index, branch=None, fraction=1.): self.unit = unit self.index = index + self.branch = branch + self.fraction = fraction @property def s_in(self): return self.unit.ins[self.index] @@ -87,14 +155,19 @@ def H_in(self): return self.s_in.H @property def H_out(self): return self.s_out.H + def _branch_info(self): + branch = self.branch + if branch is None: return '' + return f", branch {branch}, fraction {self.fraction:.4g}" + def _info(self, N_tabs=1): tabs = N_tabs*'\t' - return (f"{type(self).__name__}: {self.unit.ID}\n" + return (f"{type(self).__name__}: {self.unit.ID}{self._branch_info()}\n" + tabs + f"H_in = {self.H_in:.3g} kJ/hr\n" + tabs + f"H_out = {self.H_out:.3g} kJ/hr") def __repr__(self): - return (f"<{type(self).__name__}: {repr(self.unit)}, H_in = {round(self.H_in, 4):.3g} kJ/hr, H_out = {round(self.H_out, 4):.3g} kJ/hr>") + return (f"<{type(self).__name__}: {repr(self.unit)}{self._branch_info()}, H_in = {round(self.H_in, 4):.3g} kJ/hr, H_out = {round(self.H_out, 4):.3g} kJ/hr>") def show(self): print(self._info()) @@ -136,6 +209,18 @@ class StreamLifeCycle: ``'s_'``, the prefix of the stream's copies in the network. life_cycle : list[LifeStage] or None Stages in flow order, set by `get_life_cycle`; None until then. + Where the stream splits, the stages of every branch follow those + before the split, branch by branch (see `get_life_cycle`). + splits : list[StreamSplit] + The stream's splits in flow order, set by `get_life_cycle`; empty + for an unsplit stream. Split k's branch stages have ``branch == + (k, b)``. + entry : tuple[Unit, int] or None + ``(unit, index)``, a named tuple: the inlet port `unit.ins[index]` + where the whole stream enters the network, i.e. the first + splitter of its first split if it splits at its inlet, else its + first stage's. None until `get_life_cycle` runs, and for a life + cycle whose `life_cycle` is assigned directly. Notes ----- @@ -145,13 +230,30 @@ class StreamLifeCycle: `plot_pinch_diagram` draws them. """ - + # Defaults for an instance without them, e.g. one unpickled from before + # stream splitting: an unsplit stream with no entry port recorded. + splits = () + entry = None + def __init__(self, index, cold): self.index = index self.name = 's_%s'%index self.cold = cold self.life_cycle = None - + self.splits = [] + self.entry = None + + @property + def H_in(self): + """Enthalpy of the stream at `entry` [kJ/hr], read from the stream + when accessed: the whole flow's inlet, equal to the first stage's + `H_in` unless the stream splits at its inlet. Without an `entry` + (a `life_cycle` assigned directly), the first stage's `H_in`.""" + entry = self.entry + if entry is None: return self.life_cycle[0].H_in + unit, index = entry + return unit.ins[index].H + def get_relevant_units(self, index, new_HXs, new_HX_utils): """ Return the process and utility exchangers (two lists) that carry @@ -167,7 +269,7 @@ def 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): + def get_life_cycle(self, new_HXs, new_HX_utils, splits=None): """ Build and return the list of `LifeStage` objects for this stream. @@ -177,6 +279,11 @@ def get_life_cycle(self, new_HXs, new_HX_utils): Process exchangers of the synthesized network. new_HX_utils : list[HXutility] Utility exchangers of the synthesized network. + splits : list[StreamSplit], optional + Splits of the network (``info['splits']`` of + `synthesize_network`); those of other streams are ignored. + Without any split of this stream, the life cycle is built + exactly as for an unsplit network. Returns ------- @@ -192,6 +299,22 @@ def get_life_cycle(self, new_HXs, new_HX_utils): (cold-side stages for a cold stream, hot-side stages for a hot one) and the utility last. Also stored as `life_cycle`. + With splits, only the trunk stages (those on the whole flow, + which pass the stream's enthalpies in order) are sorted so; + the exchangers of split k's branches (``StreamSplit.branches``, + by identity) follow the first ``StreamSplit.position`` trunk + process stages, branch by branch and each branch in flow + order, as stages with ``branch == (k, b)`` and the branch's + `fraction`; the utility stays last. Enthalpies of different + branches are never compared. The stream's splits are stored, + in flow order, as `splits`, and its entry port as `entry`. + + Raises + ------ + ValueError + If an exchanger of one of the stream's splits does not carry + the stream. + """ index = self.index name = self.name @@ -215,16 +338,108 @@ def flow_order(stage): 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) + splits = [split for split in splits or () if split.stream == index] + if splits: + # flow order: by position, then (consecutive splits) by the + # enthalpy where they split + splits.sort(key=lambda split: (split.position, + sign * split.H_split)) + flow = _split_flow(life_cycle, splits, flow_order, index) + life_cycle = [stage for split, part in flow + for stage in ([part] if split is None + else [s for b in part for s in b])] + else: + life_cycle.sort(key=flow_order) + flow = [(None, stage) for stage in life_cycle[:1]] self.life_cycle = life_cycle + self.splits = splits + if flow: + split, part = flow[0] + self.entry = (_Port(part.unit, part.index) if split is None + else _Port(split.splitters[0], 0)) + else: + self.entry = None return life_cycle - + + def _flow(self): + """The life cycle in flow order as ``(None, stage)`` for every trunk + stage and ``(split, branches)`` for every split, `branches` being + the stages of each of its branches (see `_flow_order`).""" + splits = self.splits + branches = [[[] for hxs in split.branches] for split in splits] + trunk = [] + for stage in self.life_cycle: + branch = stage.branch + if branch is None: trunk.append(stage) + else: branches[branch[0]][branch[1]].append(stage) + return _flow_order(trunk, splits, branches) + + def connections(self): + """ + Yield the stream's connections in flow order, as ``(up_unit, + up_port, down_unit, down_port)``: ``up_unit.outs[up_port]`` feeds + ``down_unit.ins[down_port]``. Wiring every connection of every + life cycle joins the network. + + For an unsplit stream, these are its consecutive stages. At a + split: the stage before it feeds the first splitter (port 0); + splitter c's second outlet feeds splitter c + 1; the outlet of + branch b (``StreamSplit.outlet``) feeds the branch's first stage, + or inlet b of the mixer for a branch without exchangers (a + bypass); each branch stage feeds the next, the last one mixer + inlet b; the mixer's outlet feeds the next stage (or the next + split's first splitter). + """ + up = None # (unit, port) feeding the next node + for split, part in self._flow(): + if split is None: + if up is not None: yield (*up, part.unit, part.index) + up = (part.unit, part.index) + continue + splitters = split.splitters + if up is not None: yield (*up, splitters[0], 0) + for a, b in zip(splitters, splitters[1:]): yield (a, 1, b, 0) + for b, stages in enumerate(part): + end = split.outlet(b) + for stage in stages: + yield (*end, stage.unit, stage.index) + end = (stage.unit, stage.index) + yield (*end, split.mixer, b) + up = (split.mixer, 0) + + def stage_pairs(self): + """ + Yield the pairs ``(a, b)`` of stages where the stream flows from + `a` into `b` directly or through splitters and mixers only: for an + unsplit stream, its consecutive stages. The stage before a split + precedes the first stage of every branch, and the last stage of + every branch the stage after the split; a bypass carries the stages + before its split past it. Stages on sibling branches make no pair. + """ + frontier = [] # the stages whose outflow reaches the next node + for split, part in self._flow(): + if split is None: + for stage in frontier: yield stage, part + frontier = [part] + continue + ends = [] + for stages in part: + if stages: + for stage in frontier: yield stage, stages[0] + yield from zip(stages, stages[1:]) + last = [stages[-1]] + else: # a bypass + last = frontier + ends.extend([stage for stage in last + if not any(stage is end for end in ends)]) + frontier = ends + def __repr__(self): life_cycle = self.life_cycle cold = self.cold if not self.life_cycle: return 'Not initialized; run StreamLifeCycle.get_life_cycle or\ - HX_Network.get_stream_life_cycles first.' + HX_Network.get_stream_life_cycles first.' else: index = self.index name = 'Stream_%s'%index @@ -233,8 +448,12 @@ def __repr__(self): for LifeStage in life_cycle: line = '\t\t' + repr(LifeStage) + '\n' rep += line - rep = '' - return rep + rep = '' def show(self): """Print the life cycle, one stage per line.""" @@ -496,9 +715,10 @@ def _pinch_cut(table): 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.) + one. (`synthesize_network` does not cut streams at the pinch, even + with `stream_splitting`, whose splits are parallel branches: 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]) @@ -669,7 +889,8 @@ def pinch_state(stream_in, stream_out, T_pinch, side=None, curve=None): have. A standalone analysis helper (see also `load_duties`): the network - synthesis does not split streams at a pinch temperature, it plans on + synthesis does not cut streams at a pinch temperature (its only + splits, with `stream_splitting`, are parallel branches), it plans on the curves themselves (see `synthesize_network`). """ if side is None: @@ -727,6 +948,19 @@ def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, _ACHIEVED_TOL = 1e-6 #: Rounds of exact-state verification and local knot refinement. _MAX_REFINE = 3 +#: Extra rounds with stream splitting after the last refine round, each run +#: only if a violating exchanger lies on a split side whose network +#: (`hensmith._splitting._same_network`) is newly excluded. The retry +#: re-plans that side without its networks excluded so far: another network +#: if the side has one left, else the same one on the refined knots (a side's +#: last candidate is never excluded), after which no network is new. +_MAX_SPLIT_RETRY = 2 +#: A split's mixer outlet further than this from the planned temperature [K] +#: is a deviation: 133 times the offset of a branch-flow PH flash (7.5e-10 +#: K) and 10 times below the approach guard `_APPROACH_TOL`. It catches +#: thermosteam's PH flash disagreeing with the stream's curve (risk R-2), +#: measured at 0.8-2.4 K inside random water/methanol glides. +_MIX_T_TOL = 1e-7 def _grid_knots(curves, grid): """ @@ -804,12 +1038,23 @@ def _interval_min(f, a, fa, b, fb): return fx def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, - T_min_app): + T_min_app, fh=1., fc=1.): """ 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). + With stream splitting, `fh` and `fc` are the flow fractions of the hot + and the cold branch in the exchanger: a branch at fraction f has its + parent's states at f times the parent's enthalpy flow, so the duty + moves its parent-equivalent enthalpy by ``Q / f``. The duty position q + in [0, Q] (from the hot inlet and the cold outlet end) lies at + ``H_hot_in - q / fh`` and ``H_cold_out - q / fc``, a knot H at + ``(H_hot_in - H) fh`` or ``(H_cold_out - H) fc``; the states returned + are parent-equivalent (what `_refine_knots` takes). At f = 1 every + expression is the unsplit one, bit for bit (division and + multiplication by 1. are exact). + 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 @@ -830,15 +1075,15 @@ def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, 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 + H_hot_out, H_cold_out = H_hot_in - Q / fh, H_cold_in + Q / fc 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)]) + qs.extend((H_hot_in - H[(H > H_hot_out) & (H < H_hot_in)]) * fh) 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.extend((H_cold_out - H[(H > H_cold_in) & (H < H_cold_out)]) * fc) 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)) + planned = (_knot_T(knots[h], H_hot_in - qs / fh, True) + - _knot_T(knots[c], H_cold_out - qs / fc, False)) near = planned < T_min_app + _curve_tol_T(hot) + _curve_tol_T(cold) + 1e-9 limit = T_min_app - _APPROACH_TOL points = [] @@ -846,11 +1091,12 @@ def _exchanger_approach(curves, knots, h, c, H_hot_in, H_cold_in, Q, 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') + qh, qc = q / fh, q / fc + T_hot = hot.T_exact(hot.H_lo + H_hot_in - qh, 'low') + T_cold = cold.T_exact(cold.H_lo + H_cold_out - qc, '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)) + points.append((T_hot, H_hot_in - qh, T_cold, H_cold_out - qc)) return states[q] worst = float(planned[~near].min()) if not near.all() else np.inf @@ -868,7 +1114,8 @@ def exact(q): 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`). + ``plan.exchangers``) at the enthalpies of the walk `ends` (see `_walk`), + with the flow fractions of its branches (1 on a trunk). Returns the smallest approach [K], the violating exact states by stream, ``{stream: [(T_exact, H - H_lo), ...]}``, and the violating exchangers. """ @@ -879,7 +1126,8 @@ def _exact_approach(plan, duties, ends, curves, knots, T_min_app): e = plan.exchangers[n] h, c = e.hot, e.cold approach, points = _exchanger_approach( - curves, knots, h, c, ends[n, h][0], ends[n, c][0], Q, T_min_app + curves, knots, h, c, ends[n, h][0], ends[n, c][0], Q, T_min_app, + e.hot_frac, e.cold_frac ) worst = min(worst, approach) if points: bad.append(n) @@ -888,30 +1136,34 @@ def _exact_approach(plan, duties, ends, curves, knots, T_min_app): 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): +def _shrink(curves, knots, h, c, H_hot_in, H_cold_in, Q, T_min_app, + fh=1., fc=1.): """ Largest duty ``Q' <= Q`` (to 1e-9 of Q) at which the exchanger of - `_exchanger_approach` 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. + `_exchanger_approach` (branches at flow fractions `fh` and `fc`) 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. On branches too: duty position q lies at + ``H_cold_in + (Q - q) / fc`` on the cold branch, which falls with Q, + and at ``H_hot_in - q / fh`` on the hot one, whatever Q. """ def ok(x): return not _exchanger_approach(curves, knots, h, c, H_hot_in, - H_cold_in, x, T_min_app)[1] + H_cold_in, x, T_min_app, fh, fc)[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): + H_cold_in, Q, T_min_app, fh, fc) + CP = 0. # of the branches: f times the parent's + for j, f in ((h, fh), (c, fc)): 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())) + if slopes.size: CP = max(CP, f * 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: @@ -927,8 +1179,11 @@ def _repair(plan, duties, knots, is_hot, curves, T_min_app): 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)]``. + the loop only guards against rounding. A branch exchanger shrinks with + its flow fractions; its later branch stages and the mix (at the split + enthalpy -/+ the surviving branch duties, see `_walk`) move toward the + inlet too. Returns the new duties and the changes, ``[(n, Q_before, + Q_after)]``. """ duties = dict(duties) changes = [] @@ -940,7 +1195,8 @@ def _repair(plan, duties, knots, is_hot, curves, T_min_app): e = plan.exchangers[n] ends = _walk(plan, duties, knots, is_hot)[0] Q = _shrink(curves, knots, e.hot, e.cold, ends[n, e.hot][0], - ends[n, e.cold][0], duties[n], T_min_app) + ends[n, e.cold][0], duties[n], T_min_app, + e.hot_frac, e.cold_frac) changes.append((n, duties[n], Q)) duties[n] = Q return duties, changes @@ -1030,6 +1286,75 @@ def __init__(self, n, ID, error): super().__init__(n, ID, error) self.n, self.ID, self.error = n, ID, error +class _SplitRealizationError(Exception): + """A splitter or the mixer of split `k` (index into ``plan.splits``) + could not be simulated; `branch_exchangers` holds its branch exchangers + as ``(index into plan.exchangers, ID)``.""" + def __init__(self, k, branch_exchangers, error): + super().__init__(k, branch_exchangers, error) + self.k, self.branch_exchangers = k, branch_exchangers + self.error = error + +class StreamSplit: + """ + A split of one process stream into parallel branches that re-join in + a mixer, as realized by `synthesize_network` (``info['splits']``). + + Attributes + ---------- + stream : int + The stream's index. + side : {'above', 'below'} + The side of the pinch. + index : int + Ordinal of the split among the stream's splits on its side, from 1 + in flow order (its IDs end in ``_`` from 2 on). + fractions : tuple[float] + Flow fraction of every branch; they sum to 1 within round-off. + splitters : list[biosteam.Splitter] + The splitter chain, IDs ``Split__[_]`` + (element 1), then that plus ``_b`` (element c): element c sends + ``f_c / (f_c + ... + f_n)`` of its feed to its first outlet (branch + c - 1) and the rest, by its second outlet, to element c + 1; the + last element's second outlet is the last branch. + mixer : biosteam.Mixer + The rigorous mixer where the branches re-join (inlet b is branch + b), ID ``Mix__[_]``. + branches : list[list[HXprocess]] + The process exchangers of every branch, in flow order. A branch + whose exchangers were all dropped is empty: its splitter outlet + feeds the mixer directly (a bypass). + position : int + Number of the stream's trunk process exchangers before the split. + isothermal : bool + Every branch ends at the stream's state at `H_mix`: the branches + re-join at one temperature. + H_split, H_mix : float + Planned full-flow enthalpies of the stream where it splits and + where it re-joins [kJ/hr]. + """ + __slots__ = ('stream', 'side', 'index', 'fractions', 'splitters', + 'mixer', 'branches', 'position', 'isothermal', 'H_split', + 'H_mix') + + def __init__(self, stream, side, index, fractions, splitters, mixer, + branches, position, isothermal, H_split, H_mix): + self.stream, self.side, self.index = stream, side, index + self.fractions, self.splitters, self.mixer = (fractions, splitters, + mixer) + self.branches, self.position = branches, position + self.isothermal, self.H_split, self.H_mix = isothermal, H_split, H_mix + + def outlet(self, b): + """``(splitter, port)`` where branch `b` leaves the chain.""" + return ((self.splitters[b], 0) if b < len(self.splitters) + else (self.splitters[-1], 1)) + + def __repr__(self): + fractions = ', '.join(f'{f:.4g}' for f in self.fractions) + return (f'') + def _walk(plan, duties, knots, is_hot): """ Enthalpies (relative to each stream's H_lo) at which every stream enters @@ -1040,18 +1365,42 @@ def _walk(plan, duties, knots, is_hot): (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. + + With stream splitting (``plan.splits``), every stream is walked along + ``plan.paths``: a split's branches start at the split enthalpy and a + branch exchanger of flow fraction f moves the branch by its duty over + f, so its `ends` are parent-equivalent (the enthalpies of the full flow + in the branch's state); the stream re-joins at the split enthalpy -/+ + the sum of the surviving branch duties, which the fractions leave free + of round-off. A dropped branch exchanger shortens its branch (a branch + left without exchangers bypasses), and later stages move toward the + inlet by the duty lost (`_split_nodes` gives the split enthalpies). """ 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) + if not plan.splits: + 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) + else: + for j, hot in enumerate(is_hot): + H = knots[j][1][-1] if hot else 0. + for item in plan.paths[j]: + if isinstance(item, Split): + H = _walk_split(item, duties, ends, H, hot) + continue + if item not in duties: continue + Q = duties[item] + H_next = H - Q if hot else H + Q + ends[item, j] = (H, H_next) + H = H_next + last.append(H) count = {} pair_index = {} for j, hot in enumerate(is_hot): @@ -1063,6 +1412,87 @@ def _walk(plan, duties, knots, is_hot): count[key] = pair_index[n] = count.get(key, 0) + 1 return ends, last, pair_index +def _branch_duty(split, duties): + """Sum (exactly rounded) of the duties of `split`'s branch exchangers + in `duties`.""" + return math.fsum([duties[n] for ns in split.branches for n in ns + if n in duties]) + +def _walk_split(split, duties, ends, H, hot): + """ + `_walk` over `split` (a `hensmith._splitting.Split` of a stream that is + cooled if `hot`) from the split enthalpy `H`: fill the `ends` of its + branch exchangers in `duties`, each branch from `H` by duty over + fraction, and return the enthalpy where the branches re-join. + """ + j = split.stream + for f, ns in zip(split.fractions, split.branches): + Hb = H + for n in ns: + if n not in duties: continue + Q = duties[n] / f + H_next = Hb - Q if hot else Hb + Q + ends[n, j] = (Hb, H_next) + Hb = H_next + D = _branch_duty(split, duties) + return H - D if hot else H + D + +def _split_nodes(plan, duties, ends): + """ + Where the streams of a plan with splits (``plan.splits``) split and + re-join, for the exchangers in `duties` and their walk `ends` (see + `_walk`, whose arithmetic this repeats). + + Returns + ------- + first_nodes : dict[int, tuple[int]] + For every stream, the first exchanger of every branch of the split + that is its first node (the first item of ``plan.paths[j]`` with an + exchanger in `duties`), in branch order: they all take the stream's + real inlet (`_realize`), as that split's splitter chain does + (`_realize_splits`). Empty if the first node is not a split. + split_ends : list[tuple or None] + For every split of ``plan.splits``, ``(H_split, [H_end, ...], + H_mix)``: the enthalpies (relative to the stream's H_lo) where it + splits, where each branch ends (a parent-equivalent enthalpy; + ``H_split`` for a branch without exchangers, which bypasses) and + where the branches re-join. None for a split whose branches have + no exchanger left: it is not realized, the stream passes it + unchanged. + first_splits : dict[int, int] + For every stream whose first node is a split, that split's index + into ``plan.splits`` (the one rule for both of the above). + """ + split_ends = [] + for split in plan.splits: + j = split.stream + live = [[n for n in ns if n in duties] for ns in split.branches] + n = next((ns[0] for ns in live if ns), None) + if n is None: + split_ends.append(None) + continue + H = ends[n, j][0] + D = _branch_duty(split, duties) + H_mix = H - D if plan.exchangers[n].hot == j else H + D + split_ends.append((H, [ends[ns[-1], j][1] if ns else H + for ns in live], H_mix)) + order = {id(split): k for k, split in enumerate(plan.splits)} + first_nodes, first_splits = {}, {} + for j, path in plan.paths.items(): + first_nodes[j] = () + for item in path: + if not isinstance(item, Split): + if item in duties: break + continue + k = order[id(item)] + if split_ends[k] is not None: + first_nodes[j] = tuple( + next(n for n in ns if n in duties) + for ns in item.branches if any(n in duties for n in ns)) + first_splits[j] = k + break + return first_nodes, split_ends, first_splits + def _discard(units): """Remove units, and the streams connected to them, from the registry of the active flowsheet (after a failed realization).""" @@ -1078,10 +1508,19 @@ def _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app): 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). + + A branch exchanger of a split stream (flow fraction f) runs f of the + stream's flow in the states of the full stream: its inlet is the full + stream's state at the branch's (parent-equivalent) inlet enthalpy, + scaled by f, and its enthalpy limit f times the full stream's at the + planned outlet (`_enthalpy_limit` judges the full-flow state). The + first exchanger of every branch of a split at a stream's inlet takes + the real inlet (`_split_nodes`). """ ends, last, pair_index = _walk(plan, duties, knots, is_hot) first = [next((n for n in plan.stages[j] if n in duties), None) for j in range(len(is_hot))] + first_nodes = _split_nodes(plan, duties, ends)[0] if plan.splits else {} units = {} dT = T_min_app - _APPROACH_TOL for n in sorted(duties): @@ -1091,26 +1530,31 @@ def _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app): if e.side == 'above': ID = f'HX_{c}_{h}_hs{suffix}' ports = (c, h) + fractions = (e.cold_frac, e.hot_frac) else: ID = f'HX_{h}_{c}_cs{suffix}' ports = (h, c) + fractions = (e.hot_frac, e.cold_frac) ins, outs, H_lims = [], [], [] - for j in ports: + for j, f in zip(ports, fractions): curve = curves[j] H_in, H_out = ends[n, j] - if first[j] == n: + if first[j] == n or n in first_nodes.get(j, ()): 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])) + H_lim = _enthalpy_limit(curve, s, curve.H_lo + H_out, is_hot[j]) + if f != 1.: # a branch: f of the flow, in the same states + s.scale(f) + if H_lim is not None: H_lim *= f + ins.append(s) + outs.append(s.copy(f'{ID}__s_{j}')) + H_lims.append(H_lim) 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 @@ -1121,15 +1565,140 @@ def _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app): raise _RealizationError(n, ID, error) return units, first, last +def _realize_splits(plan, duties, units, curves, knots, streams_inlet, + is_hot): + """ + Build and run, once each, the splitter chain and the rigorous mixer of + every split of ``plan.splits`` that keeps an exchanger in `duties` + (duty by index into ``plan.exchangers``), after `_realize` built its + exchangers `units` (by exchanger index). A split without one is not + realized: the stream passes it as a trunk. + + The splitter chain's feed is the stream's real inlet if the split is + its first node (as its branches' first exchangers, `_split_nodes`), + else its state at the split enthalpy. The mixer's inlet b carries + fraction f_b of the flow: all in the stream's state at the mix + enthalpy if every branch ends there (an isothermal re-join), else each + in the state at its branch's end (parent-equivalent, see `_walk`). Its + outlet starts at the planned state (the full stream at the mix + enthalpy), which only speeds up the flash: where thermosteam's PH flash + disagrees with the stream's curve (inside some two-phase glides), the + outlet lands off the plan whatever its start, and the check on T + reports it. Such a mixer is reported, not undone: the PH flash at that + enthalpy lands there from every start state (measured), so a process + exchanger of the stream ending there would too, split or not. + + Returns + ------- + splits : list[StreamSplit] + The realized splits, in the order of ``plan.splits``. + deviations : list[dict] + The mixers whose outlet is off the plan, each as ``dict(ID, + T_plan, T, H_plan, H)``: ``|H - H_plan|`` above `_DUTY_TOL` times + the stream's duty, ``H_plan`` being the sum of its inlets' + enthalpies (the binding check for real thermo, whose rigorous + outlet is H of the converged T), or ``|T - T_plan|`` above + `_MIX_T_TOL`, ``T_plan`` being the planned state's temperature. + + Raises + ------ + _SplitRealizationError + If a splitter or a mixer cannot be simulated, after discarding the + units built for the splits and `units`. + """ + ends = _walk(plan, duties, knots, is_hot)[0] + _, split_ends, first_splits = _split_nodes(plan, duties, ends) + tolQ = plan.info['tolQ'] + # each realized split's ordinal (per stream and side, from 1 in flow + # order) and the stream's trunk exchangers before it + order = {id(split): k for k, split in enumerate(plan.splits)} + where = {} + for path in plan.paths.values(): + count, trunk = {}, 0 + for item in path: + if not isinstance(item, Split): + trunk += item in duties + continue + k = order[id(item)] + if split_ends[k] is None: continue + count[item.side] = count.get(item.side, 0) + 1 + where[k] = (count[item.side], trunk) + splits, deviations, built = [], [], [] + + def run(unit, k, live): + try: + unit._run() + except Exception as error: + branch_exchangers = [(n, units[n].ID) for ns in live for n in ns] + _discard([*built, *units.values()]) + raise _SplitRealizationError(k, branch_exchangers, error) + for k, split in enumerate(plan.splits): + if split_ends[k] is None: continue + H_split, H_ends, H_mix = split_ends[k] + j, fractions = split.stream, split.fractions + curve = curves[j] + index, position = where[k] + base = f"Split_{j}_{'hs' if split.side == 'above' else 'cs'}" + if index > 1: base += f'_{index}' + mix_ID = 'Mix' + base[len('Split'):] + live = [[n for n in ns if n in duties] for ns in split.branches] + if first_splits.get(j) == k: # the stream's first node + 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_split) + s.ID = f's_{j}__{base}' + splitters = [] + for c in range(1, len(fractions)): + ID = base if c == 1 else f'{base}_b{c}' + # tail sums: no cancellation, and the last element's rest is + # exactly its complement + r = fractions[c - 1] / math.fsum(fractions[c - 1:]) + splitter = bst.Splitter(ID, ins=s, outs=(s.copy(f'{ID}__s_{j}_0'), + s.copy(f'{ID}__s_{j}_1')), + split=r, thermo=s.thermo) + built.append(splitter) + splitters.append(splitter) + run(splitter, k, live) + s = splitter.outs[1] + isothermal = all(abs(H - H_mix) <= _ISO_TOL * tolQ for H in H_ends) + ins = [] + for b, (f, H) in enumerate(zip(fractions, H_ends)): + s = curve.state_at_H(curve.H_lo + (H_mix if isothermal else H)) + s.ID = f's_{j}_{b}__{mix_ID}' + s.scale(f) + ins.append(s) + outlet = curve.state_at_H(curve.H_lo + H_mix) # the planned state + outlet.ID = f'{mix_ID}__s_{j}' + T_plan = outlet.T + mixer = bst.Mixer(mix_ID, ins=ins, outs=outlet, rigorous=True, + thermo=outlet.thermo) + built.append(mixer) + run(mixer, k, live) + out = mixer.outs[0] + H_plan = math.fsum([s.H for s in mixer.ins]) + if (abs(out.H - H_plan) > _DUTY_TOL * abs(curve.H_out - curve.H_in) + or abs(out.T - T_plan) > _MIX_T_TOL): + deviations.append(dict(ID=mix_ID, T_plan=T_plan, T=out.T, + H_plan=H_plan, H=out.H)) + splits.append(StreamSplit( + j, split.side, index, tuple(fractions), splitters, mixer, + [[units[n] for n in ns] for ns in live], position, isothermal, + curve.H_lo + H_split, curve.H_lo + H_mix)) + return splits, deviations + def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, - avoid_recycle=False, sort_hus_by_T=False, info=None): + avoid_recycle=False, sort_hus_by_T=False, info=None, + stream_splitting=False): """ - 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. + Synthesize a heat exchanger network 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. By default no stream is split; with `stream_splitting`, a + side of the pinch that no unsplit network serves at MER is planned with + stream splits and reaches the targets. Parameters ---------- @@ -1187,7 +1756,32 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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). + equilibrium at its inlet enthalpy, see Notes). With + `stream_splitting` also 'stream_splitting' (True), 'splits' (the + realized splits, a list of `StreamSplit`) and 'split_deviations' + (mixers whose outlet is off the planned state; normally empty); + each side in 'sides' then also has 'split' (None for a side that + did not try to split, else the chosen candidate and its network + signature, both None if no candidate was found, see + `hensmith._planner.Plan`) and its method is 'split-' + when it splits. Required with `stream_splitting`, which raises a + ValueError without it (the splitters and mixers are returned only + there). + stream_splitting : bool, optional + Allow a process stream to be split into parallel branches that + re-join. A side of the pinch that no unsplit network serves at the + minimum energy requirement (a pinch-rule proof, or an unsplit + search that leaves a utility penalty) is planned with splits + instead, and then reaches the MER targets exactly on the planner's + knots (see Notes, "Stream splitting"). Sides that an unsplit + network serves are never split, so a problem that needs no split + gets the same network as with the default. The branches are + realized with `biosteam.Splitter` chains and rigorous + `biosteam.Mixer` units, which are adiabatic and cost nothing; their + structure is reported in ``info['splits']`` (`synthesize_network`) + and ``synthesis_info['splits']`` (`HeatExchangerNetwork`). With + `avoid_recycle`, a split that would repeat a stream pair is not + used, and MER is then not guaranteed. Defaults to False. Returns ------- @@ -1231,7 +1825,9 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, network works on further copies, so these keep their inlet state. stream_HXs_dict : dict[int, list[Unit]] For each stream index, its process exchangers in flow order, then - its utility exchanger. + its utility exchanger. With splits, the order is topological: the + exchangers before a split, those of its branches (branch by + branch, each in flow order), then those after it. hot_indices, cold_indices : list[int] Stream indices of the hot and cold streams. @@ -1266,7 +1862,8 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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, + best-effort plan (unless `stream_splitting`, see "Stream splitting" + below): 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. @@ -1316,6 +1913,29 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, another match's approach. Constant heat capacity streams never need either step. + *Stream splitting.* With `stream_splitting`, a side that no unsplit + network serves at MER is planned with splits (see + `hensmith._splitting`). A branch exchanger of flow fraction f runs f of + its stream's flow in the full stream's states: its inlet is the + stream's state at the branch's inlet enthalpy scaled by f, and its + enthalpy limit f times the full stream's; the exact check follows each + branch on its parent's curve with enthalpy steps of duty over f. Each + split becomes a chain of `biosteam.Splitter` units (one fewer than its + branches, fed with the real inlet if the split is the stream's first + node) and a rigorous `biosteam.Mixer`, whose outlet starts at the + planned state; a mixer outlet off that state is reported in + ``info['split_deviations']``. Each refine round re-plans a split + side's previous pick first and keeps it while it plans the same + network (its signature, with the fractions the refined knots move). If + exchangers on a split side still violate after the last refine round, + that network is excluded and the side re-planned (at most two more + rounds): with another network if the side has one left, else with the + same one on the refined knots (a side's last candidate is never + excluded), which ends the retries. If that does worse, the best MER + plan of the rounds is restored. A split whose splitters or mixer + cannot be simulated becomes a trunk (its branch exchangers are + dropped, as in ``info['dropped']``). + *Guarantees and limits.* The utilities are never below the targets. Every process exchanger keeps ``T_min_app - 1e-6`` K on the exact states at its ends and at every checked position inside it, and the @@ -1329,16 +1949,43 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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']`). + of units. By default, problems that need stream splits get a + best-effort network whose penalty is small but not minimal in general, + and a side that needs splits without a pinch-rule proof spends its + whole MER budget before the best-effort step. + + With `stream_splitting` (and without `avoid_recycle`), every side that + no unsplit network serves has a split plan at MER on the planner's + knots: the vertical core of `hensmith._splitting` always yields one + (Theorem M there), up to the part of the root deficit that the + cascade's own tolerances already absorbed into the targets (at most + about 1e-9 of the total duty). The remaining gap is the realization. + With constant heat capacities the knots are exact, and the realized + network reaches the targets to round-off. With real thermodynamics + the knots are chords, so a split plan can fall short of `T_min_app` on + the exact states by up to the chords' tolerance; the refine rounds + close that as they do for unsplit plans, with up to two more rounds + that exclude the violating network (see "Stream splitting" above), and + only an exchanger still short after them is shrunk (reported in + 'repaired', with status 'best_effort'). A rigorous mixer's outlet + enthalpy differs from the sum of its inlets by at most its flash + residual, which the stream's utility takes. The test suite reaches + 'mer' with splits on all 38 problems of its corpus that provably need + them (25 with constant heat capacities, 13 with real thermodynamics) + and on the three such regression systems. The number of exchangers is + minimized only among the candidate split plans a side generates, and + splitters and mixers are not costed. + + 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']`), and a mixer's outlet can land off its planned + state (reported in ``info['split_deviations']``). `HeatExchangerNetwork` calls this function, rewires each stream's - stages in series, converges the network as a `System` and costs it. + stages in series (the branches of a split in parallel, from its + splitter chain to its mixer), converges the network as a `System` and + costs it. Examples -------- @@ -1399,6 +2046,10 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, Wiley. Heat Exchanger Networks (Chapter 9). """ + if stream_splitting and info is None: + raise ValueError('stream_splitting=True needs an info dict: the ' + 'splitters and mixers are returned in ' + 'info["splits"]') 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 = \ @@ -1418,19 +2069,59 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, # 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). + # With stream splitting, every round re-plans a split side's previous + # pick first (stickiness); after the last refine round, up to + # _MAX_SPLIT_RETRY more rounds run while violating exchangers lie on + # split sides whose networks can still be excluded (_same_network), and + # if they end worse, the best MER plan of the rounds is restored. knots = _grid_knots(curves, grid) - for refine_round in range(_MAX_REFINE + 1): + exclude, prefer, best = {}, {}, None + n_rounds = _MAX_REFINE + (_MAX_SPLIT_RETRY if stream_splitting else 0) + for refine_round in range(n_rounds + 1): plan = plan_network(knots, is_hot, T_min_app, - avoid_recycle=avoid_recycle, Qmin=Qmin) + avoid_recycle=avoid_recycle, Qmin=Qmin, + **(dict(stream_splitting=True, + _split_exclude=exclude, + _split_prefer=prefer) + if stream_splitting else {})) 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 ) + if stream_splitting and plan.status == 'mer' and ( + best is None or len(bad) < best[0]): + best = (len(bad), plan, knots, duties, min_approach, bad) if (not violations or plan.status != 'mer' - or refine_round == _MAX_REFINE): break + or refine_round == n_rounds): break + if refine_round >= _MAX_REFINE: + # a retry (stream splitting only): exclude the networks of the + # split sides of the violating exchangers; if none is new, stop + # where the default loop stops + grew = False + for n in bad: + side = plan.exchangers[n].side + split = plan.info['sides'][side]['split'] + if split is None or split['candidate'] is None: continue + signature = split['signature'] + if not any(_same_network(signature, excluded) + for excluded in exclude.get(side, ())): + exclude.setdefault(side, set()).add(signature) + grew = True + if not grew: break + if stream_splitting: + prefer = {} + for side, side_info in plan.info['sides'].items(): + split = side_info['split'] + if split is not None and split['candidate'] is not None: + prefer[side] = (split['candidate'], split['signature']) knots = _refine_knots(knots, violations) + if (refine_round > _MAX_REFINE and best is not None + and (plan.status != 'mer' or len(bad) > best[0])): + # the retries did worse: back to the best MER plan (the fewest + # violating exchangers, the earliest round), with its knots + _, plan, knots, duties, min_approach, bad = best repaired = [] if bad: duties, changes = _repair(plan, duties, knots, is_hot, curves, @@ -1444,15 +2135,28 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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). + # the utilities: removing a match never reduces another's approach), and + # a split whose splitters or mixer cannot be simulated becomes a trunk + # (its branch exchangers are dropped). Each failure removes at least one + # exchanger, so the loop ends. dropped = [] while True: try: units, first, last = _realize(plan, duties, curves, knots, streams_inlet, is_hot, T_min_app) + splits, split_deviations = ( + _realize_splits(plan, duties, units, curves, knots, + streams_inlet, is_hot) + if plan.splits else ([], []) + ) except _RealizationError as failure: dropped.append(dict(ID=failure.ID, Q=duties.pop(failure.n), error=repr(failure.error))) + except _SplitRealizationError as failure: + for n, ID in failure.branch_exchangers: + if n in duties: + dropped.append(dict(ID=ID, Q=duties.pop(n), + error=repr(failure.error))) else: break HXs_hot_side = [units[n] for n in sorted(units) @@ -1530,6 +2234,9 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, dropped=dropped, repaired=repaired, point_loads=[i for i in indices if not curves[i].monotone], ) + if stream_splitting: + info.update(stream_splitting=True, splits=splits, + split_deviations=split_deviations) 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 @@ -1545,15 +2252,25 @@ def _order_exchanger_columns(hxs, stream_life_cycles): a topological sort (Kahn's algorithm, ties broken by the given order) yields a consistent layout. Contradictory constraints, which would need a stream to flow backwards, fall back to the given order. + + Exchangers left out of `hxs` pass the precedence on: two requested + exchangers of a stream are ordered whenever the stream flows from one + to the other. A life cycle with splits orders its exchangers by + `StreamLifeCycle.stage_pairs`, so the exchangers of sibling branches + are not ordered by that stream. """ hxs = list(hxs) position = {hx: i for i, hx in enumerate(hxs)} successors = {hx: [] for hx in hxs} N_predecessors = {hx: 0 for hx in hxs} for life_cycle in stream_life_cycles: - stages = [i.unit for i in life_cycle.life_cycle if i.unit in position] - if not life_cycle.cold: stages.reverse() - for a, b in zip(stages, stages[1:]): + if getattr(life_cycle, 'splits', None): + pairs = _split_precedence(life_cycle, position) + else: + stages = [i.unit for i in life_cycle.life_cycle if i.unit in position] + if not life_cycle.cold: stages.reverse() + pairs = zip(stages, stages[1:]) + for a, b in pairs: if b not in successors[a]: successors[a].append(b) N_predecessors[b] += 1 @@ -1568,6 +2285,31 @@ def _order_exchanger_columns(hxs, stream_life_cycles): if not N_predecessors[other]: heapq.heappush(ready, position[other]) return ordered if len(ordered) == len(hxs) else hxs +def _split_precedence(life_cycle, requested): + """ + Pairs ``(a, b)`` of `requested` exchangers of a life cycle with splits + where column `a` goes left of column `b`: the stream flows from `a` to + `b` (from `b` to `a` for a hot stream), through `stage_pairs` and the + stages not requested. Each requested stage is paired with the nearest + requested stages downstream, which gives the same precedence as their + transitive closure. + """ + successors = {} + for a, b in life_cycle.stage_pairs(): + successors.setdefault(id(a), []).append(b) + pairs = [] + for stage in life_cycle.life_cycle: + if stage.unit not in requested: continue + seen, stack = set(), list(successors.get(id(stage), ())) + while stack: + other = stack.pop() + if id(other) in seen: continue + seen.add(id(other)) + if other.unit in requested: pairs.append((stage.unit, other.unit)) + else: stack.extend(successors.get(id(other), ())) + if not life_cycle.cold: pairs = [(b, a) for a, b in pairs] + return pairs + def _format_H(H): mantissa, exponent = f'{H:.2e}'.split('e') return f'{mantissa}E{int(exponent)}' @@ -1656,10 +2398,14 @@ def plot_pinch_diagram(stream_life_cycles, inlet_Ts, outlet_Ts, Notes ----- Temperatures are shown in degC and heat flows in kJ/hr at the inlet and - outlet of each stream. Exchanger columns on each side of the pinch are + outlet of each stream (of its whole flow, `StreamLifeCycle.H_in` and + its utility's outlet). Exchanger columns on each side of the pinch are ordered so that each stream meets them in flow direction whenever the - network allows it. Stream labels read ' - ()' - next to the stream index at the inlet. + network allows it; the exchangers of a split stream's branches are + ordinary columns, with their branch duties, and sibling branches are + not ordered by that stream (splits are not drawn). Stream labels read + ' - ()' next to the stream index at the + inlet. Examples -------- @@ -1688,8 +2434,9 @@ def plot_pinch_diagram(stream_life_cycles, inlet_Ts, outlet_Ts, """ import matplotlib.pyplot as plt - # Artists carry stable gids ('HX:', 'Util:', 'Label:') - # so the drawing can be checked structurally in tests. + # Artists carry stable gids ('HX:', 'Util:', 'Label:', + # 'H_in:', 'H_out:') so the drawing can be checked + # structurally in tests. show_labels = show_units or show_auxiliary_units or show_stream_IDs if show_labels and original_hxs is None: raise ValueError('original_hxs is required to label streams with ' @@ -1756,7 +2503,8 @@ def plot_pinch_diagram(stream_life_cycles, inlet_Ts, outlet_Ts, color = cold_color if cold else hot_color yi = y[index] stages = life_cycle.life_cycle # never empty: each stream has a utility stage - H_in = stages[0].H_in + # the whole stream's inlet, also where its first stage is a branch + H_in = life_cycle.H_in H_out = stages[-1].H_out T_in = inlet_Ts[index] - 273.15 T_out = outlet_Ts[index] - 273.15 @@ -1764,14 +2512,17 @@ def plot_pinch_diagram(stream_life_cycles, inlet_Ts, outlet_Ts, T_left, H_left, T_right, H_right = ( (T_in, H_in, T_out, H_out) if cold else (T_out, H_out, T_in, H_in) ) + gid_left, gid_right = ('H_in:', 'H_out:') if cold else ('H_out:', 'H_in:') x_in, x_out, sign = (x_start, x_end, 1) if cold else (x_end, x_start, -1) ax.annotate('', xy=(x_out, yi), xytext=(x_in, yi), arrowprops=dict(arrowstyle='-|>', color=color, lw=1.2, shrinkA=0, shrinkB=0), zorder=2) ax.text(x_start - 1.3, yi, f'{T_left:.1f}', color=color, **value_kwargs) - ax.text(x_start - 0.6, yi, _format_H(H_left), color=color, **value_kwargs) + ax.text(x_start - 0.6, yi, _format_H(H_left), color=color, + gid=gid_left + str(index), **value_kwargs) ax.text(x_end + 0.6, yi, f'{T_right:.1f}', color=color, **value_kwargs) - ax.text(x_end + 1.3, yi, _format_H(H_right), color=color, **value_kwargs) + ax.text(x_end + 1.3, yi, _format_H(H_right), color=color, + gid=gid_right + str(index), **value_kwargs) # Index and label share a baseline above the stream, clear of the # exchanger circles y_text = yi + 0.25 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 6d90b43..4344a7f 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -84,6 +84,75 @@ def test_cache_network_duplicate_IDs(): simulate_cached(sys, HXN) assert_same_results(network_results(HXN), fresh) +def test_cache_network_from_before_stream_splitting_synthesizes_again(): + # a network cached by a facility from before stream splitting (no + # synthesis options or stage scales, life cycles without an entry port + # or splits; e.g. hensmith reloaded in a live session) cannot be + # reused: the facility synthesizes a new network instead of failing + sys, HXN, feed = build_system() + HXN.cache_network = True + sys.simulate() + network = HXN.HXN_sys + # nor a stream_splitting attribute: the class default (off) applies + del HXN._synthesis_options, HXN._stage_scales, HXN.stream_splitting + for life_cycle in HXN.stream_life_cycles: + del life_cycle.entry, life_cycle.splits + feed.F_mol *= 1.05 + with warnings.catch_warnings(): + warnings.simplefilter('error', RuntimeWarning) + sys.simulate() + assert HXN.stream_splitting is False and not HXN.new_splitters + assert HXN.HXN_sys is not network + assert HXN._synthesis_options == (False,) + assert all(lc.entry is not None for lc in HXN.stream_life_cycles) + again = network_results(HXN) + HXN.cache_network = False + sys.simulate() + assert_same_results(again, network_results(HXN)) + +def test_life_cycles_without_entry_plot_and_write_csv(tmp_path, monkeypatch): + # the pinch diagram and the CSV read a stream's inlet at its entry + # port, which only get_life_cycle sets: a life cycle whose life_cycle + # is assigned directly, and one from before stream splitting (no entry + # or splits; e.g. unpickled, or hensmith reloaded in a live session), + # read it at their first stage, and draw and write exactly as before + import csv + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from hensmith.hxn_synthesis import StreamLifeCycle + sys, HXN, feed = build_system() + sys.simulate() + + def outputs(name): + fig, ax = HXN.plot_pinch_diagram() + try: + texts = [(t.get_text(), t.get_position(), t.get_gid()) + for t in ax.texts] + finally: + plt.close(fig) + folder = tmp_path / name + folder.mkdir() + monkeypatch.chdir(folder) + HXN.save_stream_life_cycles_as_csv() + path, = folder.glob('HXN-*.csv') + with open(path, newline='') as file: + return texts, list(csv.reader(file)) + expected = outputs('entry') + assigned = [] + for life_cycle in HXN.stream_life_cycles: + assert life_cycle.entry is not None and not life_cycle.splits + new = StreamLifeCycle(life_cycle.index, life_cycle.cold) + new.life_cycle = life_cycle.life_cycle + assigned.append(new) + HXN.stream_life_cycles = assigned + assert all(lc.H_in == lc.life_cycle[0].H_in for lc in assigned) + assert outputs('assigned') == expected + for life_cycle in assigned: + del life_cycle.entry, life_cycle.splits + assert all(lc.entry is None and not lc.splits for lc in assigned) + assert outputs('before') == expected + 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 @@ -499,19 +568,21 @@ def test_pinch_state_at_endpoints_uses_real_states(): assert 320. <= s.T <= 400. 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.""" + """Every unit of every stream (its stages, and the splitters and mixers + of its splits) is simulated after the unit that feeds it, along the + connections of its life cycle (for an unsplit stream, its consecutive + stages), except across a declared recycle (tear) stream.""" path = list(HXN.HXN_sys.path) - assert set(path) == set(HXN.new_HXs + HXN.new_HX_utils) + assert set(path) == set(HXN.new_HXs + HXN.new_HX_utils + + HXN.new_splitters + HXN.new_mixers) 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) + for up, up_port, down, down_port in life_cycle.connections(): + s = up.outs[up_port] + assert down.ins[down_port] is s + assert position[down] > position[up] or s in recycles, (up, down) return recycles def test_network_path_follows_the_streams(): @@ -581,12 +652,10 @@ def cp_units(name, rows, offset=0.): 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]) +def simulate_strictly(sys): + """Simulate `sys`; a RuntimeWarning (including a stream replaced in the + registry, except biosteam's own furnace-air stream, and a cached network + the facility ignores) is an error.""" with warnings.catch_warnings(): warnings.simplefilter('error', RuntimeWarning) # biosteam's HeatUtility.load_agent names a new 'oxygen_rich_inlet' @@ -595,6 +664,14 @@ def simulate_HXN(units, T_min_app, name='sys', **kwargs): warnings.filterwarnings('ignore', category=RuntimeWarning, message='.* has been replaced in registry') sys.simulate() + +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]) + simulate_strictly(sys) return sys, HXN def assert_feasible(HXN, T_min_app, EB=1e-6): @@ -1345,5 +1422,1927 @@ def test_pinch_diagram_legend(): finally: plt.close(fig) +# --------------------------------------------------------------------------- +# Stream splitting: exact checks and exchangers on branches (flow fractions) +# --------------------------------------------------------------------------- + +from hensmith._curves import StreamCurve, GLIDE, _copy +from hensmith._planner import Exchanger, Plan, plan_network +from hensmith import _splitting +from hensmith import _planner +from test_hxn_planner import (records_case, sides_from_knots, + _verify_side_cells) +from hxn_mer_cases import SPLIT as MER_SPLIT +import test_hxn_mer + +#: Branch fractions of the hot and the cold stream in the branch tests (not +#: dyadic, so that a lost or a doubled fraction shows). +FH, FC = 0.37, 0.61 + +def branch_streams(kind): + """Inlets and quenched outlets ``[h_in, h_out, c_in, c_out]`` of a hot + and a cold stream: constant CP (400 -> 300 K, 2 kW/K against 330 -> + 390 K, 3 kW/K), or hot water (rtB07's H1, 400 -> 300 K at 5 bar) + against rtB07's water/methanol boiler C3 (330 -> 372 K at 1 atm), + whose curve has a glide.""" + if kind == 'constant_cp': + units = cp_units('branch_cp', [('H1', 400., 300., 2.), + ('C1', 330., 390., 3.)]) + else: + bst.settings.set_thermo(['Water', 'Methanol'], cache=True) + bst.main_flowsheet.set_flowsheet('branch_glide') + units = [] + for ID, T, P, T_out, rigorous, flow in ( + ('H1', 400., 5e5, 300., False, dict(Water=30.)), + ('C3', 330., 101325., 372., True, + dict(Water=12., Methanol=3.))): + s = bst.Stream(ID + '_in', T=T, P=P, phase='l', units='kmol/hr', + **flow) + hx = bst.HXutility(ID, ins=s, T=T_out, rigorous=rigorous) + hx.simulate() + units.append(hx) + streams = [] + for hx in units: + s_in, s_out = _copy(hx.ins[0]), _copy(hx.outs[0]) + s_out.vle(H=s_out.H, P=s_out.P) + streams += [s_in, s_out] + return streams + +def branch_curves(kind, fh=1., fc=1.): + """Curves and knots (the curves' breakpoints) of `branch_streams`, the + hot stream at `fh` of its flow and the cold one at `fc` of its flow.""" + h_in, h_out, c_in, c_out = branch_streams(kind) + for s in (h_in, h_out): s.scale(fh) + for s in (c_in, c_out): s.scale(fc) + curves = [StreamCurve(h_in, h_out, True), StreamCurve(c_in, c_out, False)] + return curves, [(c.T, c.H - c.H_lo) for c in curves] + +def branch_exchanger(curves): + """A branch exchanger on the full-flow `curves` (parent-equivalent + enthalpies): the hot branch (FH) enters at the hot stream's inlet, the + cold branch (FC) at 10 % of the cold stream's duty, and the duty is 90 % + of what the tighter branch has left.""" + ch, cc = curves + H_hot_in = ch.H_hi - ch.H_lo + H_cold_in = 0.1 * (cc.H_hi - cc.H_lo) + Q = 0.9 * min(FH * H_hot_in, FC * (cc.H_hi - cc.H_lo - H_cold_in)) + return H_hot_in, H_cold_in, Q + +@pytest.mark.parametrize('kind', ['constant_cp', 'glide']) +def test_branch_exchanger_approach_matches_scaled_curve(kind): + # a branch at fraction f runs its parent's curve with f times the + # enthalpy: the check on the parent curves with the fractions equals a + # direct computation on the curves of the scaled streams, and its + # violating states are parent-equivalent (what `_refine_knots` takes) + curves, knots = branch_curves(kind) + scaled, scaled_knots = branch_curves(kind, FH, FC) + ch, cc = curves + chf, ccf = scaled + if kind == 'glide': assert GLIDE in cc.kinds + H_hot_in, H_cold_in, Q = branch_exchanger(curves) + approach = hxn_synthesis._exchanger_approach + inf = float('inf') + worst, points = approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, + inf, FH, FC) + worst_f, _ = approach(scaled, scaled_knots, 0, 1, chf.H_hi - chf.H_lo, + FC * H_cold_in, Q, inf) + assert abs(worst - worst_f) <= 1e-9 + assert len(points) > 2 + assert worst == min(T_hot - T_cold for T_hot, _, T_cold, _ in points) + H_cold_out = H_cold_in + Q / FC + for T_hot, H_hot, T_cold, H_cold in points: + q = (H_hot_in - H_hot) * FH # the duty done, the same on both + assert -1e-12 * Q <= q <= Q * (1. + 1e-12) + assert abs((H_cold_out - H_cold) * FC - q) <= 1e-12 * Q + assert abs(chf.T_exact(chf.H_lo + FH * H_hot, 'low') - T_hot) <= 1e-9 + assert abs(ccf.T_exact(ccf.H_lo + FC * H_cold, 'high') + - T_cold) <= 1e-9 + # every knot inside the branches is a position of the check (between + # positions both knot curves are linear) + H_hot_out = H_hot_in - Q / FH + for (_, H), at, lo, hi in ( + (knots[0], [p[1] for p in points], H_hot_out, H_hot_in), + (knots[1], [p[3] for p in points], H_cold_in, H_cold_out)): + for x in H[(H > lo) & (H < hi)]: + assert np.abs(np.array(at) - x).min() <= 1e-12 * H[-1] + # a violation of 0.5 K is found where it is (the knots screen the rest) + assert approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, + worst + 0.5, FH, FC)[0] == worst + # `_exact_approach` checks a plan's exchangers on their fractions + e = Exchanger() + e.hot, e.cold, e.hot_frac, e.cold_frac = 0, 1, FH, FC + plan = Plan() + plan.exchangers = [e] + ends = {(0, 0): (H_hot_in, H_hot_out), (0, 1): (H_cold_in, H_cold_out)} + assert hxn_synthesis._exact_approach(plan, {0: Q}, ends, curves, knots, + inf) == (worst, { + 0: [(T_hot, H_hot) for T_hot, H_hot, _, _ in points], + 1: [(T_cold, H_cold) for _, _, T_cold, H_cold in points]}, [0]) + # (fractions of 1, the default, are the unsplit check bit for bit: the + # R1 oracle's full mode compares every unsplit synthesis with the one + # before stream splitting) + +@pytest.mark.parametrize('kind', ['constant_cp', 'glide']) +def test_shrink_with_fractions_is_monotone(kind): + # with both branch inlets fixed, a smaller duty moves the cold branch's + # outlet toward its inlet: the exact approach never falls as the duty + # falls, the feasible duties are [0, Q'] and `_shrink` finds Q' (as on + # the curves of the scaled streams) + curves, knots = branch_curves(kind) + scaled, scaled_knots = branch_curves(kind, FH, FC) + H_hot_in, H_cold_in, Q = branch_exchanger(curves) + approach, shrink = hxn_synthesis._exchanger_approach, hxn_synthesis._shrink + inf = float('inf') + # (an exact check at T_min_app = inf evaluates every position: 0.85 s + # on the glide curve, 0.04 s at constant CP) + xs = np.linspace(0.05, 1., 20 if kind == 'constant_cp' else 8) * Q + dTs = [approach(curves, knots, 0, 1, H_hot_in, H_cold_in, x, inf, + FH, FC)[0] for x in xs] + assert all(b <= a + 1e-9 for a, b in zip(dTs, dTs[1:])) + T_min_app = 0.5 * (dTs[0] + dTs[-1]) # Q itself violates + Q_ok = shrink(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, T_min_app, + FH, FC) + assert 0. < Q_ok < Q + for x in xs: + violates = bool(approach(curves, knots, 0, 1, H_hot_in, H_cold_in, + x, T_min_app, FH, FC)[1]) + assert violates == (x > Q_ok), x + limit = T_min_app - hxn_synthesis._APPROACH_TOL + at = approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q_ok, inf, + FH, FC)[0] + assert limit <= at <= limit + 1e-6 + chf = scaled[0] + Q_f = shrink(scaled, scaled_knots, 0, 1, chf.H_hi - chf.H_lo, + FC * H_cold_in, Q, T_min_app) + assert abs(Q_ok - Q_f) <= 1e-8 * Q + if kind == 'constant_cp': + # closed form: the approach is smallest at the cold end, where the + # hot branch leaves 400 K - Q / (FH 2 kW/K) against the cold + # branch's inlet, 330 K + 10 % of 60 K + assert_allclose(Q_ok, FH * 2. * kW * (400. - 336. - limit), + rtol=1e-8) + +def records_plan(): + """The hand-built split plan of `test_hxn_planner.records_case` (H1, + 400 -> 200 with CP 1, splits into halves that serve C1 and C2, re-joins + at 100 and serves C2 and C1 on its trunk), with its knot enthalpies.""" + sides, plans, curves = records_case() + (recs, _, dropped, stages, _, _, splits, + paths) = _splitting._split_records(sides, plans, curves, 3, 0., + sides['above'].tolQ) + assert not dropped + plan = Plan() + plan.exchangers, plan.stages, plan.paths, plan.splits = ( + recs, stages, paths, splits) + knots = [(np.array([200., 400.]), np.array([0., 200.])), + (np.array([20., 170.]), np.array([0., 150.])), + (np.array([20., 170.]), np.array([0., 150.]))] + return plan, knots, [True, False, False] + +def test_walk_split_paths(): + plan, knots, is_hot = records_plan() + walk, nodes = hxn_synthesis._walk, hxn_synthesis._split_nodes + b0, b1, t1, t0 = range(4) + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + ends, last, pair_index = walk(plan, duties, knots, is_hot) + # the planner's own walk: parent-equivalent enthalpies on the branches + for n, e in enumerate(plan.exchangers): + assert ends[n, e.hot] == (e.H_hot_in, e.H_hot_out) + assert ends[n, e.cold] == (e.H_cold_in, e.H_cold_out) + assert ends[b0, 0] == (200., 80.) and ends[b1, 0] == (200., 120.) + assert ends[t1, 0] == (100., 40.) and ends[t0, 0] == (40., 0.) + assert last == [0., 100., 100.] + assert pair_index == {b0: 1, t0: 2, b1: 1, t1: 2} + assert nodes(plan, duties, ends) == ({0: (b0, b1), 1: (), 2: ()}, + [(200., [80., 120.], 100.)], + {0: 0}) + # a dropped branch exchanger: its branch bypasses, the stream re-joins + # at H_split - 60 and C2 starts on its trunk exchanger + del duties[b1] + ends, last, pair_index = walk(plan, duties, knots, is_hot) + assert ends == {(b0, 0): (200., 80.), (b0, 1): (0., 60.), + (t1, 0): (140., 80.), (t1, 2): (0., 60.), + (t0, 0): (80., 40.), (t0, 1): (60., 100.)} + assert last == [40., 100., 60.] + assert pair_index == {b0: 1, t0: 2, t1: 1} + assert nodes(plan, duties, ends) == ({0: (b0,), 1: (), 2: ()}, + [(200., [80., 200.], 140.)], + {0: 0}) + # a shrunk branch exchanger: its branch ends earlier (by Q / f), the + # mix and the later stages move toward the inlet by the duty + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + duties[b0] = 30. + ends, last, _ = walk(plan, duties, knots, is_hot) + assert ends[b0, 0] == (200., 140.) and ends[b0, 1] == (0., 30.) + assert ends[t1, 0] == (130., 70.) and ends[t0, 0] == (70., 30.) + assert last == [30., 70., 100.] + assert nodes(plan, duties, ends)[1] == [(200., [140., 120.], 130.)] + # a split whose branches were all dropped is passed as a trunk (it is + # not realized): no first nodes, no split ends + duties = {t1: 60., t0: 40.} + ends, last, _ = walk(plan, duties, knots, is_hot) + assert ends[t1, 0] == (200., 140.) and ends[t0, 0] == (140., 100.) + assert last == [100., 40., 60.] + assert nodes(plan, duties, ends) == ({0: (), 1: (), 2: ()}, [None], + {}) + # without splits, the walk is the unsplit one on `stages` + plan.splits, plan.paths = [], None + assert walk(plan, duties, knots, is_hot)[:2] == (ends, last) + +def planned_split(name): + """Split plan of corpus case `name` on the synthesizer's round-0 grid + knots, with what the synthesis hands to its exact checks.""" + case = next(c for c in MER_SPLIT if c['name'] == name) + units, dT = test_hxn_mer._build(case) + hus = sorted([hx.heat_utilities[0] for hx in units], + key=lambda hu: hu.duty) + r = hxn_synthesis._pinch_analysis(hus, dT) + hxs, hot_indices, streams_inlet, curves, grid = (r[5], r[6], r[9], + r[13], r[14]) + is_hot = [i in hot_indices for i in range(len(hxs))] + knots = hxn_synthesis._grid_knots(curves, grid) + plan = plan_network(knots, is_hot, dT, stream_splitting=True) + assert plan.status == 'mer' and plan.splits + return plan, curves, knots, is_hot, streams_inlet, dT + +@pytest.mark.parametrize('name', ['smith2005_ex18_2_split', 'rtB05_above_2h1c']) +def test_realize_split_branch_ports(name): + # every branch exchanger is an HXprocess on f of its stream's flow: its + # inlet is the parent's state at the branch's inlet enthalpy (the real + # inlet where the stream splits at its inlet) scaled by f, and its + # enthalpy limit is f times the parent's at the planned outlet + plan, curves, knots, is_hot, streams_inlet, dT = planned_split(name) + span = [c.H_hi - c.H_lo for c in curves] + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + ends, last, _ = hxn_synthesis._walk(plan, duties, knots, is_hot) + for n, e in enumerate(plan.exchangers): + assert_allclose(ends[n, e.hot], (e.H_hot_in, e.H_hot_out), + rtol=0, atol=1e-12 * span[e.hot]) + assert_allclose(ends[n, e.cold], (e.H_cold_in, e.H_cold_out), + rtol=0, atol=1e-12 * span[e.cold]) + assert_allclose(last, [u if hot else D - u for u, D, hot + in zip(plan.utility, span, is_hot)], + rtol=0, atol=1e-12 * sum(span)) + first_nodes, split_ends, first_splits = hxn_synthesis._split_nodes( + plan, duties, ends) + assert set(first_splits) == {j for j, ns in first_nodes.items() if ns} + assert len(split_ends) == len(plan.splits) + for s, (H_split, H_ends, H_mix) in zip(plan.splits, split_ends): + assert_allclose([H_split, H_mix], [s.H_split, s.H_mix], rtol=0, + atol=1e-12 * span[s.stream]) + assert len(H_ends) == len(s.branches) + # smith2005_ex18_2 splits its hot stream (stream 2) at its inlet; rtB05 + # splits its cold stream after its trunk exchanger below the pinch + assert any(first_nodes.values()) == name.startswith('smith') + min_approach, violations, bad = hxn_synthesis._exact_approach( + plan, duties, ends, curves, knots, dT) + if name.startswith('smith'): # constant CP: the knots are exact + assert not bad and min_approach >= dT - 1e-9 + bst.main_flowsheet.set_flowsheet('realize_' + name) + units, first, _ = hxn_synthesis._realize( + plan, duties, curves, knots, streams_inlet, is_hot, dT) + try: + n_branch = 0 + for n, hx in units.items(): + e = plan.exchangers[n] + above = e.side == 'above' + ports = (e.cold, e.hot) if above else (e.hot, e.cold) + fracs = ((e.cold_frac, e.hot_frac) if above + else (e.hot_frac, e.cold_frac)) + duty = span[e.hot] + span[e.cold] + assert abs(hx.Q - duties[n]) <= 1e-9 * duty, hx.ID + for k, (j, f) in enumerate(zip(ports, fracs)): + n_branch += f != 1. + curve = curves[j] + H_in, H_out = ends[n, j] + s_in, s_out = hx.ins[k], hx.outs[k] + assert_allclose(s_in.F_mol, f * streams_inlet[j].F_mol, + rtol=1e-14) + assert abs(s_in.H - f * (curve.H_lo + H_in)) <= 1e-9 * duty + assert abs(s_out.H - f * (curve.H_lo + H_out)) <= 1e-9 * duty + H_lim = getattr(hx, f'H_lim{k}') + assert H_lim is not None + assert_allclose(H_lim, f * (curve.H_lo + H_out), rtol=1e-14) + if n == first[j] or n in first_nodes[j]: + assert s_in.T == streams_inlet[j].T # the real inlet + if name.startswith('smith'): + assert internal_approach(hx) >= dT - APPROACH_TOL, hx.ID + assert n_branch + finally: + hxn_synthesis._discard(units.values()) + +def double_split_plan(): + """ + A hand-built plan whose stream splits twice on one side (the records + case of `test_hxn_planner`, in two splits), on the constant-CP fluid: + H1 (400 -> 200 C, CP 1 kW/K) serves C1 and C2 (20 -> 170 C, CP 1 kW/K + each; all above the pinch) from its inlet: split 1 into halves (30 kW + to each cold), a trunk exchanger (40 kW to C1), split 2 into halves (30 + kW to each cold) and a trunk exchanger (40 kW to C2). + """ + case = dict(name='double_split', kind='constant_cp', T_unit='C', + Q_unit='kW', dTmin=10., + streams=[('H1', 'hot', 400., 200., 1.), + ('C1', 'cold', 20., 170., 1.), + ('C2', 'cold', 20., 170., 1.)]) + units, dT = test_hxn_mer._build(case) + hus = sorted([hx.heat_utilities[0] for hx in units], + key=lambda hu: hu.duty) + r = hxn_synthesis._pinch_analysis(hus, dT) + hot_indices, streams_inlet, curves, grid = r[6], r[9], r[13], r[14] + is_hot = [i in hot_indices for i in range(len(curves))] + assert is_hot == [False, False, True] # C1, C2, H1 + knots = hxn_synthesis._grid_knots(curves, grid) + sides = sides_from_knots(knots, is_hot, dT) + side = sides['above'] + assert (side.M, side.F) == (1, 2) + kW = 3600. # kJ/hr + B = _splitting._Cell + cells = [B(0, 0, 30. * kW, 140. * kW, 0., .5, 1., ('S', 0, 0)), + B(0, 1, 30. * kW, 140. * kW, 0., .5, 1., ('S', 0, 1)), + B(0, 0, 40. * kW, 100. * kW, 30. * kW), + B(0, 0, 30. * kW, 40. * kW, 70. * kW, .5, 1., ('S', 1, 0)), + B(0, 1, 30. * kW, 40. * kW, 30. * kW, .5, 1., ('S', 1, 1)), + B(0, 1, 40. * kW, 0., 60. * kW)] + _verify_side_cells(side, cells) + plans = {'above': _planner._SidePlan([], [0.], 'mer', 'split-X', + cells=cells, units=6)} + (recs, _, dropped, stages, _, _, splits, paths) = ( + _splitting._split_records(sides, plans, _planner._stream_curves( + knots, is_hot, dT), 3, 0., side.tolQ)) + assert not dropped and len(splits) == 2 + plan = Plan() + plan.exchangers, plan.stages, plan.paths, plan.splits = ( + recs, stages, paths, splits) + plan.info = dict(tolQ=side.tolQ) + return plan, curves, knots, is_hot, streams_inlet, dT + +def test_realize_splits_of_one_stream_on_one_side(monkeypatch): + # the second split of a stream on a side is numbered 2 (`_2` in its + # splitter's and mixer's IDs), its position counts the trunk exchanger + # before it, and only the first split (the stream's first node) takes + # the real inlet, in its splitter feed and its branches' first + # exchangers + plan, curves, knots, is_hot, streams_inlet, dT = double_split_plan() + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + first_inlet, real = hxn_synthesis._first_inlet, [] + def spy(s, *args): + real.append(s) + return first_inlet(s, *args) + monkeypatch.setattr(hxn_synthesis, '_first_inlet', spy) + bst.main_flowsheet.set_flowsheet('double_split') + units = hxn_synthesis._realize(plan, duties, curves, knots, + streams_inlet, is_hot, dT)[0] + splits, deviations = hxn_synthesis._realize_splits( + plan, duties, units, curves, knots, streams_inlet, is_hot) + try: + assert deviations == [] + assert [(sp.stream, sp.side, sp.index, sp.position, sp.isothermal) + for sp in splits] == [(2, 'above', 1, 0, True), + (2, 'above', 2, 1, True)] + assert [[u.ID for u in sp.splitters] for sp in splits] == [ + ['Split_2_hs'], ['Split_2_hs_2']] + assert [sp.mixer.ID for sp in splits] == ['Mix_2_hs', 'Mix_2_hs_2'] + assert [sp.mixer.outs[0].ID for sp in splits] == [ + 'Mix_2_hs__s_2', 'Mix_2_hs_2__s_2'] + def is_real(s): return any(s is r for r in real) + for sp, first in zip(splits, (True, False)): + assert is_real(sp.splitters[0].ins[0]) == first + for branch in sp.branches: + hx = branch[0] + assert is_real(hx.ins[stream_port(hx, 2)]) == first, hx.ID + # the stream re-joins at the planned state, between the splits + H_mix = math.fsum(s.H for s in sp.mixer.ins) + assert abs(sp.mixer.outs[0].H - H_mix) <= 1e-12 * abs(H_mix) + # the trunk exchanger between them cools H1 by 40 kW + assert abs(splits[0].H_mix - splits[1].H_split - 40. * 3600. + ) <= 1e-9 * splits[0].H_mix + finally: + hxn_synthesis._discard([*units.values(), + *(u for sp in splits + for u in (*sp.splitters, sp.mixer))]) + +def test_repair_shrinks_branches_with_fractions(monkeypatch): + # 5 K more than planned: `_repair` shrinks every violating exchanger, + # branches with their fractions, in one pass, to duties that keep the + # new approach on the exact states (and on the realized exchangers) + plan, curves, knots, is_hot, streams_inlet, dT = planned_split( + 'smith2005_ex18_2_split') + T_min_app = dT + 5. + duties = {n: e.Q for n, e in enumerate(plan.exchangers)} + ends = hxn_synthesis._walk(plan, duties, knots, is_hot)[0] + bad = hxn_synthesis._exact_approach(plan, duties, ends, curves, knots, + T_min_app)[2] + fractions = [(e.hot_frac, e.cold_frac) for e in plan.exchangers] + assert any(fractions[n] != (1., 1.) for n in bad) + shrink, calls = hxn_synthesis._shrink, [] + def spy(*args): + calls.append(args[-2:]) + return shrink(*args) + monkeypatch.setattr(hxn_synthesis, '_shrink', spy) + new, changes = hxn_synthesis._repair(plan, duties, knots, is_hot, curves, + T_min_app) + assert [n for n, _, _ in changes] == bad # one pass + assert calls == [fractions[n] for n in bad] + assert all(0. <= Q_after < Q_before for _, Q_before, Q_after in changes) + ends = hxn_synthesis._walk(plan, new, knots, is_hot)[0] + worst, _, bad = hxn_synthesis._exact_approach(plan, new, ends, curves, + knots, T_min_app) + assert not bad and worst >= T_min_app - hxn_synthesis._APPROACH_TOL + bst.main_flowsheet.set_flowsheet('repair_split') + units = hxn_synthesis._realize(plan, new, curves, knots, streams_inlet, + is_hot, T_min_app)[0] + try: + for hx in units.values(): + assert internal_approach(hx) >= T_min_app - APPROACH_TOL, hx.ID + finally: + hxn_synthesis._discard(units.values()) + +# --------------------------------------------------------------------------- +# Stream splitting: splitters, mixers and the refine loop (synthesize_network) +# --------------------------------------------------------------------------- + +import itertools +import math +import re + +SPLIT_CASES = ['smith2005_ex18_2_split', 'rtB05_above_2h1c'] + +def split_units(name, flowsheet=None): + """Heat utilities of corpus case `name` in the order the facility gives + them (by duty) and its T_min_app, in flowsheet `flowsheet` (if given).""" + case = next(c for c in MER_SPLIT if c['name'] == name) + units, dT = test_hxn_mer._build(case) + hus = sorted([hx.heat_utilities[0] for hx in units], + key=lambda hu: hu.duty) + if flowsheet: bst.main_flowsheet.set_flowsheet(flowsheet) + return hus, dT + +def split_synthesis(name, flowsheet=None, **kwargs): + """`synthesize_network` with stream splitting on corpus case `name`: + its result, its info, the streams' curves and T_min_app.""" + hus, dT = split_units(name, flowsheet) + curves = hxn_synthesis._pinch_analysis(hus, dT)[13] + info = {} + result = synthesize_network(hus, dT, info=info, stream_splitting=True, + **kwargs) + return result, info, curves, dT + +def spy_plans(monkeypatch): + """Record every plan (and the keywords it was planned with) that + `synthesize_network` makes.""" + plans, plan_network = [], hxn_synthesis.plan_network + def spy(*args, **kwargs): + plan = plan_network(*args, **kwargs) + plans.append((plan, kwargs)) + return plan + monkeypatch.setattr(hxn_synthesis, 'plan_network', spy) + return plans + +def stream_port(hx, j): + """Port of stream `j` in synthesized process exchanger `hx`.""" + return _stream_ports(hx).index(j) + +def test_synthesize_network_splitting_requires_info(): + units = r002('r002_split_info') + hus = [hx.heat_utilities[0] for hx in units] + with pytest.raises(ValueError, match='info'): + synthesize_network(hus, 10., stream_splitting=True) + +@pytest.mark.parametrize('name', [*SPLIT_CASES, 'crude_fractionation_ph11c2', + 'rtB03_above_hot_vapors']) +def test_synthesize_network_with_splitting(name): + # smith2005_ex18_2 splits its hot stream (stream 2) at its inlet above + # the pinch, isothermally; rtB05 (real thermo) splits its cold stream + # as its last node, non-isothermally, into its heater; the crude unit + # splits into three branches or more (splitter chains of several + # elements); rtB03 splits a vapor at its inlet, whose real inlet is not + # bit for bit its state at the inlet enthalpy: all reach MER through + # splitter chains, branch exchangers and rigorous mixers + result, info, curves, dT = split_synthesis(name, 'synth_' + name) + 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 info['status'] == 'mer' and info['stream_splitting'] is True + assert info['dropped'] == info['repaired'] == [] + assert info['split_deviations'] == [] and info['deviations'] == [] + scale = sum(abs(c.H_out - c.H_in) for c in curves) + assert_allclose([info['Q_hot'], info['Q_cold']], + [info['Q_hot_target'], info['Q_cold_target']], + rtol=0, atol=1e-6 * scale) + process = hs + cs + assert all(isinstance(hx, bst.HXprocess) for hx in process) + for i, stages in stream_HXs.items(): + assert isinstance(stages[-1], bst.HXutility) + assert all(isinstance(u, bst.HXprocess) for u in stages[:-1]) + # every process exchanger is on both its streams, once + assert sorted(hx.ID for stages in stream_HXs.values() + for hx in stages[:-1]) == sorted( + [hx.ID for hx in process] * 2) + splits = info['splits'] + assert splits and all(isinstance(sp, hxn_synthesis.StreamSplit) + for sp in splits) + branch_HXs = {hx for sp in splits for b in sp.branches for hx in b} + ordinal = {} + for sp in splits: + j, n = sp.stream, len(sp.fractions) + hot = j in hot_indices + curve = curves[j] + duty = abs(curve.H_out - curve.H_in) + tag = 'hs' if sp.side == 'above' else 'cs' + ordinal[j, tag] = ordinal.get((j, tag), 0) + 1 + assert sp.index == ordinal[j, tag] + base = f'Split_{j}_{tag}' + ('' if sp.index == 1 else f'_{sp.index}') + assert [u.ID for u in sp.splitters] == [base] + [ + f'{base}_b{c}' for c in range(2, n)] + assert all(type(u) is bst.Splitter for u in sp.splitters) + assert type(sp.mixer) is bst.Mixer and sp.mixer.rigorous + assert sp.mixer.ID == 'Mix' + base[len('Split'):] + assert len(sp.branches) == n == len(sp.mixer.ins) >= 2 + assert abs(math.fsum(sp.fractions) - 1.) <= 1e-12 + # the chain: element c sends f_c / (f_c + ... + f_n) of its feed to + # its first outlet; its second outlet feeds element c + 1 + F = streams_inlet[j].F_mol + feed = sp.splitters[0].ins[0] + assert_allclose(feed.F_mol, F, rtol=1e-14) + assert abs(feed.H - sp.H_split) <= 1e-9 * duty + for c, u in enumerate(sp.splitters): + assert_allclose(u.split, sp.fractions[c] + / math.fsum(sp.fractions[c:]), rtol=1e-15) + if c: assert u.ins[0] is sp.splitters[c - 1].outs[1] + for b, f in enumerate(sp.fractions): + unit, port = sp.outlet(b) + assert unit in sp.splitters + assert_allclose(unit.outs[port].F_mol, f * F, rtol=1e-12) + for hx in sp.branches[b]: + assert hx in process + assert_allclose(hx.ins[stream_port(hx, j)].F_mol, f * F, + rtol=1e-12) + # the stream re-joins at its split enthalpy -/+ the branch duties + Q = math.fsum(hx.Q for b in sp.branches for hx in b) + assert abs(sp.H_mix - (sp.H_split - Q if hot else sp.H_split + Q) + ) <= 1e-9 * duty + assert abs(sp.mixer.outs[0].H - sp.H_mix) <= 1e-9 * duty + # the stream's stages: its trunk exchangers before the split, the + # branches (branch by branch, each in flow order), then the rest + stages = stream_HXs[j][:-1] + branches = [hx for b in sp.branches for hx in b] + k = stages.index(branches[0]) + assert stages[k:k + len(branches)] == branches + assert len([hx for hx in stages[:k] + if hx not in branch_HXs]) == sp.position + if k == 0: # a split at the stream's inlet takes the real inlet, + # in its splitter chain and in every branch's first exchanger + # (rtB03's vapor inlet is 1.7e-13 K off its state at the inlet + # enthalpy) + real = _copy(streams_inlet[j]) + hxn_synthesis._first_inlet(real, not curve.monotone, + curve.T_out, hot) + assert feed.T == real.T + for branch in sp.branches: + if branch: + hx = branch[0] + assert hx.ins[stream_port(hx, j)].T == real.T, hx.ID + # smith2005_ex18_2 splits at the inlet and re-joins at the pinch (one + # temperature); rtB05 re-joins non-isothermally into its heater + if name.startswith('crude'): + assert max(len(sp.fractions) for sp in splits) >= 3 + elif name.startswith('smith'): + assert [(sp.position, sp.isothermal) for sp in splits] == [(0, True)] + elif name.startswith('rtB05'): + assert [(sp.position, sp.isothermal) for sp in splits] == [(1, False)] + (sp,) = splits + util = stream_HXs[sp.stream][-1] + assert abs(util.ins[0].H - sp.H_mix) <= 1e-9 * abs( + curves[sp.stream].H_out - curves[sp.stream].H_in) + # the facility synthesizes the same network and wires it from the life + # cycles: the strict checks (G0-G10) pass, its lists hold the realized + # splitters and mixers, and its utilities are the MER targets + net = test_hxn_mer._network(test_hxn_mer._case(name), True) + problems = test_hxn_mer._network_problems(net) + assert not problems, '\n'.join(problems) + HXN = net['HXN'] + info = HXN.synthesis_info + assert HXN.stream_splitting is True and info['status'] == 'mer' + assert [hx.ID for hx in HXN.new_HXs] == [hx.ID for hx in process] + assert_allclose([hx.Q for hx in HXN.new_HXs], [hx.Q for hx in process], + rtol=1e-9) + def structure(splits): + return [([u.ID for u in sp.splitters], sp.mixer.ID, + [[hx.ID for hx in b] for b in sp.branches]) for sp in splits] + assert structure(info['splits']) == structure(splits) + splits = info['splits'] + assert HXN.new_splitters == [u for sp in splits for u in sp.splitters] + assert HXN.new_mixers == [sp.mixer for sp in splits] + for lc in HXN.stream_life_cycles: + assert {id(sp) for sp in lc.splits} == { + id(sp) for sp in splits if sp.stream == lc.index} + assert_allclose(actual_loads(HXN), + [info['Q_hot_target'], info['Q_cold_target']], + rtol=0, atol=1e-6 * scale) + assert_feasible(HXN, dT) + +@pytest.mark.parametrize('name', SPLIT_CASES) +def test_split_mixer_outlet_is_the_planned_state(name, monkeypatch): + # a rigorous mixer seeded at the planned state lands on it: T within + # _MIX_T_TOL of the planned mix state and H equal to its inlets' (to + # round-off for constant CP; a real-thermo outlet's H is the H of the + # converged T); isothermal inlets share the mix state, others each + # carry the end state of their branch + run, ran = bst.Mixer._run, [] + def spy(self): + ran.append(self.ID) + return run(self) + with monkeypatch.context() as m: + m.setattr(bst.Mixer, '_run', spy) + result, info, curves, dT = split_synthesis(name, 'mix_' + name) + # every mixer is flashed, once: the checks below are on its outlet + assert ran == [sp.mixer.ID for sp in info['splits']] + assert hxn_synthesis._MIX_T_TOL == 1e-7 + smith = name.startswith('smith') + for sp in info['splits']: + j = sp.stream + curve = curves[j] + duty = abs(curve.H_out - curve.H_in) + out = sp.mixer.outs[0] + planned = curve.state_at_H(sp.H_mix) + assert abs(out.T - planned.T) <= hxn_synthesis._MIX_T_TOL + H_in = math.fsum(s.H for s in sp.mixer.ins) + assert abs(out.H - H_in) <= (1e-12 * abs(H_in) if smith + else 1e-9 * duty) + assert_allclose(out.F_mol, result[9][j].F_mol, rtol=1e-12) + assert [s.ID for s in sp.mixer.ins] == [ + f's_{j}_{b}__{sp.mixer.ID}' for b in range(len(sp.fractions))] + assert out.ID == f'{sp.mixer.ID}__s_{j}' + for f, s, branch in zip(sp.fractions, sp.mixer.ins, sp.branches): + assert_allclose(s.F_mol, f * out.F_mol, rtol=1e-12) + if sp.isothermal: + assert abs(s.T - planned.T) <= 1e-9 + else: + hx = branch[-1] + assert abs(s.H - hx.outs[stream_port(hx, j)].H + ) <= 1e-9 * duty + # one rule for every mixer: outside either tolerance (T, then H alone; + # `_DUTY_TOL` only decides what is reported), it is reported + for tol in ('_MIX_T_TOL', '_DUTY_TOL'): + with monkeypatch.context() as m: + m.setattr(hxn_synthesis, tol, -1.) + _, info, curves, _ = split_synthesis( + name, f'mix_dev_{tol}_{name}') + assert [d['ID'] for d in info['split_deviations']] == [ + sp.mixer.ID for sp in info['splits']], tol + for d, sp in zip(info['split_deviations'], info['splits']): + out = sp.mixer.outs[0] + assert set(d) == {'ID', 'T_plan', 'T', 'H_plan', 'H'} + assert d['T'] == out.T and d['H'] == out.H + assert d['H_plan'] == math.fsum(s.H for s in sp.mixer.ins) + assert d['T_plan'] == curves[sp.stream].state_at_H(sp.H_mix).T + +def test_unsplit_refine_loop_identical_with_splitting(monkeypatch): + # with chords 0.5 K off the exact curves and no refine round, round 0 + # violates and is repaired; no side splits, so the flag changes nothing + # (the refine loop with splitting is the default loop, R3) + curves = hxn_synthesis.stream_curves + monkeypatch.setattr(hxn_synthesis, 'stream_curves', + lambda *args, **kwargs: curves(*args, tol_T=0.5, **kwargs)) + monkeypatch.setattr(hxn_synthesis, '_MAX_REFINE', 0) + units, T_min_app = test_hxn_targets.case_curvature() + hus = [hx.heat_utilities[0] for hx in units] + plans = spy_plans(monkeypatch) + runs = [] + for flag in (False, True): + bst.main_flowsheet.set_flowsheet(f'curvature_split_{flag}') + info = {} + result = synthesize_network(hus, T_min_app, info=info, + stream_splitting=flag) + runs.append((info, [(hx.ID, hx.Q) for hx in result[0] + result[1]])) + (off, net_off), (on, net_on) = runs + assert len(plans) == 2 # one round each + assert on['refine_rounds'] == off['refine_rounds'] == 0 + assert on['repaired'] and on['repaired'] == off['repaired'] + assert net_on == net_off + assert 'splits' not in off and 'stream_splitting' not in off + assert on['splits'] == on['split_deviations'] == [] + assert all(s['split'] is None for s in on['sides'].values()) + +def test_split_retry_changes_the_candidate(monkeypatch): + # a violation on a split side after the last refine round (injected in + # round 0, with no refine rounds): the side's network is excluded by its + # signature, and the retry round plans a different one there + monkeypatch.setattr(hxn_synthesis, '_MAX_REFINE', 0) + plans = spy_plans(monkeypatch) + exact, injected = hxn_synthesis._exact_approach, [] + def inject(plan, duties, ends, curves, knots, T_min_app): + worst, violations, bad = exact(plan, duties, ends, curves, knots, + T_min_app) + if len(plans) == 1 and not injected: + assert not bad + n = next(n for n, e in enumerate(plan.exchangers) + if (e.hot_frac, e.cold_frac) != (1., 1.)) + e = plan.exchangers[n] + curve, H = curves[e.hot], ends[n, e.hot][0] # its hot end + violations = {e.hot: [(curve.T_exact(curve.H_lo + H), H)]} + bad = [n] + injected.append(e.side) + return worst, violations, bad + monkeypatch.setattr(hxn_synthesis, '_exact_approach', inject) + result, info, curves, dT = split_synthesis('smith2005_ex18_2_split', + 'split_retry') + (s,) = injected + assert len(plans) == 2 and info['refine_rounds'] == 1 + sp0 = plans[0][0].info['sides'][s]['split'] + sp1 = plans[1][0].info['sides'][s]['split'] + assert not _splitting._same_network(sp1['signature'], sp0['signature']) + assert plans[1][1]['_split_exclude'] == {s: {sp0['signature']}} + assert plans[1][1]['_split_prefer'] == { + s: (sp0['candidate'], sp0['signature'])} + assert sp1['candidates'][sp0['candidate']] == 'excluded' + # the retry's plan is the one realized (no restore) + assert info['sides'] is plans[1][0].info['sides'] + assert info['status'] == 'mer' and info['split_deviations'] == [] + +def test_split_retry_restores_the_best_plan(monkeypatch): + # a retry that does worse (injected: one violating exchanger on the + # split side in round 0, all of that side's in the retry): the round-0 + # plan, with the fewest violating exchangers, is restored and realized + monkeypatch.setattr(hxn_synthesis, '_MAX_REFINE', 0) + monkeypatch.setattr(hxn_synthesis, '_MAX_SPLIT_RETRY', 1) + plans = spy_plans(monkeypatch) + exact, injected = hxn_synthesis._exact_approach, [] + def inject(plan, duties, ends, curves, knots, T_min_app): + worst, violations, bad = exact(plan, duties, ends, curves, knots, + T_min_app) + if len(injected) < len(plans) <= 2: # once in each round + assert not bad + s = next(e.side for e in plan.exchangers + if (e.hot_frac, e.cold_frac) != (1., 1.)) + bad = [n for n, e in enumerate(plan.exchangers) if e.side == s] + bad = bad[:1] if len(plans) == 1 else bad + violations = {} + for n in bad: + e = plan.exchangers[n] + curve, H = curves[e.hot], ends[n, e.hot][0] + violations.setdefault(e.hot, []).append( + (curve.T_exact(curve.H_lo + H), H)) + injected.append((s, bad)) + return worst, violations, bad + monkeypatch.setattr(hxn_synthesis, '_exact_approach', inject) + result, info, curves, dT = split_synthesis('smith2005_ex18_2_split', + 'split_restore') + (s, bad0), (s1, bad1) = injected + assert s1 == s and len(bad0) == 1 < len(bad1) + assert len(plans) == 2 and info['refine_rounds'] == 1 + sp0 = plans[0][0].info['sides'][s]['split'] + assert plans[1][0].info['sides'][s]['split']['signature'] != ( + sp0['signature']) + assert info['sides'] is plans[0][0].info['sides'] # restored + assert info['status'] == 'mer' and info['repaired'] == [] + (sp,) = info['splits'] + assert sp.fractions == plans[0][0].splits[0].fractions + +#: A real-thermo problem (a methanol and a steam desuperheater, a +#: water/ethanol condenser, a water/methanol glide and pressurized water; +#: random, review B) whose split side below the pinch keeps one exchanger +#: 2.5e-5 K short through every refine round (it is repaired): the refine +#: rounds move its split fraction by ~1e-7 each, and the retries must still +#: see the same network there. +DRIFT_CASE = dict( + name='split_fraction_drift', kind='real_thermo', + chemicals=['Water', 'Ethanol', 'Methanol'], T_min_app=5., + streams=[ + ('H0', {'Methanol': 24.2}, 414.09091911932484, 322.2002126772226, + 200000.0, 'g', True), + ('H1', {'Water': 14.38}, 452.16355011784566, 396.2897045670141, + 500000.0, 'g', True), + ('H2', {'Water': 28.1, 'Ethanol': 14.05}, 'dew', 345.28119572613235, + 101325.0, 'l', True), + ('C0', {'Water': 16.7625, 'Methanol': 4.47}, 328.63252116884047, + 372.0, 101325.0, 'l', True), + ('C1', {'Water': 123.1}, 300.025591230207, 367.08069142831766, + 1000000.0, 'l', False)]) + +def test_split_identity_survives_refined_knots(monkeypatch): + # every refine round re-plans the split side's pick alone (its network + # is unchanged, its fraction only drifts with the knots), and a retry + # never picks a network it excluded while another candidate is live + same = _splitting._same_network + plans = spy_plans(monkeypatch) + units, dT = test_hxn_mer._build(DRIFT_CASE) + hus = sorted([hx.heat_utilities[0] for hx in units], + key=lambda hu: hu.duty) + bst.main_flowsheet.set_flowsheet('split_fraction_drift') + info = {} + synthesize_network(hus, dT, info=info, stream_splitting=True) + assert info['status'] == 'mer' and info['split_deviations'] == [] + assert info['refine_rounds'] == len(plans) - 1 > hxn_synthesis._MAX_REFINE + picks = [plan.info['sides']['below']['split'] for plan, _ in plans] + assert all(sp['candidate'] is not None for sp in picks) + fractions = [plan.splits[0].fractions for plan, _ in plans] + assert fractions[1] != fractions[0] # the knots moved them + last = hxn_synthesis._MAX_REFINE + for r, ((plan, kw), sp) in enumerate(zip(plans, picks)): + if 1 <= r <= last: # stickiness + prefer = kw['_split_prefer']['below'] + assert prefer == (picks[r - 1]['candidate'], + picks[r - 1]['signature']) + assert set(sp['candidates']) == {prefer[0]}, r + assert same(sp['signature'], prefer[1]) + elif r > last: # a retry: never a network excluded before while + # another candidate is live + before = [p['signature'] for p in picks[last:r]] + live = [v for v in sp['candidates'].values() + if isinstance(v, tuple)] + assert live or not any(same(sp['signature'], s) for s in before) + assert sp['candidates'][picks[r - 1]['candidate']] == 'excluded' + # every retry excluded a network not excluded before (the set is the + # loop's own, so it is read after the loop) + excluded = plans[-1][1]['_split_exclude']['below'] + assert len(excluded) == len(plans) - 1 - last + assert all(any(same(p['signature'], s) for s in excluded) + for p in picks[last:-1]) + assert not any(same(a, b) for a, b in itertools.combinations(excluded, 2)) + +def test_split_realization_failure_merges_the_split(monkeypatch): + # a mixer that cannot be simulated: its split's branch exchangers are + # dropped (their duty goes to the utilities), no splitter or mixer is + # left, the stream passes as a trunk and every balance still closes; + # the failed round's units (exchangers included) leave the registry, so + # the next round replaces none of them + plans = spy_plans(monkeypatch) + def fail(self): raise RuntimeError('mixer failed') + monkeypatch.setattr(bst.Mixer, '_run', fail) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + result, info, curves, dT = split_synthesis('smith2005_ex18_2_split', + 'split_failure') + replaced = re.compile(r'<\w+: (HX_|s_\d|Split_|Mix_)\S*> has been ' + r'replaced in registry') + assert not [str(w.message) for w in caught + if replaced.search(str(w.message))] + hs, cs, utils = result[:3] + streams_inlet, stream_HXs = result[9], result[10] + (plan, _), = plans + (split,) = plan.splits + branch = [n for ns in split.branches for n in ns] + assert len(info['dropped']) == len(branch) >= 2 + assert all('mixer failed' in d['error'] for d in info['dropped']) + assert_allclose(sorted(d['Q'] for d in info['dropped']), + sorted(plan.exchangers[n].Q for n in branch), rtol=1e-15) + assert info['splits'] == [] and info['status'] == 'best_effort' + assert not [ID for ID in bst.main_flowsheet.unit.data + if ID.startswith(('Split_', 'Mix_'))] + scale = sum(abs(c.H_out - c.H_in) for c in curves) + assert_allclose([info['Q_hot'], info['Q_cold']], + [info['Q_hot_plan'], info['Q_cold_plan']], + rtol=0, atol=1e-6 * scale) + Q_lost = math.fsum(d['Q'] for d in info['dropped']) + assert_allclose(info['Q_hot_plan'], info['Q_hot_target'] + Q_lost, + rtol=1e-9) + # every remaining exchanger runs whole streams + for hx in hs + cs: + for k, j in enumerate(_stream_ports(hx)): + assert_allclose(hx.ins[k].F_mol, streams_inlet[j].F_mol, + rtol=1e-14) + assert len(hs + cs) == len(plan.exchangers) - len(branch) + +# --------------------------------------------------------------------------- +# Stream splitting: life cycles through splits, pinch diagram +# --------------------------------------------------------------------------- + +from hensmith.hxn_synthesis import LifeStage, StreamSplit, _format_H + +#: Synthesized split networks: a hot stream split at its inlet (smith), a +#: split as the last node (rtB05), four-branch chains split below and then +#: above the pinch with no trunk exchanger between (crude), and splits of +#: several streams, one of them after a trunk exchanger (rtB03). +LIFE_CYCLE_CASES = [*SPLIT_CASES, 'crude_fractionation_ph11c2', + 'rtB03_above_hot_vapors'] + +def split_life_cycles(result, info): + """The life cycle of every stream of a synthesized split network (the + result and info of `split_synthesis`); every stream is given all the + splits, and keeps its own.""" + hs, cs, utils = result[:3] + streams_inlet, cold_indices = result[9], result[12] + cycles = [] + for i in range(len(streams_inlet)): + lc = StreamLifeCycle(i, i in cold_indices) + lc.get_life_cycle(hs + cs, utils, splits=info['splits']) + cycles.append(lc) + return cycles + +def _is_exchanger(unit): + return isinstance(unit, (bst.HXprocess, bst.HXutility)) + +def _downstream(connections): + """Every unit's set of units downstream of it along `connections`.""" + successors = {} + for up, _, down, _ in connections: + successors.setdefault(up, []).append(down) + reach = {} + for unit in successors: + seen, stack = set(), list(successors[unit]) + while stack: + other = stack.pop() + if other in seen: continue + seen.add(other) + stack.extend(successors.get(other, ())) + reach[unit] = seen + return reach + +def _contracted(connections, is_exchanger=_is_exchanger): + """Exchanger pairs joined by `connections` through splitters and mixers + only.""" + successors = {} + for up, _, down, _ in connections: + successors.setdefault(up, []).append(down) + pairs = set() + for unit in successors: + if not is_exchanger(unit): continue + stack = list(successors[unit]) + while stack: + other = stack.pop() + if is_exchanger(other): pairs.add((unit, other)) + else: stack.extend(successors.get(other, ())) + return pairs + +@pytest.mark.parametrize('name', LIFE_CYCLE_CASES) +def test_split_life_cycles(name): + # a stream's life cycle through its splits: its stages in the order of + # `stream_HXs` (trunk exchangers, every split's branches branch by + # branch, each in flow order), branch stages tagged with their split and + # fraction, the full-flow inlet at the entry port (the splitter chain + # where the stream splits at its inlet), and connections that wire every + # outlet to the next unit's planned inlet, each port once + result, info, curves, dT = split_synthesis(name, 'lc_' + name) + stream_HXs = result[10] + streams_inlet = result[9] + splits = info['splits'] + cycles = split_life_cycles(result, info) + assert {sp for lc in cycles for sp in lc.splits} == set(splits) + for lc in cycles: + i = lc.index + stages = lc.life_cycle + units = [s.unit for s in stages] + assert units == stream_HXs[i] + # the stream's own splits, in flow order + def first(sp): return units.index(sp.branches[0][0]) + assert lc.splits == sorted([sp for sp in splits if sp.stream == i], + key=first) + tags = {hx: ((k, b), f) for k, sp in enumerate(lc.splits) + for b, (f, hxs) in enumerate(zip(sp.fractions, sp.branches)) + for hx in hxs} + for stage in stages: + assert (stage.branch, stage.fraction) == tags.get(stage.unit, + (None, 1.)) + # one line per stage (with its enthalpies), then one per split + text = repr(lc) + assert text.count(' kJ/hr') == 2 * len(stages) + for k, sp in enumerate(lc.splits): + fractions = ', '.join(f'{f:.4g}' for f in sp.fractions) + assert (f'\n\tsplit {k}: {len(sp.fractions)} branches ' + f'({fractions})') in text + assert text.count('\tsplit ') == len(lc.splits) + duty = abs(curves[i].H_out - curves[i].H_in) + assert abs(lc.H_in - curves[i].H_in) <= 1e-9 * duty + entry = lc.entry.unit.ins[lc.entry.index] + assert_allclose(entry.F_mol, streams_inlet[i].F_mol, rtol=1e-14) + connections = list(lc.connections()) + pairs = [(a.unit, b.unit) for a, b in lc.stage_pairs()] + assert len(pairs) == len(set(pairs)) + if not lc.splits: + # exactly the consecutive stages, as before splitting + assert lc.entry == (stages[0].unit, stages[0].index) + assert lc.H_in == stages[0].H_in + assert connections == [(a.unit, a.index, b.unit, b.index) + for a, b in zip(stages, stages[1:])] + assert pairs == list(zip(units, units[1:])) + continue + if lc.splits[0].position == 0: + assert lc.entry == (lc.splits[0].splitters[0], 0) + assert_allclose(stages[0].H_in, stages[0].fraction * lc.H_in, + rtol=1e-12) + else: + assert lc.entry == (stages[0].unit, stages[0].index) + assert lc.H_in == stages[0].H_in + for stage in stages: + assert_allclose(stage.s_in.F_mol, stage.fraction * entry.F_mol, + rtol=1e-12) + # every inlet port is fed once but the entry, every outlet port + # feeds once but the utility's + splitters = [u for sp in lc.splits for u in sp.splitters] + mixers = [sp.mixer for sp in lc.splits] + inlets = {(s.unit, s.index) for s in stages} + inlets.update((u, 0) for u in splitters) + inlets.update((sp.mixer, b) for sp in lc.splits + for b in range(len(sp.fractions))) + outlets = {(s.unit, s.index) for s in stages} + outlets.update((u, p) for u in splitters for p in (0, 1)) + outlets.update((u, 0) for u in mixers) + fed = [(down, pi) for _, _, down, pi in connections] + feeding = [(up, po) for up, po, _, _ in connections] + assert len(fed) == len(set(fed)) and len(feeding) == len(set(feeding)) + assert set(fed) == inlets - {tuple(lc.entry)} + assert set(feeding) == outlets - {(units[-1], 0)} + # wiring by the plan: each outlet carries the next inlet's flow and + # enthalpy + for up, po, down, pi in connections: + s_out, s_in = up.outs[po], down.ins[pi] + assert_allclose(s_out.F_mol, s_in.F_mol, rtol=1e-12) + assert abs(s_out.H - s_in.H) <= 1e-9 * duty, (up.ID, down.ID) + # the exchangers' precedence runs through the splitters and mixers + # (none between sibling branches) + assert set(pairs) == _contracted(connections) + if name.startswith('smith'): + # the hot stream splits at its inlet and re-joins before its + # trunk exchanger and its cooler + lc = cycles[2] + assert [(u.ID, po, d.ID, pi) for u, po, d, pi in lc.connections() + ] == [('Split_2_hs', 0, 'HX_1_2_hs', 1), + ('HX_1_2_hs', 1, 'Mix_2_hs', 0), + ('Split_2_hs', 1, 'HX_0_2_hs', 1), + ('HX_0_2_hs', 1, 'Mix_2_hs', 1), + ('Mix_2_hs', 0, 'HX_2_1_cs', 0), + ('HX_2_1_cs', 0, 'Util_2_cs', 0)] + assert [(a.unit.ID, b.unit.ID) for a, b in lc.stage_pairs()] == [ + ('HX_1_2_hs', 'HX_2_1_cs'), ('HX_0_2_hs', 'HX_2_1_cs'), + ('HX_2_1_cs', 'Util_2_cs')] + # a split whose branch holds an exchanger of another stream + (sp,) = lc.splits + other = next(u for u in result[2] if u.ID == 'Util_0_hs') + bad = StreamSplit(2, sp.side, sp.index, sp.fractions, sp.splitters, + sp.mixer, [sp.branches[0], [other]], sp.position, + sp.isothermal, sp.H_split, sp.H_mix) + with pytest.raises(ValueError, match='does not carry stream 2'): + StreamLifeCycle(2, False).get_life_cycle( + result[0] + result[1], result[2], splits=[bad]) + elif name.startswith('crude'): + # the chain of four branches below the pinch re-joins into the + # chain above it + lc = cycles[1] + IDs = [(u.ID, po, d.ID, pi) for u, po, d, pi in lc.connections()] + assert IDs[:3] == [('Split_1_cs', 1, 'Split_1_cs_b2', 0), + ('Split_1_cs_b2', 1, 'Split_1_cs_b3', 0), + ('Split_1_cs', 0, 'HX_2_1_cs', 1)] + assert ('Split_1_cs_b3', 1, 'HX_7_1_cs', 1) in IDs + assert ('Mix_1_cs', 0, 'Split_1_hs', 0) in IDs + assert IDs[-1] == ('Mix_1_hs', 0, 'Util_1_hs', 0) + +def fake_split_life_cycle(cold=True): + """A hand-built life cycle of stream 7 (units are names): trunk + exchanger A; split 1 into B1 and a bypass; split 2, right after it, + into C1 -> C2, D1 and a bypass; trunk exchanger E; utility U.""" + lc = StreamLifeCycle(7, cold) + lc.splits = [ + StreamSplit(7, 'below', 1, (.6, .4), ['S1'], 'M1', [['B1'], []], 1, + True, 0., 0.), + StreamSplit(7, 'above', 1, (.5, .3, .2), ['S2', 'S2_b2'], 'M2', + [['C1', 'C2'], ['D1'], []], 1, False, 0., 0.), + ] + lc.life_cycle = [LifeStage('A', 0), LifeStage('B1', 1, (0, 0), .6), + LifeStage('C1', 0, (1, 0), .5), + LifeStage('C2', 1, (1, 0), .5), + LifeStage('D1', 0, (1, 1), .3), LifeStage('E', 0), + LifeStage('U', 0)] + return lc + +def test_life_cycle_connections_through_bypasses(): + # a bypass wires its splitter outlet to its mixer inlet, and carries + # the precedence of the stages before the split past it; consecutive + # splits chain their mixer into the next splitter + lc = fake_split_life_cycle() + assert list(lc.connections()) == [ + ('A', 0, 'S1', 0), ('S1', 0, 'B1', 1), ('B1', 1, 'M1', 0), + ('S1', 1, 'M1', 1), ('M1', 0, 'S2', 0), ('S2', 1, 'S2_b2', 0), + ('S2', 0, 'C1', 0), ('C1', 0, 'C2', 1), ('C2', 1, 'M2', 0), + ('S2_b2', 0, 'D1', 0), ('D1', 0, 'M2', 1), ('S2_b2', 1, 'M2', 2), + ('M2', 0, 'E', 0), ('E', 0, 'U', 0)] + pairs = [(a.unit, b.unit) for a, b in lc.stage_pairs()] + assert pairs == [('A', 'B1'), ('B1', 'C1'), ('A', 'C1'), ('C1', 'C2'), + ('B1', 'D1'), ('A', 'D1'), ('C2', 'E'), ('D1', 'E'), + ('B1', 'E'), ('A', 'E'), ('E', 'U')] + assert set(pairs) == _contracted(lc.connections(), + lambda u: not u.startswith(('S', 'M'))) + assert lc.entry is None # set by get_life_cycle only + +@pytest.mark.parametrize('cold', [True, False]) +def test_split_exchanger_columns(cold): + # columns follow the flow through splits (right to left for a hot + # stream), with precedence carried through the stages left out, and + # none between sibling branches, which keep the given order + from hensmith.hxn_synthesis import _order_exchanger_columns + lc = fake_split_life_cycle(cold) + def order(*hxs): + return _order_exchanger_columns(list(hxs), [lc]) + def flow(*hxs): return list(hxs) if cold else list(reversed(hxs)) + assert order('D1', 'C1') == ['D1', 'C1'] + assert order('C2', 'D1') == ['C2', 'D1'] + assert order('C2', 'A') == flow('A', 'C2') + assert order('E', 'B1', 'A') == flow('A', 'B1', 'E') + assert order('E', 'C2', 'D1', 'B1') == (['B1', 'C2', 'D1', 'E'] if cold + else ['E', 'C2', 'D1', 'B1']) + assert order('C2', 'C1') == flow('C1', 'C2') + +@pytest.mark.parametrize('name', ['smith2005_ex18_2_split', + 'crude_fractionation_ph11c2']) +def test_split_network_pinch_diagram(name): + # the diagram of a split network: branch exchangers are columns, the H + # labels are the full-flow inlet and outlet of the stream (not those of + # its first branch stage, where it splits at its inlet), and the + # columns of a split life cycle follow the flow between its non-sibling + # exchangers only + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from hensmith.hxn_synthesis import _order_exchanger_columns + result, info, curves, dT = split_synthesis(name, 'diagram_' + name) + hs, cs, T_in, T_out = result[0], result[1], result[4], result[5] + cycles = split_life_cycles(result, info) + fig, ax = hxn_synthesis.plot_pinch_diagram( + cycles, T_in, T_out, hs, cs, show_units=False, + show_auxiliary_units=False, show_stream_IDs=False) + try: + assert {a.get_gid() for a in _gid_artists(ax, 'HX:')} == { + 'HX:' + hx.ID for hx in hs + cs} + texts = {a.get_gid(): a.get_text() for a in ax.texts if a.get_gid()} + for lc in cycles: + i = lc.index + assert texts[f'H_in:{i}'] == _format_H(lc.H_in) + assert texts[f'H_out:{i}'] == _format_H(lc.life_cycle[-1].H_out) + at_inlet = [lc for lc in cycles + if lc.splits and lc.entry.unit is lc.splits[0].splitters[0]] + assert at_inlet + for lc in at_inlet: + assert _format_H(lc.life_cycle[0].H_in) != _format_H(lc.H_in) + finally: + plt.close(fig) + for lc in cycles: + if not lc.splits: continue + reach = _downstream(lc.connections()) + process = [s.unit for s in lc.life_cycle + if isinstance(s.unit, bst.HXprocess)] + for a, b in itertools.combinations(process, 2): + if b in reach[a]: expected = [a, b] if lc.cold else [b, a] + else: expected = None # siblings: the given order + for given in ([a, b], [b, a]): + assert _order_exchanger_columns(given, [lc]) == ( + expected or given), (a.ID, b.ID) + # the facility's diagram of its own split network, with the same labels + HXN = test_hxn_mer._network(test_hxn_mer._case(name), True)['HXN'] + fig, ax = HXN.plot_pinch_diagram(show_units=False, + show_auxiliary_units=False, + show_stream_IDs=False) + try: + assert {a.get_gid() for a in _gid_artists(ax, 'HX:')} == { + 'HX:' + hx.ID for hx in HXN.new_HXs} + texts = {a.get_gid(): a.get_text() for a in ax.texts if a.get_gid()} + cycles = HXN.stream_life_cycles + for lc in cycles: + i = lc.index + assert texts[f'H_in:{i}'] == _format_H(lc.H_in) + assert texts[f'H_out:{i}'] == _format_H(lc.life_cycle[-1].H_out) + assert _format_H(lc.H_in) == _format_H( + HXN.original_heat_exchangers[i].ins[0].H) + assert [lc for lc in cycles + if lc.splits and lc.entry.unit is lc.splits[0].splitters[0]] + finally: + plt.close(fig) + +# --------------------------------------------------------------------------- +# Stream splitting: the facility +# --------------------------------------------------------------------------- + +import inspect + +def corpus_units(name): + """The process exchangers of corpus case `name` and its T_min_app.""" + return test_hxn_mer._build(test_hxn_mer._case(name)) + +def split_facility(units, T_min_app, name='sys', **kwargs): + """`simulate_HXN` with stream splitting: (sys, HXN, the strict checker's + problems, G0-G10 of test_hxn_mer).""" + sys, HXN = simulate_HXN(units, T_min_app, name, stream_splitting=True, + **kwargs) + net = test_hxn_mer._network_record(units, HXN, T_min_app, True) + return sys, HXN, test_hxn_mer._network_problems(net) + +def test_stream_splitting_is_off_by_default(): + # without the keyword the facility never splits: no splitter, no mixer, + # no split in any life cycle and no split key in the synthesis info + signature = inspect.signature(HeatExchangerNetwork) + assert signature.parameters['stream_splitting'].default is False + sys, HXN, feed = build_system() + assert HXN.stream_splitting is False + sys.simulate() + assert HXN.new_splitters == HXN.new_mixers == [] + info = HXN.synthesis_info + assert 'splits' not in info and 'stream_splitting' not in info + assert all('split' not in side for side in info['sides'].values()) + assert not any(lc.splits for lc in HXN.stream_life_cycles) + assert set(HXN.HXN_sys.units) == set(HXN.new_HXs + HXN.new_HX_utils) + +#: IDs of the units of a split (design 4.5), as the strict checker has them +SPLIT_UNIT_IDS = {kind: pattern + for kind, pattern, _ in test_hxn_mer._NETWORK_UNITS + if kind in (bst.Splitter, bst.Mixer)} + +def test_split_network_units_and_ids(): + # smith2005_ex18_2 splits a hot stream at its inlet: the facility holds + # the splitter and the rigorous mixer in its lists and its system, with + # unique IDs, costs nothing for them, simulates every unit after its + # feeder along the life cycles' connections, and reaches MER feasibly + units, dT = corpus_units('smith2005_ex18_2_split') + sys, HXN, problems = split_facility(units, dT, 'sys_split_ids') + assert not problems, '\n'.join(problems) + info = HXN.synthesis_info + assert info['status'] == 'mer' + splits = info['splits'] + assert HXN.new_splitters == [u for sp in splits for u in sp.splitters] + assert HXN.new_mixers == [sp.mixer for sp in splits] + assert len(HXN.new_splitters) == len(HXN.new_mixers) == 1 + for u in HXN.new_splitters + HXN.new_mixers: + kind = type(u) + assert kind in SPLIT_UNIT_IDS and SPLIT_UNIT_IDS[kind].match(u.ID) + assert u.purchase_cost == u.installed_cost == 0. + assert all(m.rigorous for m in HXN.new_mixers) + network = (HXN.new_HXs + HXN.new_HX_utils + HXN.new_splitters + + HXN.new_mixers) + assert len({u.ID for u in network}) == len(network) + assert set(HXN.HXN_sys.units) == set(network) + assert_path_follows_streams(HXN) + assert_feasible(HXN, dT) + total = total_duty(units) + assert_allclose(actual_loads(HXN), mer_targets(units, dT), + atol=1e-6 * total) + +def test_stream_life_cycles_csv(tmp_path, monkeypatch): + # one row per stage with the stage's own inlet and outlet enthalpies; + # a stream that splits at its inlet (smith2005_ex18_2) first gets a row + # for its splitter chain at the whole stream's inlet enthalpy, so that + # no row mixes the whole stream with a branch (unsplit, the first + # stage's inlet is the stream's, exactly) + import csv + HXN = test_hxn_mer._network( + test_hxn_mer._case('smith2005_ex18_2_split'), True)['HXN'] + monkeypatch.chdir(tmp_path) + HXN.save_stream_life_cycles_as_csv() + path, = tmp_path.glob('HXN-*.csv') + with open(path, newline='') as file: + header, *rows = [row for row in csv.reader(file) if row] + assert header == ['Stream', 'Type', 'Original unit', 'HXN unit', + 'H_in (kJ/hr)', 'H_out (kJ/hr)', 'T_in (C)', 'T_out (C)'] + expected, split_entries = [], 0 + for lc in HXN.stream_life_cycles: + j = lc.index + stages = [(s.unit.ID, s.H_in, s.H_out) for s in lc.life_cycle] + entry = lc.entry.unit + if isinstance(entry, bst.Splitter): + split_entries += 1 + stages.insert(0, (entry.ID, lc.H_in, lc.H_in)) + else: + assert stages[0][1] == lc.H_in + last = len(stages) - 1 + expected += [[j, 'Cold' if lc.cold else 'Hot', ID, H_in, H_out, + HXN.inlet_Ts[j] - 273.15 if n == 0 else None, + HXN.outlet_Ts[j] - 273.15 if n == last else None] + for n, (ID, H_in, H_out) in enumerate(stages)] + assert split_entries == 1 + def number(x): return None if x == '' else float(x) + assert [[int(r[0]), r[1], r[3], *map(number, r[4:])] + for r in rows] == expected + +@pytest.mark.parametrize('name', LIFE_CYCLE_CASES) +def test_split_stage_fractions(name): + # what a cached network restores its enthalpy limits from: every limit + # as a share of its whole stream's duty, which for a branch stage (f of + # the flow, whose limit is f times the whole stream's) is on the whole + # stream's basis, with f in `_stage_scales` (branch ports only); the + # share restores the synthesized limit + HXN = test_hxn_mer._network(test_hxn_mer._case(name), True)['HXN'] + fractions, scales = HXN._stage_fractions, HXN._stage_scales + branch_ports = set() + for hx, lc in zip(HXN.original_heat_exchangers, HXN.stream_life_cycles): + H_in, H_out = hx.ins[0].H, hx.outs[0].H + for stage in lc.life_cycle[:-1]: + key = stage.unit.ID, stage.index + H_lim = getattr(stage.unit, f'H_lim{stage.index}') + if H_lim is None: + assert key not in fractions and key not in scales + continue + if stage.branch is not None: branch_ports.add(key) + f = scales.get(key, 1.) + assert f == stage.fraction + restored = f * (H_in + fractions[key] * (H_out - H_in)) + assert abs(restored - H_lim) <= 1e-12 * (abs(H_in) + abs(H_out) + + abs(H_lim)), key + assert branch_ports and set(scales) == branch_ports + +def test_synthetic_network_splits_to_mer(): + # regression case 04 (test_synthetic_network_needs_a_split) reaches MER + # when streams may split, with the network balanced and feasible + units = synthetic_units() + sys, HXN, problems = split_facility(units, 5., 'sys_synthetic_split') + assert not problems, '\n'.join(problems) + info = HXN.synthesis_info + assert info['status'] == 'mer' and info['splits'] + assert_feasible(HXN, 5.) + assert_allclose(actual_loads(HXN), mer_targets(units, 5.), + atol=1e-6 * total_duty(units)) + assert_path_follows_streams(HXN) + +#: two problems of the facility fuzz of stream splitting (random pinch +#: problems with odd CPs), ``(ID, T_in, T_out, CP)`` rows at dT 10 K +SPLIT_AT_THE_MINIMUM = { + # every Stage S rule gave H1 a branch below the minimum fraction, which + # merged away, and a core candidate split it 0.9999 / 1e-4; C6's CP + # room takes a branch of the minimum (`_splitting._cut_fractions`) + 'tiny_demand_branch': [ + ('H0', 205., 170., 7.), ('H1', 225., 170., 1.), + ('H2', 185., 150., .999999), ('C3', 150., 245., .999999), + ('C4', 120., 240., 1e-4), ('C5', 160., 245., 7.), + ('C6', 160., 240., 1e-3)], + # Stage S splits H0 0.999 / 0.001 below the pinch; the splitter makes + # its second branch as the complement of its first, a hair below 0.001 + # (G4 holds the planned fraction to the minimum, the flows to the plan) + 'complement_at_the_minimum': [ + ('H0', 190., 180., 7.), ('C1', 130., 195., 1e-3), + ('C2', 160., 260., .999999)], +} + +@pytest.mark.parametrize('problem', sorted(SPLIT_AT_THE_MINIMUM)) +def test_split_branches_keep_the_minimum_fraction(problem): + # both reach MER with every planned branch at least the minimum + # fraction, and pass the strict checks and the synthesis report + units = cp_units(f'min_fraction_{problem}', SPLIT_AT_THE_MINIMUM[problem], + 200.) + sys, HXN, problems = split_facility(units, 10., + f'sys_min_fraction_{problem}') + problems += test_hxn_mer._split_report_problems(HXN, 10.) + assert not problems, '\n'.join(problems) + splits = HXN.synthesis_info['splits'] + assert splits and all(min(s.fractions) >= _splitting._SPLIT_MIN_FRACTION + for s in splits) + assert_feasible(HXN, 10.) + +def test_split_network_registers_no_intermediate_streams(): + # as test_synthesis_registers_no_intermediate_streams, with splitting: + # no synthesis (the first, or a new one) replaces anything in a + # registry, the splitters, mixers and their streams are registered in + # the network's flowsheet under their own IDs, and the main flowsheet + # gets no stream + units = synthetic_units() + before = set(bst.main_flowsheet.stream.data) + HXN = HeatExchangerNetwork('HXN', T_min_app=5., stream_splitting=True) + sys = bst.System.from_units('sys_split_registry', units=[*units, HXN]) + for n in range(2): + 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['splits'] + flowsheet = HXN.HXN_flowsheet + for u in HXN.new_splitters + HXN.new_mixers: + assert flowsheet.unit.data[u.ID] is u + for s in (*u.ins, *u.outs): + assert flowsheet.stream.data[s.ID] is s, (u.ID, s.ID) + assert set(bst.main_flowsheet.stream.data) == before + # the doctest system needs no split: with the keyword, its network is + # the one without it, bit for bit (design R3) + sys, HXN, feed = build_system() + sys.simulate() + def network(HXN): + return ([(hx.ID, hx.Q) for hx in HXN.new_HXs], + [(hx.ID, hx.Q) for hx in HXN.new_HX_utils], + HXN.synthesis_info['refine_rounds']) + unsplit = network(HXN) + HXN.stream_splitting = True + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + sys.simulate() + assert not [w for w in caught if 'replaced in registry' in str(w.message)] + info = HXN.synthesis_info + assert info['status'] == 'mer' and info['splits'] == [] + assert HXN.new_splitters == HXN.new_mixers == [] + assert network(HXN) == unsplit + +def force_split(monkeypatch, name): + """Plan side `name` ('above' or 'below') with stream splits although an + unsplit network serves it: every plan of that side sees a 'cascade' + root (a deficit within the cascade's tolerance), so it is planned with + splits first (`hensmith._planner._plan_side`), by the core strategies.""" + plan_side, rules_violation = _planner._plan_side, _planner._Side.rules_violation + roots = [] # the side whose root check is next + def forced_plan_side(side, *args, **kwargs): + roots.append(side) + try: + return plan_side(side, *args, **kwargs) + finally: # a trivial side returns before its root check + if roots and roots[-1] is side: roots.pop() + def forced_rules_violation(self, a, b, d): + proof = rules_violation(self, a, b, d) + if roots and roots[-1] is self: # the root's check in _plan_side + roots.pop() + if proof is None and self.name == name: + return dict(side=name, rule='cascade', slack=d.slack) + return proof + monkeypatch.setattr(_planner, '_plan_side', forced_plan_side) + monkeypatch.setattr(_planner._Side, 'rules_violation', + forced_rules_violation) + +def split_point_load_units(): + """A point-load cold stream (the superheated water/ethanol liquid of + test_non_monotone_stream_is_a_point_load, boiled to its dew point) whose + whole duty is at the pinch, above which two hot water streams cool to + the pinch: an unsplit network serves them in series (the point load + keeps one temperature), or a split of the point load at its inlet in + parallel.""" + bst.settings.set_thermo(['Water', 'Ethanol'], cache=True) + bst.main_flowsheet.set_flowsheet('split_point_load') + 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', 340., Water=300.), + utility_hx('H2', 400., 5e5, 'l', 345., Water=200.)] + +def test_point_load_enters_before_the_splitter(monkeypatch): + # a point load split at its inlet enters the network at its splitter, + # at equilibrium at its inlet enthalpy (353.07 K, colder than its + # 357.44 K outlet, not its 365 K feed), as its unsplit first exchanger + # would: every branch exchanger then takes it on the plan's side of + # its outlet temperature, and the network reaches MER feasibly + force_split(monkeypatch, 'above') + units = split_point_load_units() + sys, HXN, problems = split_facility(units, 5., 'sys_split_point_load') + assert not problems, '\n'.join(problems) + info = HXN.synthesis_info + point = units[0] + j = HXN.original_heat_exchangers.index(point) + assert info['point_loads'] == [j] + assert info['sides']['above']['method'] == 'split-V' + (sp,) = info['splits'] + assert sp.stream == j and sp.position == 0 + lc = HXN.stream_life_cycles[j] + assert lc.entry == (sp.splitters[0], 0) + feed = sp.splitters[0].ins[0] + real = _copy(point.ins[0]) + hxn_synthesis._first_inlet(real, True, HXN.outlet_Ts[j], False) + assert feed.T == real.T + assert feed.T < point.outs[0].T - 4. + assert point.ins[0].T > point.outs[0].T + 7. + duty = abs(point.outs[0].H - point.ins[0].H) + assert abs(feed.H - point.ins[0].H) <= 1e-12 * duty + for branch in sp.branches: + hx = branch[0] + assert hx.ins[stream_port(hx, j)].T == feed.T, hx.ID + assert info['status'] == 'mer' + assert not info['dropped'] and not info['deviations'] + assert_feasible(HXN, 5.) + assert_allclose(actual_loads(HXN), mer_targets(units, 5.), + atol=1e-6 * total_duty(units)) + # the cached network enters the point load at its splitter the same + # way, with every feed 10 % larger, and agrees with a fresh synthesis + # at those feeds + network = HXN.HXN_sys + HXN.cache_network = True + for u in units: u.ins[0].F_mol *= 1.1 + simulate_strictly(sys) + assert HXN.HXN_sys is network, 'cached network was not used' + real = _copy(point.ins[0]) + hxn_synthesis._first_inlet(real, True, HXN.outlet_Ts[j], False) + assert feed is sp.splitters[0].ins[0] and feed.T == real.T + duty = abs(point.outs[0].H - point.ins[0].H) + assert abs(feed.H - point.ins[0].H) <= 1e-12 * duty + for branch in sp.branches: + hx = branch[0] + assert hx.ins[stream_port(hx, j)].T == feed.T, hx.ID + assert_feasible(HXN, 5.) + cached = {hx.ID: hx.Q for hx in HXN.new_HXs} + HXN.cache_network = False + simulate_strictly(sys) + assert HXN.HXN_sys is not network + (fresh,) = HXN.synthesis_info['splits'] + assert fresh.splitters[0].ins[0].T == feed.T + assert_allclose(fresh.fractions, sp.fractions, rtol=1e-12) + assert sorted(cached) == sorted(hx.ID for hx in HXN.new_HXs) + for hx in HXN.new_HXs: + assert abs(hx.Q - cached[hx.ID]) <= 1e-12 * duty, hx.ID + +def drop_branch_exchanger(monkeypatch, b): + """Make the first realization of every synthesis fail at the first + exchanger of branch `b` of the plan's first split (its duty is then + dropped, and the branch bypasses).""" + realize, failed = hxn_synthesis._realize, [] + def failing(plan, duties, *args): + units, first, last = realize(plan, duties, *args) + if plan.splits and not failed: + n = plan.splits[0].branches[b][0] + failed.append(units[n].ID) + hxn_synthesis._discard(units.values()) + raise hxn_synthesis._RealizationError( + n, units[n].ID, RuntimeError('branch exchanger failed')) + return units, first, last + monkeypatch.setattr(hxn_synthesis, '_realize', failing) + return failed + +@pytest.mark.parametrize('name, b', [('smith2005_ex18_2_split', 1), + ('rtB05_above_2h1c', 0)]) +def test_split_branch_dropped_is_bypassed(monkeypatch, name, b): + # a branch exchanger that cannot be simulated is dropped: its branch + # bypasses (its splitter outlet feeds its mixer inlet), the drop is + # reported, and the network is balanced and feasible short of MER. The + # re-join is no longer isothermal: rtB05's feeds the stream's utility, + # as planned; smith2005_ex18_2's feeds the stream's trunk exchanger + # below the pinch, which the strict checks report (a non-isothermal + # re-join may flash off the stream's curve) and nothing else + failed = drop_branch_exchanger(monkeypatch, b) + units, dT = corpus_units(name) + sys, HXN, problems = split_facility(units, dT, 'sys_split_bypass') + info = HXN.synthesis_info + (ID,) = failed + assert [d['ID'] for d in info['dropped']] == [ID] + assert 'branch exchanger failed' in info['dropped'][0]['error'] + assert info['status'] == 'best_effort' + assert ID not in [hx.ID for hx in HXN.new_HXs] + (sp,) = info['splits'] + assert sp.branches[b] == [] and all(sp.branches[:b] + sp.branches[b + 1:]) + assert not sp.isothermal + unit, port = sp.outlet(b) + assert sp.mixer.ins[b] is unit.outs[port] + lc = HXN.stream_life_cycles[sp.stream] + assert (unit, port, sp.mixer, b) in list(lc.connections()) + utility = lc.life_cycle[-1].unit + if name.startswith('rtB05'): + assert sp.mixer.outs[0].sink is utility + assert not problems, '\n'.join(problems) + else: + trunk = sp.mixer.outs[0].sink + assert isinstance(trunk, bst.HXprocess) and trunk is not utility + assert problems == [f'G5 {sp.mixer.ID}: a non-isothermal re-join ' + f'feeding {trunk.ID}, not the utility'] + assert_path_follows_streams(HXN) + assert_feasible(HXN, dT) + +def test_split_side_keeps_cells_below_Qmin_facility(): + # Qmin never drops a split cell: smith2005_ex18_2's split side keeps + # its branch exchanger below Qmin (1.44e6 kJ/hr, Qmin 2.5e7 kJ/hr) and + # reports it, while its unsplit side drops its small exchanger (2.16e7 + # kJ/hr) as without splitting (the drop raises the utilities) + units, dT = corpus_units('smith2005_ex18_2_split') + Qmin = 2.5e7 + sys, HXN, problems = split_facility(units, dT, 'sys_split_qmin', + Qmin=Qmin) + assert not problems, '\n'.join(problems) + info = HXN.synthesis_info + above = info['sides']['above'] + assert above['status'] == 'mer' and above['split'] is not None + small = above['split']['small'] + assert small and all(q < Qmin for *_, q in small) + kept = [hx for hx in HXN.new_HXs if hx.Q < Qmin] + assert kept and all(hx in HXN.new_HXs_hot_side for hx in kept) + assert_allclose(sorted(hx.Q for hx in kept), sorted(q for *_, q in small), + rtol=1e-9) + assert info['qmin_dropped'] and all( + side == 'below' and q < Qmin for side, *_, q in info['qmin_dropped']) + assert info['status'] == 'best_effort' + assert_feasible(HXN, dT) + +def branch_limits(HXN): + """Every branch stage's enthalpy limit and what a cached network + restores it to: f times the limit at the stage's share of its whole + stream's duty (the partner rule of `HeatExchangerNetwork._cost` gives + port 0 the stream's outlet when port 1 has a limit), ``[(key, H_lim, + restored)]``.""" + fractions = HXN._stage_fractions + limits = [] + for hx, lc in zip(HXN.original_heat_exchangers, HXN.stream_life_cycles): + H_in, H_out = hx.ins[0].H, hx.outs[0].H + for stage in lc.life_cycle[:-1]: + if stage.branch is None: continue + key = stage.unit.ID, stage.index + H_lim = getattr(stage.unit, f'H_lim{stage.index}') + share = fractions.get(key) + if share is None: + limits.append((key, H_lim, None)) + continue + if stage.index == 0 and (stage.unit.ID, 1) in fractions: + share = 1. + limits.append((key, H_lim, stage.fraction + * (H_in + share * (H_out - H_in)))) + return limits + +@pytest.mark.parametrize('name', [*SPLIT_CASES, 'crude_fractionation_ph11c2']) +def test_cached_split_network(monkeypatch, name): + # the cached network serves feeds 10 % larger and smaller with the same + # units: each stream enters at its entry port (smith2005_ex18_2 splits + # at its inlet), the pre-copy moves the new flows through the splitters + # (at their unchanged splits; crude_fractionation_ph11c2 has four-branch + # splitter chains and a stream split twice, below and then above the + # pinch) and the mixers (summed) without running a mixer (its PH flash + # runs only in the guarded convergence), every branch limit is f times + # its whole-stream restore, and the network scales exactly; a changed + # stream_splitting synthesizes a new network (on the small cases: the + # crude's unsplit synthesis runs the best-effort search) + units, dT = corpus_units(name) + sys, HXN, problems = split_facility(units, dT, 'sys_split_cache') + assert not problems, '\n'.join(problems) + network = HXN.HXN_sys + members = list(network.units) + # the streams of the split streams, but for those that the path-order + # pre-copy leaves at their old flows: the outlets of a tear stream's + # consumer (which it runs before the tear's producer) and all that + # follows them on their stream (there, as without splits, only the + # convergence brings the new flows) + recycles = network.recycle or [] + if not isinstance(recycles, list): recycles = [recycles] + tears = {id(s) for s in recycles} + def outlets(unit, port): + # the outlets of `unit` on the stream that enters it at `port` + if isinstance(unit, (bst.Splitter, bst.Mixer)): return unit.outs + return [unit.outs[port]] + streams, stale = [], set() + for lc in HXN.stream_life_cycles: + if not lc.splits: continue + unit, index = lc.entry + streams.append(unit.ins[index]) + for up, port, down, down_port in lc.connections(): # in flow order + s = up.outs[port] + if id(s) in tears or id(s) in stale: + stale.update(map(id, outlets(down, down_port))) + streams.append(s) + last = lc.life_cycle[-1] + streams.append(last.unit.outs[last.index]) + streams = [s for s in streams if id(s) not in stale] + assert streams + if name not in SPLIT_CASES: + # an element of a splitter chain, and a split fed by a mixer + assert any(isinstance(s.source, bst.Splitter) + and re.search(r'_b\d+$', s.source.ID) for s in streams) + assert any(isinstance(s.source, bst.Mixer) + and isinstance(s.sink, bst.Splitter) for s in streams) + flows = [s.mol.copy() for s in streams] + Q = {hx.ID: hx.Q for hx in HXN.new_HXs} + splits = [u.split.copy() for u in HXN.new_splitters] + feeds = [u.ins[0] for u in units] + F_mol = [s.F_mol for s in feeds] + converge, mix = bst.System.converge, bst.Mixer._run + state = dict(factor=None, converging=False) + def converging(self, *args, **kwargs): + # (a failure raised in here would be swallowed by the facility's + # guard, so it is recorded instead) + if self is not network: return converge(self, *args, **kwargs) + state['stale'] = [s.ID for s, mol in zip(streams, flows) + if not np.allclose(s.mol, state['factor'] * mol, + rtol=1e-12, atol=0)] + state['converging'] = True + try: return converge(self, *args, **kwargs) + finally: state['converging'] = False + def mixing(self): + state['mixed' if state['converging'] else 'outside'].append(self.ID) + return mix(self) + monkeypatch.setattr(bst.System, 'converge', converging) + monkeypatch.setattr(bst.Mixer, '_run', mixing) + HXN.cache_network = True + total = total_duty(units) + for factor in (1.1, 0.9): + state.update(factor=factor, stale=None, mixed=[], outside=[]) + for s, F in zip(feeds, F_mol): s.F_mol = factor * F + simulate_strictly(sys) + assert HXN.HXN_sys is network and list(network.units) == members + # the pre-copy moved the new flows through the splits, and only + # the convergence ran the mixers + assert state['stale'] == [] and state['outside'] == [] + assert HXN.new_mixers and (set(state['mixed']) + == {u.ID for u in HXN.new_mixers}) + for u, split in zip(HXN.new_splitters, splits): + assert (u.split == split).all(), u.ID + limits = branch_limits(HXN) + assert any(restored is not None for _, _, restored in limits) + for key, H_lim, restored in limits: + if restored is None: assert H_lim is None, key + else: assert_allclose(H_lim, restored, rtol=1e-14, err_msg=key) + for hx in HXN.new_HXs: + assert abs(hx.Q - factor * Q[hx.ID]) <= 1e-12 * factor * total + for hx, lc in zip(HXN.original_heat_exchangers, + HXN.stream_life_cycles): + stage = lc.life_cycle[-1] + s_out = stage.unit.outs[stage.index] + assert abs(s_out.H - hx.outs[0].H) <= 1e-12 * factor * total + assert abs(s_out.T - hx.outs[0].T) <= 1e-9, hx.ID + assert_feasible(HXN, dT) + for s, F in zip(feeds, F_mol): s.F_mol = F + monkeypatch.undo() + if name not in SPLIT_CASES: return + # without splitting, then with it again: a new synthesis each time + HXN.stream_splitting = False + simulate_strictly(sys) + unsplit = HXN.HXN_sys + assert unsplit is not network and 'splits' not in HXN.synthesis_info + assert HXN.new_splitters == HXN.new_mixers == [] + HXN.stream_splitting = True + simulate_strictly(sys) + assert HXN.HXN_sys not in (network, unsplit) + assert HXN.synthesis_info['splits'] and HXN.new_mixers + assert not set(HXN.HXN_sys.units) & set(members) + +def test_move_flows_moves_flows_only(monkeypatch): + # the cached network's pre-copy: a splitter splits its feed's new flows + # at its fixed fraction (and copies its state), a mixer's outlet takes + # the summed flows of its inlets without the mixer running (its PH + # flash) or any other flash, keeping its temperature and pressure, + # multiphase or not; a multiphase outlet keeps the phase fractions of + # each chemical it carries, and a chemical new to it enters in the + # phase in which it arrives + from hensmith._heat_exchanger_network import _move_flows + bst.settings.set_thermo(['Water', 'Ethanol', 'Methanol'], cache=True) + bst.main_flowsheet.set_flowsheet('move_flows') + feed = bst.Stream('mf_feed', Water=80., Ethanol=20., T=340., + units='kmol/hr') + splitter = bst.Splitter('MF_S', ins=feed, split=0.3) + splitter.simulate() + liquid = bst.Stream('mf_l', Water=50., Ethanol=10., T=345., + units='kmol/hr') + vapor = bst.Stream('mf_g', Ethanol=15., T=400., phase='g', + units='kmol/hr') + mixers = [bst.Mixer('MF_L', ins=(splitter.outs[0], splitter.outs[1])), + bst.Mixer('MF_V', ins=(liquid, vapor), rigorous=True)] + for m in mixers: m.simulate() + assert not isinstance(mixers[0].outs[0], bst.MultiStream) + two_phase = mixers[1].outs[0] + assert isinstance(two_phase, bst.MultiStream) + assert 0. < two_phase.vapor_fraction < 1. + def split(s): return np.array([s.imol['g'], s.imol['l']]) + # the PH flash's split on the Linux CI, a few ulps from the Windows one + # (the same totals): 1.1 x its phases does not sum to 1.1 x the inlets + # bit for bit, so a pre-copy that kept the split only when they did + # (and else flashed at the outlet's T and P) made the outlet all liquid + two_phase.imol['g'] = [6.289623892768382, 8.32251116802309, 0.] + two_phase.imol['l'] = [43.71037610723162, 16.677488831976913, 0.] + assert split(two_phase).sum(0).tolist() == [50., 25., 0.] + def failing(self, *args, **kwargs): + raise RuntimeError(f'{type(self).__name__} was run') + monkeypatch.setattr(bst.Mixer, '_run', failing) + monkeypatch.setattr(tmo.equilibrium.VLE, '__call__', failing) + # all flows scaled by a common factor (several, so that no factor's + # rounding can let a bitwise check pass by chance), then only the + # vapor's, then with methanol, which the outlet does not carry yet, in + # the liquid; each time with a new feed state, which the splitter's + # outlets take + cases = [((feed, liquid, vapor), f) + for f in (1.1, 0.9, 1.3, 0.7, 1.7, 0.6, 1.25, 0.85)] + cases += [((vapor,), 1.1), ((), 'methanol')] + for streams, factor in cases: + phase_flows = split(two_phase) + if factor == 'methanol': liquid.imol['Methanol'] = 5. + else: + for s in streams: s.F_mol *= factor + feed.T += 5. + feed.P *= 1.1 + states = [(m.outs[0].T, m.outs[0].P) for m in mixers] + for u in (splitter, *mixers): _move_flows(u) + assert_allclose(splitter.outs[0].mol, 0.3 * feed.mol, rtol=1e-15) + assert_allclose(splitter.outs[1].mol, 0.7 * feed.mol, rtol=1e-15) + assert all((s.T, s.P) == (feed.T, feed.P) for s in splitter.outs) + for m, state in zip(mixers, states): + s = m.outs[0] + assert (s.T, s.P) == state + assert_allclose(s.mol, sum([i.mol for i in m.ins]), rtol=1e-15) + if len(streams) == 3: + # every inlet scaled: so is each phase of the two-phase outlet + # (the PH flash's split, kept) + assert_allclose(split(two_phase), factor * phase_flows, + rtol=1e-14) + else: + # each chemical the outlet carries keeps its phase fractions; + # methanol, which it does not carry, enters in the phase in + # which it arrives (the liquid) + new = sum([i.mol for i in mixers[1].ins]).to_array() + old = phase_flows.sum(0) + carried = old > 0. + assert carried.tolist() == [True, True, False] + expected = np.zeros_like(phase_flows) + expected[:, carried] = (phase_flows[:, carried] + * (new[carried] / old[carried])) + expected[1, ~carried] = new[~carried] + assert_allclose(split(two_phase), expected, rtol=1e-14) + assert two_phase.imol['l', 'Methanol'] == 5. + +def test_move_flows_keeps_an_exchangers_phase_split(monkeypatch): + # an exchanger's multiphase outlet takes its inlet's flows by the same + # rule, without a flash: its phases scale with a scaled inlet, and a + # chemical new to it enters in the phase in which it arrives, here one + # that the outlet does not have yet + from hensmith._heat_exchanger_network import _move_flows + bst.settings.set_thermo(['Water', 'Ethanol', 'Methanol'], cache=True) + bst.main_flowsheet.set_flowsheet('move_flows_hx') + feed = bst.Stream('mfx_in', Water=50., Ethanol=25., T=340., + units='kmol/hr') + hx = bst.HXutility('MFX', ins=feed, V=0.4, rigorous=True) + hx.simulate() + out = hx.outs[0] + assert isinstance(out, bst.MultiStream) + assert 0. < out.vapor_fraction < 1. + def failing(self, *args, **kwargs): + raise RuntimeError(f'{type(self).__name__} was run') + monkeypatch.setattr(bst.HXutility, '_run', failing) + monkeypatch.setattr(tmo.equilibrium.VLE, '__call__', failing) + def split(s): + none = np.zeros(len(s.chemicals)) + return np.array([s.imol[p].to_array() if p in s.phases else none + for p in ('g', 'l', 's')]) + T, P = out.T, out.P + for factor in (1.1, 0.9, 1.3): + phase_flows = split(out) + feed.F_mol *= factor + _move_flows(hx) + assert (out.T, out.P) == (T, P) + assert_allclose(split(out), factor * phase_flows, rtol=1e-14) + # more water, and methanol as a solid + phase_flows = split(out) + feed.phases = ('l', 's') + feed.imol['l', 'Water'] += 10. + feed.imol['s', 'Methanol'] = 2. + _move_flows(hx) + assert (out.T, out.P) == (T, P) + assert set(out.phases) == {'g', 'l', 's'} + assert_allclose(out.mol, feed.mol, rtol=1e-15) + new = feed.mol.to_array() + old = phase_flows.sum(0) + expected = phase_flows.copy() + expected[:, :2] *= new[:2] / old[:2] + expected[2, 2] = 2. + assert_allclose(split(out), expected, rtol=1e-14) + +def test_split_avoid_recycle_facility(): + # r002's only unsplit MER network matches H1 twice with C3, so with + # avoid_recycle it is served best effort + # (test_avoid_recycle_never_repeats_a_pair); with stream splitting a + # split network reaches MER and still never repeats a pair + units = r002('r002_split_avoid') + sys, HXN, problems = split_facility(units, 10., 'sys_split_avoid', + avoid_recycle=True) + assert not problems, '\n'.join(problems) + pairs = [frozenset(_stream_ports(hx)) for hx in HXN.new_HXs] + assert len(pairs) == len(set(pairs)) + info = HXN.synthesis_info + assert info['status'] == 'mer' and info['splits'] + assert_allclose(actual_loads(HXN), [80. * kW, 450. * kW], rtol=1e-9) + assert_path_follows_streams(HXN) + assert_feasible(HXN, 10.) + +def test_repair_on_split_plan(monkeypatch): + # on 0.5 K chords, DRIFT_CASE's round-0 plan leaves one branch exchanger + # of its split side short of T_min_app on the exact states; with no + # refine round or retry, `_repair` shrinks it with its flow fraction and + # the facility realizes the repaired duty: the network is feasible and + # balanced, with the utilities of the repaired plan + curves = hxn_synthesis.stream_curves + monkeypatch.setattr(hxn_synthesis, 'stream_curves', + lambda *args, **kwargs: curves(*args, tol_T=0.5, + **kwargs)) + monkeypatch.setattr(hxn_synthesis, '_MAX_REFINE', 0) + monkeypatch.setattr(hxn_synthesis, '_MAX_SPLIT_RETRY', 0) + repair, realize = hxn_synthesis._repair, hxn_synthesis._realize + shrunk, realized = [], {} + def repairing(plan, duties, *args): + duties, changes = repair(plan, duties, *args) + shrunk.extend((plan, n, *change) for n, *change in changes) + return duties, changes + def realizing(plan, *args): + # the units by exchanger index of the plan's last realization + result = realized[id(plan)] = realize(plan, *args) + return result + monkeypatch.setattr(hxn_synthesis, '_repair', repairing) + monkeypatch.setattr(hxn_synthesis, '_realize', realizing) + units, dT = test_hxn_mer._build(DRIFT_CASE) + sys, HXN, problems = split_facility(units, dT, 'sys_split_repair') + info = HXN.synthesis_info + assert info['refine_rounds'] == 0 and info['splits'] + exchangers = [plan.exchangers[n] for plan, n, *_ in shrunk] + assert info['repaired'] == [ + dict(side=e.side, hot=e.hot, cold=e.cold, Q_plan=before, Q=after) + for e, (*_, before, after) in zip(exchangers, shrunk)] + assert any(e.hot_frac < 1. or e.cold_frac < 1. for e in exchangers) + assert all(0. < after < before for *_, before, after in shrunk) + total = total_duty(units) + # each branch exchanger that `_repair` shrank is realized, as a branch + # stage of its hot or cold stream in the network, with the repaired duty + for e, (plan, n, before, after) in zip(exchangers, shrunk): + if e.hot_frac == 1. and e.cold_frac == 1.: continue + hx = realized[id(plan)][0][n] + assert hx in HXN.new_HXs + assert abs(hx.Q - after) <= 1e-12 * total, hx.ID + assert any(stage.unit is hx and stage.branch is not None + for j in (e.hot, e.cold) + for stage in HXN.stream_life_cycles[j].life_cycle), hx.ID + assert info['status'] == 'best_effort' + assert_feasible(HXN, dT) + assert_allclose(actual_loads(HXN), [info['Q_hot_plan'], info['Q_cold_plan']], + rtol=0, atol=1e-12 * total) + assert not problems, '\n'.join(problems) + assert_path_follows_streams(HXN) + if __name__ == '__main__': pytest.main([__file__, '-q', '-p', 'no:cacheprovider']) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index c9d6cdc..ef008bb 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -26,10 +26,13 @@ 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 + splitting. Without stream splitting (the default) 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. + matches can approach MER arbitrarily closely. With + ``stream_splitting=True`` it must reach the targets, with every + material and energy balance closed and every exchanger feasible (see + "Networks that split streams"). Each set holds at least five problems with more than ten streams. Provenance @@ -110,6 +113,117 @@ (NO_SPLIT) or are never below them (SPLIT), and ``synthesis_info['status']`` says so. +Networks that split streams +--------------------------- +A network synthesized with stream splitting is checked on its actual +stream graph (object identity, each stream's sink and port there), and its +bookkeeping against that graph (``_split_network_problems``; every problem +is tagged with its check): + +G0 + Only ``HXprocess``, ``HXutility``, ``Splitter`` and ``Mixer`` units, the + same as the facility's lists of them, with unique IDs of the + synthesizer's forms (``HX_..``, ``Util_..``, ``Split_..``, ``Mix_..``). +G1 + One life cycle per process stream; each stream's entry port holds a + feed of the network, and the entries are exactly the network's feeds. +G2 + Walking each stream from its entry: an ``HXprocess`` passes it on at the + port it entered; the splitters of one split (a tree) send every branch + to one mixer whose inlets are exactly the branch ends, and the walk goes + on from the mixer; it ends at the stream's own utility. A mixer outside + its split, leaving the network, the utility inside a split and a cycle + are problems. +G3 + Every inlet of every unit is on exactly one walk; the walk is the life + cycle (its stages, its connections, its splits) and + ``stream_HXs_dict``; every stage's inlet carries its stage's fraction of + the flow. +G4 + Splitters balance mass; each branch is one fraction f of the split's + feed (phase by phase), at the feed's T and P, with f times its + enthalpy; f and the product of the splitters' ratios down to it equal + the split's planned fraction, which is at least 1e-3. +G5 + Mixers balance mass, energy and pressure, and their outlet is at + equilibrium at its enthalpy (the flash-free ``_Temperature``); an + isothermal re-join has every inlet at the outlet's T and specific + enthalpy, any other feeds the stream's utility. +G6 + Every stage moves the stream towards its outlet, and no branch passes + the outlet in specific enthalpy. +G7 + Every ``HXprocess``: mass and heat balances and the exact internal + approach on its streams' own (branch) material, as in + ``test_network_balanced_and_feasible``. +G8 + Each stream's exchangers change it by its duty, and its utility takes + the whole flow. +G9 + Each stream enters and leaves the network in its original states. +G10 + Energy balance error; heat - cool == net process duty. + +Without splitting, the network is held to ``_linear_network_problems`` +(the checks of ``test_network_balanced_and_feasible``); with it, to G0-G10, +and also to those if it has no splitter. +``test_split_checker_agrees_on_linear_networks`` + Both checks pass on every unsplit corpus network. +``test_split_checker_detects_mutations`` + On a split network wired by hand as the facility wires one + (``_wired_split_network``), and on a fresh facility network + (``_network(case, True, cached=False)``), each of fifteen defects (a + misnamed or unlisted unit, a moved entry, a mixer inlet taken from + another stream's trunk, swapped mixer inlets, a bypassed branch + exchanger, a wrong split ratio, a mis-scaled branch, a mixer outlet off + its inlets, a mixer outlet off equilibrium at its enthalpy (on rtB05, + real thermo), a branch past the stream's outlet, a branch exchanger + past its approach, a utility short of flow, a shifted outlet, + misreported heat) is caught by the check made for it, identified by + its message. +``test_no_split_plan_unchanged_by_splitting`` / ``test_split_cases_keep_unsplit_sides`` + At the planner (on the facility's round-0 inputs, ``_corpus_knots``), + splitting leaves an unsplit problem's plan, and a SPLIT problem's sides + that need no split, bit for bit as they are without it. +``test_split_network_balanced_and_feasible`` / ``test_split_network_reaches_mer`` + Every SPLIT problem, synthesized with ``stream_splitting=True``, passes + G0-G10 and reaches both hensmith's targets and the independent + reference within the NO_SPLIT tolerances (``MER_TOL``; see Tolerances + for the margins measured on these networks), with at most + ``_SPLIT_MIX_CAP`` mixers per curved stream and side (which bounds the + mixers' enthalpy residuals on real thermo; ``_split_mer_problems``). + Its synthesis report (``_split_report_problems``, which + ``test_hxn_regression`` applies too) shows status 'mer', no side left + to best effort, every exchanger at its planned duty (``deviations``), + nothing repaired (the refine rounds closed every exact-state violation + of a real-thermo plan), dropped or dropped by ``Qmin``, every mixer + outlet at its planned state (``split_deviations``), the minimum + approach kept, and every split side free of leaks, pre-leaks, cells + below ``Qmin`` and failed strategies. +``test_split_network_structure`` + Every realized split has two or more branches, each with a process + exchanger and one fraction, the fractions summing to 1; each life cycle + holds exactly its stream's splits in flow order, with every branch's + stages; a must re-joins isothermally. +``test_split_exact_dTmin_is_enthalpy_limited`` + A branch exchanger at exactly ``T_min_app`` stops at its enthalpy limit + (its guard ``dT`` sits 1e-6 K lower), at its planned duty (a + constant-CP and a real-thermo problem). +``test_no_split_network_with_splitting`` + A NO_SPLIT problem synthesized with ``stream_splitting=True`` is the + network synthesized without it, bit for bit (every real-thermo one and + two with constant CP, ``NO_SPLIT_WITH_SPLITTING``). +``test_backstop_alone_reaches_mer`` / ``..._in_the_facility`` + With the vertical core alone (no Stage S rule, strategy V only), every + split side of ``SPLIT``, and of the two constructed problems whose + roots pre-leak, is planned at MER, with cells that pass + test_hxn_planner's independent ``_verify_side_cells``; the facility's + backstop networks (``BACKSTOP_FACILITY``) pass the checks above. +``test_long_vertical_chain_is_exact`` + Without coarsening, the vertical core chains hundreds of elementary + blocks on a glide (rtB07 above), and every node still satisfies the + residual condition by direct evaluation, with exact cells and no leak. + Tolerances ---------- * Constant-CP targets: 1e-9 of the total stream duty (both sides are exact @@ -139,14 +253,18 @@ 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, +* Achieved utilities (NO_SPLIT, and SPLIT with ``stream_splitting=True``) + 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 NO_SPLIT + 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). + real-thermo network. The SPLIT networks synthesized with stream + splitting are held to the same tolerances; their largest measured + deviations are 5.3e-15 (fs_15sp_tkm) and 2.56e-12 (rtB05_above_2h1c), + 187 and 39 times below them. Without stream splitting, 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, @@ -158,6 +276,13 @@ 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. +* Split networks: flows and fractions within 1e-12 of the stream's flow + (a splitter scales its feed exactly; fractions multiply exactly up to + rounding); splitter outlets within 1e-9 K of their feed (a copy of its + state); mixer outlets within 1e-6 K of their equilibrium temperature and, + where the branches re-join isothermally, of every inlet (the T(H) + accuracy above, and the synthesizer's own mixer check is 1e-7 K); + enthalpies within 1e-6 of the stream's duty, as for exchangers. References ---------- @@ -168,17 +293,23 @@ Kemp, I. C. (2007). Pinch Analysis and Process Integration, 2nd ed. Butterworth-Heinemann. """ +import contextlib import math import re import time import warnings +from types import SimpleNamespace import numpy as np import pytest import biosteam as bst import thermosteam as tmo -from hensmith import HeatExchangerNetwork +from hensmith import HeatExchangerNetwork, _planner, _splitting, hxn_synthesis +from hensmith._heat_exchanger_network import _network_path +from hensmith._planner import plan_network from hensmith.hxn_synthesis import problem_table from hxn_mer_cases import NO_SPLIT, SPLIT, Q_UNITS, T_UNITS +from test_hxn_planner import (NEAR_DOUBLE_PINCH, NEAR_THRESHOLD, _verify_side_cells, + sides_from_knots, streams_from, verify_core) # --------------------------------------------------------------------------- # Tolerances (justified in the module docstring) @@ -196,6 +327,10 @@ 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 +FLOW_RTOL = 1e-12 # split networks: flows and fractions, x the stream's flow +SPLIT_T_TOL = 1e-9 # K: splitter outlets vs the splitter feed +MIX_T_TOL = 1e-6 # K: mixer outlet at equilibrium; isothermal re-joins +MIN_FRACTION = 1e-3 # smallest branch fraction on the corpus def _name(case): return case['name'] @@ -726,40 +861,116 @@ def _rt_split_proof(case, units): _NETWORKS = {} -def _network(case): - """Synthesize the case with the public facility, like a user would.""" +@contextlib.contextmanager +def _variant(variant): + """Patch the planner for one synthesis: None (the default) changes + nothing; 'V' leaves splitting with the backstop alone (no Stage S rule, + only the vertical core strategy).""" + if variant is None: + yield + return + if variant != 'V': raise ValueError(f'unknown variant {variant!r}') + with pytest.MonkeyPatch.context() as patch: + patch.setattr(_splitting, '_SPLIT_RULES', ()) + patch.setattr(_splitting, '_CORE_STRATEGIES', ('V',)) + yield + +def _network_record(units, HXN, T_min_app, stream_splitting, variant=None, + time_s=None): + """What the checks read of a simulated facility `HXN` on the process + exchangers `units` (as `_network` returns it).""" + hus = [hu for hx in HXN.new_HX_utils for hu in hx.heat_utilities] + total, net = _duties(units) + return 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), + stream_splitting=stream_splitting, variant=variant, + ) + +def _synthesize(case, stream_splitting, variant): + """Simulate the case's units with the facility (`_network`).""" + units, T_min_app = _build(case) + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app, + stream_splitting=stream_splitting) + sys = bst.System.from_units('sys', units=[*units, HXN]) + t0 = time.perf_counter() + with warnings.catch_warnings(), _variant(variant): + 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 _network_record(units, HXN, T_min_app, stream_splitting, variant, + time.perf_counter() - t0) + +def _network(case, stream_splitting=False, variant=None, cached=True): + """Synthesize the case with the public facility, like a user would, + with or without `stream_splitting` (the planner patched by `_variant` + during the synthesis only); cached per (name, stream_splitting, + variant), or a fresh network (never cached) unless `cached`.""" name = case['name'] - if name not in _NETWORKS: + if not cached: + return _synthesize(case, stream_splitting, variant) + key = (name, stream_splitting, variant) + if key 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), - ) + _NETWORKS[key] = _synthesize(case, stream_splitting, variant) except Exception as error: - _NETWORKS[name] = error - result = _NETWORKS[name] + _NETWORKS[key] = error + result = _NETWORKS[key] if isinstance(result, Exception): raise RuntimeError(f'{name}: synthesis failed: {result!r}') from result return result +def _facility_heat_utilities(units, T_min_app): + """The heat utilities of `units` in the order `HeatExchangerNetwork` + hands them to `synthesize_network`: those of its system's units (the + order of `System.from_units`, not that of `units`), by duty (a tie keeps + that order, e.g. smith2005_ex16_1's two 2880 kW coolers).""" + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app) + system = bst.System.from_units('sys', units=[*units, HXN]) + hus = bst.process_tools.heat_exchanger_utilities_from_units(system.units) + return sorted([hu for hu in hus if hu.duty], key=lambda hu: hu.duty) + +_KNOTS = {} + +def _corpus_knots(case): + """The planner's inputs for the case, as `synthesize_network` plans its + round 0 in the facility (`_facility_heat_utilities`, `_pinch_analysis`, + `_grid_knots`): dict(knots, is_hot, T_min_app, time), `time` being the + seconds taken; cached.""" + name = case['name'] + if name not in _KNOTS: + t0 = time.perf_counter() + units, T_min_app = _build(case) + hus = _facility_heat_utilities(units, T_min_app) + result = hxn_synthesis._pinch_analysis(hus, T_min_app) + hxs, hot_indices, curves, grid = result[5], result[6], result[13], result[14] + _KNOTS[name] = dict(knots=hxn_synthesis._grid_knots(curves, grid), + is_hot=[i in hot_indices for i in range(len(hxs))], + T_min_app=T_min_app, time=time.perf_counter() - t0) + return _KNOTS[name] + +def _case(name): + return next(case for case in NO_SPLIT + SPLIT if case['name'] == name) + def _network_problems(net): + """Everything wrong with a synthesized network: today's checks + (`_linear_network_problems`) without stream splitting; with it, the + strict split-aware checks (`_split_network_problems`), plus today's + checks when the network has no splitter.""" + if not net.get('stream_splitting', False): + return _linear_network_problems(net) + problems = _split_network_problems(net) + if not any(type(u) is bst.Splitter for u in net['HXN'].HXN_sys.units): + problems += _linear_network_problems(net) + return problems + +def _linear_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'] @@ -832,6 +1043,666 @@ def same_state(s, t, duty): problems.append(f'{hx.ID}: internal approach {approach:.7f} K < {T_min_app} K') return problems +# --------------------------------------------------------------------------- +# Strict checks of a network that may split streams (G0-G10) +# --------------------------------------------------------------------------- + +#: (unit type, ID pattern, the facility's list of them) for every unit a +#: synthesized network may hold +_NETWORK_UNITS = ( + (bst.HXprocess, re.compile(r'^HX_\d+_\d+_(hs|cs)(_\d+)?$'), 'new_HXs'), + (bst.HXutility, re.compile(r'^Util_\d+_(hs|cs)$'), 'new_HX_utils'), + (bst.Splitter, re.compile(r'^Split_\d+_(hs|cs)(_\d+)?(_b\d+)?$'), 'new_splitters'), + (bst.Mixer, re.compile(r'^Mix_\d+_(hs|cs)(_\d+)?$'), 'new_mixers'), +) +#: a splitter ID; group 1 names its split (the ID without the ``_b`` of +#: a chain element) +_SPLITTER_ID = re.compile(r'^(Split_\d+_(?:hs|cs)(?:_\d+)?)(?:_b\d+)?$') + +def _is_stream(s): + return isinstance(s, tmo.Stream) # not a MissingStream + +def _array(x): + """A (sparse) flow or split vector as a numpy array.""" + return x.to_array() if hasattr(x, 'to_array') else np.asarray(x, dtype=float) + +def _phase_flows(s): + """{phase: molar flows} of a stream, phase by phase for a MultiStream.""" + if isinstance(s, tmo.MultiStream): + return {phase: _array(s.imol[phase]) for phase in s.phases} + return {s.phase: _array(s.mol)} + +def _is_fraction_of(s, f, feed, atol): + """Whether every phase of stream `s` carries `f` times that of `feed`.""" + flows, feeds = _phase_flows(s), _phase_flows(feed) + zero = np.zeros_like(_array(feed.mol)) + return all(np.allclose(flows.get(p, zero), f * feeds.get(p, zero), + rtol=FLOW_RTOL, atol=atol) for p in {*flows, *feeds}) + +def _inlet_port(unit, stream): + """Index of `stream` (by identity) in ``unit.ins``, or None.""" + for port, s in enumerate(unit.ins): + if s is stream: return port + return None + +def _walk_stream(lc, network, claims, problem, name): + """ + Walk the stream of life cycle `lc` on the actual stream graph (object + identity, ``s.sink`` and the port index there) from its entry port + (G2). An HXprocess passes it on at the port it entered. A splitter tree + (the elements of one split, ``Split__[_]`` and its + ``_b`` chain, feeding each other) sends every leaf branch on to one + mixer, whose inlets must be exactly those branch ends, and the walk + goes on from the mixer's outlet (a split inside a branch likewise). The + walk must end at the stream's own utility, ``lc.life_cycle[-1].unit``, + outside every split. A mixer reached outside its split, a unit outside + the `network` (a set of units), the utility reached inside a split and + a cycle within the stream are problems, reported by calling + ``problem('G2', text)``. Every inlet port passed is counted in `claims` + (a dict keyed by (unit, port)). + + Returns + ------- + ports : list[tuple[HXprocess, int]] + The process exchanger ports passed, in flow order: trunk, then the + branches of a split, branch by branch. + edges : list[tuple] + ``(up, up_port, down, down_port)`` for every stream followed. + splits : list[tuple] + ``(splitters, leaves, mixer, branches)`` for every split passed: + its splitters in the order met, its leaves ``(splitter, port, + ratio)`` in branch order (`ratio`: the product of the splitters' + ratios down to that outlet, per chemical), its mixer and each + branch's process exchanger ports. + complete : bool + Whether the walk reached the utility. + """ + utility = lc.life_cycle[-1].unit + ports, edges, splits, seen = [], [], [], set() + + def enter(unit, port): + if (unit, port) in seen: + problem('G2', f'{name}: a cycle through {unit.ID} inlet {port}') + return False + seen.add((unit, port)) + claims[unit, port] = claims.get((unit, port), 0) + 1 + return True + + def follow(unit, port): + s = unit.outs[port] + sink = s.sink + index = None if sink is None else _inlet_port(sink, s) + if sink not in network or index is None: + problem('G2', f'{name}: leaves the network at {unit.ID} outlet {port}') + return None + edges.append((unit, port, sink, index)) + return sink, index + + def split(root, depth): + match = _SPLITTER_ID.match(root.ID) + splitters, leaves = [root], [] + def expand(splitter, ratio): + r = _array(splitter.split) + for port, share in ((0, r), (1, 1. - r)): + sink = splitter.outs[port].sink + other = (_SPLITTER_ID.match(sink.ID) if type(sink) is bst.Splitter + and sink in network else None) + if match and other and other.group(1) == match.group(1): + nxt = follow(splitter, port) + if nxt is None or not enter(*nxt): return False + splitters.append(sink) + if not expand(sink, ratio * share): return False + else: + leaves.append((splitter, port, ratio * share)) + return True + if not expand(root, 1.): return None + ends, branches = [], [] + for splitter, port, _ in leaves: + first = len(ports) + nxt = follow(splitter, port) + end = None if nxt is None else segment(*nxt, depth + 1) + if end is None: return None + ends.append(end) + branches.append(ports[first:]) + mixer = ends[0][0] + if (any(unit is not mixer for unit, _ in ends) + or sorted(port for _, port in ends) != list(range(len(mixer.ins)))): + problem('G2', f'{name}: the branches of {root.ID} end at ' + f'{[(unit.ID, port) for unit, port in ends]}, not at ' + f'every inlet of one mixer') + return None + splits.append((splitters, leaves, mixer, branches)) + return follow(mixer, 0) + + def segment(unit, port, depth): + # from inlet (unit, port) to the utility (depth 0) or to a mixer + # inlet (depth > 0), returned as (unit, port) + while True: + if not enter(unit, port): return None + kind = type(unit) + if kind is bst.HXprocess: + ports.append((unit, port)) + nxt = follow(unit, port) + elif kind is bst.Splitter: + nxt = split(unit, depth) + elif kind is bst.Mixer and depth: + return unit, port + elif kind is bst.HXutility and unit is utility and not depth: + return unit, port + else: + where = ('outside a split' if kind is bst.Mixer + else 'inside a split' if unit is utility + else 'not its utility') + problem('G2', f'{name}: reaches {unit.ID} inlet {port} ({where})') + return None + if nxt is None: return None + unit, port = nxt + + unit, port = lc.entry + complete = unit in network and segment(unit, port, 0) is not None + return ports, edges, splits, complete + +def _split_network_problems(net): + """ + Everything wrong with a synthesized network that may split streams: + the checks G0-G10 of the module docstring, made on the actual stream + graph (`_walk_stream`), against which the network's bookkeeping (the + facility's unit lists, the life cycles with their splits and + connections, ``stream_HXs_dict``) is checked. Every problem starts with + its check's tag ('G0 ...'). + """ + HXN, T_min_app = net['HXN'], net['T_min_app'] + system = HXN.HXN_sys + units = list(system.units) + network = set(units) + problems = [] + def problem(tag, text): problems.append(f'{tag} {text}') + def IDs(items): return [u.ID for u in items] + # G0 unit types, the facility's lists of them, IDs + kinds = [kind for kind, _, _ in _NETWORK_UNITS] + for u in units: + if type(u) not in kinds: problem('G0', f'{u.ID}: a {type(u).__name__}') + for kind, pattern, attr in _NETWORK_UNITS: + listed = list(getattr(HXN, attr)) + present = [u for u in units if type(u) is kind] + if not (len(set(listed)) == len(listed) and set(listed) == set(present)): + problem('G0', f'{attr} {IDs(listed)} != the {kind.__name__} units ' + f'{IDs(present)}') + for u in present: + if not pattern.match(u.ID): problem('G0', f'{u.ID}: not a {kind.__name__} ID') + if len(set(IDs(units))) != len(units): + problem('G0', f'duplicate IDs in {sorted(IDs(units))}') + 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'])): + problem('G1', 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 (_is_stream(s) and 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) + def specific(s): return s.H / s.F_mol if s.F_mol else math.nan + claims = {} # (unit, inlet port) -> number of stream walks through it + stream_duty = {} # (HXprocess, port) -> total duty of the stream it carries + entries = [] + for hx, lc in zip(originals, cycles): + inlet, outlet = hx.ins[0], hx.outs[0] + duty = abs(outlet.H - inlet.H) + sign = 1. if outlet.H >= inlet.H else -1. + F = inlet.F_mol + atol = FLOW_RTOL * F + name = f'stream {lc.index} ({hx.ID})' + stages = lc.life_cycle + utility = stages[-1].unit + for stage in stages: stream_duty[stage.unit, stage.index] = duty + # G1 the entry is a feed of the network + unit, port = lc.entry + entry = unit.ins[port] + entries.append(entry) + if unit not in network or entry.source in network: + problem('G1', f'{name}: its entry, {unit.ID} inlet {port}, is not a feed ' + f'of the network') + if type(utility) is not bst.HXutility: + problem('G2', f'{name}: its life cycle ends at {utility.ID}, not at a utility') + # G2 the series-parallel walk + ports, edges, walked, complete = _walk_stream(lc, network, claims, problem, name) + # G3 the bookkeeping is the walk + path = ports + [(utility, 0)] if complete else ports + planned = [(stage.unit, stage.index) for stage in stages] + if path != planned: + problem('G3', f'{name}: walked {[(u.ID, p) for u, p in path]} != its life ' + f'cycle {[(u.ID, p) for u, p in planned]}') + connections = list(lc.connections()) + if len(set(edges)) != len(edges) or set(edges) != set(connections): + extra = [(a.ID, i, b.ID, j) for a, i, b, j in set(edges) - set(connections)] + missing = [(a.ID, i, b.ID, j) for a, i, b, j in set(connections) - set(edges)] + problem('G3', f'{name}: walked {extra} beyond its life-cycle connections, ' + f'which also list {missing}') + for stage in stages: + s = stage.s_in + if not (_is_stream(s) and abs(stage.fraction * F - s.F_mol) <= FLOW_RTOL * F): + problem('G3', f'{name}: {stage.unit.ID} port {stage.index} is a stage with ' + f'fraction {stage.fraction:.15g} of the flow, its inlet ' + f'carries {s.F_mol / F if _is_stream(s) else 0.:.15g}') + HXs = HXN.stream_HXs_dict[lc.index] + if [id(u) for u in HXs] != [id(u) for u, _ in planned]: + problem('G3', f'{name}: stream_HXs_dict {IDs(HXs)} != its life cycle') + own = list(lc.splits) + for splitters, leaves, mixer, branches in walked: + root = splitters[0] + split = next((sp for sp in own if sp.splitters[0] is root), None) + if split is None: + problem('G3', f'{name}: the split at {root.ID} is not one of its splits') + else: + own.remove(split) + if not ([id(u) for u in split.splitters] == [id(u) for u in splitters] + and split.mixer is mixer + and [[id(u) for u in hxs] for hxs in split.branches] + == [[id(u) for u, _ in b] for b in branches]): + problem('G3', f'{name}: {split!r} is not the split walked from {root.ID}') + # G4 splitters: a splitter tree is one split + feed = root.ins[0] + for u in splitters: + total = _array(u.outs[0].mol) + _array(u.outs[1].mol) + if not np.allclose(total, _array(u.ins[0].mol), rtol=FLOW_RTOL, atol=atol): + problem('G4', f'{u.ID}: its outlets carry {total.sum():.15g} kmol/hr, ' + f'its feed {u.ins[0].F_mol:.15g}') + for b, (u, port, ratio) in enumerate(leaves): + s = u.outs[port] + f = s.F_mol / feed.F_mol if feed.F_mol else math.nan + label = f'{u.ID} outlet {port} (branch {b} of {name})' + # the minimum holds for the planned fraction, exactly; the + # flows give it to FLOW_RTOL (a splitter's last branch is + # its feed minus its first: round-off of the feed's flow) + planned = (split.fractions[b] if split is not None + and b < len(split.fractions) else f) + if not (MIN_FRACTION <= planned < 1. + and abs(f - planned) <= FLOW_RTOL): + problem('G4', f'{label}: fraction {f:.15g}, planned ' + f'{planned:.15g}') + if not _is_fraction_of(s, f, feed, atol): + problem('G4', f'{label}: its flows are not {f:.15g} x those of the feed') + if split is not None and not ( + b < len(split.fractions) + and np.all(np.abs(ratio - split.fractions[b]) <= FLOW_RTOL)): + problem('G4', f'{label}: the split ratios give {np.unique(ratio)}, ' + f'the split fractions are {split.fractions}') + if not (abs(s.T - feed.T) <= SPLIT_T_TOL and s.P == feed.P): + problem('G4', f'{label}: at {s.T:.12g} K, {s.P:.12g} Pa; the feed at ' + f'{feed.T:.12g} K, {feed.P:.12g} Pa') + if not abs(s.H - f * feed.H) <= DUTY_RTOL * duty: + problem('G4', f'{label}: H = {s.H:.10g} != {f:.15g} x the feed H ' + f'{feed.H:.10g} kJ/hr') + # G5 mixers + out = mixer.outs[0] + H_in = math.fsum([s.H for s in mixer.ins]) + mol_in = sum([_array(s.mol) for s in mixer.ins]) + if not np.allclose(_array(out.mol), mol_in, rtol=FLOW_RTOL, atol=atol): + problem('G5', f'{mixer.ID}: mass balance') + if not abs(out.H - H_in) <= DUTY_RTOL * duty: + problem('G5', f'{mixer.ID}: outlet H = {out.H:.10g} != that of its inlets, ' + f'{H_in:.10g} kJ/hr') + if any(s.P != out.P for s in mixer.ins): + problem('G5', f'{mixer.ID}: inlets at {[s.P for s in mixer.ins]} Pa, ' + f'outlet at {out.P} Pa') + T_H = _temperature(out)(out.H) if out.F_mol else math.nan + if not abs(out.T - T_H) <= MIX_T_TOL: + problem('G5', f'{mixer.ID}: outlet at {out.T:.9f} K, not at equilibrium at ' + f'its enthalpy ({T_H:.9f} K)') + if split is None: continue + if split.isothermal: + h = specific(out) + for b, s in enumerate(mixer.ins): + if not (abs(specific(s) - h) <= DUTY_RTOL * duty / F + and abs(s.T - out.T) <= MIX_T_TOL): + problem('G5', f'{mixer.ID} inlet {b}: at {s.T:.9f} K, ' + f'{specific(s):.10g} kJ/kmol; the isothermal ' + f're-join at {out.T:.9f} K, {h:.10g} kJ/kmol') + elif out.sink is not utility: + problem('G5', f'{mixer.ID}: a non-isothermal re-join feeding ' + f'{getattr(out.sink, "ID", None)}, not the utility') + if own: + problem('G3', f'{name}: splits never walked: {own}') + # G6 every stage moves the stream towards its outlet, and no branch + # passes the outlet (in specific enthalpy) + h_out = specific(outlet) + for u, p in ports + ([(utility, 0)] if complete else []): + s_in, s_out = u.ins[p], u.outs[p] + change = sign * (s_out.H - s_in.H) + if not change >= -DUTY_RTOL * duty: + problem('G6', f'{name}: {"cooled" if sign > 0 else "heated"} by ' + f'{-change:.10g} kJ/hr in {u.ID}') + if not sign * (h_out - specific(s_out)) >= -DUTY_RTOL * duty / F: + problem('G6', f'{name}: passes its outlet in {u.ID} ({specific(s_out):.10g} ' + f'kJ/kmol; outlet {h_out:.10g} kJ/kmol)') + # G8 the stream's exchangers change it by its duty, and its utility + # takes the whole flow + if complete: + change = math.fsum([u.outs[p].H - u.ins[p].H for u, p in ports] + + [utility.outs[0].H - utility.ins[0].H]) + if not abs(change - (outlet.H - inlet.H)) <= DUTY_RTOL * duty: + problem('G8', f'{name}: its exchangers change it by {change:.10g} kJ/hr, ' + f'not {outlet.H - inlet.H:.10g}') + if not np.allclose(_array(utility.ins[0].mol), _array(inlet.mol), + rtol=FLOW_RTOL, atol=atol): + problem('G8', f'{name}: {utility.ID} takes {utility.ins[0].F_mol:.15g} ' + f'kmol/hr of {F:.15g}') + # G9 end states + for label, s, original in (('inlet', entry, inlet), + ('outlet', utility.outs[0], outlet)): + if not same_state(s, original, duty): + problem('G9', f'{name}: network {label} != original (H={original.H:.10g} ' + f'kJ/hr, P={original.P:.8g} Pa)') + # G1 the entries are the network's feeds + feeds = [s for u in units for s in u.ins if s.source not in network] + entry_IDs = {id(s) for s in entries} + if not (len(entry_IDs) == len(entries) == len(feeds) + and entry_IDs == {id(s) for s in feeds} == {id(s) for s in system.feeds}): + problem('G1', f'entry streams {sorted(map(str, entries))} != the feeds of the ' + f'network {sorted(map(str, feeds))} (of HXN_sys: ' + f'{sorted(map(str, system.feeds))})') + # G3 every inlet of every unit is on exactly one stream walk + for u in units: + for port in range(len(u.ins)): + n = claims.get((u, port), 0) + if n != 1: problem('G3', f'{u.ID} inlet {port}: on {n} stream walks, expected 1') + # G7 every process exchanger: mass, heat and the exact internal approach + # on its streams' own material (C11-C13) + for hx in [u for u in units if type(u) is bst.HXprocess]: + if not all(map(_is_stream, [*hx.ins, *hx.outs])): + problem('G7', f'{hx.ID}: a port without a stream') + continue + 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): + problem('G7', 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: + problem('G7', f'{hx.ID}: heat released {dH[h]:.10g} != heat absorbed ' + f'{-dH[c]:.10g} kJ/hr') + if dH[h] <= 1e-12 * scale: continue # no heat transferred + if not (hx.ins[h].F_mol and hx.ins[c].F_mol): + problem('G7', f'{hx.ID}: an empty inlet') + continue + 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: + problem('G7', f'{hx.ID}: internal approach {approach:.7f} K < {T_min_app} K') + # G10 energy balance error; heat - cool == net duty (C1, C2) + if not abs(HXN.energy_balance_percent_error) < EB_TOL: + problem('G10', f'energy balance error {HXN.energy_balance_percent_error:.3g} %') + if not abs(net['heat'] - net['cool'] - net['net']) <= NET_RTOL * net['total']: + problem('G10', f"heat - cool = {net['heat'] - net['cool']:.10g} " + f"!= net duty {net['net']:.10g}") + return problems + +# --------------------------------------------------------------------------- +# A split network wired by hand, as the facility wires one +# --------------------------------------------------------------------------- + +def _converge(system): + """Converge a network's system and design and cost its units, as + ``HeatExchangerNetwork._cost`` does (without its fallback: a failure + raises).""" + system._setup() + system.converge() + for unit in system.units: unit._summary() + +_WIRED = {} + +def _wired_split_network(case, cached=False): + """ + The case's network with stream splitting, wired by hand the way + `HeatExchangerNetwork` wires its own (the stream-splitting design, + 5.2): `synthesize_network` with `stream_splitting`, one + `StreamLifeCycle` per stream given the splits, a copy of the stream's + real inlet in its entry port, every connection of every life cycle, + and one converged `System`. Returned as `_network` returns a network, + its 'HXN' a namespace with the facility's attributes; a fresh network + unless `cached`. + """ + name = case['name'] + if cached and name in _WIRED: return _WIRED[name] + units, T_min_app = _build(case) + hus = _facility_heat_utilities(units, T_min_app) + flowsheet = bst.Flowsheet('mer_wired_HXN') + for registry in flowsheet.registries: registry.clear() + info = {} + with flowsheet.temporary(), bst.IgnoreDockingWarnings(): + (hot_side, cold_side, utils, hxs, _, T_out, _, _, _, _, stream_HXs, _, + cold_indices) = hxn_synthesis.synthesize_network( + hus, T_min_app, info=info, stream_splitting=True) + new_HXs = hot_side + cold_side + cycles = [] + for i in range(len(hxs)): + lc = hxn_synthesis.StreamLifeCycle(i, i in cold_indices) + lc.get_life_cycle(new_HXs, utils, splits=info['splits']) + cycles.append(lc) + for i, lc in enumerate(cycles): + unit, port = lc.entry + inlet = unit.ins[port] + inlet.copy_like(hxs[i].ins[0]) + if isinstance(unit, (bst.HXprocess, bst.Splitter)): + hxn_synthesis._first_inlet(inlet, i in info['point_loads'], T_out[i], + i not in cold_indices) + for lc in cycles: + for up, up_port, down, down_port in lc.connections(): + down.ins[down_port] = up.outs[up_port] + splitters = [u for split in info['splits'] for u in split.splitters] + mixers = [split.mixer for split in info['splits']] + path, recycles = _network_path(new_HXs + utils + splitters + mixers, cycles) + system = bst.System('mer_wired', path, recycle=recycles or None) + system.set_tolerance(method='fixedpoint', subsystems=True, mol=1e-9, rmol=1e-12, + T=1e-8, rT=1e-12, maxiter=200) + _converge(system) + new_hus = [hu for hx in utils for hu in hx.heat_utilities] + def load(hus): return sum([abs(hu.duty * hu.agent.heat_transfer_efficiency) + for hu in bst.HeatUtility.sum_by_agent(hus)]) + Q_bal = (2. * sum([abs(hx.Q) for hx in new_HXs]) + load(new_hus)) / load(hus) + HXN = SimpleNamespace( + HXN_sys=system, new_HXs=new_HXs, new_HX_utils=utils, new_splitters=splitters, + new_mixers=mixers, original_heat_exchangers=hxs, stream_life_cycles=cycles, + stream_HXs_dict=stream_HXs, synthesis_info=info, + energy_balance_percent_error=100. * (Q_bal - 1.), + ) + total, net = _duties(units) + result = dict( + units=units, HXN=HXN, T_min_app=T_min_app, total=total, net=net, + heat=sum(hu.unit_duty for hu in new_hus if hu.unit_duty > 0), + cool=-sum(hu.unit_duty for hu in new_hus if hu.unit_duty < 0), + stream_splitting=True, variant=None, + ) + if cached: _WIRED[name] = result + return result + +# --------------------------------------------------------------------------- +# Defects of a split network, one per check (each returns the check's tag +# and a fragment of the message only that check gives) +# --------------------------------------------------------------------------- + +MUTATION_CASE = 'smith2005_ex18_2_split' +#: The case of a defect that `MUTATION_CASE` cannot show: its constant-CP +#: fluid is locked to liquid, so a mixer outlet off equilibrium needs real +#: thermodynamics (rtB05 splits a water stream). +MUTATION_CASES = {'mixer_outlet_off_equilibrium': 'rtB05_above_2h1c'} + +def _split_stage(net, b=0): + """The network's first split, the index of its stream's life cycle, and + the life-cycle stage of the first exchanger of its branch `b`.""" + HXN = net['HXN'] + split = HXN.synthesis_info['splits'][0] + n = next(n for n, lc in enumerate(HXN.stream_life_cycles) if lc.index == split.stream) + stage = next(s for s in HXN.stream_life_cycles[n].life_cycle + if s.unit is split.branches[b][0]) + return split, n, stage + +def _rewire(unit, port, stream): + with bst.IgnoreDockingWarnings(): unit.ins[port] = stream + +def _misnamed_mixer(net): + split, *_ = _split_stage(net) + split.mixer.ID = 'Mixer_' + split.mixer.ID + return 'G0', 'not a Mixer ID' + +def _unlisted_splitter(net): + net['HXN'].new_splitters.remove(_split_stage(net)[0].splitters[0]) + return 'G0', '!= the Splitter units' + +def _entry_moved(net): + # the stream's entry moved into its first branch + split, n, stage = _split_stage(net) + net['HXN'].stream_life_cycles[n].entry = hxn_synthesis._Port(stage.unit, stage.index) + return 'G1', 'is not a feed of the network' + +def _mixer_inlet_from_a_trunk(net): + # a mixer inlet re-pointed to another stream's trunk + split, *_ = _split_stage(net) + other = next(lc for lc in net['HXN'].stream_life_cycles + if lc.index != split.stream and not lc.splits and len(lc.life_cycle) > 1) + _rewire(split.mixer, 1, other.life_cycle[0].s_out) + return 'G2', '(outside a split)' + +def _mixer_inlets_swapped(net): + # the branches re-join at each other's mixer inlets + mixer = _split_stage(net)[0].mixer + with bst.IgnoreDockingWarnings(): mixer.ins[:] = [mixer.ins[1], mixer.ins[0], *mixer.ins[2:]] + return 'G3', 'beyond its life-cycle connections' + +def _branch_exchanger_bypassed(net): + # the exchanger's inlet stream wired to its outlet's sink + stage = _split_stage(net)[2] + s_out = stage.s_out + _rewire(s_out.sink, _inlet_port(s_out.sink, s_out), stage.s_in) + return 'G3', '!= its life cycle' + +def _wrong_split_ratio(net): + split, *_ = _split_stage(net) + split.splitters[0].split = 0.9 * split.fractions[0] + _converge(net['HXN'].HXN_sys) + return 'G4', 'the split ratios give' + +def _branch_inlet_scaled(net): + # a branch inlet carries the wrong fraction + _split_stage(net)[2].s_in.scale(1.1) + return 'G4', 'its outlets carry' + +def _mixer_outlet_shifted(net): + mixer = _split_stage(net)[0].mixer + mixer.outs[0].T += 0.01 + return 'G5', '!= that of its inlets' + +def _mixer_outlet_off_equilibrium(net): + # the outlet keeps its flows, pressure and enthalpy, so every balance + # holds, with 2 % of its main chemical vaporized: colder than its + # equilibrium state at that enthalpy, which the equilibrium check sees + out = _split_stage(net)[0].mixer.outs[0] + H, main = out.H, out.chemicals.IDs[int(np.argmax(out.mol))] + x = 0.02 * out.F_mol + out.phases = ('g', 'l') + out.imol['g', main] = x + out.imol['l', main] -= x + out.H = H + return 'G5', 'not at equilibrium' + +def _branch_passes_the_outlet(net): + split, n, stage = _split_stage(net) + outlet = net['HXN'].original_heat_exchangers[n].outs[0] + sign = 1. if net['HXN'].stream_life_cycles[n].cold else -1. + stage.s_out.T = outlet.T + sign + return 'G6', 'passes its outlet' + +def _branch_H_lim_shifted(net): + # a branch exchanger's enthalpy limits moved on by 5 % of its duty, + # past its approach (its dT guard lowered too: at T_min_app - 1e-6 K it + # would cap the duty at the terminals). Branch 1: on MUTATION_CASE its + # partner comes from outside the split stream's loop (branch 0's + # partner is heated by the split stream below the pinch, and the + # converged loop lowers its inlet, keeping the approach) + split, n, stage = _split_stage(net, 1) + hx = stage.unit + hx.dT = 0.5 * net['T_min_app'] + for k in (0, 1): + H_lim = getattr(hx, f'H_lim{k}') + if H_lim is not None: + setattr(hx, f'H_lim{k}', H_lim + 0.05 * (H_lim - hx.ins[k].H)) + _converge(net['HXN'].HXN_sys) + return 'G7', 'internal approach' + +def _utility_inlet_short(net): + split, n, stage = _split_stage(net) + net['HXN'].stream_life_cycles[n].life_cycle[-1].s_in.scale(1. - 1e-9) + return 'G8', ' kmol/hr of ' + +def _utility_outlet_shifted(net): + split, n, stage = _split_stage(net) + net['HXN'].stream_life_cycles[n].life_cycle[-1].s_out.T += 0.01 + return 'G9', 'network outlet != original' + +def _heat_misreported(net): + net['heat'] += 1e-6 * net['total'] + return 'G10', 'heat - cool = ' + +SPLIT_MUTATIONS = {f.__name__.strip('_'): f for f in ( + _misnamed_mixer, _unlisted_splitter, _entry_moved, _mixer_inlet_from_a_trunk, + _mixer_inlets_swapped, _branch_exchanger_bypassed, _wrong_split_ratio, + _branch_inlet_scaled, _mixer_outlet_shifted, _mixer_outlet_off_equilibrium, + _branch_passes_the_outlet, + _branch_H_lim_shifted, _utility_inlet_short, _utility_outlet_shifted, + _heat_misreported, +)} + +# --------------------------------------------------------------------------- +# Plans (bitwise comparison) +# --------------------------------------------------------------------------- + +def _same(a, b): + """Whether `a` and `b` are equal, floats bit for bit (nested dicts, + lists, tuples and arrays).""" + if isinstance(a, dict): + return (isinstance(b, dict) and a.keys() == b.keys() + and all(_same(a[k], b[k]) for k in a)) + if isinstance(a, (list, tuple)): + return (type(a) is type(b) and len(a) == len(b) + and all(map(_same, a, b))) + if isinstance(a, np.ndarray): + return (isinstance(b, np.ndarray) and a.dtype == b.dtype + and a.shape == b.shape and a.tobytes() == b.tobytes()) + if isinstance(a, (float, np.floating)): + return (isinstance(b, (float, np.floating)) + and float(a).hex() == float(b).hex()) + return type(a) is type(b) and a == b + +def _plan_exchangers(plan): + return [(e.side, e.hot, e.cold, e.Q, e.H_hot_in, e.H_hot_out, e.H_cold_in, + e.H_cold_out, e.pair_index, e.hot_seq, e.cold_seq, e.hot_frac, + e.cold_frac, e.hot_branch, e.cold_branch) for e in plan.exchangers] + +def _side_matches(plan, side): + """{stream: [(side, hot, cold, Q) of its exchangers on `side`, in flow + order]}.""" + return {j: [(e.side, e.hot, e.cold, e.Q) for e in (plan.exchangers[n] for n in stages) + if e.side == side] for j, stages in plan.stages.items()} + +def _plan_without_best_effort(knots, is_hot, T_min_app): + """`plan_network` without stream splitting, except that a side it + cannot serve at MER is left unplanned instead of planned for best + effort (`_planner._best_effort` is patched to return every must's + whole duty as a gap). Without `avoid_recycle`, `plan_network` plans + each side on its own (`_planner._plan_sides`), and only a side whose + status is then 'best_effort' reaches `_best_effort`, so every 'mer' + and 'trivial' side is exactly as in the real plan; the best-effort + searches of the SPLIT problems (94 s in all) are skipped.""" + def unplanned(side, proof, cap1, forbid, work_scale, work): + return _planner._SidePlan([], list(side.Qm), 'best_effort', 'unplanned', + work, proof) + with pytest.MonkeyPatch.context() as patch: + patch.setattr(_planner, '_best_effort', unplanned) + return plan_network(knots, is_hot, T_min_app) + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -951,3 +1822,365 @@ def test_split_network_never_beats_mer(case): 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' + +# --------------------------------------------------------------------------- +# Stream splitting: the strict checker and the planner at the corpus +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('case', NO_SPLIT + SPLIT, ids=_name) +def test_split_checker_agrees_on_linear_networks(case): + # on the unsplit networks, the strict split-aware checks (G0-G10) and + # today's checks agree: neither finds a problem + net = _network(case) + problems = _split_network_problems(net) + assert not problems, '\n'.join(problems) + problems = _linear_network_problems(net) + assert not problems, '\n'.join(problems) + +@pytest.mark.parametrize('source', ['wired', 'facility']) +@pytest.mark.parametrize('mutation', list(SPLIT_MUTATIONS)) +def test_split_checker_detects_mutations(mutation, source): + # a split network, wired by hand (as the facility wires it) or by the + # facility itself, passes the strict checks; each defect, applied to a + # fresh copy, is caught by the check made for it, known by its message + # (and possibly by others) + case = _case(MUTATION_CASES.get(mutation, MUTATION_CASE)) + if source == 'wired': + net = _wired_split_network(case, cached=True) + else: + net = _network(case, True) + problems = _network_problems(net) + assert not problems, '\n'.join(problems) + assert net['HXN'].synthesis_info['splits'] + if source == 'wired': + net = _wired_split_network(case) + else: + net = _network(case, True, cached=False) + tag, message = SPLIT_MUTATIONS[mutation](net) + problems = _network_problems(net) + assert any(p.startswith(tag + ' ') and message in p for p in problems), ( + tag, message, problems) + +@pytest.mark.parametrize('case', NO_SPLIT, ids=_name) +def test_no_split_plan_unchanged_by_splitting(case): + # a problem that needs no split plans bit for bit as without splitting + knots = _corpus_knots(case) + args = knots['knots'], knots['is_hot'], knots['T_min_app'] + off = plan_network(*args) + on = plan_network(*args, stream_splitting=True) + assert on.status == off.status == 'mer' + assert on.splits == [] + assert all(side['split'] is None for side in on.info['sides'].values()) + info = dict(on.info, sides={name: {k: v for k, v in side.items() if k != 'split'} + for name, side in on.info['sides'].items()}) + assert _same(info, off.info) + assert _same(_plan_exchangers(on), _plan_exchangers(off)) + for attr in ('Q_hot_target', 'Q_cold_target', 'pinch_T', 'cut', 'stages', + 'utility', 'Q_hot', 'Q_cold', 'penalty', 'paths'): + assert _same(getattr(on, attr), getattr(off, attr)), attr + +#: SPLIT problems with a side that an unsplit plan serves +UNSPLIT_SIDES = {'smith2005_ex18_2_split': 'below', 'rtB05_above_2h1c': 'below', + 'luo_20sp_ph10c10': 'above'} + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_cases_keep_unsplit_sides(case): + # the side of a SPLIT problem that an unsplit plan serves (or that is + # trivial) is planned exactly as without splitting: the same matches in + # the same order along every stream, and the same side info. Enthalpies + # are not compared: a stream crossing into the split side enters the + # unsplit side at another enthalpy once the split side reaches MER. + knots = _corpus_knots(case) + args = knots['knots'], knots['is_hot'], knots['T_min_app'] + on = plan_network(*args, stream_splitting=True) + assert on.status == 'mer' + off = _plan_without_best_effort(*args) + kept = [name for name, side in off.info['sides'].items() + if side['status'] in ('mer', 'trivial')] + assert all(side['method'] == 'unplanned' for name, side in off.info['sides'].items() + if name not in kept) + if case['name'] in UNSPLIT_SIDES: assert UNSPLIT_SIDES[case['name']] in kept + for name in kept: + side_on, side_off = on.info['sides'][name], off.info['sides'][name] + assert side_on['split'] is None, name + assert _same({k: side_on[k] for k in side_off}, side_off), name + assert _same(_side_matches(on, name), _side_matches(off, name)), name + +def test_network_variant_is_patched_for_the_synthesis_only(): + # the backstop variant ('V') patches the splitting portfolio inside its + # context only, so a cached variant network never leaks the patch + rules, strategies = _splitting._SPLIT_RULES, _splitting._CORE_STRATEGIES + with _variant('V'): + assert _splitting._SPLIT_RULES == () + assert _splitting._CORE_STRATEGIES == ('V',) + assert _splitting._SPLIT_RULES is rules + assert _splitting._CORE_STRATEGIES is strategies + with _variant(None): + assert _splitting._SPLIT_RULES is rules + with pytest.raises(ValueError): + with _variant('LV'): pass + +# --------------------------------------------------------------------------- +# Stream splitting: the facility at the corpus +# --------------------------------------------------------------------------- + +#: NO_SPLIT problems synthesized end to end with stream splitting: every +#: real-thermo one (only a synthesis refines their knots) and two with +#: constant CP (at the planner, test_no_split_plan_unchanged_by_splitting +#: covers them all) +NO_SPLIT_WITH_SPLITTING = (['4sp1_lee1970_dt10F', 'linnhoff_4stream'] + + [case['name'] for case in NO_SPLIT if not _is_cp(case)]) + +#: the backstop's facility networks: one constant-CP problem and two real- +#: thermo ones (rtB12: a superheated vapor at the pinch; rtB07: a glide +#: boiler above it) +BACKSTOP_FACILITY = ['smith2005_ex18_4_split', 'rtB12_above_2h1c_cond_boil_below', + 'rtB07_above_with_cold_glide'] + +#: problems (dT, rows) of test_hxn_planner whose roots the planner's own +#: cascade tolerances leave slightly negative, so that splitting starts +#: from the pre-leaked root: a threshold deficit of 23.7 tolQ, and +#: nptel_t5_3 with CP2 = 6 - 1.58e-11 (near-equal minima, 0.05 tolQ) +PRELEAK_PROBLEMS = {'near_threshold': NEAR_THRESHOLD, + 'near_double_pinch': NEAR_DOUBLE_PINCH} + +def _curved(case): + """{(stream, side): whether the planner's curve of the stream on that + side of the pinch has a kink (more than two knots), on the facility's + round-0 knots}.""" + knots = _corpus_knots(case) + sides = sides_from_knots(knots['knots'], knots['is_hot'], knots['T_min_app']) + return {(c.stream, name): c.n > 2 for name, side in sides.items() + for c in side.musts + side.flexes} + +def _split_mer_problems(case, net, candidate=None): + """Everything that keeps the split network `net` of `case` from being + the MER network the synthesizer promises (test_split_network_reaches_mer): + its synthesis report (`_split_report_problems`), its utilities vs + hensmith's targets and the reference, and its mixers per curved stream + and side; `candidate`, if given, is the name every split side must have + picked.""" + HXN = net['HXN'] + problems = _split_report_problems(HXN, net['T_min_app'], candidate) + atol = MER_TOL[case['kind']] * net['total'] + for label, got, target, ref, ref_tol in _utilities(case, net): + if not abs(got - target) <= atol: + problems.append(f'{label} {got!r} != target {target!r}') + if not abs(got - ref) <= atol + ref_tol: + problems.append(f'{label} {got!r} != reference {ref!r}') + curved = _curved(case) + mixers = {} + for split in HXN.synthesis_info['splits']: + key = split.stream, split.side + mixers[key] = mixers.get(key, 0) + 1 + for key, n in mixers.items(): + if curved[key] and n > _splitting._SPLIT_MIX_CAP: + problems.append(f'stream {key[0]} {key[1]}: {n} mixers on a curved stream') + return problems + +def _split_report_problems(HXN, T_min_app, candidate=None): + """Everything in the synthesis report of the facility `HXN`, synthesized + with stream splitting, that keeps its network from being the MER + network the synthesizer promises, on any problem (no reference needed; + also test_hxn_regression): a status other than 'mer', no realized + split, anything repaired, dropped (by ``Qmin`` or otherwise) or off its + planned state (exchangers, mixers), the minimum approach lost, a side + left to best effort, and a split side with no candidate or with leaks, + pre-leaks, cells below ``Qmin`` or failed strategies; `candidate`, if + given, is the name every split side must have picked.""" + info = HXN.synthesis_info + problems = [] + if info['status'] != 'mer': problems.append(f"status {info['status']!r}") + if not (HXN.new_splitters and HXN.new_mixers and info['splits']): + problems.append('no split') + for key in ('repaired', 'dropped', 'qmin_dropped', 'split_deviations', 'deviations'): + if info[key] != []: problems.append(f'{key}: {info[key]}') + if not info['min_approach'] >= T_min_app - APPROACH_TOL: + problems.append(f"min_approach {info['min_approach']!r} K") + split_sides = 0 + for name, side in info['sides'].items(): + if side['status'] == 'best_effort': problems.append(f'{name}: best effort') + split = side['split'] + if split is None: continue + split_sides += 1 + if split['candidate'] is None or side['method'] != f"split-{split['candidate']}": + problems.append(f"{name}: method {side['method']!r}, candidate " + f"{split['candidate']!r}") + if candidate is not None and split['candidate'] != candidate: + problems.append(f"{name}: picked {split['candidate']!r}") + for key, empty in (('leak', 0.), ('preleak', 0.), ('small', []), ('errors', [])): + if split[key] != empty: problems.append(f'{name}: {key} {split[key]!r}') + if not split_sides: problems.append('no side split') + return problems + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_network_balanced_and_feasible(case): + # the strict split-aware checks (G0-G10) on the network synthesized + # with stream splitting + problems = _network_problems(_network(case, True)) + assert not problems, '\n'.join(problems) + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_network_reaches_mer(case): + # with stream splitting, a problem whose MER needs splits reaches both + # hensmith's targets and the independent reference, with every + # exchanger at its planned duty (no deviation), nothing repaired or + # dropped, and splits free of leaks and small cells + problems = _split_mer_problems(case, _network(case, True)) + assert not problems, '\n'.join(problems) + +@pytest.mark.parametrize('case', SPLIT, ids=_name) +def test_split_network_structure(case): + # every realized split: at least two branches with one fraction each, + # summing to 1, each with a process exchanger; the life cycles hold + # exactly the stream's splits, in flow order, with the branches' stages; + # a must (a hot stream above the pinch, a cold one below) re-joins at + # one temperature + HXN = _network(case, True)['HXN'] + splits = HXN.synthesis_info['splits'] + assert splits + cycles = {lc.index: lc for lc in HXN.stream_life_cycles} + new_HXs = {id(hx) for hx in HXN.new_HXs} + for split in splits: + assert len(split.fractions) == len(split.branches) >= 2 + assert abs(math.fsum(split.fractions) - 1.) <= 1e-12 + assert all(split.branches), split + assert all(type(hx) is bst.HXprocess and id(hx) in new_HXs + for branch in split.branches for hx in branch), split + assert (any(split.mixer is u for u in HXN.new_mixers) + and all(any(s is u for u in HXN.new_splitters) for s in split.splitters)) + must = (split.side == 'above') != cycles[split.stream].cold + if must: assert split.isothermal, split + for lc in HXN.stream_life_cycles: + own = [split for split in splits if split.stream == lc.index] + first = 'below' if lc.cold else 'above' + own.sort(key=lambda split: (split.side != first, split.index)) + assert [id(split) for split in lc.splits] == [id(split) for split in own] + for k, split in enumerate(lc.splits): + for b, branch in enumerate(split.branches): + stages = [stage for stage in lc.life_cycle if stage.branch == (k, b)] + assert [id(stage.unit) for stage in stages] == [id(hx) for hx in branch] + assert all(stage.fraction == split.fractions[b] for stage in stages) + assert ([id(u) for u in HXN.stream_HXs_dict[lc.index]] + == [id(stage.unit) for stage in lc.life_cycle]) + +def _planned_duties(case): + """{exchanger ID: planned duty [kJ/hr]} of the facility's round-0 plan + of `case` with stream splitting (its IDs as `hxn_synthesis._realize` + names the exchangers).""" + knots = _corpus_knots(case) + plan = plan_network(knots['knots'], knots['is_hot'], knots['T_min_app'], + Qmin=1e-3, stream_splitting=True) + duties = {} + for e in plan.exchangers: + suffix = '' if e.pair_index == 1 else f'_{e.pair_index}' + ID = (f'HX_{e.cold}_{e.hot}_hs{suffix}' if e.side == 'above' + else f'HX_{e.hot}_{e.cold}_cs{suffix}') + duties[ID] = e.Q + return duties + +@pytest.mark.parametrize('case', [_case('smith2005_ex18_4_split'), + _case('rtB12_above_2h1c_cond_boil_below')], ids=_name) +def test_split_exact_dTmin_is_enthalpy_limited(case): + # a branch exchanger at exactly the minimum approach (at the pinch) runs + # with its guard dT = T_min_app - 1e-6 K, so its duty ends where an + # enthalpy limit (f times the parent's) binds: the planned duty + net = _network(case, True) + HXN, T_min_app = net['HXN'], net['T_min_app'] + info = HXN.synthesis_info + assert info['refine_rounds'] == 0 # round 0 is the realized plan + planned = _planned_duties(case) + duty = {lc.index: abs(hx.outs[0].H - hx.ins[0].H) + for hx, lc in zip(HXN.original_heat_exchangers, HXN.stream_life_cycles)} + at_pinch = 0 + for split in info['splits']: + for hx in (hx for branch in split.branches for hx in branch): + assert hx.dT == T_min_app - 1e-6 + streams = [int(n) for n in hx.ID.split('_')[1:3]] # at ports 0, 1 + limited = [k for k, H_lim in enumerate((hx.H_lim0, hx.H_lim1)) + if H_lim is not None + and abs(hx.outs[k].H - H_lim) <= DUTY_RTOL * duty[streams[k]]] + assert limited, hx.ID + scale = duty[streams[0]] + duty[streams[1]] + assert abs(hx.Q - planned[hx.ID]) <= hxn_synthesis._DUTY_TOL * scale, hx.ID + h = 0 if hx.ins[0].T > hx.ins[1].T else 1 + c = 1 - h + terminal = min(hx.ins[h].T - hx.outs[c].T, hx.outs[h].T - hx.ins[c].T) + at_pinch += abs(terminal - T_min_app) <= 1e-9 + assert at_pinch + +@pytest.mark.parametrize('name', NO_SPLIT_WITH_SPLITTING) +def test_no_split_network_with_splitting(name): + # a problem that needs no split gives, end to end with stream splitting, + # exactly the network it gives without (bit for bit) + case = _case(name) + on, off = _network(case, True), _network(case) + info_on, info_off = on['HXN'].synthesis_info, off['HXN'].synthesis_info + assert info_on['status'] == info_off['status'] == 'mer' + assert info_on['splits'] == on['HXN'].new_splitters == on['HXN'].new_mixers == [] + assert not any(type(u) in (bst.Splitter, bst.Mixer) for u in on['HXN'].HXN_sys.units) + assert ([(hx.ID, float(hx.Q).hex()) for hx in on['HXN'].new_HXs] + == [(hx.ID, float(hx.Q).hex()) for hx in off['HXN'].new_HXs]) + for key in ('refine_rounds', 'repaired'): + assert info_on[key] == info_off[key], key + problems = _network_problems(on) + assert not problems, '\n'.join(problems) + +@pytest.mark.parametrize('name', [case['name'] for case in SPLIT] + + list(PRELEAK_PROBLEMS)) +def test_backstop_alone_reaches_mer(name, monkeypatch): + # the vertical core alone (no Stage S rule, strategy V only) plans every + # split side at MER from the pre-leaked root, with cells that pass the + # independent check (test_hxn_planner); only the constructed problems + # pre-leak + planned = {} + plan_side = _planner._plan_side + def spy(side, *args, **kwargs): + result = plan_side(side, *args, **kwargs) + planned[side.name] = side, result + return result + monkeypatch.setattr(_planner, '_plan_side', spy) + k = None if name in PRELEAK_PROBLEMS else _corpus_knots(_case(name)) + with _variant('V'): + if k is None: + dT, rows = PRELEAK_PROBLEMS[name] + plan = _planner._plan_numeric(streams_from(rows), dT, + stream_splitting=True)['plan'] + else: + plan = plan_network(k['knots'], k['is_hot'], k['T_min_app'], + stream_splitting=True) + assert plan.status == 'mer' + splits = {n: side['split'] for n, side in plan.info['sides'].items() + if side['split'] is not None} + assert splits and all(split['candidate'] == 'V' for split in splits.values()) + assert all(split['leak'] == 0. for split in splits.values()) + preleak = math.fsum(split['preleak'] for split in splits.values()) + assert (preleak > 0.) == (name in PRELEAK_PROBLEMS) + assert plan.penalty <= preleak + 1e-12 * plan.info['scale'] + for n in splits: + side, result = planned[n] + assert result.status == 'mer' + _verify_side_cells(side, result.cells, _splitting._preleak_root(side)[1]) + +@pytest.mark.parametrize('case', [_case(name) for name in BACKSTOP_FACILITY], ids=_name) +def test_backstop_alone_reaches_mer_in_the_facility(case): + # the facility with the vertical core alone: MER, and a strictly + # balanced and feasible network + net = _network(case, True, variant='V') + problems = _network_problems(net) + _split_mer_problems(case, net, candidate='V') + assert not problems, '\n'.join(problems) + +def test_long_vertical_chain_is_exact(monkeypatch): + # the vertical core with elementary blocks only (no coarsening) on + # rtB07 above (a glide boiler: hundreds of knots): a chain of hundreds + # of closed-form nodes, each satisfying (R) by direct evaluation, with + # no leak and exact cells (round-off does not accumulate along it) + k = _corpus_knots(_case('rtB07_above_with_cold_glide')) + side = sides_from_knots(k['knots'], k['is_hot'], k['T_min_app'])['above'] + a0 = _splitting._preleak_root(side)[1] + assert a0 == [0.] * side.M + coarse = _splitting._drive(side, a0, 'V') + monkeypatch.setattr(_splitting, '_SPLIT_COARSEN', False) + chain = _splitting._drive(side, a0, 'V') + assert len(chain.blocks) >= 200 and len(coarse.blocks) <= 10 # 276 and 3 + verify_core(side, chain, a0) diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 1b997d9..e1d4dc9 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -16,12 +16,15 @@ """ import math import random +from collections import Counter import numpy as np import pytest from numpy.testing import assert_allclose from hensmith import _planner as P +from hensmith import _splitting as SP +from hxn_mer_cases import SPLIT as CORPUS_SPLIT, Q_UNITS, T_UNITS # %% Independent helpers @@ -947,3 +950,2503 @@ def test_qmin_drops_small_exchangers(Qmin): sum(m['Q'] for m in full['matches'])) if dropped: assert net['status'] == 'best_effort' + + +# %% 15. Stream-splitting primitives (hensmith._splitting) + +MERGED_BREAKPOINT = [ # flex B starts 4e-7 K above the pinch: 1.2e-6 of A's + # heat (< tolQ = 2e-6) but far more than tolP = 2.9e-9 K of level + ('H1', 'h', 300, 50, 1.), ('H2', 'h', 300, 50, 1.), + ('A', 'c', 100, 250, 3.), ('B', 'c', 100 + 4e-7, 200, 1.), + ('H3', 'h', 70, 60, 1e4), ('C3', 'c', 0, 10, 1e4)] + + +def sides_from(rows, dT): + """The planner's sides of a constant-CP problem, built as in + `plan_network`.""" + streams = streams_from(rows) + return sides_from_knots( + [([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) + + +def sides_from_knots(knots, is_hot, dT): + """The planner's sides of a problem given by stream knots, built as in + `plan_network`.""" + curves = P._stream_curves(knots, is_hot, dT) + act = [c for c in curves if c is not None] + scale = sum(c.duty for c in act) + span = max(c.T[-1] for c in act) - min(c.T[0] for c in act) + return P._sides(curves, P._cascade(curves, scale), + P._REL_Q * max(scale, 1.), P._REL_T * max(span, 1.)) + + +def split_sides(): + """The sides (with musts and flexes) of the SPLIT cases and of the + merged-breakpoint case.""" + out = [] + for dT, rows in [*SPLIT.values(), (10., MERGED_BREAKPOINT)]: + out += [s for s in sides_from(rows, dT).values() if s.M and s.F] + return out + + +def random_side(rng, tol=1e-9): + M, F = rng.randint(1, 3), rng.randint(1, 3) + return P._Side('above', [random_level_curve(rng) for _ in range(M)], + [random_level_curve(rng) for _ in range(F)], tol, tol) + + +def random_fractions(rng): + """One to three branch fractions (each > 0.05) summing to 1.""" + w = [rng.uniform(1., 5.) for _ in range(rng.randint(1, 3))] + f = [x / sum(w) for x in w[:-1]] + return f + [1. - sum(f)] + + +def is_forest(cells): + """True if the bipartite graph of the (must, flex) cells has no + cycle.""" + parent = {} + + def find(u): + while parent.get(u, u) != u: + u = parent[u] + return u + for i, j in cells: + ru, rv = find(('m', i)), find(('f', j)) + if ru == rv: + return False + parent[ru] = rv + return True + + +def test_branch_curve_is_the_scaled_parent(): + rng = random.Random(51) + curves = [random_level_curve(rng) for _ in range(30)] + curves += [random_level_curve(rng, grid=True) for _ in range(10)] + for side in split_sides(): + curves += side.musts + side.flexes + for c in curves: + for f in (1., .5, 1e-3, rng.uniform(1e-3, 1.)): + for role in ('must', 'flex'): + bc = SP._branch_curve(c, f, role) + assert bc.y == c.y and bc.flats == c.flats # levels + assert bc.q == [f * q for q in c.q] and bc.Q == f * c.Q + assert (bc.stream, bc.H0, bc.sgn) == (c.stream, c.H0, c.sgn) + for k in range(c.n - 1): # slopes / f + s = (c.y[k + 1] - c.y[k]) / (c.q[k + 1] - c.q[k]) + assert bc.slope_right(bc.q[k]) == pytest.approx( + s / f, rel=1e-12) + # at branch heat tau the branch is the parent at tau / f + xs = np.linspace(0., bc.Q, 41) + assert_allclose(bc.at_many(xs), c.at_many(xs / f), rtol=0., + atol=1e-12 * (1. + abs(c.y[-1]))) + + +def test_splitting_preserves_the_residual_cascade(): + # Lemma R: branches with fractions summing to 1 leave every composite, + # and so the slack of (R), unchanged + rng = random.Random(52) + for side in split_sides() + [random_side(rng) for _ in range(40)]: + scale = max(side.duty, 1.) + for trial in range(4): + if trial: + a = [rng.uniform(0., .6 * q) for q in side.Qm] + b = [rng.uniform(0., .6 * q) for q in side.Qf] + else: + a, b = [0.] * side.M, [0.] * side.F + musts, am, flexes, bf = [], [], [], [] + for c, x in zip(side.musts, a): + for f in random_fractions(rng): + musts.append(SP._branch_curve(c, f, 'must')) + am.append(f * x) + for c, x in zip(side.flexes, b): + for g in random_fractions(rng): + flexes.append(SP._branch_curve(c, g, 'flex')) + bf.append(g * x) + bside = P._Side(side.name, musts, flexes, side.tolQ, side.tolP) + d, bd = side.analyse(a, b), bside.analyse(am, bf) + assert bd.slack == pytest.approx(d.slack, abs=1e-12 * scale) + if not trial: # the root: the same levels, the same composites + np.testing.assert_array_equal(bd.levels, d.levels) + for k in ('Si', 'Se', 'Di', 'De'): + assert_allclose(getattr(bd, k), getattr(d, k), rtol=0., + atol=1e-12 * scale) + + +def test_cell_margin_matches_scaled_max_duty(): + # Lemma 1: the margin at the knots of both branch curves decides (C) + # exactly as _max_duty does on the branch-scaled curves + rng = random.Random(53) + tol = 1e-9 + count = [0, 0] + for _ in range(500): + cm, cf = random_level_curve(rng), random_level_curve(rng) + side = P._Side('above', [cm], [cf], tol, tol) + f = rng.choice((1., rng.uniform(.05, 1.))) + g = rng.choice((1., rng.uniform(.05, 1.))) + a = rng.choice((0., rng.choice(cm.q[:-1]), rng.uniform(0., cm.Q))) + b = rng.choice((0., rng.choice(cf.q[:-1]), rng.uniform(0., cf.Q))) + xmax = min(f * (cm.Q - a), g * (cf.Q - b)) + x = rng.choice((xmax, rng.uniform(0., xmax))) + margin, touch = SP._cell_margin(side, SP._Cell(0, 0, x, a, b, f, g)) + bm, bf = SP._branch_curve(cm, f, 'must'), SP._branch_curve(cf, g, + 'flex') + xd = P._max_duty(bm, f * a, bf, g * b, x, tol) + assert (margin >= -tol) == (xd >= x - tol) + ts = np.concatenate(([0., x], bm.qa - f * a, bf.qa - g * b)) + ts = ts[(ts >= 0.) & (ts <= x)] + direct = (bm.at_many(f * a + ts) - bf.at_many(g * b + ts)).min() + assert margin == pytest.approx(direct, abs=1e-9) + count[margin >= -tol] += 1 + assert min(count) >= 100 + + +def test_cell_margin_touch(): + cm = P._LevelCurve([0., 20.], [0., 10.]) + side = P._Side('above', [cm], [P._LevelCurve([0., 20.], [0., 10.])], + 1e-9, 1e-9) + # equal branch fractions: parallel at zero approach along the cell + assert SP._cell_margin(side, SP._Cell(0, 0, 10., 0., 0., .5, .5)) == ( + 0., True) + # the flex trunk rises half as fast: zero approach at the pinch only + assert SP._cell_margin(side, SP._Cell(0, 0, 10., 0., 0., .5, 1.)) == ( + 0., False) + # the flex branch overtakes the must trunk + assert SP._cell_margin(side, SP._Cell(0, 0, 10., 0., 0., 1., .5))[0] == ( + pytest.approx(-5.)) + # beyond the end of the flex: infeasible whatever the levels + assert SP._cell_margin(side, SP._Cell(0, 0, 11., 0., 0., 1., .5)) == ( + -math.inf, False) + # a must flat on a flex flat at the same level + side = P._Side('above', [P._LevelCurve([0., 10., 20.], [4., 4., 8.])], + [P._LevelCurve([0., 10., 20.], [0., 4., 4.])], 1e-9, 1e-9) + assert SP._cell_margin(side, SP._Cell(0, 0, 10., 0., 10.)) == (0., True) + assert SP._cell_margin(side, SP._Cell(0, 0, 10., 0., 0.)) == (0., False) + + +def test_vertical_coupling_positions(): + rng = random.Random(54) + nodes = [(s, [0.] * s.M, [0.] * s.F) for s in split_sides()] + for _ in range(400): + side = random_side(rng) + a = [rng.choice((0., rng.uniform(0., .6 * q))) for q in side.Qm] + b = [rng.choice((0., rng.uniform(0., .6 * q))) for q in side.Qf] + if residual_slack(side, a, b) >= 0.: + nodes.append((side, a, b)) + assert len(nodes) >= 60 + for side, a, b in nodes: + d = side.analyse(a, b) + cp = SP._Coupling(side, a, b, d) + X = math.fsum(q - x for q, x in zip(side.Qm, a)) + scale = max(side.duty, 1.) + t = cp.t + assert cp.X == X and cp.ulp == SP._SPLIT_ULP * max(X, 1.) + assert t[0] == 0. and t[-1] == X and cp.K == t.size - 1 >= 1 + assert (np.diff(t) > cp.ulp).all() + # no breakpoint is merged beyond round-off (and none folded here) + raw = np.clip(np.concatenate((d.De, d.Di, d.Se, d.Si)), 0., X) + assert cp.folded == 0 + assert np.abs(raw[:, None] - t[None, :]).min(1).max() <= cp.ulp + # positions: monotone, from the node, exact at X, heat-consistent + Pm, Pf = cp.Pm, cp.Pf + assert Pm.shape == (t.size, side.M) and Pf.shape == (t.size, side.F) + assert (np.diff(Pm, axis=0) >= 0.).all() + assert (np.diff(Pf, axis=0) >= 0.).all() + assert Pm[0].tolist() == a and Pf[0].tolist() == b + assert Pm[-1].tolist() == side.Qm + assert (Pf <= np.array(side.Qf)).all() + assert_allclose((Pm - a).sum(1), t, rtol=0., atol=1e-12 * scale) + assert_allclose((Pf - b).sum(1), t, rtol=0., atol=1e-12 * scale) + # must_at and flex_at: the same closed form anywhere in [0, X] + for k in range(t.size): + assert_allclose(cp.must_at(t[k]), Pm[k], rtol=0., atol=cp.ulp) + assert_allclose(cp.flex_at(t[k]), Pf[k], rtol=0., atol=cp.ulp) + assert cp.must_at(X) == side.Qm and cp.must_at(0.) == a + for tm in [rng.uniform(0., X) for _ in range(5)]: + assert math.fsum(cp.must_at(tm)) - math.fsum(a) == pytest.approx( + tm, abs=1e-12 * scale) + # merged breakpoint: B's supply is 1.2e-6 of A's heat (< tolQ) above + # the pinch; that breakpoint must stay (tolQ merging hides B's knot) + side = sides_from(MERGED_BREAKPOINT, 10.)['above'] + streams = [c.stream for c in side.flexes] + jA, jB = streams.index(2), streams.index(3) + cp = SP._Coupling(side, [0.] * side.M, [0.] * side.F) + tB = 3. * ((100 + 4e-7) - 100) + assert cp.ulp < tB < side.tolQ + k = int(np.abs(cp.t - tB).argmin()) + assert cp.t[k] == pytest.approx(tB, rel=1e-6) + assert cp.Pf[k, jB] == 0. and cp.Pf[k, jA] == pytest.approx(tB, rel=1e-6) + + +def test_transport_forest(): + h, g = {0: 1., 1: 1.}, {0: 1., 1: 1.} + full = {(i, j) for i in h for j in g} + # north-west, with continuations first; forbidden pairs never used + assert SP._transport(h, g, full) == {(0, 0): 1., (1, 1): 1.} + assert SP._transport(h, g, full, [(0, 1)]) == {(0, 1): 1., (1, 0): 1.} + assert SP._transport(h, g, full, [(0, 0)], {(0, 0)}) == { + (0, 1): 1., (1, 0): 1.} + # the greedy is stuck, Edmonds-Karp is not + assert SP._transport(h, g, {(0, 0), (0, 1), (1, 0)}) == { + (0, 1): 1., (1, 0): 1.} + # no transport exists + assert SP._transport({0: 2., 1: 1.}, {0: 1., 1: 2.}, + {(0, 0), (1, 1)}) is None + assert SP._transport(h, g, {(0, 0), (1, 0)}) is None + # a row of round-off heat on an exhausted column still gets its cell + assert SP._transport({0: 1., 1: 1e-12}, {0: 1.}, {(0, 0), (1, 0)}, + tol=1e-9) == {(0, 0): 1., (1, 0): 1e-12} + rng = random.Random(55) + eps = np.finfo(float).eps + for _ in range(300): + m, n = rng.randint(1, 5), rng.randint(1, 5) + comp = {(i, j) for i in range(m) for j in range(n) + if rng.random() < .6} + q0 = {c: rng.uniform(.1, 10.) for c in sorted(comp) + if rng.random() < .7} + h = {i: math.fsum(x for (k, _), x in q0.items() if k == i) + for i in range(m)} + g = {j: (math.fsum(x for (_, k), x in q0.items() if k == j) + + rng.choice((0., rng.uniform(0., 5.)))) * (1. - 1e-15) + for j in range(n)} # flex surplus, and round-off imbalance + forbid = {c for c in comp if c not in q0 and rng.random() < .5} + prefer = rng.sample(sorted(comp), min(len(comp), 2)) + q = SP._transport(h, g, comp, prefer, forbid, tol=1e-9) + assert q is not None and is_forest(q) + assert all(x > 0. and c in comp and c not in forbid + for c, x in q.items()) + for i, v in h.items(): # exact row sums + assert abs(sum(x for (k, _), x in q.items() if k == i) + - v) <= 4 * eps * v + for j, v in g.items(): + assert math.fsum(x for (_, k), x in q.items() if k == j) <= ( + v + 1e-9) + lines = {('m', i) for i, _ in q} | {('f', j) for _, j in q} + assert len(q) <= max(len(lines) - 1, 0) + for _ in range(100): # a live continuation is always kept + h = {i: rng.uniform(1., 5.) for i in range(rng.randint(1, 4))} + g = {j: rng.uniform(1., 5.) for j in range(rng.randint(1, 4))} + g[0] += sum(h.values()) + c = (rng.choice(list(h)), rng.choice(list(g))) + q = SP._transport(h, g, {(i, j) for i in h for j in g}, [c]) + assert q[c] == pytest.approx(min(h[c[0]], g[c[1]]), rel=4 * eps) + + +# %% 16. Vertical core (hensmith._splitting) + +NEAR_THRESHOLD = (10., [ # _cascade sets Qh = 0: below root slack -23.7 tolQ + ('H1', 'h', 200, 100, 2.), ('C1', 'c', 100, 190, 1.), + ('C2', 'c', 100, 190, 1. + 1e-9)]) +NEAR_DOUBLE_PINCH = (10., [ # nptel_t5_3, CP2 = 6 - 1.58e-11: -0.05 tolQ + ('1', 'h', 200, 65, 3.), ('2', 'h', 90, 30, 6. - 1.58e-11), + ('3', 'c', 30, 142, 3.5), ('4', 'c', 25, 130, 4.)]) +FAR_PAIR = [ # a balanced large-CP pair far below every other stream + ('HF', 'h', 0, -10, 1e4), ('CF', 'c', -50, -40, 1e4)] + + +def jittered_problem(seed): + """A random problem whose stream temperatures are offset by 3e-8..1e-6 K + (more than tolP, less than tolQ/CP: FAR_PAIR sets the scale).""" + rng = random.Random(seed) + rows, dT = pinch_problem(rng) if seed % 2 == 0 else random_problem(rng) + jit = random.Random(10_000 + seed) + rows = [(n, k, Ti + jit.choice([0., 0., 3e-8, 1e-7, 4e-7, 1e-6]), + To + jit.choice([0., 0., 3e-8, 1e-7, 4e-7]), cp) + for n, k, Ti, To, cp in rows] + return rows + FAR_PAIR, dT + + +def _verify_side_cells(side, cells, a0=None, leak=None): + """Independent check of the cells of a split side: every cell by + `_max_duty` and by direct evaluation on the branch-scaled curves; the + branches of every stage with one fraction each, summing to 1, contiguous + from the stage's split end (a must's far end, a flex's start); every + must served exactly from `a0` (plus its leak) and every flex used as a + prefix.""" + M, F = side.M, side.F + a0 = [0.] * M if a0 is None else list(a0) + leak = [0.] * M if leak is None else list(leak) + tolQ, tolP = side.tolQ, side.tolP + scale = max(side.duty, 1.) + for c in cells: + assert c.x > 0. and 0. < c.f <= 1. and 0. < c.g <= 1. + cm, cf = side.musts[c.i], side.flexes[c.j] + assert c.a + c.x / c.f <= cm.Q + tolQ + assert c.b + c.x / c.g <= cf.Q + tolQ + bm = SP._branch_curve(cm, c.f, 'must') + bf = SP._branch_curve(cf, c.g, 'flex') + am, bb = c.f * c.a, c.g * c.b + # (C) by _max_duty against the flex lowered by tolP: its linear + # case stops at the zero crossing of the approach, which is + # ill-conditioned when a near-parallel cell closes to 0 (a relative + # slope difference just above _SLOPE_EQ moves the crossing far more + # than tolQ per ulp of level) + low = P._LevelCurve(bf.q, [y - tolP for y in bf.y], role='flex') + assert P._max_duty(bm, am, low, bb, c.x, 0.) >= c.x - tolQ + ts = np.concatenate(([0., c.x], bm.qa - am, bf.qa - bb)) + ts = ts[(ts >= 0.) & (ts <= c.x)] + assert (bm.at_many(am + ts) - bf.at_many(bb + ts)).min() >= -tolP + for role, n, Q in (('m', M, side.Qm), ('f', F, side.Qf)): + must = role == 'm' + for s in range(n): + items = {} + for c in cells: + if (c.i if must else c.j) == s: + key = c.km if must else c.kf + items.setdefault(('trunk', id(c)) if key is None + else key[:-1], []).append(c) + spans = [] + for key, cs in items.items(): + pos = [(c.a, c.a_end, c.f) if must else (c.b, c.b_end, c.g) + for c in cs] + duty = math.fsum(c.x for c in cs) + if key[0] == 'trunk': + spans.append((pos[0][0], duty)) + continue + # a must splits at its far end and mixes toward the pinch, + # a flex splits at its start and mixes at its far end (as + # in `_remix`): the branches share the split end + split = (max(p[1] for p in pos) if must + else min(p[0] for p in pos)) + branches = {} + for c, p in zip(cs, pos): + branches.setdefault((c.km if must else c.kf)[-1], + []).append(p) + fr = [ps[0][2] for ps in branches.values()] + assert abs(math.fsum(fr) - 1.) <= 1e-12 + for ps in branches.values(): + ps.sort() + assert all(p[2] == ps[0][2] for p in ps) + assert abs((ps[-1][1] if must else ps[0][0]) + - split) <= tolQ + for p, p2 in zip(ps, ps[1:]): + assert abs(p2[0] - p[1]) <= tolQ + spans.append((split - duty if must else split, duty)) + spans.sort() + end = a0[s] if must else 0. + for start, duty in spans: # no overlap, no gap beyond leaks + assert -tolQ <= start - end <= tolQ + sum(leak) + end = start + duty + if must: + served = a0[s] + math.fsum(d for _, d in spans) + leak[s] + assert abs(served - Q[s]) <= 1e-12 * scale + else: + assert end <= Q[s] + tolQ + + +def verify_core(side, cand, a0): + """A core candidate: exact cells, (R) at every node, no leak, no missed + knot, every must served.""" + assert cand.blocks and cand.blocks[0].start[0] == list(a0) + for blk in cand.blocks: + assert residual_slack(side, *blk.end) >= -SP._SPLIT_R_TOL * side.tolQ + assert cand.blocks[-1].end[0] == side.Qm + assert cand.leak_by_must == [0.] * side.M + assert sum(blk.knots for blk in cand.blocks) == 0 + _verify_side_cells(side, cand.cells, a0) + + +def test_verify_side_cells_anchors_stages_like_remix(): + # a must splits at its far end and mixes toward the pinch (`_remix`): + # its branches share their far end (10), not their start + L, B = P._LevelCurve, SP._Cell + side = P._Side('above', [L([0., 10.], [50., 60.])], + [L([0., 20.], [0., 10.])] * 2, 1e-9, 1e-9) + far = [B(0, 0, 3., 4., 0., .5, 1., ('B', 0, 0, 0)), + B(0, 1, 5., 0., 0., .5, 1., ('B', 0, 0, 1)), + B(0, 0, 2., 0., 3.)] + assert not SP._remix('m', SP._stages(far)['m', 0, ('B', 0, 0)], 1e-9)[2] + _verify_side_cells(side, far) + near = [B(0, 0, 3., 0., 0., .5, 1., ('B', 0, 0, 0)), + B(0, 1, 5., 0., 0., .5, 1., ('B', 0, 0, 1)), + B(0, 0, 2., 8., 3.)] + with pytest.raises(AssertionError): + _verify_side_cells(side, near) + + +def core_sides(rng): + """Sides (with musts and flexes) of 200 constant-CP problems, 50 with + point loads and 50 with temperatures closer than tolQ/CP, and of the + merged-breakpoint and near-tie cases.""" + problems = [] + for k in range(200): + rows, dT = pinch_problem(rng) if k % 2 else random_problem(rng) + problems.append(sides_from(rows, dT)) + for _ in range(50): + kn, hot, dT = random_curve_problem(rng, flat_p=0.5) + problems.append(sides_from_knots(kn, hot, dT)) + for seed in range(50): + problems.append(sides_from(*jittered_problem(seed))) + problems.append(sides_from(MERGED_BREAKPOINT, 10.)) + for dT, rows in (NEAR_THRESHOLD, NEAR_DOUBLE_PINCH): + problems.append(sides_from(rows, dT)) + return [s for sides in problems for s in sides.values() if s.M and s.F] + + +def test_theorem_v_random(monkeypatch): + # Theorem V': from a node satisfying (R) (the root, pre-leaked when the + # cascade's tolerances left it slightly negative), the chain of + # elementary vertical blocks is feasible and every node satisfies (R) + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + rng = random.Random(61) + checked = preleaked = skipped = 0 + for side in core_sides(rng): + delta, a0 = SP._preleak_root(side) + if delta > SP._preleak_max(side): # not a tolerance-level deficit + skipped += 1 + continue + preleaked += delta > 0. + assert math.fsum(a0) == pytest.approx(delta, abs=1e-12 * side.duty) + verify_core(side, SP._drive(side, a0, 'V'), a0) + checked += 1 + assert checked >= 360 and preleaked >= 2 and skipped == 0 + + +@pytest.mark.parametrize('coarsen', [True, False]) +def test_vertical_core_on_split_sides(monkeypatch, coarsen): + monkeypatch.setattr(SP, '_SPLIT_COARSEN', coarsen) + for side in split_sides(): + delta, a0 = SP._preleak_root(side) + assert delta == 0. + cand = SP._drive(side, a0, 'V') + verify_core(side, cand, a0) + assert cand.name == 'V' and cand.meta['leak'] == 0. + for blk in cand.blocks: # a coarsened block keeps usable fractions + assert blk.span == 1 or (coarsen and all( + min(c.f, c.g) >= SP._SPLIT_MIN_FRACTION for c in blk.cells)) + + +def test_vertical_core_from_the_preleaked_root(): + # _cascade's own tolerances leave these roots slightly negative: V from + # the root fails its node check; V from P(delta) is exact (Lemma P) + for dT, rows in (NEAR_THRESHOLD, NEAR_DOUBLE_PINCH): + sides = [s for s in sides_from(rows, dT).values() if s.M and s.F] + neg = [s for s in sides if SP._preleak_root(s)[0] > 0.] + assert neg + for side in neg: + delta, a0 = SP._preleak_root(side) + assert SP._SPLIT_R_TOL * side.tolQ < delta <= SP._preleak_max(side) + assert delta == -side.analyse([0.] * side.M, [0.] * side.F).slack + with pytest.raises(SP._SplitInvariantError): + SP._drive(side, [0.] * side.M, 'V') + assert residual_slack(side, a0, [0.] * side.F) >= ( + -SP._SPLIT_R_TOL * side.tolQ) + verify_core(side, SP._drive(side, a0, 'V'), a0) + + +def test_coarsened_blocks_end_at_vertical_nodes(monkeypatch): + # Corollary C: a coarsened block ends where the elementary chain it + # replaces ends, so (R) there is inherited + rng = random.Random(62) + sides = split_sides() + [s for s in core_sides(rng)[::7]] + n_coarse = 0 + for side in sides: + delta, a0 = SP._preleak_root(side) + if delta > SP._preleak_max(side): + continue + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + fine = SP._drive(side, a0, 'V') + monkeypatch.setattr(SP, '_SPLIT_COARSEN', True) + coarse = SP._drive(side, a0, 'V') + verify_core(side, coarse, a0) + assert len(coarse.blocks) <= len(fine.blocks) + nodes = [np.array(blk.end[0] + blk.end[1]) for blk in fine.blocks] + z_f = [0.] * side.F + cp = SP._Coupling(side, a0, z_f, SP._exact(side).analyse(a0, z_f)) + for blk in coarse.blocks: + end = np.array(blk.end[0] + blk.end[1]) + assert min(np.abs(end - n).max() for n in nodes) <= side.tolQ + t = math.fsum(blk.end[0]) - math.fsum(a0) + assert_allclose(blk.end[0], cp.must_at(t), rtol=0., + atol=side.tolQ) + assert_allclose(blk.end[1], cp.flex_at(t), rtol=0., + atol=side.tolQ) + n_coarse += len(coarse.blocks) < len(fine.blocks) + assert n_coarse >= 10 + + +def test_core_node_check_is_exact(monkeypatch): + # flex 0 ends 5e-4 (< tolQ) above flex 1's start and the top level is + # tight: after the block that stops at flex 1's start, flex 0 keeps a + # sliver below tolQ, which the search's analysis omits (slack -tolQ/2); + # the core analyses its nodes exactly + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + L = P._LevelCurve + side = P._Side('above', [L([0., 10.], [0., 10.])], + [L([0., 5.], [0., 5.]), L([0., 5.], [4.9995, 9.9995])], + 1e-3, 1e-9) + cand = SP._drive(side, [0.], 'V') + verify_core(side, cand, [0.]) + lim = -SP._SPLIT_R_TOL * side.tolQ + assert min(side.analyse(*blk.end).slack for blk in cand.blocks) < lim + assert min(SP._exact(side).analyse(*blk.end).slack + for blk in cand.blocks) >= lim + + +NEAR_FLAT = (10., [ # jittered: near-flat pieces (glides of 3e-8..1e-6 K) + ([110.000001, 114.0000004, 210.00000003], [0., 4., 100.]), + ([100.00000003, 134., 134., 165.000001], [0., 68., 108., 139.]), + ([110.0000004, 133.00000003, 133.0000001, 140.0000004], + [0., 46., 51., 58.]), + ([90.0000004, 110.00000003, 139., 155.000001], [0., 60., 147., 179.]), + ([-10., 0.], [0., 1e5]), ([-50., -40.], [0., 1e5])], + [True, True, False, False, True, False]) + + +def test_exact_residual_arrays_on_near_flat_pieces(): + # a piece of 1e-7 K over 10 of heat has a slope of 1e8: the core's + # residual arrays add every piece's clipped share directly, so the + # residual never exceeds the stream's heat (a cumulative sum of slopes + # and offsets cancels catastrophically and drifts with the level) + L = P._LevelCurve + c = L([0., 10., 110., 115.], + [-110.0000001, -110., -60.0000004, -60.0000004]) + d = SP._exact(P._Side('below', [], [c], 0., 1e-9)).analyse([], [0.]) + assert d.Rfi[0].tolist() == [0., 10., 115.] + assert d.Rfe[0].tolist() == [0., 10., 110.] + # several pieces (flats, near-flats) per stream, frontiers inside them: + # the arrays match the residual heat evaluated in exact arithmetic on + # the curve from its frontier ``(f, at(f))`` (inverting the curve, + # ``x_le``, is ill-conditioned on a near-flat piece) + from fractions import Fraction as Fr + rng = random.Random(5) + + def curve(): + c = random_level_curve(rng) + y = [v + 1e-7 * k * (rng.random() < 0.3) for k, v in enumerate(c.y)] + return L(c.q, sorted(y)) + + def residual(c, f, level, inclusive): + pts = [(f, c.at(f))] + [(x, y) for x, y in zip(c.q, c.y) if x > f] + R, L = Fr(0), Fr(level) + for (x0, y0), (x1, y1) in zip(pts, pts[1:]): + ln = Fr(x1) - Fr(x0) + if y1 > y0: + R += ln * min(max((L - Fr(y0)) / (Fr(y1) - Fr(y0)), 0), 1) + elif L > y0 or inclusive and L == y0: + R += ln + return float(R) + for _ in range(40): + side = P._Side('above', [curve() for _ in range(rng.randint(1, 3))], + [curve() for _ in range(rng.randint(1, 3))], 0., 1e-9) + a = [rng.uniform(0., c.Q) * rng.randint(0, 1) for c in side.musts] + b = [rng.uniform(0., c.Q) * rng.randint(0, 1) for c in side.flexes] + d = SP._exact(side).analyse(a, b) + for curves, front, Ri, Re in ((side.musts, a, d.Rmi, d.Rme), + (side.flexes, b, d.Rfi, d.Rfe)): + for k, (c, f) in enumerate(zip(curves, front)): + tol = 1e-14 * c.Q + assert_allclose(Ri[k], [residual(c, f, y, True) + for y in d.levels], rtol=0, atol=tol) + assert_allclose(Re[k], [residual(c, f, y, False) + for y in d.levels], rtol=0, atol=tol) + + +def test_core_on_near_flat_pieces_reaches_mer(): + # the core analyses its nodes exactly: on near-flat pieces every + # strategy reaches MER from the pre-leaked root (the drift of the + # residual arrays failed every core strategy at 'core node (R)') + dT, kn, hot = NEAR_FLAT + side = sides_from_knots(kn, hot, dT)['below'] + delta, a0 = SP._preleak_root(side) + assert 0. < delta <= SP._preleak_max(side) + for strategy in SP._CORE_STRATEGIES: + verify_core(side, SP._drive(side, a0, strategy), a0) + plan = P.plan_network(kn, hot, dT, stream_splitting=True) + assert plan.status == 'mer' + for s in plan.info['sides'].values(): + assert s['split'] is None or s['split']['errors'] == [] + + +NEAR_PARALLEL = (10., [ # jittered: S:demand pairs a branch with a flex + # whose slope differs by a relative 1e-9 over 25 K of level + ([140.0000001, 140.0000001, 158., 160.0000004], [0., 40., 58., 62.]), + ([100.000001, 100.00000003, 205.0000001], [0., 20., 230.]), + ([130.000001, 130.00000003, 141.00000003, 142., 142.0000001, + 160.00000003, 160.], [0., 40., 51., 52., 62., 80., 90.]), + ([100.0000001, 118.0000001, 230.], [0., 18., 130.]), + ([120.0000004, 120.0000004, 170.], [0., 10., 110.]), + ([90.0000001, 154., 215.00000003, 215.00000003], [0., 128., 250., 270.]), + ([90.0000004, 90., 175.00000003], [0., 20., 105.]), + ([-10., 0.], [0., 1e5]), ([-50., -40.], [0., 1e5])], + [True, True, True, True, False, False, False, True, False]) + + +def test_split_searches_keep_near_parallel_pairs_feasible(): + # the planner's `_max_duty` takes slopes within a relative `_SLOPE_EQ` + # as parallel, so a converging pair may close by `_SLOPE_EQ` times its + # level span, beyond tolP; the split path's searches use a max duty + # whose parallel shortcut holds only where the closure stays within + # tolP, so every piece they place satisfies (C) (`_cell_margin`) + L = P._LevelCurve + tolP = 1e-9 + cm = L([0., 25.], [100., 125.]) + for rate, g0, strict in ((5e-10, 0., 0.), (5e-10, 5e-9, 10.), + (5e-10, -5e-10, 0.), (4e-16, 0., 25.), + (0., -0.5 * tolP, 25.), (-1e-3, 0., 25.)): + cf = L([0., 25.], [100. - g0, 125. - g0 + 25. * rate]) + side = P._Side('above', [cm], [cf], 1e-6, tolP) + x = SP._max_duty_strict(cm, 0., cf, 0., 25., tolP) + assert x == pytest.approx(strict, rel=1e-4) # levels round off + assert SP._cell_margin(side, SP._Cell(0, 0, x, 0., 0.))[0] >= -tolP + lenient = P._max_duty(cm, 0., cf, 0., 25., tolP) + assert lenient == 25. and (lenient == x or SP._cell_margin( + side, SP._Cell(0, 0, lenient, 0., 0.))[0] < -tolP) + # the split path searches strictly; the unsplit planner does not + assert P._Side.max_duty is P._max_duty + assert SP._strict(side).max_duty is SP._max_duty_strict + # end to end: S:demand's DFS met such a pair and its plan failed (C) + dT, kn, hot = NEAR_PARALLEL + plan = P.plan_network(kn, hot, dT, stream_splitting=True) + assert plan.status == 'mer' + for s in plan.info['sides'].values(): + assert s['split'] is None or s['split']['errors'] == [] + assert plan.info['sides']['above']['split']['candidates'][ + 'S:demand'] != 'error' + + +def one_to_one_side(): + """One must over one flex, far apart in level: one vertical block.""" + return P._Side('above', [P._LevelCurve([0., 10.], [50., 60.])], + [P._LevelCurve([0., 20.], [0., 10.])], 1e-9, 1e-9) + + +def carrier(cp, k, cells=()): + """A finished vertical block ending at breakpoint `k` of `cp`.""" + end = (cp.Pm[k].tolist(), cp.Pf[k].tolist()) + return SP._Block('vertical', end, end, list(cells), cp=cp, k=k) + + +def test_vertical_block_recovery(monkeypatch): + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + # 1. missed knot: without the breakpoints of flex B's supply level (the + # musts reach it at t1, flex A at t2), elementary block 0 puts B 4e-7 K + # above the pinch against the musts at the pinch; the first hidden + # composite breakpoint is inserted and the block ends there + side = sides_from(MERGED_BREAKPOINT, 10.)['above'] + z_m, z_f = [0.] * side.M, [0.] * side.F + cp = SP._Coupling(side, z_m, z_f) + t1, t2 = cp.t[1], cp.t[2] + assert t1 == pytest.approx(2. * 4e-7, rel=1e-6) + assert t2 == pytest.approx(3. * 4e-7, rel=1e-6) + cp.t, cp.K = np.delete(cp.t, [1, 2]), cp.K - 2 + cp.Pm, cp.Pf = np.delete(cp.Pm, [1, 2], 0), np.delete(cp.Pf, [1, 2], 0) + blk = SP._vertical_block(side, z_m, z_f, [carrier(cp, 0)]) + assert blk.knots == 1 and cp.t[blk.k] == t1 and blk.leak == {} + assert blk.cells and all(SP._cell_margin(side, c)[0] >= -side.tolP + for c in blk.cells) + # 2. a cell failing (C) by position round-off is leaked when its heat is + # below _SPLIT_R_TOL tolQ, attached to the must's series cell in the + # previous block when that re-verifies, and raises otherwise + side = one_to_one_side() + tiny = 0.1 * SP._SPLIT_R_TOL * side.tolQ + margin = SP._cell_margin + monkeypatch.setattr(SP, '_cell_margin', lambda s, c: ( + (-1., False) if c.x <= tiny else margin(s, c))) + cp = SP._Coupling(side, [0.], [0.]) + cp.insert(tiny) + blk = SP._vertical_block(side, [0.], [0.], [carrier(cp, 0)]) + assert blk.cells == [] and blk.leak == {0: tiny} and blk.k == 1 + cp = SP._Coupling(side, [0.], [0.]) + k = cp.insert(5.) + cp.insert(5. + tiny) + series = SP._Cell(0, 0, 5., 0., 0.) + blk = SP._vertical_block(side, [5.], [5.], [carrier(cp, k, [series])]) + assert blk.cells == [] and blk.leak == {} + assert series.a_end == cp.Pm[k + 1, 0] and series.b_end == cp.Pf[k + 1, 0] + monkeypatch.setattr(SP, '_cell_margin', lambda s, c: (-1., False)) + with pytest.raises(SP._SplitInvariantError): + SP._vertical_block(side, [0.], [0.]) + + +def test_vertical_block_forbid_and_used(monkeypatch): + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + side = P._Side('above', [P._LevelCurve([0., 10.], [50., 60.])] * 2, + [P._LevelCurve([0., 20.], [0., 10.])] * 2, 1e-9, 1e-9) + z = [0., 0.] + blk = SP._vertical_block(side, z, z, (), frozenset({(0, 0)})) + assert (0, 0) not in blk.pairs and blk.pairs + assert SP._vertical_block(side, z, z, (), + frozenset({(0, 0), (0, 1)})) is None + # avoid_recycle: a used pair returns only as a series continuation + # (coarse blocks: elementary ones repeat a pair on every split side) + monkeypatch.setattr(SP, '_SPLIT_COARSEN', True) + n = 0 + for s in split_sides(): + cand = SP._drive(s, [0.] * s.M, 'V', cap1=True) + if cand is not None: + n += 1 + pairs = [(c.i, c.j) for c in SP._merge_cells(cand.cells, s.tolQ)] + assert len(pairs) == len(set(pairs)) + _verify_side_cells(s, cand.cells) + assert n >= 1 + + +def test_vertical_block_on_a_round_off_sliver(monkeypatch): + # must 1 starts o ulp above must 0, so the first interval holds o ulp + # of must heat, shared by two flexes with at most one ulp each: the + # flex heat is round-off, yet it still needs a column + monkeypatch.setattr(SP, '_SPLIT_COARSEN', False) + L = P._LevelCurve + u = 20. * SP._SPLIT_ULP + for o in (1.2, 1.5, 1.9, 2.5, 3.5): + side = P._Side('above', [L([0., 10.], [0., 10.]), + L([0., 10. - o * u], [o * u, 10.])], + [L([0., 10.], [0., 10.])] * 2, 1e-9, 1e-9) + cp = SP._Coupling(side, [0., 0.], [0., 0.]) + assert cp.Pm[1, 0] > cp.ulp and cp.Pm[1, 1] == 0. + assert (o >= 2.) == (cp.Pf[1] > cp.ulp).all() + verify_core(side, SP._drive(side, [0., 0.], 'V'), [0., 0.]) + # a vertical block with no transport is None only when forbidden or + # used pairs removed the transport; otherwise it is a failed invariant + side = one_to_one_side() + monkeypatch.setattr(SP, '_transport', lambda *args, **kw: None) + with pytest.raises(SP._SplitInvariantError, match='no transport'): + SP._vertical_block(side, [0.], [0.]) + + +def test_coarsened_block_keeps_the_minimum_fraction(monkeypatch): + # flex 1 carries 0.005 of heat parallel to flex 0 up to level 10, so + # every block over that range sends must 0 to flex 1 with a fraction + # near 5e-4 < _SPLIT_MIN_FRACTION. The block to X verifies cell by cell + # but is rejected for that fraction: elementary blocks cover the range + # and one coarsened block the rest + monkeypatch.setattr(SP, '_SPLIT_COARSEN', True) + L = P._LevelCurve + lv = [float(x) for x in range(0, 21, 2)] + side = P._Side('above', [L([0., 20.005], [1., 21.])], + [L(lv, lv), L([0., .005], [0., 10.])], 1e-9, 1e-9) + cp = SP._Coupling(side, [0.], [0., 0.]) + tiny = lambda blk: min(min(c.f, c.g) for c in blk.cells) + cand = SP._drive(side, [0.], 'V') + verify_core(side, cand, [0.]) + *low, top = cand.blocks + assert len(low) == int(np.searchsorted(cp.t, 10.005 - 1e-9)) + for blk in low: + assert blk.span == 1 and {c.j for c in blk.cells} == {0, 1} + assert tiny(blk) < SP._SPLIT_MIN_FRACTION + assert top.span > 1 and top.end[0] == side.Qm + assert {c.j for c in top.cells} == {0} and tiny(top) == 1. + # without the minimum fraction, the block to X is taken + monkeypatch.setattr(SP, '_SPLIT_MIN_FRACTION', 0.) + cand = SP._drive(side, [0.], 'V') + verify_core(side, cand, [0.]) + assert [blk.span for blk in cand.blocks] == [cp.K] + assert tiny(cand.blocks[0]) < 1e-3 + + +def key_side(curved=False): + musts = [P._LevelCurve([0., 10.], [50., 60.]) for _ in range(2)] + flexes = [P._LevelCurve([0., 20.], [0., 10.]) for _ in range(2)] + if curved: + flexes.append(P._LevelCurve([0., 5., 20.], [0., 1., 10.])) + for k, c in enumerate(musts + flexes): + c.stream = k + return P._Side('above', musts, flexes, 1e-9, 1e-9) + + +def test_candidate_key_and_signature(): + side = key_side() + B = SP._Cell + + def cand(cells, name='V', order=(1, 0), **kw): + return SP._Candidate(name, order, side, cells, **kw) + # must 0 in two branches (a V block), must 1 on a trunk to flex 1 + split = [B(0, 0, 4., 0., 0., .4, .5, ('B', 0, 0, 0), ('B', 0, 0, 0)), + B(0, 1, 6., 0., 0., .6, 1., ('B', 0, 0, 1), None), + B(1, 0, 4., 0., 0., 1., .5, None, ('B', 0, 0, 1)), + B(1, 1, 6., 4., 6.)] + c = cand(split) + assert (c.units, c.stages, c.branches, c.mixers) == (4, 2, 4, 1) + assert c.key() == (0, 0, 0, 8, 1, (1, 0)) + assert c.meta == dict(candidate='V', stages=2, branches=4, leak=0., + small=[]) + # the same network under another name and branch numbering + swap = [B(0, 0, 4., 0., 0., .4, .5, ('B', 7, 0, 1), ('B', 7, 0, 1)), + B(0, 1, 6., 0., 0., .6, 1., ('B', 7, 0, 0), None), + B(1, 0, 4., 0., 0., 1., .5, None, ('B', 7, 0, 0)), + B(1, 1, 6., 4., 6.)] + assert cand(swap, 'LVT', (1, 3)).signature == c.signature + other = [B(0, 0, 3., 0., 0., .3, 3. / 7., ('B', 0, 0, 0), ('B', 0, 0, 0)), + B(0, 1, 7., 0., 0., .7, 1., ('B', 0, 0, 1), None), + B(1, 0, 4., 0., 0., 1., 4. / 7., None, ('B', 0, 0, 1)), + B(1, 1, 6., 4., 7.)] + assert cand(other).signature != c.signature + # series continuations merge into one unit; Qmin and tiny fractions + series = [B(1, 1, 3., 0., 0.), B(1, 1, 7., 3., 3.)] + c = cand(series, Qmin=8.) + assert c.units == 1 and c.small == 0 and c.meta['small'] == [] + c = cand(series, Qmin=11.) + assert c.units == 1 and c.small == 1 and c.meta['small'] == [(1, 3, 10.)] + tiny = [B(0, 0, 1e-4, 0., 0., 1e-5, 1., ('B', 0, 0, 0), None), + B(0, 1, 10. - 1e-4, 0., 0., 1. - 1e-5, 1., ('B', 0, 0, 1), None)] + assert cand(tiny).small == 1 + # a non-isothermal flex remix followed by an exchanger on that flex + remix = [B(0, 0, 2., 0., 0., 1., .25, None, ('B', 0, 0, 0)), + B(1, 0, 2., 0., 0., 1., .75, None, ('B', 0, 0, 1))] + assert cand(remix).mixbad == 0 # feeds only the flex's utility + assert cand(remix + [B(0, 0, 1., 2., 5.)]).mixbad == 1 + iso = [B(0, 0, 2., 0., 0., 1., .5, None, ('B', 0, 0, 0)), + B(1, 0, 2., 0., 0., 1., .5, None, ('B', 0, 0, 1)), + B(0, 0, 1., 2., 4.)] + assert cand(iso).mixbad == 0 + # a must splits at its far end (10) and mixes toward the pinch (2): + # bad only if an exchanger of that must follows the mix + remix = [B(0, 0, 3., 4., 0., .5, 1., ('B', 0, 0, 0), None), + B(0, 1, 5., 0., 0., .5, 1., ('B', 0, 0, 1), None)] + assert cand(remix).mixbad == 0 # feeds only the must's utility + assert cand(remix + [B(0, 0, 2., 0., 3.)]).mixbad == 1 + # a curved stream (more than two knots) with more than _SPLIT_MIX_CAP + # split stages (mixers) on the side counts as a bad remix, even when + # every remix is isothermal; a straight stream does not + side = key_side(curved=True) + + def stages(j, n): # n isothermal stages of flex j: two branches each + return [B(i, j, 1., k, 2. * k, 1., .5, None, ('B', k, j, i)) + for k in range(n) for i in (0, 1)] + assert SP._SPLIT_MIX_CAP == 2 + assert side.flexes[2].n > 2 and side.flexes[0].n == 2 + for j, n, bad in ((2, 3, 1), (2, 2, 0), (0, 3, 0)): + c = cand(stages(j, n)) + assert (c.stages, c.mixers, c.mixbad) == (n, n, bad) + # touch counts only on a side with a curved stream + par = [B(0, 0, 5., 0., 0., .5, .5)] # parallel at the minimum approach + side = P._Side('above', [P._LevelCurve([0., 20.], [0., 10.])], + [P._LevelCurve([0., 20.], [0., 10.])], 1e-9, 1e-9) + assert SP._Candidate('V', (1, 0), side, par).touch == 0 + side.flexes.append(P._LevelCurve([0., 5., 20.], [-9., -8., -1.])) + side = P._Side('above', side.musts, side.flexes, 1e-9, 1e-9) + assert SP._Candidate('V', (1, 0), side, par).touch == 1 + # a cell that fails (C) is never accepted + with pytest.raises(SP._SplitInvariantError): + SP._Candidate('V', (1, 0), side, [B(0, 0, 5., 0., 1.)]) + + +# %% 17. Split plans end to end (records and the switch) + +MATCH_KEYS = {'side', 'hot', 'cold', 'Q', 'T_hot_in', 'T_hot_out', + 'T_cold_in', 'T_cold_out', 'hot_seq', 'cold_seq', 'pair_index'} +SPLIT_KEYS = {'hot_frac', 'cold_frac', 'hot_branch', 'cold_branch'} +SPLIT_ON = dict(stream_splitting=True) + + +def split_infos(plan): + """``{side: info['sides'][side]['split']}`` of the split sides (a side + whose split attempt found no candidate records one with candidate + None).""" + return {name: s['split'] for name, s in plan.info['sides'].items() + if s.get('split') and s['split']['candidate'] is not None} + + +def check_split_network(streams, dT, net, tol=1e-6, exact=True, mer=True): + """ + Walk every stream along ``plan.paths``: trunk exchangers in series and + the branches of every split in parallel from the split state, each at + ``Q / (f CP)``, then the mix at ``Q / CP`` of the total. Recompute the + temperatures and assert: the recorded temperatures, fractions and + branches; both ends of every exchanger keep dT (constant CP: linear + profiles); fractions in (0, 1] summing to 1; ``H_split`` is the state + reached and ``H_mix = H_split -/+ sum Q``; the flattened paths are + ``plan.stages`` with consecutive seqs; utilities >= 0 close every + stream and equal the targets up to the pre-leak; every must of a split + side is served exactly up to its gap. With `exact`, the penalty + is at most the pre-leak (+ 1e-12 of the scale); without it, only + 'mer'-close: temperatures offset by less than tolQ/CP let the unsplit + planner's own tolerances reach the utilities (`_sides` omits a stream + part of at most tolQ at the pinch; `_cascade` thresholds the target of + a side with no must). With ``mer=False`` (a best-effort network, e.g. + avoid_recycle), the utilities are only at least the targets. Returns + the hot and cold utility. + """ + plan = net['plan'] + matches = net['matches'] + scale = duty_scale(streams) + qtol = 1e-9 * scale + preleak = math.fsum(s['preleak'] for s in split_infos(plan).values()) + index = {id(s): k for k, s in enumerate(plan.splits)} + assert len(index) == len(plan.splits) + temps = [{} for _ in matches] + assert all(u >= 0. for u in plan.utility) + for j, s in enumerate(streams): + hot = s['kind'] == 'hot' + role = 'hot' if hot else 'cold' + sg, CP = (-1. if hot else 1.), s['CP'] + lo = min(s['T_in'], s['T_out']) + flat = [] + + def run(ns, T, f, branch): + for n in ns: + m = matches[n] + assert m[role] == s['name'] and m['Q'] > 0. + if plan.splits: + assert m[role + '_frac'] == f + assert m[role + '_branch'] == branch + T2 = T + sg * m['Q'] / (f * CP) + assert_allclose((m[f'T_{role}_in'], m[f'T_{role}_out']), + (T, T2), rtol=1e-9, atol=tol) + temps[n][hot] = (T, T2) + flat.append(n) + T = T2 + return T + T = s['T_in'] + for item in plan.paths[j]: + if not isinstance(item, SP.Split): + T = run([item], T, 1., None) + continue + k = index[id(item)] + fr = item.fractions + assert item.stream == j and len(fr) == len(item.branches) >= 2 + assert all(0. < f <= 1. for f in fr) + assert abs(math.fsum(fr) - 1.) <= 1e-12 + assert item.H_split == pytest.approx(CP * (T - lo), abs=qtol) + D, ends = 0., [] + for b, (f, ns) in enumerate(zip(fr, item.branches)): + assert ns + ends.append(run(ns, T, f, (k, b))) + D += math.fsum(matches[n]['Q'] for n in ns) + assert item.H_mix == pytest.approx(item.H_split + sg * D, + abs=1e-12 * scale) + # isothermal: every branch ends (parent-equivalent) within + # _ISO_TOL tolQ of the mix; a non-isothermal remix is the + # stream's last item, only its utility follows + assert item.isothermal == all( + abs(CP * (e - lo) - item.H_mix) + <= SP._ISO_TOL * plan.info['tolQ'] for e in ends) + assert item.isothermal or item is plan.paths[j][-1] + T += sg * D / CP + assert flat == plan.stages[j] + assert ([matches[n][role + '_seq'] for n in flat] + == list(range(1, len(flat) + 1))) + need = CP * ((T - s['T_out']) if hot else (s['T_out'] - T)) + u = (net['cold_utility'] if hot else net['hot_utility']).get( + s['name'], 0.) + assert need >= -qtol and abs(u - need) <= qtol + assert all(len(t) == 2 for t in temps) # every exchanger walked twice + for t in temps: + (hi, ho), (ci, co) = t[True], t[False] + assert min(hi - co, ho - ci) >= dT - tol + Qh = sum(net['hot_utility'].values()) + Qc = sum(net['cold_utility'].values()) + Qh_t, Qc_t, _ = cascade(streams, dT) + if mer: + assert abs(Qh - Qh_t) <= qtol + preleak + assert abs(Qc - Qc_t) <= qtol + preleak + else: # never below the targets + assert Qh >= Qh_t - qtol and Qc >= Qc_t - qtol + # the split machinery adds no heat error: every must is served exactly + sides = sides_from_knots( + [([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) + exact_tol = 1e-12 * plan.info['scale'] + for name in split_infos(plan): + role = 'hot' if name == 'above' else 'cold' + gaps = plan.info['sides'][name]['gaps'] + for c in sides[name].musts: + served = math.fsum(m['Q'] for m in matches if m['side'] == name + and m[role] == streams[c.stream]['name']) + assert served + gaps.get(c.stream, 0.) == pytest.approx( + c.Q, abs=exact_tol) + if exact and mer: + assert plan.penalty <= preleak + exact_tol + return Qh, Qc + + +S_NAMES = tuple('S:' + rule for rule in SP._SPLIT_RULES) +SPLIT_SIZE = { # units + extra branches + split stages of the split side, + # at most (as recorded when Stage S was added) + 'smith2005_exr18_4': 5, 'smith2005_ex16_5_five_stream': 5, + 'rnd_uniform_n2-6_s327': 4} + + +def split_size(side_info): + """Key element 4 of a split side: units + extra branches + split + stages (every splitter and mixer pair counts like a unit).""" + # units + extra branches (branches - stages) + split stages + return side_info['units'] + side_info['split']['branches'] + + +def repeated_pairs(net): + """(hot, cold) pairs of more than one exchanger, across the pinch.""" + pairs = Counter((m['hot'], m['cold']) for m in net['matches']) + return [p for p, n in pairs.items() if n > 1] + + +@pytest.mark.parametrize('name', sorted(SPLIT)) +def test_split_cases_reach_mer(name): + dT, rows = SPLIT[name] + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + plan = net['plan'] + assert net['status'] == 'mer' and plan.splits + assert plan.info['dropped'] == [] and plan.info['qmin_dropped'] == [] + check_split_network(streams, dT, net) + assert set(net['matches'][0]) == MATCH_KEYS | SPLIT_KEYS + for sname, s in plan.info['sides'].items(): + sp = s['split'] + if sp is None: # a side the unsplit search serves + assert s['status'] in ('mer', 'trivial') + continue + assert s['status'] == 'mer' and s['method'] == 'split-' + sp[ + 'candidate'] + assert s['proof']['rule'] in ('outward', 'inward') + assert sp['preleak'] == sp['leak'] == 0. + assert sp['errors'] == [] and sp['small'] == [] + # the whole portfolio ran: Stage S (a rule reaches MER) and the + # core backstop, and the smallest key won + cands = sp['candidates'] + assert set(cands) == set(S_NAMES) | set(SP._CORE_STRATEGIES) + keys = {n: k for n, k in cands.items() if isinstance(k, tuple)} + assert 'V' in keys and any(n in keys for n in S_NAMES) + assert sp['candidate'] == min(keys, key=keys.get) + assert keys[sp['candidate']][1] == 0 # mixbad: no bad remix + assert split_size(s) <= SPLIT_SIZE[name] + assert s['units'] == len([e for e in plan.exchangers + if e.side == sname]) + + +def test_split_plan_heat_closes_exactly(): + # a leak event (_SPLIT_R_TOL tolQ, 1e-14 of the scale) is 100 times + # inside the planner's 1e-12 heat closure + assert SP._SPLIT_R_TOL * P._REL_Q == pytest.approx(1e-14) + n = 0 + for dT, rows in [*SPLIT.values(), NEAR_THRESHOLD, NEAR_DOUBLE_PINCH]: + streams = streams_from(rows) + plan = P._plan_numeric(streams, dT, **SPLIT_ON)['plan'] + scale = plan.info['scale'] + infos = split_infos(plan) + assert infos + preleak = math.fsum(sp['preleak'] for sp in infos.values()) + assert all(sp['leak'] == 0. for sp in infos.values()) + assert plan.penalty <= preleak + 1e-12 * scale + for name, sp in infos.items(): # truthful gaps + gaps = plan.info['sides'][name]['gaps'] + assert math.fsum(gaps.values()) == pytest.approx( + sp['preleak'] + sp['leak'], abs=1e-12 * scale) + n += 1 + assert n >= 5 + + +def test_splitting_off_adds_nothing(): + # every fixture but the slowest (seconds of best effort; a bitwise dump + # of plan_network with the flag off, over the whole corpus, proves the + # identity outside the test suite) + slow = ('hold_nested_n21-40_s100233',) + cases = [(10., R002), (10., LINNHOFF4), *REPEATED_PAIR.values(), + *(v for k, v in HARD.items() if k not in slow), + *SPLIT.values(), NEAR_THRESHOLD, (10., MERGED_BREAKPOINT)] + info_keys = {'sides', 'qmin_dropped', 'dropped', 'min_approach', 'work', + 'scale', 'tolQ', 'cascade'} + side_keys = {'status', 'method', 'work', 'proof', 'units', 'M', 'F', + 'gaps'} + for dT, rows in cases: + net = P._plan_numeric(streams_from(rows), dT, stream_splitting=False) + plan = net['plan'] + assert set(plan.info) == info_keys + assert all(set(s) == side_keys for s in plan.info['sides'].values()) + assert plan.splits == [] + assert plan.paths == {j: list(s) for j, s in plan.stages.items()} + assert all(set(m) == MATCH_KEYS for m in net['matches']) + assert all((e.hot_frac, e.cold_frac, e.hot_branch, e.cold_branch) + == (1., 1., None, None) for e in plan.exchangers) + + +def test_threshold_and_near_tie_roots_preleak(): + # _cascade's own tolerances leave these roots slightly negative (a + # threshold deficit of 23.7 tolQ = 9e-8; near-equal minima, 0.05 tolQ): + # the core starts from the pre-leaked root and the deficit is booked + for (dT, rows), rel in ((NEAR_THRESHOLD, 23.7), (NEAR_DOUBLE_PINCH, .05)): + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + plan = net['plan'] + assert net['status'] == 'mer' + check_split_network(streams, dT, net) + sides = sides_from(rows, dT) + infos = split_infos(plan) + leaked = {n: sp for n, sp in infos.items() if sp['preleak'] > 0.} + assert len(leaked) == 1 + for name, sp in leaked.items(): + side = sides[name] + slack = side.analyse([0.] * side.M, [0.] * side.F).slack + assert sp['preleak'] == -slack + assert sp['preleak'] == pytest.approx(rel * side.tolQ, rel=.01) + assert sp['preleak'] <= SP._preleak_max(side) + # a 'cascade' root with a deficit beyond the cascade's tolerances: the + # targets cannot be met, so today's best effort (no split) applies + L = P._LevelCurve + side = P._Side('above', [L([0., 10.], [50., 60.])], + [L([0., 5.], [0., 10.])], 1e-9, 1e-9) + z = ([0.], [0.]) + d = side.analyse(*z) + assert side.rules_violation(*z, d) is None + assert -d.slack > SP._preleak_max(side) + off = P._plan_side(side) + on = P._plan_side(side, split=dict(Qmin=0., exclude={}, prefer={})) + assert off.proof['rule'] == on.proof['rule'] == 'cascade' + assert on.status == 'best_effort' and on.cells == [] and on.split is None + assert (on.pieces, on.gaps, on.method) == (off.pieces, off.gaps, + off.method) + + +def records_case(): + """A hand-built split side: H1 (200 kW, far above both colds, which + need 100 kW of heating: all above the pinch) splits at its far end into + two halves that re-join non-isothermally at 100, then serves C2 and C1 + on its trunk.""" + rows = [('H1', 'h', 400, 200, 1.), ('C1', 'c', 20, 170, 1.), + ('C2', 'c', 20, 170, 1.)] + streams = streams_from(rows) + knots = [([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] + curves = P._stream_curves(knots, hot, 10.) + sides = sides_from_knots(knots, hot, 10.) + side = sides['above'] + assert (side.M, side.F, sides['below'].M + sides['below'].F) == (1, 2, 0) + B = SP._Cell + cells = [B(0, 0, 60., 80., 0., .5, 1., ('S', 0, 0)), # [80, 200] + B(0, 1, 40., 120., 0., .5, 1., ('S', 0, 1)), # [120, 200] + B(0, 1, 60., 40., 40.), B(0, 0, 40., 0., 60.)] + _verify_side_cells(side, cells) + plans = {'above': P._SidePlan([], [0.], 'mer', 'split-X', cells=cells, + units=4)} + return sides, plans, curves + + +def test_split_records_walk_a_non_isothermal_remix(monkeypatch): + sides, plans, curves = records_case() + tolQ = sides['above'].tolQ + (recs, qmin_dropped, dropped, stages, utility, min_dT, splits, + paths) = SP._split_records(sides, plans, curves, 3, 0., tolQ) + assert qmin_dropped == dropped == [] and min_dT >= 0. + b0, b1, t1, t0 = range(4) # records in cell order + s, = splits + # the must flows from its far end: the split, then its trunk + assert paths == {0: [s, t1, t0], 1: [b0, t0], 2: [b1, t1]} + assert stages == {0: [b0, b1, t1, t0], 1: [b0, t0], 2: [b1, t1]} + assert (s.stream, s.side, s.key, s.fractions) == (0, 'above', ('S', 0), + (.5, .5)) + assert s.branches == [[b0], [b1]] and not s.isothermal + assert (s.H_split, s.H_mix) == (200., 100.) + H = [(e.H_hot_in, e.H_hot_out, e.hot_seq, e.hot_branch) for e in recs] + assert H == [(200., 80., 1, (0, 0)), (200., 120., 2, (0, 1)), + (100., 40., 3, None), (40., 0., 4, None)] + C = [(e.H_cold_in, e.H_cold_out, e.cold_seq, e.cold_frac) for e in recs] + assert C == [(0., 60., 1, 1.), (0., 40., 1, 1.), (40., 100., 2, 1.), + (60., 100., 2, 1.)] + assert utility == [0., 50., 50.] + # the safety net (a bug if it fires) keeps a dropped branch, empty + real = SP._approach_violation_split + monkeypatch.setattr(SP, '_approach_violation_split', lambda ch, cc, e: ( + -1. if e.Q == 40. and e.hot_frac == .5 else real(ch, cc, e))) + sides, plans, curves = records_case() + recs, _, dropped, stages, utility, _, splits, paths = ( + SP._split_records(sides, plans, curves, 3, 0., tolQ)) + s, = splits + assert dropped == [('above', 0, 2, 40., -1.)] + assert s.branches == [[0], []] and s.fractions == (.5, .5) + assert paths[0] == [s, 1, 2] and paths[2] == [1] + assert s.H_mix == 140. and utility == [40., 50., 90.] + + +def test_split_side_candidates_and_the_hook(): + # the hook: a side the unsplit search serves returns exactly as today; + # a root proof goes to the split path, which reports its candidates + dT, rows = SPLIT['smith2005_exr18_4'] + sides = sides_from(rows, dT) + split = dict(Qmin=0., exclude={}, prefer={}) + for name, side in sides.items(): + off = P._plan_side(side) + on = P._plan_side(side, split=split) + if off.status != 'best_effort': # 'mer' or 'trivial' + assert (on.pieces, on.gaps, on.method, on.work, on.units) == ( + off.pieces, off.gaps, off.method, off.work, off.units) + assert on.cells == [] and on.split is None + continue + assert on.status == 'mer' and on.pieces == [] and on.cells + assert on.proof == off.proof and on.gaps == [0.] * side.M + assert on.units == len(SP._merge_cells(on.cells, side.tolQ)) + assert set(on.split) == {'candidate', 'signature', 'candidates', + 'stages', 'branches', 'preleak', 'leak', + 'small', 'errors'} + _verify_side_cells(side, on.cells) + + +def test_failed_split_keeps_its_diagnostics(monkeypatch): + # every generator fails: the side falls back to best effort, and its + # split info keeps the attempt (no candidate, the reasons and errors), + # unlike a side that never tried to split (None) + def fail(side, a0, strategy, *args, **kw): + raise SP._SplitInvariantError('injected') + monkeypatch.setattr(SP, '_drive', fail) + monkeypatch.setattr(SP, '_SPLIT_RULES', ()) + dT, rows = SPLIT['smith2005_exr18_4'] + streams = streams_from(rows) + off = P._plan_numeric(streams, dT)['plan'].info['sides'] + plan = P._plan_numeric(streams, dT, **SPLIT_ON)['plan'] + assert plan.status == 'best_effort' and plan.splits == [] + assert split_infos(plan) == {} + tried = 0 + for name, s in plan.info['sides'].items(): + o = off[name] + assert (s['status'], s['method'], s['gaps'], s['work']) == ( + o['status'], o['method'], o['gaps'], o['work']) + if o['status'] != 'best_effort': + assert s['split'] is None + continue + tried += 1 + sp = s['split'] + assert set(sp) == {'candidate', 'signature', 'candidates', 'stages', + 'branches', 'preleak', 'leak', 'small', 'errors'} + assert sp['candidate'] is None and sp['signature'] is None + assert sp['candidates'] == dict.fromkeys(SP._CORE_STRATEGIES, 'error') + assert sp['errors'] == [f'{k}: injected' for k in SP._CORE_STRATEGIES] + assert 0. <= sp['preleak'] <= SP._preleak_max( + sides_from(rows, dT)[name]) + assert tried + + +def test_exhausted_schedule_splits(monkeypatch): + # no root proof, but the unsplit search runs out of budget and best + # effort leaves a penalty (> 1e-3 tolQ): the split path serves the side + monkeypatch.setattr(P, '_SCHEDULE', tuple(p[:3] + (1.,) + for p in P._SCHEDULE)) + monkeypatch.setattr(P, '_BE_WORK', 3000.) # keep best effort short + dT, rows = HARD['rnd_nested_n2-6_s237'] + streams = streams_from(rows) + off = P._plan_numeric(streams, dT) + assert off['status'] == 'best_effort' and not root_proof(off['plan']) + # spies: the work the split path is handed, and every core candidate's + handed, drawn = {}, {} + split_side, drive = SP._split_side, SP._drive + + def spy_split(side, proof, cap1, forbid, work_scale, work, split): + handed[side.name] = work + return split_side(side, proof, cap1, forbid, work_scale, work, split) + + def spy_drive(side, *args, **kw): + c = drive(side, *args, **kw) + drawn.setdefault(side.name, []).append(0. if c is None else c.work) + return c + monkeypatch.setattr(SP, '_split_side', spy_split) + monkeypatch.setattr(SP, '_drive', spy_drive) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + assert net['status'] == 'mer' + check_split_network(streams, dT, net) + infos = split_infos(net['plan']) + assert infos + for name, sp in infos.items(): + s = net['plan'].info['sides'][name] + assert s['proof'] is None and s['method'] in { + 'split-' + n for n in SP._CORE_STRATEGIES} + # the best effort's work is counted once, with the candidates' + assert handed[name] == off['plan'].info['sides'][name]['work'] + assert s['work'] == math.fsum([handed[name], *drawn[name]]) + + +def two_core_generators(monkeypatch): + """A second core generator for the selection tests: 'V0' is the chain + of elementary vertical blocks (a different network from V).""" + drive = SP._drive + + def fake(side, a0, strategy, *args, **kw): + if strategy != 'V0': + return drive(side, a0, strategy, *args, **kw) + with monkeypatch.context() as m: + m.setattr(SP, '_SPLIT_COARSEN', False) + return drive(side, a0, strategy, *args, **kw) + monkeypatch.setattr(SP, '_CORE_ORDER', SP._CORE_ORDER + ('V0',)) + monkeypatch.setattr(SP, '_CORE_STRATEGIES', ('V', 'V0')) + monkeypatch.setattr(SP, '_drive', fake) + + +def test_split_exclusion_by_signature(monkeypatch): + dT, rows = SPLIT['smith2005_ex16_5_five_stream'] + streams = streams_from(rows) + + def plan(**kw): + net = P._plan_numeric(streams, dT, **SPLIT_ON, **kw) + check_split_network(streams, dT, net) + (name, sp), = split_infos(net['plan']).items() + return name, sp + # the whole portfolio (Stage S and the core): excluding the chosen + # network gives a different one; excluding every network in turn + # still returns a candidate (never None) + name, sp = plan() + assert sp['candidate'] in S_NAMES + ex = {sp['signature']} + _, sp2 = plan(_split_exclude={name: set(ex)}) + assert sp2['signature'] not in ex + assert sp2['candidates'][sp['candidate']] == 'excluded' + while True: + _, last = plan(_split_exclude={name: set(ex)}) + if last['signature'] in ex: + break + ex.add(last['signature']) + assert len(ex) >= 3 and not any( + isinstance(k, tuple) for k in last['candidates'].values()) + # stickiness: a live preferred Stage S candidate that plans the same + # network as before (its signature) is taken first, alone + _, pr = plan(_split_prefer={name: (sp2['candidate'], sp2['signature'])}) + assert (pr['candidate'], pr['signature']) == (sp2['candidate'], + sp2['signature']) + assert set(pr['candidates']) == {sp2['candidate']} + # one whose network changed (another signature) is not: the whole + # portfolio runs (the preferred one once) and the best key wins + _, pr = plan(_split_prefer={name: (sp2['candidate'], sp['signature'])}) + assert (pr['candidate'], pr['signature']) == (sp['candidate'], + sp['signature']) + assert set(pr['candidates']) == set(sp['candidates']) + # an excluded preferred one: the rest of the portfolio, not it again + _, pr = plan(_split_prefer={name: (sp['candidate'], sp['signature'])}, + _split_exclude={name: {sp['signature']}}) + assert pr['signature'] == sp2['signature'] + assert pr['candidates'][sp['candidate']] == 'excluded' + assert set(pr['candidates']) == set(sp['candidates']) + # the core alone (Stage S off), its four strategies: LV and LVT build + # one network here, so excluding the pick excludes both + monkeypatch.setattr(SP, '_SPLIT_RULES', ()) + name, sp = plan() + assert set(sp['candidates']) == set(SP._CORE_STRATEGIES) + assert sp['candidate'] == 'LV' + _, sp2 = plan(_split_exclude={name: {sp['signature']}}) + assert sp2['candidates']['LV'] == sp2['candidates']['LVT'] == 'excluded' + assert sp2['candidate'] == 'VT' and sp2['signature'] != sp['signature'] + # one core candidate: excluding its signature still returns it (a + # side's last candidate is never excluded) + monkeypatch.setattr(SP, '_CORE_STRATEGIES', ('V',)) + name, sp = plan() + sig = sp['signature'] + name2, sp2 = plan(_split_exclude={name: {sig}}) + assert (name2, sp2['candidate'], sp2['signature']) == (name, 'V', sig) + assert sp2['candidates'] == {'V': 'excluded'} + # two core candidates with different networks + two_core_generators(monkeypatch) + _, sp = plan() + assert set(sp['candidates']) == {'V', 'V0'} + assert sp['candidate'] == 'V' # fewer units + branches + stages + assert sp['candidates']['V'] < sp['candidates']['V0'] + _, ex = plan(_split_exclude={name: {sig}}) + assert ex['candidate'] == 'V0' and ex['signature'] != sig + assert ex['candidates']['V'] == 'excluded' + both = {sig, ex['signature']} + _, last = plan(_split_exclude={name: both}) + assert last['candidate'] == 'V' and last['signature'] == sig + # stickiness: a live preferred candidate is taken as is, first, while + # its network is unchanged + _, pr = plan(_split_prefer={name: ('V0', ex['signature'])}) + assert pr['candidate'] == 'V0' and set(pr['candidates']) == {'V0'} + _, pr = plan(_split_prefer={name: ('V0', ex['signature'])}, + _split_exclude={name: {ex['signature']}}) + assert pr['candidate'] == 'V' and set(pr['candidates']) == {'V', 'V0'} + _, pr = plan(_split_prefer={name: ('V0', sig)}) + assert pr['candidate'] == 'V' and set(pr['candidates']) == {'V', 'V0'} + # the exclusion is by network, not by name: another side's is ignored + _, other = plan(_split_exclude={'none': {sig}}) + assert other['candidate'] == 'V' + + +def drifted(signature, d): + """`signature` with every split fraction moved by `d` (alternating in + sign, as a refine round moves the CP ratios of the knots).""" + cells, fracs = signature + return cells, tuple((role, stream, k, tuple( + f + (d if b % 2 else -d) for b, f in enumerate(fs))) + for role, stream, k, fs in fracs) + + +def test_split_identity_tolerates_refined_fractions(): + # a split's fractions are CP ratios on the knots, so a refine round + # moves them (by ~1e-6 on real thermo) while the network stays the + # same: the preferred and the excluded networks are matched by + # `_same_network` (the structure exactly, the fractions within + # `_SPLIT_SAME_FRACTION`), not by an exact signature + dT, rows = SPLIT['smith2005_ex16_5_five_stream'] + streams = streams_from(rows) + + def plan(**kw): + net = P._plan_numeric(streams, dT, **SPLIT_ON, **kw) + (name, sp), = split_infos(net['plan']).items() + return name, sp + name, sp = plan() + sig = sp['signature'] + same, tol = SP._same_network, SP._SPLIT_SAME_FRACTION + assert sig[1] and 1e-6 < tol <= 1e-3 + assert same(sig, sig) and same(sig, drifted(sig, 1e-6)) + assert same(sig, drifted(sig, 0.5 * tol)) + assert not same(sig, drifted(sig, 2. * tol)) + assert not same(sig, (sig[0][1:], sig[1])) + assert not same(sig, (sig[0], ())) + # the preferred network, drifted, is still taken first and alone + _, pr = plan(_split_prefer={name: (sp['candidate'], + drifted(sig, 1e-6))}) + assert pr['candidate'] == sp['candidate'] + assert set(pr['candidates']) == {sp['candidate']} + # an excluded network, drifted, is still excluded + _, ex = plan(_split_exclude={name: {drifted(sig, 1e-6)}}) + assert not same(ex['signature'], sig) + assert ex['candidates'][sp['candidate']] == 'excluded' + + +def test_split_signature_is_knot_independent(): + # the refine loop re-plans on refined knots and excludes (or prefers) + # by network (`_same_network`); extra collinear knots move no fraction, + # so they give the same pick with the same signature + cases = [*SPLIT.values(), *(corpus_problem(name) for name in ( + 'smith2005_exr18_5_nine_stream', 'fs_22sp_ph', 'cgm_unbalanced10'))] + n = 0 + for dT, rows in cases: + streams = streams_from(rows) + picks = [] + for knots in (1, 3): + plan = P._plan_numeric(streams, dT, knots, **SPLIT_ON)['plan'] + picks.append({name: (sp['candidate'], sp['signature']) + for name, sp in split_infos(plan).items()}) + assert picks[0] == picks[1] + n += len(picks[0]) + assert n >= len(SPLIT) + + +def check_split_curves(kn, is_hot, dT, plan, tol=1e-6): + """ + Independent knot walk of a plan on piecewise-linear streams (knots + ``(T, H)``): T(H) by interpolation on the knots; every stream along + ``plan.paths`` from its inlet, trunk records in series, the branches + of a split in parallel from ``H_split`` by ``Q / f`` each, the mix at + ``H_split -/+ sum Q``; the isothermal flag and a non-isothermal remix + last; the utility closes every stream; the approach is at least dT at + both ends and at every knot of every exchanger; the utilities equal + the targets of `knot_cascade` up to the pre-leak. + """ + scale = sum(H[-1] - H[0] for _, H in kn) + qtol = 1e-9 * scale + tolQ = plan.info['tolQ'] + index = {id(s): k for k, s in enumerate(plan.splits)} + seen = Counter() + for j, ((T, H), hot) in enumerate(zip(kn, is_hot)): + sg, role = (-1., 'hot') if hot else (1., 'cold') + io = ('H_hot_in', 'H_hot_out') if hot else ('H_cold_in', 'H_cold_out') + Hc = H[-1] if hot else H[0] + + def run(ns, Hb, f, branch): + for n in ns: + e = plan.exchangers[n] + seen[n] += 1 + assert getattr(e, role) == j and e.Q > 0. + assert getattr(e, role + '_frac') == f + assert getattr(e, role + '_branch') == branch + H2 = Hb + sg * e.Q / f + assert_allclose([getattr(e, io[0]), getattr(e, io[1])], + [Hb, H2], rtol=0, atol=qtol) + Hb = H2 + return Hb + for item in plan.paths[j]: + if not isinstance(item, SP.Split): + Hc = run([item], Hc, 1., None) + continue + k = index[id(item)] + assert item.stream == j and len(item.branches) >= 2 + assert abs(math.fsum(item.fractions) - 1.) <= 1e-12 + assert item.H_split == pytest.approx(Hc, abs=qtol) + ends = [run(ns, Hc, f, (k, b)) for b, (f, ns) in enumerate( + zip(item.fractions, item.branches))] + D = math.fsum(plan.exchangers[n].Q for ns in item.branches + for n in ns) + Hc += sg * D + assert item.H_mix == pytest.approx(Hc, abs=qtol) + assert item.isothermal == all( + abs(x - item.H_mix) <= SP._ISO_TOL * tolQ for x in ends) + assert item.isothermal or item is plan.paths[j][-1] + need = Hc - H[0] if hot else H[-1] - Hc + assert need >= -qtol + assert plan.utility[j] == pytest.approx(need, abs=qtol) + assert all(seen[n] == 2 for n in range(len(plan.exchangers))) + for e in plan.exchangers: # counter-current: tau is the duty done + (Th, Hh), (Tc, Hc) = kn[e.hot], kn[e.cold] + taus = {0., e.Q} + taus.update((e.H_hot_in - h) * e.hot_frac for h in Hh + if e.H_hot_out < h < e.H_hot_in) + taus.update((e.H_cold_out - h) * e.cold_frac for h in Hc + if e.H_cold_in < h < e.H_cold_out) + for tau in taus: + th = np.interp(e.H_hot_in - tau / e.hot_frac, Hh, Th) + tc = np.interp(e.H_cold_out - tau / e.cold_frac, Hc, Tc) + assert th - tc >= dT - tol + Qh_t, Qc_t = knot_cascade(kn, is_hot, dT) + preleak = math.fsum(sp['preleak'] for sp in split_infos(plan).values()) + assert abs(plan.Q_hot - Qh_t) <= qtol + preleak + assert abs(plan.Q_cold - Qc_t) <= qtol + preleak + + +def pinch_curve_problem(rng): + """Random piecewise-linear problem built around a pinch at P (as + `pinch_problem`), with CP changes and point loads, some at the + pinch.""" + dT, P0 = 10., float(rng.choice(range(100, 200, 10))) + + def stream(lo, hi): + Ts = sorted({lo, hi, *rng.sample(range(int(lo) + 1, int(hi)), + rng.randint(0, 2))}) + T, H = [float(Ts[0])], [0.] + for t in Ts[1:]: + if rng.random() < 0.35: # a point load at the current level + T.append(T[-1]) + H.append(H[-1] + rng.choice([5., 10., 20.])) + T.append(float(t)) + H.append(H[-1] + rng.choice([0.5, 1., 2., 3.]) * (t - T[-2])) + return T, H + kn, hot = [], [] + for is_hot, base in ((True, P0 + dT), (False, P0)): + for _ in range(rng.randint(1, 3)): + lo = base - rng.choice([0, 0, 10, 20]) + kn.append(stream(lo, base + rng.choice(range(10, 60, 5)))) + hot.append(is_hot) + return kn, hot, dT + + +def curved_split_problems(): + """Point-load and CP-change variants of the number rule and the CP rule + (each must split), then random pinch-centred curve problems.""" + yield [([100., 200.], [0., 100.]), ([100., 200.], [0., 100.]), + ([90., 190., 190.], [0., 300., 310.])], [True, True, False], 10. + yield [([100., 150., 200.], [0., 40., 100.]), ([100., 200.], [0., 100.]), + ([90., 140., 190.], [0., 160., 300.])], [True, True, False], 10. + yield [([100., 150., 150., 200.], [0., 150., 170., 320.]), + ([90., 140., 190.], [0., 80., 200.]), ([90., 190.], [0., 200.])], [ + True, False, False], 10. + rng = random.Random(83) + for _ in range(60): + yield pinch_curve_problem(rng) + + +def test_curved_split_plans_walk_on_the_knots(): + # plan_network with splitting on piecewise-linear streams (CP changes, + # point loads): the records, fractions, mixes and approaches hold on + # an independent walk of the knots, and the plan is MER + n_split = n_flat = 0 + for k, (kn, hot, dT) in enumerate(curved_split_problems()): + plan = P.plan_network(kn, hot, dT, stream_splitting=True) + assert plan.status == 'mer' + for s in plan.info['sides'].values(): + assert s['split'] is None or s['split']['errors'] == [] + assert k >= 3 or plan.splits # the constructed cases split + check_split_curves(kn, hot, dT, plan) + n_split += bool(plan.splits) + n_flat += bool(plan.splits) and any( + len(set(T)) < len(T) for T, _ in kn) + assert n_split >= 10 and n_flat >= 5 + + +def test_fuzz_splitting_always_reaches_mer(monkeypatch): + monkeypatch.setattr(P, '_BE_WORK', 3000.) # keep best effort short + monkeypatch.setattr(SP, '_SPLIT_S_WORK', 3000.) + rules = SP._SPLIT_RULES + rng = random.Random(29) + n_split = n_same = n_s = 0 + for seed in range(300): + # half the seeds with the core alone (Stage S off) + monkeypatch.setattr(SP, '_SPLIT_RULES', () if seed % 2 else rules) + jitter = seed % 3 == 2 # temperatures closer than tolQ/CP + if jitter: + rows, dT = jittered_problem(seed) + elif seed % 3: # built around a pinch, violating the pinch rules + rows, dT = pinch_problem(rng) + while not needs_split(streams_from(rows), dT): + rows, dT = pinch_problem(rng) + else: + rows, dT = random_problem(rng) + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + plan = net['plan'] + assert net['status'] == 'mer', (seed, rows, dT) + assert plan.info['dropped'] == [] + check_split_network(streams, dT, net, exact=not jitter) + infos = split_infos(plan) + assert all(sp['leak'] == 0. and sp['errors'] == [] + for sp in infos.values()) + if seed % 2: + assert all(sp['candidate'] in SP._CORE_STRATEGIES + for sp in infos.values()) + n_s += sum(sp['candidate'] in S_NAMES for sp in infos.values()) + n_split += bool(infos) + if not needs_split(streams, dT): + off = P._plan_numeric(streams, dT) + assert fingerprint(off) == fingerprint(net) and not plan.splits + assert [(e.H_hot_in, e.H_cold_in) for e in plan.exchangers] == [ + (e.H_hot_in, e.H_cold_in) for e in off['plan'].exchangers] + n_same += 1 + assert n_split >= 110 and n_same >= 150 and n_s >= 40 + + +# %% 18. Stage S cut transports (hensmith._splitting) + +CUT_RULES = ('partner', 'demand', 'mincell', 'nw-rho-desc', 'nw-rho-asc', + 'nw-exact-desc', 'nw-exact-asc') +TWO_HOT_ONE_COLD = (10., [ # the number rule: two hot streams, one cold + ('H1', 'h', 200, 100, 1.), ('H2', 'h', 200, 100, 1.), + ('C1', 'c', 90, 190, 3.)]) +ONE_HOT_TWO_COLD = (10., [ # the CP rule: no cold stream is large enough + ('H1', 'h', 200, 100, 3.), ('C1', 'c', 90, 190, 2.), + ('C2', 'c', 90, 190, 2.)]) +TINY_BRANCH = (10., [ # the cold branch for H2 would carry 1e-4 of it + ('H1', 'h', 200, 100, 1.), ('H2', 'h', 200, 100, 1e-4), + ('C1', 'c', 90, 190, 3.)]) +TINY_HOT_BRANCH = (10., [ # below: the hot branch for C2 would carry 1e-6 + ('H0', 'h', 170, 150, 7.), ('C1', 'c', 120, 175, 1.000001), + ('C2', 'c', 140, 180, 1e-6)]) +TIGHT_TINY_BRANCH = (10., [ # below: as tiny, and no CP slack to raise it + ('H0', 'h', 170, 100, 1.0001), ('C1', 'c', 90, 175, 1.), + ('C2', 'c', 90, 180, 1e-4)]) +TINY_DEMAND_BRANCH = (10., [ # above (the facility fuzz): H1 (CP 1) needs a + # branch of 1e-6 to 1e-3 of it for C3 (CP 1e-3) or C1 (CP 1e-4) + ('H0', 'h', 205, 170, 7.), ('H1', 'h', 225, 170, 1.), + ('H2', 'h', 185, 150, .999999), ('C0', 'c', 150, 245, .999999), + ('C1', 'c', 120, 240, 1e-4), ('C2', 'c', 160, 245, 7.), + ('C3', 'c', 160, 240, 1e-3)]) + + +def corpus_problem(name): + """(dT, rows) of a constant-CP corpus case (`hxn_mer_cases`), in K and + kW.""" + case = next(c for c in CORPUS_SPLIT if c['name'] == name) + sc, off = T_UNITS[case['T_unit']] + kw = Q_UNITS[case['Q_unit']] + return case['dTmin'] * sc, [ + (n, kind[0], (Ti + off) * sc, (To + off) * sc, cp * kw / sc) + for n, kind, Ti, To, cp in case['streams']] + + +def root_of(dT, rows, name='above'): + """A side of a constant-CP problem, its pre-leaked root and its root + proof.""" + side = sides_from(rows, dT)[name] + z_m, z_f = [0.] * side.M, [0.] * side.F + proof = side.rules_violation(z_m, z_f, side.analyse(z_m, z_f)) + return side, SP._preleak_root(side)[1], proof + + +def cut_sets(side, a0, proof): + return SP._cut_sets(side, a0, [0.] * side.F, proof['level'], + proof['cut'], proof['rule']) + + +def cps(curves): + """CP of every constant-CP curve.""" + return [1. / P._slope1(c) for c in curves] + + +def check_cut_transport(dem, cap, rule, cells): + """Independent check of the cells of a cut transport: every demand + served in full, no capacity loaded beyond its CP, fractions in (0, 1] + summing to 1 per item, branch CP ratio ``g c / (f m) >= 1`` in every + cell (uniform, ``rho = sum c / sum m``, for the rho rules), a forest, + and each rule's shape.""" + m, c = dict(dem), dict(cap) + fr = SP._cut_fractions(cells) + assert len(fr) == len(cells) + for d in m: + loads = [x for k, _, x in cells if k == d] + assert loads and math.fsum(loads) == pytest.approx(m[d], rel=1e-12) + for k in c: + assert math.fsum(x for _, j, x in cells if j == k) <= c[k] * ( + 1. + 1e-12) + ratio = [] + for (d, k, x), (f, g) in zip(cells, fr): + assert x > 0. and 0. < f <= 1. and 0. < g <= 1. + ratio.append(g * c[k] / (f * m[d])) + assert min(ratio) >= 1. - 1e-12 + for role in (0, 1): + for item in {cell[role] for cell in cells}: + assert abs(math.fsum(f[role] for cell, f in zip(cells, fr) + if cell[role] == item) - 1.) <= 1e-12 + assert is_forest([(d, k) for d, k, _ in cells]) + if rule.startswith('nw-rho'): + rho = math.fsum(c.values()) / math.fsum(m.values()) + assert_allclose(ratio, rho, rtol=1e-12) + if rule == 'partner': # the demands stay whole + assert len(cells) == len(m) + if rule == 'demand': # the capacities stay whole + assert len({k for _, k, _ in cells}) == len(cells) + + +def check_items(side, a0, items, extra): + """The branched side of `items` keeps the cascade, satisfies the pinch + rules at its pre-leaked root, and its branches are well formed.""" + bside, a = SP._branched_side(side, items, a0) + roles = [it[0] for it in items] + assert roles == sorted(roles, key=lambda r: r != 'must') + for curve, (role, p, f, _) in zip(bside.musts + bside.flexes, items): + parent = (side.musts if role == 'must' else side.flexes)[p] + assert curve.y == parent.y and curve.q == [f * q for q in parent.q] + assert a == [f * a0[p] for role, p, f, _ in items if role == 'must'] + z = [0.] * bside.F + d = bside.analyse(a, z) + assert bside.rules_violation(a, z, d) is None + assert abs(d.slack - side.analyse(a0, [0.] * side.F).slack) <= ( + 10. * side.tolQ) + parents = {} + for role, p, f, key in items: + parents.setdefault((role, p), []).append((f, key)) + assert sorted(parents) == sorted( + [('must', i) for i in range(side.M)] + + [('flex', j) for j in range(side.F)]) + for (role, p), br in parents.items(): + if len(br) == 1: + assert br == [(1., None)] + continue + assert abs(math.fsum(f for f, _ in br) - 1.) <= 1e-12 + assert all(f >= SP._SPLIT_MIN_FRACTION for f, _ in br) + assert [k for _, k in br] == [('S', p, n) for n in range(len(br))] + assert extra == sum(len(br) - 1 for br in parents.values()) + return bside + + +@pytest.mark.parametrize('rule', CUT_RULES) +def test_cut_transport_rules(rule): + assert SP._SPLIT_RULES == CUT_RULES + # the number rule (2h1c): the cold stream hosts both hot streams, +1 + side, a0, proof = root_of(*TWO_HOT_ONE_COLD) + assert proof['rule'] == 'outward' + dem, cap = cut_sets(side, a0, proof) + assert [d for d, _ in dem] == [0, 1] and [k for k, _ in cap] == [0] + assert_allclose([m for _, m in dem] + [c for _, c in cap], [1., 1., 3.], + rtol=1e-12) + out = SP._pinch_split(side, a0, proof, rule) + if rule == 'demand': # the cold stream is matched: none is left + assert out is None and SP._cut_transport(dem, cap, rule) is None + else: + items, extra = out + check_items(side, a0, items, extra) + assert extra == 1 + assert [it[:2] for it in items] == [ + ('must', 0), ('must', 1), ('flex', 0), ('flex', 0)] + assert_allclose([it[2] for it in items[2:]], [.5, .5], rtol=1e-12) + # the CP rule (1h2c): the hot stream splits over both cold streams, +1 + side, a0, proof = root_of(*ONE_HOT_TWO_COLD) + out = SP._pinch_split(side, a0, proof, rule) + if rule == 'partner': # no cold stream has room for the hot one + assert out is None + else: + items, extra = out + check_items(side, a0, items, extra) + assert extra == 1 and [it[0] for it in items] == [ + 'must', 'must', 'flex', 'flex'] + assert_allclose([it[2] for it in items[:2]], + [2 / 3, 1 / 3] if rule.startswith('nw-exact') + else [.5, .5], rtol=1e-12) + # zero CP slack (smith2005_ex18_4 below: the cold stream's CP 300 on + # the hot CPs 200 and 100): every branch has its partner's CP + side, a0, proof = root_of(*corpus_problem('smith2005_ex18_4_split'), + name='below') + out = SP._pinch_split(side, a0, proof, rule) + if rule == 'partner': + assert out is None + else: + bside = check_items(side, a0, *out) + assert out[1] == 1 + assert_allclose(sorted(cps(bside.flexes)), sorted(cps(bside.musts)), + rtol=1e-12) + # cornell above: CP 40 and 30 on CP 60 and 20; no whole demand or + # capacity rule, and the minimum is 3 cells (k = 1: one super-bin) + side, a0, proof = root_of( + *corpus_problem('cornell_processdesign_four_stream_split')) + dem, cap = cut_sets(side, a0, proof) + assert_allclose([m for _, m in dem], [40., 30.], rtol=1e-12) + assert_allclose([c for _, c in cap], [60., 20.], rtol=1e-12) + cells = SP._cut_transport(dem, cap, rule) + if rule in ('partner', 'demand'): + assert cells is None + else: + check_cut_transport(dem, cap, rule, cells) + assert len(cells) == 3 + if rule == 'mincell': + assert cells == SP._cut_transport(dem, cap, 'nw-rho-desc') + # random cut sets, on scales from 1e-3 to 1e3; half of them tight (the + # capacities scaled to the demands' CP sum, or up to 30 % above it) + rng = random.Random(CUT_RULES.index(rule)) + grid = [.5, 1., 1.5, 2., 3., 4., 7.] + + def cp(hi): + return rng.choice(grid) if rng.random() < .7 else rng.uniform(.1, hi) + n_ok = n_whole = 0 + for _ in range(300): + s = rng.choice([1e-3, 1., 1e3]) + dem = [(n, s * cp(5.)) for n in range(rng.randint(1, 4))] + cap = [(n, s * cp(8.)) for n in range(rng.randint(1, 4))] + if rng.random() < .5: + k = (math.fsum(m for _, m in dem) / math.fsum(c for _, c in cap) + * rng.choice([1., rng.uniform(1., 1.3)])) + cap = [(n, c * k) for n, c in cap] + cells = SP._cut_transport(dem, cap, rule) + if math.fsum(c for _, c in cap) < math.fsum(m for _, m in dem) * ( + 1. - 1e-12): + assert cells is None + continue + if rule not in ('partner', 'demand'): # these never fail then + assert cells is not None + if cells is not None: + check_cut_transport(dem, cap, rule, cells) + n_ok += 1 + # the matching is maximum: where the rule holds whole, the number + # and CP rules and mincell split nothing + if rule in ('partner', 'demand', 'mincell') and _matching( + [m for _, m in dem], [c for _, c in cap], + lambda m, c: c >= m * (1. - 1e-12)): + assert len(cells) == len(dem) == len({k for _, k, _ in cells}) + n_whole += 1 + assert n_ok >= 100 and (n_whole >= 40 or rule.startswith('nw-')) + + +def test_mincell_prefers_fewer_cells(): + # k = 0 packs the demands whole: CP 2 and 1 into CP 3 and 1, 2 cells + dem, cap = [(0, 2.), (1, 1.)], [(0, 3.), (1, 1.)] + cells = SP._cut_transport(dem, cap, 'mincell') + check_cut_transport(dem, cap, 'mincell', cells) + assert sorted((d, k) for d, k, _ in cells) == [(0, 0), (1, 1)] + assert len(SP._cut_transport(dem, cap, 'nw-rho-desc')) == 3 + # CP 1.2 bins hold one demand each: only k = 4 packs all six, so the + # rule falls back to one group + dem = [(n, 1.) for n in range(6)] + cap = [(n, 1.2) for n in range(5)] + assert SP._cut_transport(dem, cap, 'mincell') == SP._cut_transport( + dem, cap, 'nw-rho-desc') + + +def test_cut_fractions_spread_the_capacity_slack(): + # the loads' shares, bit for bit, unless a capacity branch would be + # below _SPLIT_MIN_FRACTION of its parent: then g_k = max(g_min, lam + # x_k), adding up to 1, which keeps g_k c >= x_k while lam c >= 1 + cells = [(0, 0, 1.), (1, 0, 1e-4), (2, 0, 5e-4), (3, 0, 2e-3)] + shares = SP._cut_fractions(cells) + X = math.fsum(x for *_, x in cells) + assert shares == [(1., x / X) for *_, x in cells] + assert SP._cut_fractions(cells, {0: (10., 1.), 7: (1., 1.)}) != shares + for c, f in ((10., 1.), (10., .25), (10., .3), (1.01, 1.)): + fr = SP._cut_fractions(cells, {0: (c, f)}) + assert [d for d, _ in fr] == [d for d, _ in shares] + g = [g for _, g in fr] + g_min = SP._SPLIT_MIN_FRACTION / f + lam = g[0] / cells[0][2] # the largest load: never raised + assert_allclose(g, [max(g_min, lam * x) for *_, x in cells], + rtol=1e-12) + assert abs(math.fsum(g) - 1.) <= 1e-15 + assert all(f * gk >= SP._SPLIT_MIN_FRACTION for gk in g) + assert all(gk * c >= x for gk, (*_, x) in zip(g, cells)) + assert_allclose([g for _, g in SP._cut_fractions(cells, {0: (10., 1.)})], + [.998 / 1.002, 1e-3, 1e-3, 2e-3 * .998 / 1.002], + rtol=1e-12) + # all branches reach the minimum, or the slack cannot raise them (the + # branch then merges, `_split_items`): the loads' shares + for cells, cap in (([(0, 0, 1.), (1, 0, 2.)], {0: (5., 1.)}), + ([(0, 0, 1.), (1, 0, 1e-4)], {0: (1.0001, 1.)}), + ([(0, 0, 1.), (1, 0, 1e-4)], {0: (3., 1.5e-3)})): + assert SP._cut_fractions(cells, cap) == SP._cut_fractions(cells) + + +def test_cut_fractions_raise_a_demand_branch_into_capacity_room(): + # a demand's branches are its loads' shares, bit for bit, unless one + # would be below _SPLIT_MIN_FRACTION of its parent and the room left in + # the capacities takes the raise: then g_k = max(g_min, lam x_k / m), + # adding up to 1, with every raised load g_k m within its capacity's CP + cells = [(0, 0, .999999), (0, 1, 1e-6)] + shares = SP._cut_fractions(cells) + cap = {0: (.999999, 1.), 1: (1e-3, 1.)} + assert SP._cut_fractions(cells, cap) == shares # no demand given + fr = SP._cut_fractions(cells, cap, {0: 1.}) + assert_allclose([f for f, _ in fr], [.999, 1e-3], rtol=1e-12) + assert [g for _, g in fr] == [1., 1.] + assert abs(math.fsum(f for f, _ in fr) - 1.) <= 1e-15 + assert all(f >= SP._SPLIT_MIN_FRACTION for f, _ in fr) + # a demand of fraction .5 of its parent needs g_min = 2e-3 + fr = SP._cut_fractions(cells, {0: (.999999, 1.), 1: (3e-3, 1.)}, {0: .5}) + assert_allclose([f for f, _ in fr], [.998, 2e-3], rtol=1e-12) + assert all(.5 * f >= SP._SPLIT_MIN_FRACTION for f, _ in fr) + # no room for the raise: the loads' shares + for cap, dem in (({0: (.999999, 1.), 1: (1e-4, 1.)}, {0: 1.}), + ({0: (.999999, 1.), 1: (1e-3, 1.)}, {0: .5})): + assert SP._cut_fractions(cells, cap, dem) == shares + # two demands' slivers on one capacity whose room takes one raise: the + # first demand is raised, the second keeps its shares (its sliver then + # merges, `_split_items`), and the capacity's own branch for it is + # raised from its CP slack + cells = [(0, 0, .999999), (0, 2, 1e-6), (1, 1, .999999), (1, 2, 1e-6)] + cap = {0: (.999999, 1.), 1: (.999999, 1.), 2: (1.5e-3, 1.)} + fr = SP._cut_fractions(cells, cap, {0: 1., 1: 1.}) + assert_allclose([f for f, _ in fr], [.999, 1e-3, .999999, 1e-6], + rtol=1e-12) + assert_allclose([g for _, g in fr], [1., .999, 1., 1e-3], rtol=1e-12) + + +def test_stage_s_raises_demand_branches_to_the_minimum(): + # TINY_DEMAND_BRANCH: H1 (CP 1) fits no cold stream at the pinch, and + # every cut transport gives it a branch below _SPLIT_MIN_FRACTION (for + # C3 or C1), which merged away and undid the split: every Stage S rule + # failed and a core candidate split H1 0.9999 / 1e-4. C3's CP room + # takes a branch of the minimum: 'demand' and 'nw-exact-desc' split H1 + # 0.999 / 0.001, and the plan reaches MER with a Stage S candidate and + # every fraction at least the minimum + dT, rows = TINY_DEMAND_BRANCH + side, a0, proof = root_of(dT, rows) + for rule in ('demand', 'nw-exact-desc'): + items, extra = SP._pinch_split(side, a0, proof, rule) + check_items(side, a0, items, extra) + assert extra == 1 + assert_allclose(sorted(it[2] for it in items if it[3] is not None), + [1e-3, .999], rtol=1e-12) + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + plan = net['plan'] + assert net['status'] == 'mer' + check_split_network(streams, dT, net) + assert split_infos(plan)['above']['candidate'] in S_NAMES + assert plan.splits and all(min(s.fractions) >= SP._SPLIT_MIN_FRACTION + for s in plan.splits) + assert all(min(e.hot_frac, e.cold_frac) >= SP._SPLIT_MIN_FRACTION + for e in plan.exchangers) + + +@pytest.mark.parametrize('problem', ['TINY_BRANCH', 'TINY_HOT_BRANCH']) +def test_stage_s_keeps_capacity_branches_above_the_minimum(problem): + # a capacity whose branch for a tiny demand would be below + # _SPLIT_MIN_FRACTION of it has CP slack to raise it: Stage S splits + # (it used to merge the branch and fail, leaving the side to a core + # candidate with a branch of 1e-4 or 1e-6 of its stream) + dT, rows = globals()[problem] + side, a0, proof = root_of(dT, rows, 'above' if problem == 'TINY_BRANCH' + else 'below') + for rule in CUT_RULES: + out = SP._pinch_split(side, a0, proof, rule) + if rule != 'demand': # no capacity to take a whole demand + check_items(side, a0, *out) + assert out[1] == 1 + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + plan = net['plan'] + assert net['status'] == 'mer' + check_split_network(streams, dT, net) + infos = split_infos(plan) + assert infos and all(sp['candidate'] in S_NAMES for sp in infos.values()) + assert plan.splits and all(min(s.fractions) >= SP._SPLIT_MIN_FRACTION + for s in plan.splits) + assert all(min(e.hot_frac, e.cold_frac) >= SP._SPLIT_MIN_FRACTION + for e in plan.exchangers) + + +def test_pinch_split_screens_the_branched_root(monkeypatch): + # a capacity branch below _SPLIT_MIN_FRACTION takes CP slack from its + # sibling: the cold stream (CP 3) splits 0.999 / 0.001, branch CPs + # 2.997 >= 1 and 0.003 >= 1e-4 + side, a0, proof = root_of(*TINY_BRANCH) + items, extra = SP._pinch_split(side, a0, proof, 'partner') + check_items(side, a0, items, extra) + assert extra == 1 + assert_allclose([it[2] for it in items[2:]], [.999, .001], rtol=1e-12) + # with no CP slack to raise it (CP 1.0001 on 1 and 1e-4), the branch + # merges into its sibling, which undoes the split: the rule fails + side, a0, proof = root_of(*TIGHT_TINY_BRANCH, name='below') + dem, cap = cut_sets(side, a0, proof) + assert proof['rule'] == 'outward' + assert_allclose([m for _, m in dem] + [c for _, c in cap], + [1., 1e-4, 1.0001], rtol=1e-12) + assert all(SP._pinch_split(side, a0, proof, rule) is None + for rule in CUT_RULES) + with monkeypatch.context() as mp: + mp.setattr(SP, '_SPLIT_MIN_FRACTION', 0.) + items, extra = SP._pinch_split(side, a0, proof, 'partner') + assert extra == 1 and min(it[2] for it in items) < 1e-3 + # a cut with a flat demand: splitting cannot serve it + flat = P._LevelCurve([0., 5., 10.], [100., 100., 120.], 0) + slope = P._LevelCurve([0., 10.], [100., 110.], 1) + fs = P._Side('above', [flat, slope], [slope], 1e-9, 1e-9) + assert SP._cut_sets(fs, [0., 0.], [0.], 100., '-', 'outward') is None + # Lemma R broken (a branch that is not the scaled parent): raises + side, a0, proof = root_of(*TWO_HOT_ONE_COLD) + branch = SP._branch_curve + monkeypatch.setattr(SP, '_branch_curve', lambda c, f, role='must': ( + branch(c, .5 * f, role))) + with pytest.raises(SP._SplitInvariantError): + SP._pinch_split(side, a0, proof, 'partner') + + +def test_pinch_split_on_the_corpus(monkeypatch): + """Every constant-CP SPLIT side with a rules proof: the branched side of + every rule that succeeds keeps the cascade and satisfies the pinch rules + at its pre-leaked root, and some rule succeeds. On a double pinch a + split can move the violation to the other tight cut.""" + n_sides = 0 + for case in CORPUS_SPLIT: + if case['kind'] != 'constant_cp': + continue + dT, rows = corpus_problem(case['name']) + for name, side in sides_from(rows, dT).items(): + z_m, z_f = [0.] * side.M, [0.] * side.F + proof = side.rules_violation(z_m, z_f, side.analyse(z_m, z_f)) + if proof is None: + continue + a0 = SP._preleak_root(side)[1] + ok = [] + for rule in CUT_RULES: + out = SP._pinch_split(side, a0, proof, rule) + if out is not None: + check_items(side, a0, *out) + ok.append(rule) + assert ok, (case['name'], name) + n_sides += 1 + assert n_sides == 32 + # nptel_t5_3 below: the outward split at the pinch breaks the inward + # rule at the second tight cut, and the rho rules and mincell never + # satisfy both; nw-exact-desc does at once, nw-exact-asc in three cuts + side, a0, proof = root_of( + *corpus_problem('nptel_t5_3_four_stream_split'), name='below') + assert [rule for rule in CUT_RULES if SP._pinch_split( + side, a0, proof, rule) is not None] == [ + 'nw-exact-desc', 'nw-exact-asc'] + # the same with CP2 = 6 - 1.58e-11: the pre-leaked root is branched too + side, a0, proof = root_of(*NEAR_DOUBLE_PINCH, name='below') + assert max(a0) > 0. and proof['rule'] == 'outward' + check_items(side, a0, *SP._pinch_split(side, a0, proof, 'nw-exact-asc')) + monkeypatch.setattr(SP, '_SPLIT_CUTS', 2) + assert SP._pinch_split(side, a0, proof, 'nw-exact-asc') is None + assert SP._pinch_split(side, a0, proof, 'nw-exact-desc')[1] == 2 + + +# %% 19. Stage S: pinch splits planned by the unchanged DFS + +def stage_s(side, a0, proof, cap1=False, forbid=frozenset(), Qmin=0., **kw): + """`_stage_s` with fresh errors and reasons.""" + errors, reasons = [], {} + split = dict(Qmin=Qmin, exclude={}, prefer={}) + cands, _ = SP._stage_s(side, a0, proof, cap1, forbid, 1., split, + errors, reasons, **kw) + return cands, errors, reasons + + +def test_stage_s_cells_fold_and_sweep(): + L = P._LevelCurve + # fold (Lemma F): a CP-3 flex in three branches serves two CP-1 musts + # from the pinch; the 0.1 branch is unused, so its fraction goes to + # the others in proportion and their parent positions shrink + musts = [L([0., 100.], [95., 195.], 0), L([0., 100.], [95., 195.], 1)] + flex = L([0., 300.], [95., 195.], 2, role='flex') + side = P._Side('above', musts, [flex], 1e-9, 1e-9) + items = [('must', 0, 1., None), ('must', 1, 1., None), + ('flex', 0, .5, ('S', 0, 0)), ('flex', 0, .4, ('S', 0, 1)), + ('flex', 0, .1, ('S', 0, 2))] + pieces = [(0, 0, 0., 0., 60.), (0, 0, 60., 60., 40.), + (1, 1, 0., 0., 100.)] + cells, why = SP._s_cells(side, items, [0., 0.], pieces) + assert why is None + assert [(c.i, c.j, c.x, c.a, c.b, c.f, c.km) for c in cells] == [ + (0, 0, 100., 0., 0., 1., None), (1, 0, 100., 0., 0., 1., None)] + assert [(c.g, c.kf) for c in cells] == [(.5 / .9, ('S', 0, 0)), + (.4 / .9, ('S', 0, 1))] + _verify_side_cells(side, cells) + # one used branch: a trunk again + one = P._Side('above', musts[:1], [flex], 1e-9, 1e-9) + cells, why = SP._s_cells(one, items[:1] + items[2:], [0.], pieces[:2]) + assert [(c.x, c.b, c.g, c.kf) for c in cells] == [(100., 0., 1., None)] + # the residual sweep: must A ends r short (r <= tolQ, the DFS's own + # tolerance); r goes to A's far-end cell, and the later cell of the + # same flex (B's) moves with it, so the flex stays a prefix + musts = [L([0., 50.], [95., 145.], 0), L([0., 100.], [95., 195.], 1)] + flexes = [L([0., 150.], [95., 195.], 2, role='flex'), + L([0., 150.], [95., 195.], 3, role='flex')] + side = P._Side('above', musts, flexes, 1e-9, 1e-9) + items = [('must', 0, 1., None), ('must', 1, 1., None), + ('flex', 0, 1., None), ('flex', 1, 1., None)] + for r in (.5e-9, -.5e-9): + pieces = [(0, 0, 0., 0., 50. - r), (1, 1, 0., 0., 60.), + (1, 0, 60., 50. - r, 40.)] + cells, why = SP._s_cells(side, items, [0., 0.], pieces) + assert why is None + assert abs(cells[0].x - 50.) <= 1e-13 + assert cells[2].b == cells[0].b_end and cells[2].x == 40. + _verify_side_cells(side, cells) + # beyond tolQ, or a must with no cell: rejected + pieces = [(0, 0, 0., 0., 50. - 2e-9), (1, 1, 0., 0., 60.), + (1, 0, 60., 50. - 2e-9, 40.)] + assert SP._s_cells(side, items, [0., 0.], pieces) == (None, 'sweep') + assert SP._s_cells(side, items, [0., 0.], pieces[:1]) == (None, + 'sweep') + + +def test_stage_s_candidates_are_verified(): + """Every Stage S candidate on the TP split sides, the number and CP + rule cases and the nptel_t5_3 double pinch: exact cells from the + pre-leaked root, isothermal must remixes, no bad remix, the rule's + order; the reasons name every rule.""" + problems = [SPLIT[n] for n in sorted(SPLIT)] + [ + TWO_HOT_ONE_COLD, ONE_HOT_TWO_COLD, NEAR_DOUBLE_PINCH, + corpus_problem('nptel_t5_3_four_stream_split')] + n = 0 + for dT, rows in problems: + for name, side in sides_from(rows, dT).items(): + z_m, z_f = [0.] * side.M, [0.] * side.F + proof = side.rules_violation(z_m, z_f, side.analyse(z_m, z_f)) + if proof is None: + continue + a0 = SP._preleak_root(side)[1] + cands, errors, reasons = stage_s(side, a0, proof) + assert errors == [] and cands and set(reasons) == set(S_NAMES) + for c in cands: + assert c.order == (0, S_NAMES.index(c.name)) + assert reasons[c.name] == c.key() and not c.excluded + assert c.leak_by_must == [0.] * side.M and c.mixbad == 0 + _verify_side_cells(side, c.cells, a0) + merged = SP._merge_cells(c.cells, side.tolQ) + for (role, _, _), br in SP._stages(merged).items(): + assert role == 'f' or SP._remix(role, br, side.tolQ)[2] + n += 1 + assert n >= 10 + + +def test_stage_s_budget(monkeypatch): + # the rules share one budget: a rule not reached is 'budget', and the + # core still serves the side + monkeypatch.setattr(SP, '_SPLIT_S_WORK', 1.) + dT, rows = SPLIT['smith2005_exr18_4'] + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, **SPLIT_ON) + assert net['status'] == 'mer' + check_split_network(streams, dT, net) + (_, sp), = split_infos(net['plan']).items() + assert sp['candidate'] in SP._CORE_STRATEGIES + reasons = [sp['candidates'][n] for n in S_NAMES] + assert 'budget' in reasons + assert set(reasons) <= {'budget', 'no split', 'same split'} + + +def test_split_first_wins_is_deterministic(monkeypatch): + monkeypatch.setattr(SP, '_SPLIT_FIRST_WINS', True) + for dT, rows in [SPLIT[n] for n in sorted(SPLIT)] + [NEAR_DOUBLE_PINCH]: + streams = streams_from(rows) + one = P._plan_numeric(streams, dT, **SPLIT_ON) + two = P._plan_numeric(streams, dT, **SPLIT_ON) + assert one['status'] == 'mer' + check_split_network(streams, dT, one) + assert fingerprint(one) == fingerprint(two) + infos = split_infos(one['plan']) + assert infos and infos == split_infos(two['plan']) + for sp in infos.values(): # the first live candidate wins + live = [n for n, k in sp['candidates'].items() + if isinstance(k, tuple)] + assert live == [sp['candidate']] + + +def test_splitting_with_avoid_recycle_never_repeats_a_pair(monkeypatch): + monkeypatch.setattr(P, '_BE_WORK', 300.) # keep best effort short + monkeypatch.setattr(SP, '_SPLIT_S_WORK', 3000.) + rng = random.Random(31) + cases = [SPLIT[n] for n in sorted(SPLIT)] + while len(cases) < 20: + rows, dT = pinch_problem(rng) + if needs_split(streams_from(rows), dT): + cases.append((dT, rows)) + n_mer = n_split = n_rep = 0 + for dT, rows in cases: + streams = streams_from(rows) + net = P._plan_numeric(streams, dT, avoid_recycle=True, **SPLIT_ON) + plan = net['plan'] + assert repeated_pairs(net) == [] # not even across the pinch + assert net['status'] in ('mer', 'best_effort') + check_split_network(streams, dT, net, mer=net['status'] == 'mer') + n_mer += net['status'] == 'mer' + n_split += bool(plan.splits) + for sp in split_infos(plan).values(): + assert sp['errors'] == [] + n_rep += 'repeated pair' in sp['candidates'].values() + # candidates repeating a pair were generated and rejected + assert n_mer >= 6 and n_split >= 10 and n_rep >= 2 + + +def test_split_side_keeps_cells_below_Qmin(): + # Qmin never drops a split cell: a split side's exchangers below Qmin count in the key but are + # kept and reported (Qmin never costs MER); an unsplit side drops them + # as today + dT, rows = SPLIT['smith2005_exr18_4'] + streams = streams_from(rows) + Qmin = 3.5 # above two split cells (2.2, 2.4) and an unsplit one (3.3) + net = P._plan_numeric(streams, dT, Qmin=Qmin, **SPLIT_ON) + plan = net['plan'] + above, below = plan.info['sides']['above'], plan.info['sides']['below'] + sp = above['split'] + assert above['status'] == below['status'] == 'mer' + assert sp['small'] and all(q < Qmin for *_, q in sp['small']) + # every exchanger of the split side is in the records, small ones too + recs = [e for e in plan.exchangers if e.side == 'above'] + assert len(recs) == above['units'] + assert sorted((h, c, q) for h, c, q in sp['small']) == sorted( + (e.hot, e.cold, e.Q) for e in recs if e.Q < Qmin) + # the unsplit side's small exchanger is dropped, as without splitting + (side, h, c, q), = plan.info['qmin_dropped'] + off = P._plan_numeric(streams, dT, Qmin=Qmin)['plan'].info + assert side == 'below' and [d for d in off['qmin_dropped'] + if d[0] == side] == [(side, h, c, q)] + assert q == pytest.approx(3.304, abs=1e-3) + assert net['status'] == 'best_effort' # the drop raises the utility + check_split_network(streams, dT, net, mer=False) + + +def test_stage_s_unit_bound_and_improvement(monkeypatch): + def best(case, name): + side, a0, proof = root_of(*corpus_problem(case), name=name) + cands, errors, reasons = stage_s(side, a0, proof) + assert errors == [] + return min(cands, key=SP._Candidate.key), reasons + # the unit bound prunes the rules that cannot beat the incumbent + # ('bound') and changes nothing else + b, reasons = best('nptel_t4_4_four_stream_dT20', 'below') + assert 'bound' in reasons.values() + search = SP._s_search + with monkeypatch.context() as m: + m.setattr(SP, '_s_search', lambda *a: search(*a[:5], math.inf, a[6])) + b0, r0 = best('nptel_t4_4_four_stream_dT20', 'below') + assert 'bound' not in r0.values() + assert (b0.name, b0.key(), b0.signature) == (b.name, b.key(), b.signature) + # the winner's unit improvement: 7sp3 above, 8 -> 7 (units + extra + # branches + split stages) + b, _ = best('7sp3_dt20F', 'above') + with monkeypatch.context() as m: + m.setattr(SP, '_improve_units', lambda side, pieces, *a, **k: ( + pieces, 0.)) + b0, _ = best('7sp3_dt20F', 'above') + assert (b0.key()[3], b.key()[3]) == (8, 7) and b.name == b0.name + + +# %% 20. Core pinch blocks and DFS tails (hensmith._splitting) + +def test_search_b0_default_is_identical(monkeypatch): + # _Search gains the flex frontiers b0 (and _improve_units/_units_guard + # pass a0 and b0 through): at the default every search is today's, + # and from a mid-plan node (a, b) the search plans the rest from there + rng = random.Random(71) + n_mid = 0 + spy_case = None + for k in range(40): + rows, dT = pinch_problem(rng) if k % 2 else random_problem(rng) + for side in sides_from(rows, dT).values(): + if not (side.M and side.F): + continue + z_f = [0.] * side.F + runs = [P._Search(side, 'restricted', 3, False, 3000., **kw).run() + for kw in ({}, dict(b0=None), dict(b0=z_f))] + assert runs[0] == runs[1] == runs[2] and z_f == [0.] * side.F + pieces = runs[0] + if not pieces: + continue + if spy_case is None: + spy_case = side, pieces + args = (side, pieces, 100., False, frozenset(), 1.) + assert (P._improve_units(*args) == P._improve_units( + *args, a0=None, b0=None)) + # the node after the first piece + i, j, _, _, x = pieces[0] + a, b = [0.] * side.M, list(z_f) + a[i], b[j] = min(x, side.Qm[i]), min(x, side.Qf[j]) + rest = P._Search(side, 'full', None, True, 3000., a0=a, + b0=b).run() + if rest is None: + continue + n_mid += 1 + front = list(b) + for i2, j2, a2, b2, x2 in rest: # every flex a prefix from b + assert b2 == front[j2] and x2 > 0. + front[j2] = min(b2 + x2, side.Qf[j2]) + for i2 in range(side.M): # every must served from a + duty = math.fsum(p[4] for p in rest if p[0] == i2) + assert abs(a[i2] + duty - side.Qm[i2]) <= side.tolQ + assert n_mid >= 20 + # the unit searches start from (a0, b0) + seen = [] + + class Spy(P._Search): + def __init__(self, *args, **kw): + seen.append((kw.get('a0'), kw.get('b0'))) + super().__init__(*args, **kw) + monkeypatch.setattr(P, '_Search', Spy) + monkeypatch.setattr(P, '_GUARD_FACTOR', 0) # run the guard + side, pieces = spy_case # frontiers that differ from each other + a, b = [0.25 * q for q in side.Qm], [0.1 * q for q in side.Qf] + P._improve_units(side, pieces, 100., False, frozenset(), 1., a0=a, b0=b) + P._units_guard(side, pieces, False, frozenset(), 1., a0=a, b0=b) + assert seen and all(s == (a, b) for s in seen) + + +def pinch_side(m2, cp_flex, Q_flex): + """A side 'above' (tolerances 1e-9): musts m0 and m1 (CP 1, levels 0 to + 10) at the tight root level 0, a third must `m2` = (heat, lowest level, + highest level) above it, and one flex over levels 0 to `Q_flex/cp_flex`. + The rules fail outward at level 0 (two musts, one flex).""" + L = P._LevelCurve + Q2, lo, hi = m2 + return P._Side('above', [L([0., 10.], [0., 10.], 0), + L([0., 10.], [0., 10.], 1), + L([0., Q2], [lo, hi], 2)], + [L([0., Q_flex], [0., Q_flex / cp_flex], 3)], 1e-9, 1e-9) + + +def check_pinch_block(side, blk, a, b): + """A pinch block from (a, b): exact cells, isothermal must branches, + CP-based flex fractions (each branch of CP at least its must branch's), + and (R) at its end node within `_SPLIT_R_TOL tolQ`.""" + lim = -SP._SPLIT_R_TOL * side.tolQ + assert blk.kind == 'pinch' and blk.start == (a, b) + v = side.rules_violation(a, b, side.analyse(a, b)) + assert v['rule'] == 'outward' + dem, cap = map(dict, SP._cut_sets(side, a, b, v['level'], v['cut'], + 'outward')) + a2, b2 = blk.end + assert SP._exact(side).analyse(a2, b2).slack >= lim + assert residual_slack(side, a2, b2) >= lim + # tick-off: a must ends exactly at its end or more than tolQ short + assert all(x == q or x < q - side.tolQ for x, q in zip(a2, side.Qm)) + fg = Counter() + for c in blk.cells: + assert c.a == a[c.i] and c.b == b[c.j] and c.x > 0. + assert SP._cell_margin(side, c)[0] >= -side.tolP + assert abs(c.a_end - a2[c.i]) <= side.tolQ # isothermal + assert c.g * cap[c.j] >= c.f * dem[c.i] * (1. - 1e-12) + fg['m', c.i] += c.f + fg['f', c.j] += c.g + assert all(abs(s - 1.) <= 1e-12 for s in fg.values()) + for j in range(side.F): + duty = math.fsum(c.x for c in blk.cells if c.j == j) + assert abs(b[j] + duty - b2[j]) <= side.tolQ + + +TICK_OFF = (10., [ # H1 splits 0.7/0.3 and (f 400)/f rounds to 400 - 6e-14: + # without the tick-off, H1 would stop 6e-14 short of its end + ('H1', 'h', 200, 100, 4.), ('C1', 'c', 90, 190, 3.5), + ('C2', 'c', 90, 190, 1.5)]) + + +def test_pinch_block_acceptance(): + lim = -SP._SPLIT_R_TOL * 1e-9 + # the number rule (two hot, one cold) and the CP rule (one hot, two + # cold): lambda = 1 and every must ticks off, exactly + for dT, rows in (TWO_HOT_ONE_COLD, ONE_HOT_TWO_COLD, TICK_OFF): + side = sides_from(rows, dT)['above'] + z_m, z_f = [0.] * side.M, [0.] * side.F + blk = SP._pinch_block(side, z_m, z_f) + check_pinch_block(side, blk, z_m, z_f) + assert blk.end[0] == side.Qm # tick-off: exactly the must ends + assert len(blk.cells) == 2 and blk.leak == {} + assert SP._pinch_block(side, z_m, z_f, forbid={(0, 0)}) is None + assert SP._pinch_block(side, z_m, z_f, used={(0, 0)}) is None + # no pinch block where the rules do not fail outward + assert SP._pinch_block(side, z_m, z_f, viol=dict( + rule='inward', level=0., cut='+')) is None + # lambda < 1: at lambda = 1 the flex heat left above level 40/7 misses + # m2 (levels 5 to 7), so (R) fails; the largest lambda is 0.875 + side = pinch_side((5., 5., 7.), 3.5, 70.) + z_m, z_f = [0.] * 3, [0.] + assert SP._exact(side).analyse([10., 10., 0.], [20.]).slack < lim + blk = SP._pinch_block(side, z_m, z_f) + check_pinch_block(side, blk, z_m, z_f) + lam = blk.end[0][0] / 10. + assert blk.end[0][1] == blk.end[0][0] and blk.end[0][2] == 0. + assert abs(lam - 0.875) <= 1e-8 + assert [(c.f, c.g) for c in blk.cells] == [(1., .5), (1., .5)] + # below _SPLIT_LAMBDA_MIN: m2 starts 1e-6 above the cut, so any lambda + # above 2.5e-7 strands it; no block, and LV falls back to V there + side = pinch_side((5., 1e-6, 2.), 5., 100.) + assert side.rules_violation(z_m, z_f, side.analyse(z_m, z_f))[ + 'rule'] == 'outward' + assert SP._pinch_block(side, z_m, z_f) is None + cand = SP._drive(side, z_m, 'LV') + verify_core(side, cand, z_m) + assert {blk.kind for blk in cand.blocks} == {'vertical'} + + +def test_tail_sweeps_the_residual(): + # the DFS closes must 0 on flex 0, which is 5e-10 (tolQ/2) short: the + # tail sweeps the residual into the cell, so the must is served exactly + L = P._LevelCurve + side = P._Side('above', [L([0., 10.], [0., 10.], 0)], + [L([0., 10. - 5e-10], [0., 5.], 1), + L([0., 10.], [5., 15.], 2)], 1e-9, 1e-9) + blk, work = SP._tail(side, [0.], [0., 0.]) + assert work > 0. and blk.kind == 'tail' and blk.start == ([0.], [0., 0.]) + (c,) = blk.cells + assert (c.i, c.j, c.x, c.a, c.b, c.f, c.g) == (0, 0, 10., 0., 0., 1., 1.) + assert SP._cell_margin(side, c)[0] >= 0. + assert blk.end == ([10.], [10. - 5e-10, 0.]) + # a tail from a node where the search fails returns None + blk, work = SP._tail(side, [0.], [10. - 5e-10, 0.]) + assert blk is None and work > 0. + + +CLEAN_PRELEAK = (10., [ # C1 ends 5e-8 K above H1 (12.5 tolQ): _cascade sets + # Qh = 0, the rules fail at the root and hold at the pre-leaked root + ('H1', 'h', 200, 100, 1.), ('H2', 'h', 150, 100, 2.), + ('C1', 'c', 90, 190 + 5e-8, 1.), ('C2', 'c', 90, 140, 2.)]) + + +def core_problem_sides(): + """Split sides of the SPLIT dict, the merged-breakpoint case, five + constant-CP corpus cases and the near-threshold, near-double-pinch and + clean pre-leaked cases (pre-leaked roots).""" + sides = split_sides() + for name in ('8sp1_dt20F', 'smith2005_exr18_5_nine_stream', + 'crude_fractionation_ph11c2', 'fs_22sp_ph', + 'cgm_unbalanced10'): + dT, rows = corpus_problem(name) + sides += [s for s in sides_from(rows, dT).values() if s.M and s.F] + for dT, rows in (NEAR_THRESHOLD, NEAR_DOUBLE_PINCH, CLEAN_PRELEAK): + sides += [s for s in sides_from(rows, dT).values() if s.M and s.F] + return sides + + +def test_core_strategies_reach_mer(): + # every core strategy is MER and exact from the pre-leaked root: pinch + # blocks (L) only where the rules fail outward, at most M + F of them; + # a DFS tail (T) only at a rule-clean node other than the unleaked + # root, and always last + rng = random.Random(72) + sides = core_problem_sides() + core_sides(rng)[::6] + size = Counter() + n_pinch = n_tail = n_part = n_root_tail = 0 + for side in sides: + delta, a0 = SP._preleak_root(side) + if delta > SP._preleak_max(side): + continue + cands = {} + for strategy in SP._CORE_STRATEGIES: + cand = cands[strategy] = SP._drive(side, a0, strategy) + verify_core(side, cand, a0) + assert cand.name == strategy and cand.meta['leak'] == 0. + kinds = [blk.kind for blk in cand.blocks] + assert 'pinch' not in kinds or 'L' in strategy + assert 'tail' not in kinds[:-1] + assert 'tail' not in kinds or 'T' in strategy + assert kinds.count('pinch') <= side.M + side.F + for blk in cand.blocks: + a, b = blk.start + v = side.rules_violation(a, b, side.analyse(a, b)) + if blk.kind == 'pinch': + check_pinch_block(side, blk, a, b) + # a must that stops short of its end + n_part += any(0. < x - a0x < q - a0x - side.tolQ + for x, a0x, q in zip(blk.end[0], a, + side.Qm)) + elif blk.kind == 'tail': + assert v is None and (blk is not cand.blocks[0] + or any(a0)) + assert all(c.f == c.g == 1. and c.km is c.kf is None + for c in blk.cells) + n_root_tail += blk is cand.blocks[0] + n_pinch += 'pinch' in kinds + n_tail += 'tail' in kinds + for s, c in cands.items(): + size[s] += c.key()[3] + # where no strategy builds a pinch block or a tail, all are V + if all(blk.kind == 'vertical' for c in cands.values() + for blk in c.blocks): + assert len({c.signature for c in cands.values()}) == 1 + assert n_pinch >= 20 and n_tail >= 20 and n_part >= 10 + assert n_root_tail >= 1 + # pinch blocks and tails shrink the networks (units + extra branches + # + split stages, summed over the sides) + assert size['LVT'] < size['LV'] < size['V'] and size['VT'] < size['V'] diff --git a/tests/test_hxn_regression.py b/tests/test_hxn_regression.py index c392494..c501a42 100644 --- a/tests/test_hxn_regression.py +++ b/tests/test_hxn_regression.py @@ -56,6 +56,20 @@ 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). + +``test_hxn_regression_with_splitting`` synthesizes every case again with +``stream_splitting=True`` and holds it to the same checks (i)-(v). Cases +4, 8 and 9 (``SPLIT_CASES``) must then reach their MER targets with split +streams, held to the standard of the split corpus of ``test_hxn_mer`` +rather than to the looser 'mer' status of (ii): the strict split-aware +checks G0-G10 (splitters, mixers at equilibrium, stream closure and +wiring; ``_network_problems``), a synthesis report with nothing repaired, +dropped or off its planned state and every split side free of leaks and +pre-leaks (``_split_report_problems``), and both utilities within +``MER_TOL`` of the targets (1e-10 of the total stream duty; at most +1.75e-14 measured, case 9's cooling). Every other case must give, bit for +bit, the network it gives without the option (the same exchangers and +duties, refine rounds and repairs). """ import warnings import pytest @@ -65,6 +79,9 @@ 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 +# the split corpus's checks, for the cases whose MER needs splits +from test_hxn_mer import (_network_problems, _network_record, _split_report_problems, + MER_TOL) EB_TOLERANCE = 1e-6 # percent; converged networks close to ~1e-10 % MER_RTOL = 1e-9 # network may not beat the MER target by more than this x total duty @@ -201,13 +218,17 @@ def case_10_ten_streams(): 'case_10_ten_streams': (case_10_ten_streams, 1.44713e+07, 8.46199e+06), } +#: the cases whose MER needs stream splits (see the module docstring) +SPLIT_CASES = ('case_04_report_case', 'case_08_two_condensers', 'case_09_near_degenerate') + # --------------------------------------------------------------------------- # Harness # --------------------------------------------------------------------------- -def synthesize(builder): +def synthesize(builder, stream_splitting=False): units, T_min_app = builder() - HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app) + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app, + stream_splitting=stream_splitting) sys = bst.System.from_units('sys', units=[*units, HXN]) with warnings.catch_warnings(): warnings.simplefilter('error', RuntimeWarning) @@ -250,10 +271,9 @@ def internal_approach(hx): 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) +def check_network(name, units, HXN, T_min_app): + """Checks (i)-(v) of the module docstring on the simulated network.""" + _, doc_heat, doc_cool = CASES[name] 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 @@ -287,6 +307,42 @@ def test_hxn_regression(name): assert heat <= doc_heat * (1 + DOC_RTOL) + atol, (name, heat, doc_heat) assert cool <= doc_cool * (1 + DOC_RTOL) + atol, (name, cool, doc_cool) +@pytest.mark.parametrize('name', list(CASES)) +def test_hxn_regression(name): + builder = CASES[name][0] + check_network(name, *synthesize(builder)) + +@pytest.mark.parametrize('name', list(CASES)) +def test_hxn_regression_with_splitting(name): + # with stream splitting, every case passes the same checks; the cases + # whose MER needs splits reach it with them, and every other case gives + # the network synthesized without the option, bit for bit + builder = CASES[name][0] + units, HXN, T_min_app = synthesize(builder, stream_splitting=True) + check_network(name, units, HXN, T_min_app) + info = HXN.synthesis_info + if name in SPLIT_CASES: + # held to the standard of the split corpus (test_hxn_mer): the + # strict split-aware checks G0-G10, a synthesis report with status + # 'mer', splits, and nothing repaired, dropped or off its planned + # state, and the utilities at the MER targets within MER_TOL + net = _network_record(units, HXN, T_min_app, True) + problems = _network_problems(net) + _split_report_problems(HXN, T_min_app) + atol = MER_TOL['real_thermo'] * net['total'] + for label, got, target in zip(('heating', 'cooling'), actual_loads(HXN), + mer_targets(units, T_min_app)): + if not abs(got - target) <= atol: + problems.append(f'{label} {got!r} != target {target!r}') + assert not problems, '\n'.join([name, *problems]) + return + default = synthesize(builder)[1] + assert info['status'] == default.synthesis_info['status'] == 'mer', name + assert info['splits'] == HXN.new_splitters == HXN.new_mixers == [], name + assert ([(hx.ID, float(hx.Q).hex()) for hx in HXN.new_HXs] + == [(hx.ID, float(hx.Q).hex()) for hx in default.new_HXs]), name + for key in ('refine_rounds', 'repaired'): + assert info[key] == default.synthesis_info[key], (name, key) + if __name__ == '__main__': for name, (builder, *_) in CASES.items(): units, HXN, T_min_app = synthesize(builder)