From a5fdd313b499b90f260d24273ceb0d09db5f30e9 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Wed, 23 Sep 2026 22:07:01 -0700 Subject: [PATCH 01/24] add stream-splitting primitives: branch curves, cells, vertical coupling, transport First step (T1) of optional stream splitting in the MER planner: the new private module hensmith/_splitting.py with the primitives the later split planner builds on. Nothing calls it yet, so every existing result is unchanged (R1 oracle below). - _branch_curve (Lemma B): a branch of flow fraction f is the parent's level curve with every heat times f (same levels, slopes / f, flats kept). - _Cell: one exchanger in parent coordinates (duty x; must branch of fraction f over [a, a + x/f], flex branch of fraction g over [b, b + x/g]; stage keys). - _cell_margin (Lemma 1): the approach at tau = 0, tau = x and every knot of both curves inside their ranges, which decides feasibility exactly on piecewise-linear curves. A must knot is evaluated at its own position (the flex at the matching tau) and vice versa, so no knot level is perturbed by the change of coordinates. `touch` flags a cell that runs parallel at the minimum approach along a segment. - _Coupling: the vertical (composite-quantile) coupling at a node, with closed-form must and flex positions from side.analyse's residual arrays. Breakpoints are merged, and positions snapped to the curve ends, at round-off only (_SPLIT_ULP * max(X, 1), 8 eps), never at tolQ: tolQ of heat can hide a knot tolQ/CP away in level, far more than tolP (the 'merged breakpoint' case: flex B starts 4e-7 K above the pinch, 1.2e-6 < tolQ = 2e-6 of heat away; its breakpoint is now kept). The position formulas use a strict flat test (t < Di[k]) so both flat and sloped parts return the residual arrays exactly at their ends. must_at/flex_at evaluate the same closed form anywhere in [0, X] (for the pre-leaked root P(delta)). - _transport: greedy (continuations first, then north-west by level), Edmonds-Karp max flow when the greedy is stuck, cycle cancelling to a forest (at most m + f - 1 cells), exact row sums (the largest cell of a row takes the row heat minus the others). The greedy needs no cancelling: every assignment exhausts its row or column, so its cells never close a cycle. Adjacency lists (not sets of string-tagged tuples, as in the prototype) keep the result independent of PYTHONHASHSEED. Refinements over the design, each for robustness: - float-empty folding folds an interval over which no *must* position changes (a superset of "no position changes"): such an interval carries no must heat, so a block over it would be empty; its flex movement is round-off, or the <= tolQ residual of a must too small for side.analyse's composite, and joins the next interval. - touch uses |approach| <= tolP (identical for any feasible cell) and a segment longer than tolQ of duty (not merely > 0), so near-coincident knots of two curves cannot fake a parallel run. Tests (tests/test_hxn_planner.py, written first and seen failing with NotImplementedError): test_branch_curve_is_the_scaled_parent, test_splitting_preserves_the_residual_cascade (Lemma R: the branched root has the same levels and composites, and any branched node the same slack), test_cell_margin_matches_scaled_max_duty (500 random cells with flats: margin >= -tolP iff _max_duty on branch-scaled curves, and equal to a direct evaluation), test_cell_margin_touch, test_vertical_coupling_positions (SPLIT and merged-breakpoint roots plus random (R) nodes: monotone, P(X) == Qm exactly, sum(P - a) == t and sum(R - b) == t within 1e-12 scale, no breakpoint merged beyond round-off, B's breakpoint kept), test_transport_forest (NW, continuations, forbid, the stuck-greedy case, infeasibility, 300 random instances: exact rows, forest, compatibility and forbid respected). A scratch probe confirmed the tests exercise the max-flow fallback (65 times) and cycle cancelling (8 cycles) and catch four mutations (tolQ merging, no forest, no max-flow, end-points-only margin). Validation: - full suite (CI invocation): 473 passed in 205.50s (467 before + 6 new). - ast.parse(feature_version=(3, 12)) on both changed files. - R1 oracle, planner mode: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 573.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 557 ++++++++++++++++++++++++++++++++++++++ tests/test_hxn_planner.py | 277 +++++++++++++++++++ 2 files changed, 834 insertions(+) create mode 100644 hensmith/_splitting.py diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py new file mode 100644 index 0000000..064b600 --- /dev/null +++ b/hensmith/_splitting.py @@ -0,0 +1,557 @@ +# -*- 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 the pinch design rules prove that a side of the pinch has no minimum +energy requirement (MER) network without stream splits, the planner can +split 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. It is equivalent to the +planner's ``_max_duty`` on branch-scaled curves; every cell of a split plan +is verified this way before the plan is accepted. + +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). +""" +import math +from collections import deque + +import numpy as np + +from ._planner import _LevelCurve + +__all__ = () + +_SPLIT_ULP = 8. * np.finfo(float).eps # round-off, relative to max(X, 1) + + +# %% 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 + + +# %% 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() + + +# %% 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 diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 1b997d9..6fdce5b 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -22,6 +22,7 @@ from numpy.testing import assert_allclose from hensmith import _planner as P +from hensmith import _splitting as SP # %% Independent helpers @@ -947,3 +948,279 @@ 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) + curves = P._stream_curves( + [([min(s['T_in'], s['T_out']), max(s['T_in'], s['T_out'])], + [0., s['CP'] * abs(s['T_in'] - s['T_out'])]) for s in streams], + [s['kind'] == 'hot' for s in streams], dT) + 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) From 3b2e0ce5416856a441b2deeab13701490ae0329b Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Wed, 23 Sep 2026 22:57:03 -0700 Subject: [PATCH 02/24] add the vertical core of stream splitting: blocks, pre-leak, driver, candidates Second step (T2) of optional stream splitting in the MER planner: the vertical core of the provable backstop in hensmith/_splitting.py. The planner does not call it yet, so every existing result is unchanged (R1 oracle below). - _vertical_block (Theorem V', Corollary C). From a node that satisfies (R), the block between two consecutive coupling breakpoints, with must heats P(t_e) - P(t_s), flex heats R(t_e) - R(t_s) and any transport between them, is feasible, and the node after it satisfies (R). Under _SPLIT_COARSEN it tries the block to X first, then gallops (2, 4, 8 intervals) and bisects. Feasibility is not monotone in t_e, so 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 still fails (C), recovery tries, in order: insert a composite breakpoint hidden inside the interval; attach the failing must heat to the must's series cell in the previous block, if that re-verifies; leak it to the must's utility if it is at most _SPLIT_R_TOL tolQ (recorded per must); otherwise raise _SplitInvariantError. A block that neither advances a must nor books a leak raises too. - Node discipline. A chain of vertical blocks runs on the breakpoints of one coupling: a vertical block hands its coupling to the next one. Every node is that coupling's closed-form positions, never the start plus the duties, so round-off never accumulates. The column round-off of a transport is balanced on the largest flex, and anything above max(_SPLIT_R_TOL tolQ, 4 (M+F) ulp) raises. - _exact: the core analyses its nodes with no heat tolerance. The search's _Side.analyse omits a stream's last tolQ of residual heat. In the constructed case of test_core_node_check_is_exact, it reports a slack of -0.5 tolQ at a node whose exact slack is 0, so the (R) check (-_SPLIT_R_TOL tolQ) would raise spuriously there. - _preleak_root and _preleak_max (Lemma P). _cascade's own tolerances can leave a root slack of up to -(_THRESHOLD_TOL/_REL_Q + 0.1) tolQ. The core starts from P(delta), delta = -slack, where (R) holds exactly. - _drive runs strategy 'V' from the pre-leaked root. It checks (R) at every node (raising, never asserting), sets the stage keys ('B', block, stream, branch) and returns a _Candidate. - _Candidate verifies every merged exchanger with _cell_margin and holds the key (small, mixbad, touch, units + extra branches + split stages, most mixers, order), the info meta and the signature. The signature is knot-independent, with canonical branch ordinals. _merge_cells merges series continuations. Deviations from the design: - Coupling reuse replaces the design's per-node coupling. Re-analysing at every node adds each node's frontier levels as breakpoints; these are not composite knots, and they can subdivide an interval geometrically. - Couplings and node checks use _exact instead of side.analyse. - The missed-knot rule inserts the first composite breakpoint value (De/Di/Se/Si) strictly inside the interval that has must heat on both sides of it. The design's rule looks for a curve knot strictly inside the interval, and that misses a flex's start (the merged-breakpoint case). - For now _CORE_STRATEGIES = ('V',). _CORE_ORDER fixes the tie-break order V, LV, VT, LVT. - Additive signatures: _drive(..., Qmin=0.), _merge_cells(cells, tolQ), and extra _Block slots (cp, k, span, leak, knots, work). Tests (test_hxn_planner.py, section 16), written first: - test_theorem_v_random: elementary V from the (pre-leaked) root on 367 sides. They come from 200 constant-CP problems, 50 point-load problems, 50 problems jittered by 3e-8..1e-6 K (a far 1e4-CP pair sets tolQ/CP above the jitter), the merged-breakpoint case and the two near-tie cases. Every side passes _verify_side_cells with (R) after every block, no leak and no inserted knot. - test_vertical_core_on_split_sides (coarsened and elementary). - test_vertical_core_from_the_preleaked_root: on the near-threshold and near-double-pinch cases, V fails from the root and is exact from P(delta). - test_coarsened_blocks_end_at_vertical_nodes. - test_core_node_check_is_exact. - test_vertical_block_recovery: missed knot, leak, attach and raise. - test_vertical_block_forbid_and_used. - test_candidate_key_and_signature. Scratch check (knots.pkl), on the synthesizer's round-0 knots, with _SPLIT_COARSEN off: rtB07 above gives 276 blocks and 1064 units, every node at exact slack 0 (and by direct evaluation), leak 0, knots 0, in 0.22 s. Coarsened, it gives 3 blocks and 12 units. On all 52 root-proof sides of the 38 SPLIT cases, both modes pass verify_core, in 0.63 s total coarsened and 1.58 s elementary. The coarsened unit counts match the prototype's V column (for example luo below 49 with 34/80 and nptel_t5_3 below 4 with 2/4). Validation: full suite 482 passed in 283 s (T1: 473 passed in 206 s). The new tests take about 7 s, so most of the difference is run-to-run variance. py3.12 syntax checked with ast.parse(feature_version=(3, 12)). R1 oracle (planner): 78 records, sha256 7307a56778e56268, 744.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 774 +++++++++++++++++++++++++++++++++++++- tests/test_hxn_planner.py | 366 +++++++++++++++++- 2 files changed, 1137 insertions(+), 3 deletions(-) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 064b600..a0b6356 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -81,17 +81,87 @@ 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). + +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`. + +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, so it +identifies the same network across knot refinements and generators. """ import math -from collections import deque +from collections import Counter, defaultdict, deque import numpy as np -from ._planner import _LevelCurve +from ._planner import (_LevelCurve, _REL_Q, _Side, _THRESHOLD_TOL, + _WORK_EVENT) __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 +_CORE_ORDER = ('V', 'LV', 'VT', 'LVT') # candidate order of the core +_CORE_STRATEGIES = ('V',) # the core strategies `_drive` runs # %% Cells and branch curves @@ -363,6 +433,21 @@ def flex_at(self, t): 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 @@ -555,3 +640,688 @@ def link(c, on): 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. + """ + + +def _exact(side): + """ + `side` with no heat tolerance in its residual arrays. + + `_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. + """ + return _Side(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'} + How the block was built. + 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 + no transport exists or, for a `coarse` block, if the block does not + verify or has a fraction below `_SPLIT_MIN_FRACTION`. + """ + 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)) + 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: + return None + 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 no transport exists + (forbidden pairs only).""" + 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 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 _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. + """ + 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 + while any(x < q for x, q in zip(a, side.Qm)): + d = ex.analyse(a, b) + if d.slack < lim: + raise _SplitInvariantError( + f'{strategy}: core node (R): slack {d.slack!r} after ' + f'{len(blocks)} blocks') + 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, a0, + [c for blk in blocks for c in blk.cells], leak, + math.fsum(blk.work for blk in blocks), 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): + """Knot-independent identity of a split network (see `_Candidate`).""" + 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)) + + +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. + a0 : sequence[float] + The pre-leaked root. + 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's always is), 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, so 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', 'a0', 'leak_by_must', 'work', + 'blocks', 'excluded', 'units', 'stages', 'branches', + 'mixers', 'small', 'mixbad', 'touch', 'meta', 'signature') + + def __init__(self, name, order, side, a0, cells, leak_by_must=None, + work=0., Qmin=0., blocks=()): + self.name, self.order = name, order + self.cells = list(cells) + self.a0 = [float(x) for x in a0] + 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'{name}: {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, _, iso = _remix(role, br, tolQ) + if iso: + continue + inside = {id(c) for cs in br.values() for c in cs} + mixbad += role == 'm' or any( + c.j == s and id(c) not in inside 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 - self.stages) + self.stages, + self.mixers, self.order) + + def __repr__(self): + return f'<_Candidate {self.name} key={self.key()}>' diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 6fdce5b..924fe81 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -963,10 +963,16 @@ def sides_from(rows, dT): """The planner's sides of a constant-CP problem, built as in `plan_network`.""" streams = streams_from(rows) - curves = P._stream_curves( + 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) @@ -1224,3 +1230,361 @@ def test_transport_forest(): 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 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 + assert P._max_duty(bm, am, bf, bb, c.x, tolP) >= 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] + start = min(p[0] for p in pos) + if key[0] != 'trunk': + 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[0][0] - start) <= tolQ + for p, p2 in zip(ps, ps[1:]): + assert abs(p2[0] - p[1]) <= tolQ + spans.append((start, math.fsum(c.x for c in cs))) + 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 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 + + +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 + for s in split_sides(): + cand = SP._drive(s, [0.] * s.M, 'V', cap1=True) + if cand is not None: + 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) + + +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, [0., 0.], 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 + # 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, [0.], 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, [0.], par).touch == 1 + # a cell that fails (C) is never accepted + with pytest.raises(SP._SplitInvariantError): + SP._Candidate('V', (1, 0), side, [0.], [B(0, 0, 5., 0., 1.)]) From 8da98f719e1c081a0b3c94b465b9af0a2eb43b39 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Wed, 23 Sep 2026 23:40:33 -0700 Subject: [PATCH 03/24] address inspection of T2: carry round-off slivers, raise on a lost transport Fixes for the T2 inspection findings. Each finding was checked against the code before it was fixed. 1. (major) An elementary vertical block could fail silently. `_Vertical.cells` keeps only flexes with more than `ulp` of heat in the interval (design 2.4.2: a flex is absent only when g_j <= ulp). Take a float-nonempty sliver interval whose flex heat equals the must heat up to round-off but is shared by two or more flexes, each with at most one ulp. Every flex was dropped while a must still carried heat. The gap balancing then had no column (`if gap > 0. and g`), `_transport` found no pair for the row and returned None, and `elementary()`, `_vertical_block` and `_drive` passed that None up. None is also the documented result for "forbidden or used pairs only", so T3's `_split_side` would have dropped the strategy with no entry in `split['errors']`. That breaks Theorem V' (the elementary block is always accepted) and 2.4.3(6). Reproduced on the inspector's side (must 1 starting o x 20 ulp above must 0, two identical flexes): `_drive(side, [0, 0], 'V')` with coarsening off returned None for o = 1.2, 1.5 and 1.9 and succeeded for 2.5 and 3.5. Cause and fix: the ulp filter treats each flex's heat as round-off, but when a must carries heat the total is not round-off. When the filter leaves no column and some row has heat, the flex with the most heat is kept, and the existing balance (at most max(_SPLIT_R_TOL tolQ, 4 (M+F) ulp)) puts the rest on it. Its cell is verified like any other, and a failure would go through the missed-knot / round-off recovery. The None contract now holds by construction. When an elementary block's transport fails, `cells` re-runs it without `forbid`/`used`. It returns None only if that re-run succeeds, i.e. avoid_recycle removed pairs the block needed. Otherwise it raises `_SplitInvariantError`: by Theorem V' a transport on all pairs always exists, so the strategy fails and is recorded. 2. (minor) No test covered the minimum fraction of coarsened blocks (2.4.3(2)). New test: flex 1 carries 0.005 of heat parallel to flex 0 up to level 10, so every block there sends the must to flex 1 with a fraction near 5e-4. The block to X verifies cell by cell but is rejected for that fraction. Elementary blocks cover the range and one coarsened block covers the rest. With _SPLIT_MIN_FRACTION = 0 the block to X is taken. Mutation check (scratch): with the module constant set to 0 the test fails; at 1e-3 it passes. 3. (minor) The test helper `_verify_side_cells` anchored every stage's branches at the stage's lowest start. `_remix` splits a must at its far end (max a_end), where the branches share the split state, and mixes it toward the pinch; a flex splits at its start. The helper now anchors must stages at the far end and flex stages at the start, and a must stage spans [far - D, far]. For V the remixes are isothermal and the two anchors coincide, so the existing checks are unchanged. The new test shows a valid non-isothermal must stage passing and a must stage anchored at its start failing. T4b/T5 can therefore reuse the helper for Stage S and pinch blocks. 4. (minor) `_Candidate` counted every non-isothermal must remix as mixbad. Design 2.5 counts only remixes "followed by a process exchanger of the same stream". A must mixes toward the pinch, so its remix now counts only if an exchanger of that must lies below the mix point (a_end <= pm + tolQ, outside the stage). A utility or the pre-leak does not count. This implements the design's rule now instead of deferring it: it mirrors the flex rule, and it changes nothing for V (see the scratch probe below). Tests, written first: - test_vertical_block_on_a_round_off_sliver (the inspector's side for o in 1.2, 1.5, 1.9, 2.5, 3.5, coarsening off, passes verify_core; a vertical block with no transport on all pairs raises) - test_coarsened_block_keeps_the_minimum_fraction - test_verify_side_cells_anchors_stages_like_remix - test_candidate_key_and_signature: a non-isothermal must remix that feeds only its utility (0) and one followed by an exchanger (1) Before the fixes, the sliver, helper and remix tests failed for the reasons above. The min-fraction test passed (the code was right, the test was missing) and fails under the mutation. Scratch checks: - With _SPLIT_ULP raised to 1e-11 relative (the inspector's probe) and coarsening off, 367/367 core_sides pass verify_core (before: 3 returned None). At the real ulp, 367/367 pass as well. - Rerunning T2's rtB07 / 52-split-side probe (knots.pkl) gives the same blocks, units, keys (mixbad included) and node slacks as T2's output; only the timings differ. Validation: full suite 485 passed in 251.19s (0:04:11) (T2: 482 passed in 283 s). A code comment was reworded after the suite started, so test_hxn_planner.py and the _splitting doctests were run again on the final files: 52 passed. py3.12 syntax checked with ast.parse(feature_version=(3, 12)). R1 oracle (planner): 78 records, sha256 7307a56778e56268, 596.9 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 45 ++++++++++---- tests/test_hxn_planner.py | 120 ++++++++++++++++++++++++++++++++------ 2 files changed, 136 insertions(+), 29 deletions(-) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index a0b6356..f3f1739 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -767,8 +767,11 @@ 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 - no transport exists or, for a `coarse` block, if the block does not - verify or has a fraction below `_SPLIT_MIN_FRACTION`. + 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 @@ -779,6 +782,12 @@ def cells(self, ks, ke, coarse): 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 @@ -804,7 +813,13 @@ def cells(self, ks, ke, coarse): while True: q = _transport(h, g, comp, self.conts, forbid, self.tol) if q is None: - return 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 @@ -855,8 +870,8 @@ def coarse(self, ks): def elementary(self, ks): """The elementary block ``[ks, ks + 1]`` with the recovery of a - failing cell: ``(cells, leak)``, or None if no transport exists - (forbidden pairs only).""" + 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: @@ -980,8 +995,8 @@ def _vertical_block(side, a, b, prev=(), forbid=frozenset(), used=frozenset(), Raises ------ _SplitInvariantError - If the elementary block cannot be completed within round-off, or the - block would make no progress. + 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 ----- @@ -1243,8 +1258,9 @@ class _Candidate: `_SPLIT_MIN_FRACTION`. mixbad : int Non-isothermal remixes followed by a process exchanger of the same - stream (a must's always is), plus curved streams (more than two - knots) with more than `_SPLIT_MIX_CAP` mixers on the side. + 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. @@ -1296,12 +1312,17 @@ def __init__(self, name, order, side, a0, cells, leak_by_must=None, mixers = Counter((role, s) for role, s, _ in stages) mixbad = 0 for (role, s, _), br in stages.items(): - p0, _, iso = _remix(role, br, tolQ) + 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 += role == 'm' or any( - c.j == s and id(c) not in inside and c.b > p0 for c in merged) + 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 diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 924fe81..f58f399 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -1260,8 +1260,9 @@ 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 start; every must served exactly from `a0` (plus its - leak) and every flex used as a prefix.""" + 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) @@ -1292,21 +1293,29 @@ def _verify_side_cells(side, cells, a0=None, leak=None): 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] - start = min(p[0] for p in pos) - if key[0] != 'trunk': - 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[0][0] - start) <= tolQ - for p, p2 in zip(ps, ps[1:]): - assert abs(p2[0] - p[1]) <= tolQ - spans.append((start, math.fsum(c.x 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 @@ -1331,6 +1340,24 @@ def verify_core(side, cand, a0): _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 @@ -1522,6 +1549,59 @@ def test_vertical_block_forbid_and_used(monkeypatch): _verify_side_cells(s, cand.cells) +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)] @@ -1577,6 +1657,12 @@ def cand(cells, name='V', order=(1, 0), **kw): 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 # 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.])], From 798e1cb830f544f996abd387dbcdd83b30ead9ca Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 00:29:59 -0700 Subject: [PATCH 04/24] add split plan records and the stream_splitting switch to the planner T3 of the stream-splitting design: V-only split mode, end to end at the planner level. With stream_splitting=False (the default) no new code runs and no new info key appears; the R1 oracle below proves the planner is bitwise unchanged on the whole corpus and its variants. The switch (_planner.py) - plan_network(..., stream_splitting=False, _split_exclude=None, _split_prefer=None), keyword-only. The private dicts (side -> set of network signatures, side -> candidate name) are for the refine loop. - _plan_side(split=None) hook (design 2.1): a root proof 'outward' or 'inward', or a 'cascade' root whose deficit is within _preleak_max (the cascade's own tolerances, Lemma P), goes to _splitting._split_side; so does an exhausted schedule whose best effort leaves a penalty above _SPLIT_R_TOL tolQ (1e-14 of the scale, below R2's 1e-12). A side the unsplit search serves returns exactly as before. If no candidate exists (deficit beyond _preleak_max, or avoid_recycle left no block), today's best effort applies. - _plan_sides(split) passes it through; the avoid_recycle loop collects used pairs from the pieces and the cells (empty when off). - _SidePlan(cells, split, units), Exchanger.__init__ with hot_frac, cold_frac = 1 and hot_branch, cold_branch = None, Plan.splits and Plan.paths (always set; paths == stages when unsplit). - Record dispatch: the existing record/walk/safety-net block runs verbatim unless some side plan has cells. - side_info[s]['split'] only when stream_splitting; _plan_numeric's match dicts gain hot_frac, cold_frac, hot_branch, cold_branch only when plan.splits. Selection and records (_splitting.py) - _split_side (core only for now): V from the pre-leaked root (_preleak_root, an exact analysis). The preferred candidate is tried first and taken as is when live; otherwise every generator runs (the preferred one not again) and the smallest _Candidate.key wins among the candidates whose signature is not excluded; a side's last candidate is never excluded. A generator raising _SplitInvariantError is recorded in split['errors']. Gaps are a0 + leak per must, so the side's penalty is truthful (MF4). split info: candidate, signature, candidates ({name: key tuple | 'excluded' | 'error' | 'forbidden pairs'}), stages, branches, preleak, leak, small, errors. - Split (stream, side, key, fractions, branches, H_split, H_mix, isothermal), _record, _paths, _walk_paths, _approach_violation_split and _split_records (design 4.3): trunk records and split stages occupy disjoint parent ranges per stream and side, so one sort by midpoint orders them pinch outward (a must stage spans [far end - D, far end], a flex stage [start, start + D], which also orders non-isothermal Stage S remixes correctly); musts flow toward the pinch, flexes away; branch records walk by Q/f (parent-equivalent enthalpies) and the mix is written with the duties, H_mix = H_split -/+ sum Q, so fractions add no round-off. Split sides are never Qmin-dropped (MF5). The safety net uses the fractions; a record it drops (a bug) keeps its branch, empty, so the fractions still sum to 1 (the realization can bypass it). Tests (written first; they failed on the missing keyword) - check_split_network: walks plan.paths with Q/(f CP) per branch; ends of every exchanger, fractions, H_split/H_mix, flat order and seqs, utilities, targets, and MF4 per must of every split side (served + gap == Qm within 1e-12 of the scale). - test_split_cases_reach_mer (V), test_split_plan_heat_closes_exactly, test_splitting_off_adds_nothing, test_threshold_and_near_tie_roots_preleak (preleak == -root slack: 23.7 and 0.05 tolQ; a 'cascade' root beyond _preleak_max keeps today's best effort), test_split_exclusion_by_signature (core), test_fuzz_splitting_always_reaches_mer, plus test_split_records_walk_a_non_isothermal_remix (hand-built non-isothermal must stage, and the drop path), test_split_side_candidates_and_the_hook, test_exhausted_schedule_splits (the proof=None trigger). Deviations from the design, with reasons - The exhausted-schedule hook passes be.work, not work + be.work: _best_effort already adds the work passed to it, so the design's sum counts the search twice (the test checks it). - check_split_network asserts penalty <= preleak + 1e-12 scale only on problems without sub-tolQ temperature offsets. On the jittered fuzz problems the unsplit planner's own tolerances reach the utilities (_sides omits a stream part of at most tolQ at the pinch; _cascade thresholds a trivial side's target): 5 of 100 jittered seeds show 0.15-0.4 tolQ of penalty, 3 of them with no split at all. MF4 is asserted instead, per must, on every split side, which is the property the bound was meant to prove. - Fuzz: one third random_problem, one third pinch_problem rejection-sampled on needs_split (random_problem alone needs a split about once in 100), one third jittered: 119 of 300 plans split, 170 need none and are identical to the flag off. _SPLIT_S_WORK and the rules-disabled half come with Stage S (T4b). - test_split_exclusion_by_signature uses a test-only second core generator (the elementary V chain, via monkeypatched _CORE_STRATEGIES and _drive), since V is the only core strategy until T5. - test_splitting_off_adds_nothing skips the two slowest fixtures (7.5 s of best effort); the oracle proves R1 on the whole corpus. - _walk_paths takes tolQ (for isothermal). Scratch probes (docs/superpowers/plans/stream-splitting/scratch/t3_*.py): avoid_recycle with splitting on the SPLIT dict + 40 split-needing pinch problems never repeats a pair (V under cap1 mostly finds no block: 34 of 43 fall back to best effort, as designed until Stage S); Qmin between the exchanger duties keeps every split cell and reports it in 'small'. Validation - Full suite: 496 passed in 297.74s (0:04:57) (T2: 485 in 251 s; +11 tests, the fuzz 3.3 s and the off test 2.7 s of it). - ast.parse(feature_version=(3, 12)) on the three changed files. - R1 oracle, planner mode (hxn_synthesis.py and _heat_exchanger_network.py untouched): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 730.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_planner.py | 220 +++++++++++++++----- hensmith/_splitting.py | 390 ++++++++++++++++++++++++++++++++++- tests/test_hxn_planner.py | 417 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 971 insertions(+), 56 deletions(-) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index e120685..31f4942 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -1336,18 +1336,24 @@ 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.""" __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 +1366,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 +1385,16 @@ 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, d, proof, cap1, forbid, + work_scale, 0., split) + if sp is not None: + return sp return _best_effort(side, proof, cap1, forbid, work_scale, 0.) work = 0. pieces = method = None @@ -1391,7 +1412,17 @@ 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 + # below R2's 1e-12 of the scale: no best-effort penalty R2 + # would see is accepted without trying the split path + if be.penalty > _splitting._SPLIT_R_TOL * side.tolQ: + sp = _splitting._split_side(side, d, None, cap1, forbid, + work_scale, be.work, split) + if sp is not None: + return sp + return be first = work pieces, w = _improve_units(side, pieces, first, cap1, forbid, work_scale) work += w @@ -1884,18 +1915,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 +1949,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 +2013,12 @@ 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 an unsplit side, + 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``), ``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 +2026,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' 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 +2298,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 +2343,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 index f3f1739..9e10048 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -143,14 +143,27 @@ stream; units + extra branches + split stages; the most mixers on one stream; the candidate order. Its signature holds no duty or position, 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 its signature +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. +`_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 import numpy as np -from ._planner import (_LevelCurve, _REL_Q, _Side, _THRESHOLD_TOL, - _WORK_EVENT) +from ._planner import (Exchanger, _APPROACH_TOL, _LevelCurve, _REL_Q, + _Side, _SidePlan, _THRESHOLD_TOL, _WORK_EVENT, _merge) __all__ = () @@ -1346,3 +1359,376 @@ def key(self): def __repr__(self): return f'<_Candidate {self.name} key={self.key()}>' + + +# %% Portfolio and selection + +def _excluded(split, side_name, cand): + """True if the network signature of `cand` is excluded on its side + (exclusion is by network, never by generator name).""" + return cand.signature in split['exclude'].get(side_name, ()) + + +def _side_plan(side, best, cands, reasons, a0, delta, errors, work, proof): + """The side plan of the chosen candidate `best`. Each must's gap is its + pre-leak plus its leak, so the penalty is truthful.""" + 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 + math.fsum(c.work for c in cands), proof, + cells=best.cells, split=info, units=best.units) + + +def _split_side(side, d, proof, cap1, forbid, work_scale, work, split): + """ + Split plan of a side that no unsplit network serves at MER. + + Parameters + ---------- + side : _Side + The side. + d : _Residual + The search's analysis of the root, which triggered the split. The + core analyses the root again, 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 candidate tried first). + + Returns + ------- + _SidePlan or None + A side plan with status 'mer', method ``'split-'``, the + cells of the chosen candidate and the ``split`` info; None only if + no candidate exists: the root deficit exceeds `_preleak_max`, or + avoid_recycle's pairs left no block. + + Notes + ----- + The core starts from the pre-leaked root ``a0 = P(delta)`` (Lemma P). + The preferred candidate (the previous refine round's pick) is generated + first and taken as is unless its signature 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. + A generator that raises `_SplitInvariantError` is recorded in + ``errors`` and the others remain. + """ + delta, a0 = _preleak_root(side) + core = delta <= _preleak_max(side) + prefer = split['prefer'].get(side.name) + cands, errors, reasons = [], [], {} + + 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 + c.excluded = _excluded(split, side.name, c) + reasons[name] = 'excluded' if c.excluded else c.key() + cands.append(c) + return c + if core and prefer in _CORE_STRATEGIES: + c = generate(prefer) + if c is not None and not c.excluded: + return _side_plan(side, c, cands, reasons, a0, delta, errors, + work, proof) + if core: + for strategy in _CORE_STRATEGIES: + if strategy != prefer: + generate(strategy) + if not cands: + return None + live = [c for c in cands if not c.excluded] or cands + return _side_plan(side, min(live, key=_Candidate.key), cands, reasons, + a0, delta, errors, work, proof) + + +# %% 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/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index f58f399..1ec2c20 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -1674,3 +1674,420 @@ def cand(cells, name='V', order=(1, 0), **kw): # a cell that fails (C) is never accepted with pytest.raises(SP._SplitInvariantError): SP._Candidate('V', (1, 0), side, [0.], [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.""" + return {name: s['split'] for name, s in plan.info['sides'].items() + if s.get('split')} + + +def check_split_network(streams, dT, net, tol=1e-6, exact=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 (MF4). 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). 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 = 0. + for b, (f, ns) in enumerate(zip(fr, item.branches)): + assert ns + 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) + 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) + assert abs(Qh - Qh_t) <= qtol + preleak + assert abs(Qc - Qc_t) <= qtol + preleak + # MF4: the split machinery adds no heat error + 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: + assert plan.penalty <= preleak + exact_tol + return Qh, Qc + + +@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-V' + assert s['proof']['rule'] in ('outward', 'inward') + assert sp['candidate'] == 'V' and sp['preleak'] == sp['leak'] == 0. + assert sp['errors'] == [] and sp['small'] == [] + assert isinstance(sp['candidates']['V'], tuple) + 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; the module says so + assert SP._SPLIT_R_TOL * P._REL_Q == pytest.approx(1e-14) + assert "``1e-12`` heat closure" in SP.__doc__ + 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 (MF4) + 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 two slowest (4.5 s and 3 s of best effort; the + # R1 oracle proves the bitwise identity on the whole corpus) + 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_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']) + 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'] == 'split-V' + # the best effort's work is counted once + assert s['work'] > off['plan'].info['sides'][name]['work'] + + +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 + # one core candidate: excluding its signature still returns it (a + # side's last candidate is never excluded) + 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 + _, pr = plan(_split_prefer={name: 'V0'}) + assert pr['candidate'] == 'V0' and set(pr['candidates']) == {'V0'} + _, pr = plan(_split_prefer={name: 'V0'}, + _split_exclude={name: {ex['signature']}}) + 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 test_fuzz_splitting_always_reaches_mer(monkeypatch): + monkeypatch.setattr(P, '_BE_WORK', 3000.) # keep best effort short + rng = random.Random(29) + n_split = n_same = 0 + for seed in range(300): + 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()) + 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 From 8531240ba8882150191869f4a6e404e76a15f789 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 01:27:13 -0700 Subject: [PATCH 05/24] add the stage s cut transports and the branched side to splitting T4a of the stream-splitting design (2.3.1-2.3.3): the pinch-split machinery that Stage S (T4b) plans with the unchanged DFS. Nothing calls it yet, so the planner is unchanged with the flag on or off; the R1 oracle below confirms it. Cut sets (_cut_sets) - Repeats rules_violation's selection at a tight cut on the side's local indices. Outward: demands = the musts leaving L, capacities = the flexes at L, CP = 1/slope_right; inward: demands = the flexes reaching L from below, capacities = the musts, CP = 1/slope_left. A flat capacity is left out (split-invariant, unlimited series capacity: a cut with one violates nothing); a flat demand returns None (it accepts only a flat partner, so no split serves it). rules_violation's own dict is untouched. Transports (_cut_transport, _SPLIT_RULES in order) - partner / demand: a best-fit maximum matching (demands CP descending, each taking the smallest free capacity that fits; optimal since the neighbourhoods are nested), then the unmatched demands join the capacity with the most room (partner, demands whole) or take the largest free capacities in proportion (demand, capacities whole). - mincell (Linnhoff-Hindmarsh minimum cells, from probe_pinchfirst's structure/pack): super-bins of merged capacities with sum(|S|-1) = k, k = 0..3, exact branch-and-bound packing of the demands (CP descending, capacity pruning, identical-bin symmetry breaking, bound on the bins still reachable and the current smallest ratio), score (bins used, smallest CP ratio); the best packing at the smallest feasible k, each group served by the uniform-rho north-west corner; otherwise one group. - nw-rho-desc/asc and nw-exact-desc/asc: the north-west corner against the capacities scaled to c/rho (every cell has branch-CP ratio rho) or unscaled (capacities never reached stay whole). Each cell is the overlap of two intervals on the prefix sums, so no subtraction chain carries round-off; breakpoints within _SPLIT_ULP of the total coincide. - Compatibility is rules_violation's, c >= m (1 - 1e-12) (_CP_TOL), so the transports agree with the screen that follows. - _cut_fractions: f = load over the demand's load sum (= load/m), g = load over the capacity's load sum (the whole capacity splits, no bypass, so g c >= load); fractions sum to 1 within round-off. Branched side (_pinch_split, _split_items, _branched_side) - Items (role, parent, fraction, key), musts first; key ('S', parent, n) on a branch, None on a trunk. A branch below _SPLIT_MIN_FRACTION of its parent merges into its largest sibling (MF9); a parent left with one branch is a trunk again. - Screen at the branched pre-leaked root (must branch at f a0_i): slack of (R) within 10 tolQ of the side's (Lemma R, else _SplitInvariantError), then rules_violation there. A cut still violated gets the same rule again (branches as items; a branch of a branch has the product fraction), at most _SPLIT_CUTS = 3 cuts; a split that changes nothing, or a violation left after three cuts, fails the rule (None). - extra = sum(branches - 1) over the split parents. Corpus behaviour (scratch t4a_probe2.out): all 32 constant-CP SPLIT sides with a rules proof have a succeeding rule, in one cut except nptel_t5_3 below: the double pinch there makes the rho rules and mincell ping-pong between the two tight cuts (they fail, as design section 3 notes); nw-exact-desc succeeds at once (+2) and nw-exact-asc after three cuts (+8). smith2005_ex18_4 below (zero CP slack, 300 on 200 + 100) needs one cut for every rule but partner. Tests (written first; they failed on the missing _SPLIT_RULES, _cut_sets, _cut_transport, _pinch_split) - test_cut_transport_rules (per rule): 2h1c -> +1 (partner, the cold stream hosts both; demand fails), 1h2c CP rule -> +1 (partner fails), zero slack (smith2005_ex18_4 below) -> branch CP == partner CP, cornell above -> 3 cells (partner and demand fail; mincell == the k = 1 one- group structure); 300 random cut sets on scales 1e-3..1e3, half tight: every demand served, no capacity overloaded, g c >= f m per cell, uniform rho for the rho rules, a forest, the rule's shape, and no split at all where a whole matching exists (partner, demand, mincell). - test_mincell_prefers_fewer_cells (k = 0 whole packing; fallback). - test_pinch_split_screens_the_branched_root (min-fraction merge undoes a 1e-4 branch; flat demand; a broken Lemma R raises). - test_pinch_split_on_the_corpus (every constant-CP SPLIT side; the branched curves and root checked independently; the double pinch and its pre-leaked variant NEAR_DOUBLE_PINCH need the extra cuts). - Mutation probe (scratch t4a_mutation.py): no-tolerance fits, NW never/ always inflating, partner/demand swapped, mincell as one group, wrong capacity fractions, no min-fraction merge, one cut, unscaled root: each fails at least one of the new tests. Deviations from the design, with reasons - _cut_sets(side, a, b, L, cut, rule) takes no items: it runs on the branched side itself, whose local indices are the items. - _pinch_split(side, a0, proof, rule) takes the pre-leaked root a0 (the screen needs it) instead of items (it always starts unsplit; the items are its output). _branched_side(side, items, a0) rebuilds the side and its root for T4b. - _MINCELL_WORK counts the nodes and merge-set families of one mincell transport together, which bounds its time also when many families exist; the probe's "every merged set used" filter is dropped as provably redundant (a packing leaving one empty is feasible at a smaller k, which was searched in full). - The zero-slack case of test_cut_transport_rules is the corpus's smith2005_ex18_4 below (design 2.8(c)), where partner, not demand, fails. Validation - Full suite: 506 passed in 495.69s (0:08:15) (T3: 496 in 298 s; +10 tests, 10 s of them in isolation). The machine ran slower this session: the planner oracle, which runs none of the new code, took 1046 s against 730 s at T3. - ast.parse(feature_version=(3, 12)) on both changed files. - R1 oracle, planner mode (hxn_synthesis.py and _heat_exchanger_network.py untouched): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 1046.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 473 ++++++++++++++++++++++++++++++++++++++ tests/test_hxn_planner.py | 288 +++++++++++++++++++++++ 2 files changed, 761 insertions(+) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 9e10048..03b528d 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -82,6 +82,20 @@ 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. Its +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. + The core (the provable backstop) -------------------------------- **Theorem V'.** Let a node satisfy (R) and let ``[t_s, t_e]`` be two @@ -159,6 +173,7 @@ """ import math from collections import Counter, defaultdict, deque +from itertools import accumulate, combinations import numpy as np @@ -175,6 +190,11 @@ _ISO_TOL = 10. # isothermal remix, x tolQ _CORE_ORDER = ('V', 'LV', 'VT', 'LVT') # candidate order of the core _CORE_STRATEGIES = ('V',) # the core strategies `_drive` runs +_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) +_MINCELL_WORK = 20000 # search nodes of one 'mincell' transport +_CP_TOL = 1e-12 # CP compatibility, as in `rules_violation` # %% Cells and branch curves @@ -1361,6 +1381,459 @@ 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 that satisfy the pinch rule at a cut (design 2.3.2). + + 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): + """ + 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). + """ + 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()} + return [(x / sd[d], x / sc[c]) for d, c, x in cells] + + +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 merges into its largest sibling (MF9), 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 _Side(side.name, musts, flexes, side.tolQ, side.tolP), a + + +def _pinch_split(side, a0, proof, rule): + """ + The branched side of one Stage S rule (design 2.3.3). + + The rule's transport (`_cut_transport`) at the proof's cut splits the + demands and capacities there into whole-side branches. 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') + split = defaultdict(list) + for (d, c, _), (f, g) in zip(cells, _cut_fractions(cells)): + 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'S:{rule}: 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 + + # %% Portfolio and selection def _excluded(split, side_name, cand): diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 1ec2c20..f656626 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -23,6 +23,7 @@ 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 @@ -2091,3 +2092,290 @@ def test_fuzz_splitting_always_reaches_mer(monkeypatch): (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 + + +# %% 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.)]) + + +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_pinch_split_screens_the_branched_root(monkeypatch): + # a branch below _SPLIT_MIN_FRACTION merges into its sibling, which + # undoes the split: the rule fails + side, a0, proof = root_of(*TINY_BRANCH) + assert SP._pinch_split(side, a0, proof, 'partner') is None + 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 From 45a6a39f2b0a9099c27d38b1f05d86d83086f49a Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 02:32:19 -0700 Subject: [PATCH 06/24] add stage s to stream splitting: pinch splits planned by the dfs T4b of the stream-splitting design (2.3.4, 2.5, 2.6, 3): a side that the pinch rules prove needs splits now gets Stage S candidates, one per rule of _SPLIT_RULES, besides the vertical core V, and the smallest selection key wins. Stage S branches the side at the violating cut (T4a's _pinch_split) and plans the branched side with the planner's own DFS, unchanged, so it usually adds one splitter/mixer pair where V adds many. With the flag off nothing new runs; the R1 oracle below confirms it bit for bit. Stage S (_stage_s, _s_search, _s_cells, _s_candidate, _fold) - Per rule, in order: _pinch_split -> _branched_side (pre-leaked root a0' = f a0_i per must branch), then the first _SPLIT_PASSES = 3 _SCHEDULE passes from a0' through _Search's existing a0, deduplicated as _plan_side does, with avoid_recycle's forbidden pairs mapped onto every branch pair of their parents. A rule whose branching equals an earlier rule's is skipped ('same split'). - Unit bound: once a live incumbent has small == mixbad == touch == 0, the DFS runs with unit_bound = score - extra - stages (score = its units + extra branches + split stages), which keeps ties; a rule that cannot beat it ends as 'bound'. - One work budget for all rules of a side, _SPLIT_S_WORK = 30000 x work_scale (the size of the _BE_WORK best effort that S replaces), deterministic; a rule not reached, or stopped by it, is 'budget'. - Conversion: _merge(pieces), then _Cell(parent(i'), parent(j'), x, a'/f, b'/g, f, g, key(i'), key(j')) (Lemma B). Folding (Lemma F): a flex branch the DFS left unused gives its fraction to its used siblings in proportion (g/G), keys are renumbered, and a single used branch is a trunk again; positions b'/g move toward the pinch, where the flex levels are no higher, so every cell stays feasible. Branches that are all used keep their fractions exactly. - Residual sweep (MF4): the DFS completes a must within tolQ. For each must item, r = f (Qm_i - a0_i) - its duties; |r| <= tolQ goes to the item's far-end cell, and the later cells of that cell's flex item move by r with it, so the flex stays an exact prefix; the moved cells are re-verified with _cell_margin. Otherwise the plan is rejected ('sweep'): Stage S books no leak. - Verification: every merged cell by _Candidate (a failure is an invariant error, recorded in split['errors']); with cap1 a plan whose merged cells repeat a pair is rejected ('repeated pair'). - Signature and exclusion as for the core (_excluded); an excluded candidate stays in the list. The winner (smallest key among the live S candidates) gets _improve_units and _units_guard on its branched side from a0'; the result replaces it if it is live and its key is not worse. - _SPLIT_FIRST_WINS (False; a runtime lever): _stage_s stops at its first live candidate and the core is skipped when one exists. Portfolio (_split_side) - s_ok: the root proof is 'outward' or 'inward', and the root deficit is within _preleak_max as for the core. - Stickiness: a preferred 'S:' runs alone first (_stage_s(only=), winner improvement included) and is taken if live; otherwise the portfolio runs Stage S (skipping the preferred rule), then the core. - avoid_recycle: every candidate, the core's too, is rejected if its merged cells repeat a pair (_repeats_a_pair, design 5.6). - split['candidates'] names every rule run: its key or why there is none ('no split', 'same split', 'budget', 'bound', 'no plan', 'sweep', 'repeated pair', 'error', 'excluded'). Planner (_planner.py): _improve_units and _units_guard gain a0=None and pass it to _Search; at the default the searches are today's (R1 below). Corpus check (scratch planner_corpus_check.py on the 38 SPLIT cases' round-0 knots, knots.pkl; planner_corpus_check_analytic.out for the 25 constant-CP cases on their analytic knots) - All 38 'mer'; every split side's cells pass _verify_side_cells from the pre-leaked root; mixbad == 0 everywhere; no errors. - 52 split sides; 50 pick a Stage S rule (partner 29, demand 10, nw-exact-desc 5, mincell 4, nw-rho-desc 1, nw-rho-asc 1), V 2. Most sides add one branch (one split stage). - Bound per case (units + extra branches + split stages, whole network), for the tests of T9b/T10 (per side in scratch t4b_sides.txt): cornell 11, nptel_t4_4 7, nptel_t5_3 9, smith2005_ex18_2 6, smith2005_ex18_4 6, smith2005_ex16_5 5, smith2005_exr18_4 8, smith2005_exr18_7 8, 7sp3 11, 7sp4 11, 8sp1_dt20F 10, 8sp1_sargent 9, nptel_t5_7 11, smith2005_exr18_5 9, crude_fractionation 25, bagajewicz 69, bjork_pettersson 27, fs_15sp_tkm 17, cgm_balanced8 23, cgm_balanced10 33, cgm_unbalanced10 32, luo_20sp 132, fs_22sp1 28, fs_22sp_ph 23, grossmann_balanced12 41, rtB01 14, rtB02 14, rtB03 17, rtB04 10, rtB05 5, rtB06 5, rtB07 13, rtB08 14, rtB09 5, rtB10 16, rtB11 12, rtB12 6, rtB13 11. - _SPLIT_S_WORK calibration: the most Stage S DFS work on one side is 25204 (luo below, knots.pkl) and 22761 (luo below, analytic knots, where nw-exact-desc is reached after three failed rules of 6300 each and wins at size 60); no rule that succeeds standalone is cut off on either input, so 30000 stays. - Finding: on knots.pkl every S rule fails on luo below and bagajewicz above, in the first 3 passes and even in all 6 _SCHEDULE passes, so V serves them (sizes 129 and 67, the design's V alternatives). The facility lists the streams by duty (heats in kJ/h); in the literature order the same branched problems succeed at once (S:nw-exact-desc 60, S:partner 42). The unchanged DFS is order-sensitive: a canonical order of the branched side's items is a unit-count follow-up (design O-1), not a correctness issue (V is the backstop). - Planner time, one round on the 38 cases: 24.6 s with Stage S + V against 1.3 s with V alone; 15.7 s for the 25 analytic cases. Tests (written first; they failed on the missing Stage S candidates) - test_split_cases_reach_mer: the whole portfolio runs (every S rule named, V has a key), the smallest key wins, size <= the recorded bound. - test_split_exclusion_by_signature (all candidates): excluding the pick gives a different network, excluding every network in turn still returns one, a live preferred S rule is taken alone, an excluded one is not run again; the core-only part as before (S off). - test_fuzz_splitting_always_reaches_mer: S on half the seeds (_SPLIT_S_WORK patched to 3000, like _BE_WORK), the core alone on the other half; at least 40 S picks. - test_splitting_with_avoid_recycle_never_repeats_a_pair (the SPLIT dict + 17 pinch problems): no pair twice, even across the pinch; 'mer' or 'best_effort'; repeated-pair candidates are generated and rejected. - test_split_first_wins_is_deterministic; test_split_side_keeps_cells_ below_Qmin (MF5: with Qmin = 3.5 the split side keeps and reports its 2.2 and 2.4 exchangers, the unsplit side drops its 3.3 as without splitting); test_stage_s_cells_fold_and_sweep (fold, trunk again, sweep with the flex move, rejection); test_stage_s_candidates_are_ verified; test_stage_s_budget; test_stage_s_unit_bound_and_improvement (the bound changes nothing but the work; the winner's improvement takes 7sp3 above from 8 to 7). check_split_network gains mer=False for best-effort networks. - Mutation probe (scratch t4b_mutation.py): no folding, no sweep, no repeated-pair check, S ignoring forbid, no unit bound, a bound that drops ties, no shared budget, no winner improvement: each fails at least one new test. - Times: the fuzz test 10.1 s, the avoid_recycle test 7.0 s (mostly the unsplit best effort of the other side), the rest under 0.5 s each. Deviations from the design, with reasons - The residual sweep also moves the later cells of the swept cell's flex item: adding r to the cell alone would make it overlap the next cell of that flex branch by r/g in parent heat. It treats |r| <= tolQ (an overshoot by round-off too), not only 0 < r. - s_ok also requires the root deficit within _preleak_max: beyond it the pre-leaked root would book more than the cascade's own tolerance. - _stage_s takes T3's reasons dict and only/skip names (the design has skip alone and a separate _generate). - _improve_units/_units_guard gain a0 here (T5 adds b0): the winner's improvement needs the pre-leaked root. - The winner's improvement also runs under _SPLIT_FIRST_WINS. Validation - Full suite: 513 passed in 523.99s (0:08:43) (T4a: 506 in 495.69 s). - ast.parse(feature_version=(3, 12)) on the three changed files. - R1 oracle, planner mode (hxn_synthesis.py and _heat_exchanger_network.py untouched): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 1161.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_planner.py | 15 +- hensmith/_splitting.py | 388 ++++++++++++++++++++++++++++++++++++-- tests/test_hxn_planner.py | 297 +++++++++++++++++++++++++++-- 3 files changed, 667 insertions(+), 33 deletions(-) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index 31f4942..d096f74 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -1431,9 +1431,10 @@ 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): """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 frontiers `a0` (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 @@ -1443,7 +1444,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) res = srch.run() used += srch.work if res is not None: @@ -1457,10 +1458,11 @@ 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): """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 frontiers `a0` (default: the pinch).""" U = _count_units(pieces) if U <= _GUARD_FACTOR * (side.M + side.F): return pieces, 0. @@ -1472,7 +1474,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) res = srch.run() used += srch.work if res is not None: diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 03b528d..45218d0 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -94,7 +94,19 @@ 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. +planner's search: `_stage_s` plans it from its pre-leaked root with the +first passes of the planner's DFS, unchanged, 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) -------------------------------- @@ -178,7 +190,9 @@ import numpy as np from ._planner import (Exchanger, _APPROACH_TOL, _LevelCurve, _REL_Q, - _Side, _SidePlan, _THRESHOLD_TOL, _WORK_EVENT, _merge) + _SCHEDULE, _Search, _Side, _SidePlan, _THRESHOLD_TOL, + _WORK_EVENT, _combine_cap, _improve_units, _merge, + _units_guard) __all__ = () @@ -193,6 +207,9 @@ _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: the 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` @@ -1834,6 +1851,319 @@ def _pinch_split(side, a0, proof, rule): 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 (design 2.3.4, steps 3-5): 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 (MF4): 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` (design 2.3.4, steps 3-7), 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, a0, 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 (design 2.3.4). + + 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 + ------- + list[_Candidate] + The candidates, excluded ones included (`_excluded`). + + 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 + # 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 + + # %% Portfolio and selection def _excluded(split, side_name, cand): @@ -1886,21 +2216,28 @@ def _split_side(side, d, proof, cap1, forbid, work_scale, work, split): A side plan with status 'mer', method ``'split-'``, the cells of the chosen candidate and the ``split`` info; None only if no candidate exists: the root deficit exceeds `_preleak_max`, or - avoid_recycle's pairs left no block. + avoid_recycle's pairs left no candidate. Notes ----- - The core starts from the pre-leaked root ``a0 = P(delta)`` (Lemma P). - The preferred candidate (the previous refine round's pick) is generated - first and taken as is unless its signature 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. - A generator that raises `_SplitInvariantError` is recorded in - ``errors`` and the others remain. + 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 + unless its signature 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`). """ 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 = split['prefer'].get(side.name) cands, errors, reasons = [], [], {} @@ -1915,19 +2252,40 @@ def generate(name): if c is None: reasons[name] = 'forbidden pairs' return None + 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 - if core and prefer in _CORE_STRATEGIES: - c = generate(prefer) + + def stage_s(**kw): + cs = _stage_s(side, a0, proof, cap1, forbid, work_scale, split, + errors, reasons, **kw) + cands.extend(cs) + return cs + # the preferred candidate (stickiness), taken as is if it is live + 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: return _side_plan(side, c, cands, reasons, a0, delta, errors, work, proof) - if core: + # 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: - generate(strategy) + c = generate(strategy) + if _SPLIT_FIRST_WINS and c is not None and not c.excluded: + break if not cands: return None live = [c for c in cands if not c.excluded] or cands diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index f656626..e40f5e2 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -16,6 +16,7 @@ """ import math import random +from collections import Counter import numpy as np import pytest @@ -1691,7 +1692,7 @@ def split_infos(plan): if s.get('split')} -def check_split_network(streams, dT, net, tol=1e-6, exact=True): +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 @@ -1707,7 +1708,9 @@ def check_split_network(streams, dT, net, tol=1e-6, exact=True): '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). Returns the hot and cold utility. + 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'] @@ -1772,8 +1775,11 @@ def run(ns, T, f, branch): Qh = sum(net['hot_utility'].values()) Qc = sum(net['cold_utility'].values()) Qh_t, Qc_t, _ = cascade(streams, dT) - assert abs(Qh - Qh_t) <= qtol + preleak - assert abs(Qc - Qc_t) <= qtol + preleak + 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 # MF4: the split machinery adds no heat error sides = sides_from_knots( [([min(s['T_in'], s['T_out']), max(s['T_in'], s['T_out'])], @@ -1788,11 +1794,31 @@ def run(ns, T, f, branch): and m[role] == streams[c.stream]['name']) assert served + gaps.get(c.stream, 0.) == pytest.approx( c.Q, abs=exact_tol) - if exact: + 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).""" + sp = side_info['split'] + return side_info['units'] + (sp['branches'] - sp['stages']) + sp['stages'] + + +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] @@ -1808,11 +1834,19 @@ def test_split_cases_reach_mer(name): 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-V' + assert s['status'] == 'mer' and s['method'] == 'split-' + sp[ + 'candidate'] assert s['proof']['rule'] in ('outward', 'inward') - assert sp['candidate'] == 'V' and sp['preleak'] == sp['leak'] == 0. + assert sp['preleak'] == sp['leak'] == 0. assert sp['errors'] == [] and sp['small'] == [] - assert isinstance(sp['candidates']['V'], tuple) + # 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 split_size(s) <= SPLIT_SIZE[name] assert s['units'] == len([e for e in plan.exchangers if e.side == sname]) @@ -2031,8 +2065,36 @@ def plan(**kw): check_split_network(streams, dT, net) (name, sp), = split_infos(net['plan']).items() return name, sp - # one core candidate: excluding its signature still returns it (a - # side's last candidate is never excluded) + # 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 is taken first, alone + _, pr = plan(_split_prefer={name: sp2['candidate']}) + assert (pr['candidate'], pr['signature']) == (sp2['candidate'], + sp2['signature']) + assert set(pr['candidates']) == {sp2['candidate']} + # an excluded preferred one: the rest of the portfolio, not it again + _, pr = plan(_split_prefer={name: sp['candidate']}, + _split_exclude={name: {sp['signature']}}) + assert pr['signature'] == sp2['signature'] + assert pr['candidates'][sp['candidate']] == 'excluded' + # the core alone (Stage S off): one core candidate; excluding its + # signature still returns it (a side's last candidate is never + # excluded) + monkeypatch.setattr(SP, '_SPLIT_RULES', ()) name, sp = plan() sig = sp['signature'] name2, sp2 = plan(_split_exclude={name: {sig}}) @@ -2063,9 +2125,13 @@ def plan(**kw): 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 = 0 + 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) @@ -2084,6 +2150,10 @@ def test_fuzz_splitting_always_reaches_mer(monkeypatch): 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) @@ -2091,7 +2161,7 @@ def test_fuzz_splitting_always_reaches_mer(monkeypatch): 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 + assert n_split >= 110 and n_same >= 150 and n_s >= 40 # %% 18. Stage S cut transports (hensmith._splitting) @@ -2379,3 +2449,206 @@ def test_pinch_split_on_the_corpus(monkeypatch): 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'] == 'V' + 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(): + # MF5: 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 From 38e86a8753e3d61da0976cce897e129512544efd Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 03:37:03 -0700 Subject: [PATCH 07/24] add core pinch blocks and dfs tails to stream splitting T5 of the stream-splitting design (2.4.4-2.4.6): the core backstop now runs four strategies from the pre-leaked root, V, LV, VT and LVT, where V was the only one. L adds pinch blocks (Linnhoff and Hindmarsh's pinch split, as one block) at nodes where the rules fail outward; T completes the side with the planner's own DFS from a rule-clean node. Every candidate stays MER and verified, and the key picks among them, so the split sides get smaller networks where Stage S fails or is beaten. With the flag off nothing new runs; the R1 oracle below confirms it bit for bit. Planner (_planner.py) - _Search gains b0=None, the flex frontiers; run() starts from list(b0), else [0.] * F, so the default is today's search. _improve_units and _units_guard gain b0 beside a0 and pass both to every search. Pinch block (_pinch_block) - Only at a node whose rules violation (on the search's analysis, as the DFS sees it) is 'outward'. Cut sets by _cut_sets (None if a demand is flat), CP transport 'mincell', else 'nw-rho-desc'; f = load / m_i, g = load / sum load_j (_cut_fractions), so every flex branch has at least its must branch's CP. All cells start at the node. - Extents: h_i = min over must i's cells of x_max / f, 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). Every branch of a must covers the same parent range, so the must's remix is isothermal; flex branches end apart (a non-isothermal remix the key counts when the flex has a later exchanger). A demand that cannot move more than tolQ fails the transport. - Tick-off: h_i >= rem_i - tolQ gives h_i = rem_i exactly, the last cell takes rem_i less the others and the node is Qm_i exactly (no sliver). - Acceptance at lambda = 1, else the largest lambda found by _SPLIT_BISECT = 30 bisection steps, below _SPLIT_LAMBDA_MIN = 1e-3 none: every cell passes (C) (_cell_margin) and the end node, in parent coordinates (Lemma R), has slack >= -_SPLIT_R_TOL tolQ. Every accepted lambda was checked, so nothing depends on monotonicity. DFS tail (_tail) - The first _SPLIT_PASSES = 3 _SCHEDULE passes from (a, b) at _SPLIT_TAIL_WORK = 0.2 of their budgets, then _improve_units and _units_guard from (a, b). The DFS closes a must within tolQ; the residual is swept into its far-end cell exactly as Stage S does (_s_cells, every item a trunk), else the tail is rejected. A tail is the candidate's last block (kind 'tail', every must at its end); it never yields a node. Driver (_drive) and portfolio - Per node: the exact (R) check; with T, a tail at a rule-clean node that is not the unleaked root (a pre-leaked root qualifies), at most _SPLIT_TAIL_TRIES = 3 per strategy; with L, a pinch block at an outward violation, at most M + F per strategy; else a vertical block (after a pinch block it builds a new coupling from the exact analysis). Failed tails' work counts in the candidate's work. - _CORE_STRATEGIES = _CORE_ORDER = ('V', 'LV', 'VT', 'LVT'); _split_side runs them after Stage S, as before. Strategies that build one network share its signature, so excluding one excludes them all. Corpus check (scratch t5_corpus_check.py on the 38 SPLIT cases' round-0 knots, knots.pkl, and on the 25 constant-CP cases' analytic knots) - All 38 'mer'; on every split side every core candidate, not only the pick, passes _verify_side_cells from the pre-leaked root; picks have mixbad == 0; no errors. - Picks on knots.pkl: Stage S 46, LVT 3, VT 1, LV 1, V 1 (luo below). No side got larger; five got smaller: bagajewicz above 67 -> 46 (VT; every S rule fails there), rtB01 above 10 -> 6 (LV), rtB02, rtB07 and rtB10 above -1 each (LVT). Network bounds (units + extra branches + split stages) that change from T4b: bagajewicz 69 -> 48, rtB01 14 -> 10, rtB02 14 -> 13, rtB07 13 -> 12, rtB10 16 -> 15; the rest as T4b. The analytic constant-CP picks are unchanged (all Stage S). - Planner time, one round on the 38 cases, back to back on this (loaded) machine: 30.4-32.4 s against 25.8-26.8 s with the core V alone; the analytic 25: 17.6 s (T4b 15.7 s). Tests (written first; they failed on the missing b0, _pinch_block and strategies) - test_search_b0_default_is_identical: _Search at b0 None/zeros equals today's, _improve_units likewise; from the node after the first piece the search continues every flex from b and serves every must from a (20+ cases); _improve_units and _units_guard pass (a0, b0) to every search. - test_pinch_block_acceptance: the number rule, the CP rule and a tick-off case ((f 400)/f rounds 6e-14 short) end at Qm exactly with lambda = 1; forbid, used and a non-outward violation give None; a hand-built side where lambda = 1 strands a must gets lambda = 0.875 (1e-8); one where any lambda above 2.5e-7 does gives None, and LV falls back to V. - test_tail_sweeps_the_residual: a flex tolQ/2 short; the tail serves the must exactly; a tail where the search fails is None. - test_core_strategies_reach_mer: the four strategies on the SPLIT dict, five corpus cases, the near-threshold, near-double-pinch and a clean pre-leaked case (CLEAN_PRELEAK: the rules fail at the root and hold at the pre-leaked root, where VT's tail succeeds) and 1/6 of the core fuzz sides: exact cells, (R) at every node, no leak, pinch blocks only at outward violations (check_pinch_block: exact cells, isothermal musts, branch CPs, (R) at the end, tick-off), tails only last, trunk and at rule-clean nodes; summed sizes LVT < LV < V and VT < V. - Existing tests now see four core candidates: test_exhausted_schedule_ splits (the pick is any core strategy, VT here), test_split_exclusion_ by_signature (core only: LV wins, LV and LVT share one network and are excluded together, VT next; the one-candidate part pins _CORE_STRATEGIES = ('V',)), test_stage_s_budget. - Mutation probe (scratch t5_mutation.py): lambda always 1, no tick-off, a pinch block ignoring forbid/used, a tail at the unleaked root, no tail sweep, no tails, no pinch blocks, _Search ignoring b0, _improve_units or _units_guard dropping b0: each fails at least one new test. Test helper fix (_verify_side_cells, the cause of one scratch failure) - On cgm_balanced8 above, LV's and LVT's vertical block after the pinch block has two cells whose approach closes linearly from 8.4e-9 K to exactly 0 (margin 0.0 by _cell_margin; (C) holds). The helper's _max_duty cross-check stopped 0.043 kW short (tolQ 2e-4): _max_duty's linear case stops at the zero crossing, g0 / (sf - sm), and with the branch slopes 1.4e-9 apart (just above _SLOPE_EQ) one ulp of the ~500 K levels in g0 moves that crossing by ~0.3 kW. The cross-check now runs _max_duty against the flex lowered by tolP with tolP = 0, which is (C) exactly (phi - psi >= -tolP) and well conditioned; the direct evaluation beside it is unchanged, so no tolerance is loosened. Deviations from the design, with reasons - The pinch block's acceptance uses the exact analysis (_exact), not the search's: the driver checks every node exactly (T2), and the search's analysis omits slivers up to tolQ, so a node it accepts could fail the driver's next check with _SplitInvariantError. - 'nw-rho-desc' is also tried when the 'mincell' block fails (a demand that cannot move, a forbidden or used pair, lambda below the minimum), not only when 'mincell' finds no transport (it then falls back to the north-west corner itself, and neither exists when sum c < sum m). - A pinch block with a forbidden or used pair is None (V serves the node): _cut_transport has no forbidden pairs. - _pinch_block takes the driver's violation (viol=); _tail returns (block, work) and is a _Block, so the candidate, its verification and its records treat it like any block. With cap1 the tail forbids the earlier blocks' pairs. Validation - Full suite: 517 passed in 362.72s (0:06:02) (T4b: 513 passed in 523.99 s). - ast.parse(feature_version=(3, 12)) on the three changed files. - R1 oracle, planner mode (hxn_synthesis.py and _heat_exchanger_network.py untouched): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 793.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_planner.py | 22 +-- hensmith/_splitting.py | 280 ++++++++++++++++++++++++++++++++++++-- tests/test_hxn_planner.py | 260 ++++++++++++++++++++++++++++++++++- 3 files changed, 540 insertions(+), 22 deletions(-) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index d096f74..99714c9 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -967,11 +967,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 +984,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 +994,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) @@ -1431,10 +1435,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, a0=None): +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, from the - must frontiers `a0` (default: the pinch).""" + 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 @@ -1444,7 +1449,7 @@ def _improve_units(side, pieces, first, cap1, forbid, work_scale, a0=None): found = None for mode in ('restricted', 'full'): srch = _Search(side, mode, cap, False, min(step, total - used), - unit_bound=U - 1, forbid=forbid, a0=a0) + unit_bound=U - 1, forbid=forbid, a0=a0, b0=b0) res = srch.run() used += srch.work if res is not None: @@ -1458,11 +1463,12 @@ def _improve_units(side, pieces, first, cap1, forbid, work_scale, a0=None): return pieces, used -def _units_guard(side, pieces, cap1, forbid, work_scale, a0=None): +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). - The searches start from the must frontiers `a0` (default: the pinch).""" + 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. @@ -1475,7 +1481,7 @@ def _units_guard(side, pieces, cap1, forbid, work_scale, a0=None): srch = _Search(side, mode, _combine_cap(cap, cap1), extra, _GUARD_WORK * work_scale, min_piece=lambda i, j, thr=thr: thr, forbid=forbid, - a0=a0) + a0=a0, b0=b0) res = srch.run() used += srch.work if res is not None: diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 45218d0..c09e307 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -159,6 +159,19 @@ (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, 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. + Candidates ---------- A candidate (:class:`_Candidate`) is one verified split plan of a side. Its @@ -191,8 +204,8 @@ from ._planner import (Exchanger, _APPROACH_TOL, _LevelCurve, _REL_Q, _SCHEDULE, _Search, _Side, _SidePlan, _THRESHOLD_TOL, - _WORK_EVENT, _combine_cap, _improve_units, _merge, - _units_guard) + _WORK_EVENT, _combine_cap, _improve_units, _max_duty, + _merge, _units_guard) __all__ = () @@ -203,11 +216,15 @@ _SPLIT_MIX_CAP = 2 # mixers per curved stream and side (the key) _ISO_TOL = 10. # isothermal remix, x tolQ _CORE_ORDER = ('V', 'LV', 'VT', 'LVT') # candidate order of the core -_CORE_STRATEGIES = ('V',) # the core strategies `_drive` runs +_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: the first `_SCHEDULE` passes +_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 @@ -759,8 +776,9 @@ class _Block: Parameters ---------- - kind : {'vertical', 'pinch'} - How the block was built. + 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). @@ -1086,6 +1104,222 @@ def _vertical_block(side, a, b, prev=(), forbid=frozenset(), used=frozenset(), 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)`` (design 2.4.4): 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(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)`` (design 2.4.5): the planner's + unsplit search completes the side from there. + + 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 + 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(side, 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(side, pieces, work, cap1, forbid, scale, + a0=a, b0=b) + work += w + pieces, w = _units_guard(side, 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).""" @@ -1139,6 +1373,15 @@ def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., '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') @@ -1146,13 +1389,33 @@ def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., 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'{strategy}: core node (R): slack {d.slack!r} after ' f'{len(blocks)} blocks') - blk = _vertical_block(side, a, b, blocks[-1:], forbid, used, d) + 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)) @@ -1164,7 +1427,8 @@ def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., leak[i] += x return _Candidate(strategy, (1, _CORE_ORDER.index(strategy)), side, a0, [c for blk in blocks for c in blk.cells], leak, - math.fsum(blk.work for blk in blocks), Qmin, blocks) + math.fsum(blk.work for blk in blocks) + lost, Qmin, + blocks) # %% Candidates diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index e40f5e2..296c9a3 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -1278,7 +1278,13 @@ def _verify_side_cells(side, cells, a0=None, leak=None): 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 - assert P._max_duty(bm, am, bf, bb, c.x, tolP) >= c.x - tolQ + # (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 @@ -2035,7 +2041,8 @@ def test_exhausted_schedule_splits(monkeypatch): assert infos for name, sp in infos.items(): s = net['plan'].info['sides'][name] - assert s['proof'] is None and s['method'] == 'split-V' + 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 assert s['work'] > off['plan'].info['sides'][name]['work'] @@ -2091,11 +2098,19 @@ def plan(**kw): _split_exclude={name: {sp['signature']}}) assert pr['signature'] == sp2['signature'] assert pr['candidates'][sp['candidate']] == 'excluded' - # the core alone (Stage S off): one core candidate; excluding its - # signature still returns it (a side's last candidate is never - # excluded) + # 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) @@ -2551,7 +2566,7 @@ def test_stage_s_budget(monkeypatch): assert net['status'] == 'mer' check_split_network(streams, dT, net) (_, sp), = split_infos(net['plan']).items() - assert sp['candidate'] == 'V' + 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'} @@ -2652,3 +2667,236 @@ def best(case, name): 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 + 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 + 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 + a, b = [1.] * side.M, [2.] * side.F + 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'] From de5000bba4ec16bc1d0b4c65c218ca8ee628cd89 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 06:04:51 -0700 Subject: [PATCH 08/24] address review A: exact core residuals, strict split searches, diagnostics Milestone A review of the planner-level splitting (T1-T5b). Every finding was reproduced before it was acted on; all were confirmed. Major 1, the core backstop failed on near-flat pieces. `_exact` reused `_Side._R`, whose multi-piece path evaluates a stream's residual as `B * (L - D)` with B and D cumulative sums of the pieces' slopes and offsets. On a near-flat piece (a glide of 1e-7 K over 10 of heat has a slope of 1e8) the sums cancel catastrophically and the residual drifts with the level, past the stream's own heat (q = [0, 10, 110, 115], y = [-110.0000001, -110, -60.0000004, -60.0000004] gives 115.00000006593 at the top level, Q = 115). Every core invariant reads these arrays, so on jittered data all four core strategies raised 'core node (R): slack -2.13e-08' (a 0.026 tolQ root deficit) and the side fell back to best effort. `_exact` now returns an `_ExactSide`, whose `_R` adds each piece's clipped share directly (`clip((L - lo)/(hi - lo), 0, 1) ln`, flats by level), so a share never leaves [0, ln] and the arrays are exact to round-off. The search keeps its own arrays, so the unsplit planner is unchanged (R1). Follow-up for the user, not done here: the same drift reaches the unsplit DFS through `_Side._R` (57 tolQ in the one-stream example); fixing it there changes R1. Minor 2, Stage S plans failed verification on near-parallel pairs. The planner's `_max_duty` takes linear slopes within a relative `_SLOPE_EQ` (1e-9) as parallel and returns the full limit, so a converging pair may close by 1e-9 times its level span: on jittered case #92, S:demand paired a branch with a flex whose slopes differ by 9.997e-10 over 25 K, and `_Candidate` found the cell failing (C) by -2.5e-8 against tolP 2.7e-9 (an invariant error, the candidate lost). The cause is in the search, so it is fixed there rather than after it: `_Search` now asks its side for the max duty (`_Side.max_duty`, the planner's `_max_duty`: bit-identical for the unsplit planner), and the split path's searches (Stage S's branched sides, the core's DFS tails and their unit improvements) run on a `_StrictSide`, whose `_max_duty_strict` applies the parallel shortcut only where the gap at the limit stays >= -tolP, and otherwise returns the duty of any converging pair, max(g0, 0)/(sf - sm). `_pinch_block`'s extents use it too. Shortening the failing cell afterwards (the review's first option) would have cut it from 25 to about 2.7, far beyond the tolQ the residual sweep may move, so it amounts to rejecting the candidate; with the strict search S:demand now yields a verified candidate on #92. The module docstring (Lemma 1, Stage S, Strategies) and the hand-off design's Theorem S step 1 now say that `_cell_margin` establishes (C) at tolP and the split path searches strictly. Minor 3-8: `_split_side` no longer drops a failed attempt. It always returns a side plan; without a candidate it has status 'failed' and the attempt's split info (candidate and signature None, the reasons, the errors, the root deficit), which `_plan_side` keeps on its best-effort plan, with the work spent. The work spent now also counts every Stage S rule's DFS passes, not only those that produced a candidate (`_stage_s` returns its work). The unused `d` parameter of `_split_side` and the dead `_Candidate.a0` are removed; the key reads `units + branches` (units + extra branches + split stages). The generator name is no longer repeated in error messages ('S:demand: S:demand: ...'). Code and tests no longer cite the locally excluded design document (design 2.x, MF4/5/9/19, R2, R1 oracle); they point to the module docstring sections or say what the label meant. Tests (major 9 and minor 10-16): - check_split_network asserts `Split.isothermal` against the walked branch ends (parent-equivalent, within _ISO_TOL tolQ of H_mix) and that a non-isothermal remix is the stream's last item; test_split_cases_reach_mer asserts mixbad == 0 for the pick. - test_search_b0_default_is_identical spies on a real side (M, F >= 1) with distinct a0/b0; test_vertical_block_forbid_and_used counts its avoid_recycle candidates (it checked none: coarsening was off there); test_exhausted_schedule_splits asserts the work handed to the split path equals the best effort's and the side's work is that plus every core candidate's; the docstring-phrase assert is dropped; the slow fixture comment names the one fixture skipped. - test_candidate_key_and_signature covers the mixer cap (three isothermal stages of a curved flex: mixbad 1; two: 0; a straight flex: 0). - New: test_exact_residual_arrays_on_near_flat_pieces (exact arithmetic reference), test_core_on_near_flat_pieces_reaches_mer (case #740), test_split_searches_keep_near_parallel_pairs_feasible (case #92), test_failed_split_keeps_its_diagnostics, test_split_signature_is_knot_independent (knots 1 vs 3 on the SPLIT dict and three corpus cases), test_curved_split_plans_walk_on_the_knots (plan_network on point-load and CP-change variants of the number and CP rules plus 60 pinch-centred curve problems, checked by an independent knot walk: records, fractions, mixes, isothermal flag, closure, the approach at every knot, MER against knot_cascade). Mutations checked in scratch: the isothermal flag forced False, or on the scale instead of tolQ, or exact; the mixer cap as >=, on n > 1, or dropped; the strict max duty made lenient; `_exact` on `_Side`: each fails a test. Validation: the review's jittered piecewise probe (reviewA_probe4, in scratch) found, before: 1 of 1000 plans not MER (full portfolio, seed 2) with 71 V/LV/LVT and 6 Stage S invariant errors, and 6 of 500 not MER with V alone. After: full portfolio, seed 2: 1000 problems, 142 split, 0 not MER, errors {} V alone, seed 0: 500 problems, 74 split, 0 not MER, errors {} V alone, seed 2: 500 problems, 79 split, 0 not MER, errors {} Python 3.12 syntax (ast.parse, feature_version=(3, 12)) of the three changed files: ok. Full suite (-m pytest . --disable-numba=1 -q -p no:cacheprovider): 523 passed in 300.97s (0:05:00) (517 before, +6 new tests) R1 oracle --check (planner mode; hxn_synthesis.py and _heat_exchanger_network.py unchanged): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 660.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/_planner.py | 41 ++-- hensmith/_splitting.py | 240 ++++++++++++++++------- tests/test_hxn_planner.py | 398 +++++++++++++++++++++++++++++++++++--- 3 files changed, 573 insertions(+), 106 deletions(-) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index 99714c9..63a3689 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -676,6 +676,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 @@ -1156,7 +1158,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 @@ -1180,8 +1182,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]) @@ -1343,7 +1345,9 @@ class _SidePlan: 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.""" + 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', 'cells', 'split') @@ -1395,10 +1399,15 @@ def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1., # 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, d, proof, cap1, forbid, + sp = _splitting._split_side(side, proof, cap1, forbid, work_scale, 0., split) - if sp is not None: + 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 @@ -1419,13 +1428,15 @@ def _plan_side(side, cap1=False, forbid=frozenset(), work_scale=1., be = _best_effort(side, None, cap1, forbid, work_scale, work) if split: from . import _splitting - # below R2's 1e-12 of the scale: no best-effort penalty R2 - # would see is accepted without trying the split path + # 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, d, None, cap1, forbid, + sp = _splitting._split_side(side, None, cap1, forbid, work_scale, be.work, split) - if sp is not None: + 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) @@ -2023,11 +2034,13 @@ class Plan: ``Q_hot - Q_hot_target`` (>= 0). info : dict ``sides`` (per side: status, method, work, proof, gaps, units, and - with `stream_splitting` also ``split``: None for an unsplit side, - else a dict with the chosen ``candidate``, its network - ``signature``, every ``candidates`` key or failure reason, + 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``), + 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``, diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index c09e307..7974485 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -55,9 +55,14 @@ ``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. It is equivalent to the -planner's ``_max_duty`` on branch-scaled curves; every cell of a split plan -is verified this way before the plan is accepted. +: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 ----------------- @@ -95,7 +100,8 @@ 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, under a unit bound from 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 @@ -167,8 +173,8 @@ 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, its -must residuals swept as in Stage S. A tail never yields a node, so every +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. @@ -189,7 +195,9 @@ The previous refine round's pick is tried first and kept if its signature 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. +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 @@ -203,9 +211,10 @@ import numpy as np from ._planner import (Exchanger, _APPROACH_TOL, _LevelCurve, _REL_Q, - _SCHEDULE, _Search, _Side, _SidePlan, _THRESHOLD_TOL, - _WORK_EVENT, _combine_cap, _improve_units, _max_duty, - _merge, _units_guard) + _SCHEDULE, _SLOPE_EQ, _Search, _Side, _SidePlan, + _THRESHOLD_TOL, _WORK_EVENT, _combine_cap, + _improve_units, _max_duty, _merge, _slope1, + _units_guard) __all__ = () @@ -357,6 +366,47 @@ def _cell_margin(side, cell): 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: @@ -720,17 +770,54 @@ class _SplitInvariantError(Exception): """ +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. + `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 _Side(side.name, side.musts, side.flexes, 0., side.tolP) + return _ExactSide(side.name, side.musts, side.flexes, 0., side.tolP) def _preleak_max(side): @@ -1107,9 +1194,9 @@ def _vertical_block(side, a, b, prev=(), forbid=frozenset(), used=frozenset(), def _pinch_block(side, a, b, forbid=frozenset(), used=frozenset(), viol=None): """ - A pinch block from node ``(a, b)`` (design 2.4.4): Linnhoff and - Hindmarsh's pinch split at an outward violation of the rules, as one - block of the core. + 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 ---------- @@ -1186,8 +1273,8 @@ def extents(rows): 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(bm, f * a[i], bf, g * b[j], - min(f * rem, g * (Qf[j] - b[j])), tolP) + 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 @@ -1253,8 +1340,9 @@ def at(rows, h, tick, lam): def _tail(side, a, b, cap1=False, forbid=frozenset(), work_scale=1.): """ - A DFS tail from core node ``(a, b)`` (design 2.4.5): the planner's - unsplit search completes the side from there. + 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 ---------- @@ -1291,12 +1379,13 @@ def _tail(side, a, b, cap1=False, forbid=frozenset(), work_scale=1.): 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(side, mode, cap, extra, budget * scale, forbid=forbid, + srch = _Search(ss, mode, cap, extra, budget * scale, forbid=forbid, a0=a, b0=b) pieces = srch.run() work += srch.work @@ -1304,10 +1393,10 @@ def _tail(side, a, b, cap1=False, forbid=frozenset(), work_scale=1.): break if pieces is None: return None, work - pieces, w = _improve_units(side, pieces, work, cap1, forbid, scale, + pieces, w = _improve_units(ss, pieces, work, cap1, forbid, scale, a0=a, b0=b) work += w - pieces, w = _units_guard(side, pieces, cap1, forbid, scale, a0=a, b0=b) + 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)]) @@ -1396,7 +1485,7 @@ def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., d = ex.analyse(a, b) if d.slack < lim: raise _SplitInvariantError( - f'{strategy}: core node (R): slack {d.slack!r} after ' + 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) @@ -1425,7 +1514,7 @@ def _drive(side, a0, strategy, cap1=False, forbid=frozenset(), work_scale=1., used |= blk.pairs for i, x in blk.leak.items(): leak[i] += x - return _Candidate(strategy, (1, _CORE_ORDER.index(strategy)), side, a0, + 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) @@ -1546,8 +1635,6 @@ class _Candidate: _CORE_ORDER)`` for the core. side : _Side The side. - a0 : sequence[float] - The pre-leaked root. cells : list[_Cell] The cells (stage keys set). leak_by_must : sequence[float], optional @@ -1595,15 +1682,14 @@ class _Candidate: _SplitInvariantError If an exchanger fails (C) (`_cell_margin`). """ - __slots__ = ('name', 'order', 'cells', 'a0', 'leak_by_must', 'work', + __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, a0, cells, leak_by_must=None, + 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.a0 = [float(x) for x in a0] 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) @@ -1617,7 +1703,7 @@ def __init__(self, name, order, side, a0, cells, leak_by_must=None, margin, t = _cell_margin(side, c) if not margin >= -tolP: raise _SplitInvariantError( - f'{name}: {c!r} fails (C) by {margin!r}') + 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 @@ -1655,7 +1741,7 @@ def key(self): 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 - self.stages) + self.stages, + self.units + self.branches, # units + extra branches + stages self.mixers, self.order) def __repr__(self): @@ -1748,7 +1834,7 @@ def inward(curves, front): def _cut_transport(dem, cap, rule): """ - Branches that satisfy the pinch rule at a cut (design 2.3.2). + Branches that satisfy the pinch rule at a cut (module docstring, 'Stage S'). Parameters ---------- @@ -1984,7 +2070,7 @@ 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 merges into its largest sibling (MF9), and a parent left with + parent 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. """ @@ -2034,12 +2120,12 @@ def _branched_side(side, items, a0): else: c = side.flexes[p] flexes.append(c if f == 1. else _branch_curve(c, f, 'flex')) - return _Side(side.name, musts, flexes, side.tolQ, side.tolP), a + 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 (design 2.3.3). + 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. By Lemma R the @@ -2106,7 +2192,7 @@ def _pinch_split(side, a0, proof, rule): d = bside.analyse(a, [0.] * bside.F) if abs(d.slack - slack0) > 10. * side.tolQ: raise _SplitInvariantError( - f'S:{rule}: the branched root has slack {d.slack!r}, the ' + 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: @@ -2168,7 +2254,7 @@ def _fold(items, M, used): def _s_cells(side, items, a0, pieces): """ - Cells of a Stage S plan (design 2.3.4, steps 3-5): the DFS `pieces` of + 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. @@ -2201,7 +2287,7 @@ def _s_cells(side, items, a0, pieces): 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 (MF4): if ``|r| <= tolQ``, `r` is added to + 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. @@ -2278,8 +2364,9 @@ def _s_search(bside, a0, cap1, forbid, work_scale, bound, room): def _s_candidate(name, n, side, items, a0, pieces, cap1, work, split, errors, reasons): - """The Stage S candidate of DFS `pieces` (design 2.3.4, steps 3-7), or - None; its key or the reason is recorded in `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 @@ -2288,7 +2375,7 @@ def _s_candidate(name, n, side, items, a0, pieces, cap1, work, split, reasons[name] = 'repeated pair' return None try: - c = _Candidate(name, (0, n), side, a0, cells, None, work, + c = _Candidate(name, (0, n), side, cells, None, work, split['Qmin']) except _SplitInvariantError as e: errors.append(f'{name}: {e}') @@ -2303,7 +2390,7 @@ 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 (design 2.3.4). + DFS (module docstring, 'Stage S'). Parameters ---------- @@ -2337,8 +2424,11 @@ def _stage_s(side, a0, proof, cap1, forbid, work_scale, split, errors, Returns ------- - list[_Candidate] + 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 ----- @@ -2410,7 +2500,7 @@ def _stage_s(side, a0, proof, cap1, forbid, work_scale, split, errors, runs[id(c)] = (bside, a0b, bforbid, items, pieces) live = [c for c in out if not c.excluded] if not live: - return out + 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)] @@ -2425,7 +2515,7 @@ def _stage_s(side, a0, proof, cap1, forbid, work_scale, split, errors, out[out.index(best)] = c else: reasons[best.name] = best.key() - return out + return out, used + w1 + w2 # %% Portfolio and selection @@ -2436,28 +2526,31 @@ def _excluded(split, side_name, cand): return cand.signature in split['exclude'].get(side_name, ()) -def _side_plan(side, best, cands, reasons, a0, delta, errors, work, proof): - """The side plan of the chosen candidate `best`. Each must's gap is its - pre-leak plus its leak, so the penalty is truthful.""" +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 + math.fsum(c.work for c in cands), proof, + return _SidePlan([], gaps, 'mer', 'split-' + best.name, work, proof, cells=best.cells, split=info, units=best.units) -def _split_side(side, d, proof, cap1, forbid, work_scale, work, split): +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. - d : _Residual - The search's analysis of the root, which triggered the split. The - core analyses the root again, exactly (`_preleak_root`). + 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. @@ -2476,11 +2569,15 @@ def _split_side(side, d, proof, cap1, forbid, work_scale, work, split): Returns ------- - _SidePlan or None - A side plan with status 'mer', method ``'split-'``, the - cells of the chosen candidate and the ``split`` info; None only if - no candidate exists: the root deficit exceeds `_preleak_max`, or - avoid_recycle's pairs left no candidate. + _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 ----- @@ -2496,14 +2593,15 @@ def _split_side(side, d, proof, cap1, forbid, work_scale, work, split): `_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`). + (`_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 = split['prefer'].get(side.name) - cands, errors, reasons = [], [], {} + cands, errors, reasons, spent = [], [], {}, [work] def generate(name): try: @@ -2516,6 +2614,7 @@ def generate(name): 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 @@ -2525,10 +2624,15 @@ def generate(name): return c def stage_s(**kw): - cs = _stage_s(side, a0, proof, cap1, forbid, work_scale, split, - errors, reasons, **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 first = [] if s_ok and prefer is not None and prefer[2:] in _SPLIT_RULES and ( @@ -2538,8 +2642,7 @@ def stage_s(**kw): first = [generate(prefer)] for c in first: if c is not None and not c.excluded: - return _side_plan(side, c, cands, reasons, a0, delta, errors, - work, proof) + return plan(c) # the portfolio: Stage S, then the core if s_ok: stage_s(skip=prefer) @@ -2551,10 +2654,9 @@ def stage_s(**kw): if _SPLIT_FIRST_WINS and c is not None and not c.excluded: break if not cands: - return None + return plan(None) live = [c for c in cands if not c.excluded] or cands - return _side_plan(side, min(live, key=_Candidate.key), cands, reasons, - a0, delta, errors, work, proof) + return plan(min(live, key=_Candidate.key)) # %% Plan records diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 296c9a3..c705dff 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -1485,6 +1485,128 @@ def test_core_node_check_is_exact(monkeypatch): 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.])], @@ -1549,12 +1671,17 @@ def test_vertical_block_forbid_and_used(monkeypatch): 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): @@ -1625,7 +1752,7 @@ def test_candidate_key_and_signature(): B = SP._Cell def cand(cells, name='V', order=(1, 0), **kw): - return SP._Candidate(name, order, side, [0., 0.], cells, **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), @@ -1671,17 +1798,30 @@ def cand(cells, name='V', order=(1, 0), **kw): 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, [0.], par).touch == 0 + 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, [0.], par).touch == 1 + 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, [0.], [B(0, 0, 5., 0., 1.)]) + SP._Candidate('V', (1, 0), side, [B(0, 0, 5., 0., 1.)]) # %% 17. Split plans end to end (records and the switch) @@ -1693,9 +1833,11 @@ def cand(cells, name='V', order=(1, 0), **kw): def split_infos(plan): - """``{side: info['sides'][side]['split']}`` of the split sides.""" + """``{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')} + 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): @@ -1709,7 +1851,7 @@ def check_split_network(streams, dT, net, tol=1e-6, exact=True, mer=True): 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 (MF4). With `exact`, the penalty + 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 @@ -1759,13 +1901,20 @@ def run(ns, T, f, branch): 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 = 0. + D, ends = 0., [] for b, (f, ns) in enumerate(zip(fr, item.branches)): assert ns - run(ns, T, f, (k, b)) + 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] @@ -1786,7 +1935,7 @@ def run(ns, T, f, branch): assert abs(Qc - Qc_t) <= qtol + preleak else: # never below the targets assert Qh >= Qh_t - qtol and Qc >= Qc_t - qtol - # MF4: the split machinery adds no heat error + # 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], @@ -1815,8 +1964,8 @@ def run(ns, T, f, branch): 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).""" - sp = side_info['split'] - return side_info['units'] + (sp['branches'] - sp['stages']) + sp['stages'] + # units + extra branches (branches - stages) + split stages + return side_info['units'] + side_info['split']['branches'] def repeated_pairs(net): @@ -1852,6 +2001,7 @@ def test_split_cases_reach_mer(name): 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]) @@ -1859,9 +2009,8 @@ def test_split_cases_reach_mer(name): 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; the module says so + # inside the planner's 1e-12 heat closure assert SP._SPLIT_R_TOL * P._REL_Q == pytest.approx(1e-14) - assert "``1e-12`` heat closure" in SP.__doc__ n = 0 for dT, rows in [*SPLIT.values(), NEAR_THRESHOLD, NEAR_DOUBLE_PINCH]: streams = streams_from(rows) @@ -1872,7 +2021,7 @@ def test_split_plan_heat_closes_exactly(): 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 (MF4) + 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) @@ -1881,8 +2030,9 @@ def test_split_plan_heat_closes_exactly(): def test_splitting_off_adds_nothing(): - # every fixture but the two slowest (4.5 s and 3 s of best effort; the - # R1 oracle proves the bitwise identity on the whole corpus) + # 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), @@ -2024,6 +2174,40 @@ def test_split_side_candidates_and_the_hook(): _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 @@ -2034,6 +2218,20 @@ def test_exhausted_schedule_splits(monkeypatch): 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) @@ -2043,8 +2241,9 @@ def test_exhausted_schedule_splits(monkeypatch): 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 - assert s['work'] > off['plan'].info['sides'][name]['work'] + # 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): @@ -2138,6 +2337,155 @@ def plan(**kw): assert other['candidate'] == 'V' +def test_split_signature_is_knot_independent(): + # the refine loop re-plans on refined knots and excludes (or prefers) + # by signature, so the signature holds nothing knot-dependent: extra + # collinear knots 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.) @@ -2472,8 +2820,8 @@ 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) + cands, _ = SP._stage_s(side, a0, proof, cap1, forbid, 1., split, + errors, reasons, **kw) return cands, errors, reasons @@ -2616,7 +2964,7 @@ def test_splitting_with_avoid_recycle_never_repeats_a_pair(monkeypatch): def test_split_side_keeps_cells_below_Qmin(): - # MF5: a split side's exchangers below Qmin count in the key but are + # 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'] @@ -2677,6 +3025,7 @@ def test_search_b0_default_is_identical(monkeypatch): # 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(): @@ -2689,6 +3038,8 @@ def test_search_b0_default_is_identical(monkeypatch): 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)) @@ -2718,7 +3069,8 @@ def __init__(self, *args, **kw): super().__init__(*args, **kw) monkeypatch.setattr(P, '_Search', Spy) monkeypatch.setattr(P, '_GUARD_FACTOR', 0) # run the guard - a, b = [1.] * side.M, [2.] * side.F + 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) From 6dbb33b5ad418751776590f2cec3709b148744e4 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 06:43:32 -0700 Subject: [PATCH 09/24] add split-aware exact checks, walk and branch ports to hxn_synthesis Task T6 of the stream-splitting design (section 4.4). The synthesizer's exact-state layer assumed every exchanger runs its streams' full flow: `_walk` moved each stream by the full duty along `plan.stages`, and `_exchanger_approach`/`_shrink` mapped duty position q to H_in - q. On a branch of flow fraction f the parent-equivalent enthalpy moves by Q / f, so a split plan's branch exchangers would have been checked, repaired and realized as full-flow exchangers (e.g. rtB05's walk put C1's cold branch outlet 168582 kJ/hr short of the plan's). - `_walk`: `if not plan.splits:` the old loop verbatim; otherwise walk `plan.paths`: trunk exchangers as before, a split's branches from the split enthalpy by duty / f (`_walk_split`), and the mix at H_split -/+ the fsum of the surviving branch duties (the planner's `_walk_paths` arithmetic, so the ends equal the planner's record enthalpies bit for bit on the hand-built plan). Still the 3-tuple `(ends, last, pair_index)`; `ends` are parent-equivalent. A dropped branch exchanger shortens its branch, an emptied branch bypasses (ends at H_split). - `_split_nodes(plan, duties, ends)` (new): `first_nodes[j]` = the first surviving exchanger of every branch of the split that is stream j's first node (() otherwise), `split_ends[k] = (H_split, [H_end_b], H_mix)` from the walk arithmetic, None for a split with all branches dropped (not realized; the stream passes it unchanged). - `_exchanger_approach(..., fh=1., fc=1.)`: H_out = H_in -/+ Q / f, knot positions (H_hot_in - H) fh and (H_cold_out - H) fc, exact states at H_hot_in - q / fh and H_cold_out - q / fc; violation states are parent-equivalent, so `_refine_knots` is unchanged. At f = 1 every expression is the old one bit for bit (x / 1. and x * 1. are exact; the association of H_lo + H_in - q is kept). - `_shrink(..., fh=1., fc=1.)`: the same mapping; the CP guess uses the branches' f CP. Monotonicity holds on branches (cold position H_cold_in + (Q - q) / fc falls with Q, the hot one does not depend on Q). - `_exact_approach` and `_repair` pass `e.hot_frac, e.cold_frac`. - `_enthalpy_limit` call site (`_realize`): judged on the full-flow state (parent basis) and multiplied by f; the branch inlet is the full stream's state at the branch inlet enthalpy (the real inlet for the first exchanger of every branch of a first-node split, via `_split_nodes`), scaled by f. `plan.splits` is empty without stream splitting, so nothing changes there. Tests (tests/test_hxn.py, written first; 7 failed / 1 passed before the implementation for the right reasons: no fh keyword, no `_split_nodes`, the unsplit walk): - test_branch_exchanger_approach_matches_scaled_curve[constant_cp, glide] (glide: rtB07's water/methanol boiler C3 vs hot water): the check with fractions on the parent curves equals `_exchanger_approach` on the StreamCurves of the scaled streams (worst difference 0.0 K), every state within 1e-9 K of the scaled curve's T_exact (measured 1.0e-10 K), both branches at the same duty, every knot inside the branches a position, a 0.5 K violation found with a finite T_min_app, `_exact_approach` passes the fractions, f = 1 identical to the default. - test_shrink_with_fractions_is_monotone[constant_cp, glide]: approach non-increasing in the duty, feasible set [0, Q'], `_shrink` tight and equal to the scaled-curve shrink within 1e-8 Q, closed form for constant CP. - test_walk_split_paths: T3's hand-built records plan: ends == the planner's record enthalpies exactly, full / dropped / shrunk / all-empty branches, pair_index, `_split_nodes`, and the unsplit path. - test_realize_split_branch_ports[smith2005_ex18_2_split, rtB05]: split plans on round-0 grid knots: walk vs planner, `_split_nodes` vs Split.H_split/H_mix, exact approach clean (smith), realized branch HXprocess units at f of the flow with inlet/outlet H at f x the planned parent state (<= 2e-14 x duty measured), H_lim = f x the parent's, first-node branches on the real inlet (smith splits at an inlet, rtB05 after a trunk exchanger), internal approach >= dT (smith). - test_repair_shrinks_branches_with_fractions: dT + 5 K on smith: one pass, `_shrink` called with the fractions, the repaired plan clean on the exact states and on the realized units. Mutation check (scratch t6_mutation.py): 12 source mutations; all but one fail a test. The survivor drops fc from the cold stream's knot-screen (`planned`) position; with fc <= 1 that only underestimates the planned approach, so more intervals get the exact check and no result changes. Validation: - Full suite: 531 passed in 224.92s (0:03:44) (523 before, +8 new). - ast.parse(feature_version=(3, 12)) on both changed files. - R1 oracle --check (full mode, hxn_synthesis.py touched): R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 108.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) - R1 oracle --check (planner mode): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 430.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- hensmith/hxn_synthesis.py | 217 +++++++++++++++++++----- tests/test_hxn.py | 343 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 40 deletions(-) diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 74d04d0..d61c26f 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -15,6 +15,7 @@ """ from collections import namedtuple import heapq +import math import re import numpy as np import biosteam as bst @@ -22,6 +23,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 __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', 'synthesize_network', 'plot_pinch_diagram') @@ -804,12 +806,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 +843,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 +859,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 +882,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 +894,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 +904,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 +947,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 +963,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 @@ -1040,18 +1064,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 + without any 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 +1111,81 @@ 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`). 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. + """ + 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)) + first_nodes = {} + 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 + firsts = [next((n for n in ns if n in duties), None) + for ns in item.branches] + firsts = tuple(n for n in firsts if n is not None) + if firsts: + first_nodes[j] = firsts + break + return first_nodes, split_ends + def _discard(units): """Remove units, and the streams connected to them, from the registry of the active flowsheet (after a failed realization).""" @@ -1078,10 +1201,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 +1223,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 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 6d90b43..c287e55 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -1345,5 +1345,348 @@ 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 test_hxn_planner import records_case +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 are the unsplit check, bit for bit + for T_min_app in (inf, worst + 0.5): + assert (approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, + T_min_app, 1., 1.) + == approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, + T_min_app)) + +@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') + xs = np.linspace(0.05, 1., 20) * 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) + assert (shrink(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, T_min_app, + 1., 1.) + == shrink(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, + T_min_app)) + +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.)]) + # 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.)]) + # 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 = hxn_synthesis._split_nodes(plan, duties, ends) + 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 a cold stream 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 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()) + if __name__ == '__main__': pytest.main([__file__, '-q', '-p', 'no:cacheprovider']) From 457d759f918f00da9ab224ac46e202109e80f78c Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 07:25:58 -0700 Subject: [PATCH 10/24] realize stream splits and add the split-aware refine loop to synthesize_network synthesize_network gains stream_splitting=False (after info). With it on, split plans are realized end to end and the exact-check loop can re-plan a split side; with it off, nothing changes (R1, oracle below). Realization (design 4.5). New StreamSplit (documented, not in __all__) and _realize_splits, run after _realize inside the same retry loop, only when plan.splits. For every split that keeps an exchanger (an all-empty split is a trunk, as _split_nodes says), a chain of len(fractions) - 1 bst.Splitter units, IDs Split__[_k] then _b, element c splitting f_c / fsum(f_c..f_n) (tail sums: no cancellation, the last element's rest is its exact complement) and feeding element c+1 through its second outlet; the feed is the real inlet (_copy + _first_inlet) when the split is the stream's first node, as its branch exchangers' inlets already are, else state_at_H(H_lo + H_split). Then one bst.Mixer(rigorous=True, thermo=outlet.thermo) Mix__[_k]: inlet b is f_b of the stream at the mix state when every branch ends within _ISO_TOL tolQ of H_mix (the planner's isothermal criterion, evaluated on the realized walk so a dropped or shrunk branch is handled), else at its branch end; the outlet starts at the planned state. The ordinal k counts realized splits per stream and side in flow order; position counts live trunk exchangers before the split. Deviations use one rule for every mixer: |H - sum H_in| > _DUTY_TOL x stream duty or |T - T_plan| > _MIX_T_TOL = 1e-7 K, reported as dict(ID, T_plan, T, H_plan, H) with H_plan = sum H_in (the checked quantity). A Splitter or Mixer whose _run raises discards every unit built and raises _SplitRealizationError(k, [(n, ID)], error); the retry loop drops that split's branch exchangers (the split becomes a trunk), so every failure removes at least one exchanger and the loop ends. Utility inlets, stream_HXs_dict (plan.stages is topological) and the info utility sums need no change, as designed. Refine loop (design 4.6). n_rounds = _MAX_REFINE + (_MAX_SPLIT_RETRY = 2 if stream_splitting). After _MAX_REFINE, a round runs only if a violating exchanger lies on a split side whose signature is newly excluded (keyed on split['candidate'] is not None, per review A); otherwise the loop stops where the default loop stops. The best MER plan (fewest violating exchangers, earliest round) is kept and restored with its knots if the retries end worse. info gains 'stream_splitting', 'splits' and 'split_deviations' only when the flag is on. ValueError without info. Stickiness: signature-aware (the T5b refinement, adopted). _split_prefer is now side -> (candidate name, network signature) of the previous pick, and _split_side takes the regenerated preferred candidate as is only if it is live AND its signature is unchanged; otherwise the whole portfolio runs (the preferred generator not re-run; its candidate competes on the key). Why: T5b found that on rtB07's refined knots the preferred S:partner below grows from 3 to 4 exchangers under a new signature, so name-only stickiness costs an exchanger. Measured at synthesis (scratch t7_probe.py): rtB07 now picks S:demand below in round 1, 8 process exchangers in 1.16 s, against 9 in 0.57 s with name-only stickiness; the other 37 SPLIT cases need no refine round and are unchanged. Guarantees kept: R3 (no split side: prefer == {}, exclude never grows, the loop is the default one); the MER argument of 2.7 step 2 (a round either takes a live MER candidate or runs the whole portfolio including V from a0); termination (the loop is bounded by n_rounds; the retry exclusion still makes every retry plan a different network, since an unchanged preferred signature is excluded and a changed one is only taken through the key among live candidates). Planner tests updated to the (name, signature) form, plus the stale-signature case. Tests (written first; they failed on the missing keyword and on the name-only prefer): test_synthesize_network_splitting_requires_info, test_synthesize_network_with_splitting (smith2005_ex18_2, rtB05, the crude unit with >= 3-branch chains, rtB03 with a vapor split at its inlet), test_split_mixer_outlet_is_the_planned_state (+ the deviation rule), test_unsplit_refine_loop_identical_with_splitting (R3 through the loop), test_split_retry_changes_the_candidate, test_split_retry_restores_the_best_plan (restore; beyond the design list), test_split_realization_failure_merges_the_split; test_split_exclusion_by_signature extended. 15 source mutations (scratch t7_mutation.py/.out) each fail a test. Scratch probes: every one of the 38 SPLIT cases synthesizes to 'mer' with no split deviation, drop or repair (t7_probe_rt.out, t7_probe_cp.out; 8.8 s and 10.8 s in total). Validation: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 433.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 109.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Full suite: 542 passed in 227.44s (0:03:47) (531 after T6; +11 new test items) ast.parse(feature_version=(3, 12)) clean on every changed file. Co-Authored-By: Claude Opus 5.5 --- hensmith/_planner.py | 8 +- hensmith/_splitting.py | 14 +- hensmith/hxn_synthesis.py | 314 +++++++++++++++++++++++++++++++++++- tests/test_hxn.py | 327 ++++++++++++++++++++++++++++++++++++++ tests/test_hxn_planner.py | 23 ++- 5 files changed, 664 insertions(+), 22 deletions(-) diff --git a/hensmith/_planner.py b/hensmith/_planner.py index 63a3689..9155eee 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -2148,9 +2148,11 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., _split_exclude : dict[str, set[tuple]], optional Private (the refine loop): per side, network signatures a split candidate may not have unless it is the side's last candidate. - _split_prefer : dict[str, str], optional - Private (the refine loop): per side, the candidate tried first - (the previous round's pick). + _split_prefer : dict[str, tuple[str, tuple]], optional + Private (the refine loop): per side, the previous round's pick as + ``(candidate name, network signature)``. That candidate is tried + first and taken as is if it is live and plans the same network; + otherwise the side's whole portfolio runs. Returns ------- diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 7974485..fec2556 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -2565,7 +2565,8 @@ def _split_side(side, proof, cap1, forbid, work_scale, work, split): 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 candidate tried first). + ``prefer`` (per side, the previous round's pick as ``(candidate + name, network signature)``, tried first). Returns ------- @@ -2586,7 +2587,8 @@ def _split_side(side, proof, cap1, forbid, work_scale, work, split): `_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 - unless its signature is excluded. Otherwise every generator runs (the + while it plans the same network (its signature is the preferred one), + unless that signature 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 @@ -2600,7 +2602,7 @@ def _split_side(side, proof, cap1, forbid, work_scale, work, split): core = delta <= _preleak_max(side) s_ok = core and proof is not None and proof['rule'] in ('outward', 'inward') - prefer = split['prefer'].get(side.name) + prefer, signature = split['prefer'].get(side.name, (None, None)) cands, errors, reasons, spent = [], [], {}, [work] def generate(name): @@ -2633,7 +2635,9 @@ def stage_s(**kw): 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 + # 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:')): @@ -2641,7 +2645,7 @@ def plan(best): elif core and prefer in _CORE_STRATEGIES: first = [generate(prefer)] for c in first: - if c is not None and not c.excluded: + if c is not None and not c.excluded and c.signature == signature: return plan(c) # the portfolio: Stage S, then the core if s_ok: diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index d61c26f..a26ad64 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -23,7 +23,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 +from ._splitting import Split, _ISO_TOL __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', 'synthesize_network', 'plot_pinch_diagram') @@ -729,6 +729,14 @@ 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 (its +#: signature) is newly excluded, so that every one plans another network. +_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`. +_MIX_T_TOL = 1e-7 def _grid_knots(curves, grid): """ @@ -1054,6 +1062,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 @@ -1258,8 +1335,128 @@ 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), where the flash of an adiabatic mix lands. + + 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 = _split_nodes(plan, duties, ends)[1] + tolQ = plan.info['tolQ'] + # each realized split's ordinal (per stream and side, from 1 in flow + # order), the stream's trunk exchangers before it and whether it is the + # stream's first node + order = {id(split): k for k, split in enumerate(plan.splits)} + where = {} + for path in plan.paths.values(): + count, trunk, started = {}, 0, False + for item in path: + if not isinstance(item, Split): + if item in duties: trunk, started = trunk + 1, True + 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, not started) + started = True + 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, first = 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: + 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 @@ -1324,7 +1521,27 @@ 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) and its method is 'split-' when it splits. + 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']``. With `avoid_recycle`, + a split that would repeat a stream pair is not used, and MER is + then not guaranteed. Needs `info`. Defaults to False. Returns ------- @@ -1368,7 +1585,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. @@ -1453,6 +1672,26 @@ 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). 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, each with a network not tried + before); 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 @@ -1536,6 +1775,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 = \ @@ -1555,19 +1798,58 @@ 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 (by signature), 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, ends, min_approach, + violations, 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 not None and split['candidate'] is not None + and split['signature'] not in exclude.get(side, ())): + exclude.setdefault(side, set()).add(split['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, ends, min_approach, violations, bad = best repaired = [] if bad: duties, changes = _repair(plan, duties, knots, is_hot, curves, @@ -1581,15 +1863,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) @@ -1667,6 +1962,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 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index c287e55..6dd1735 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -1688,5 +1688,332 @@ def spy(*args): finally: hxn_synthesis._discard(units.values()) +# --------------------------------------------------------------------------- +# Stream splitting: splitters, mixers and the refine loop (synthesize_network) +# --------------------------------------------------------------------------- + +import math + +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 a cold stream 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 + real = _copy(streams_inlet[j]) + hxn_synthesis._first_inlet(real, not curve.monotone, + curve.T_out, hot) + assert feed.T == real.T + # 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) + +@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 + result, info, curves, dT = split_synthesis(name, 'mix_' + name) + 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 the tolerance, it is reported + monkeypatch.setattr(hxn_synthesis, '_MIX_T_TOL', -1.) + _, info, _, _ = split_synthesis(name, 'mix_dev_' + name) + assert [d['ID'] for d in info['split_deviations']] == [ + sp.mixer.ID for sp in info['splits']] + for d, sp in zip(info['split_deviations'], info['splits']): + assert set(d) == {'ID', 'T_plan', 'T', 'H_plan', 'H'} + assert d['T'] == sp.mixer.outs[0].T and d['H'] == sp.mixer.outs[0].H + +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 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 + +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 + plans = spy_plans(monkeypatch) + def fail(self): raise RuntimeError('mixer failed') + monkeypatch.setattr(bst.Mixer, '_run', fail) + result, info, curves, dT = split_synthesis('smith2005_ex18_2_split', + 'split_failure') + 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) + if __name__ == '__main__': pytest.main([__file__, '-q', '-p', 'no:cacheprovider']) diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index c705dff..a54714b 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -2287,16 +2287,24 @@ def plan(**kw): 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 is taken first, alone - _, pr = plan(_split_prefer={name: sp2['candidate']}) + # 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']}, + _, 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', ()) @@ -2326,12 +2334,15 @@ def plan(**kw): 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 - _, pr = plan(_split_prefer={name: 'V0'}) + # 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'}, + _, 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' From bb818d0ae0fd15c940b5697a71eb2ffee9b4760d Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 08:33:07 -0700 Subject: [PATCH 11/24] address review B: drift-proof split identity, split first-node rule, tests Review milestone B (HS diff T6-T7) findings, each verified before acting. Major, split network identity (hxn_synthesis refine loop, _splitting exclusion and stickiness). A split's signature carried its fractions to 9 significant digits and was compared exactly, but the fractions are CP ratios on the knots, so every refine round on real thermo moves them (~1e-7 to 1e-6). Reproduced on the review's random real-thermo problem (seed 172, 3 hot, 2 cold, T_min_app 5 K): the preferred S:partner never matched, so the whole 11-candidate portfolio ran in every refine round, and each retry "excluded" a signature the next round no longer produced, re-planning the same network with the same violating exchanger in all 6 rounds (12.0 s). Structure-only identity is not the fix: a probe over the 28 constant-CP SPLIT problems (fixB_sigprobe.py) finds 47 candidate pairs of one side with the same structure but fractions 1.57e-2 to 0.45 apart, i.e. genuinely different networks. So the new `_same_network(a, b)` compares the structure exactly and each fraction within `_SPLIT_SAME_FRACTION = 1e-3` (15x below the smallest distinct difference, >1000x above the measured drift; equal to the smallest branch fraction planned). `_excluded`, the stickiness test in `_split_side` and the retry loop's "newly excluded" test use it; the 'knot-independent' docstrings are corrected. On seed 172 every refine round now re-plans S:partner alone (1 candidate instead of 11), the retries pick two other networks (S:nw-rho-desc, then VT), which do no better, so the round-0 plan is restored and repaired as before; the run takes 4.7 s instead of 12.0 s (test_split_identity_survives_refined_knots, which fails under the old exact comparison at round 1). Minor, rejected in part: the mixer-flash premise. The docstring claim that the flash of the mix lands on the planned state is wrong and is fixed (the seed only speeds up the flash; `_MIX_T_TOL` catches thermosteam's PH flash disagreeing with the curve, R-2). The suggested policy (merge a deviating split whose stream continues into a process exchanger) is not adopted: on seed 54 a PH flash at H_mix lands at 363.2951291042193 K from every start state tried (the planned state, the split state, the stream's inlet; curve states 360.7-362.5 K), so a process exchanger ending in that band lands there too, split or not (the design's G7 exposure). Merging the split would cost MER without removing it. Deviations stay reported. Minor, fixed: retry docstrings (a retry re-plans the side without its excluded networks: another one if left, else the same one on the refined knots, which ends the retries); dead `ends`/`violations` in the restored `best`; `_walk` docstring wording; the first-node rule is now computed once, in `_split_nodes` (new third return value `first_splits`), and `_realize_splits` takes the splitter feed's real inlet from it instead of its own scan. Tests: branch first exchangers of an inlet split take the real inlet (fails on rtB03 when the rule is removed: 1.7e-13 K); the mixer report's H rule alone (`_DUTY_TOL` = -1) with T_plan/H_plan values; one Mixer._run per mixer (spy); no 'replaced in registry' warning after a failed split realization (fails with the HX units' warnings when they are not discarded); a stream split twice on one side (IDs Split_2_hs / Split_2_hs_2 / Mix_2_hs_2, positions 0 and 1, only the first split on the real inlet); drift-tolerant exclusion and stickiness at planner level. Tautological f = 1 assertions removed (the R1 oracle's full mode covers f = 1); the glide shrink sweep uses 8 duties (16.1 s -> 8.0 s). Validation: full suite 545 passed in 220.36s (was 542 in 237.24s); py3.12 ast.parse on every changed file. R1 oracle --check: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 436.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 110.0 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 72 ++++++++--- hensmith/hxn_synthesis.py | 94 +++++++++------ tests/test_hxn.py | 247 +++++++++++++++++++++++++++++++++----- tests/test_hxn_planner.py | 46 ++++++- 4 files changed, 372 insertions(+), 87 deletions(-) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index fec2556..80f8568 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -186,18 +186,22 @@ 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, so it -identifies the same network across knot refinements and generators. +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 its signature -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). +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 @@ -224,6 +228,9 @@ _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 @@ -1581,7 +1588,9 @@ def _sig(v): def _signature(side, merged, stages): - """Knot-independent identity of a split network (see `_Candidate`).""" + """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(): @@ -1621,6 +1630,30 @@ def path(role, s, key): 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 @@ -1672,8 +1705,9 @@ class _Candidate: 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, so it identifies the same - network across knot refinements and generators. + 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). @@ -2521,9 +2555,11 @@ def _stage_s(side, a0, proof, cap1, forbid, work_scale, split, errors, # %% Portfolio and selection def _excluded(split, side_name, cand): - """True if the network signature of `cand` is excluded on its side - (exclusion is by network, never by generator name).""" - return cand.signature in split['exclude'].get(side_name, ()) + """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): @@ -2587,8 +2623,9 @@ def _split_side(side, proof, cap1, forbid, work_scale, work, split): `_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 (its signature is the preferred one), - unless that signature is excluded. Otherwise every generator runs (the + 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 @@ -2645,7 +2682,8 @@ def plan(best): elif core and prefer in _CORE_STRATEGIES: first = [generate(prefer)] for c in first: - if c is not None and not c.excluded and c.signature == signature: + 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: diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index a26ad64..1b3d17e 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -23,7 +23,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 +from ._splitting import Split, _ISO_TOL, _same_network __all__ = ('StreamLifeCycle', 'ProblemTable', 'problem_table', 'synthesize_network', 'plot_pinch_diagram') @@ -730,12 +730,17 @@ def load_duties(streams, streams_quenched, pinch_T_arr, T_out_arr, indices, #: 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 (its -#: signature) is newly excluded, so that every one plans another network. +#: 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`. +#: 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): @@ -1148,9 +1153,9 @@ def _walk(plan, duties, knots, is_hot): 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 - without any bypasses, and later stages move toward the inlet by the - duty lost (`_split_nodes` gives the split enthalpies). + 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 = [] @@ -1225,7 +1230,8 @@ def _split_nodes(plan, duties, ends): 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`). Empty if the first node is not a split. + 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 @@ -1234,6 +1240,9 @@ def _split_nodes(plan, duties, ends): 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: @@ -1248,20 +1257,22 @@ def _split_nodes(plan, duties, ends): 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)) - first_nodes = {} + 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 - firsts = [next((n for n in ns if n in duties), None) - for ns in item.branches] - firsts = tuple(n for n in firsts if n is not None) - if firsts: - first_nodes[j] = firsts + 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 + return first_nodes, split_ends, first_splits def _discard(units): """Remove units, and the streams connected to them, from the registry of @@ -1351,7 +1362,12 @@ def _realize_splits(plan, duties, units, curves, knots, streams_inlet, 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), where the flash of an adiabatic mix lands. + 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 ------- @@ -1372,24 +1388,22 @@ def _realize_splits(plan, duties, units, curves, knots, streams_inlet, units built for the splits and `units`. """ ends = _walk(plan, duties, knots, is_hot)[0] - split_ends = _split_nodes(plan, duties, ends)[1] + _, 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), the stream's trunk exchangers before it and whether it is the - # stream's first node + # 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, started = {}, 0, False + count, trunk = {}, 0 for item in path: if not isinstance(item, Split): - if item in duties: trunk, started = trunk + 1, True + 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, not started) - started = True + where[k] = (count[item.side], trunk) splits, deviations, built = [], [], [] def run(unit, k, live): @@ -1404,12 +1418,12 @@ def run(unit, k, live): H_split, H_ends, H_mix = split_ends[k] j, fractions = split.stream, split.fractions curve = curves[j] - index, position, first = where[k] + 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: + 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: @@ -1685,12 +1699,15 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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). 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, each with a network not tried - before); 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']``). + 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 @@ -1801,7 +1818,7 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, # 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 (by signature), and + # 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) exclude, prefer, best = {}, {}, None @@ -1821,8 +1838,7 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, ) if stream_splitting and plan.status == 'mer' and ( best is None or len(bad) < best[0]): - best = (len(bad), plan, knots, duties, ends, min_approach, - violations, bad) + best = (len(bad), plan, knots, duties, min_approach, bad) if (not violations or plan.status != 'mer' or refine_round == n_rounds): break if refine_round >= _MAX_REFINE: @@ -1833,9 +1849,11 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, for n in bad: side = plan.exchangers[n].side split = plan.info['sides'][side]['split'] - if (split is not None and split['candidate'] is not None - and split['signature'] not in exclude.get(side, ())): - exclude.setdefault(side, set()).add(split['signature']) + 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: @@ -1849,7 +1867,7 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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, ends, min_approach, violations, bad = best + _, plan, knots, duties, min_approach, bad = best repaired = [] if bad: duties, changes = _repair(plan, duties, knots, is_hot, curves, diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 6dd1735..2f95316 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -1352,7 +1352,9 @@ def test_pinch_diagram_legend(): from hensmith._curves import StreamCurve, GLIDE, _copy from hensmith._planner import Exchanger, Plan, plan_network from hensmith import _splitting -from test_hxn_planner import records_case +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 @@ -1459,12 +1461,9 @@ def test_branch_exchanger_approach_matches_scaled_curve(kind): 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 are the unsplit check, bit for bit - for T_min_app in (inf, worst + 0.5): - assert (approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, - T_min_app, 1., 1.) - == approach(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, - T_min_app)) + # (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): @@ -1477,7 +1476,9 @@ def test_shrink_with_fractions_is_monotone(kind): H_hot_in, H_cold_in, Q = branch_exchanger(curves) approach, shrink = hxn_synthesis._exchanger_approach, hxn_synthesis._shrink inf = float('inf') - xs = np.linspace(0.05, 1., 20) * Q + # (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:])) @@ -1503,10 +1504,6 @@ def test_shrink_with_fractions_is_monotone(kind): # branch's inlet, 330 K + 10 % of 60 K assert_allclose(Q_ok, FH * 2. * kW * (400. - 336. - limit), rtol=1e-8) - assert (shrink(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, T_min_app, - 1., 1.) - == shrink(curves, knots, 0, 1, H_hot_in, H_cold_in, Q, - T_min_app)) def records_plan(): """The hand-built split plan of `test_hxn_planner.records_case` (H1, @@ -1540,7 +1537,8 @@ def test_walk_split_paths(): 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.)]) + [(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] @@ -1551,7 +1549,8 @@ def test_walk_split_paths(): 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.)]) + [(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)} @@ -1567,7 +1566,8 @@ def test_walk_split_paths(): 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]) + 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) @@ -1606,7 +1606,9 @@ def test_realize_split_branch_ports(name): 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 = hxn_synthesis._split_nodes(plan, duties, ends) + 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, @@ -1652,6 +1654,97 @@ def test_realize_split_branch_ports(name): 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 @@ -1692,7 +1785,9 @@ def spy(*args): # 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'] @@ -1819,11 +1914,18 @@ def test_synthesize_network_with_splitting(name): 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 + 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'): @@ -1844,7 +1946,15 @@ def test_split_mixer_outlet_is_the_planned_state(name, monkeypatch): # 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 - result, info, curves, dT = split_synthesis(name, 'mix_' + name) + 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']: @@ -1869,14 +1979,21 @@ def test_split_mixer_outlet_is_the_planned_state(name, monkeypatch): hx = branch[-1] assert abs(s.H - hx.outs[stream_port(hx, j)].H ) <= 1e-9 * duty - # one rule for every mixer: outside the tolerance, it is reported - monkeypatch.setattr(hxn_synthesis, '_MIX_T_TOL', -1.) - _, info, _, _ = split_synthesis(name, 'mix_dev_' + name) - assert [d['ID'] for d in info['split_deviations']] == [ - sp.mixer.ID for sp in info['splits']] - for d, sp in zip(info['split_deviations'], info['splits']): - assert set(d) == {'ID', 'T_plan', 'T', 'H_plan', 'H'} - assert d['T'] == sp.mixer.outs[0].T and d['H'] == sp.mixer.outs[0].H + # 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 @@ -1932,7 +2049,7 @@ def inject(plan, duties, ends, curves, knots, T_min_app): 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 sp1['signature'] != sp0['signature'] + 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'])} @@ -1980,15 +2097,85 @@ def inject(plan, duties, ends, curves, knots, T_min_app): (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 + # 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) - result, info, curves, dT = split_synthesis('smith2005_ex18_2_split', - 'split_failure') + 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 diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index a54714b..4122de2 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -2348,10 +2348,52 @@ def plan(**kw): 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 signature, so the signature holds nothing knot-dependent: extra - # collinear knots give the same pick with the same signature + # 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 From 4138a581f36f51c8d547954788ba428009e26976 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 09:03:03 -0700 Subject: [PATCH 12/24] add split-aware stream life cycles, connections and pinch-diagram columns A synthesized split network (T7) has stream paths that are no longer a chain: a split stream passes a splitter chain, parallel branches of exchangers carrying a fraction of its flow each, and a rigorous mixer. The life cycle recovered from the exchanger IDs could only sort stages by inlet enthalpy, which is meaningless across branches (a branch stage's H_in is f times the parent's), and its first stage no longer carries the whole stream's inlet when the stream splits at its inlet. The facility (T8b) needs the life cycles to say how to wire such a network. Life cycles (design 5.1, additive): - LifeStage(unit, index, branch=None, fraction=1.): branch stages carry (k, b) and their branch's fraction; their repr/_info name the branch. - StreamLifeCycle.splits (always a list, [] when unsplit), entry (a _Port namedtuple: the first splitter's inlet if the stream splits at its inlet, else the first stage's) and H_in (full-flow inlet enthalpy at entry; equals life_cycle[0].H_in when unsplit). - get_life_cycle(new_HXs, new_HX_utils, splits=None): with no split of this stream (splits of other streams are ignored) it runs exactly today's code. With splits, the stream's splits are sorted into flow order by (position, sign * H_split) (consecutive splits share a position), branch exchangers are tagged by identity, only the trunk stages (full flow, monotone in H) are sorted by today's key, each split's branch stages are inserted branch-major after `position` trunk process stages, and the utility stays last. No enthalpy is compared across branches; the order equals synthesize_network's stream_HXs_dict. - connections() yields (up_unit, up_port, down_unit, down_port) in flow order (trunk -> splitter 0, chain c.1 -> c+1.0, outlet(b) -> branch or mixer.b for a bypass, branch -> mixer.b, mixer.0 -> next); for an unsplit stream exactly the consecutive stage pairs. - stage_pairs(): exchanger precedence through splitters and mixers (the contraction of connections()); a bypass carries the stages before its split past it; siblings get no pair. - repr adds one "split k: n branches (f...)" line per split after the stage list (no kJ/hr, so the stage-line count is unchanged). Pinch diagram (5.7): _order_exchanger_columns keeps today's code for any life cycle without splits (getattr(lc, 'splits', None), so the duck-typed fakes of test_pinch_diagram_column_order_follows_stream_direction keep working unchanged); a split life cycle orders its requested exchangers by their nearest requested successors along stage_pairs() (same partial order as the transitive closure, so precedence carries through excluded stages), reversed for hot streams, none between siblings. plot_pinch_diagram labels the stream ends with lc.H_in and the utility's H_out, and the two H texts get gids 'H_in:' / 'H_out:' (SVG ids only; PNG output unchanged). save_stream_life_cycles_as_csv writes lc.H_in for stage 0. Tests (written first; they failed on the missing splits= keyword): test_split_life_cycles (smith, rtB05, crude, rtB03 networks from T7: order == stream_HXs_dict, split order, branch/fraction tags, entry, H_in, repr lines, every port fed/feeding once, each connection's outlet carries the next inlet's flow (1e-12) and enthalpy (1e-9 x duty; measured max 4.4e-12 x duty), stage_pairs == contraction of connections, the explicit smith and crude wiring, ValueError for a foreign branch exchanger); test_life_cycle_connections_through_bypasses (hand-built, consecutive splits with bypasses); test_split_exchanger_columns (cold and hot); test_split_network_pinch_diagram (synthesis-level part: HX gids, full-flow H labels, which differ from the first branch stage's, and column order vs reachability for every exchanger pair of every split life cycle, both given orders). A scratch mutation probe (late split insertion, wrong mixer port, branch H label, hot not reversed) was caught by every mutation. Validation: full suite 554 passed in 227.52s (was 545); py3.12 ast.parse on every changed file. R1 oracle --check: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 455.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 113.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- hensmith/_heat_exchanger_network.py | 3 + hensmith/hxn_synthesis.py | 301 +++++++++++++++++++++++++--- tests/test_hxn.py | 283 ++++++++++++++++++++++++++ 3 files changed, 563 insertions(+), 24 deletions(-) diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 2d5e98c..b6bfd6f 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -709,6 +709,9 @@ def save_stream_life_cycles_as_csv(self): # pragma: no cover H_out = stage.H_out T_in, T_out = None, None if stage_no == 0: + # the whole stream's inlet (its first stage may be a + # branch of a split) + H_in = life_cycle.H_in T_in = inlet_Ts[stream] - 273.15 if stage_no == len_stages - 1: T_out = outlet_Ts[stream] - 273.15 diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 1b3d17e..4080b70 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -46,6 +46,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 @@ -59,6 +114,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 ---------- @@ -67,15 +129,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] @@ -89,14 +154,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()) @@ -138,6 +208,21 @@ 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. + H_in : float + 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. Notes ----- @@ -147,13 +232,20 @@ class StreamLifeCycle: `plot_pinch_diagram` draws them. """ - + 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): + unit, index = self.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 @@ -169,7 +261,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. @@ -179,6 +271,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 ------- @@ -194,6 +291,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 @@ -217,16 +330,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 @@ -235,8 +440,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.""" @@ -1998,15 +2207,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 @@ -2021,6 +2240,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)}' @@ -2109,10 +2353,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 -------- @@ -2141,8 +2389,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 ' @@ -2209,7 +2458,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 @@ -2217,14 +2467,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 2f95316..f0cf972 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -2202,5 +2202,288 @@ def fail(self): raise RuntimeError('mixer failed') 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) + if __name__ == '__main__': pytest.main([__file__, '-q', '-p', 'no:cacheprovider']) From fbb4081ab5dcc7e5c17cdb93256f16c25fe39d14 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 10:02:00 -0700 Subject: [PATCH 13/24] add the strict split-network checker and the corpus harness to test_hxn_mer Task T9a of the stream-splitting design: before the facility splits streams (T8b), the tests get a checker that can judge a split network, and the checker is itself tested. Harness: - _network(case, stream_splitting=False, variant=None) caches per (name, stream_splitting, variant) and records both in the network dict. variant='V' (the backstop alone: no Stage S rule, core strategy V only) patches _splitting inside the synthesis call only (_variant, with pytest.MonkeyPatch.context()), so a cached variant never leaks the patch (test_network_variant_is_patched_for_the_synthesis_only). The facility gets stream_splitting=True only when asked; until T8b adds that keyword, flag-off networks are built exactly as before. - _corpus_knots(case): the planner inputs of synthesize_network's round 0, cached, with the seconds taken (0.9 s for all 78 cases). A scratch check that spies on plan_network during every flag-off facility synthesis first found smith2005_ex16_1 different: two coolers tie at 2880 kW, the facility takes its heat utilities in System.from_units order, and the stable sort by duty keeps that order for the tie, while the build order (used by the R1 oracle's planner mode and proto/dump_knots.py) puts them the other way round. _facility_heat_utilities now reproduces the facility's order; all 78 cases are then bit-identical to the facility's round-0 knots, is_hot and T_min_app. Checker: - _network_problems dispatches: without splitting, today's body, moved verbatim to _linear_network_problems (only the name changed; the existing tests are unchanged); with it, _split_network_problems, plus the linear checks when the network has no splitter. - _split_network_problems implements G0-G10 of design 6.1, each problem tagged with its check. _walk_stream walks each stream on the actual stream graph (identity, s.sink, the port index there): HXprocess on the same port, a splitter tree (one split's Split__[_k] chain) to one mixer whose inlets are exactly the branch ends, on to the stream's own utility. The bookkeeping (facility lists, life cycles, their connections and splits, stream_HXs_dict, stage fractions) is checked against the walk. Tolerances are the design's (flows and fractions 1e-12, splitter T 1e-9 K, mixer equilibrium and isothermal re-joins 1e-6 K, enthalpies DUTY_RTOL x duty, the minimum fraction 1e-3) and are documented in the module docstring with G0-G10. Tests (written first; they failed on the missing infrastructure): - test_split_checker_agrees_on_linear_networks: both checkers find no problem on every corpus network without splitting (all 78, not a sample: 5.7 s). - test_split_checker_detects_mutations: smith2005_ex18_2 with splitting, wired by hand as the facility will wire it (_wired_split_network: synthesize_network(stream_splitting=True), life cycles with the splits, the entry copy, every connection, a System over a topological path of the connections), passes; each of 14 mutations, applied to a fresh synthesis, is caught by the check made for it: G0 misnamed mixer and unlisted splitter, G1 entry moved into a branch, G2 mixer inlet taken from another stream's trunk, G3 mixer inlets swapped (caught by G3's edge check alone) and a bypassed branch exchanger, G4 a wrong split ratio (re-simulated) and a mis-scaled branch inlet, G5 a mixer outlet off its inlets, G6 a branch past the stream's outlet, G7 a branch exchanger's H_lims moved past its approach (re-simulated; its dT guard lowered, which otherwise caps the duty at the terminals; branch 1, since branch 0's partner is heated by the split stream below the pinch and the converged loop lowers its inlet, keeping the approach and leaving only the non-isothermal re-join that G5 reports), G8 a utility short of flow, G9 a shifted outlet, G10 misreported heat. - test_no_split_plan_unchanged_by_splitting (40 NO_SPLIT): with the flag the plan equals the default one bit for bit (float.hex): exchangers with every field, stages, paths, utilities, targets, info apart from each side's 'split' (None), no splits. - test_split_cases_keep_unsplit_sides (38 SPLIT): every side that the flag- off plan serves ('mer', or 'trivial') has, with the flag, split None, bit-equal side info and the same (side, hot, cold, Q) per stream in flow order. The flag-off reference is planned with _planner._best_effort patched to leave a side unplanned: without avoid_recycle plan_network plans each side on its own, and only a side that ends 'best_effort' reaches _best_effort, so every compared side is exactly as in the real plan (scratch: 24 compared sides identical with and without the patch), and the best-effort searches (93.9 s of flag-off planning on the 38 cases, against 0.3 s patched) are skipped. The test asserts every other side went through the patch and that the design's examples (smith2005_ex18_2 below, rtB05 below, luo above) are compared. Time: the TM additions take 28.6 s (keep-unsplit-sides 18.2 s, linear agreement 5.7 s, NO_SPLIT identity 4.2 s, mutations 0.45 s). Validation: full suite 725 passed in 256.30s (T8a: 554 passed in 227.52s); py3.12 ast.parse on the changed file. No library file changed. R1 oracle --check: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 437.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 111.5 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- tests/test_hxn_mer.py | 919 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 910 insertions(+), 9 deletions(-) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index c9d6cdc..a28b637 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -110,6 +110,76 @@ (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 least 1e-3, the product of the splitters' + ratios down to it equals the split's fraction, at the feed's T and P, + with f times its enthalpy. +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``), each of fourteen 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 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. +``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. + Tolerances ---------- * Constant-CP targets: 1e-9 of the total stream duty (both sides are exact @@ -158,6 +228,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,15 +245,19 @@ Kemp, I. C. (2007). Pinch Analysis and Process Integration, 2nd ed. Butterworth-Heinemann. """ +import contextlib +import heapq 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._planner import plan_network from hensmith.hxn_synthesis import problem_table from hxn_mer_cases import NO_SPLIT, SPLIT, Q_UNITS, T_UNITS @@ -196,6 +277,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,16 +811,35 @@ 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(case, stream_splitting=False, variant=None): + """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).""" name = case['name'] - if name not in _NETWORKS: + 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) + options = dict(stream_splitting=True) if stream_splitting else {} + HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app, **options) sys = bst.System.from_units('sys', units=[*units, HXN]) t0 = time.perf_counter() - with warnings.catch_warnings(): + 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 @@ -746,20 +850,65 @@ def _network(case): 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( + _NETWORKS[key] = 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, ) 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 +981,672 @@ 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})' + if not MIN_FRACTION <= f < 1.: + problem('G4', f'{label}: fraction {f:.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() + +def _connection_path(units, cycles): + """The simulation path of `units` along the connections of the life + `cycles`, and the streams to tear: a topological order (ties by the + order of `units`) that places, where the connections form a cycle, the + unit with the fewest unplaced feeders next (the rule of the facility's + `_network_path`).""" + position = {u: n for n, u in enumerate(units)} + successors = {u: [] for u in units} + waiting = {u: 0 for u in units} + for lc in cycles: + for up, port, down, _ in lc.connections(): + successors[up].append((down, up.outs[port])) + waiting[down] += 1 + ready = [position[u] for u in units if not waiting[u]] + heapq.heapify(ready) + placed = {} + while len(placed) < len(units): + if ready: + unit = units[heapq.heappop(ready)] + if unit in placed: continue + else: + unit = min((u for u in units if u not in placed), + key=lambda u: (waiting[u], position[u])) + placed[unit] = len(placed) + for other, _ in successors[unit]: + waiting[other] -= 1 + if not waiting[other] and other not in placed: + heapq.heappush(ready, position[other]) + path = sorted(units, key=placed.__getitem__) + recycles = [s for u in units for other, s in successors[u] if placed[other] <= placed[u]] + return path, recycles + +_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 = _connection_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) +# --------------------------------------------------------------------------- + +MUTATION_CASE = 'smith2005_ex18_2_split' + +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' + +def _unlisted_splitter(net): + net['HXN'].new_splitters.remove(_split_stage(net)[0].splitters[0]) + return 'G0' + +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' + +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' + +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' + +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' + +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' + +def _branch_inlet_scaled(net): + # a branch inlet carries the wrong fraction + _split_stage(net)[2].s_in.scale(1.1) + return 'G4' + +def _mixer_outlet_shifted(net): + mixer = _split_stage(net)[0].mixer + mixer.outs[0].T += 0.01 + return 'G5' + +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' + +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' + +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' + +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' + +def _heat_misreported(net): + net['heat'] += 1e-6 * net['total'] + return 'G10' + +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, _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 +1766,89 @@ 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('mutation', list(SPLIT_MUTATIONS)) +def test_split_checker_detects_mutations(mutation): + # a split network wired by hand (as the facility wires it) passes the + # strict checks; each defect, applied to a fresh copy, is caught by the + # check made for it (and possibly others) + case = _case(MUTATION_CASE) + problems = _network_problems(_wired_split_network(case, cached=True)) + assert not problems, '\n'.join(problems) + net = _wired_split_network(case) + tag = SPLIT_MUTATIONS[mutation](net) + problems = _network_problems(net) + assert any(p.startswith(tag + ' ') for p in problems), (tag, 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 From 3dc81af47349a2c987ed0959c241c02c7462680f Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 10:44:55 -0700 Subject: [PATCH 14/24] let the facility split streams: stream_splitting, wiring from life cycles Task T8b of the stream-splitting design (sections 5.2, 5.3, 5.5): the fresh path of HeatExchangerNetwork synthesizes, wires and simulates a network whose streams may split. The cached path and avoid_recycle are T8c's. HeatExchangerNetwork: - stream_splitting=False keyword (last, so positional calls keep their meaning), documented in Parameters and in the Notes; passed to synthesize_network, and recorded as _synthesis_options for T8c's cache key. - new_splitters and new_mixers: the realized splits' splitter chains and mixers (info['splits']), always set, empty without splitting. They join the unique-ID assertion and HXN_sys; their costs are 0. - _get_stream_life_cycles gives each stream its own splits; a stream without any gets today's call. - Each stream enters at its life cycle's entry port (its first stage, or the first splitter where it splits at its inlet) as a copy of its real inlet, and _enter_network now also flashes a point-load stream that enters at a Splitter to its equilibrium state at the inlet enthalpy, as synthesize_network planned and as its unsplit first exchanger takes it. - Wiring and _network_path follow StreamLifeCycle.connections(): for an unsplit stream these are exactly its consecutive stages in order, so the assignments, the successors and the waiting counts, and so the path and the recycles, are those of today (the old loop's `if s_out` was `is not None`: a Stream is always truthy). - _stage_fractions: a branch stage's limit is f times the whole stream's, so its share is recorded on the whole stream's basis (H_lim / f) and f in the new _stage_scales (branch ports only); unsplit stages have fraction 1. and are unchanged. Tests (written first; all failed on the missing keyword): - test_hxn_mer: _network passes stream_splitting to the facility always (_synthesize, _network_record), cached=False builds a fresh network, G0 reads new_splitters/new_mixers without a default, and the 14 mutation tests run on the facility's own network too (28 cases). - test_hxn: assert_path_follows_streams follows connections() and the splitters and mixers; test_stream_splitting_is_off_by_default, test_split_network_units_and_ids (smith2005_ex18_2), test_synthetic_network_splits_to_mer (regression case 04: 'mer'), test_split_stage_fractions, test_split_network_registers_no_intermediate_streams (two syntheses of case 04 replace nothing, splitters, mixers and their streams are in the network's flowsheet, the main flowsheet gains no stream; the doctest system is unchanged by the keyword), test_point_load_enters_before_the_splitter (fresh), test_split_branch_dropped_is_bypassed, test_split_side_keeps_cells_below_Qmin_facility, and facility parts in test_synthesize_network_with_splitting (same exchangers and splits as the direct synthesis, strict checker clean, MER) and test_split_network_pinch_diagram (full-flow H labels). Every facility network here passes the strict checker (G0-G10). - The point load: no corpus case splits one at its inlet (T7), and a point load never needs a split (it keeps one temperature, so its matches run in series: the probe's unsplit plan is 'mer'). The test forces the split: force_split makes the side's root check see a 'cascade' root (a deficit within the cascade's tolerance), so the side is planned with splits by the core (split-V splits the superheated water/ethanol reboiler of test_non_monotone_stream_is_a_point_load at its inlet, 0.698/0.302, against two hot water streams). The splitter feed and both branch inlets are at 353.07 K (its equilibrium state at the feed enthalpy), not the 365 K feed. - The bypass: _realize fails once at a branch exchanger. rtB05's re-join feeds its heater and the strict checker is clean. smith2005_ex18_2's split re-joins before its trunk exchanger below the pinch; with a branch bypassed the re-join is no longer isothermal, and the checker reports exactly that G5 problem ('a non-isothermal re-join feeding HX_2_1_cs, not the utility') and nothing else. The design expected "clean apart from MER" there; that is unreachable for that topology, and the G5 rule is kept strict. - Qmin = 2.5e7 kJ/hr on smith2005_ex18_2: the split side keeps its 1.44e6 kJ/hr branch exchanger and reports it in split['small'], the unsplit side drops its 2.16e7 kJ/hr exchanger into qmin_dropped. Mutations (scratch t8b_mutation*.sh): the Splitter left out of _enter_network (1 test fails), the path from consecutive stages (4), no branch scale in the stage fractions (4), life cycles without splits (30), no mixers listed (31). Validation: full suite 751 passed in 253.14s (baseline before the change: 725 passed in 271.80s; +26: 12 test_hxn cases, 14 facility mutations). py3.12 ast.parse on the three changed files. R1 oracle --check: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 443.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 114.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- hensmith/_heat_exchanger_network.py | 119 ++++++--- tests/test_hxn.py | 361 +++++++++++++++++++++++++++- tests/test_hxn_mer.py | 85 ++++--- 3 files changed, 488 insertions(+), 77 deletions(-) diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index b6bfd6f..b2e7b31 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -66,26 +66,28 @@ 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,10 +125,19 @@ 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, where the minimum energy requirement needs it (see + `synthesize_network`). The branches are realized with + `biosteam.Splitter` chains (`new_splitters`) and rigorous + `biosteam.Mixer` units (`new_mixers`), which are adiabatic and + cost nothing. Defaults to False: the network has no split, and + both lists are empty. + Notes ----- - The network is synthesized without stream splits by + The network is synthesized without stream splits (unless + `stream_splitting`) 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 @@ -136,7 +147,8 @@ class HeatExchangerNetwork(bst.Facility): 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 @@ -147,8 +159,10 @@ class HeatExchangerNetwork(bst.Facility): 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 + 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). @@ -251,7 +265,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 +276,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 +300,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) @@ -418,8 +436,16 @@ 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) + self._synthesis_options = (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 +458,32 @@ 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`. self._stage_fractions = stage_fractions = {} + self._stage_scales = stage_scales = {} for i, life_cycle in enumerate(stream_life_cycles): hx = hx_heat_utils_rearranged[i].unit H_in = hx.ins[0].H @@ -461,6 +493,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 +652,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 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index f0cf972..4951262 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -499,19 +499,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(): @@ -1938,6 +1940,32 @@ def test_synthesize_network_with_splitting(name): 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): @@ -2484,6 +2512,323 @@ def test_split_network_pinch_diagram(name): 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) +SPLIT_UNIT_IDS = { + bst.Splitter: re.compile(r'^Split_\d+_(hs|cs)(_\d+)?(_b\d+)?$'), + bst.Mixer: re.compile(r'^Mix_\d+_(hs|cs)(_\d+)?$'), +} + +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) + +@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) + +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: nothing changes with the keyword + sys, HXN, feed = build_system() + 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 == [] + +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)) + +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) 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 a28b637..96f5463 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -825,38 +825,50 @@ def _variant(variant): patch.setattr(_splitting, '_CORE_STRATEGIES', ('V',)) yield -def _network(case, stream_splitting=False, variant=None): +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).""" + variant), or a fresh network (never cached) unless `cached`.""" name = case['name'] + 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) - options = dict(stream_splitting=True) if stream_splitting else {} - HXN = HeatExchangerNetwork('HXN', T_min_app=T_min_app, **options) - 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() - 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[key] = 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, - ) + _NETWORKS[key] = _synthesize(case, stream_splitting, variant) except Exception as error: _NETWORKS[key] = error result = _NETWORKS[key] @@ -1161,7 +1173,7 @@ def IDs(items): return [u.ID for u in items] 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, [])) + 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 ' @@ -1781,15 +1793,24 @@ def test_split_checker_agrees_on_linear_networks(case): 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): - # a split network wired by hand (as the facility wires it) passes the - # strict checks; each defect, applied to a fresh copy, is caught by the - # check made for it (and possibly others) +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 (and possibly others) case = _case(MUTATION_CASE) - problems = _network_problems(_wired_split_network(case, cached=True)) + 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) - net = _wired_split_network(case) + assert net['HXN'].synthesis_info['splits'] + if source == 'wired': + net = _wired_split_network(case) + else: + net = _network(case, True, cached=False) tag = SPLIT_MUTATIONS[mutation](net) problems = _network_problems(net) assert any(p.startswith(tag + ' ') for p in problems), (tag, problems) From 701203ac761466017ce020875444ad1d64a36096 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 11:21:16 -0700 Subject: [PATCH 15/24] let the cached network serve split networks: entry, branch limits, flows Task T8c of the stream-splitting design (sections 5.4, 5.6): the cached path of HeatExchangerNetwork now reuses a split network correctly. With stream_splitting off nothing changes: the cache key is always true, every f is 1, and the pre-copy of an exchanger is today's code verbatim (R1 oracle identical, including its cache and avoid_recycle runs). Causes (each shown by a test that failed before the fix): - The cached path copied each stream's real inlet into its first stage, life_cycle[0]. For a stream split at its inlet that stage is the first exchanger of branch 0, so the whole feed went into one branch while the splitter's own feed kept the last run's flows: with feeds 10 % larger the outlets were 10 % short and the facility warned "cache algorithm failed" and synthesized again (smith2005_ex18_2, the forced point-load split). It now enters at lc.entry and calls _enter_network with it, as the fresh path does (a point load is flashed before the splitter). - The pre-copy moved flows by zip(unit.ins, unit.outs): a splitter's outlet 0 got the whole feed (and outlet 1 kept its old flows), a mixer's outlet only its first inlet's flows. The new _move_flows keeps that code for exchangers, runs Splitter._run for a splitter (its fixed split; it copies T, P and phases and flashes nothing) and gives a mixer's outlet the summed inlet flows by the same rule (a multiphase outlet: flows scaled, and if that does not reproduce them, the inlets' phase flows and a TP flash at its own T and P). The rigorous mixer (a PH flash) runs only inside the guarded sys.converge(). - A branch stage's limit was restored on the whole stream's basis: its recorded share (T8b's _stage_fractions, parent basis) must be scaled by its flow fraction f (_stage_scales). H_lim = H_in + share*(H_out - H_in), then *= f when f != 1; the partner rule (port 0 at the outlet when port 1 has a limit) gives f times the outlet. The split fractions stay fixed, so scaled feeds keep their branch proportions. - A network was reused after stream_splitting was toggled. The cache key now also requires getattr(self, '_synthesis_options', (False,)) == (self.stream_splitting,) (_synthesis_options is set by T8b's fresh path). - _stage_scales is read with getattr(..., {}) like _synthesis_options, for a facility cached before stream splitting existed. - The class Notes say that the cache also keys on stream_splitting and that the splitters keep their fractions. The _plan_sides cells collection of 5.6 was already in place since T3 (used pairs include sp.cells); removing it makes test_splitting_with_avoid_recycle_never_repeats_a_pair fail (checked). Tests (tests/test_hxn.py): - simulate_strictly(sys): the strict simulation of simulate_HXN, factored out (same filters). - test_cached_split_network[smith2005_ex18_2_split, rtB05_above_2h1c]: cache_network=True, all feeds +10 % then -10 %: the same System and units are reused; at the start of the network's convergence every stream of every split stream carries factor x its fresh flows (rtol 1e-12; none of them is a tear stream, whose consumer runs before its producer in path order: there, as without splits, only the convergence brings the new flows); no mixer ran outside the convergence and every mixer ran inside it (a spy, which records because the facility's guard would swallow an assertion); splitter splits unchanged; every branch limit equals f x its whole-stream restore (branch_limits); every exchanger's Q is factor x its fresh Q and every outlet equals the original's (1e-12 of the total duty in H, 1e-9 K; measured at most 4.3e-15, 2.4e-14 and 4.3e-12 K); feasible, EB < 1e-6 %. Then stream_splitting off gives a new unsplit network and on again a new split network with no unit in common. Before the fix: smith failed on the cache warning, rtB05 on the stale branch flows. - test_point_load_enters_before_the_splitter, cached part: feeds +10 %, the cached network is reused, the splitter feed is the point load's equilibrium state at its inlet enthalpy (bit-identical T, H within 1e-12 of the duty), both branch inlets at that T; a fresh synthesis at the same feeds agrees: same splitter feed T, fractions within rtol 1e-12 (they differ by 1 ulp), every Q within 1e-12 of the duty (measured 1.8e-17). Failed before the fix on the cache warning. - test_move_flows_moves_flows_only: a splitter and two mixers (liquid outlet; rigorous, two-phase outlet), all flows +10 %, then only the vapor feed: split outlets are 0.3/0.7 of the feed at its T, mixer outlets are the summed flows at their own T and P, and Mixer._run (patched to raise) never runs. - test_split_avoid_recycle_facility: r002 with avoid_recycle and stream_splitting reaches MER (80/450 kW) with a split below and no pair repeated, strict checker clean, feasible. - test_repair_on_split_plan: DRIFT_CASE (review B's seed 172) on 0.5 K chords with _MAX_REFINE = _MAX_SPLIT_RETRY = 0: _repair shrinks a branch exchanger (hot fraction 0.822) from 222328 to 222189 kJ/hr, info['repaired'] records exactly _repair's changes, the realized branch exchanger has the repaired duty, the utilities are the repaired plan's (1e-12 of the total), status best_effort, feasible, strict checker clean. The last two passed before this change (T3, T7 and T8b built what they test); they are the task's acceptance tests at the facility. Mutations (scratch t8c_mutation.sh), each caught: the cache key without the options (2 failures), life_cycle[0] as the entry (2), no f on branch limits (2), Mixer._run in the pre-copy (3), zip for splitters (3), zip for mixers (3). Deviation: the design names regression case 04 for test_repair_on_split_plan, but case 04 (and cases 08 and 09, and rtB05) has no round-0 violation even at tol_T = 0.5 to 10 K, so _repair never runs there; DRIFT_CASE is the facility case whose split side needs it. Observed (thermosteam, read-only): a TP flash of 50 Water/25 Ethanol kmol/hr at 101325 Pa gives vapor fraction 0 at 355.1135 K although the bubble and dew points are 354.46 and 363.30 K (0.049 at 354.6 K, 0.478 at 357 K). The pre-copy's TP flash only sets a starting phase split (today's exchanger rule has the same exposure); the rigorous units re-flash in the convergence. Validation: Full suite (pytest . --disable-numba=1 -q): 756 passed in 271.48s (0:04:31) (T8b: 751 passed). R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 111.8 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) (includes th/kemp_cache and th/r002_avoid_recycle) R1 oracle (planner): 78 records, sha256 7307a56778e56268, 456.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) ast.parse(feature_version=(3, 12)) on both changed files: ok. Co-Authored-By: Claude Opus 5.5 --- hensmith/_heat_exchanger_network.py | 98 ++++++++--- tests/test_hxn.py | 257 +++++++++++++++++++++++++++- 2 files changed, 326 insertions(+), 29 deletions(-) diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index b2e7b31..08ca961 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -61,6 +61,48 @@ def _load_utility_costs(unit): owner = unit.owner if owner is not unit: owner._load_operation_costs() +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). + No enthalpy flash runs here: only the convergence, which the facility + guards, runs the rigorous units. + + - 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 multiphase outlet keeps its temperature and pressure: its flows are + scaled to the new total and, if that does not reproduce the inlet + flows, replaced by them and split between the phases at its + temperature and pressure. + """ + if isinstance(unit, bst.Splitter): + unit._run() + elif isinstance(unit, bst.Mixer): + s_out, = unit.outs + ins = unit.ins + mol = sum([s.mol for s in ins]) + if isinstance(s_out, bst.MultiStream): + s_out.F_mol = sum([s.F_mol for s in ins]) + if not s_out.mol.sparse_equal(mol): + s_out.imol.mix_from([s.imol for s in ins]) + s_out.vle(T=s_out.T, P=s_out.P) + else: + s_out.mol[:] = mol + else: + 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) + 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 @@ -167,12 +209,14 @@ class HeatExchangerNetwork(bst.Facility): 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 + 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. @@ -362,8 +406,12 @@ 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) 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', (False,)) + == (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 = ( @@ -380,13 +428,16 @@ def _cost(self): new_HXs = self.new_HXs new_HX_utils = self.new_HX_utils stage_fractions = self._stage_fractions + # (none in a network cached before stream splitting existed) + stage_scales = getattr(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: @@ -405,24 +456,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 diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 4951262..1d958b7 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -583,12 +583,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' @@ -597,6 +595,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): @@ -2750,6 +2756,33 @@ def test_point_load_enters_before_the_splitter(monkeypatch): 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 @@ -2830,5 +2863,217 @@ def test_split_side_keeps_cells_below_Qmin_facility(): 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) +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) 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 + 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 (none of them a tear stream, whose + # consumer the path-order pre-copy runs before its producer: there, as + # without splits, only the convergence brings the new flows) + streams = [] + for lc in HXN.stream_life_cycles: + if not lc.splits: continue + unit, index = lc.entry + last = lc.life_cycle[-1] + streams += [unit.ins[index], last.unit.outs[last.index], + *[up.outs[port] for up, port, *_ in lc.connections()]] + recycles = network.recycle or [] + if not isinstance(recycles, list): recycles = [recycles] + assert streams and not {id(s) for s in streams} & {id(s) for s in recycles} + 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() + # 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), keeping its temperature and pressure, multiphase or not + from hensmith._heat_exchanger_network import _move_flows + bst.settings.set_thermo(['Water', 'Ethanol'], 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 failing(self): raise RuntimeError(f'{self.ID} was run') + monkeypatch.setattr(bst.Mixer, '_run', failing) + # all flows 10 % larger, then only the vapor's + for streams in ((feed, liquid, vapor), (vapor,)): + for s in streams: s.F_mol *= 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 == feed.T 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) + assert isinstance(two_phase, bst.MultiStream) + +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, shrunk = hxn_synthesis._repair, [] + def spy(plan, duties, *args): + duties, changes = repair(plan, duties, *args) + shrunk.extend((plan.exchangers[n], *change) + for n, *change in changes) + return duties, changes + monkeypatch.setattr(hxn_synthesis, '_repair', spy) + 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'] + assert info['repaired'] == [ + dict(side=e.side, hot=e.hot, cold=e.cold, Q_plan=before, Q=after) + for e, before, after in shrunk] + assert any(e.hot_frac < 1. or e.cold_frac < 1. for e, *_ in shrunk) + assert all(0. < after < before for _, before, after in shrunk) + total = total_duty(units) + branch_Q = [stage.unit.Q for lc in HXN.stream_life_cycles + for stage in lc.life_cycle if stage.branch is not None] + for e, before, after in shrunk: + if e.hot_frac < 1. or e.cold_frac < 1.: + assert min(abs(Q - after) for Q in branch_Q) <= 1e-12 * total + 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']) From 9b152d365439517cc9292557451f908b647cd011 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 13:50:39 -0700 Subject: [PATCH 16/24] address review C: old caches resynthesize, csv splitter row, stronger tests Review milestone C (HX and life-cycle diff T8a-T8c). Each finding was checked against the code before acting; nine are fixed, one (pre-existing) is left as a follow-up. Facility (hensmith/_heat_exchanger_network.py): - Cache key fallback (findings 1 and 3, one defect). The key read getattr(self, '_synthesis_options', (False,)), which the T8c body says lets a facility cached before stream splitting keep its cache. It could not: _stage_fractions predates stream splitting (ce17233), so such a facility passed the key with stream_splitting off, and the cached branch then raised AttributeError: 'StreamLifeCycle' object has no attribute 'entry' (reproduced by the new test below; plot_pinch_diagram fails the same way through StreamLifeCycle.H_in). The default is now None: a facility with no recorded options synthesizes a new network. The T8c rationale for the getattr defaults was wrong. _stage_scales is read directly, and _synthesis_options is now set together with _stage_fractions and _stage_scales, so whenever it is present they are too. Objects the current code creates are unaffected (the fresh path always sets all three), so R1 is identical. - save_stream_life_cycles_as_csv (finding 4). For a stream split at its inlet, row 0 (the first exchanger of branch 0) wrote the whole stream's H_in next to the branch's H_out (a fraction f of the flow), so the row was neither the stage's duty nor the stream's. Every row now carries its stage's own H_in and H_out, and a stream whose entry port is a Splitter first gets a row for its splitter chain at the whole stream's inlet enthalpy (H_in = H_out = lc.H_in, with T_in). Unsplit streams write the same rows as before (stages[0].H_in == lc.H_in exactly). The file is now written in a with block (it was never closed) with newline='', as the csv module requires (on Windows each row was followed by a blank line). The method is tested now, so its "pragma: no cover" is dropped. - Class Notes (finding 6): the three paragraphs that the T8b/T8c edits left ragged are re-filled to the file's width. The first sentence now begins "Unless `stream_splitting`," so that the :func: role fits on a line. The meaning is unchanged. Tests: - test_cache_network_from_before_stream_splitting_synthesizes_again (new): a facility stripped of _synthesis_options, _stage_scales and each life cycle's entry/splits (what an older object lacks), with the feed 5 % larger, synthesizes a new network with no RuntimeWarning and matches a fresh synthesis. It failed before the fix with the AttributeError above. - test_stream_life_cycles_csv (new): on smith2005_ex18_2 every row equals its stage's (ID, H_in, H_out, T_in on the first row, T_out on the last), with one Split_2_hs entry row. It failed before the fix (row HX_1_2_hs had the whole stream's H_in). - test_move_flows_moves_flows_only (finding 7): the feed's T and P change before each pre-copy and both splitter outlets must take them. The two-phase mixer outlet must keep its PH-flash phase split, scaled, when every inlet grows by 10 %, and must equal a TP flash at its own T and P when only the vapor grows. The always-true isinstance check is removed. The reviewer's check 0 < vapor_fraction < 1 is not added: thermosteam's TP flash (read-only) gives all liquid at that outlet's 355.11 K, although its bubble and dew points are 354.46 and 363.30 K (the T8c observation), so after the re-flash both the outlet and any reference flash have vapor fraction 0. The reviewer's weak _move_flows (no T/P copy at the splitter, no flash at the mixer) now fails the test. - test_split_network_registers_no_intermediate_streams (finding 8): the doctest system with stream_splitting must give the network it has without the flag bit for bit ([(ID, Q)] of new_HXs and of new_HX_utils, and refine_rounds; design R3). - test_repair_on_split_plan (finding 9): a spy on _realize keeps the units of each plan's last realization. Every branch exchanger that _repair shrank must be the unit realized for its plan index, be in new_HXs, be a branch stage in its hot or cold stream's life cycle, and carry the repaired duty (1e-12 of the total). Before, any branch exchanger whose Q matched the value passed. - test_cached_split_network (finding 10): the +/-10 % part also runs crude_fractionation_ph11c2, whose cold stream 1 is split into four branches below the pinch and again above it (two splitter chains, the second fed by the first's mixer). The stream_splitting toggle stays on the two small cases. This network has tear streams on the split stream: hot streams 2, 5 and 7 meet stream 1 both above and below the pinch, so the path places the above-pinch branch exchangers before Split_1_hs, and the splitter's outlets to them are tears. The stale-flow check now skips only what the path-order pre-copy cannot update before the convergence: the outlets of a tear's consumer and everything after them on that stream, found in flow order along lc.connections(). The same is true without splits. The test asserts that a chain element's outlet and a mixer-fed splitter are among the streams it checks. - Duplication (finding 5): test_hxn_mer's _connection_path, a line-for-line copy of the facility's _network_path, is deleted, and _wired_split_network now calls _network_path (its heapq import is dropped). test_hxn's SPLIT_UNIT_IDS is built from test_hxn_mer._NETWORK_UNITS. Not fixed (finding 2, pre-existing, also seen with stream_splitting off): when the cached network's sys.converge() fails, _cost runs every unit once, warns, and keeps the unconverged network if the outlet check (rtol 1e-3) and the EB check pass. The reviewer measured this on bagajewicz_ou_crude_unit_15_stream at x1.1 then x0.9: EB 2.7e-4 %, one approach 19.9998 K, and, with splits, non-isothermal re-joins. Following the reviewer, it is left as a follow-up. The minimal remedy is to treat a failed convergence as a failed cache check (resynthesize); separately, the slow contraction of the repeated-match loops under the cached path's fixed H_lim needs a root cause. Related: the cached path's pre-copy moves flows in path order, so the consumer of a tear starts the convergence from old flows. Moving the flows along each stream's own connections, which are acyclic, would avoid that. Mutations (scratch reviewCfix_mutation.sh; each caught): the cache default back to (False,) (the new cache test fails), no f on branch limits (all three cached cases, crude included), splitters moved by zip (the three cached cases and move_flows), mixers moved by zip (the same four), the mixed CSV row (the CSV test). The reviewer's weak _move_flows is also caught. Validation: Full suite (pytest . --disable-numba=1 -q): 759 passed in 307.50s (0:05:07) (review C baseline at 701203a: 756 passed; +3: two new tests and the crude parameter of test_cached_split_network). R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 122.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) R1 oracle (planner): 78 records, sha256 7307a56778e56268, 529.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) ast.parse(feature_version=(3, 12)) on the three changed files: ok. Co-Authored-By: Claude Opus 5.5 --- hensmith/_heat_exchanger_network.py | 106 ++++++++-------- tests/test_hxn.py | 189 ++++++++++++++++++++++------ tests/test_hxn_mer.py | 36 +----- 3 files changed, 204 insertions(+), 127 deletions(-) diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 08ca961..4e3943e 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -178,15 +178,14 @@ class HeatExchangerNetwork(bst.Facility): Notes ----- - The network is synthesized without stream splits (unless - `stream_splitting`) 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, or, @@ -203,22 +202,21 @@ class HeatExchangerNetwork(bst.Facility): to the HeatExchangerNetwork object. Each stream passes its exchangers in series (where it splits, its branches run in parallel from a splitter chain to a mixer); the network is simulated as a `System` (`HXN_sys`) - whose path follows the streams, with the loops that repeated matches - can form torn - and converged to a tight tolerance (every exchanger starts at its - planned state, so the loops are at their fixed point after one pass). + 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 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 + 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. + 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 @@ -407,10 +405,11 @@ def _cost(self): hx_utils = self._get_original_heat_utilties() use_cached_network = False # A network synthesized with other options (stream splitting on or - # off) is never reused. + # off), or by a facility from before stream splitting (no options + # recorded; its life cycles have no entry port), is never reused. if (self.cache_network and hasattr(self, 'original_heat_utils') and hasattr(self, '_stage_fractions') - and getattr(self, '_synthesis_options', (False,)) + 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} @@ -428,8 +427,7 @@ def _cost(self): new_HXs = self.new_HXs new_HX_utils = self.new_HX_utils stage_fractions = self._stage_fractions - # (none in a network cached before stream splitting existed) - stage_scales = getattr(self, '_stage_scales', {}) + stage_scales = self._stage_scales for i, life_cycle in enumerate(stream_life_cycles): hx = hxs[i] s_util_in = hx.ins[0] @@ -490,7 +488,6 @@ def _cost(self): self.force_ideal_thermo, self.avoid_recycle, self.sort_hus_by_T, info=synthesis_info, stream_splitting=self.stream_splitting) - self._synthesis_options = (self.stream_splitting,) new_HXs = HXs_hot_side + HXs_cold_side # the realized splits (none without stream splitting) splits = synthesis_info.get('splits', ()) @@ -533,9 +530,11 @@ def _cost(self): # 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`. + # 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 @@ -768,7 +767,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 @@ -781,39 +780,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: - # the whole stream's inlet (its first stage may be a - # branch of a split) + 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.unit + if isinstance(entry, bst.Splitter): H_in = life_cycle.H_in - 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 + rows.insert(0, (entry.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/tests/test_hxn.py b/tests/test_hxn.py index 1d958b7..bf58115 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -84,6 +84,30 @@ 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 + del HXN._synthesis_options, HXN._stage_scales + 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.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 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 @@ -2572,11 +2596,10 @@ def test_stream_splitting_is_off_by_default(): 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) -SPLIT_UNIT_IDS = { - bst.Splitter: re.compile(r'^Split_\d+_(hs|cs)(_\d+)?(_b\d+)?$'), - bst.Mixer: re.compile(r'^Mix_\d+_(hs|cs)(_\d+)?$'), -} +#: 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 @@ -2607,6 +2630,42 @@ def test_split_network_units_and_ids(): 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 @@ -2670,8 +2729,15 @@ def test_split_network_registers_no_intermediate_streams(): 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: nothing changes with the keyword + # 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') @@ -2680,6 +2746,7 @@ def test_split_network_registers_no_intermediate_streams(): 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 @@ -2887,33 +2954,55 @@ def branch_limits(HXN): * (H_in + share * (H_out - H_in)))) return limits -@pytest.mark.parametrize('name', SPLIT_CASES) +@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) 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 + # (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 (none of them a tear stream, whose - # consumer the path-order pre-copy runs before its producer: there, as - # without splits, only the convergence brings the new flows) - streams = [] + # 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 += [unit.ins[index], last.unit.outs[last.index], - *[up.outs[port] for up, port, *_ in lc.connections()]] - recycles = network.recycle or [] - if not isinstance(recycles, list): recycles = [recycles] - assert streams and not {id(s) for s in streams} & {id(s) for s in recycles} + 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] @@ -2966,6 +3055,7 @@ def mixing(self): 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) @@ -3003,19 +3093,35 @@ def test_move_flows_moves_flows_only(monkeypatch): assert 0. < two_phase.vapor_fraction < 1. def failing(self): raise RuntimeError(f'{self.ID} was run') monkeypatch.setattr(bst.Mixer, '_run', failing) - # all flows 10 % larger, then only the vapor's + def split(s): return np.array([s.imol['g'], s.imol['l']]) + # all flows 10 % larger, then only the vapor's, each time with a new + # feed state, which the splitter's outlets take for streams in ((feed, liquid, vapor), (vapor,)): + phase_flows = split(two_phase) for s in streams: s.F_mol *= 1.1 + 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 == feed.T for s in splitter.outs) + 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) - assert isinstance(two_phase, bst.MultiStream) + if len(streams) == 3: + # every inlet 10 % larger: so is each phase of the two-phase + # outlet (the PH flash's split, kept) + assert_allclose(split(two_phase), 1.1 * phase_flows, + rtol=1e-14) + else: + # the inlet flows, split between the phases at the outlet's T + # and P (thermosteam's TP flash gives all liquid here, although + # T lies between the bubble and dew points) + flashed = two_phase.copy() + flashed.vle(T=two_phase.T, P=two_phase.P) + assert_allclose(split(two_phase), split(flashed), rtol=1e-9) def test_split_avoid_recycle_facility(): # r002's only unsplit MER network matches H1 twice with C3, so with @@ -3046,28 +3152,39 @@ def test_repair_on_split_plan(monkeypatch): **kwargs)) monkeypatch.setattr(hxn_synthesis, '_MAX_REFINE', 0) monkeypatch.setattr(hxn_synthesis, '_MAX_SPLIT_RETRY', 0) - repair, shrunk = hxn_synthesis._repair, [] - def spy(plan, duties, *args): + repair, realize = hxn_synthesis._repair, hxn_synthesis._realize + shrunk, realized = [], {} + def repairing(plan, duties, *args): duties, changes = repair(plan, duties, *args) - shrunk.extend((plan.exchangers[n], *change) - for n, *change in changes) + shrunk.extend((plan, n, *change) for n, *change in changes) return duties, changes - monkeypatch.setattr(hxn_synthesis, '_repair', spy) + 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 shrunk] - assert any(e.hot_frac < 1. or e.cold_frac < 1. for e, *_ in shrunk) - assert all(0. < after < before for _, before, after in shrunk) + 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) - branch_Q = [stage.unit.Q for lc in HXN.stream_life_cycles - for stage in lc.life_cycle if stage.branch is not None] - for e, before, after in shrunk: - if e.hot_frac < 1. or e.cold_frac < 1.: - assert min(abs(Q - after) for Q in branch_Q) <= 1e-12 * total + # 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']], diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 96f5463..28f2bec 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -246,7 +246,6 @@ Butterworth-Heinemann. """ import contextlib -import heapq import math import re import time @@ -257,6 +256,7 @@ import biosteam as bst import thermosteam as tmo 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 @@ -1395,38 +1395,6 @@ def _converge(system): system.converge() for unit in system.units: unit._summary() -def _connection_path(units, cycles): - """The simulation path of `units` along the connections of the life - `cycles`, and the streams to tear: a topological order (ties by the - order of `units`) that places, where the connections form a cycle, the - unit with the fewest unplaced feeders next (the rule of the facility's - `_network_path`).""" - position = {u: n for n, u in enumerate(units)} - successors = {u: [] for u in units} - waiting = {u: 0 for u in units} - for lc in cycles: - for up, port, down, _ in lc.connections(): - successors[up].append((down, up.outs[port])) - waiting[down] += 1 - ready = [position[u] for u in units if not waiting[u]] - heapq.heapify(ready) - placed = {} - while len(placed) < len(units): - if ready: - unit = units[heapq.heappop(ready)] - if unit in placed: continue - else: - unit = min((u for u in units if u not in placed), - key=lambda u: (waiting[u], position[u])) - placed[unit] = len(placed) - for other, _ in successors[unit]: - waiting[other] -= 1 - if not waiting[other] and other not in placed: - heapq.heappush(ready, position[other]) - path = sorted(units, key=placed.__getitem__) - recycles = [s for u in units for other, s in successors[u] if placed[other] <= placed[u]] - return path, recycles - _WIRED = {} def _wired_split_network(case, cached=False): @@ -1469,7 +1437,7 @@ def _wired_split_network(case, cached=False): 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 = _connection_path(new_HXs + utils + splitters + mixers, cycles) + 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) From ac646e00ff5b43c3e32c18573fb2c3be678d9646 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 16:06:21 -0700 Subject: [PATCH 17/24] test that splitting reaches mer on the constant-cp split corpus The SPLIT half of the MER corpus (test_hxn_mer) proves, by the pinch design rules, that MER needs stream splitting, and so far only checked the default facility (feasible, balanced, 'best_effort'). With the facility's new stream_splitting option (milestones A-C) the synthesizer promises MER on these problems; this adds the tests that hold it to that promise on the 25 constant-CP SPLIT problems (design 6.2; the 13 real-thermo ones follow in T10 by extending the parametrization from SPLIT_CP to SPLIT): - test_split_network_balanced_and_feasible: the strict split-aware checks G0-G10 (_split_network_problems) on _network(case, True). - test_split_network_reaches_mer (_split_mer_problems): status 'mer'; the heating and cooling equal hensmith's targets within MER_TOL x total duty (1e-12 for constant CP) and the independent closed-form cascade within MER_TOL + its tolerance; splitters and mixers present; deviations, split_deviations, repaired, dropped and qmin_dropped all empty (every exchanger at its planned duty, every mixer at its planned state); min_approach >= T_min_app - APPROACH_TOL; no side best effort; every split side's method is split-, with leak == preleak == 0, no small cell and no failed strategy; at most _SPLIT_MIX_CAP mixers per curved stream and side (vacuous for constant CP: the planner merges collinear grid knots, so none of the 25 cases has a curve with more than two knots, scratch t9b_curved_probe.py; it bites in T10). - test_split_network_structure: >= 2 branches, one fraction each, summing to 1 within 1e-12, every branch with an exchanger, each an HXprocess of the facility's new_HXs, and the split's splitters and mixer among its new_splitters and new_mixers; each life cycle holds exactly its stream's splits in flow order with every branch's stages and fractions; a must split (hot above, cold below) is isothermal; stream_HXs_dict is the life cycle. - test_split_exact_dTmin_is_enthalpy_limited (smith2005_ex18_4_split): every branch exchanger has dT == T_min_app - 1e-6, stops at one of its enthalpy limits (within DUTY_RTOL of the stream duty), and its duty equals the planned duty of the facility's round-0 plan (rebuilt independently from _corpus_knots, IDs as _realize names them) within _DUTY_TOL; at least one of them has a terminal at exactly T_min_app (1e-9 K). - test_no_split_network_with_splitting (4sp1_lee1970_dt10F, linnhoff_4stream): with the flag, the same exchanger IDs and bitwise the same duties as without, no splitter or mixer, the same refine rounds and repairs, and the checks pass. - test_backstop_alone_reaches_mer: the vertical core alone (_SPLIT_RULES = (), _CORE_STRATEGIES = ('V',)) at the planner on the 25 cases' round-0 knots plus the two constructed pre-leak problems of test_hxn_planner (NEAR_THRESHOLD, and nptel_t5_3 with CP2 = 6 - 1.58e-11): 'mer', every split side picks V with no leak, penalty <= preleak + 1e-12 scale, preleak > 0 exactly on the constructed problems, and every split side's cells (captured from _planner._plan_side) pass the independent _verify_side_cells from the pre-leaked root; test_backstop_alone_reaches_mer_in_the_facility does the same end to end on smith2005_ex18_4_split (variant 'V'): the checks above and G0-G10. Every test passed on its first run: the behaviour was built by T3-T8c, and no failure needed a fix in hensmith (nothing under hensmith/ changed). That the tests can fail was shown with source mutations of hensmith, each caught (scratch t9b_mutation.py/.out, t9b_mutation2.py/.out): no dT guard (exact-dTmin), branch H_lim off by 1e-4 (reaches_mer, balanced, exact-dTmin, backstop facility), every mixer reported as deviating (reaches_mer), never isothermal (structure, balanced), stage fractions lost (structure, balanced), splits in reverse flow order (structure, balanced, reaches_mer), the flag changing the planner's Qmin (balanced, reaches_mer; at 1e10 also test_no_split_network_with_splitting), no pre-leak (backstop, on the two constructed problems). Per-case times with stream splitting (facility synthesis and simulation, _network(case, True)['time']; without the flag in parentheses; scratch t9b_times.out): case on (off) units splits picks (side and rule) cornell_processdesign_four_stream_split 0.17s (1.77s) 5 HX 3 splits above S:mincell, below S:nw-exact-desc nptel_t4_4_four_stream_dT20 0.04s (1.10s) 5 HX 1 splits below S:partner nptel_t5_3_four_stream_split 0.03s (2.00s) 5 HX 2 splits below S:nw-exact-desc smith2005_ex18_2_split 0.02s (0.03s) 4 HX 1 splits above S:nw-exact-desc smith2005_ex18_4_split 0.01s (0.12s) 4 HX 1 splits below S:demand smith2005_ex16_5_five_stream 0.05s (0.13s) 3 HX 1 splits above S:nw-exact-desc smith2005_exr18_4 0.06s (0.07s) 6 HX 1 splits above S:partner smith2005_exr18_7_six_stream 0.06s (0.07s) 6 HX 1 splits above S:partner 7sp3_dt20F 0.09s (0.26s) 9 HX 1 splits above S:partner 7sp4_dolan1990_dt10K 0.09s (0.16s) 9 HX 1 splits above S:partner 8sp1_dt20F 0.44s (0.06s) 8 HX 1 splits above S:nw-rho-asc 8sp1_sargent1978_dt10F 0.05s (0.07s) 7 HX 1 splits above S:demand nptel_t5_7_eight_stream_split 0.06s (1.82s) 9 HX 1 splits below S:demand smith2005_exr18_5_nine_stream 0.20s (0.52s) 7 HX 1 splits below S:partner crude_fractionation_ph11c2 0.32s (2.11s) 17 HX 2 splits above S:partner, below S:nw-exact-desc bagajewicz_ou_crude_unit_15_stream 2.08s (2.16s) 28 HX 7 splits above VT bjork_pettersson_15sp_ph8c7 0.59s (4.66s) 23 HX 2 splits above S:partner, below S:demand fs_15sp_tkm 0.61s (0.34s) 14 HX 1 splits below S:partner cgm_balanced8 0.46s (1.93s) 21 HX 1 splits above S:partner cgm_balanced10 1.02s (4.40s) 29 HX 2 splits above S:partner, below S:demand cgm_unbalanced10 0.95s (4.02s) 27 HX 2 splits above S:partner, below S:demand luo_20sp_ph10c10 3.01s (1.48s) 52 HX 34 splits below V fs_22sp1 0.13s (2.74s) 26 HX 1 splits above S:partner fs_22sp_ph 0.71s (2.80s) 16 HX 3 splits above S:partner, below S:mincell grossmann_balanced12_r0 0.63s (3.81s) 35 HX 3 splits above S:partner, below S:demand facility, variant V: smith2005_ex18_4_split 0.01 s, mer The facility is faster with the flag on the whole (11.9 s against 38.6 s): the split sides skip the unsplit best-effort searches. The planner-level backstop takes 0.4 s for the 25 cases. Validation: tests/test_hxn_mer.py parses with ast feature_version (3, 12); full suite 865 passed in 394.55s (0:06:34) (759 before: + 25 x 3 split tests, 1 exact-dTmin, 2 NO_SPLIT identity, 27 planner backstop, 1 facility backstop = +106); the same tests took 289 s in the run before a system crash interrupted this task (suite times on this machine vary between runs: 271-469 s at milestone C). R1 oracle --check, planner mode: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 564.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle --check, full mode: R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 138.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- tests/test_hxn_mer.py | 276 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 273 insertions(+), 3 deletions(-) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 28f2bec..5d6d2fe 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 @@ -179,6 +182,33 @@ 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 constant-CP SPLIT problem (``SPLIT_CP``), synthesized with + ``stream_splitting=True``, passes + G0-G10 and reaches both hensmith's targets and the independent + reference within the NO_SPLIT tolerances (``_split_mer_problems``): + status 'mer', no side left to best effort, every exchanger at its + planned duty (``deviations``), nothing repaired, dropped or dropped by + ``Qmin``, every mixer outlet at its planned state, 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. +``test_no_split_network_with_splitting`` + A NO_SPLIT problem synthesized with ``stream_splitting=True`` is the + network synthesized without it, bit for bit. +``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_CP``, 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 network (smith2005_ex18_4_split) passes the checks above. Tolerances ---------- @@ -260,6 +290,8 @@ 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) # --------------------------------------------------------------------------- # Tolerances (justified in the module docstring) @@ -1841,3 +1873,241 @@ def test_network_variant_is_patched_for_the_synthesis_only(): assert _splitting._SPLIT_RULES is rules with pytest.raises(ValueError): with _variant('LV'): pass + +# --------------------------------------------------------------------------- +# Stream splitting: the facility at the corpus +# --------------------------------------------------------------------------- + +#: the constant-CP SPLIT problems +SPLIT_CP = [case for case in SPLIT if _is_cp(case)] + +#: NO_SPLIT problems synthesized end to end with stream splitting (at the +#: planner, test_no_split_plan_unchanged_by_splitting covers them all) +NO_SPLIT_WITH_SPLITTING = ['4sp1_lee1970_dt10F', 'linnhoff_4stream'] + +#: 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 _numeric_knots(dT, rows): + """Planner inputs (knots, is_hot, T_min_app) of a constant-CP problem + given as test_hxn_planner rows, as `_planner._plan_numeric` builds + them.""" + knots, is_hot = [], [] + for _, kind, T_in, T_out, CP in rows: + a, b = float(min(T_in, T_out)), float(max(T_in, T_out)) + T = np.array([a, b]) + knots.append((T, float(CP) * (T - a))) + is_hot.append(kind == 'h') + return knots, is_hot, float(dT) + +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); + `candidate`, if given, is the name every split side must have picked.""" + HXN, T_min_app = net['HXN'], net['T_min_app'] + info = HXN.synthesis_info + problems = [] + if info['status'] != 'mer': problems.append(f"status {info['status']!r}") + 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}') + 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') + curved = _curved(case) + mixers = {} + for split in 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 + +@pytest.mark.parametrize('case', SPLIT_CP, 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_CP, 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_CP, 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')], 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_CP] + + 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 + if name in PRELEAK_PROBLEMS: + knots, is_hot, T_min_app = _numeric_knots(*PRELEAK_PROBLEMS[name]) + else: + k = _corpus_knots(_case(name)) + knots, is_hot, T_min_app = k['knots'], k['is_hot'], k['T_min_app'] + monkeypatch.setattr(_splitting, '_SPLIT_RULES', ()) + monkeypatch.setattr(_splitting, '_CORE_STRATEGIES', ('V',)) + 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) + plan = plan_network(knots, is_hot, 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('smith2005_ex18_4_split')], 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) From f366124812cfcd42152e610a46d43d5f5d24dcc5 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 16:46:48 -0700 Subject: [PATCH 18/24] test that splitting reaches mer on the real-thermo corpus and regression T9b held the facility's stream_splitting option to its MER promise on the 25 constant-CP SPLIT problems. This extends the same tests to the 13 real-thermo ones, where the knots are chords of the true curves, so a split plan reaches MER only if the refine loop closes every exact-state violation (risk R-1) and every rigorous mixer lands on its planned state (R-2, R-11); it also adds the design's identity and regression items (design 6.2, 6.4, task T10): - test_split_network_balanced_and_feasible, test_split_network_reaches_mer, test_split_network_structure and the planner-level test_backstop_alone_reaches_mer now run over the whole SPLIT list (38 cases; SPLIT_CP is gone). On real thermo, _split_mer_problems holds the utilities to the targets and the reference within MER_TOL 1e-10 x total duty, asserts repaired == split_deviations == deviations == dropped == [] (R-1: the refine rounds closed every violation, no _repair), and its mixer cap (_SPLIT_MIX_CAP per curved stream and side, the bound on the mixers' enthalpy residuals) now bites: every real-thermo split network has a mixer on a curved stream (13 of 13; vacuous on constant CP). - test_split_exact_dTmin_is_enthalpy_limited also on rtB12_above_2h1c_cond_boil_below (no refine round, so round 0 is the realized plan). - test_backstop_alone_reaches_mer_in_the_facility also on rtB12 and rtB07 (BACKSTOP_FACILITY). - test_no_split_network_with_splitting also on the 16 real-thermo NO_SPLIT cases, the only place their refined knots meet the flag: bitwise the same exchanger IDs and duties, refine rounds and repairs. - test_long_vertical_chain_is_exact (new): V with _SPLIT_COARSEN = False on rtB07 above chains 276 elementary blocks (3 coarsened); every node passes (R) by direct evaluation, no leak, no missed knot, the last node is exactly the musts' duties, and the cells pass _verify_side_cells (test_hxn_planner.verify_core, now imported). - tests/test_hxn_regression.py: the checks (i)-(v) move into check_network; test_hxn_regression_with_splitting runs all 10 CASES with stream_splitting=True through them; cases 04, 08 and 09 (SPLIT_CASES) must reach 'mer' with splits, every other case must equal the default network bit for bit (IDs, duties, refine rounds, repairs). synthesize() takes stream_splitting (default False). Module docstrings updated. Every test passed on its first run apart from one assertion of the new chain test itself: it expected 100x more elementary than coarsened blocks, and the case has 276 against 3 (now >= 200 and <= 10). Nothing under hensmith/ changed, and no tolerance was touched. No real-thermo case needed investigation: no repaired, split_deviations or G5 problem anywhere. That the tests can fail was shown with mutations (scratch t10_mutation.py, t10_mutation_run1.out, t10_mutation2.out; control: 188 passed): - no refine rounds (_MAX_REFINE = _MAX_SPLIT_RETRY = 0): reaches_mer fails on rtB07 (repaired); - the test's mixer cap at 0: reaches_mer fails on all 13 real-thermo cases and the backstop facility on rtB12 and rtB07, on no constant-CP case; - vertical-block end nodes off by 64 ulp: the long-chain test fails; - no dT guard: 2 failures, exact-dTmin on rtB12 among them; - planned duties 1 ulp up with the flag: the identity fails on 15 of the 16 rtA cases and 6 of the 7 unsplit regression cases (rtA02 and case 02 realize the same bits); - Qmin x 1e10 with the flag: 68 failures (x 1e4 changes no network); - no split candidates (no rule, no core strategy): 82 failures, among them regression cases 04, 08 and 09. Measured (facility synthesis and simulation, _network(case, True)['time']; without the flag in parentheses; scratch t10_probe.out, t10_probe_E.out): case on (off) units splits rr picks rtB01_above_3h2c_liquids 2.68s (9.32s) 6 HX 2 0 above LV, below S:partner rtB02_below_3c2h_liquids 1.01s (8.28s) 9 HX 2 0 above LVT, below S:partner rtB03_above_hot_vapors 0.28s (5.10s) 9 HX 4 0 above S:mincell, below S:demand rtB04_below_cold_boilers 0.75s (5.37s) 8 HX 1 0 below S:partner rtB05_above_2h1c 0.05s (0.18s) 3 HX 1 0 above S:partner rtB06_below_2c1h 0.03s (0.35s) 3 HX 1 0 below S:partner rtB07_above_with_cold_glide 1.48s (9.25s) 8 HX 2 1 above LVT, below S:demand rtB08_above_steam_creator 1.70s (12.43s) 7 HX 3 0 above S:mincell, below S:demand rtB09_below_with_cold_glide 0.11s (0.36s) 3 HX 1 0 below S:partner rtB10_11_streams_above 2.16s (14.00s) 11 HX 2 0 above LVT, below S:partner rtB11_12_streams_below 0.92s (6.34s) 9 HX 1 0 below S:partner rtB12_above_2h1c_cond_boil_below 0.06s (0.19s) 4 HX 1 0 above S:partner rtB13_above_3h2c_glide_below 0.33s (6.71s) 7 HX 2 0 above S:partner, below S:partner total 11.6s (77.9s) - refine rounds: 1 in total (rtB07, as the T5b spike predicted), retries 0, no restore; repaired, split_deviations, deviations, dropped empty everywhere; min_approach >= T_min_app - 4.2e-10 K; at most 1 mixer per curved stream and side. - backstop alone (V), facility: smith2005_ex18_4_split 0.01 s, rtB12 0.03 s, rtB07 0.53 s (17 HX, 11 splits), all 'mer' with no refine round; planner: 13 real-thermo cases 0.4 s. - NO_SPLIT real-thermo with the flag: 16 cases, 2.6 s of synthesis, all refine_rounds 0 and identical to the default. - regression with the flag: 0.7 s for the 10 cases; 04 (6 -> 4 HX, 1 split), 08 (13 -> 8, 3 splits; 4.0 s -> 0.20 s) and 09 (12 -> 8, 2 splits; 3.3 s -> 0.18 s) reach 'mer' with no repair (without the flag 08 and 09 repair 2 exchangers each); the 7 others are identical. - the T10 tests add about 18 s (65 timed entries: 22 s in a targeted run that also built the default rtA networks). Validation: both test files parse with ast feature_version (3, 12); git diff --check clean; full suite 947 passed in 356.92s (0:05:56) (865 before: + 13 x 3 real-thermo split tests, 13 planner backstop, 1 exact-dTmin, 16 NO_SPLIT identity, 2 facility backstop, 1 long chain, 10 regression = +82), +108.6 s over the 248.35 s baseline, inside the +200 s target, so no 6.6 lever was applied (T9b's run took 394.55 s: suite times here vary between runs). R1 oracle --check, planner mode: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 502.6 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle --check, full mode: R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 129.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- tests/test_hxn_mer.py | 74 +++++++++++++++++++++++++----------- tests/test_hxn_regression.py | 48 ++++++++++++++++++++--- 2 files changed, 94 insertions(+), 28 deletions(-) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 5d6d2fe..79df054 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -183,15 +183,17 @@ 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 constant-CP SPLIT problem (``SPLIT_CP``), synthesized with - ``stream_splitting=True``, passes + 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 (``_split_mer_problems``): status 'mer', no side left to best effort, every exchanger at its - planned duty (``deviations``), nothing repaired, dropped or dropped by - ``Qmin``, every mixer outlet at its planned state, the minimum - approach kept, and every split side free of leaks, pre-leaks, cells - below ``Qmin`` and failed strategies. + 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, the + minimum approach kept, every split side free of leaks, pre-leaks, + cells below ``Qmin`` and failed strategies, and at most + ``_SPLIT_MIX_CAP`` mixers per curved stream and side (which bounds the + mixers' enthalpy residuals on real thermo). ``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 @@ -199,16 +201,22 @@ 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. + (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. + 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_CP``, and of the two constructed problems whose + 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 network (smith2005_ex18_4_split) passes the checks above. + 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 ---------- @@ -291,7 +299,7 @@ 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) + sides_from_knots, verify_core) # --------------------------------------------------------------------------- # Tolerances (justified in the module docstring) @@ -1878,12 +1886,18 @@ def test_network_variant_is_patched_for_the_synthesis_only(): # Stream splitting: the facility at the corpus # --------------------------------------------------------------------------- -#: the constant-CP SPLIT problems -SPLIT_CP = [case for case in SPLIT if _is_cp(case)] +#: 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)]) -#: NO_SPLIT problems synthesized end to end with stream splitting (at the -#: planner, test_no_split_plan_unchanged_by_splitting covers them all) -NO_SPLIT_WITH_SPLITTING = ['4sp1_lee1970_dt10F', 'linnhoff_4stream'] +#: 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 @@ -1957,14 +1971,14 @@ def _split_mer_problems(case, net, candidate=None): problems.append(f'stream {key[0]} {key[1]}: {n} mixers on a curved stream') return problems -@pytest.mark.parametrize('case', SPLIT_CP, ids=_name) +@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_CP, ids=_name) +@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 @@ -1973,7 +1987,7 @@ def test_split_network_reaches_mer(case): problems = _split_mer_problems(case, _network(case, True)) assert not problems, '\n'.join(problems) -@pytest.mark.parametrize('case', SPLIT_CP, ids=_name) +@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 @@ -2023,7 +2037,8 @@ def _planned_duties(case): duties[ID] = e.Q return duties -@pytest.mark.parametrize('case', [_case('smith2005_ex18_4_split')], ids=_name) +@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 @@ -2069,7 +2084,7 @@ def test_no_split_network_with_splitting(name): problems = _network_problems(on) assert not problems, '\n'.join(problems) -@pytest.mark.parametrize('name', [case['name'] for case in SPLIT_CP] +@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 @@ -2104,10 +2119,25 @@ def spy(side, *args, **kwargs): assert result.status == 'mer' _verify_side_cells(side, result.cells, _splitting._preleak_root(side)[1]) -@pytest.mark.parametrize('case', [_case('smith2005_ex18_4_split')], ids=_name) +@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_regression.py b/tests/test_hxn_regression.py index c392494..2f085af 100644 --- a/tests/test_hxn_regression.py +++ b/tests/test_hxn_regression.py @@ -56,6 +56,13 @@ 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; 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 @@ -201,13 +208,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 +261,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 +297,32 @@ 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: + assert info['status'] == 'mer', name + assert info['splits'] and HXN.new_splitters and HXN.new_mixers, name + 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) From a5cfa0b4d8456c0212ef0672d50534ccf898cc16 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 17:42:13 -0700 Subject: [PATCH 19/24] address review D: hold regression split cases to the corpus checks, document split MER margins Review milestone D (tests diff T9a-T10) found only test-side gaps; no library code changes. Regression split cases (three findings, one gap): for cases 04, 08 and 09, the only real-thermo split networks outside the corpus (case 08 splits the same stream on both sides of the pinch), test_hxn_regression_with_splitting checked only check_network (i)-(v), status 'mer' and that splits exist. Check (ii) and the synthesizer's 'mer' both accept a utility gap of up to 1e-6 x total duty, and nothing ran the split-specific checks, so a network whose mixer ended off its planned state, whose exchanger was repaired, or whose split side leaked would pass whenever the lost duty stayed under 1e-6 x total. These cases are now held to the corpus standard: the strict split-aware checks G0-G10 (_network_problems on _network_record), both utilities within MER_TOL['real_thermo'] of the problem-table targets, and the synthesis report checks of the corpus. Those report checks (status, realized splits, repaired / dropped / qmin_dropped / split_deviations / deviations empty, min approach, no best-effort side, every split side with a candidate and free of leaks, pre-leaks, small cells and errors) are moved verbatim out of _split_mer_problems into _split_report_problems(HXN, T_min_app, candidate), which needs no reference and so serves both modules; _split_mer_problems keeps the reference and mixer-cap checks and calls it, so the corpus tests check exactly what they checked before. Red check (scratch fixD_red.py): on each of the three networks, a fake 'repaired' or 'split_deviations' entry, a split-side leak, a mixer outlet 0.01 K off (04, 09) and utilities shifted by 1e-9 x total all pass the old branch and fail the new one with the intended message; unmutated networks pass (measured gaps at most 1.75e-14 x total, case 09 cooling). MER_TOL docstring (two findings): the Tolerances bullet justified MER_TOL only for NO_SPLIT networks ("about 100 times"), although _split_mer_problems applies it to the 38 SPLIT networks synthesized with stream_splitting=True. Extended without changing a value, with the maxima from a run (scratch fixD_gaps.out): 5.3e-15 x total (fs_15sp_tkm) and 2.56e-12 (rtB05_above_2h1c), 187 and 39 times below MER_TOL; the flag-off 1e-9 "never beat" rule stays the rule for the default networks. The test_split_network_reaches_mer entry now points at it and names _split_report_problems. Backstop patch (one finding): test_backstop_alone_reaches_mer repeated the 'V' variant's two setattr calls; it now plans under _variant('V'), the patch the facility-level backstop test uses, so the two cannot drift. monkeypatch remains for the _plan_side spy only. Knot copy (one finding): _numeric_knots re-implemented the knots of _planner._plan_numeric. With knots=1, _plan_numeric builds bit-identical knots (linspace(a, b, 2) with its ends reassigned, float CP * (T - a), float(dTmin)) and calls plan_network, which calls _planner._plan_side by its module-global name (so the spy still records the sides); the pre-leaked problems now plan through _plan_numeric(streams_from(rows), dT, stream_splitting=True)['plan'] and the copy is deleted. Validation: ast.parse(feature_version=(3, 12)) on both files; targeted tests (regression, backstop, split MER) 141 passed; full suite 947 passed in 366.77s (0:06:06); tests/test_hxn_regression.py again after a final docstring reflow: 20 passed. No library file changed, so the R1 oracle runs in planner mode: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 478.7 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) Co-Authored-By: Claude Opus 5.5 --- tests/test_hxn_mer.py | 110 +++++++++++++++++++---------------- tests/test_hxn_regression.py | 30 ++++++++-- 2 files changed, 86 insertions(+), 54 deletions(-) diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 79df054..322c199 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -185,15 +185,18 @@ ``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 (``_split_mer_problems``): - 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, the - minimum approach kept, every split side free of leaks, pre-leaks, - cells below ``Qmin`` and failed strategies, and at most + 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). + 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 @@ -247,14 +250,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, @@ -299,7 +306,7 @@ 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, verify_core) + sides_from_knots, streams_from, verify_core) # --------------------------------------------------------------------------- # Tolerances (justified in the module docstring) @@ -1906,18 +1913,6 @@ def test_network_variant_is_patched_for_the_synthesis_only(): PRELEAK_PROBLEMS = {'near_threshold': NEAR_THRESHOLD, 'near_double_pinch': NEAR_DOUBLE_PINCH} -def _numeric_knots(dT, rows): - """Planner inputs (knots, is_hot, T_min_app) of a constant-CP problem - given as test_hxn_planner rows, as `_planner._plan_numeric` builds - them.""" - knots, is_hot = [], [] - for _, kind, T_in, T_out, CP in rows: - a, b = float(min(T_in, T_out)), float(max(T_in, T_out)) - T = np.array([a, b]) - knots.append((T, float(CP) * (T - a))) - is_hot.append(kind == 'h') - return knots, is_hot, float(dT) - 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 @@ -1929,18 +1924,42 @@ def _curved(case): 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); - `candidate`, if given, is the name every split side must have picked.""" - HXN, T_min_app = net['HXN'], net['T_min_app'] - info = HXN.synthesis_info - problems = [] - if info['status'] != 'mer': problems.append(f"status {info['status']!r}") + 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'): @@ -1961,14 +1980,6 @@ def _split_mer_problems(case, net, candidate=None): 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') - curved = _curved(case) - mixers = {} - for split in 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 @pytest.mark.parametrize('case', SPLIT, ids=_name) @@ -2091,13 +2102,6 @@ def test_backstop_alone_reaches_mer(name, monkeypatch): # 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 - if name in PRELEAK_PROBLEMS: - knots, is_hot, T_min_app = _numeric_knots(*PRELEAK_PROBLEMS[name]) - else: - k = _corpus_knots(_case(name)) - knots, is_hot, T_min_app = k['knots'], k['is_hot'], k['T_min_app'] - monkeypatch.setattr(_splitting, '_SPLIT_RULES', ()) - monkeypatch.setattr(_splitting, '_CORE_STRATEGIES', ('V',)) planned = {} plan_side = _planner._plan_side def spy(side, *args, **kwargs): @@ -2105,7 +2109,15 @@ def spy(side, *args, **kwargs): planned[side.name] = side, result return result monkeypatch.setattr(_planner, '_plan_side', spy) - plan = plan_network(knots, is_hot, T_min_app, stream_splitting=True) + 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} diff --git a/tests/test_hxn_regression.py b/tests/test_hxn_regression.py index 2f085af..c501a42 100644 --- a/tests/test_hxn_regression.py +++ b/tests/test_hxn_regression.py @@ -60,9 +60,16 @@ ``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; every other case must give, bit for bit, the network it gives -without the option (the same exchangers and duties, refine rounds and -repairs). +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 @@ -72,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 @@ -312,8 +322,18 @@ def test_hxn_regression_with_splitting(name): check_network(name, units, HXN, T_min_app) info = HXN.synthesis_info if name in SPLIT_CASES: - assert info['status'] == 'mer', name - assert info['splits'] and HXN.new_splitters and HXN.new_mixers, name + # 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 From 48531fe44d8724b7353fda85054d65456e0dc226 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 18:20:18 -0700 Subject: [PATCH 20/24] document stream splitting in docstrings, docs pages and the readme Stream splitting (stream_splitting=True) was implemented and tested in milestones A-D, but the documentation still described hensmith as a synthesizer that never splits streams. This commit documents the option and marks every "does not split" statement as the default behavior. There are no code changes and no behavior changes, apart from one property docstring (see Docstrings). Docstrings: - The stream_splitting parameter now has the same text, byte for byte, in HeatExchangerNetwork, synthesize_network and plan_network (checked with diff). - The module docstrings of hxn_synthesis, _planner and _heat_exchanger_network describe the option. The last of these was only a "Created on" stub. - The _planner module docstring has a new "Stream splitting (optional)" section. Its "best effort", "series alternation" and "guarantees" statements now say what changes when the option is on. - The _splitting module docstring now states Theorem M (the candidate portfolio always contains an MER candidate), with its proof sketch. - The Notes of synthesize_network give the guarantee and the precise remaining gap: the knots are exact for constant CP, and on real thermodynamics the chords are closed by refine rounds plus the split retry, then repair. - The info parameter says it is required with stream_splitting, and what 'split' holds when no candidate is found. - The class Notes of HeatExchangerNetwork have a "Stream splitting" paragraph covering the Split_/Mix_ IDs, new_splitters/new_mixers and synthesis_info['splits']. - _pinch_cut and pinch_state now say that the synthesis does not cut streams at the pinch, even when it splits them. - The description of StreamLifeCycle.H_in moved from the class Attributes section to the property's own docstring. With both, autodoc documented H_in twice and `sphinx-build -W` failed ("duplicate object description of hensmith.StreamLifeCycle.H_in"). This was latent since T8a added the property. Doctests: two new examples. Their outputs were pasted from a run in which the examples were first written without outputs and failed (scratch t11_red.out): - plan_network: a constant-CP case with two hot streams and one cold stream breaks the number rule above the pinch. Without the option the plan is 'best_effort'. With it the plan is 'mer', and the cold stream is split 0.5/0.5 above the pinch. - HeatExchangerNetwork: case rtB05 (real thermodynamics) is simulated twice. Without the option it is 'best_effort'. With it the result is 'mer', with Split_0_hs and Mix_0_hs, the exchangers HX_0_2_hs, HX_0_3_hs and HX_1_0_cs, and fractions (0.5201, 0.4799). docs/source: - index, API/heat_exchanger_network: a stream_splitting row in the options table; new_splitters and new_mixers rows; the cache key; the stream_HXs_dict order; the HXN_sys and synthesis_info rows. - API/hxn_synthesis: an autoclass for StreamSplit (:no-members:, because its __slots__ duplicate the Attributes entries), and _splitting in the note on private modules. - concepts: a new "Stream splitting" section on branches, realization, and the guarantee and its limits. The planner, report, convergence, validation, regression and guarantees text is qualified to match. - tutorials 02, 03 and 04; contributing (the module list and what the tests check). Tutorial 04 has a new "Splitting streams" section. Its code is a new [start:splitting] region of docs/_demo_src/examples/ch04_configuring.py. Its output is the new docs/source/_generated/ch04_splitting.txt, produced by running only that script through pylock. The run synthesizes the 15 K quickstart network, the first best-effort network of the sweep, both ways: - Without the option: best_effort, 8 exchangers, 6.684e+05 USD. - With the option: mer, 5 exchangers, 5.893e+05 USD. The condenser (stream 3) is split below the pinch into branches of 0.5624 and 0.4376, one for each cold stream at the pinch. After the run, `git diff --stat docs/source/_generated docs/source/_static` showed no content change to the existing ch04 captures or PNGs. The three existing captures showed as modified only because they were rewritten with LF line endings, so they were checked out again. docs/_demo_src/README.md lists the new capture. README: one sentence on the option (DECISIONS Q7), and the best-effort statement now says it is the default. Validation: - ast.parse(feature_version=(3, 12)) passes on every changed .py file. - `sphinx-build -E -W -b html docs/source docs/build/html` exits 0 (build succeeded). The theme packages come from a --target directory, and the env is untouched. - Full suite, run on the final tree: 947 passed in 322.70s (0:05:22). R1 oracle --check: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 462.2 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 116.5 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- README.md | 7 +- docs/_demo_src/README.md | 2 +- docs/_demo_src/examples/ch04_configuring.py | 21 +++- docs/source/API/heat_exchanger_network.rst | 22 +++- docs/source/API/hxn_synthesis.rst | 19 ++- docs/source/_generated/ch04_splitting.txt | 14 +++ docs/source/concepts.rst | 127 ++++++++++++++++---- docs/source/contributing/contributing.rst | 35 ++++-- docs/source/index.rst | 5 +- docs/source/tutorial/02_pinch_analysis.rst | 11 +- docs/source/tutorial/03_network_anatomy.rst | 18 ++- docs/source/tutorial/04_configuring.rst | 62 ++++++++-- hensmith/_heat_exchanger_network.py | 74 ++++++++++-- hensmith/_planner.py | 80 ++++++++++-- hensmith/_splitting.py | 35 +++++- hensmith/hxn_synthesis.py | 99 ++++++++++----- 16 files changed, 513 insertions(+), 118 deletions(-) create mode 100644 docs/source/_generated/ch04_splitting.txt diff --git a/README.md b/README.md index 0db04f6..0c5637b 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,11 @@ 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. ```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/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 4e3943e..054694e 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 """ @@ -169,12 +175,19 @@ class HeatExchangerNetwork(bst.Facility): to all unit operations in the system. stream_splitting : bool, optional Allow a process stream to be split into parallel branches that - re-join, where the minimum energy requirement needs it (see - `synthesize_network`). The branches are realized with - `biosteam.Splitter` chains (`new_splitters`) and rigorous - `biosteam.Mixer` units (`new_mixers`), which are adiabatic and - cost nothing. Defaults to False: the network has no split, and - both lists are empty. + 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 ----- @@ -196,6 +209,22 @@ class HeatExchangerNetwork(bst.Facility): 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 @@ -293,7 +322,38 @@ 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 diff --git a/hensmith/_planner.py b/hensmith/_planner.py index 9155eee..35628b2 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 ---------- @@ -2142,9 +2176,13 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., 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. With `avoid_recycle`, a - split that would repeat a stream pair is not used, and MER is then - not guaranteed. Defaults to False. + 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. _split_exclude : dict[str, set[tuple]], optional Private (the refine loop): per side, network signatures a split candidate may not have unless it is the side's last candidate. @@ -2192,6 +2230,30 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., >>> 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) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 80f8568..03de833 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -9,9 +9,10 @@ """ Stream splitting for the MER planner (`hensmith._planner`). -When the pinch design rules prove that a side of the pinch has no minimum -energy requirement (MER) network without stream splits, the planner can -split streams into parallel branches. This module holds the numeric +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. @@ -174,9 +175,31 @@ 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. +`_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 ---------- diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 4080b70..9a6441b 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -8,10 +8,11 @@ # 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 @@ -219,10 +220,6 @@ class StreamLifeCycle: 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. - H_in : float - 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. Notes ----- @@ -243,6 +240,9 @@ def __init__(self, index, cold): @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.""" unit, index = self.entry return unit.ins[index].H @@ -707,9 +707,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]) @@ -880,7 +881,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: @@ -1681,12 +1683,14 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, avoid_recycle=False, sort_hus_by_T=False, info=None, stream_splitting=False): """ - 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 ---------- @@ -1750,7 +1754,11 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, (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) and its method is 'split-' when it splits. + 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 @@ -1762,9 +1770,10 @@ def synthesize_network(hus, T_min_app=5., Qmin=1e-3, force_ideal_thermo=False, 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']``. With `avoid_recycle`, - a split that would repeat a stream pair is not used, and MER is - then not guaranteed. Needs `info`. Defaults to False. + 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 ------- @@ -1845,7 +1854,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. @@ -1931,16 +1941,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 -------- From 4acb8db50b43d15c902a6a0f4e0bd1393d18f260 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 19:35:39 -0700 Subject: [PATCH 21/24] address review E: raise tiny capacity branches, entry-free life cycles Review milestone E. I reproduced each finding before acting on it (scratch revE_repro.py and revE_mutations.py). All ten were real. The two README findings name the same sentence, so one qualifier fixes both. major: plot_pinch_diagram and save_stream_life_cycles_as_csv failed on life cycles without an entry port, even with stream splitting off. Cause: StreamLifeCycle.H_in unpacked self.entry, which only get_life_cycle sets, and the CSV read life_cycle.entry.unit. At HEAD, a life cycle whose life_cycle was assigned directly raised TypeError (plot) and AttributeError (CSV). One from before stream splitting (no entry or splits, e.g. unpickled) raised AttributeError in both. Fix: class defaults entry = None and splits = (), so older instances resolve them. H_in falls back to the first stage's H_in when there is no entry. This is bit-identical: for an unsplit stream, entry is that same stage's port. The CSV row for a splitter is written only when there is an entry. New test test_life_cycles_without_entry_plot_and_write_csv: on the doctest system, the plot texts and the CSV rows are identical with get_life_cycle's life cycles, with life_cycle assigned directly, and with entry and splits deleted. minor: a facility from before stream splitting failed in _cost. It has no stream_splitting attribute, and the cache key reads it. Fix: class attribute stream_splitting = False. __init__ still sets it, so new objects are unchanged. test_cache_network_from_before_stream_splitting_ synthesizes_again now also deletes HXN.stream_splitting and checks that the network is synthesized again, with no warning and no splitter. minor: Stage S threw away workable splits when a capacity branch would be tiny. Cause: _cut_fractions gave a capacity branch the fraction load / sum(loads) and ignored the capacity's CP slack. _split_items then merged any branch below _SPLIT_MIN_FRACTION into its sibling. For a capacity that undid the split, so every rule failed, and a core candidate carried a branch of 1e-4 to 1e-9 of its stream. A capacity branch only needs g c >= load. Fix: _pinch_split passes each capacity's CP and its own fraction f of its parent to _cut_fractions. When some branch would fall below the minimum (f g < _SPLIT_MIN_FRACTION), the new _raised_fractions spreads the capacity's CP slack in closed form (water-filling): g_k = max(g_min, lam x_k), with sum g = 1. Sort the loads ascending. The raised branches are then the first t, and lam = (1 - t g_min) / sum_{k>=t} x_k. The first t with lam x_t >= g_min is the answer: raising branch t lowers lam exactly when lam x_t < g_min, so lam only falls and no raised branch comes back above g_min. The unraised branches keep one CP ratio, lam c (uniform, like the loads' shares). Every branch satisfies g c >= x iff lam c >= 1. g_min is rounded up until f g_min >= _SPLIT_MIN_FRACTION in floating point, which is the test _split_items applies. When the slack cannot cover the raise (lam c < 1, or n g_min > 1), or when every branch already reaches the minimum, the fractions stay the loads' shares bit for bit. Demand branches are unchanged (f = load / m). TINY_BRANCH and the review's case (H0 170->150 CP 7, C1 120->175 CP 1.000001, C2 140->180 CP e, dT 10; e = 1e-4, 1e-6 and 1e-9) used to end with every S rule failing and V or LV picked with a branch of 1e-4 to 1e-9. They now pick S:partner with fractions (0.999, 0.001). Tests: test_cut_fractions_spread_the_capacity_ slack (the closed form, f < 1, the rounding guard, and the no-slack and all-above-minimum cases unchanged), test_stage_s_keeps_capacity_branches_ above_the_minimum (TINY_BRANCH and TINY_HOT_BRANCH: every rule except 'demand' splits, an S candidate is picked, the plan is 'mer' and exact, and every fraction is >= 1e-3). test_pinch_split_screens_the_branched_root now expects (0.999, 0.001) on TINY_BRANCH, and shows the merge on TIGHT_TINY_BRANCH (CP 1.0001 on 1 and 1e-4: no slack, every rule fails). Module docstring ('Stage S') and _pinch_split/_split_items docs updated. minor (tests): no mutation tripped the G5 equilibrium sub-check on its own, and the mutations matched only the tag. New mutation mixer_outlet_off_equilibrium, run on rtB05 (real thermo; MUTATION_CASES): the mixer outlet keeps its flows, P and H, with 2 % of its main chemical vaporized. It gives exactly 'G5 Mix_0_hs: outlet at 366.96 K, not at equilibrium at its enthalpy (377.74 K)' on both the wired and the facility network. Every mutation now returns (tag, message fragment), and the test asserts that the defect's own message appears. I surveyed each mutation's problems on both sources to pick the fragments. The module docstring now says each defect is applied to both the hand-wired network and a fresh facility network (fifteen defects). minor (docs): README qualifies the MER claim ("not guaranteed with avoid_recycle; see the documentation for the limits with real thermodynamics"), in one sentence (DECISIONS Q7). plan_network's stream_splitting entry now says what it returns (plan.splits, plan.paths, the exchangers' fractions, info['sides'][side]['split']) instead of describing realization. _splitting.py lines 1894 and 2130 are re-wrapped to 79 columns. Two test comments said smith2005_ex18_2 splits a cold stream; it splits its hot stream (stream 2). Validation: - full suite: 953 passed in 373.29 s (947 + 6 new items) - py3.12 ast.parse on every changed .py file: ok - docs/_demo_src/examples/ch04_configuring.py re-run: generated outputs unchanged, including ch04_splitting.txt (line-ending churn reverted) - R1 oracle --check (planner and full, since hxn_synthesis.py and _heat_exchanger_network.py changed): R1 oracle (planner): 78 records, sha256 7307a56778e56268, 543.4 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 139.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- hensmith/_heat_exchanger_network.py | 9 ++- hensmith/_planner.py | 10 +-- hensmith/_splitting.py | 112 ++++++++++++++++++++++------ hensmith/hxn_synthesis.py | 14 +++- tests/test_hxn.py | 65 +++++++++++++--- tests/test_hxn_mer.py | 78 ++++++++++++------- tests/test_hxn_planner.py | 83 ++++++++++++++++++++- 8 files changed, 298 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 0c5637b..fcff57f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ 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. +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/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 054694e..0572f90 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -358,6 +358,9 @@ class HeatExchangerNetwork(bst.Facility): 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 @@ -859,10 +862,10 @@ def save_stream_life_cycles_as_csv(self): # 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.unit - if isinstance(entry, bst.Splitter): + 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.ID, H_in, 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 diff --git a/hensmith/_planner.py b/hensmith/_planner.py index 35628b2..a4374bb 100644 --- a/hensmith/_planner.py +++ b/hensmith/_planner.py @@ -2176,11 +2176,11 @@ def plan_network(knots, is_hot, T_min_app, *, avoid_recycle=False, Qmin=0., 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 + gets the same network as with the default. The splits are + returned in ``plan.splits`` (each stream's flow order in + ``plan.paths``), every exchanger carries the fractions and branches + of its streams, and ``plan.info['sides'][side]['split']`` records + each side's chosen candidate; `synthesize_network` realizes them. With `avoid_recycle`, a split that would repeat a stream pair is not used, and MER is then not guaranteed. Defaults to False. _split_exclude : dict[str, set[tuple]], optional diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 03de833..65c1c0a 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -95,15 +95,18 @@ 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. Its -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 +north-west corner with the CP slack spread uniformly or not at all. A +capacity branch below `_SPLIT_MIN_FRACTION` takes its parent's CP slack +from its siblings where that covers it (a branch needs only ``CP_cap >= +CP_dem``, `_cut_fractions`), and merges into its largest sibling where it +does 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 @@ -1891,7 +1894,7 @@ def inward(curves, front): def _cut_transport(dem, cap, rule): """ - Branches that satisfy the pinch rule at a cut (module docstring, 'Stage S'). + Branches satisfying the pinch rule at a cut (module docstring, 'Stage S'). Parameters ---------- @@ -2105,13 +2108,20 @@ def _mincell(dem, cap): return cells -def _cut_fractions(cells): +def _cut_fractions(cells, cap=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 capacity branch only needs ``g c >= load``, so with `cap` + (``{capacity: (c, f)}``: its CP and its own fraction of its parent), a + capacity with a branch below `_SPLIT_MIN_FRACTION` of its parent + (``f g``), which `_split_items` would merge away, spreads its CP slack + instead (`_raised_fractions`); if the slack cannot raise the branch, + its fractions stay the loads' shares. """ by_d, by_c = defaultdict(list), defaultdict(list) for d, c, x in cells: @@ -2119,7 +2129,56 @@ def _cut_fractions(cells): 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()} - return [(x / sd[d], x / sc[c]) for d, c, x in cells] + fractions = [(x / sd[d], x / sc[c]) for d, c, x in cells] + 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_min = _SPLIT_MIN_FRACTION / f + while f * g_min < _SPLIT_MIN_FRACTION: # as `_split_items` tests + g_min = math.nextafter(g_min, 2.) + g = _raised_fractions(by_c[c], cp, g_min) + if g is not None: + for n, gn in zip(at, g): + fractions[n] = (fractions[n][0], gn) + return fractions + + +def _raised_fractions(loads, c, g_min): + """ + Branch fractions of a capacity of CP `c` over its `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, so they keep one CP ratio ``lam c`` (uniform, as the loads' + shares do). 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. Every branch then 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``). + """ + 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 + g = [max(g_min, lam * x) for x in loads] + if all(_fits(gk * c, x) for gk, x in zip(g, loads)): + return g + return None def _split_items(items, M, split): @@ -2127,9 +2186,10 @@ 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 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 (a demand branch, or a capacity branch its 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): @@ -2185,14 +2245,15 @@ 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. 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. + demands and capacities there into whole-side branches, a capacity's CP + slack raising its 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 ---------- @@ -2237,8 +2298,11 @@ def _pinch_split(side, a0, proof, rule): return None dem, cap = ('must', 'flex') if v['rule'] == 'outward' else ( 'flex', 'must') + # each capacity's CP and its 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]} split = defaultdict(list) - for (d, c, _), (f, g) in zip(cells, _cut_fractions(cells)): + for (d, c, _), (f, g) in zip(cells, _cut_fractions(cells, room)): split[dem, d].append(f) split[cap, c].append(g) new = _split_items(items, bside.M, split) diff --git a/hensmith/hxn_synthesis.py b/hensmith/hxn_synthesis.py index 9a6441b..fb3907e 100644 --- a/hensmith/hxn_synthesis.py +++ b/hensmith/hxn_synthesis.py @@ -219,7 +219,8 @@ class StreamLifeCycle: ``(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. + first stage's. None until `get_life_cycle` runs, and for a life + cycle whose `life_cycle` is assigned directly. Notes ----- @@ -229,6 +230,10 @@ 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 @@ -242,8 +247,11 @@ def __init__(self, index, cold): 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.""" - unit, index = self.entry + `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): diff --git a/tests/test_hxn.py b/tests/test_hxn.py index bf58115..85df50c 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -93,13 +93,15 @@ def test_cache_network_from_before_stream_splitting_synthesizes_again(): HXN.cache_network = True sys.simulate() network = HXN.HXN_sys - del HXN._synthesis_options, HXN._stage_scales + # 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) @@ -108,6 +110,49 @@ def test_cache_network_from_before_stream_splitting_synthesizes_again(): 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 @@ -1646,8 +1691,8 @@ def test_realize_split_branch_ports(name): 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 a cold stream at its inlet; rtB05 splits its - # cold stream after its trunk exchanger below the pinch + # 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) @@ -1867,13 +1912,13 @@ def test_synthesize_network_splitting_requires_info(): @pytest.mark.parametrize('name', [*SPLIT_CASES, 'crude_fractionation_ph11c2', 'rtB03_above_hot_vapors']) def test_synthesize_network_with_splitting(name): - # smith2005_ex18_2 splits a cold stream 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 + # 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, diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 322c199..8a86919 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -171,13 +171,16 @@ 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``), each of fourteen 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 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. + (``_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 @@ -1510,10 +1513,15 @@ def load(hus): return sum([abs(hu.duty * hu.agent.heat_transfer_efficiency) return result # --------------------------------------------------------------------------- -# Defects of a split network, one per check (each returns the check's tag) +# 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 @@ -1531,17 +1539,17 @@ def _rewire(unit, port, stream): def _misnamed_mixer(net): split, *_ = _split_stage(net) split.mixer.ID = 'Mixer_' + split.mixer.ID - return 'G0' + return 'G0', 'not a Mixer ID' def _unlisted_splitter(net): net['HXN'].new_splitters.remove(_split_stage(net)[0].splitters[0]) - return 'G0' + 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' + 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 @@ -1549,43 +1557,56 @@ def _mixer_inlet_from_a_trunk(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' + 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' + 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' + 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' + 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' + return 'G4', 'its outlets carry' def _mixer_outlet_shifted(net): mixer = _split_stage(net)[0].mixer mixer.outs[0].T += 0.01 - return 'G5' + 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' + return 'G6', 'passes its outlet' def _branch_H_lim_shifted(net): # a branch exchanger's enthalpy limits moved on by 5 % of its duty, @@ -1602,26 +1623,27 @@ def _branch_H_lim_shifted(net): 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' + 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' + 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' + return 'G9', 'network outlet != original' def _heat_misreported(net): net['heat'] += 1e-6 * net['total'] - return 'G10' + 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, _branch_passes_the_outlet, + _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, )} @@ -1813,8 +1835,9 @@ def test_split_checker_agrees_on_linear_networks(case): 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 (and possibly others) - case = _case(MUTATION_CASE) + # 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: @@ -1826,9 +1849,10 @@ def test_split_checker_detects_mutations(mutation, source): net = _wired_split_network(case) else: net = _network(case, True, cached=False) - tag = SPLIT_MUTATIONS[mutation](net) + tag, message = SPLIT_MUTATIONS[mutation](net) problems = _network_problems(net) - assert any(p.startswith(tag + ' ') for p in problems), (tag, problems) + 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): diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 4122de2..6c3a4e9 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -2593,6 +2593,12 @@ def test_fuzz_splitting_always_reaches_mer(monkeypatch): 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)]) def corpus_problem(name): @@ -2802,11 +2808,82 @@ def test_mincell_prefers_fewer_cells(): 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) + + +@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 branch below _SPLIT_MIN_FRACTION merges into its sibling, which - # undoes the split: the rule fails + # 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) - assert SP._pinch_split(side, a0, proof, 'partner') is None + 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') From 6d4776f40d4b56b360fb6f45fe07ef2079e0e912 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Thu, 24 Sep 2026 20:28:23 -0700 Subject: [PATCH 22/24] raise tiny demand branches in stage s, check the planned minimum fraction T12 final validation. Review E's facility fuzz (scratch reviewE_fuzz.py, pinch/odd/dbl/rand x 13, seed 0) was run with stream_splitting=True on 4acb8db. All 52 problems reached 'mer', but 5 of the 13 'odd' problems (odd CPs including 1e-4 and 1e-3) failed the strict check G4 'fraction' (test_hxn_mer._split_network_problems). There were two causes. 1. Stage S dropped demand branches it could have raised (odd 1). H1 (CP 1) fits no cold stream at the pinch: C3 (CP 0.999999) is short by 1e-6, so H1 needs a branch of 1e-6 to 1e-3 of its CP for C3 (CP 1e-3) or C1 (CP 1e-4). Every cut transport gave that branch less than _SPLIT_MIN_FRACTION: 'demand' 0.000999, 'nw-exact-desc' 1e-6, 'mincell' 1e-4. _cut_fractions gave a demand its loads' shares, so _split_items merged the sliver into its sibling. That undid the split, and every S rule ended 'no split'. The side fell back to a core candidate (LV) that split H1 0.9999 / 1e-4. C3's CP has room for a load of 1e-3, so the realizable split 0.999 / 0.001 existed. Review E raised capacity branches from their parent's CP slack, but it left demand branches unchanged. Fix: _cut_fractions takes each demand's own fraction (dem, passed by _pinch_split). It raises a demand branch below the minimum by the same water-filling as a capacity: g_k = max(g_min, lam x_k), sum g = 1, with lam falling as branches are raised (proof in _water_fill, now shared with _raised_fractions). g_min is rounded up as before (_min_share). The raise is kept only if every capacity that gets more load still has sum load <= c (_fits). The demands go in order, and each uses only the room the earlier ones left. Capacity shares are then taken from the new loads, and the capacity raise runs on those. An item the slack cannot raise keeps the loads' shares bit for bit, and the capacity path does the same arithmetic as before. The exact screen of _pinch_split still decides every split. On odd 1, 'demand' and 'nw-exact-desc' now split H1 0.999 / 0.001 and the side picks an S candidate. 2. G4 compared a measured flow ratio with the minimum at zero tolerance (odd 5, 7, 8, 10). Stage S planned the split (0.999, 0.001), exactly at _SPLIT_MIN_FRACTION (review E's rounding). The splitter's ratio is 0.999/fsum(fractions), and thermosteam's split_to makes the second outlet as feed - first. The branch's ratio (1 - r) and its flows therefore come out 1.3e-16 below 0.001 (0.00099999999999987), cancellation relative to the feed. No splitter chain can promise f >= 1e-3 exactly for a planned fraction at 1e-3: even a branch taken by multiplication is off by an ulp. The checker already accepts the realized ratios within FLOW_RTOL of the plan. Fix (test only): G4 applies the 1e-3 minimum, exactly, to the split's planned fraction. It also requires the measured fraction to equal the planned one within FLOW_RTOL, which it did not check directly before. The threshold is unchanged. The module docstring's G4 says so, and the fifteen mutation tests still find their defects. Tests (written first; each failed for the cause above): test_cut_fractions_raise_a_demand_branch_into_capacity_room (bit for bit without room or dem; g_min for a fraction .5; two demands sharing one capacity's room), test_stage_s_raises_demand_branches_to_the_minimum (TINY_DEMAND_BRANCH = odd 1: the rules split H1 0.999 / 0.001, and the plan is 'mer' with an S candidate and every fraction >= the minimum), test_split_branches_keep_the_minimum_fraction[tiny_demand_branch, complement_at_the_minimum] (facility: strict checks and the synthesis report clean, every planned fraction >= the minimum, feasible). Robustness beyond the corpus (final tree): - facility fuzz, seed 0, stream_splitting=True: pinch 13, odd 13, dbl 13, rand 13: 52 'mer', 52 split, 0 with problems (was 5, all 'odd'). - extra seed 1 (odd 20, dbl 20): 40 'mer'. Two 'odd' problems carry a branch below 1e-3 (G4 fraction), and physics forces it. In odd 7, H1 (CP 1.000001) must shed >= 1e-6 of CP to a CP-0.001 capacity at the pinch, so its branch is <= 0.001/1.000001 < 1e-3. In odd 17 below, the same holds for C1 (CP 1.000001) against H2 (CP 0.001). The design accepts such branches from elementary vertical blocks and penalises them in the selection key (design section 0, 'min branch fraction'); every other check is clean. Not a defect. - avoid_recycle probe (scratch t12_ar.py, 30 pinch problems): no crash, no problem from the strict checks (G0-G10 incl. G7 approach), no repeated pair, nothing repaired/dropped/deviating; statuses 'mer' 11, 'best_effort' 19 (23 split); MER is not promised with avoid_recycle. Validation: - full suite (CI invocation, --durations=30): 957 passed in 384.68 s (953 + 4 new items; baseline 467 in 248.35 s: +136.3 s, within the +200 s target, so no 6.6 lever). The 30 slowest entries take 189.2 s: test_hxn_mer 16 (105.5 s, top: network_balanced_and_feasible rtB10 14.3 s, rtB08 11.5 s, rtB01 9.6 s), test_hxn 8 (49.4 s, top: shrink_with_fractions_is_monotone[glide] 10.7 s, repair_on_split_plan 8.1 s), test_hxn_planner 5 (30.5 s, top: core_on_near_flat_pieces_reaches_mer 10.6 s), test_hxn_regression 1 (3.8 s). The first T12 run on 4acb8db: 953 passed in 385.91 s. - py3.12 ast.parse on every .py file changed since f914c4c: 9 files ok. - R1 oracle --check, planner mode under PYTHONHASHSEED=0 and =1, full mode: R1 oracle (planner): 78 records, sha256 7307a56778e56268, 534.3 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=0 R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (planner): 78 records, sha256 7307a56778e56268, 534.9 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=1 R1 oracle (planner): IDENTICAL to r1_planner.json (stored sha256 7307a56778e56268) R1 oracle (full): 93 records, sha256 6c98f043a59a2e5b, 134.1 s, hensmith hensmith\__init__.py, PYTHONHASHSEED=unset R1 oracle (full): IDENTICAL to r1_full.json (stored sha256 6c98f043a59a2e5b) Co-Authored-By: Claude Opus 5.5 --- hensmith/_splitting.py | 150 ++++++++++++++++++++++++++------------ tests/test_hxn.py | 34 +++++++++ tests/test_hxn_mer.py | 17 +++-- tests/test_hxn_planner.py | 68 +++++++++++++++++ 4 files changed, 217 insertions(+), 52 deletions(-) diff --git a/hensmith/_splitting.py b/hensmith/_splitting.py index 65c1c0a..0eaa291 100644 --- a/hensmith/_splitting.py +++ b/hensmith/_splitting.py @@ -96,18 +96,19 @@ 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 -capacity branch below `_SPLIT_MIN_FRACTION` takes its parent's CP slack -from its siblings where that covers it (a branch needs only ``CP_cap >= -CP_dem``, `_cut_fractions`), and merges into its largest sibling where it -does 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 +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 @@ -2108,7 +2109,7 @@ def _mincell(dem, cap): return cells -def _cut_fractions(cells, cap=None): +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 @@ -2116,12 +2117,20 @@ def _cut_fractions(cells, cap=None): split whole, with no bypass, so its branch has CP ``g c >= load``. An item with one cell has fraction 1 (no split). - A capacity branch only needs ``g c >= load``, so with `cap` - (``{capacity: (c, f)}``: its CP and its own fraction of its parent), a - capacity with a branch below `_SPLIT_MIN_FRACTION` of its parent - (``f g``), which `_split_items` would merge away, spreads its CP slack - instead (`_raised_fractions`); if the slack cannot raise the branch, - its fractions stay the loads' shares. + 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: @@ -2130,43 +2139,70 @@ def _cut_fractions(cells, cap=None): 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_min = _SPLIT_MIN_FRACTION / f - while f * g_min < _SPLIT_MIN_FRACTION: # as `_split_items` tests - g_min = math.nextafter(g_min, 2.) - g = _raised_fractions(by_c[c], cp, g_min) + 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 _raised_fractions(loads, c, g_min): +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 of a capacity of CP `c` over its `loads`, each at - least `g_min`: ``g_k = max(g_min, lam x_k)``, ``sum g_k = 1``. + 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, so they keep one CP ratio ``lam c`` (uniform, as the loads' - shares do). 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. Every branch then needs ``g_k - c >= x_k``: a raised one has it when ``lam c >= 1``, and the others - have it iff ``lam c >= 1``. + 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 they do not exist - (``n g_min > 1``, or the CP slack ``c - sum x`` cannot cover the - raise: ``lam c < 1``). + 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): @@ -2175,8 +2211,26 @@ def _raised_fractions(loads, c, g_min): break else: return None - g = [max(g_min, lam * x) for x in loads] - if all(_fits(gk * c, x) for gk, x in zip(g, loads)): + 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 @@ -2186,10 +2240,10 @@ 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 (a demand branch, or a capacity branch its 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 (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): @@ -2245,8 +2299,8 @@ 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, a capacity's CP - slack raising its branches to `_SPLIT_MIN_FRACTION` where it can + 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 @@ -2298,11 +2352,13 @@ def _pinch_split(side, a0, proof, rule): return None dem, cap = ('must', 'flex') if v['rule'] == 'outward' else ( 'flex', 'must') - # each capacity's CP and its item's fraction of its parent + # 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)): + 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) diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 85df50c..6b71930 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -2750,6 +2750,40 @@ def test_synthetic_network_splits_to_mer(): 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 diff --git a/tests/test_hxn_mer.py b/tests/test_hxn_mer.py index 8a86919..ef008bb 100644 --- a/tests/test_hxn_mer.py +++ b/tests/test_hxn_mer.py @@ -141,9 +141,9 @@ the flow. G4 Splitters balance mass; each branch is one fraction f of the split's - feed (phase by phase), at least 1e-3, the product of the splitters' - ratios down to it equals the split's fraction, at the feed's T and P, - with f times its enthalpy. + 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 @@ -1310,8 +1310,15 @@ def specific(s): return s.H / s.F_mol if s.F_mol else math.nan 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})' - if not MIN_FRACTION <= f < 1.: - problem('G4', f'{label}: fraction {f:.15g}') + # 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 ( diff --git a/tests/test_hxn_planner.py b/tests/test_hxn_planner.py index 6c3a4e9..e1d4dc9 100644 --- a/tests/test_hxn_planner.py +++ b/tests/test_hxn_planner.py @@ -2599,6 +2599,12 @@ def test_fuzz_splitting_always_reaches_mer(monkeypatch): 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): @@ -2839,6 +2845,68 @@ def test_cut_fractions_spread_the_capacity_slack(): 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 From 7891192a65dca43e434cd244f6522e02de4eedb0 Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Wed, 7 Oct 2026 19:54:10 -0700 Subject: [PATCH 23/24] fix duplicate autodoc entries for StreamLifeCycle.entry and splits Read the Docs build 35005409 of PR #2 failed under -W on two warnings, "duplicate object description of hensmith.StreamLifeCycle.splits" (and .entry). The cause is autodoc_default_options in docs/source/conf.py, which listed 'undoc-members': False. Sphinx copies every key present in autodoc_default_options into the directive options and converts a flag option with bool_option, which returns True whatever the value, so the key turned undocumented members ON. That had no effect until 4acb8db (review E) gave StreamLifeCycle the class defaults splits = () and entry = None: the class's autoclass (the only one with members; the others use :no-members:) then documented them as undocumented members, next to the entries napoleon writes for them from the class's Attributes section, and each object was described twice. Delete the key, so that undocumented members are really left out, with a comment saying why. StreamLifeCycle has no other public member without a docstring, so nothing else leaves the API pages. Validation, built as on Read the Docs (sphinx 9.1.0, pydata-sphinx-theme 0.21.0, sphinx-design 0.7.0, sphinx-copybutton 0.5.2; python -m sphinx -T -E -W --keep-going -b html, intersphinx online): - sphinx's _process_documenter_options gives undoc_members True for {'undoc-members': False} and None without the key; - 6d4776f (git archive): "build finished with problems, 2 warnings", the two duplicates; with this change: "build succeeded", 0 warnings; - same 17 HTML pages and the same 65 objects.inv names before and after. Object signatures differ only on API/hxn_synthesis.html, where the undocumented "splits = ()" and "entry = None" are gone (42 -> 40) and StreamLifeCycle.entry and .splits are each described once, from the Attributes section; their inventory entries now link to those descriptions instead of the duplicates' anchors (#id5, #id0), and genindex lists each once. No other text on any page changed; - full test suite: 957 passed in 325.91s (0:05:25). Co-Authored-By: Claude Opus 5.5 --- docs/source/conf.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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' From 5ef9a35b12167ef4421abd663df1f41571da74af Mon Sep 17 00:00:00 2001 From: sarangbhagwat Date: Wed, 7 Oct 2026 20:45:15 -0700 Subject: [PATCH 24/24] keep the phase split in the cached network's pre-copy without a flash CI (ubuntu, Python 3.12 and 3.13) failed test_move_flows_moves_flows_only: after `_move_flows` with every inlet of a rigorous mixer scaled by 1.1, the two-phase outlet came out all liquid ([[0, 0], [55, 27.5]]) instead of its vapor/liquid split scaled by 1.1. Locally (Windows) it passed. Cause: for a multiphase outlet, `_move_flows` scaled the total flow (the F_mol setter multiplies every phase by F_new/F_old) and kept that split only if `s_out.mol.sparse_equal(mol)`, i.e. only if the scaled phases summed to the inlet flows bit for bit (SparseVector.sparse_equal compares the float dicts exactly); otherwise it replaced the flows and flashed at the outlet's own T and P. Whether the sums match depends on the last bits of the PH flash that made the split. CI's split, rebuilt exactly from the DESIRED values it printed (each has a single preimage under x1.1), is a few ulps from the Windows one with the same totals (50 and 25): 1.1 x its phases sums to 55.0 water and 27.500000000000004 ethanol against inlets of 55.00000000000001 and 27.5, so the check failed and the code flashed. Replaying the old code on CI's split gives CI's ACTUAL bit for bit, and on the local split CI's DESIRED. The flash is wrong there: thermosteam's TP flash at the PH flash's own 355.11 K and 101325 Pa returns V = 0 (also for a fresh stream), although the bubble and dew points are 354.46 and 363.30 K and the PH flash gave V = 0.195 (a thermosteam issue, left to thermosteam). The old pre-copy was fragile on Windows too: 65 of 301 common factors in [0.5, 2] and 17 of 41 one-ulp moves of the split left the outlet all liquid (126 of 301 factors on CI's split). The exchanger branch (master's original pre-copy) had the same check and flash. Fix: `_move_flows` moves flows only; it compares no float for equality and flashes nothing (as its docstring already promised). A multiphase outlet keeps its phase split by a rule that needs no detection (`_move_phase_flows`): each chemical that it carries keeps the fraction of its flow in each phase (its phase flows are scaled by its new total over its old one), so a common factor on every inlet scales every phase, to rounding; a chemical that it does not carry enters in the phases in which it arrives (a phase the outlet lacks is added with thermosteam's own `phases` setter; `imol.mix_from` reads the phase list before expanding it and fails on a genuinely new phase). The per-chemical flows match the inlets to a few ulps, the phase flows stay non-negative and T and P are untouched, which is all the cached path needs: the network's convergence re-runs every rigorous unit from there. Single-phase outlets are unchanged. The rule serves mixers and every other unit (exchangers) alike. Tests: test_move_flows_moves_flows_only now sets CI's exact split on the two-phase outlet (so CI's condition reproduces on any platform), forbids Mixer._run and VLE.__call__, scales by 8 common factors, and checks a vapor-only change and methanol arriving in the liquid against the per-chemical rule (replacing the expectation of a TP flash). The new test_move_flows_keeps_an_exchangers_phase_split covers a rigorous HXutility(V=0.4) outlet: 3 factors, then more water with methanol arriving as a new solid phase. Both fail on the old code ("VLE was run") locally and on CI's pinned stack. Validation: - move_flows, cached-network, served-streams and HXN_sys tests: 14 passed locally (py3.14) and on CI's pinned stack (python 3.13.9, numpy 2.5.3, scipy 1.18.1, fluids 1.3.1, chemicals 1.5.2, thermo 0.6.1, numba 0.68.0, thermosteam HEAD 909df06c); - full suite, CI invocation: 958 passed locally (315.7 s) and 958 passed on CI's pinned stack with python 3.13.9 (306.0 s); - sweep with the fix: the split is kept for 301/301 factors (local and CI splits) and 41/41 one-ulp splits, within 4.5e-16; - R1 oracle, full mode: IDENTICAL (sha256 6c98f043a59a2e5b), so default-off results are bit for bit unchanged; - ast.parse(feature_version=(3, 12)) on both changed files. Linux and Python 3.12 are not available locally; the push re-runs CI there. Co-Authored-By: Claude Opus 5.5 --- hensmith/_heat_exchanger_network.py | 64 ++++++++++++----- tests/test_hxn.py | 107 +++++++++++++++++++++++----- 2 files changed, 138 insertions(+), 33 deletions(-) diff --git a/hensmith/_heat_exchanger_network.py b/hensmith/_heat_exchanger_network.py index 0572f90..6a257c0 100644 --- a/hensmith/_heat_exchanger_network.py +++ b/hensmith/_heat_exchanger_network.py @@ -67,13 +67,52 @@ 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). - No enthalpy flash runs here: only the convergence, which the facility - guards, runs the rigorous units. + 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). @@ -81,31 +120,24 @@ def _move_flows(unit): rigorous mixer would flash). - Any other unit's outlet ``k`` takes the flows of its inlet ``k``. - A multiphase outlet keeps its temperature and pressure: its flows are - scaled to the new total and, if that does not reproduce the inlet - flows, replaced by them and split between the phases at its - temperature and pressure. + 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 - mol = sum([s.mol for s in ins]) if isinstance(s_out, bst.MultiStream): - s_out.F_mol = sum([s.F_mol for s in ins]) - if not s_out.mol.sparse_equal(mol): - s_out.imol.mix_from([s.imol for s in ins]) - s_out.vle(T=s_out.T, P=s_out.P) + _move_phase_flows(s_out, ins) else: - s_out.mol[:] = mol + 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): - 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) + _move_phase_flows(s_out, [s_in]) else: s_out.mol[:] = s_in.mol diff --git a/tests/test_hxn.py b/tests/test_hxn.py index 6b71930..4344a7f 100644 --- a/tests/test_hxn.py +++ b/tests/test_hxn.py @@ -3151,9 +3151,12 @@ 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), keeping its temperature and pressure, multiphase or not + # 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'], cache=True) + 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') @@ -3170,14 +3173,31 @@ def test_move_flows_moves_flows_only(monkeypatch): two_phase = mixers[1].outs[0] assert isinstance(two_phase, bst.MultiStream) assert 0. < two_phase.vapor_fraction < 1. - def failing(self): raise RuntimeError(f'{self.ID} was run') - monkeypatch.setattr(bst.Mixer, '_run', failing) def split(s): return np.array([s.imol['g'], s.imol['l']]) - # all flows 10 % larger, then only the vapor's, each time with a new - # feed state, which the splitter's outlets take - for streams in ((feed, liquid, vapor), (vapor,)): + # 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) - for s in streams: s.F_mol *= 1.1 + 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] @@ -3190,17 +3210,70 @@ def split(s): return np.array([s.imol['g'], s.imol['l']]) 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 10 % larger: so is each phase of the two-phase - # outlet (the PH flash's split, kept) - assert_allclose(split(two_phase), 1.1 * phase_flows, + # 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: - # the inlet flows, split between the phases at the outlet's T - # and P (thermosteam's TP flash gives all liquid here, although - # T lies between the bubble and dew points) - flashed = two_phase.copy() - flashed.vle(T=two_phase.T, P=two_phase.P) - assert_allclose(split(two_phase), split(flashed), rtol=1e-9) + # 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