CoolProp 8.0.0
An open-source fluid property and humid air property database
CoolProp-Tests-PXcdj.cpp
Go to the documentation of this file.
1// Characterization tests for P + {H, S, U} flash inputs.
2//
3// The legacy P+X solver is HSU_P_flash_singlephase_Brent in
4// src/Backends/Helmholtz/FlashRoutines.cpp; PH/PS/PU all dispatch through
5// FlashRoutines::HSU_P_flash. This file is the acceptance harness for the
6// rewrite tracked in bd CoolProp-cdj — it characterises both the named
7// regressions we expect to keep passing (CI default) and a broad single-phase
8// (T, p) sweep that maps the full surface for the production code path
9// (opt-in heavy test, writes a CSV fixture).
10//
11// Two test cases:
12// [PXcdj] named regressions, must pass on current master
13// [PXcdj_sweep][.] opt-in dense grid across CoolProp's pure-fluid list
14// — writes a CSV with timing + round-trip results, used
15// to produce dev/fixtures/pxcdj_master_baseline.csv.gz
16//
17// Input pair argument orders (verified against include/DataStructures.h):
18// HmolarP_INPUTS : (value1=h, value2=p)
19// PSmolar_INPUTS : (value1=p, value2=s)
20// PUmolar_INPUTS : (value1=p, value2=u)
21//
22// Built into CatchTestRunner when COOLPROP_CATCH_MODULE=ON. Run via
23// ./build_catch_rel/CatchTestRunner "[PXcdj]" # CI default
24// ./build_catch_rel/CatchTestRunner "[PXcdj_sweep]" # opt-in heavy
25
27#include "CoolProp/CoolProp.h"
30
31#if defined(ENABLE_CATCH)
32# include <catch2/catch_all.hpp>
33
34# include <chrono>
35# include <cmath>
36# include <cstdio>
37# include <cstdlib>
38# include <fstream>
39# include <memory>
40# include <string>
41# include <vector>
42
43namespace {
44
45// Read an env var as a size_t, returning def if absent or malformed.
46std::size_t env_or(const char* name, std::size_t def) {
47 const char* v = std::getenv(name);
48 if (v == nullptr) return def;
49 try {
50 const long n = std::stol(v);
51 if (n > 1) return static_cast<std::size_t>(n);
52 } catch (...) {
53 return def;
54 }
55 return def;
56}
57
58std::vector<double> linspace(double a, double b, std::size_t n) {
59 std::vector<double> v(n);
60 for (std::size_t i = 0; i < n; ++i) {
61 v[i] = (n == 1) ? a : a + (b - a) * static_cast<double>(i) / static_cast<double>(n - 1);
62 }
63 return v;
64}
65
66std::vector<double> logspace(double a, double b, std::size_t n) {
67 std::vector<double> v(n);
68 for (std::size_t i = 0; i < n; ++i) {
69 v[i] = (n == 1) ? a : a * std::pow(b / a, static_cast<double>(i) / static_cast<double>(n - 1));
70 }
71 return v;
72}
73
74const char* pair_label(CoolProp::input_pairs pr) {
75 switch (pr) {
77 return "PH";
79 return "PS";
81 return "PU";
82 default:
83 return "??";
84 }
85}
86
87double caloric_molar(CoolProp::AbstractState& AS, CoolProp::input_pairs pr) {
88 switch (pr) {
90 return AS.hmolar();
92 return AS.smolar();
94 return AS.umolar();
95 default:
96 return _HUGE;
97 }
98}
99
100// Re-flash via the chosen P+X pair and check (T, rho) recover within 1e-5
101// relative. Uses CHECK (not FAIL_CHECK on the recover assertions) so callers
102// see WHICH coordinate missed. Wraps the flash in try/catch and FAIL_CHECKs
103// on throw — every named-regression point must complete on current master.
104void check_PX_roundtrip(CoolProp::AbstractState& wrk, CoolProp::input_pairs pr, double p, double value, double T_expect, double rho_expect) {
105 INFO("pair=" << pair_label(pr) << " p=" << p << " value=" << value << " T_expect=" << T_expect << " rho_expect=" << rho_expect);
106 try {
107 if (pr == CoolProp::HmolarP_INPUTS) {
108 wrk.update(pr, value, p); // (h, p)
109 } else {
110 wrk.update(pr, p, value); // (p, s) or (p, u)
111 }
112 } catch (const std::exception& e) {
113 FAIL_CHECK("update threw: " << e.what());
114 return;
115 } catch (...) {
116 FAIL_CHECK("update threw non-std exception");
117 return;
118 }
119 const double T_out = wrk.T(), rho_out = wrk.rhomolar();
120 CHECK(std::isfinite(T_out));
121 CHECK(std::isfinite(rho_out));
122 CHECK(T_out == Catch::Approx(T_expect).epsilon(1e-5));
123 CHECK(rho_out == Catch::Approx(rho_expect).epsilon(1e-5));
124}
125
126// One named-regression record.
127struct NamedCase
128{
129 const char* fluid;
130 double T_K;
131 double p_Pa;
132 const char* note;
133};
134
135// Round-trip a single (fluid, T, p) point across all three P+X pairs. The
136// reference state is set via PT_INPUTS; rho, h, s, u are read off; then
137// PH/PS/PU each get a fresh state and must recover (T, rho).
138void run_named_case(const NamedCase& c) {
139 CAPTURE(c.fluid, c.note, c.T_K, c.p_Pa);
140 auto ref = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", c.fluid));
141 auto wrk = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", c.fluid));
142 try {
143 ref->update(CoolProp::PT_INPUTS, c.p_Pa, c.T_K);
144 } catch (const std::exception& e) {
145 FAIL_CHECK("PT reference update threw for " << c.fluid << ": " << e.what());
146 return;
147 }
148 const double T_ref = ref->T(), rho_ref = ref->rhomolar();
149 const double h = ref->hmolar(), s = ref->smolar(), u = ref->umolar();
150 if (!std::isfinite(rho_ref) || !std::isfinite(h) || !std::isfinite(s) || !std::isfinite(u)) {
151 FAIL_CHECK("non-finite reference state for " << c.fluid);
152 return;
153 }
154 {
155 INFO("PH");
156 check_PX_roundtrip(*wrk, CoolProp::HmolarP_INPUTS, c.p_Pa, h, T_ref, rho_ref);
157 }
158 {
159 INFO("PS");
160 check_PX_roundtrip(*wrk, CoolProp::PSmolar_INPUTS, c.p_Pa, s, T_ref, rho_ref);
161 }
162 {
163 INFO("PU");
164 check_PX_roundtrip(*wrk, CoolProp::PUmolar_INPUTS, c.p_Pa, u, T_ref, rho_ref);
165 }
166}
167
168} // namespace
169
170// ---------------------------------------------------------------------------
171// CI default: focused PH/PS/PU round-trips that should PASS on current master.
172// Every point is established via PT_INPUTS first, then re-flashed through each
173// of HmolarP/PSmolar/PUmolar — the recovered (T, rho) must agree to 1e-5
174// relative. Covers:
175// * gas / liquid / supercritical points across a representative fluid set
176// (Water, CO2, R134a, Propane, Nitrogen, MM)
177// * three prototype-era known-hard points that current master DOES handle:
178// - Nitrogen PS at the supercritical-cold wrong-basin trap
179// - R134a PH at the spinodal step
180// - Water near the density anomaly
181//
182// NOTE: The two Nitrogen PS supercritical-cold failures on master at
183// (T=86.35K, p=4.754MPa) and (T=90.20K, p=7.768MPa) are deliberately omitted
184// here — they are fixed in PR #3020. They show up in the [PXcdj_sweep] CSV.
185// ---------------------------------------------------------------------------
186TEST_CASE("PXcdj named regressions", "[PXcdj]") {
187 static const NamedCase kCases[] = {
188 // Water: gas, liquid, supercritical
189 {"Water", 600.0, 1.0e5, "gas"},
190 {"Water", 320.0, 5.0e6, "liquid"},
191 {"Water", 700.0, 30.0e6, "supercrit"},
192 // CO2: gas, supercritical, liquid
193 {"CarbonDioxide", 350.0, 1.0e6, "gas"},
194 {"CarbonDioxide", 320.0, 10.0e6, "supercrit"},
195 {"CarbonDioxide", 250.0, 5.0e6, "liquid"},
196 // R134a: liquid, gas
197 {"R134a", 250.0, 3.0e5, "liquid"},
198 {"R134a", 350.0, 2.0e5, "gas"},
199 // Propane: gas, liquid
200 {"n-Propane", 350.0, 1.0e6, "gas"},
201 {"n-Propane", 250.0, 5.0e6, "liquid"},
202 // Nitrogen: gas, supercritical (NOT the 86.35K / 90.20K failing points)
203 {"Nitrogen", 300.0, 1.0e5, "gas"},
204 {"Nitrogen", 200.0, 10.0e6, "supercrit"},
205 // MM (hexamethyldisiloxane): gas, liquid
206 {"MM", 450.0, 1.0e5, "gas"},
207 {"MM", 350.0, 5.0e6, "liquid"},
208 // Prototype-era known-hard points that master handles correctly.
209 {"Nitrogen", 121.0, 3.7e6, "supercritical-cold wrong-basin trap"},
210 {"R134a", 250.0, 3.0e5, "spinodal step"},
211 {"Water", 277.0, 5.0e6, "density anomaly"},
212 };
213 for (const auto& c : kCases) {
214 DYNAMIC_SECTION(c.fluid << " " << c.note << " T=" << c.T_K << "K p=" << c.p_Pa << "Pa") {
215 run_named_case(c);
216 }
217 }
218}
219
220// ---------------------------------------------------------------------------
221// Opt-in heavy sweep: iterate CoolProp's pure-fluid list, build a dense
222// (log-p, linear-T) grid, establish a reference via PT_INPUTS, then re-flash
223// each P+X pair through the production code and check (T, rho) recovery.
224// Writes a single CSV with timing + per-point success — the moderate (30x30)
225// run is committed as dev/fixtures/pxcdj_master_baseline.csv.gz to serve as
226// a regression fingerprint.
227//
228// Env knobs:
229// PXCDJ_NT grid size in T (default 40)
230// PXCDJ_NP grid size in p (default 40)
231// PXCDJ_CSV output CSV path (default "pxcdj_sweep.csv" in cwd)
232// PXCDJ_REPS timing reps per point (default 3); best of reps is kept
233// ---------------------------------------------------------------------------
234TEST_CASE("PXcdj broad sweep", "[PXcdj_sweep][.]") {
235 const std::size_t NT = env_or("PXCDJ_NT", 40);
236 const std::size_t NP = env_or("PXCDJ_NP", 40);
237 const std::size_t reps = env_or("PXCDJ_REPS", 3);
238 const char* csv_env = std::getenv("PXCDJ_CSV");
239 const std::string csv_path = (csv_env != nullptr) ? csv_env : "pxcdj_sweep.csv";
240
241 std::ofstream out(csv_path);
242 if (!out.is_open()) {
243 FAIL("Failed to open PXcdj sweep CSV: " << csv_path);
244 }
245 out << "fluid,pair,T_K,p_Pa,rho_molm3,value,T_solved,rho_solved,success,microseconds\n";
246
247 const std::string fluids_csv = CoolProp::get_global_param_string("FluidsList");
248 const std::vector<std::string> fluids = strsplit(fluids_csv, ',');
249
251
252 std::size_t fluids_attempted = 0, fluids_completed = 0;
253 std::size_t grand_total = 0, grand_ok = 0;
254
255 for (const auto& fluid : fluids) {
256 if (fluid.empty()) continue;
257 ++fluids_attempted;
258
259 std::shared_ptr<CoolProp::AbstractState> ref, wrk;
260 try {
261 ref.reset(CoolProp::AbstractState::factory("HEOS", fluid));
262 wrk.reset(CoolProp::AbstractState::factory("HEOS", fluid));
263 } catch (const std::exception& e) {
264 std::printf("[PXcdj_sweep] %s: factory threw — skipped (%s)\n", fluid.c_str(), e.what());
265 continue;
266 } catch (...) {
267 std::printf("[PXcdj_sweep] %s: factory threw non-std — skipped\n", fluid.c_str());
268 continue;
269 }
270
271 double Tmin = 0, Tmax = 0, pmax = 0, ptriple = 0;
272 try {
273 Tmin = ref->Tmin();
274 Tmax = ref->Tmax();
275 pmax = ref->pmax();
276 } catch (const std::exception& e) {
277 std::printf("[PXcdj_sweep] %s: limits query threw — skipped (%s)\n", fluid.c_str(), e.what());
278 continue;
279 }
280 try {
281 ptriple = ref->p_triple();
282 } catch (...) {
283 ptriple = 0;
284 }
285 const double plo = std::max(std::max(ptriple, pmax * 1e-8), 1.0);
286 if (!(Tmax > Tmin) || !(pmax > plo)) {
287 std::printf("[PXcdj_sweep] %s: degenerate (T,p) bounds — skipped\n", fluid.c_str());
288 continue;
289 }
290
291 const auto T_vec = linspace(Tmin + 0.1, Tmax, NT);
292 const auto p_vec = logspace(plo, pmax, NP);
293
294 // Per-pair counters: total points, successful round-trips.
295 std::size_t pair_total[3] = {0, 0, 0};
296 std::size_t pair_ok[3] = {0, 0, 0};
297 std::size_t fluid_total = 0, fluid_ok = 0;
298
299 for (double T : T_vec) {
300 for (double p : p_vec) {
301 // Establish reference state; skip out-of-domain or two-phase.
302 try {
303 ref->update(CoolProp::PT_INPUTS, p, T);
304 } catch (...) {
305 continue;
306 }
307 if (ref->phase() == CoolProp::iphase_twophase) continue;
308 const double rho_ref = ref->rhomolar();
309 if (!std::isfinite(rho_ref) || rho_ref <= 0) continue;
310
311 for (std::size_t pi = 0; pi < 3; ++pi) {
312 const CoolProp::input_pairs pr = kPairs[pi];
313 double value = _HUGE;
314 try {
315 value = caloric_molar(*ref, pr);
316 } catch (...) {
317 continue;
318 }
319 if (!std::isfinite(value)) continue;
320
321 ++pair_total[pi];
322 ++fluid_total;
323 ++grand_total;
324
325 double best_us = 1e300;
326 bool threw = false;
327 double T_out = std::nan(""), rho_out = std::nan("");
328 for (std::size_t r = 0; r < reps; ++r) {
329 const auto t0 = std::chrono::steady_clock::now();
330 try {
331 if (pr == CoolProp::HmolarP_INPUTS) {
332 wrk->update(pr, value, p); // (h, p)
333 } else {
334 wrk->update(pr, p, value); // (p, s/u)
335 }
336 } catch (...) {
337 threw = true;
338 }
339 const double us = std::chrono::duration<double, std::micro>(std::chrono::steady_clock::now() - t0).count();
340 if (us < best_us) best_us = us;
341 if (threw) break; // no point timing repeats once it threw
342 }
343
344 bool point_ok = false;
345 if (!threw) {
346 try {
347 T_out = wrk->T();
348 rho_out = wrk->rhomolar();
349 } catch (...) {
350 T_out = std::nan("");
351 rho_out = std::nan("");
352 }
353 if (std::isfinite(T_out) && std::isfinite(rho_out) && std::abs(T_out - T) / T < 1e-5
354 && std::abs(rho_out - rho_ref) / rho_ref < 1e-5) {
355 point_ok = true;
356 }
357 }
358 if (point_ok) {
359 ++pair_ok[pi];
360 ++fluid_ok;
361 ++grand_ok;
362 }
363
364 // Always write a row, even on failure / throw. T_solved /
365 // rho_solved are nan on throw; success is 0/1.
366 out << fluid << ',' << pair_label(pr) << ',' << T << ',' << p << ',' << rho_ref << ',' << value << ',' << T_out << ',' << rho_out
367 << ',' << (point_ok ? 1 : 0) << ',' << best_us << '\n';
368 }
369 }
370 }
371
372 ++fluids_completed;
373 std::printf("[PXcdj_sweep] %s: %zu/%zu round-trips (PH:%zu/%zu PS:%zu/%zu PU:%zu/%zu)\n", fluid.c_str(), fluid_ok, fluid_total, pair_ok[0],
374 pair_total[0], pair_ok[1], pair_total[1], pair_ok[2], pair_total[2]);
375 }
376
377 out.close();
378 std::printf("[PXcdj_sweep] wrote %zu rows to %s\n", grand_total, csv_path.c_str());
379 std::printf("[PXcdj_sweep] fluids: %zu attempted, %zu completed; round-trips: %zu/%zu (%.2f%%)\n", fluids_attempted, fluids_completed, grand_ok,
380 grand_total, (grand_total > 0) ? 100.0 * grand_ok / grand_total : 0.0);
381 // The sweep is a characterisation map, not a pass/fail: don't fail the
382 // test when some points fail (e.g. the Nitrogen/PS supercritical-cold
383 // points fixed by PR #3020). The CSV is the artifact of interest.
384}
385
386#endif // ENABLE_CATCH