Skip to content

fix HXN pinch analysis: energy-consistent problem table - #254

Open
sarangbhagwat wants to merge 5 commits into
fix-hxn-cache-networkfrom
fix-hxn-pinch-analysis
Open

fix HXN pinch analysis: energy-consistent problem table#254
sarangbhagwat wants to merge 5 commits into
fix-hxn-cache-networkfrom
fix-hxn-pinch-analysis

Conversation

@sarangbhagwat

Copy link
Copy Markdown
Member

Stacked on #253 (fix-hxn-cache-network) — it depends on tests/test_hxn.py and the cache fix there. Merge #253 first; this PR's base should then be retargeted to master (the diff shown here is only this branch's five commits).

Problem

temperature_interval_pinch_analysis (biosteam/facilities/hxn/hxn_synthesis.py) built the heat cascade from interval enthalpies that were

  1. evaluated by flashing hot streams at the shifted temperature T − T_min_app, so a stream entering within T_min_app of its dew point was already liquid and lost its latent heat;
  2. never clipped to the stream's actual [H_in, H_out], so non-equilibrium column outlets inflated interval duties;
  3. assigned with the wrong sign for streams whose outlet temperature moves against their duty.

The resulting utility targets were physically impossible — the hot target exceeded the un-integrated heating load on the class doctest system, and hot − cold ≠ Σ unit_duty on sugarcane — so the pinch temperatures handed to synthesize_network were wrong (304 K vs 359 K on sugarcane).

Fix

The cascade is now built by a pure function problem_table(streams_inlet, streams_quenched, is_hot, T_min_app) -> ProblemTable:

  • Each monotone stream is walked once down the shifted-temperature grid with a single warm-started copy, flashed at the real temperature T + shift. Its enthalpies are exact at its own end points (assigned by index) and clipped to [H_in, H_out] in between, so its interval contributions telescope exactly to sign · |H_out − H_in|.
  • Isothermal streams, and streams whose outlet temperature moves against their duty (e.g. a reboiler outlet at VLE), are point loads at their outlet temperature.
  • The cascade checks feasibility of the heat both arriving at each boundary (before its point loads) and leaving it — a hot point load at T cannot serve a sink above T, and checking only the leaving flow let such loads mask a deficit in the interval directly above them.
  • Hence hot_util_load − cold_util_load == Σ unit_duty and both targets are non-negative by construction (rounding in the threshold branch is absorbed into the hot target to keep the identity exact).
  • A failed VLE warns with the stream's ID, falls back to linear interpolation in T, and restarts the warm-started copy cleanly.

temperature_interval_pinch_analysis is now a thin wrapper; its signature and 12-tuple return are unchanged, and synthesize_network / HeatExchangerNetwork are untouched. problem_table/ProblemTable are exported with an Examples doctest.

Synthesis-side note: non-monotone streams get pinch_T = T_in in pinch_T_arr, so load_duties places their whole duty on the hot side (the old code produced a meaningless negative dH2 for them when T_in sat on the wrong side of the pinch). Reconciling that with the point-load treatment in the table is left to the planned synthesis-heuristic work.

Validation

tests/test_hxn.py (new tests):

  • per-stream and net energy identities on the doctest system and a 4-stream synthetic case at ΔT_min = 5/10/20 K (also: a target can never exceed the un-integrated load; the public wrapper reports the same targets);

  • two-stream closed forms (threshold problem; pinch at the cold inlet);

  • isothermal / non-monotone streams handled as point loads;

  • regression for the point-load masking case (condensing stream at 400 K vs. a cold stream 392 → 398 K: correct hot target = the 395–398 K segment, pinch 395 K);

  • the synthetic 4-stream network reaches the corrected MER.

  • HeatExchangerNetwork class doctest unchanged (same pinch on that system; ratio still 0.82).

  • Full suite (pytest . --disable-numba=1 -m "not slow"): identical failure set to master — stack drift, missing optional deps, tutorial notebooks — nothing new. tests/test_biorefineries.py::test_sugarcane (full HXN synthesis) passes; test_cornstover fails with the same pre-existing numbers as on master.

🤖 Generated with Claude Code

sarangbhagwat and others added 5 commits August 22, 2026 16:10
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ut], point loads for isothermal streams

temperature_interval_pinch_analysis built the heat cascade from interval
enthalpies that were (1) evaluated by flashing hot streams at the *shifted*
temperature T - T_min_app, so a stream entering within T_min_app of its dew
point was already liquid and lost its latent heat, (2) never clipped to the
stream's actual [H_in, H_out], so non-equilibrium column outlets inflated
interval duties, and (3) assigned with the wrong sign to streams whose
outlet temperature moves against their duty. Targets were impossible
(hot target above the un-integrated heating load on the class doctest;
hot - cold != net demand on sugarcane).

The cascade is now built by problem_table(): each monotone stream is walked
once down the shifted grid with a warm-started copy flashed at the real
temperature, exact at its own end points and clipped in between, so its
contributions telescope exactly to its duty; isothermal and non-monotone
streams are point loads at their outlet temperature. Hence
hot_util_load - cold_util_load == sum(unit_duty) and both targets are
non-negative by construction. Signature and return value of
temperature_interval_pinch_analysis are unchanged.

Validated by tests/test_hxn.py: per-stream and net energy identities on the
doctest system and a 4-stream case at dT = 5/10/20 K, two-stream closed
forms, point-load handling, and the synthetic network reaching MER.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… warning, clean restart after failed flash

Three review fixes to the problem_table helpers added on this branch:

1. _stream_H_at_boundaries compared grid temperatures to T_hi/T_lo with
   float equality to detect a stream's own end points; this only worked
   because Ts was built from the same floats. Assign Hs[0]/Hs[-1] by
   position instead (the caller always passes T_hi first, T_lo last, and
   monotone streams have T_hi > T_lo strictly so the slice has >= 2
   entries, asserted), and loop only over the interior boundaries.

2. The VLE-failure warning printed {stream!r} for an anonymous inlet
   copy, giving no way to trace which stream failed. Pass the inlet
   stream's own ID (set by the wrapper to s_<i>__Util_<i>) into the
   helper and name it in the warning.

3. On a failed flash, the warm-started copy was left in whatever state
   the failed VLE call put it in and reused for the next boundary. Re-copy
   stream_in in the except branch so the next boundary restarts clean.

Also rewrote the comment above the pinch_T_arr loop in
temperature_interval_pinch_analysis, which claimed non-monotone streams
get pinch = T_in "as before this fix" -- not true in general (e.g.
T_out < T_in <= pinch_cold_stream_T for a cold stream gave T_out/pinch
under the old rule). The comment now states the actual rule (streams
already on one side of the pinch, including non-monotone ones, are not
split; load_duties assigns their whole duty to one side) without
claiming equivalence with prior behavior.

Covering tests unchanged (tests/test_hxn.py + hxn doctests):
12 passed in 13.36s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re its point loads

The cascade residual[k] = sum(point_H[:, :k+1]) + sum(interval_H[:, :k])
is the heat *leaving* boundary Ts[k], after that boundary's point loads.
Testing only this for non-negativity lets a hot point load at Ts[k] (an
isothermal condenser, a phase-changing HXutility) mask a deficit in the
cold interval (Ts[k-1], Ts[k]) directly above it - but a source at Ts[k]
cannot serve a sink above Ts[k]. Example: a condensing stream at 400 K
(shifted 395 K) against a cold stream heated 392 -> 398 K returned a hot
utility target of zero; the correct target is the cold 395-398 K segment.

The target is now the minimum over both the arriving flow
(residual - point_total) and the leaving flow at every boundary, which is
the standard problem-table treatment of point loads. Also: cold_util_load
is clamped at zero in the threshold branch (residual[-1] could be negative
by a rounding-level amount, and the tests assert non-negativity);
problem_table/ProblemTable are exported in __all__ with an Examples
doctest (two-stream threshold case).

Note on synthesis behaviour (unchanged by this commit, introduced with the
wrapper rewrite): non-monotone streams (outlet temperature moving against
the duty) get pinch_T = T_in in pinch_T_arr, so load_duties places their
whole duty on the hot side. The old code gave a meaningless negative dH2
for such streams when T_in was on the wrong side of the pinch. The table
counts them as point loads at T_out; reconciling that with the synthesis
heuristic is a separate item.

Validation: regression test test_problem_table_point_load_cannot_heat_above_itself;
tests/test_hxn.py + hxn doctests 14 passed (class doctest unchanged);
canonical suite 71 failed / 476 passed / 62 skipped with the same
pre-existing failure set as the baseline; tests/test_biorefineries.py
1 failed (test_cornstover, baseline) / 4 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… utility negative

The threshold branch could leave cold_util_load = residual[-1] negative by
up to 1e-9 * scale; clamping it to zero alone would break
hot_util_load - cold_util_load == sum(unit_duty) in relative terms (the
identity the tests assert with rtol=1e-9). Absorb the rounding into
hot_util_load instead, so both loads are non-negative and the identity is
exact. Also round the problem_table doctest's cold utility to 10 kJ/hr so
it does not demand ten significant digits from a VLE enthalpy across
Python versions.

tests/test_hxn.py + hxn doctests: 14 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sarangbhagwat

sarangbhagwat commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

Effect on heat recovery (old vs. new problem table)

Same systems simulated with the old and new hxn_synthesis.py (biorefineries with HXN.units = None, i.e. all utility streams integrated; T_min_app = 5 K; loads are unit_duty):

Case Hot utility (MJ/hr) old → new Recovered (MJ/hr) old → new Old network feasible?
HeatExchangerNetwork doctest system 282.8 → 282.8 60.1 → 60.1 yes — network unchanged; only the MER target moved (435 → 283, from impossible to feasible)
4-stream synthetic (tests) 6.53 → 2.37 10.6 → 14.7 yes — now at MER
Sugarcane 111.2 → 126.2 58.9 → 44.0 noHX_2_7 (20.4 MJ/hr) has a −3 K internal temperature cross
Lipidcane 71.9 → 79.4 48.8 → 41.3 no — 10.0 MJ/hr in exchangers with internal approach < 5 K

The sugarcane/lipidcane results look like they worsened, but the old networks there actually "beat" the corrected MER, so they weren't actually feasible. This is an issue in HXprocess's internal counter-current H–T profile (not just the end-point approaches that HXprocess enforces): e.g., on sugarcane, H304 is a vapor whose 21.8 MJ/hr is almost all latent heat at 364 K, and the old pinch (328 K, after the shifted-temperature flash dropped that latent heat) led the greedy synthesizer to match it against a cold stream that is at 367 K at the condensation point.

With the corrected table: sugarcane recovers 43.99 of the 44.07 MJ/hr the MER allows (at MER) and lipidcane 41.3 of 43.9 (94 %), with no infeasible exchangers. In feasible heat, recovery improves on every case (sugarcane ≈ +14 %, lipidcane ≈ +6 %, synthetic +39 %, doctest unchanged).

Downstream effect (all-streams configurations): sugarcane utility cost +5.5 %, TCI −0.7 %; lipidcane +1.6 % / −0.2 %. The default configurations, which integrate a hand-picked subset, are unchanged.

Follow-ups this surfaced (out of scope here): HXprocess should check the internal approach for latent-heat streams (or the synthesizer should split condensing streams at their dew point), and the synthesizer can emit a reversed match (lipidcane HX_3_19, "hot" 374.6 → 374.1 K vs. "cold" 386 K, 0.04 MJ/hr — present before and after this PR).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant