CoolProp 8.0.0
An open-source fluid property and humid air property database
CoolProp-Tests-Michelsen.cpp
Go to the documentation of this file.
1#if defined(ENABLE_CATCH)
2
5# include "../Backends/Cubics/CubicBackend.h"
6# include "../Backends/Helmholtz/HelmholtzEOSMixtureBackend.h"
7# include <algorithm>
8# include <cmath>
9# include <memory>
10# include <string>
11# include <vector>
12# include <catch2/catch_all.hpp>
14# include "CoolProp/CoolProp.h"
15# include <cmath>
16
17using namespace CoolProp;
18
19namespace {
20// Recompute the equal-fugacity equilibrium residual max_i |ln(f_i^V / f_i^L)| from a
21// converged two-phase flash result, by re-solving each reported phase composition as a
22// single-phase state at (p, T). A correctly converged split drives this to ~0; the
23// #3168 bug published splits with residuals of order 1e-1..1e0. Asserting on this (not
24// just phase()==twophase) is what keeps a grossly-unconverged split from passing CI.
25// Trace components (mole fraction < 1e-4 in either phase) are skipped: their fugacities
26// carry machine-precision noise that would otherwise dominate the max. (Signature is
27// (..., T, p) -- the merge of #3170 and #3174 standardized on this order; see #3174.)
28double equilibrium_residual(const std::string& backend, const std::string& fluids, const std::vector<double>& x, const std::vector<double>& y,
29 double T, double p) {
30 auto L = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
31 L->set_mole_fractions(x);
32 L->specify_phase(iphase_liquid);
33 L->update(PT_INPUTS, p, T);
34 auto V = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
35 V->set_mole_fractions(y);
36 V->specify_phase(iphase_gas);
37 V->update(PT_INPUTS, p, T);
38 double maxresid = 0;
39 for (std::size_t i = 0; i < x.size(); ++i) {
40 if (std::min(x[i], y[i]) < 1e-4) continue; // trace in one phase: skip machine-precision noise
41 double fL = L->fugacity(i), fV = V->fugacity(i);
42 if (fL > 1e-15 && fV > 1e-15) maxresid = std::max(maxresid, std::abs(std::log(fV / fL)));
43 }
44 return maxresid;
45}
46} // namespace
47
48TEST_CASE("Michelsen Flash: Issue #2333 (PR mixture 264.65 K, 6.51 MPa)", "[michelsen][cubic][flash][2333]") {
49 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("PR", "CarbonDioxide&Nitrogen"));
50 std::vector<double> z = {0.97, 0.03};
51 AS->set_mole_fractions(z);
52
53 // This case was previously problematic for cubic mixtures.
54 // It should now converge successfully.
55 CHECK_NOTHROW(AS->update(CoolProp::PT_INPUTS, 6.51e6, 264.65));
56
57 // Check that we got a sensible result (e.g., density is positive)
58 CHECK(AS->rhomolar() > 0);
59}
60
61TEST_CASE("Michelsen Flash: Issue #1668 (PR mixture high pressure)", "[michelsen][cubic][flash][1668]") {
62 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("PR", "Methane&CarbonDioxide"));
63 std::vector<double> z = {0.5, 0.5};
64 AS->set_mole_fractions(z);
65
66 // High pressure update should be robust
67 CHECK_NOTHROW(AS->update(CoolProp::PT_INPUTS, 100e6, 300.0));
68 CHECK(AS->rhomolar() > 0);
69}
70
71TEST_CASE("Michelsen Flash: Issue #2637 (HEOS mixture phase envelope)", "[michelsen][phase_envelope][2637]") {
72 // Phase envelope construction works for HEOS. Cubic backends (PR, SRK)
73 // still hang in build_phase_envelope for this mixture, and
74 // all_critical_points() remains broken — those are open issues.
75 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("HEOS", "Methane&Ethane"));
76 std::vector<double> z = {0.85, 0.15};
77 AS->set_mole_fractions(z);
78
79 CHECK_NOTHROW(AS->build_phase_envelope(""));
80}
81
82TEST_CASE("Michelsen Flash: Multi-component convergence (4-comp mix)", "[michelsen][cubic][flash]") {
83 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("PR", "Methane&Ethane&Propane&n-Decane"));
84 std::vector<double> z = {0.25, 0.25, 0.25, 0.25};
85 AS->set_mole_fractions(z);
86
87 double T = 300.0;
88 double P = 5.0e6;
89
90 CHECK_NOTHROW(AS->update(CoolProp::PT_INPUTS, P, T));
91 CHECK(AS->rhomolar() > 0);
92}
93
94// ============================================================================
95// Benchmark test cases from the literature
96//
97// References:
98// [M82a] Michelsen, M.L., "The isothermal flash problem. Part I. Stability",
99// Fluid Phase Equilibria 9 (1982) 1-19.
100// [M82b] Michelsen, M.L., "The isothermal flash problem. Part II. Phase-split
101// calculation", Fluid Phase Equilibria 9 (1982) 21-40.
102// [MM07] Michelsen, M.L. and Mollerup, J.M., "Thermodynamic Models:
103// Fundamentals & Computational Aspects", 2nd ed., Tie-Line (2007),
104// Chapters 9 (Stability Analysis) and 12 (Flash and Phase Envelope).
105// ============================================================================
106
107TEST_CASE("Michelsen Flash: 7-component natural gas [M82a,M82b]", "[michelsen][flash][benchmark]") {
108 // Composition from Michelsen (1982a) Table 1: synthetic natural gas
109 // z = {C1:0.9430, C2:0.0270, C3:0.0074, nC4:0.0049, nC5:0.0027, nC6:0.0010, N2:0.0140}
110 std::string fluids = "Methane&Ethane&Propane&n-Butane&n-Pentane&n-Hexane&Nitrogen";
111 std::vector<double> z = {0.9430, 0.0270, 0.0074, 0.0049, 0.0027, 0.0010, 0.0140};
112
113 SECTION("SRK two-phase at 190 K, 4 MPa") {
114 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
115 AS->set_mole_fractions(z);
116 CHECK_NOTHROW(AS->update(PT_INPUTS, 4e6, 190.0));
117 CHECK(AS->phase() == iphase_twophase);
118 CHECK(AS->Q() > 0);
119 CHECK(AS->Q() < 1);
120 // #3168: the published split must actually be at equilibrium, not a
121 // grossly-unconverged one accepted on phase()/Q() alone.
122 CHECK(equilibrium_residual("SRK", fluids, AS->mole_fractions_liquid_double(), AS->mole_fractions_vapor_double(), 190.0, 4e6) < 1e-6);
123 }
124 SECTION("PR two-phase at 190 K, 4 MPa") {
125 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
126 AS->set_mole_fractions(z);
127 CHECK_NOTHROW(AS->update(PT_INPUTS, 4e6, 190.0));
128 CHECK(AS->phase() == iphase_twophase);
129 CHECK(AS->Q() > 0);
130 CHECK(AS->Q() < 1);
131 CHECK(equilibrium_residual("PR", fluids, AS->mole_fractions_liquid_double(), AS->mole_fractions_vapor_double(), 190.0, 4e6) < 1e-6);
132 }
133 SECTION("SRK stable gas at 250 K, 1 MPa") {
134 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
135 AS->set_mole_fractions(z);
136 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 250.0));
137 CHECK(AS->rhomolar() > 0);
138 }
139 SECTION("PR stable gas at 250 K, 1 MPa") {
140 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
141 AS->set_mole_fractions(z);
142 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 250.0));
143 CHECK(AS->rhomolar() > 0);
144 }
145}
146
147TEST_CASE("Michelsen Flash: CH4/CO2 binary [MM07 Ch.9]", "[michelsen][flash][benchmark]") {
148 // 50/50 Methane/CO2 -- classic binary from Michelsen & Mollerup (2007) Ch. 9
149 // Mixture critical point near Tc~252 K, Pc~8.5 MPa (SRK estimate)
150 std::string fluids = "Methane&CarbonDioxide";
151 std::vector<double> z = {0.5, 0.5};
152
153 SECTION("SRK two-phase at 220 K, 3 MPa") {
154 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
155 AS->set_mole_fractions(z);
156 CHECK_NOTHROW(AS->update(PT_INPUTS, 3e6, 220.0));
157 CHECK(AS->phase() == iphase_twophase);
158 }
159 SECTION("PR two-phase at 220 K, 3 MPa") {
160 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
161 AS->set_mole_fractions(z);
162 CHECK_NOTHROW(AS->update(PT_INPUTS, 3e6, 220.0));
163 CHECK(AS->phase() == iphase_twophase);
164 }
165 SECTION("HEOS two-phase at 220 K, 3 MPa") {
166 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
167 AS->set_mole_fractions(z);
168 CHECK_NOTHROW(AS->update(PT_INPUTS, 3e6, 220.0));
169 CHECK(AS->phase() == iphase_twophase);
170 }
171 SECTION("SRK stable supercritical at 300 K, 10 MPa") {
172 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
173 AS->set_mole_fractions(z);
174 CHECK_NOTHROW(AS->update(PT_INPUTS, 10e6, 300.0));
175 CHECK(AS->rhomolar() > 0);
176 }
177 SECTION("PR stable supercritical at 300 K, 10 MPa") {
178 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
179 AS->set_mole_fractions(z);
180 CHECK_NOTHROW(AS->update(PT_INPUTS, 10e6, 300.0));
181 CHECK(AS->rhomolar() > 0);
182 }
183}
184
185TEST_CASE("Michelsen Flash: CH4/C2H6/CO2 ternary [MM07 Ch.9]", "[michelsen][flash][benchmark]") {
186 // 30/30/40 Methane/Ethane/CO2 -- ternary from Michelsen & Mollerup (2007) Ch. 9
187 std::string fluids = "Methane&Ethane&CarbonDioxide";
188 std::vector<double> z = {0.3, 0.3, 0.4};
189
190 SECTION("SRK two-phase at 220 K, 2 MPa") {
191 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
192 AS->set_mole_fractions(z);
193 CHECK_NOTHROW(AS->update(PT_INPUTS, 2e6, 220.0));
194 CHECK(AS->phase() == iphase_twophase);
195 }
196 SECTION("PR two-phase at 220 K, 2 MPa") {
197 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
198 AS->set_mole_fractions(z);
199 CHECK_NOTHROW(AS->update(PT_INPUTS, 2e6, 220.0));
200 CHECK(AS->phase() == iphase_twophase);
201 }
202 SECTION("HEOS two-phase at 220 K, 2 MPa") {
203 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
204 AS->set_mole_fractions(z);
205 CHECK_NOTHROW(AS->update(PT_INPUTS, 2e6, 220.0));
206 CHECK(AS->phase() == iphase_twophase);
207 }
208 SECTION("SRK stable gas at 300 K, 1 MPa") {
209 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
210 AS->set_mole_fractions(z);
211 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 300.0));
212 CHECK(AS->rhomolar() > 0);
213 }
214 SECTION("PR stable gas at 300 K, 1 MPa") {
215 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
216 AS->set_mole_fractions(z);
217 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 300.0));
218 CHECK(AS->rhomolar() > 0);
219 }
220}
221
222TEST_CASE("Michelsen Flash: CH4/n-C10 wide-boiling binary", "[michelsen][flash][benchmark]") {
223 // Wide-boiling pair: Tc(CH4)=190.6K vs Tc(nC10)=617.7K
224 // Stress test for log-K storage -- K-factors span several orders of magnitude
225 // See [M82b] Section 5 discussion on wide-boiling mixtures
226 std::string fluids = "Methane&n-Decane";
227 std::vector<double> z = {0.7, 0.3};
228
229 SECTION("SRK two-phase at 350 K, 5 MPa") {
230 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
231 AS->set_mole_fractions(z);
232 CHECK_NOTHROW(AS->update(PT_INPUTS, 5e6, 350.0));
233 CHECK(AS->phase() == iphase_twophase);
234 }
235 SECTION("PR two-phase at 350 K, 5 MPa") {
236 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
237 AS->set_mole_fractions(z);
238 CHECK_NOTHROW(AS->update(PT_INPUTS, 5e6, 350.0));
239 CHECK(AS->phase() == iphase_twophase);
240 }
241 SECTION("SRK two-phase at 300 K, 3 MPa (large K-spread)") {
242 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
243 AS->set_mole_fractions(z);
244 CHECK_NOTHROW(AS->update(PT_INPUTS, 3e6, 300.0));
245 CHECK(AS->phase() == iphase_twophase);
246 }
247 SECTION("PR two-phase at 300 K, 3 MPa (large K-spread)") {
248 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
249 AS->set_mole_fractions(z);
250 CHECK_NOTHROW(AS->update(PT_INPUTS, 3e6, 300.0));
251 CHECK(AS->phase() == iphase_twophase);
252 }
253}
254
255TEST_CASE("Michelsen Flash: CH4/H2S binary", "[michelsen][flash][benchmark]") {
256 // CH4/H2S system -- known to exhibit three-phase (VLLE) behavior at some
257 // conditions. This tests that the two-phase VLE flash converges robustly
258 // in the classical VLE region.
259 // See: Heidemann, R.A. and Khalil, A.M., AIChE J. 26 (1980) 769-779.
260 std::string fluids = "Methane&HydrogenSulfide";
261 std::vector<double> z = {0.5, 0.5};
262
263 SECTION("SRK two-phase at 220 K, 2 MPa") {
264 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
265 AS->set_mole_fractions(z);
266 CHECK_NOTHROW(AS->update(PT_INPUTS, 2e6, 220.0));
267 CHECK(AS->phase() == iphase_twophase);
268 }
269 SECTION("PR two-phase at 220 K, 2 MPa") {
270 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
271 AS->set_mole_fractions(z);
272 CHECK_NOTHROW(AS->update(PT_INPUTS, 2e6, 220.0));
273 CHECK(AS->phase() == iphase_twophase);
274 }
275}
276
277TEST_CASE("Michelsen Flash: CH4/C2H6 near mixture critical [M82a]", "[michelsen][flash][benchmark]") {
278 // Near the mixture critical point, successive substitution converges slowly
279 // and second-order methods (GDEM acceleration, Newton with trust region)
280 // are essential. See [M82a] Section 4.
281 std::string fluids = "Methane&Ethane";
282 std::vector<double> z = {0.5, 0.5};
283
284 SECTION("SRK two-phase at 230 K, 4 MPa") {
285 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
286 AS->set_mole_fractions(z);
287 CHECK_NOTHROW(AS->update(PT_INPUTS, 4e6, 230.0));
288 CHECK(AS->phase() == iphase_twophase);
289 }
290 SECTION("PR two-phase at 230 K, 4 MPa") {
291 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
292 AS->set_mole_fractions(z);
293 CHECK_NOTHROW(AS->update(PT_INPUTS, 4e6, 230.0));
294 CHECK(AS->phase() == iphase_twophase);
295 }
296}
297
298TEST_CASE("Michelsen Flash: SRK vs PR cross-validation [M82b]", "[michelsen][flash][benchmark]") {
299 // Both cubic EOS should agree on phase identification for the same
300 // conditions, even though densities and thermodynamic properties differ.
301 // See [M82b] for the general methodology applied to cubic EOS.
302 std::string fluids = "Methane&Propane";
303 std::vector<double> z = {0.6, 0.4};
304
305 auto AS_srk = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
306 auto AS_pr = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
307 AS_srk->set_mole_fractions(z);
308 AS_pr->set_mole_fractions(z);
309
310 SECTION("Two-phase at 250 K, 3 MPa") {
311 CHECK_NOTHROW(AS_srk->update(PT_INPUTS, 3e6, 250.0));
312 CHECK_NOTHROW(AS_pr->update(PT_INPUTS, 3e6, 250.0));
313 CHECK(AS_srk->phase() == iphase_twophase);
314 CHECK(AS_pr->phase() == iphase_twophase);
315 // Vapor fractions should be qualitatively similar
316 CHECK(AS_srk->Q() > 0);
317 CHECK(AS_pr->Q() > 0);
318 }
319}
320
321TEST_CASE("Michelsen Flash: N2/CH4 cryogenic binary", "[michelsen][flash][benchmark]") {
322 // Nitrogen/Methane is relevant for LNG and air separation.
323 // Tests flash at cryogenic conditions where both components are near
324 // or below their critical temperatures.
325 std::string fluids = "Nitrogen&Methane";
326 std::vector<double> z = {0.2, 0.8};
327
328 SECTION("SRK stable gas at 200 K, 1 MPa") {
329 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
330 AS->set_mole_fractions(z);
331 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 200.0));
332 CHECK(AS->rhomolar() > 0);
333 }
334 SECTION("PR stable gas at 200 K, 1 MPa") {
335 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
336 AS->set_mole_fractions(z);
337 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e6, 200.0));
338 CHECK(AS->rhomolar() > 0);
339 }
340}
341
342TEST_CASE("Michelsen Flash: 11-comp PR hang at high pressure", "[michelsen][cubic][flash][benchmark]") {
343 // 11-component natural gas with PR backend hangs at T=520K, P=10.13 MPa.
344 // SRK works fine. The issue is the cubic blind flash takes excessive time
345 // or throws "cubic has three roots, but phase not imposed".
346 std::string fluids = "Methane&Nitrogen&CO2&Ethane&Propane&Isobutane&Butane&Isopentane&Pentane&Hexane&Heptane";
347 std::vector<double> z = {0.9092, 0.0271, 0.0018, 0.0386, 0.011, 0.0037, 0.0037, 0.00135, 0.00135, 0.0008, 0.0014};
348
349 SECTION("PR at 520 K, 1 MPa (works)") {
350 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
351 AS->set_mole_fractions(z);
352 CHECK_NOTHROW(AS->update(PT_INPUTS, 999999.0, 520.0));
353 CHECK(AS->rhomolar() > 0);
354 }
355 SECTION("SRK at 520 K, 10.13 MPa (works)") {
356 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", fluids));
357 AS->set_mole_fractions(z);
358 CHECK_NOTHROW(AS->update(PT_INPUTS, 10132500.0, 520.0));
359 CHECK(AS->rhomolar() > 0);
360 }
361 SECTION("PR at 520 K, 10.13 MPa (previously hung)") {
362 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", fluids));
363 AS->set_mole_fractions(z);
364 CHECK_NOTHROW(AS->update(PT_INPUTS, 10132500.0, 520.0));
365 CHECK(AS->rhomolar() > 0);
366 }
367}
368
369TEST_CASE("Michelsen Flash: Issue #3066 (8-comp natural gas density failure)", "[michelsen][flash][benchmark][3066]") {
370 // 8-component natural gas at 290.15K fails with "No density solutions"
371 // for pressures between ~34.2 and ~35.5 bar because solver_rho_Tp_global encounters
372 // an S-shaped isotherm with the target pressure between the spinodal pressures.
373 // The fix falls back to SRK-seeded Newton solver (solver_rho_Tp) in this case.
374 std::string fluids = "Methane&Nitrogen&CO2&Ethane&Propane&n-Butane&n-Pentane&IsoButane";
375 std::vector<double> z = {0.9254, 0.007, 0.008, 0.048, 0.0085, 0.0014, 0.0002, 0.0015};
376
377 SECTION("HEOS at 290.15 K, 35.0 bar (previously failed)") {
378 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
379 AS->set_mole_fractions(z);
380 CHECK_NOTHROW(AS->update(PT_INPUTS, 35.0e5, 290.15));
381 CHECK(AS->rhomolar() > 0);
382 // Density should be gas-like (~1500 mol/m3), consistent with SRK/PR results
383 CHECK(AS->rhomolar() > 1000);
384 CHECK(AS->rhomolar() < 3000);
385 }
386 SECTION("HEOS at 290.15 K, 34.5 bar (previously failed)") {
387 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
388 AS->set_mole_fractions(z);
389 CHECK_NOTHROW(AS->update(PT_INPUTS, 34.5e5, 290.15));
390 CHECK(AS->rhomolar() > 0);
391 }
392 SECTION("HEOS at 290.15 K, 34.2 bar (boundary, previously worked)") {
393 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
394 AS->set_mole_fractions(z);
395 CHECK_NOTHROW(AS->update(PT_INPUTS, 34.2e5, 290.15));
396 CHECK(AS->rhomolar() > 0);
397 }
398 SECTION("HEOS at 290.15 K, 35.5 bar (boundary, previously worked)") {
399 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
400 AS->set_mole_fractions(z);
401 CHECK_NOTHROW(AS->update(PT_INPUTS, 35.5e5, 290.15));
402 CHECK(AS->rhomolar() > 0);
403 }
404}
405
406TEST_CASE("Legacy Stability: check that legacy algorithm still works", "[stability][legacy]") {
407 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("HEOS", "Methane&Ethane"));
408 std::vector<double> z = {0.5, 0.5};
409 AS->set_mole_fractions(z);
410
411 // Force legacy algorithm via configuration
412 CoolProp::set_config_int(MIXTURE_STABILITY_ALGORITHM, 0);
413
414 // Methane/Ethane at 200K, 1MPa is stable single-phase
415 CHECK_NOTHROW(AS->update(CoolProp::PT_INPUTS, 1e6, 200.0));
416
417 // Reset to default
418 CoolProp::set_config_int(MIXTURE_STABILITY_ALGORITHM, 1);
419}
420
421TEST_CASE("Blind PT flash: Methane/Ethane [0.5/0.5]", "[michelsen][blind][flash]") {
422 std::vector<double> z = {0.5, 0.5};
423 SECTION("SRK gas T=300 P=1e5") {
424 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Ethane"));
425 AS->set_mole_fractions(z);
426 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
427 CHECK(AS->Q() == -1);
428 CHECK(AS->rhomolar() > 0);
429 }
430 SECTION("SRK 2ph T=150 P=1e5") {
431 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Ethane"));
432 AS->set_mole_fractions(z);
433 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
434 CHECK(AS->Q() >= 0);
435 CHECK(AS->Q() <= 1);
436 }
437 SECTION("SRK liquid T=100 P=1e5") {
438 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Ethane"));
439 AS->set_mole_fractions(z);
440 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
441 CHECK(AS->Q() == -1);
442 CHECK(AS->rhomolar() > 0);
443 }
444 SECTION("PR gas T=300 P=1e5") {
445 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Ethane"));
446 AS->set_mole_fractions(z);
447 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
448 CHECK(AS->Q() == -1);
449 CHECK(AS->rhomolar() > 0);
450 }
451 SECTION("PR 2ph T=150 P=1e5") {
452 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Ethane"));
453 AS->set_mole_fractions(z);
454 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
455 CHECK(AS->Q() >= 0);
456 CHECK(AS->Q() <= 1);
457 }
458 SECTION("PR liquid T=100 P=1e5") {
459 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Ethane"));
460 AS->set_mole_fractions(z);
461 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
462 CHECK(AS->Q() == -1);
463 CHECK(AS->rhomolar() > 0);
464 }
465 SECTION("HEOS gas T=300 P=1e5") {
466 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
467 AS->set_mole_fractions(z);
468 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
469 CHECK(AS->Q() == -1);
470 CHECK(AS->rhomolar() > 0);
471 }
472 SECTION("HEOS 2ph T=150 P=1e5") {
473 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
474 AS->set_mole_fractions(z);
475 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
476 CHECK(AS->Q() >= 0);
477 CHECK(AS->Q() <= 1);
478 }
479 SECTION("HEOS liquid T=100 P=1e5") {
480 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
481 AS->set_mole_fractions(z);
482 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
483 CHECK(AS->Q() == -1);
484 CHECK(AS->rhomolar() > 0);
485 }
486}
487
488TEST_CASE("Blind PT flash: Methane/Propane [0.7/0.3]", "[michelsen][blind][flash]") {
489 std::vector<double> z = {0.7, 0.3};
490 SECTION("SRK gas T=300 P=1e5") {
491 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Propane"));
492 AS->set_mole_fractions(z);
493 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
494 CHECK(AS->Q() == -1);
495 CHECK(AS->rhomolar() > 0);
496 }
497 SECTION("SRK 2ph T=150 P=1e5") {
498 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Propane"));
499 AS->set_mole_fractions(z);
500 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
501 CHECK(AS->Q() >= 0);
502 CHECK(AS->Q() <= 1);
503 }
504 SECTION("SRK liquid T=100 P=1e5") {
505 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Methane&Propane"));
506 AS->set_mole_fractions(z);
507 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
508 CHECK(AS->Q() == -1);
509 CHECK(AS->rhomolar() > 0);
510 }
511 SECTION("PR gas T=300 P=1e5") {
512 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Propane"));
513 AS->set_mole_fractions(z);
514 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
515 CHECK(AS->Q() == -1);
516 CHECK(AS->rhomolar() > 0);
517 }
518 SECTION("PR 2ph T=150 P=1e5") {
519 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Propane"));
520 AS->set_mole_fractions(z);
521 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
522 CHECK(AS->Q() >= 0);
523 CHECK(AS->Q() <= 1);
524 }
525 SECTION("PR liquid T=100 P=1e5") {
526 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Methane&Propane"));
527 AS->set_mole_fractions(z);
528 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
529 CHECK(AS->Q() == -1);
530 CHECK(AS->rhomolar() > 0);
531 }
532 SECTION("HEOS gas T=300 P=1e5") {
533 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Propane"));
534 AS->set_mole_fractions(z);
535 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
536 CHECK(AS->Q() == -1);
537 CHECK(AS->rhomolar() > 0);
538 }
539 SECTION("HEOS 2ph T=150 P=1e5") {
540 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Propane"));
541 AS->set_mole_fractions(z);
542 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
543 CHECK(AS->Q() >= 0);
544 CHECK(AS->Q() <= 1);
545 }
546 SECTION("HEOS liquid T=100 P=1e5") {
547 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Propane"));
548 AS->set_mole_fractions(z);
549 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
550 CHECK(AS->Q() == -1);
551 CHECK(AS->rhomolar() > 0);
552 }
553}
554
555TEST_CASE("Blind PT flash: Nitrogen/Oxygen [0.79/0.21]", "[michelsen][blind][flash]") {
556 std::vector<double> z = {0.79, 0.21};
557 SECTION("SRK gas T=300 P=1e5") {
558 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Oxygen"));
559 AS->set_mole_fractions(z);
560 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
561 CHECK(AS->Q() == -1);
562 CHECK(AS->rhomolar() > 0);
563 }
564 SECTION("SRK 2ph T=80 P=1e5") {
565 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Oxygen"));
566 AS->set_mole_fractions(z);
567 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
568 CHECK(AS->Q() >= 0);
569 CHECK(AS->Q() <= 1);
570 }
571 SECTION("SRK liquid T=75 P=1e5") {
572 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Oxygen"));
573 AS->set_mole_fractions(z);
574 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 75.0));
575 CHECK(AS->Q() == -1);
576 CHECK(AS->rhomolar() > 0);
577 }
578 SECTION("PR gas T=300 P=1e5") {
579 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Oxygen"));
580 AS->set_mole_fractions(z);
581 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
582 CHECK(AS->Q() == -1);
583 CHECK(AS->rhomolar() > 0);
584 }
585 SECTION("PR 2ph T=80 P=1e5") {
586 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Oxygen"));
587 AS->set_mole_fractions(z);
588 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
589 CHECK(AS->Q() >= 0);
590 CHECK(AS->Q() <= 1);
591 }
592 SECTION("PR liquid T=75 P=1e5") {
593 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Oxygen"));
594 AS->set_mole_fractions(z);
595 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 75.0));
596 CHECK(AS->Q() == -1);
597 CHECK(AS->rhomolar() > 0);
598 }
599 SECTION("HEOS gas T=300 P=1e5") {
600 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
601 AS->set_mole_fractions(z);
602 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
603 CHECK(AS->Q() == -1);
604 CHECK(AS->rhomolar() > 0);
605 }
606 SECTION("HEOS 2ph T=80 P=1e5") {
607 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
608 AS->set_mole_fractions(z);
609 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
610 CHECK(AS->Q() >= 0);
611 CHECK(AS->Q() <= 1);
612 }
613 SECTION("HEOS liquid T=75 P=1e5") {
614 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
615 AS->set_mole_fractions(z);
616 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 75.0));
617 CHECK(AS->Q() == -1);
618 CHECK(AS->rhomolar() > 20000); // liquid density — Gibbs selection picks liquid root
619 }
620}
621
622TEST_CASE("Blind flash: N2/O2 subcooled liquid phase selection", "[michelsen][blind][flash]") {
623 // N2/O2 at 75K, 1 bar: below bubble point (78.76 K).
624 // Previously the blind flash selected the gas root because SRK
625 // tried gas first and both roots converged. The Gibbs-comparison
626 // fix selects the thermodynamically stable liquid root.
627 std::vector<double> z = {0.79, 0.21};
628
629 auto AS = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
630 AS->set_mole_fractions(z);
631 AS->update(CoolProp::PT_INPUTS, 1e5, 75.0);
632 CHECK(AS->Q() == -1);
633 CHECK(AS->phase() == CoolProp::iphase_liquid);
634 CHECK(AS->rhomolar() > 20000); // liquid density
635
636 SECTION("Imposed liquid agrees with blind") {
637 auto AS_imp = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
638 AS_imp->set_mole_fractions(z);
639 AS_imp->specify_phase(CoolProp::iphase_liquid);
640 AS_imp->update(CoolProp::PT_INPUTS, 1e5, 75.0);
641 AS_imp->unspecify_phase();
642 CHECK(AS_imp->rhomolar() > 20000);
643 CHECK(AS_imp->rhomolar() == Catch::Approx(AS->rhomolar()).epsilon(1e-6));
644 }
645
646 SECTION("Imposed gas gives low density") {
647 auto AS_imp = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
648 AS_imp->set_mole_fractions(z);
649 AS_imp->specify_phase(CoolProp::iphase_gas);
650 AS_imp->update(CoolProp::PT_INPUTS, 1e5, 75.0);
651 AS_imp->unspecify_phase();
652 CHECK(AS_imp->rhomolar() < 500); // gas-like density (metastable)
653 }
654}
655
656TEST_CASE("Blind PT flash: N2/CH4/C2H6/C3H8 [0.1/0.5/0.25/0.15]", "[michelsen][blind][flash]") {
657 std::vector<double> z = {0.1, 0.5, 0.25, 0.15};
658 SECTION("SRK gas T=300 P=1e5") {
659 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Methane&Ethane&Propane"));
660 AS->set_mole_fractions(z);
661 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
662 CHECK(AS->Q() == -1);
663 CHECK(AS->rhomolar() > 0);
664 }
665 SECTION("SRK 2ph T=145 P=1e5") {
666 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Methane&Ethane&Propane"));
667 AS->set_mole_fractions(z);
668 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 145.0));
669 CHECK(AS->Q() >= 0);
670 CHECK(AS->Q() <= 1);
671 }
672 SECTION("SRK liquid T=80 P=1e5") {
673 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("SRK", "Nitrogen&Methane&Ethane&Propane"));
674 AS->set_mole_fractions(z);
675 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
676 CHECK(AS->Q() == -1);
677 CHECK(AS->rhomolar() > 0);
678 }
679 SECTION("PR gas T=300 P=1e5") {
680 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Methane&Ethane&Propane"));
681 AS->set_mole_fractions(z);
682 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
683 CHECK(AS->Q() == -1);
684 CHECK(AS->rhomolar() > 0);
685 }
686 SECTION("PR 2ph T=145 P=1e5") {
687 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Methane&Ethane&Propane"));
688 AS->set_mole_fractions(z);
689 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 145.0));
690 CHECK(AS->Q() >= 0);
691 CHECK(AS->Q() <= 1);
692 }
693 SECTION("PR liquid T=80 P=1e5") {
694 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("PR", "Nitrogen&Methane&Ethane&Propane"));
695 AS->set_mole_fractions(z);
696 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
697 CHECK(AS->Q() == -1);
698 CHECK(AS->rhomolar() > 0);
699 }
700 SECTION("HEOS gas T=300 P=1e5") {
701 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane&Ethane&Propane"));
702 AS->set_mole_fractions(z);
703 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
704 CHECK(AS->Q() == -1);
705 CHECK(AS->rhomolar() > 0);
706 }
707 SECTION("HEOS 2ph T=145 P=1e5") {
708 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane&Ethane&Propane"));
709 AS->set_mole_fractions(z);
710 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 145.0));
711 CHECK(AS->Q() >= 0);
712 CHECK(AS->Q() <= 1);
713 }
714 SECTION("HEOS liquid T=80 P=1e5") {
715 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane&Ethane&Propane"));
716 AS->set_mole_fractions(z);
717 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
718 CHECK(AS->Q() == -1);
719 CHECK(AS->rhomolar() > 0);
720 }
721}
722
723TEST_CASE("Legacy Flash: check that legacy Jacobian solver still works", "[flash][legacy]") {
724 std::shared_ptr<CoolProp::AbstractState> AS(CoolProp::AbstractState::factory("HEOS", "Methane&Ethane"));
725 std::vector<double> z = {0.5, 0.5};
726 AS->set_mole_fractions(z);
727
728 // Force legacy algorithm via configuration
729 CoolProp::set_config_int(MIXTURE_STABILITY_ALGORITHM, 0);
730
731 // Methane/Ethane at 200K, 1MPa is stable single-phase
732 // For HEOS it might go through PTflash_twophase if it thinks it is twophase
733 // Let's use a state that is definitely twophase to exercise the solver
734 // T = 180K, P = 1MPa for 50/50 Methane/Ethane
735 CHECK_NOTHROW(AS->update(CoolProp::PT_INPUTS, 1e6, 180.0));
736 CHECK(AS->phase() == CoolProp::iphase_twophase);
737
738 // Reset to default
739 CoolProp::set_config_int(MIXTURE_STABILITY_ALGORITHM, 1);
740}
741
742TEST_CASE("PT flash with built phase envelope", "[michelsen][phase_envelope]") {
743 // Methane(0.85)/Ethane(0.15) mixture — build the phase envelope first,
744 // then verify that envelope-guided PT flash produces the same results
745 // as blind flash (no envelope).
746
747 std::vector<double> z = {0.85, 0.15};
748
749 // Build envelope once
750 auto AS_env = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Methane&Ethane"));
751 AS_env->set_mole_fractions(z);
752 AS_env->build_phase_envelope("");
753
754 // Separate instance without envelope for cross-check
755 auto AS_blind = std::shared_ptr<CoolProp::AbstractState>(CoolProp::AbstractState::factory("HEOS", "Methane&Ethane"));
756 AS_blind->set_mole_fractions(z);
757
758 SECTION("Gas side: 1 MPa, 250 K") {
759 AS_env->update(CoolProp::PT_INPUTS, 1e6, 250.0);
760 AS_blind->update(CoolProp::PT_INPUTS, 1e6, 250.0);
761
762 CHECK(AS_env->Q() == -1);
763 CHECK(AS_env->phase() == CoolProp::iphase_gas);
764 CHECK(AS_env->rhomolar() == Catch::Approx(AS_blind->rhomolar()).epsilon(1e-6));
765 }
766
767 SECTION("Liquid side: 3 MPa, 120 K") {
768 AS_env->update(CoolProp::PT_INPUTS, 3e6, 120.0);
769 AS_blind->update(CoolProp::PT_INPUTS, 3e6, 120.0);
770
771 CHECK(AS_env->Q() == -1);
772 CHECK(AS_env->phase() == CoolProp::iphase_liquid);
773 // The GERG-2008 EOS has two liquid-like roots at this state point.
774 // The PE path and the Gibbs-comparing blind flash may converge to
775 // different roots (~0.6% apart). Both are liquid; just verify that.
776 CHECK(AS_blind->phase() == CoolProp::iphase_liquid);
777 CHECK(AS_env->rhomolar() > 20000);
778 CHECK(AS_blind->rhomolar() > 20000);
779 }
780
781 SECTION("Two-phase: 2 MPa, 180 K") {
782 AS_env->update(CoolProp::PT_INPUTS, 2e6, 180.0);
783 AS_blind->update(CoolProp::PT_INPUTS, 2e6, 180.0);
784
785 CHECK(AS_env->phase() == CoolProp::iphase_twophase);
786 // Q values may have swapped phase labeling (Q_env + Q_blind ≈ 1) which is
787 // thermodynamically equivalent. Check that Q is valid and overall density agrees.
788 CHECK(AS_env->Q() > 0.0);
789 CHECK(AS_env->Q() < 1.0);
790 CHECK(AS_env->rhomolar() == Catch::Approx(AS_blind->rhomolar()).epsilon(1e-4));
791 }
792}
793
794TEST_CASE("PQ/QT flash with built envelope at 0<Q<1 (GH #3192)", "[michelsen][phase_envelope]") {
795 // Regression for GH #3192. With a built phase envelope a mixture PQ/QT flash for a
796 // genuine two-phase state (0 < Q < 1) used to route to newton_raphson_twophase, whose
797 // residual vectors r/err_rel were Eigen::Vector2d (fixed size 2) but need 2N-1 (>=3)
798 // entries -> out-of-bounds write -> HARD CRASH (process abort, not a C++ exception, so it
799 // cannot be caught — pre-fix this test binary terminates here). The two-phase solver is
800 // now hardened (step damping + non-finite/convergence guards), so the envelope fast path
801 // is used and must (a) not crash and (b) match the blind (no-envelope) flash closely; any
802 // solver failure falls back to the blind path.
803 std::vector<double> z = {0.5, 0.5};
804
805 auto make_pe = [&]() {
806 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
807 AS->set_mole_fractions(z);
808 AS->build_phase_envelope("");
809 return AS;
810 };
811 auto make_blind = [&]() {
812 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
813 AS->set_mole_fractions(z);
814 return AS;
815 };
816
817 SECTION("PQ flash, P=1e5, Q=0.5 (exact reporter repro)") {
818 auto AS = make_pe();
819 auto ref = make_blind();
820 // Guard: the fix is only exercised if the envelope is actually built; otherwise the
821 // dispatch already falls through to the blind branch and the test is vacuous.
822 REQUIRE(AS->get_phase_envelope_data().built);
823 CHECK_NOTHROW(AS->update(PQ_INPUTS, 1e5, 0.5));
824 ref->update(PQ_INPUTS, 1e5, 0.5);
825 CHECK(AS->phase() == iphase_twophase);
826 CHECK(std::isfinite(AS->T()));
827 CHECK(AS->T() == Catch::Approx(ref->T()).epsilon(1e-6));
828 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
829 }
830
831 SECTION("QT flash, Q=0.5, T=180 K") {
832 auto AS = make_pe();
833 auto ref = make_blind();
834 REQUIRE(AS->get_phase_envelope_data().built);
835 CHECK_NOTHROW(AS->update(QT_INPUTS, 0.5, 180.0));
836 ref->update(QT_INPUTS, 0.5, 180.0);
837 CHECK(AS->phase() == iphase_twophase);
838 CHECK(AS->p() == Catch::Approx(ref->p()).epsilon(1e-6));
839 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
840 }
841
842 SECTION("Dew/bubble (Q==0, Q==1) still use the envelope fast path and stay accurate") {
843 auto AS = make_pe();
844 auto ref = make_blind();
845 REQUIRE(AS->get_phase_envelope_data().built);
846 CHECK_NOTHROW(AS->update(PQ_INPUTS, 1e5, 1.0)); // dew point
847 ref->update(PQ_INPUTS, 1e5, 1.0);
848 CHECK(AS->T() == Catch::Approx(ref->T()).epsilon(1e-6));
849 CHECK_NOTHROW(AS->update(PQ_INPUTS, 1e5, 0.0)); // bubble point
850 ref->update(PQ_INPUTS, 1e5, 0.0);
851 CHECK(AS->T() == Catch::Approx(ref->T()).epsilon(1e-6));
852 }
853
854 SECTION("pq -> pt -> q roundtrip recovers the imposed quality") {
855 // The reporter's accuracy metric: a two-phase PQ flash followed by a PT flash at the
856 // recovered T must return the original quality. Before the rework the envelope branch
857 // returned its unconverged interpolation guess and the roundtrip was off by ~0.4%.
858 auto AS = make_pe();
859 REQUIRE(AS->get_phase_envelope_data().built);
860 AS->update(PQ_INPUTS, 1e5, 0.5);
861 const double T_2ph = AS->T();
862 auto rt = make_blind();
863 rt->update(PT_INPUTS, 1e5, T_2ph);
864 CHECK(rt->Q() == Catch::Approx(0.5).epsilon(1e-4));
865 }
866}
867
868TEST_CASE("PQ flash with built envelope, ternary two-phase (GH #3192, N>=3)", "[michelsen][phase_envelope]") {
869 // Locks in N>=3. The Newton-step damping bounds the dependent mole fraction x[N-1]=1-sum as
870 // well as the N-1 independent ones, so a ternary two-phase PQ flash must converge through the
871 // envelope path and match the blind solver (verified during review to ~1e-9 across 0.5-4 MPa).
872 std::vector<double> z = {0.4, 0.35, 0.25};
873 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane&Propane"));
874 AS->set_mole_fractions(z);
875 AS->build_phase_envelope("");
876 // Guard against vacuous coverage: if the envelope did not build, the dispatch would silently
877 // use the blind path and this test would no longer exercise the envelope-guided solver.
878 REQUIRE(AS->get_phase_envelope_data().built);
879 auto ref = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane&Propane"));
880 ref->set_mole_fractions(z);
881
882 CHECK_NOTHROW(AS->update(PQ_INPUTS, 1e6, 0.5));
883 ref->update(PQ_INPUTS, 1e6, 0.5);
884 CHECK(AS->phase() == iphase_twophase);
885 CHECK(std::isfinite(AS->T()));
886 CHECK(AS->T() == Catch::Approx(ref->T()).epsilon(1e-5));
887 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-5));
888}
889
890// ============================================================================
891// Phase-envelope-guided PT flash tests
892//
893// Test conditions from jakobreichert. For each mixture the envelope is built
894// first, then PT flash is performed for gas / liquid / two-phase conditions.
895// Results are cross-checked against the blind flash (no envelope).
896//
897// Mixtures whose envelopes are not closed (Methane&Propane) or not fully built
898// (4-component) exercise the fall-through to the blind flash path. Mixtures
899// with closed envelopes (Methane&Ethane, Nitrogen&Oxygen) exercise the
900// envelope-guided code path.
901// ============================================================================
902
903TEST_CASE("PE flash: Methane/Ethane [0.5/0.5]", "[michelsen][phase_envelope]") {
904 // Envelope closed — exercises the full PE code path for gas, liquid, two-phase
905 std::vector<double> z = {0.5, 0.5};
906
907 auto make_pe = [&]() {
908 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
909 AS->set_mole_fractions(z);
910 AS->build_phase_envelope("");
911 return AS;
912 };
913 auto make_blind = [&]() {
914 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Ethane"));
915 AS->set_mole_fractions(z);
916 return AS;
917 };
918
919 SECTION("HEOS gas T=300 P=1e5") {
920 auto AS = make_pe();
921 auto ref = make_blind();
922 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
923 ref->update(PT_INPUTS, 1e5, 300.0);
924 CHECK(AS->Q() == -1);
925 CHECK(AS->rhomolar() > 0);
926 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
927 }
928 SECTION("HEOS 2ph T=150 P=1e5") {
929 auto AS = make_pe();
930 auto ref = make_blind();
931 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
932 ref->update(PT_INPUTS, 1e5, 150.0);
933 CHECK(AS->phase() == iphase_twophase);
934 CHECK(AS->Q() > 0);
935 CHECK(AS->Q() < 1);
936 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-4));
937 }
938 SECTION("HEOS liquid T=100 P=1e5") {
939 auto AS = make_pe();
940 auto ref = make_blind();
941 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
942 ref->update(PT_INPUTS, 1e5, 100.0);
943 CHECK(AS->Q() == -1);
944 CHECK(AS->rhomolar() > 0);
945 // PE and blind may converge to slightly different densities (~0.03%) because
946 // the phase hint from the envelope steers the HEOS solver differently.
947 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-3));
948 }
949}
950
951TEST_CASE("PE flash: Methane/Propane [0.7/0.3]", "[michelsen][phase_envelope]") {
952 // Envelope built but NOT closed (max_fraction exit) — falls through to blind flash.
953 // Verifies the closed-guard works: results must match the blind flash.
954 std::vector<double> z = {0.7, 0.3};
955
956 auto make_pe = [&]() {
957 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Propane"));
958 AS->set_mole_fractions(z);
959 AS->build_phase_envelope("");
960 return AS;
961 };
962 auto make_blind = [&]() {
963 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Methane&Propane"));
964 AS->set_mole_fractions(z);
965 return AS;
966 };
967
968 SECTION("HEOS gas T=300 P=1e5") {
969 auto AS = make_pe();
970 auto ref = make_blind();
971 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
972 ref->update(PT_INPUTS, 1e5, 300.0);
973 CHECK(AS->Q() == -1);
974 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
975 }
976 SECTION("HEOS 2ph T=150 P=1e5") {
977 auto AS = make_pe();
978 auto ref = make_blind();
979 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 150.0));
980 ref->update(PT_INPUTS, 1e5, 150.0);
981 CHECK(AS->phase() == iphase_twophase);
982 CHECK(AS->Q() > 0);
983 CHECK(AS->Q() < 1);
984 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-4));
985 }
986 SECTION("HEOS liquid T=100 P=1e5") {
987 auto AS = make_pe();
988 auto ref = make_blind();
989 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 100.0));
990 ref->update(PT_INPUTS, 1e5, 100.0);
991 CHECK(AS->Q() == -1);
992 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
993 }
994}
995
996TEST_CASE("PE flash: Nitrogen/Oxygen [0.79/0.21]", "[michelsen][phase_envelope]") {
997 // Envelope closed. The dew-bubble gap at 1 bar is only ~3 K, so is_inside()
998 // may misclassify borderline two-phase points. Gas and liquid are reliable.
999 std::vector<double> z = {0.79, 0.21};
1000
1001 auto make_pe = [&]() {
1002 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1003 AS->set_mole_fractions(z);
1004 AS->build_phase_envelope("");
1005 return AS;
1006 };
1007 auto make_blind = [&]() {
1008 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1009 AS->set_mole_fractions(z);
1010 return AS;
1011 };
1012
1013 SECTION("HEOS gas T=300 P=1e5") {
1014 auto AS = make_pe();
1015 auto ref = make_blind();
1016 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
1017 ref->update(PT_INPUTS, 1e5, 300.0);
1018 CHECK(AS->Q() == -1);
1019 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
1020 }
1021 SECTION("HEOS liquid T=75 P=1e5") {
1022 // At 75K the mixture is subcooled liquid (bubble T at 1 bar ≈ 78.8 K).
1023 // Both PE-guided and blind flash should select the liquid root via
1024 // Gibbs comparison. Cross-check densities agree.
1025 auto AS = make_pe();
1026 auto ref = make_blind();
1027 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 75.0));
1028 ref->update(PT_INPUTS, 1e5, 75.0);
1029 CHECK(AS->Q() == -1);
1030 CHECK(AS->rhomolar() > 20000); // liquid density for N2/O2 at 75K
1031 CHECK(ref->rhomolar() > 20000);
1032 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-3));
1033 }
1034}
1035
1036TEST_CASE("PE flash: N2/CH4/C2H6/C3H8 [0.1/0.5/0.25/0.15]", "[michelsen][phase_envelope]") {
1037 // Envelope tracing does not reach the closure condition for this 4-component
1038 // mixture (built=false) — falls through to blind flash for all phases.
1039 std::vector<double> z = {0.1, 0.5, 0.25, 0.15};
1040
1041 auto make_pe = [&]() {
1042 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane&Ethane&Propane"));
1043 AS->set_mole_fractions(z);
1044 AS->build_phase_envelope("");
1045 return AS;
1046 };
1047 auto make_blind = [&]() {
1048 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane&Ethane&Propane"));
1049 AS->set_mole_fractions(z);
1050 return AS;
1051 };
1052
1053 SECTION("HEOS gas T=300 P=1e5") {
1054 auto AS = make_pe();
1055 auto ref = make_blind();
1056 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 300.0));
1057 ref->update(PT_INPUTS, 1e5, 300.0);
1058 CHECK(AS->Q() == -1);
1059 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
1060 }
1061 SECTION("HEOS 2ph T=145 P=1e5") {
1062 auto AS = make_pe();
1063 auto ref = make_blind();
1064 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 145.0));
1065 ref->update(PT_INPUTS, 1e5, 145.0);
1066 CHECK(AS->phase() == iphase_twophase);
1067 CHECK(AS->Q() > 0);
1068 CHECK(AS->Q() < 1);
1069 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-4));
1070 }
1071 SECTION("HEOS liquid T=80 P=1e5") {
1072 auto AS = make_pe();
1073 auto ref = make_blind();
1074 CHECK_NOTHROW(AS->update(PT_INPUTS, 1e5, 80.0));
1075 ref->update(PT_INPUTS, 1e5, 80.0);
1076 CHECK(AS->Q() == -1);
1077 CHECK(AS->rhomolar() == Catch::Approx(ref->rhomolar()).epsilon(1e-6));
1078 }
1079}
1080
1081TEST_CASE("Methanol-benzene PT flash at problematic compositions", "[michelsen][meoh_benz]") {
1082 // Regression test: x_methanol = 0.56 and 0.78 at 308.15 K / 101325 Pa
1083 // previously failed because solver_rho_Tp corrupted HEOS._T/_p to -inf
1084 // on the gas-phase attempt, causing the liquid fallback to also fail.
1085 //
1086 // At 308.15 K / 1 atm this mixture is subcooled single-phase liquid (bubble
1087 // pressure ~34 kPa << 101 kPa). #3168: the Michelsen stability check produces
1088 // a false-positive two-phase classification at x_methanol = 0.54 whose split
1089 // never converges (both roots liquid-like, rho_vap > rho_liq). The convergence
1090 // gate + single-phase fallback must recover the correct single-phase liquid
1091 // result rather than publishing the unconverged split.
1092 for (double x : {0.54, 0.56, 0.58, 0.76, 0.78, 0.80}) {
1093 DYNAMIC_SECTION("x_methanol = " << x) {
1094 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "methanol&benzene"));
1095 AS->set_mole_fractions({x, 1.0 - x});
1096 REQUIRE_NOTHROW(AS->update(PT_INPUTS, 101325, 308.15));
1097 double rho = AS->rhomolar();
1098 CAPTURE(rho);
1099 CHECK(std::isfinite(rho));
1100 CHECK(rho > 0);
1101 CHECK(std::isfinite(AS->gibbsmolar()));
1102 // Subcooled liquid: single phase, liquid-like density.
1103 CHECK(AS->Q() == -1);
1104 CHECK(AS->phase() == iphase_liquid);
1105 CHECK(rho > 9000);
1106 }
1107 }
1108}
1109
1110// Independently recompute the equal-fugacity residual max_i |ln f_i^V - ln f_i^L|
1111// for a published two-phase split using fresh single-phase sub-states, so a trivial
1112// (x == y) or unconverged split cannot hide behind the flash's own internal state.
1113
1114TEST_CASE("Blind PT flash: near-dew two-phase classification (CoolProp-zgpy)", "[michelsen][blind][flash][zgpy]") {
1115 // Regression for CoolProp-zgpy: for cubic mixtures at high vapor fraction the
1116 // Michelsen stability trials can converge to the trivial (feed) solution and report
1117 // a false "stable" verdict, so a genuinely two-phase near-dew state was published as
1118 // single-phase liquid. A Wilson bubble/dew cross-check (guess_split_from_wilson)
1119 // now recovers the split. Mixture + condition from jakobreichert's report on
1120 // GitHub #3168: 5-component natural gas at P = 3 bar. T = 220 K sits at ~0.98 vapor
1121 // fraction, well inside the two-phase region (T_bub ~ 93 K, T_dew ~ 245 K), and was
1122 // classified single-phase liquid on master for SRK and PR.
1123 const std::string fluids = "Nitrogen&Methane&Ethane&Butane&Pentane";
1124 const std::vector<double> z = {0.3797, 0.3225, 0.278, 0.0014, 0.0184};
1125 const double p = 3e5, T = 220.0;
1126
1127 for (const std::string& backend : {std::string("SRK"), std::string("PR")}) {
1128 DYNAMIC_SECTION(backend << " two-phase at 220 K, 3 bar") {
1129 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
1130 AS->set_mole_fractions(z);
1131 REQUIRE_NOTHROW(AS->update(PT_INPUTS, p, T));
1132
1133 // Must be two-phase, not the single-phase-liquid false negative.
1134 CHECK(AS->phase() == iphase_twophase);
1135 CHECK(AS->Q() > 0.0);
1136 CHECK(AS->Q() < 1.0);
1137
1138 std::vector<double> x = AS->mole_fractions_liquid();
1139 std::vector<double> y = AS->mole_fractions_vapor();
1140
1141 // Guard against a trivial (x == y) split passed off as two-phase: the
1142 // incipient liquid is heavy-rich, so the phase compositions must differ.
1143 double spread = 0;
1144 for (std::size_t i = 0; i < x.size(); ++i)
1145 spread = std::max(spread, std::abs(x[i] - y[i]));
1146 CHECK(spread > 1e-2);
1147
1148 // The published split must be at genuine equilibrium (independently checked).
1149 CHECK(equilibrium_residual(backend, fluids, x, y, T, p) < 1e-6);
1150 }
1151 }
1152}
1153
1154// Port of jakobreichert's scan_twophase_PT.py (GitHub #3168): sweep the two-phase region
1155// of several mixtures with a BLIND PT flash and classify each result with an INDEPENDENT
1156// equal-fugacity check, so the test suite (not a Python script against a possibly-stale
1157// build) is the source of truth. Per point: twophase + fug<1e-6 = good; non-twophase =
1158// misclassification; twophase with x==y = trivial/degenerate split; twophase with
1159// fug>1e-6 (or a phase that cannot be re-solved) = unconverged. Hard guard: no trivial
1160// split is ever published; the misclass/bad-fug counts are reported (WARN) for tracking.
1161TEST_CASE("Stability sweep: blind two-phase classification + fugacity (CoolProp-zgpy)", "[michelsen][stability_sweep][zgpy]") {
1162 struct SweepCase
1163 {
1164 std::string fluids;
1165 std::vector<double> z;
1166 double p;
1167 };
1168 std::vector<SweepCase> cases = {
1169 {"Nitrogen&Methane&Ethane&Butane&Pentane", {0.3797, 0.3225, 0.278, 0.0014, 0.0184}, 3e5},
1170 {"Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e6},
1171 {"Methane&Ethane", {0.5, 0.5}, 1e6},
1172 };
1173 const int NT = 40;
1174 for (const std::string backend : {std::string("SRK"), std::string("PR"), std::string("HEOS")}) {
1175 for (const SweepCase& c : cases) {
1176 double Tbub = -1, Tdew = -1;
1177 try {
1178 auto S = std::shared_ptr<AbstractState>(AbstractState::factory(backend, c.fluids));
1179 S->set_mole_fractions(c.z);
1180 S->update(PQ_INPUTS, c.p, 0.0);
1181 Tbub = S->T();
1182 S->update(PQ_INPUTS, c.p, 1.0);
1183 Tdew = S->T();
1184 } catch (...) {
1185 continue; // backend can't bracket this mixture; skip
1186 }
1187 if (!(Tbub > 0 && Tdew > Tbub)) continue;
1188
1189 int misclass = 0, bad_fug = 0, trivial = 0;
1190 for (int k = 0; k < NT; ++k) {
1191 double T = Tbub + (Tdew - Tbub) * (k + 0.5) / NT;
1192 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, c.fluids));
1193 AS->set_mole_fractions(c.z);
1194 try {
1195 AS->update(PT_INPUTS, c.p, T);
1196 } catch (...) {
1197 ++misclass;
1198 continue;
1199 }
1200 if (AS->phase() != iphase_twophase) {
1201 ++misclass;
1202 continue;
1203 }
1204 try {
1205 std::vector<double> x = AS->mole_fractions_liquid(), y = AS->mole_fractions_vapor();
1206 double spread = 0;
1207 for (std::size_t i = 0; i < x.size(); ++i)
1208 spread = std::max(spread, std::abs(x[i] - y[i]));
1209 if (spread < 1e-6)
1210 ++trivial;
1211 else if (equilibrium_residual(backend, c.fluids, x, y, T, c.p) > 1e-6)
1212 ++bad_fug;
1213 } catch (...) {
1214 ++bad_fug; // published a split whose phases cannot be independently re-solved
1215 }
1216 }
1217 WARN(backend << " " << c.fluids << " P=" << c.p << " misclass=" << misclass << " bad_fug=" << bad_fug << " trivial=" << trivial << " / "
1218 << NT);
1219 CHECK(trivial == 0);
1220
1221 // False-positive guard: superheated-vapor points above the dew point must stay
1222 // single phase -- the speculative/forced attempt must never publish a clearly
1223 // single-phase feed as two-phase. (Only the vapor side is checked: for these
1224 // heavy mixtures the subcooled-liquid side falls below some components' triple
1225 // points, a pre-existing sub-triple-point region unrelated to this fix.)
1226 for (double Tsp : {Tdew + 30.0, Tdew + 80.0}) {
1227 if (Tsp <= 0) continue;
1228 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, c.fluids));
1229 AS->set_mole_fractions(c.z);
1230 try {
1231 AS->update(PT_INPUTS, c.p, Tsp);
1232 } catch (...) {
1233 continue; // outside the model's valid range for this mixture/backend
1234 }
1235 INFO(backend << " " << c.fluids << " single-phase check at T=" << Tsp);
1236 CHECK(AS->phase() != iphase_twophase);
1237 }
1238 }
1239 }
1240}
1241
1242// Helper: run a round-trip HSU_P flash test.
1243// 1. PT flash at (P, T) to get reference state
1244// 2. Read the target property (H, S, or U)
1245// 3. Flash with (Y, P) inputs on a fresh object
1246// 4. Check that T and rho match the reference
1247static void hsu_p_roundtrip(const std::string& backend, const std::string& fluids, const std::vector<double>& z, double P, double T,
1248 CoolProp::input_pairs flash_pair, double eps = 1e-6) {
1249 using namespace CoolProp;
1250 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
1251 AS->set_mole_fractions(z);
1252 AS->update(PT_INPUTS, P, T);
1253 double T_ref = AS->T();
1254 double rho_ref = AS->rhomolar();
1255
1256 double y_ref;
1257 switch (flash_pair) {
1258 case HmolarP_INPUTS:
1259 y_ref = AS->hmolar();
1260 break;
1261 case PSmolar_INPUTS:
1262 y_ref = AS->smolar();
1263 break;
1264 case PUmolar_INPUTS:
1265 y_ref = AS->umolar();
1266 break;
1267 default:
1268 throw ValueError("unsupported flash pair in hsu_p_roundtrip");
1269 }
1270
1271 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
1272 AS2->set_mole_fractions(z);
1273 // HmolarP_INPUTS: (H, P); PSmolar_INPUTS: (P, S); PUmolar_INPUTS: (P, U)
1274 if (flash_pair == HmolarP_INPUTS) {
1275 AS2->update(flash_pair, y_ref, P);
1276 } else {
1277 AS2->update(flash_pair, P, y_ref);
1278 }
1279 CHECK(AS2->T() == Catch::Approx(T_ref).epsilon(eps));
1280 CHECK(AS2->rhomolar() == Catch::Approx(rho_ref).epsilon(eps));
1281}
1282
1283TEST_CASE("HSU_P flash: mixture HP round-trip", "[michelsen][flash][HSU_P]") {
1284 SECTION("N2/O2 gas T=300 P=1e5") {
1285 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, HmolarP_INPUTS);
1286 }
1287 SECTION("N2/O2 liquid T=75 P=1e5") {
1288 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, HmolarP_INPUTS);
1289 }
1290 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1291 hsu_p_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, HmolarP_INPUTS);
1292 }
1293 SECTION("4-component gas T=300 P=1e5") {
1294 hsu_p_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, HmolarP_INPUTS);
1295 }
1296}
1297
1298TEST_CASE("HSU_P flash: mixture SP round-trip", "[michelsen][flash][HSU_P]") {
1299 SECTION("N2/O2 gas T=300 P=1e5") {
1300 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, PSmolar_INPUTS);
1301 }
1302 SECTION("N2/O2 liquid T=75 P=1e5") {
1303 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, PSmolar_INPUTS);
1304 }
1305 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1306 hsu_p_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, PSmolar_INPUTS);
1307 }
1308 SECTION("4-component gas T=300 P=1e5") {
1309 hsu_p_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, PSmolar_INPUTS);
1310 }
1311}
1312
1313TEST_CASE("HSU_P flash: mixture UP round-trip", "[michelsen][flash][HSU_P]") {
1314 SECTION("N2/O2 gas T=300 P=1e5") {
1315 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, PUmolar_INPUTS);
1316 }
1317 SECTION("N2/O2 liquid T=75 P=1e5") {
1318 hsu_p_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, PUmolar_INPUTS);
1319 }
1320 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1321 hsu_p_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, PUmolar_INPUTS);
1322 }
1323 SECTION("4-component gas T=300 P=1e5") {
1324 hsu_p_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, PUmolar_INPUTS);
1325 }
1326}
1327
1328TEST_CASE("HSU_P flash: mixture two-phase HP round-trip", "[michelsen][flash][HSU_P]") {
1329 // For N2/O2, the two-phase region at 1 bar spans ~78-90 K.
1330 // Flash at a two-phase (T, P), read H, then HP-flash and verify
1331 // that T, Q, and rho are recovered.
1332 using namespace CoolProp;
1333 SECTION("N2/O2 two-phase T=80 P=1e5") {
1334 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1335 AS->set_mole_fractions({0.79, 0.21});
1336 AS->update(PT_INPUTS, 1e5, 80.0);
1337 REQUIRE(AS->phase() == iphase_twophase);
1338 double T_ref = AS->T();
1339 double rho_ref = AS->rhomolar();
1340 double Q_ref = AS->Q();
1341 double h_ref = AS->hmolar();
1342
1343 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1344 AS2->set_mole_fractions({0.79, 0.21});
1345 AS2->update(HmolarP_INPUTS, h_ref, 1e5);
1346 CHECK(AS2->T() == Catch::Approx(T_ref).epsilon(1e-4));
1347 CHECK(AS2->rhomolar() == Catch::Approx(rho_ref).epsilon(1e-4));
1348 CHECK(AS2->Q() == Catch::Approx(Q_ref).margin(0.01));
1349 }
1350}
1351
1352// "No silent wrong answer" invariant for the mixture HSU_P flash (PR #3148 /
1353// hsu_p_native_flaw.py). For a hard CO2/Water/N2/Ar/O2 mixture the native HmolarP / PSmolar
1354// flash used to return a state that did NOT satisfy the request -- it converged to a T whose
1355// enthalpy/entropy differed from the target by a large margin (the GitHub #3148 flaw) -- while
1356// reporting success. The required, achievable guarantee is that the flash must NEVER silently
1357// return such a state: it must either throw (honest failure) OR return a state that reproduces
1358// the requested property. The #3192 residual-verification guard in HSU_P_flash enforces it.
1359//
1360// NOTE: this does NOT assert the flash lands on a particular T. For water that condenses out
1361// of a CO2/NG-type mixture the underlying (T,p) flash can pick a different (but property-
1362// consistent) branch; returning the physically-intended state there is a separate, future
1363// concern (an immiscible / water-dropout flash), deliberately out of scope here.
1364TEST_CASE("HSU_P flash: CO2/Water/N2/Ar/O2 mixture no silent wrong answer (GitHub #3148)", "[michelsen][flash][HSU_P]") {
1365 using namespace CoolProp;
1366 const std::string fluids = "CarbonDioxide&Water&Nitrogen&Argon&Oxygen";
1367 const std::vector<double> z = {0.90, 0.02, 0.04, 0.01, 0.03};
1368 const double P = 2.05e6; // Pa
1369
1370 // Helper: flash for `value` via `pair`, then assert it either threw or its OWN returned
1371 // state reproduces `value` (so the caller is never handed a state that violates the request).
1372 auto assert_no_silent_miss = [&](input_pairs pair, parameters key, double value) {
1373 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1374 AS->set_mole_fractions(z);
1375 bool threw = false;
1376 try {
1377 // HmolarP_INPUTS: (H, P); PSmolar/PUmolar_INPUTS: (P, value)
1378 if (pair == HmolarP_INPUTS)
1379 AS->update(pair, value, P);
1380 else
1381 AS->update(pair, P, value);
1382 } catch (const CoolProp::CoolPropBaseError&) {
1383 threw = true;
1384 }
1385 if (!threw) {
1386 const double got = AS->keyed_output(key);
1387 CAPTURE(value, got);
1388 CHECK(got == Catch::Approx(value).epsilon(1e-6)); // returned state satisfies the request
1389 }
1390 // threw == true is acceptable: an honest failure rather than a silent wrong answer.
1391 };
1392
1393 for (double T_true : {255.0, 260.0, 270.0, 280.0, 290.0, 300.0}) {
1394 DYNAMIC_SECTION("HP at T_true = " << T_true << " K") {
1395 auto ref = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1396 ref->set_mole_fractions(z);
1397 ref->update(PT_INPUTS, P, T_true);
1398 assert_no_silent_miss(HmolarP_INPUTS, iHmolar, ref->hmolar());
1399 }
1400 }
1401
1402 SECTION("SP at T_true = 270 K") {
1403 auto ref = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1404 ref->set_mole_fractions(z);
1405 ref->update(PT_INPUTS, P, 270.0);
1406 assert_no_silent_miss(PSmolar_INPUTS, iSmolar, ref->smolar());
1407 }
1408}
1409
1410// Helper: run a round-trip DHSU_T flash test.
1411// 1. PT flash at (P, T) to get reference state
1412// 2. Read the target property (D, H, S, or U)
1413// 3. Flash with (Y, T) inputs on a fresh object
1414// 4. Check that P and rho match the reference
1415static void dhsu_t_roundtrip(const std::string& backend, const std::string& fluids, const std::vector<double>& z, double P, double T,
1416 CoolProp::input_pairs flash_pair, double eps = 1e-6) {
1417 using namespace CoolProp;
1418 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
1419 AS->set_mole_fractions(z);
1420 AS->update(PT_INPUTS, P, T);
1421 double P_ref = AS->p();
1422 double rho_ref = AS->rhomolar();
1423
1424 double y_ref;
1425 switch (flash_pair) {
1426 case DmolarT_INPUTS:
1427 y_ref = AS->rhomolar();
1428 break;
1429 case HmolarT_INPUTS:
1430 y_ref = AS->hmolar();
1431 break;
1432 case SmolarT_INPUTS:
1433 y_ref = AS->smolar();
1434 break;
1435 case TUmolar_INPUTS:
1436 y_ref = AS->umolar();
1437 break;
1438 default:
1439 throw ValueError("unsupported flash pair in dhsu_t_roundtrip");
1440 }
1441
1442 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(backend, fluids));
1443 AS2->set_mole_fractions(z);
1444 // DmolarT_INPUTS: (rho, T); HmolarT_INPUTS: (H, T); SmolarT_INPUTS: (S, T); TUmolar_INPUTS: (T, U)
1445 if (flash_pair == TUmolar_INPUTS) {
1446 AS2->update(flash_pair, T, y_ref);
1447 } else {
1448 AS2->update(flash_pair, y_ref, T);
1449 }
1450 CHECK(AS2->p() == Catch::Approx(P_ref).epsilon(eps));
1451 CHECK(AS2->rhomolar() == Catch::Approx(rho_ref).epsilon(eps));
1452}
1453
1454TEST_CASE("DHSU_T flash: mixture DT round-trip", "[michelsen][flash][DHSU_T]") {
1455 SECTION("N2/O2 gas T=300 P=1e5") {
1456 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, DmolarT_INPUTS);
1457 }
1458 SECTION("N2/O2 liquid T=75 P=1e5") {
1459 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, DmolarT_INPUTS);
1460 }
1461 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1462 dhsu_t_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, DmolarT_INPUTS);
1463 }
1464}
1465
1466TEST_CASE("DHSU_T flash: mixture HT round-trip", "[michelsen][flash][DHSU_T]") {
1467 SECTION("N2/O2 gas T=300 P=1e5") {
1468 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, HmolarT_INPUTS);
1469 }
1470 SECTION("N2/O2 liquid T=75 P=1e5") {
1471 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, HmolarT_INPUTS);
1472 }
1473 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1474 dhsu_t_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, HmolarT_INPUTS);
1475 }
1476 SECTION("4-component gas T=300 P=1e5") {
1477 dhsu_t_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, HmolarT_INPUTS);
1478 }
1479}
1480
1481TEST_CASE("DHSU_T flash: mixture ST round-trip", "[michelsen][flash][DHSU_T]") {
1482 SECTION("N2/O2 gas T=300 P=1e5") {
1483 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, SmolarT_INPUTS);
1484 }
1485 SECTION("N2/O2 liquid T=75 P=1e5") {
1486 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, SmolarT_INPUTS);
1487 }
1488 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1489 dhsu_t_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, SmolarT_INPUTS);
1490 }
1491 SECTION("4-component gas T=300 P=1e5") {
1492 dhsu_t_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, SmolarT_INPUTS);
1493 }
1494}
1495
1496TEST_CASE("DHSU_T flash: mixture UT round-trip", "[michelsen][flash][DHSU_T]") {
1497 SECTION("N2/O2 gas T=300 P=1e5") {
1498 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 300.0, TUmolar_INPUTS);
1499 }
1500 SECTION("N2/O2 liquid T=75 P=1e5") {
1501 dhsu_t_roundtrip("HEOS", "Nitrogen&Oxygen", {0.79, 0.21}, 1e5, 75.0, TUmolar_INPUTS);
1502 }
1503 SECTION("CH4/C2H6 gas T=250 P=1e6") {
1504 dhsu_t_roundtrip("HEOS", "Methane&Ethane", {0.85, 0.15}, 1e6, 250.0, TUmolar_INPUTS);
1505 }
1506 SECTION("4-component gas T=300 P=1e5") {
1507 dhsu_t_roundtrip("HEOS", "Nitrogen&Methane&Ethane&Propane", {0.1, 0.5, 0.25, 0.15}, 1e5, 300.0, TUmolar_INPUTS);
1508 }
1509}
1510
1511// ============================================================================
1512// Tests adapted from jakobreichert's PR CoolProp/CoolProp#2720.
1513//
1514// The original PR adds CoolProp-Tests-MixtureFlash.cpp with comprehensive
1515// mixture flash tests covering PT, PQ, and HSU_P flashes. The tests below
1516// cover the same ground, adapted for our test file and HEOS-focused scope.
1517// ============================================================================
1518
1519TEST_CASE("HSU_P flash: near saturation Propane/Butane", "[michelsen][flash][HSU_P][saturation]") {
1520 // Propane/Butane 50/50 at P=1e5. Tests HP/SP/UP round-trip for points
1521 // close to the bubble and dew curves.
1522 const std::string fluids = "Propane&Butane";
1523 const std::vector<double> z = {0.5, 0.5};
1524 const double P = 1e5;
1525 const double TOL = 0.1; // K
1526
1527 auto sat = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1528 sat->set_mole_fractions(z);
1529 sat->update(PQ_INPUTS, P, 0.0);
1530 const double T_bub = sat->T();
1531 sat->update(PQ_INPUTS, P, 1.0);
1532 const double T_dew = sat->T();
1533
1534 struct Case
1535 {
1536 std::string label;
1537 double T;
1538 phases ph;
1539 };
1540 std::vector<Case> cases = {
1541 {"subcooled T_bub-0.5", T_bub - 0.5, iphase_liquid}, {"subcooled T_bub-1", T_bub - 1.0, iphase_liquid},
1542 {"two-phase T_bub+1", T_bub + 1.0, iphase_twophase}, {"two-phase midpoint", 0.5 * (T_bub + T_dew), iphase_twophase},
1543 {"two-phase T_dew-1", T_dew - 1.0, iphase_twophase}, {"superheated T_dew+1", T_dew + 1.0, iphase_gas},
1544 };
1545
1546 for (auto& c : cases) {
1547 auto ref = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1548 ref->set_mole_fractions(z);
1549 // Only specify_phase for single-phase cases; two-phase detection
1550 // via blind PT flash works but specify_phase(iphase_twophase) does
1551 // not yet work for the SRK-based initial guess in solver_rho_Tp.
1552 if (c.ph != iphase_twophase) {
1553 ref->specify_phase(c.ph);
1554 }
1555 ref->update(PT_INPUTS, P, c.T);
1556 const double H_ref = ref->hmass();
1557 const double S_ref = ref->smass();
1558 const double U_ref = ref->umass();
1559 ref->unspecify_phase();
1560
1561 DYNAMIC_SECTION(c.label + " HP") {
1562 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1563 AS->set_mole_fractions(z);
1564 AS->update(HmassP_INPUTS, H_ref, P);
1565 CHECK(std::abs(AS->T() - c.T) < TOL);
1566 }
1567 DYNAMIC_SECTION(c.label + " SP") {
1568 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1569 AS->set_mole_fractions(z);
1570 AS->update(PSmass_INPUTS, P, S_ref);
1571 CHECK(std::abs(AS->T() - c.T) < TOL);
1572 }
1573 DYNAMIC_SECTION(c.label + " UP") {
1574 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1575 AS->set_mole_fractions(z);
1576 AS->update(PUmass_INPUTS, P, U_ref);
1577 CHECK(std::abs(AS->T() - c.T) < TOL);
1578 }
1579 }
1580}
1581
1582TEST_CASE("HSU_P flash: near saturation 5-component N2-HC", "[michelsen][flash][HSU_P][saturation]") {
1583 // N2/CH4/C2H6/C4H10/C5H12 at P=3e5. Same structure as the
1584 // Propane/Butane test but for a 5-component system.
1585 // NOTE: subcooled cases are excluded — the underlying PT flash for this
1586 // 5-component mixture can fail near Tmin, causing our TOMS748-based
1587 // HSU_P solver to diverge. PR #2720 fixes the underlying PT flash.
1588 const std::string fluids = "Nitrogen&Methane&Ethane&Butane&Pentane";
1589 const std::vector<double> z = {0.3797, 0.3225, 0.278, 0.0014, 0.0184};
1590 const double P = 3e5;
1591 const double TOL = 0.1; // K
1592
1593 auto sat = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1594 sat->set_mole_fractions(z);
1595 sat->update(PQ_INPUTS, P, 0.0);
1596 const double T_bub = sat->T();
1597 sat->update(PQ_INPUTS, P, 1.0);
1598 const double T_dew = sat->T();
1599
1600 struct Case
1601 {
1602 std::string label;
1603 double T;
1604 phases ph;
1605 };
1606 std::vector<Case> cases = {
1607 {"two-phase T_bub+1", T_bub + 1.0, iphase_twophase},
1608 {"two-phase midpoint", 0.5 * (T_bub + T_dew), iphase_twophase},
1609 {"two-phase T_dew-1", T_dew - 1.0, iphase_twophase},
1610 {"superheated T_dew+1", T_dew + 1.0, iphase_gas},
1611 };
1612
1613 for (auto& c : cases) {
1614 auto ref = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1615 ref->set_mole_fractions(z);
1616 if (c.ph != iphase_twophase) {
1617 ref->specify_phase(c.ph);
1618 }
1619 ref->update(PT_INPUTS, P, c.T);
1620 const double H_ref = ref->hmass();
1621 const double S_ref = ref->smass();
1622 const double U_ref = ref->umass();
1623 ref->unspecify_phase();
1624
1625 DYNAMIC_SECTION(c.label + " HP") {
1626 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1627 AS->set_mole_fractions(z);
1628 AS->update(HmassP_INPUTS, H_ref, P);
1629 CHECK(std::abs(AS->T() - c.T) < TOL);
1630 }
1631 DYNAMIC_SECTION(c.label + " SP") {
1632 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1633 AS->set_mole_fractions(z);
1634 AS->update(PSmass_INPUTS, P, S_ref);
1635 CHECK(std::abs(AS->T() - c.T) < TOL);
1636 }
1637 DYNAMIC_SECTION(c.label + " UP") {
1638 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1639 AS->set_mole_fractions(z);
1640 AS->update(PUmass_INPUTS, P, U_ref);
1641 CHECK(std::abs(AS->T() - c.T) < TOL);
1642 }
1643 }
1644}
1645
1646TEST_CASE("PT flash: 5-component two-phase consistency", "[michelsen][flash][VLE]") {
1647 // Sweep 100 temperatures from T_bub to T_dew and verify:
1648 // 1. phase == iphase_twophase
1649 // 2. hmolar() non-decreasing with T
1650 // 3. Fugacity equality for non-trace components
1651 // Adapted from jakobreichert PR #2720.
1652 // NOTE: HEOS only — SRK/PR have pre-existing two-phase detection issues
1653 // near the dew point for this 5-component system.
1654 const std::string fluids = "Nitrogen&Methane&Ethane&Butane&Pentane";
1655 const std::vector<double> z = {0.3797, 0.3225, 0.278, 0.0014, 0.0184};
1656 const double P = 3e5;
1657 const int NQ = 100;
1658 const std::size_t NC = z.size();
1659 const double FUG_TOL = 1e-5; // relaxed from 1e-7 — near dew point convergence noise
1660 const double Z_MIN = 1e-4;
1661 const double F_MIN = 1e-15;
1662 const double H_SLACK = 0.1;
1663
1664 auto sat = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1665 sat->set_mole_fractions(z);
1666 sat->update(PQ_INPUTS, P, 0.0);
1667 const double T_bub = sat->T();
1668 sat->update(PQ_INPUTS, P, 1.0);
1669 const double T_dew = sat->T();
1670 REQUIRE(T_dew > T_bub);
1671
1672 auto liq = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1673 auto vap = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1674 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1675 AS->set_mole_fractions(z);
1676
1677 double H_prev = -1e100;
1678 for (int i = 0; i < NQ; ++i) {
1679 const double alpha = (static_cast<double>(i) + 0.5) / NQ;
1680 const double T = T_bub + (T_dew - T_bub) * alpha;
1681 CAPTURE(T);
1682
1683 AS->update(PT_INPUTS, P, T);
1684 CHECK(AS->phase() == iphase_twophase);
1685
1686 const double H = AS->hmolar();
1687 CHECK(H >= H_prev - H_SLACK);
1688 H_prev = H;
1689
1690 if (AS->phase() == iphase_twophase) {
1691 const auto x = AS->mole_fractions_liquid();
1692 const auto y = AS->mole_fractions_vapor();
1693 liq->set_mole_fractions(x);
1694 liq->specify_phase(iphase_liquid);
1695 liq->update(PT_INPUTS, P, T);
1696 vap->set_mole_fractions(y);
1697 vap->specify_phase(iphase_gas);
1698 vap->update(PT_INPUTS, P, T);
1699
1700 for (std::size_t j = 0; j < NC; ++j) {
1701 if (std::min(x[j], y[j]) < Z_MIN) continue;
1702 const double f_liq = liq->fugacity_coefficient(j) * x[j];
1703 const double f_vap = vap->fugacity_coefficient(j) * y[j];
1704 const double f_ref = std::max(std::abs(f_liq), std::abs(f_vap));
1705 if (f_ref > F_MIN) {
1706 CAPTURE(j);
1707 CHECK(std::abs(f_liq - f_vap) / f_ref < FUG_TOL);
1708 }
1709 }
1710 }
1711 }
1712}
1713
1714TEST_CASE("PQ flash: 5-component two-phase consistency", "[michelsen][flash][VLE]") {
1715 // Sweep 100 quality midpoints from Q=0 to Q=1 and verify:
1716 // 1. phase == iphase_twophase
1717 // 2. T non-decreasing with Q
1718 // 3. Fugacity equality for non-trace components
1719 // Adapted from jakobreichert PR #2720. HEOS only.
1720 const std::string fluids = "Nitrogen&Methane&Ethane&Butane&Pentane";
1721 const std::vector<double> z = {0.3797, 0.3225, 0.278, 0.0014, 0.0184};
1722 const double P = 3e5;
1723 const int NQ = 100;
1724 const std::size_t NC = z.size();
1725 const double FUG_TOL = 1e-5;
1726 const double Z_MIN = 1e-4;
1727 const double F_MIN = 1e-15;
1728
1729 auto sat = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1730 sat->set_mole_fractions(z);
1731 sat->update(PQ_INPUTS, P, 0.0);
1732 const double T_bub = sat->T();
1733 sat->update(PQ_INPUTS, P, 1.0);
1734 const double T_dew = sat->T();
1735 REQUIRE(T_dew > T_bub);
1736
1737 auto liq = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1738 auto vap = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1739 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1740 AS->set_mole_fractions(z);
1741
1742 double T_prev = -1e100;
1743 for (int i = 0; i < NQ; ++i) {
1744 const double Q = (static_cast<double>(i) + 0.5) / NQ;
1745 CAPTURE(Q);
1746
1747 AS->update(PQ_INPUTS, P, Q);
1748 CHECK(AS->phase() == iphase_twophase);
1749
1750 const double T = AS->T();
1751 CHECK(T >= T_prev);
1752 T_prev = T;
1753
1754 if (AS->phase() == iphase_twophase) {
1755 const auto x = AS->mole_fractions_liquid();
1756 const auto y = AS->mole_fractions_vapor();
1757 liq->set_mole_fractions(x);
1758 liq->specify_phase(iphase_liquid);
1759 liq->update(PT_INPUTS, P, T);
1760 vap->set_mole_fractions(y);
1761 vap->specify_phase(iphase_gas);
1762 vap->update(PT_INPUTS, P, T);
1763
1764 for (std::size_t j = 0; j < NC; ++j) {
1765 if (std::min(x[j], y[j]) < Z_MIN) continue;
1766 const double f_liq = liq->fugacity_coefficient(j) * x[j];
1767 const double f_vap = vap->fugacity_coefficient(j) * y[j];
1768 const double f_ref = std::max(std::abs(f_liq), std::abs(f_vap));
1769 if (f_ref > F_MIN) {
1770 CAPTURE(j);
1771 CHECK(std::abs(f_liq - f_vap) / f_ref < FUG_TOL);
1772 }
1773 }
1774 }
1775 }
1776}
1777
1778// Regression for the #3192 convergence-gate refinement (follow-up to #3168). For a
1779// wide-boiling mixture the Michelsen two-phase split converges only to ~1e-6 (SS converges
1780// linearly and the second-order stage stalls), and the original hard 1e-7 gate discarded it
1781// -- silently misclassifying a genuine two-phase state as single-phase. The refined gate
1782// accepts a non-trivial, near-converged equilibrium. This pins that behaviour: a blind PT
1783// flash well inside the two-phase region must report two-phase with an independently-verified,
1784// non-trivial equal-fugacity split. (Reverting the gate to the hard 1e-7 throw fails this.)
1785TEST_CASE("PT flash: wide-boiling split survives the convergence gate (GitHub #3192)", "[michelsen][flash][VLE]") {
1786 using namespace CoolProp;
1787 const std::string fluids = "Nitrogen&Methane&Ethane&Butane&Pentane";
1788 const std::vector<double> z = {0.3797, 0.3225, 0.278, 0.0014, 0.0184};
1789 const double P = 3e5;
1790
1791 auto sat = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1792 sat->set_mole_fractions(z);
1793 sat->update(PQ_INPUTS, P, 0.0);
1794 const double T_bub = sat->T();
1795 sat->update(PQ_INPUTS, P, 1.0);
1796 const double T_dew = sat->T();
1797 REQUIRE(T_dew > T_bub);
1798
1799 // Points in the narrow band just above the bubble point, where the wide-boiling split
1800 // converges only to ~1e-6 and the pre-refinement hard 1e-7 gate threw nonconvergence
1801 // (this mixture was misclassified single-phase liquid at T ~ 91-96 K on master).
1802 for (double frac : {0.005, 0.01, 0.02, 0.03, 0.05}) {
1803 const double T = T_bub + frac * (T_dew - T_bub);
1804 DYNAMIC_SECTION("frac = " << frac << " (T = " << T << " K)") {
1805 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", fluids));
1806 AS->set_mole_fractions(z);
1807 REQUIRE_NOTHROW(AS->update(PT_INPUTS, P, T));
1808 CHECK(AS->phase() == iphase_twophase);
1809 CHECK(AS->Q() > 0.0);
1810 CHECK(AS->Q() < 1.0);
1811
1812 const auto x = AS->mole_fractions_liquid_double();
1813 const auto y = AS->mole_fractions_vapor_double();
1814 double spread = 0;
1815 for (std::size_t i = 0; i < z.size(); ++i)
1816 spread = std::max(spread, std::abs(x[i] - y[i]));
1817 CHECK(spread > 1e-2); // genuine, non-trivial split (not a trivial x==y collapse)
1818 CHECK(equilibrium_residual("HEOS", fluids, x, y, T, P) < 1e-5); // at equilibrium
1819 }
1820 }
1821}
1822
1823TEST_CASE("PQ flash with built PE: N2/CH4", "[michelsen][flash][PQ_flash][PhaseEnvelope]") {
1824 // PQ flash works when the phase envelope is built.
1825 // Adapted from jakobreichert PR #2720.
1826 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane"));
1827 AS->set_mole_fractions({0.5, 0.5});
1828 AS->build_phase_envelope("");
1829 const std::size_t npts = AS->get_phase_envelope_data().T.size();
1830 CAPTURE(npts);
1831 CHECK(npts > 0);
1832 CHECK(AS->get_phase_envelope_data().built);
1833 // Use a separate (non-PE) object for PQ flash — PQ flash on the same
1834 // object that built the PE can crash in the current codebase.
1835 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Methane"));
1836 AS2->set_mole_fractions({0.5, 0.5});
1837 REQUIRE_NOTHROW(AS2->update(PQ_INPUTS, 1.5e5, 0.5));
1838 CHECK(AS2->phase() == iphase_twophase);
1839}
1840
1841TEST_CASE("PQ flash: 6-component no-throw sweep", "[michelsen][flash][PQ_flash]") {
1842 // Regression: saturation_Wilson Secant can diverge for Q between 0.88
1843 // and 0.95 in some multi-component mixtures. Fixed by always trying
1844 // Brent (bounded) first, falling back to Secant only if Brent fails.
1845 // Adapted from jakobreichert PR #2720.
1846 const std::string fluids = "Nitrogen&Methane&Ethane&Propane&Butane&Pentane";
1847 const std::vector<double> z = {0.2936, 0.2720, 0.0592, 0.2932, 0.0787, 0.0033};
1848 const double P = 3.92e5;
1849 const int NQ = 100;
1850
1851 const std::vector<std::string> backends = {"SRK", "PR", "HEOS"};
1852 for (const auto& be : backends) {
1853 DYNAMIC_SECTION(be) {
1854 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(be, fluids));
1855 AS->set_mole_fractions(z);
1856 for (int i = 0; i < NQ; ++i) {
1857 double q = static_cast<double>(i) / (NQ - 1);
1858 REQUIRE_NOTHROW(AS->update(PQ_INPUTS, P, q));
1859 }
1860 }
1861 }
1862}
1863
1864// ============================================================================
1865// DHSU_T flash: two-phase round-trip tests
1866//
1867// These verify that HmolarT, SmolarT, DmolarT, and TUmolar flashes
1868// correctly recover a known two-phase state created via PQ flash.
1869// Previously, the mixture DHSU_T flash only handled single-phase states,
1870// silently returning wrong results (on the Van der Waals loop) for
1871// two-phase inputs.
1872// ============================================================================
1873
1874TEST_CASE("DHSU_T flash: two-phase N2/O2 HEOS round-trip", "[michelsen][dhsu_t][twophase]") {
1875 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1876 AS->set_mole_fractions({0.79, 0.21});
1877 AS->update(PQ_INPUTS, 1e5, 0.5);
1878 double T = AS->T(), H = AS->hmolar(), S = AS->smolar();
1879 double U = AS->umolar(), rho = AS->rhomolar(), Q = AS->Q();
1880
1881 SECTION("HmolarT recovers two-phase state") {
1882 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1883 AS2->set_mole_fractions({0.79, 0.21});
1884 REQUIRE_NOTHROW(AS2->update(HmolarT_INPUTS, H, T));
1885 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.01));
1886 CHECK(AS2->Q() >= 0);
1887 CHECK(AS2->Q() <= 1);
1888 CHECK(AS2->Q() == Catch::Approx(Q).margin(0.05));
1889 }
1890 SECTION("SmolarT recovers two-phase state") {
1891 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1892 AS2->set_mole_fractions({0.79, 0.21});
1893 REQUIRE_NOTHROW(AS2->update(SmolarT_INPUTS, S, T));
1894 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.01));
1895 CHECK(AS2->Q() >= 0);
1896 CHECK(AS2->Q() <= 1);
1897 CHECK(AS2->Q() == Catch::Approx(Q).margin(0.05));
1898 }
1899 // DmolarT is NOT tested for two-phase round-trip. For mixtures,
1900 // (T, rho_overall) is ambiguous: the lever-rule overall density can also
1901 // be achieved as a valid single-phase gas at a different P. The flash
1902 // legitimately returns the single-phase solution.
1903 SECTION("TUmolar recovers two-phase state") {
1904 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1905 AS2->set_mole_fractions({0.79, 0.21});
1906 REQUIRE_NOTHROW(AS2->update(TUmolar_INPUTS, T, U));
1907 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.01));
1908 CHECK(AS2->Q() >= 0);
1909 CHECK(AS2->Q() <= 1);
1910 CHECK(AS2->Q() == Catch::Approx(Q).margin(0.05));
1911 }
1912}
1913
1914TEST_CASE("DHSU_T flash: two-phase cubic round-trip", "[michelsen][dhsu_t][twophase][cubic]") {
1915 const std::vector<std::string> backends = {"SRK", "PR"};
1916 for (const auto& be : backends) {
1917 DYNAMIC_SECTION(be << " backend") {
1918 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(be, "Nitrogen&Oxygen"));
1919 AS->set_mole_fractions({0.79, 0.21});
1920 AS->update(PQ_INPUTS, 1e5, 0.5);
1921 double T = AS->T(), H = AS->hmolar(), S = AS->smolar();
1922 double rho = AS->rhomolar(), Q = AS->Q();
1923
1924 SECTION("HmolarT") {
1925 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(be, "Nitrogen&Oxygen"));
1926 AS2->set_mole_fractions({0.79, 0.21});
1927 REQUIRE_NOTHROW(AS2->update(HmolarT_INPUTS, H, T));
1928 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.02));
1929 CHECK(AS2->Q() >= 0);
1930 CHECK(AS2->Q() <= 1);
1931 }
1932 SECTION("SmolarT") {
1933 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(be, "Nitrogen&Oxygen"));
1934 AS2->set_mole_fractions({0.79, 0.21});
1935 REQUIRE_NOTHROW(AS2->update(SmolarT_INPUTS, S, T));
1936 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.02));
1937 CHECK(AS2->Q() >= 0);
1938 CHECK(AS2->Q() <= 1);
1939 }
1940 SECTION("TUmolar") {
1941 double U = AS->umolar();
1942 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(be, "Nitrogen&Oxygen"));
1943 AS2->set_mole_fractions({0.79, 0.21});
1944 REQUIRE_NOTHROW(AS2->update(TUmolar_INPUTS, T, U));
1945 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.02));
1946 CHECK(AS2->Q() >= 0);
1947 CHECK(AS2->Q() <= 1);
1948 }
1949 }
1950 }
1951}
1952
1953TEST_CASE("DHSU_T flash: single-phase mixture regression", "[michelsen][dhsu_t]") {
1954 // Verify that single-phase HEOS mixture states still work after the
1955 // two-phase P-sweep was added (fast-path regression test).
1956 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1957 AS->set_mole_fractions({0.79, 0.21});
1958 // Gas state at 300 K, 1 atm — well above the dew point
1959 AS->update(PT_INPUTS, 1e5, 300.0);
1960 double T = AS->T(), H = AS->hmolar(), S = AS->smolar();
1961 double U = AS->umolar(), rho = AS->rhomolar();
1962
1963 SECTION("HmolarT single-phase") {
1964 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1965 AS2->set_mole_fractions({0.79, 0.21});
1966 REQUIRE_NOTHROW(AS2->update(HmolarT_INPUTS, H, T));
1967 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.001));
1968 CHECK(AS2->p() == Catch::Approx(1e5).epsilon(0.001));
1969 }
1970 SECTION("SmolarT single-phase") {
1971 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1972 AS2->set_mole_fractions({0.79, 0.21});
1973 REQUIRE_NOTHROW(AS2->update(SmolarT_INPUTS, S, T));
1974 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.001));
1975 CHECK(AS2->p() == Catch::Approx(1e5).epsilon(0.001));
1976 }
1977 SECTION("DmolarT single-phase") {
1978 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1979 AS2->set_mole_fractions({0.79, 0.21});
1980 REQUIRE_NOTHROW(AS2->update(DmolarT_INPUTS, rho, T));
1981 CHECK(AS2->hmolar() == Catch::Approx(H).epsilon(0.001));
1982 CHECK(AS2->p() == Catch::Approx(1e5).epsilon(0.001));
1983 }
1984 SECTION("TUmolar single-phase") {
1985 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1986 AS2->set_mole_fractions({0.79, 0.21});
1987 REQUIRE_NOTHROW(AS2->update(TUmolar_INPUTS, T, U));
1988 CHECK(AS2->rhomolar() == Catch::Approx(rho).epsilon(0.001));
1989 CHECK(AS2->p() == Catch::Approx(1e5).epsilon(0.001));
1990 }
1991}
1992
1993TEST_CASE("HSU_D flash: two-phase N2/O2 HEOS round-trip", "[michelsen][hsu_d][twophase]") {
1994 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
1995 AS->set_mole_fractions({0.79, 0.21});
1996 AS->update(PQ_INPUTS, 1e5, 0.5);
1997 double T = AS->T(), H = AS->hmolar(), S = AS->smolar();
1998 double U = AS->umolar(), rho = AS->rhomolar();
1999
2000 SECTION("DmolarHmolar") {
2001 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2002 AS2->set_mole_fractions({0.79, 0.21});
2003 REQUIRE_NOTHROW(AS2->update(DmolarHmolar_INPUTS, rho, H));
2004 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2005 CHECK(AS2->Q() >= 0);
2006 CHECK(AS2->Q() <= 1);
2007 }
2008 SECTION("DmolarSmolar") {
2009 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2010 AS2->set_mole_fractions({0.79, 0.21});
2011 REQUIRE_NOTHROW(AS2->update(DmolarSmolar_INPUTS, rho, S));
2012 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2013 CHECK(AS2->Q() >= 0);
2014 CHECK(AS2->Q() <= 1);
2015 }
2016 SECTION("DmolarUmolar") {
2017 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2018 AS2->set_mole_fractions({0.79, 0.21});
2019 REQUIRE_NOTHROW(AS2->update(DmolarUmolar_INPUTS, rho, U));
2020 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2021 CHECK(AS2->Q() >= 0);
2022 CHECK(AS2->Q() <= 1);
2023 }
2024}
2025
2026TEST_CASE("HSU_D flash: two-phase N2/O2 cubic round-trip", "[michelsen][hsu_d][twophase][cubic]") {
2027 for (const std::string& backend : {"SRK", "PR"}) {
2028 DYNAMIC_SECTION("Backend: " << backend) {
2029 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory(backend, "Nitrogen&Oxygen"));
2030 AS->set_mole_fractions({0.79, 0.21});
2031 AS->update(PQ_INPUTS, 1e5, 0.5);
2032 double T = AS->T(), H = AS->hmolar(), S = AS->smolar();
2033 double U = AS->umolar(), rho = AS->rhomolar();
2034
2035 SECTION("DmolarHmolar") {
2036 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(backend, "Nitrogen&Oxygen"));
2037 AS2->set_mole_fractions({0.79, 0.21});
2038 REQUIRE_NOTHROW(AS2->update(DmolarHmolar_INPUTS, rho, H));
2039 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2040 }
2041 SECTION("DmolarSmolar") {
2042 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(backend, "Nitrogen&Oxygen"));
2043 AS2->set_mole_fractions({0.79, 0.21});
2044 REQUIRE_NOTHROW(AS2->update(DmolarSmolar_INPUTS, rho, S));
2045 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2046 }
2047 SECTION("DmolarUmolar") {
2048 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory(backend, "Nitrogen&Oxygen"));
2049 AS2->set_mole_fractions({0.79, 0.21});
2050 REQUIRE_NOTHROW(AS2->update(DmolarUmolar_INPUTS, rho, U));
2051 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.01));
2052 }
2053 }
2054 }
2055}
2056
2057TEST_CASE("HSU_D flash: single-phase N2/O2 HEOS regression", "[michelsen][hsu_d][singlephase]") {
2058 auto AS = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2059 AS->set_mole_fractions({0.79, 0.21});
2060 AS->update(PT_INPUTS, 1e5, 300.0);
2061 double T = AS->T(), P = AS->p(), H = AS->hmolar(), S = AS->smolar();
2062 double U = AS->umolar(), rho = AS->rhomolar();
2063
2064 SECTION("DmolarHmolar") {
2065 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2066 AS2->set_mole_fractions({0.79, 0.21});
2067 REQUIRE_NOTHROW(AS2->update(DmolarHmolar_INPUTS, rho, H));
2068 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.001));
2069 CHECK(AS2->p() == Catch::Approx(P).epsilon(0.001));
2070 }
2071 SECTION("DmolarSmolar") {
2072 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2073 AS2->set_mole_fractions({0.79, 0.21});
2074 REQUIRE_NOTHROW(AS2->update(DmolarSmolar_INPUTS, rho, S));
2075 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.001));
2076 CHECK(AS2->p() == Catch::Approx(P).epsilon(0.001));
2077 }
2078 SECTION("DmolarUmolar") {
2079 auto AS2 = std::shared_ptr<AbstractState>(AbstractState::factory("HEOS", "Nitrogen&Oxygen"));
2080 AS2->set_mole_fractions({0.79, 0.21});
2081 REQUIRE_NOTHROW(AS2->update(DmolarUmolar_INPUTS, rho, U));
2082 CHECK(AS2->T() == Catch::Approx(T).epsilon(0.001));
2083 CHECK(AS2->p() == Catch::Approx(P).epsilon(0.001));
2084 }
2085}
2086
2087#endif