CoolProp 8.0.0
An open-source fluid property and humid air property database
CoolProp-Tests-HSU_D.cpp
Go to the documentation of this file.
1// Characterization tests for D + {H, S, U} flash inputs (HSU_D_flash).
2//
3// These tests pin the superancillary "happy path" ON and verify the
4// density-plus-caloric flashes reproduce their inputs across the regions
5// that were historically fragile; they are the acceptance harness for the
6// hybrid superancillary/fallback rewrite tracked in bd CoolProp-j3n. (The
7// legacy ancillary fallback is known-broken on exactly these points -- that
8// is the rewrite's motivation -- so it is not asserted here; the opt-in
9// [HSU_D_bench] case exercises it under COOLPROP_DISABLE_SUPERANC_HSU_D.)
10//
11// Every case is mined from a real (mostly closed) GitHub issue and tagged
12// with its number so a failure points straight at the report:
13//
14// [2486] Water DmolarSmolar round-trip along the saturation boundary
15// [2157] R1233ZD(E)/R32 D,U two-phase quality-band failures
16// [1698] R134a D,U two-phase "Brent reached max steps"
17// [1054] R245fa D,S two-phase point that fails while neighbours pass
18// [2154] Hydrogen D,U near the critical density -> "p is not valid"
19// [2173] H2/He D,U near-critical-density sweep (PR #2173 fix)
20// [1965] Nitrogen DmassUmass -> "matrix is singular"
21// [2022] Air Hmass,Smass (related HS pair, not D+; characterised too)
22// [2685] REFPROP::MM DmassUmass -> smass == inf
23// [2426] REFPROP::Hydrogen DmassUmass -> ungraceful failure / wrong h
24//
25// The checks are deliberately self-consistency based: after a D+X flash the
26// resulting state must reproduce the input density AND the input caloric
27// property (and, where a saturation/PT reference exists, the temperature).
28// Mismatches are recorded with FAIL_CHECK rather than aborting, so a single
29// run characterises the entire failure surface instead of stopping at the
30// first bad point.
31//
32// Built into CatchTestRunner when COOLPROP_CATCH_MODULE=ON. Run the whole
33// suite with `CatchTestRunner "[HSU_D]"`.
34
36#include "CoolProp/CoolProp.h"
40#include "../Backends/Helmholtz/HelmholtzEOSMixtureBackend.h"
42
43#if defined(ENABLE_CATCH)
44# include <catch2/catch_all.hpp>
45
46# include <cmath>
47# include <cstdlib>
48# include <limits>
49# include <memory>
50# include <string>
51# include <vector>
52
53namespace {
54
55// RAII toggle of the ENABLE_SUPERANCILLARIES config flag, restored on scope
56// exit. These tests pin it ON (SuperancGuard guard(true)) so the
57// superancillary "happy path" is exercised hermetically regardless of the
58// ambient config.
59struct SuperancGuard
60{
61 bool saved;
62 explicit SuperancGuard(bool on) : saved(CoolProp::get_config_bool(ENABLE_SUPERANCILLARIES)) {
63 CoolProp::set_config_bool(ENABLE_SUPERANCILLARIES, on);
64 }
65 ~SuperancGuard() {
66 CoolProp::set_config_bool(ENABLE_SUPERANCILLARIES, saved);
67 }
68 SuperancGuard(const SuperancGuard&) = delete;
69 SuperancGuard& operator=(const SuperancGuard&) = delete;
70 SuperancGuard(SuperancGuard&&) = delete;
71 SuperancGuard& operator=(SuperancGuard&&) = delete;
72};
73
74std::vector<double> linspace(double a, double b, std::size_t n) {
75 std::vector<double> v(n);
76 for (std::size_t i = 0; i < n; ++i) {
77 v[i] = (n == 1) ? a : a + (b - a) * static_cast<double>(i) / static_cast<double>(n - 1);
78 }
79 return v;
80}
81
82// The mass-based caloric accessor matching a D+X input pair.
83double other_mass(CoolProp::AbstractState& AS, CoolProp::input_pairs pair) {
84 switch (pair) {
86 return AS.hmass();
88 return AS.smass();
90 return AS.umass();
91 default:
92 return _HUGE;
93 }
94}
95
96const char* pair_name(CoolProp::input_pairs pair) {
97 switch (pair) {
99 return "DmassHmass";
101 return "DmassSmass";
103 return "DmassUmass";
104 default:
105 return "?";
106 }
107}
108
109// Re-flash a fresh state via (D, other) and verify it reproduces the inputs.
110// T_expect > 0 adds a temperature-recovery check. Records failures (throw or
111// mismatch) without aborting so a sweep maps the whole surface.
112void check_DX(CoolProp::AbstractState& rt, CoolProp::input_pairs pair, double d_mass, double other_in, double T_expect) {
113 INFO("pair=" << pair_name(pair) << " d_mass=" << d_mass << " other_in=" << other_in << " T_expect=" << T_expect);
114 try {
115 rt.update(pair, d_mass, other_in);
116 } catch (const std::exception& e) {
117 FAIL_CHECK("update threw: " << e.what());
118 return;
119 } catch (...) {
120 FAIL_CHECK("update threw non-std exception");
121 return;
122 }
123 const double d_out = rt.rhomass();
124 const double other_out = other_mass(rt, pair);
125 CHECK(std::isfinite(d_out));
126 CHECK(std::isfinite(other_out));
127 CHECK(d_out == Catch::Approx(d_mass).epsilon(1e-5));
128 // Caloric properties can pass through zero (reference-state dependent),
129 // so guard the relative compare with a margin.
130 CHECK(other_out == Catch::Approx(other_in).epsilon(1e-5).margin(1e-3 * std::abs(other_in) + 1e-6));
131 if (T_expect > 0) {
132 // Input recovery (d_out, other_out above) is the PRIMARY correctness
133 // check -- it is exactly what the flash solves for. T is a derived,
134 // secondary guard: loose enough to tolerate the critical-region
135 // ill-conditioning (dT/d(rho,X) blows up near T_crit, so a converged
136 // (rho, X) root can sit ~1e-3 off in T) while still catching a flash
137 // that lands on the wrong phase/branch (those miss T by whole percent).
138 CHECK(rt.T() == Catch::Approx(T_expect).epsilon(1e-3));
139 }
140}
141
142} // namespace
143
144// ---------------------------------------------------------------------------
145// #2486 : round-trip on the saturation boundary (Q = 0 and Q = 1).
146// This is the hardest region for HSU_D: the input density sits exactly where
147// the two-phase and single-phase branches meet. Establish the reference with
148// QT_INPUTS, then re-flash through each of D+{H,S,U}.
149// ---------------------------------------------------------------------------
150TEST_CASE("HSU_D: saturation-boundary round-trip (#2486)", "[HSU_D][HSU_D_satbound][2486]") {
151 const std::string fluid = GENERATE(as<std::string>{}, "Water", "n-Propane", "R134a", "CarbonDioxide", "Nitrogen");
153 SuperancGuard guard(true);
154 CAPTURE(fluid, pair_name(pair));
155
156 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
157 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
158 const double Tt = CoolProp::Props1SI(fluid, "Ttriple");
159 const double Tc = CoolProp::Props1SI(fluid, "Tcrit");
160
161 for (double T : linspace(Tt + 0.5, Tc - 0.5, 60)) {
162 for (double Q : {0.0, 1.0}) {
163 CAPTURE(T, Q);
164 ref->update(CoolProp::QT_INPUTS, Q, T);
165 const double d = ref->rhomass();
166 double other = 0;
167 switch (pair) {
169 other = ref->hmass();
170 break;
172 other = ref->smass();
173 break;
174 default:
175 other = ref->umass();
176 break;
177 }
178 check_DX(*rt, pair, d, other, T);
179 }
180 }
181}
182
183// ---------------------------------------------------------------------------
184// #2157 / #1698 / #1054 : two-phase interior round-trips.
185// Reports describe failures (Brent max-steps, invalid secant residual) for
186// D,U and D,S inside the dome, worst at low quality. Sweep T and Q.
187// ---------------------------------------------------------------------------
188TEST_CASE("HSU_D: two-phase interior round-trip (#2157,#1698,#1054)", "[HSU_D][HSU_D_twophase][2157][1698][1054]") {
189 // R1233ZD(E)=#2157, R32=#2157, R134a=#1698, R245fa=#1054.
190 const std::string fluid = GENERATE(as<std::string>{}, "R1233zd(E)", "R32", "R134a", "R245fa");
192 SuperancGuard guard(true);
193 CAPTURE(fluid, pair_name(pair));
194
195 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
196 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
197 const double Tt = CoolProp::Props1SI(fluid, "Ttriple");
198 const double Tc = CoolProp::Props1SI(fluid, "Tcrit");
199
200 for (double T : linspace(Tt + 1.0, Tc - 1.0, 40)) {
201 for (double Q : {0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9}) {
202 CAPTURE(T, Q);
203 ref->update(CoolProp::QT_INPUTS, Q, T);
204 const double d = ref->rhomass();
205 const double other = (pair == CoolProp::DmassUmass_INPUTS) ? ref->umass() : ref->smass();
206 check_DX(*rt, pair, d, other, T);
207 }
208 }
209}
210
211// ---------------------------------------------------------------------------
212// #2154 / #2173 : near the critical density. saturation_D_pure destabilises
213// when rho ~ rho_crit; PR #2173 patched the HEOS path. This region is the
214// hardest part of the surface, so it gets three dedicated sweeps:
215// (A) a dense (T, rho) box around the critical point, all three D+X pairs,
216// (B) the critical-isochore crossing that #2154/#1965 actually exercise,
217// (C) the near-critical two-phase dome edge (T within ~0.01 K of T_crit),
218// where HSU_D_flash_twophase's hard 0.01 K bracket limit bites (#2173).
219// The grids are concentrated near rho_crit / T_crit, where the failures live.
220// ---------------------------------------------------------------------------
221
222// Density and temperature multipliers clustered around the critical point.
223static const std::vector<double> kRhoFactors = {0.50, 0.75, 0.90, 0.95, 0.98, 0.99, 0.995, 0.999, 1.0,
224 1.001, 1.005, 1.01, 1.02, 1.05, 1.10, 1.25, 1.50, 2.00};
225static const std::vector<double> kTFactors = {0.90, 0.95, 0.98, 0.99, 0.995, 1.0, 1.005, 1.01, 1.02, 1.05, 1.10};
226
227// (A) Dense (T, rho) box around the critical point, all three D+X pairs.
228TEST_CASE("HSU_D: near-critical (T,rho) box, all pairs (#2154,#2173)", "[HSU_D][HSU_D_critdens][2154][2173]") {
229 const std::string fluid =
230 GENERATE(as<std::string>{}, "Water", "CarbonDioxide", "n-Propane", "R134a", "Nitrogen", "Hydrogen", "Helium", "Methane");
232 SuperancGuard guard(true);
233 CAPTURE(fluid, pair_name(pair));
234
235 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
236 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
237 const double Tc = CoolProp::Props1SI(fluid, "Tcrit");
238 const double rhoc = CoolProp::Props1SI(fluid, "rhomass_critical");
239
240 for (double tf : kTFactors) {
241 for (double rf : kRhoFactors) {
242 // The exact critical point (T_c, rho_c) is a singular point of the
243 // EOS where the density<->caloric map degenerates; flashing there is
244 // ill-posed, so skip it (all near-critical neighbours are kept).
245 if (tf == 1.0 && rf == 1.0) continue;
246 const double T = tf * Tc, rho = rf * rhoc;
247 CAPTURE(tf, rf, T, rho);
248 // Reference state from (rho, T): always single-phase well-posed here.
249 ref->update(CoolProp::DmassT_INPUTS, rho, T);
250 check_DX(*rt, pair, rho, other_mass(*ref, pair), T);
251 }
252 }
253}
254
255// (B) Crossing the critical isochore: hold rho ~ rho_crit and sweep T (and
256// hence internal energy) from subcritical to supercritical, exactly the
257// tank-fill/defuel scenario in #2154 and #1965.
258TEST_CASE("HSU_D: critical-isochore crossing (#2154,#1965)", "[HSU_D][HSU_D_critdens][2154][1965]") {
259 const std::string fluid = GENERATE(as<std::string>{}, "Hydrogen", "Helium", "Nitrogen", "CarbonDioxide", "Water", "Methane");
260 const double rf = GENERATE(0.97, 0.99, 1.0, 1.01, 1.03);
261 SuperancGuard guard(true);
262 CAPTURE(fluid, rf);
263
264 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
265 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
266 const double Tc = CoolProp::Props1SI(fluid, "Tcrit");
267 const double rhoc = CoolProp::Props1SI(fluid, "rhomass_critical");
268 const double rho = rf * rhoc;
269
270 for (double T : linspace(0.92 * Tc, 1.12 * Tc, 40)) {
271 CAPTURE(T);
272 ref->update(CoolProp::DmassT_INPUTS, rho, T);
273 check_DX(*rt, CoolProp::DmassUmass_INPUTS, rho, ref->umass(), T);
274 }
275}
276
277// (C) Two-phase dome edge as T -> T_crit from below. Densities on both
278// branches collapse toward rho_crit and HSU_D_flash_twophase's 0.01 K bracket
279// limit on T can prevent convergence (#2173 noted this as a residual failure).
280TEST_CASE("HSU_D: near-critical two-phase dome edge (#2173)", "[HSU_D][HSU_D_critdens][2173]") {
281 const std::string fluid = GENERATE(as<std::string>{}, "Hydrogen", "Helium", "CarbonDioxide", "Water", "n-Propane");
283 SuperancGuard guard(true);
284 CAPTURE(fluid, pair_name(pair));
285
286 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
287 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
288 const double Tc = CoolProp::Props1SI(fluid, "Tcrit");
289 const double Tt = CoolProp::Props1SI(fluid, "Ttriple");
290
291 // Approach Tc from below; the tightest point sits well within 0.01 K of Tc.
292 // Skip offsets that would fall below the triple point (Helium's Tc is only
293 // ~5.2 K, so the coarse 10 K offset is invalid there).
294 for (double dT : {10.0, 1.0, 0.1, 0.01, 0.001}) {
295 const double T = Tc - dT;
296 if (T <= Tt + 0.1) continue;
297 for (double Q : {0.1, 0.3, 0.5, 0.7, 0.9}) {
298 CAPTURE(dT, T, Q);
299 ref->update(CoolProp::QT_INPUTS, Q, T);
300 check_DX(*rt, pair, ref->rhomass(), other_mass(*ref, pair), T);
301 }
302 }
303}
304
305// Exact point from #2154: PropsSI("T","D",31.46258141,"U",2391760.261,"Hydrogen").
306TEST_CASE("HSU_D: #2154 reported hydrogen D,U point", "[HSU_D][2154]") {
307 SuperancGuard guard(true);
308 double T = 0;
309 REQUIRE_NOTHROW(T = CoolProp::PropsSI("T", "D", 31.46258141, "U", 2391760.261, "Hydrogen"));
310 CAPTURE(T);
311 CHECK(std::isfinite(T));
312 // Issue states the answer should be roughly -40..140 degC.
313 CHECK(T > 233.0);
314 CHECK(T < 414.0);
315}
316
317// ---------------------------------------------------------------------------
318// #1965 : nitrogen DmassUmass -> "Zero occurred in row 1, the matrix is
319// singular" (open). The reported point sits near rho_crit on the liquid side.
320// ---------------------------------------------------------------------------
321TEST_CASE("HSU_D: #1965 nitrogen DmassUmass singular matrix", "[HSU_D][1965]") {
322 SuperancGuard guard(true);
323 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Nitrogen"));
324 check_DX(*rt, CoolProp::DmassUmass_INPUTS, 313.305125, 154834.285193, /*T_expect=*/-1);
325}
326
327// ---------------------------------------------------------------------------
328// #1054 : the specific R245fa D,S point that fails while neighbours pass.
329// ---------------------------------------------------------------------------
330TEST_CASE("HSU_D: #1054 R245fa D,S isolated failure point", "[HSU_D][1054]") {
331 SuperancGuard guard(true);
332 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "R245fa"));
333 // Neighbours that the issue reports as working - they must keep working.
334 for (double d : {17.04, 17.05, 17.048396706125065}) {
335 for (double s : {1.6174e3, 1.6175e3, 1617.463750828963}) {
336 CAPTURE(d, s);
337 check_DX(*rt, CoolProp::DmassSmass_INPUTS, d, s, /*T_expect=*/-1);
338 }
339 }
340}
341
342// ---------------------------------------------------------------------------
343// #2022 : HEOS::Air with Hmass,Smass. NOT a D+X pair, but the same family of
344// flash-robustness failures; characterised here so the rewrite can confirm it
345// did not regress (or, ideally, fixed) the reported points. Air is a
346// pseudo-pure mixture, so the superancillary happy path never runs on it --
347// these points are governed entirely by the (unchanged) HmassSmass flash.
348// ---------------------------------------------------------------------------
349TEST_CASE("HSU_D-adjacent: #2022 HEOS::Air Hmass,Smass", "[HSU_D][2022]") {
350 SuperancGuard guard(true);
351 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Air"));
352
353 // Round-trip check for one (h, s) input; returns true iff the flash
354 // reproduces both inputs at a physical (finite, positive) pressure.
355 auto roundtrips = [&](double h, double s) -> bool {
356 try {
358 return std::isfinite(rt->p()) && rt->p() > 0 && rt->hmass() == Catch::Approx(h).epsilon(1e-4)
359 && rt->smass() == Catch::Approx(s).epsilon(1e-4);
360 } catch (const std::exception&) {
361 return false;
362 }
363 };
364
365 // Points that round-trip today: assert they keep working (no-regression guard).
366 const std::vector<std::pair<double, double>> hs_ok = {
367 {429667.3064, 2735.612747}, {430867.4193, 2732.980272}, {427500.0, 2740.0}, {435000.0, 2724.0}};
368 for (auto& [h, s] : hs_ok) {
369 CAPTURE(h, s);
370 CHECK(roundtrips(h, s));
371 }
372
373 // Known-broken: {h=435000, s=2654} is a physically valid Air state
374 // (~T=320 K, p~8.3 MPa, just lower entropy / higher pressure than the
375 // {435000, 2724} neighbour above) but the HmassSmass flash diverges,
376 // driving p toward ~1e11 Pa (returns hmass~4.5e7 or throws "Brent ... do
377 // not bracket the root"). Pre-existing on master and out of scope for
378 // this D+{H,S,U} PR; characterised non-fatally so a future fix flips it
379 // green. Tracked in bd CoolProp-l34.
380 {
381 const double h = 435000.0, s = 2654.0;
382 CAPTURE(h, s);
383 if (!roundtrips(h, s)) {
384 WARN("known-broken Air HmassSmass point (h=" << h << ", s=" << s << ") still diverges; see bd CoolProp-l34 / gh #2022");
385 }
386 }
387}
388
389// ---------------------------------------------------------------------------
390// REFPROP-backed reports. REFPROP is available locally (see CLAUDE.md), so
391// these run rather than skip on Ian's machine; CI skips cleanly.
392// ---------------------------------------------------------------------------
393TEST_CASE("HSU_D: #2685 REFPROP::MM DmassUmass returns finite entropy", "[HSU_D][2685][REFPROP]") {
395 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("REFPROP", "MM"));
396 // Reported point returned smass == inf while T/p/h were fine.
397 check_DX(*rt, CoolProp::DmassUmass_INPUTS, 126.64425, 413304.53, /*T_expect=*/-1);
398}
399
400TEST_CASE("HSU_D: #2426 REFPROP::Hydrogen DmassUmass round-trip", "[HSU_D][2426][REFPROP]") {
402 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("REFPROP", "Hydrogen"));
403 // Reported failure (ungraceful) plus wrong hmass (~2.94e4 vs expected ~3.89e6).
404 check_DX(*rt, CoolProp::DmassUmass_INPUTS, 20.0, 2.5e6, /*T_expect=*/-1);
405}
406
407// ---------------------------------------------------------------------------
408// Water's saturated-liquid density has a maximum near T ~ 277 K (the famous
409// density anomaly): rho_satL(T) rises from the triple point to a maximum at
410// T_anom, then falls toward rho_crit. For densities just below that maximum
411// the liquid saturation branch is non-monotonic, so get_all_intersections('D',
412// rho) returns MULTIPLE liquid-side roots -- the topology the happy-path
413// interval classification has to get right (it cannot assume "two-phase below
414// the single root"). Round-trip two-phase states whose overall density sits in
415// the anomaly band, plus genuinely compressed-liquid states on either side.
416// ---------------------------------------------------------------------------
417TEST_CASE("HSU_D: water two-phase around the saturated-liquid density maximum", "[HSU_D][HSU_D_wateranom]") {
419 SuperancGuard guard(true);
420 CAPTURE(pair_name(pair));
421
422 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Water"));
423 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Water"));
424
425 // Locate the saturated-liquid density maximum (T_anom ~ 277 K for water).
426 double T_anom = 277.0, rhoL_max = 0.0;
427 for (double T : linspace(274.0, 295.0, 600)) {
428 ref->update(CoolProp::QT_INPUTS, 0.0, T);
429 if (ref->rhomass() > rhoL_max) {
430 rhoL_max = ref->rhomass();
431 T_anom = T;
432 }
433 }
434 CAPTURE(T_anom, rhoL_max);
435
436 // (1) Two-phase round-trips spanning the anomaly temperature, including very
437 // liquid-rich states whose mixture density lands in the non-monotonic band.
438 for (double T : linspace(274.0, 300.0, 40)) {
439 for (double Q : {0.0, 0.001, 0.01, 0.1, 0.5, 0.9, 1.0}) {
440 CAPTURE(T, Q);
441 ref->update(CoolProp::QT_INPUTS, Q, T);
442 check_DX(*rt, pair, ref->rhomass(), other_mass(*ref, pair), T);
443 }
444 }
445
446 // (2) Genuinely single-phase compressed liquid (high pressure, so densities
447 // above rho_satL,max) across the anomaly temperature band.
448 for (double p : {20e6, 50e6, 100e6}) {
449 for (double T : linspace(274.0, 320.0, 16)) {
450 CAPTURE(p, T);
451 ref->update(CoolProp::PT_INPUTS, p, T);
452 check_DX(*rt, pair, ref->rhomass(), other_mass(*ref, pair), T);
453 }
454 }
455}
456
457// ---------------------------------------------------------------------------
458// HSU_D_TWOPHASE_EOS_POLISH config toggle: both modes must reproduce the inputs;
459// polish-off (raw superancillary) should agree with polish-on (EOS-exact) to the
460// superancillary precision (~1e-8).
461// ---------------------------------------------------------------------------
462TEST_CASE("HSU_D: two-phase EOS-polish config toggle", "[HSU_D][HSU_D_polishcfg]") {
463 SuperancGuard sa(true);
464 const bool saved = CoolProp::get_config_bool(HSU_D_TWOPHASE_EOS_POLISH);
465 struct Restore
466 {
467 bool v;
468 ~Restore() {
469 CoolProp::set_config_bool(HSU_D_TWOPHASE_EOS_POLISH, v);
470 }
471 Restore(const Restore&) = delete;
472 Restore& operator=(const Restore&) = delete;
473 Restore(Restore&&) = delete;
474 Restore& operator=(Restore&&) = delete;
475 } restore{saved};
476
477 for (const auto& fluid : {std::string("Water"), std::string("CarbonDioxide"), std::string("n-Propane")}) {
478 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
479 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", fluid));
480 const double Tt = CoolProp::Props1SI(fluid, "Ttriple"), Tc = CoolProp::Props1SI(fluid, "Tcrit");
481 for (double T : linspace(Tt + 1.0, Tc - 1.0, 12)) {
482 for (double Q : {0.05, 0.5, 0.95}) {
483 CAPTURE(fluid, T, Q);
484 ref->update(CoolProp::QT_INPUTS, Q, T);
485 const double d = ref->rhomass(), s = ref->smass();
486 CoolProp::set_config_bool(HSU_D_TWOPHASE_EOS_POLISH, true);
488 const double T_on = rt->T();
489 CoolProp::set_config_bool(HSU_D_TWOPHASE_EOS_POLISH, false);
491 const double T_off = rt->T();
492 // Both reproduce the saturation temperature; they agree to ~SA precision.
493 CHECK(T_on == Catch::Approx(T).epsilon(1e-6));
494 CHECK(T_off == Catch::Approx(T).epsilon(1e-5));
495 CHECK(std::abs(T_on - T_off) / T < 1e-6);
496 }
497 }
498 }
499}
500
501// ---------------------------------------------------------------------------
502// Comprehensive single-phase sweep: for every pure fluid that has a
503// superancillary, walk a dense (log p, linear T) grid -- the same coordinate
504// layout as the consistency plots -- establish each state with PT_INPUTS
505// (always single phase), and round-trip it through D+{H,S,U} with the
506// superancillary "happy path" enabled. This blankets the liquid, vapor and
507// supercritical regions of the whole fluid set.
508//
509// Heavy (hundreds of fluids x grid x 3 pairs), so it is hidden from the default
510// run via the [.] tag. Invoke explicitly:
511// CatchTestRunner "[HSU_D_ptsweep]"
512// CatchTestRunner "[HSU_D_ptsweep]" -c Water # single fluid section
513// ---------------------------------------------------------------------------
514TEST_CASE("HSU_D: dense PT sweep over all superancillary fluids", "[HSU_D_ptsweep][.]") {
515 SuperancGuard guard(true);
516
517 static const std::vector<std::string> fluids = strsplit(CoolProp::get_global_param_string("fluids_list"), ',');
518 const std::string fluid = GENERATE(from_range(fluids));
519 CAPTURE(fluid);
520
521 std::shared_ptr<CoolProp::AbstractState> ref, rt;
522 try {
523 ref.reset(CoolProp::AbstractState::factory("HEOS", fluid));
524 rt.reset(CoolProp::AbstractState::factory("HEOS", fluid));
525 } catch (...) {
526 return; // un-constructible fluid -> nothing to test
527 }
528 auto* be = dynamic_cast<CoolProp::HelmholtzEOSMixtureBackend*>(ref.get());
529 if (be == nullptr || !be->is_pure()) return; // mixtures/pseudo-pure: no superancillary
530 std::shared_ptr<CoolProp::EquationOfState::SuperAncillary_t> sa;
531 try {
532 sa = be->get_superanc();
533 } catch (...) {
534 sa.reset(); // treat a failed lookup as "no superancillary"
535 }
536 if (!sa) return; // pure fluid without a superancillary -> happy path n/a, skip
537
538 const double Tmin = ref->Tmin(), Tmax = ref->Tmax(), pmax = ref->pmax();
539 double plo;
540 try {
541 plo = std::max(ref->p_triple(), pmax * 1e-8);
542 } catch (...) {
543 plo = pmax * 1e-8;
544 }
545 if (!(Tmax > Tmin) || !(pmax > plo)) return;
546
547 // Grid density is tunable via env vars (no recompile needed); defaults keep
548 // the opt-in run reasonable. e.g. HSU_PTSWEEP_NT=200 HSU_PTSWEEP_NP=200 for
549 // a high-confidence sweep.
550 auto env_or = [](const char* name, std::size_t def) -> std::size_t {
551 const char* v = std::getenv(name);
552 if (v == nullptr) return def;
553 try {
554 const long n = std::stol(v);
555 if (n > 1) return static_cast<std::size_t>(n);
556 } catch (...) {
557 return def; // malformed env value -> default
558 }
559 return def;
560 };
561 const std::size_t NT = env_or("HSU_PTSWEEP_NT", 40), NP = env_or("HSU_PTSWEEP_NP", 30);
563
564 for (double T : linspace(Tmin + 0.1, Tmax, NT)) {
565 for (std::size_t j = 0; j < NP; ++j) {
566 // Logarithmic spacing in pressure (linear in T), as in the
567 // consistency plots.
568 const double p = plo * std::pow(pmax / plo, static_cast<double>(j) / static_cast<double>(NP - 1));
569 try {
570 ref->update(CoolProp::PT_INPUTS, p, T); // single-phase reference
571 } catch (...) {
572 continue; // (p, T) outside the EOS domain (e.g. solid) -> skip
573 }
574 const double d = ref->rhomass();
575 if (!std::isfinite(d) || d <= 0) continue;
576 for (auto pr : pairs) {
577 CAPTURE(T, p);
578 // T_expect = -1: the temperature check is intentionally skipped
579 // here. At fixed density h/s/u(rho, T) is NOT monotonic for some
580 // fluids (e.g. compressed cryogenic hydrogen has a minimum of
581 // h along the isochore), so (rho, X) can map to two valid
582 // single-phase temperatures. The flash's contract is only to
583 // return a state reproducing the inputs (rho and X), which is
584 // exactly what the recovery checks in check_DX assert.
585 check_DX(*rt, pr, d, other_mass(*ref, pr), /*T_expect=*/-1);
586 }
587 }
588 }
589}
590
591// ---------------------------------------------------------------------------
592// Regression: the matrix-balancing helper used by the superancillary
593// Chebyshev root finder (CoolProp::superancillary::detail::balance_matrix) must
594// always terminate, even when a companion-matrix row/column norm is non-finite.
595//
596// A near-zero leading Chebyshev coefficient makes the companion matrix's last
597// column (-coeffs.head(N)/(2*coeffs(N))) overflow to +/-inf. With an inf row
598// norm r, the scaling loop condition (c < r/beta) is perpetually true and
599// c *= beta never catches up, so balance_matrix spun forever -- the root cause
600// of the devdocs consistency-plot hang (Propyne D+H flash on x86 Linux, exposed
601// when #2995 routed D+{H,S,U} through get_all_intersections -> get_x_for_y ->
602// balance_matrix). The hang was FP-rounding dependent, hence x86-only.
603//
604// This drives balance_matrix directly with an inf entry so the guard is
605// exercised hermetically on every platform. Without the fix the test hangs.
606// ---------------------------------------------------------------------------
607TEST_CASE("balance_matrix terminates on a non-finite companion entry", "[HSU_D][balance_matrix]") {
608 Eigen::MatrixXd A(3, 3), Aprime, D;
609 A.setZero();
610 A(1, 0) = 1.0;
611 A(2, 1) = 1.0;
612 // Last column as produced by a near-zero leading coefficient: overflows to inf.
613 A(0, 2) = -1.0 / (2.0 * std::numeric_limits<double>::min() * std::numeric_limits<double>::min());
614 REQUIRE(std::isinf(A(0, 2)));
615
616 // Must return (terminate) rather than spin in the scaling loops.
618
619 // The inf row/column is left at unit scaling; finite rows stay finite.
620 CHECK(std::isfinite(D(1, 1)));
621 CHECK(D(1, 1) == 1.0);
622}
623
624// ---------------------------------------------------------------------------
625// Speed comparison: the superancillary "happy path" vs the legacy ancillary
626// path on representative D+{H,S,U} flashes. Same binary, toggled per process
627// via the kill-switch, so the timings are directly comparable:
628// CatchTestRunner "[HSU_D_bench]" (happy)
629// COOLPROP_DISABLE_SUPERANC_HSU_D=1 CatchTestRunner "[HSU_D_bench]" (legacy)
630// Hidden by default ([.]).
631// ---------------------------------------------------------------------------
632TEST_CASE("HSU_D: happy-vs-legacy speed", "[HSU_D_bench][.]") {
633 auto rt = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Water"));
634 auto co2 = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "CarbonDioxide"));
635 auto setup = [](CoolProp::AbstractState& AS, CoolProp::input_pairs ref_pair, double a, double b, CoolProp::input_pairs out) {
636 AS.update(ref_pair, a, b);
637 return std::pair<double, double>{AS.rhomass(), other_mass(AS, out)};
638 };
639
640 // Two-phase (mid-dome), Water, D+S
641 {
642 auto [d, s] = setup(*rt, CoolProp::QT_INPUTS, 0.4, 450.0, CoolProp::DmassSmass_INPUTS);
643 BENCHMARK("Water two-phase D+S") {
644 rt->update(CoolProp::DmassSmass_INPUTS, d, s);
645 return rt->T();
646 };
647 }
648 // Two-phase (mid-dome), Water, D+U
649 {
650 auto [d, u] = setup(*rt, CoolProp::QT_INPUTS, 0.4, 450.0, CoolProp::DmassUmass_INPUTS);
651 BENCHMARK("Water two-phase D+U") {
652 rt->update(CoolProp::DmassUmass_INPUTS, d, u);
653 return rt->T();
654 };
655 }
656 // Single-phase compressed liquid, Water, D+H
657 {
658 auto [d, h] = setup(*rt, CoolProp::PT_INPUTS, 50e6, 320.0, CoolProp::DmassHmass_INPUTS);
659 BENCHMARK("Water liquid D+H") {
660 rt->update(CoolProp::DmassHmass_INPUTS, d, h);
661 return rt->T();
662 };
663 }
664 // Superheated vapor, Water, D+U
665 {
666 auto [d, u] = setup(*rt, CoolProp::PT_INPUTS, 1e5, 600.0, CoolProp::DmassUmass_INPUTS);
667 BENCHMARK("Water vapor D+U") {
668 rt->update(CoolProp::DmassUmass_INPUTS, d, u);
669 return rt->T();
670 };
671 }
672 // Supercritical, CO2, D+H
673 {
674 auto [d, h] = setup(*co2, CoolProp::PT_INPUTS, 12e6, 330.0, CoolProp::DmassHmass_INPUTS);
675 BENCHMARK("CO2 supercritical D+H") {
676 co2->update(CoolProp::DmassHmass_INPUTS, d, h);
677 return co2->T();
678 };
679 }
680 // Near-critical two-phase, CO2, D+S
681 {
682 auto [d, s] = setup(*co2, CoolProp::QT_INPUTS, 0.5, 300.0, CoolProp::DmassSmass_INPUTS);
683 BENCHMARK("CO2 near-crit two-phase D+S") {
684 co2->update(CoolProp::DmassSmass_INPUTS, d, s);
685 return co2->T();
686 };
687 }
688 // Near-critical DENSITY, supercritical, CO2, D+U. rho ~ rho_crit just above
689 // T_crit is where legacy saturation_D_pure is least stable (omega-halving
690 // retry loop), so the happy path should pull ahead here.
691 {
692 const double rhoc = CoolProp::Props1SI("CarbonDioxide", "rhomass_critical");
693 const double Tc = CoolProp::Props1SI("CarbonDioxide", "Tcrit");
694 co2->update(CoolProp::DmassT_INPUTS, rhoc, 1.003 * Tc);
695 const double d = co2->rhomass(), u = co2->umass();
696 BENCHMARK("CO2 near-crit density D+U") {
697 co2->update(CoolProp::DmassUmass_INPUTS, d, u);
698 return co2->T();
699 };
700 }
701}
702
703// ---------------------------------------------------------------------------
704// Component profile of the two-phase residual call(): how is the time split
705// between the superancillary Chebyshev evals and the EOS property evaluations?
706// CatchTestRunner "[HSU_D_prof]"
707// ---------------------------------------------------------------------------
708TEST_CASE("HSU_D: two-phase call() component profile", "[HSU_D_prof][.]") {
709 auto AS = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Water"));
710 auto* be = dynamic_cast<CoolProp::HelmholtzEOSMixtureBackend*>(AS.get());
711 if (be == nullptr || !be->is_pure()) return; // not a pure HEOS backend -> no superancillary
712 std::shared_ptr<CoolProp::EquationOfState::SuperAncillary_t> sa;
713 try {
714 sa = be->get_superanc();
715 } catch (...) {
716 sa.reset(); // treat a failed lookup as "no superancillary"
717 }
718 if (!sa) return; // Water lacks a superancillary -> nothing to profile
719 const double T = 450.0;
720 const double rhoL = sa->eval_sat(T, 'D', 0), rhoV = sa->eval_sat(T, 'D', 1), p = sa->eval_sat(T, 'P', 1);
721
722 BENCHMARK("3x eval_sat (Chebyshev only)") {
723 return sa->eval_sat(T, 'D', 0) + sa->eval_sat(T, 'D', 1) + sa->eval_sat(T, 'P', 1);
724 };
725 BENCHMARK("1x update_TDmolarP_unchecked (no prop)") {
726 be->SatL->update_TDmolarP_unchecked(T, rhoL, p);
727 return be->SatL->rhomolar();
728 };
729 BENCHMARK("1x keyed_output(Hmolar) after set") {
730 be->SatL->update_TDmolarP_unchecked(T, rhoL, p);
731 return be->SatL->keyed_output(CoolProp::iHmolar);
732 };
733 BENCHMARK("FULL 2phase residual body (3 eval_sat + 2 set + 2 keyed_output)") {
734 const double rl = sa->eval_sat(T, 'D', 0), rv = sa->eval_sat(T, 'D', 1), pp = sa->eval_sat(T, 'P', 1);
735 be->SatL->update_TDmolarP_unchecked(T, rl, pp);
736 be->SatV->update_TDmolarP_unchecked(T, rv, pp);
737 const double yL = be->SatL->keyed_output(CoolProp::iHmolar);
738 const double yV = be->SatV->keyed_output(CoolProp::iHmolar);
739 return yL + yV + rl + rv;
740 };
741 BENCHMARK("baseline: update_DmolarT_direct + hmolar (1 EOS eval)") {
742 be->update_DmolarT_direct(900.0, T);
743 return be->keyed_output(CoolProp::iHmolar);
744 };
745 // Setup + commit costs that bracket the rootfind in the real flash.
746 const double rho_2ph = 0.5 * (rhoL + rhoV); // a two-phase density at T=450
747 BENCHMARK("get_all_intersections('D', rho)") {
748 return static_cast<double>(sa->get_all_intersections('D', rho_2ph, 48, 100, 1e-13).size());
749 };
750 BENCHMARK("commit: update(QT_INPUTS, Q, T)") {
751 AS->update(CoolProp::QT_INPUTS, 0.5, T);
752 return AS->T();
753 };
754}
755
756#endif // ENABLE_CATCH