Repository navigation
add opt-in stream splitting that reaches MER on split-needed problems - #2
Merged
Merged
Conversation
…ing, 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…ansport 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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:<rule>' 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…stics
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ze_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_<j>_<hs|cs>[_k] then _b<c>, 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_<j>_<hs|cs>[_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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
…umns 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:<i>' / 'H_out:<i>' (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 <noreply@anthropic.com>
…xn_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_<j>_<hs|cs>[_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 <noreply@anthropic.com>
…cles
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
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-<candidate>, 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ocument 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…tion 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Optional stream splitting for heat exchanger network synthesis, behind a new keyword that defaults to off:
The same keyword is on
synthesize_networkandplan_network. With it on, the synthesizer reaches theminimum-energy-requirement (MER) targets on problems that provably need split streams. Before this change
it reported
'best_effort'on all of them.Why
PR #1 made the pinch-outward planner reach MER on every problem an unsplit network can serve. The other half of the
MER corpus (
SPLITintests/hxn_mer_cases.py: 25 constant-CP literature problems with up to 22 streams, and 13real-thermodynamics water/ethanol/methanol problems with phase change and binary glides) needs splits by the pinch
design rules. A network without splits cannot reach MER on any of them; the hot-utility excess ran up to 35 % (
cgm_unbalanced10).How
proof, or an unsplit search that leaves a utility penalty. Every other side gets the same network as before, so a
problem that needs no split gets the identical network with the option on.
split ratios. Each branched side is then planned by the existing DFS, unchanged. This is the pick on most of the
corpus' split sides.
at every knot. Optional L&H pinch blocks and DFS tails keep unit counts down. From any state that satisfies the
planner's residual condition (R), a vertical block always exists and preserves (R). So whenever MER is reachable,
the core reaches it exactly on the planner's knots, including flats (isothermal phase change), double pinches and
zero CP slack. The derivation is in the
hensmith/_splitting.pymodule docstring.that would leak heat are rejected, not booked. Real-thermo split sides go through the existing exact-state refine
loop, with candidate stickiness and a split-only retry.
HXprocessunits with scaled inlets and enthalpy limits. Splits usebst.Splitterchains andbst.Mixer(rigorous=True). Both are adiabatic and cost nothing, and each mixer is createdat its planned state.
StreamLifeCyclegains additive branch bookkeeping (splits,entry,H_in,connections()). The facility wires units from those connections on both the fresh and the cached paths, andcache_networkis keyed on the option.Compatibility
commit: a float.hex dump of all 78 corpus plans, 93 facility runs, and the cache,
avoid_recycle,Qmin,replace-utilities and ideal-thermo variants, under two hash seeds.
synthesize_networkstill returns the same 13-tuple. Split structure goes toinfo['splits']/synthesis_info['splits'].synthesize_network(stream_splitting=True, info=None)raisesValueError, becausethe splitters and mixers are returned only through
info.problem_tableand default heuristics. biosteam needs no change.the option.
Tests
957 passed, up from 467. Run locally with the CI invocation on Python 3.14: 362 s, against 248 s before.
stream_splitting=True, through the public facility (76 items):test_split_network_reaches_mer: status'mer', and heating and cooling equal to hensmith's targets and tothe independent references within 1e-12 × total duty (constant CP) or 1e-10 (real thermo).
test_split_network_balanced_and_feasible: a strict split-aware checker (G0-G10). It covers splitter and mixermass and energy balances, isothermal re-joins, fractions, life-cycle structure, per-exchanger heat balance, and
the exact internal approach in every exchanger (≥ T_min_app − 1e-6 K). 15 mutation tests show that each check
catches its defect.
'mer'with splitting.primitives and fuzz tests.
'mer'with every strict check clean.'mer'. In 2 of them the CP rule forces a branch below the harness's 1e-3 minimum fraction.avoid_recycle: no crash and no infeasible exchanger. MER is not guaranteed under that option.Known limits
luo_20sp_ph10c10(below the pinch) needs the vertical backstop: 52 exchangers and 34 splits on that side.avoid_recycle=True, a split that would repeat a pair is not used, so MER is not guaranteed.Pre-existing issues found (not changed here; they affect the default path)
_planner._Side._Rloses precision on near-flat curve pieces (glides under about 1 µK). The split path uses anexact replacement.
cache_network=True, a cached network is kept even whensys.converge()fails. Reproduced onbagajewicz_ou_crude_unit_15_streamwith feeds scaled by 1.1 and then 0.9.🤖 Generated with Claude Code