fix HXN pinch analysis: energy-consistent problem table - #254
Conversation
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>
Effect on heat recovery (old vs. new problem table)Same systems simulated with the old and new
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 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): |
|
Great work! Users reading this pull might find this note helpful: The issue in BioSTEAM where temperatures do not match heating/cooling directions in condensers/reboilers is due to the way they are modeled in shortcut methods (FUG and McCabeThiele). There is nothing we can do make everything thermodynamically consistent (maintaining the overall energy balance and phase equilibrium at both outlets) because the system is overspecified internally in shortcut methods. This pull request makes sure all results are conservative... If users what to include reboiler/condensers where the problem arises, it is better to direct them to a rigorous column. Thank you!! |
Problem
temperature_interval_pinch_analysis(biosteam/facilities/hxn/hxn_synthesis.py) built the heat cascade from interval enthalpies that wereT − T_min_app, so a stream entering withinT_min_appof its dew point was already liquid and lost its latent heat;[H_in, H_out], so non-equilibrium column outlets inflated interval duties;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_dutyon sugarcane — so the pinch temperatures handed tosynthesize_networkwere 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: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 tosign · |H_out − H_in|.Tcannot serve a sink aboveT, and checking only the leaving flow let such loads mask a deficit in the interval directly above them.hot_util_load − cold_util_load == Σ unit_dutyand both targets are non-negative by construction (rounding in the threshold branch is absorbed into the hot target to keep the identity exact).temperature_interval_pinch_analysisis now a thin wrapper; its signature and 12-tuple return are unchanged, andsynthesize_network/HeatExchangerNetworkare untouched.problem_table/ProblemTableare exported with anExamplesdoctest.Synthesis-side note: non-monotone streams get
pinch_T = T_ininpinch_T_arr, soload_dutiesplaces their whole duty on the hot side (the old code produced a meaningless negativedH2for them whenT_insat 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.
HeatExchangerNetworkclass doctest unchanged (same pinch on that system; ratio still 0.82).Full suite (
pytest . --disable-numba=1 -m "not slow"): identical failure set tomaster— stack drift, missing optional deps, tutorial notebooks — nothing new.tests/test_biorefineries.py::test_sugarcane(full HXN synthesis) passes;test_cornstoverfails with the same pre-existing numbers as onmaster.🤖 Generated with Claude Code