CoolProp 8.0.0
An open-source fluid property and humid air property database
FlashRoutines.cpp
Go to the documentation of this file.
1#include "VLERoutines.h"
2#include "FlashRoutines.h"
3
4#include <cmath>
5#include <algorithm>
6#include <cstdint>
7#include <cstdlib>
8#include "CoolProp/CoolProp.h"
10#include "HelmholtzEOSBackend.h"
14
15#if defined(ENABLE_CATCH)
16# include <catch2/catch_all.hpp>
18#endif
19
20#include "boost/math/tools/toms748_solve.hpp"
21
22namespace CoolProp {
23
25 if (HEOS.PhaseEnvelope.built && HEOS.PhaseEnvelope.closed) {
26 // Use the closed phase envelope to classify the (T, p) point
27 try {
28 SimpleState closest_state;
29 std::size_t iclosest = 0;
30 const PhaseEnvelopeData& env = HEOS.PhaseEnvelope;
31 bool twophase = PhaseEnvelopeRoutines::is_inside(env, iP, HEOS._p, iT, HEOS._T, iclosest, closest_state);
32
33 if (!twophase) {
34 if (!ValidNumber(closest_state.T)) {
35 throw ValueError("Phase envelope is_inside returned false without populating closest_state");
36 }
37 // Single-phase: determine gas vs liquid from temperature relative to closest envelope point
38 // Save T and p before solver calls — solver_rho_Tp may corrupt
39 // HEOS._T/_p on failure (Householder sets them to -inf).
40 const CoolPropDbl T_saved = HEOS._T;
41 const CoolPropDbl p_saved = HEOS._p;
42 phases phase_guess = (T_saved > closest_state.T) ? iphase_gas : iphase_liquid;
43 HEOS.specify_phase(phase_guess);
44 double rho;
45 try {
46 rho = HEOS.solver_rho_Tp(T_saved, p_saved);
47 } catch (...) {
48 // If guessed phase fails, try the other branch
49 phases alt = (phase_guess == iphase_gas) ? iphase_liquid : iphase_gas;
50 HEOS.specify_phase(alt);
51 rho = HEOS.solver_rho_Tp(T_saved, p_saved);
52 }
53 HEOS.unspecify_phase();
54 HEOS.update_DmolarT_direct(rho, T_saved);
55 HEOS._Q = -1;
56 HEOS._phase = (rho < HEOS.rhomolar_reducing()) ? iphase_gas : iphase_liquid;
57 } else {
58 // Two-phase: seed PT flash from the closest envelope point
59 std::size_t N = env.K.size();
61 o.x.resize(N);
62 o.y.resize(N);
63 for (std::size_t k = 0; k < N; ++k) {
64 o.x[k] = env.x[k][iclosest];
65 o.y[k] = env.y[k][iclosest];
66 }
67 o.rhomolar_liq = env.rhomolar_liq[iclosest];
68 o.rhomolar_vap = env.rhomolar_vap[iclosest];
69 o.z = HEOS.get_mole_fractions();
70 o.T = HEOS._T;
71 o.p = HEOS._p;
72 o.omega = 1.0;
74 solver.solve();
75 // Beta-fallback: collapse trivial splits to single-phase
76 if (o.beta < 1e-10) {
79 HEOS._Q = -1;
80 } else if (o.beta > 1.0 - 1e-10) {
83 HEOS._Q = -1;
84 } else {
86 HEOS._Q = o.beta;
87 HEOS._rhomolar = 1.0 / (o.beta / o.rhomolar_vap + (1.0 - o.beta) / o.rhomolar_liq);
88 }
89 }
90 return;
91 } catch (...) {
92 HEOS.unspecify_phase();
93 // Envelope-guided flash failed; fall through to blind flash below
94 }
95 }
96
98 // Blind flash call
99
100 // Single-phase density determination via Gibbs-minimizing root selection.
101 // Used both for a stable feed and as the fallback when a flagged two-phase
102 // split fails to converge — e.g. a stability false-positive at a single-phase
103 // liquid state where both "phases" collapse to liquid-like roots (GitHub #3168).
104 auto solve_single_phase = [&]() {
105 // Save T and p before any solver calls — solver_rho_Tp may corrupt
106 // HEOS._T/_p on failure (Householder sets them to -inf).
107 const CoolPropDbl T_saved = HEOS.T();
108 const CoolPropDbl p_saved = HEOS.p();
109
110 // Solve SRK cubic for both gas and liquid roots to decide which
111 // HEOS branch(es) to solve. When both roots are valid, solve
112 // both HEOS densities and pick the phase with lower Gibbs energy.
113 // This mirrors the pattern in solver_rho_Tp_global().
114 double rho;
115 double rho_srk_gas = HEOS.solver_rho_Tp_SRK(T_saved, p_saved, iphase_gas);
116 double rho_srk_liq = HEOS.solver_rho_Tp_SRK(T_saved, p_saved, iphase_liquid);
117 bool gas_ok = rho_srk_gas > 0 && ValidNumber(rho_srk_gas);
118 bool liq_ok = rho_srk_liq > 0 && ValidNumber(rho_srk_liq);
119
120 if (gas_ok && liq_ok) {
121 // Both SRK roots valid — solve both HEOS roots, pick lower Gibbs
122 double rho_gas = -1, rho_liq = -1;
124 try {
125 rho_gas = HEOS.solver_rho_Tp(T_saved, p_saved);
126 } catch (...) {
127 }
129 try {
130 rho_liq = HEOS.solver_rho_Tp(T_saved, p_saved);
131 } catch (...) {
132 }
133 HEOS.unspecify_phase();
134
135 if (rho_gas > 0 && rho_liq > 0) {
136 double G_gas = HEOS.calc_gibbsmolar_nocache(T_saved, rho_gas);
137 double G_liq = HEOS.calc_gibbsmolar_nocache(T_saved, rho_liq);
138 rho = (G_liq <= G_gas) ? rho_liq : rho_gas;
139 } else if (rho_gas > 0 || rho_liq > 0) {
140 rho = (rho_gas > 0) ? rho_gas : rho_liq;
141 } else {
142 throw ValueError("Unable to obtain either HEOS density root in PT_flash_mixtures");
143 }
144 } else {
145 // Only one SRK root valid (or neither) — solve that branch,
146 // fall back to the other if HEOS throws.
147 phases primary = gas_ok ? iphase_gas : (liq_ok ? iphase_liquid : iphase_gas);
148 phases fallback = (primary == iphase_gas) ? iphase_liquid : iphase_gas;
149 HEOS.specify_phase(primary);
150 try {
151 rho = HEOS.solver_rho_Tp(T_saved, p_saved);
152 } catch (...) {
153 HEOS.specify_phase(fallback);
154 try {
155 rho = HEOS.solver_rho_Tp(T_saved, p_saved);
156 } catch (...) {
157 HEOS.unspecify_phase();
158 throw;
159 }
160 }
161 HEOS.unspecify_phase();
162 }
163 HEOS.update_DmolarT_direct(rho, T_saved);
164 HEOS._Q = -1;
165 HEOS._phase = (rho < HEOS.rhomolar_reducing()) ? iphase_gas : iphase_liquid;
166 };
167
168 // Instantiates stability tester (dispatches to Michelsen or Gernert based on config)
169 StabilityRoutines::StabilityEvaluationClass stability_tester(HEOS);
170 bool do_twophase = !stability_tester.is_stable();
172 o.z = HEOS.get_mole_fractions();
173 bool wilson_seeded = false;
174
175 // CoolProp-zgpy (#3174): the Michelsen stability test can report a false "stable"
176 // verdict near a phase boundary -- its trial minimization can converge to the trivial
177 // (feed) solution (cubic mixtures at high vapor fraction) or fail to conclude at all --
178 // so a genuinely two-phase state gets mislabeled single-phase liquid. Cross-check a
179 // "stable" verdict against a cheap Wilson K-factor estimate and, if warranted, attempt
180 // a split seeded from it. When the verdict was non-conclusive (is_uncertain), force the
181 // attempt even where the IDEAL estimate does not bracket (require_bracket = false); the
182 // EOS-based refinement and the verify below decide. A genuinely single-phase point
183 // fails the Wilson test or the verify and falls back to single phase. (Lever from
184 // jakobreichert's PR #2720.)
185 if (!do_twophase) {
186 const bool uncertain = stability_tester.is_uncertain();
187 try {
188 if (CoolProp::SaturationSolvers::guess_split_from_wilson(HEOS, o.x, o.y, o.rhomolar_liq, o.rhomolar_vap, o.z, HEOS.T(), HEOS.p(), 10,
189 !uncertain)) {
190 do_twophase = true;
191 wilson_seeded = true;
192 }
193 } catch (const CoolProp::CoolPropBaseError&) {
194 // Speculative seed failed (e.g. no liquid density root at this state);
195 // trust the stability verdict and stay on the single-phase path.
196 do_twophase = false;
197 wilson_seeded = false;
198 }
199 }
200
201 if (do_twophase) {
202 // There is a phase split and liquid and vapor phases are formed
203 if (!wilson_seeded) {
204 stability_tester.get_liq(o.x, o.rhomolar_liq);
205 stability_tester.get_vap(o.y, o.rhomolar_vap);
206 }
207 o.T = HEOS.T();
208 o.p = HEOS.p();
209 o.omega = 1.0;
211 if (wilson_seeded) {
212 // The Wilson split is speculative (the stability test said "stable"): a failure
213 // to converge, or a trivial (x == y) result, must fall back to the single-phase
214 // path rather than abort the flash or publish a bogus split.
215 try {
216 solver.solve();
217 } catch (const CoolProp::CoolPropBaseError&) {
218 do_twophase = false;
219 }
220 if (do_twophase) {
221 // Accept the speculative split only if it is a genuine, non-degenerate
222 // equilibrium: a non-trivial composition spread AND equal fugacities
223 // (recomputed on the published split). This guards the forced path against
224 // publishing a degenerate (x == y) or unconverged split, since the base
225 // two-phase solver does not itself gate on convergence.
226 bool ok = false;
227 try {
228 CoolPropDbl spread = 0;
229 for (std::size_t i = 0; i < o.z.size(); ++i)
230 spread = std::max(spread, std::abs(o.x[i] - o.y[i]));
231 HEOS.SatL->set_mole_fractions(o.x);
232 HEOS.SatL->update_DmolarT_direct(o.rhomolar_liq, o.T);
233 HEOS.SatV->set_mole_fractions(o.y);
234 HEOS.SatV->update_DmolarT_direct(o.rhomolar_vap, o.T);
235 CoolPropDbl fug_resid = 0;
236 for (std::size_t i = 0; i < o.z.size(); ++i) {
237 if (o.x[i] < 1e-12 || o.y[i] < 1e-12) continue; // trace in one phase
238 CoolPropDbl lnfL = std::log(o.x[i]) + std::log(HEOS.SatL->fugacity_coefficient(i));
239 CoolPropDbl lnfV = std::log(o.y[i]) + std::log(HEOS.SatV->fugacity_coefficient(i));
240 fug_resid = std::max(fug_resid, std::abs(lnfV - lnfL));
241 }
242 ok = (spread >= 1e-6) && ValidNumber(fug_resid) && (fug_resid <= 1e-7);
243 } catch (const CoolProp::CoolPropBaseError&) {
244 ok = false;
245 }
246 if (!ok) do_twophase = false; // trivial / unconverged / unverifiable -> single phase
247 }
248 } else {
249 // Genuine instability from the stability test. #3170: solve_michelsen's
250 // convergence gate (o.nonconvergence, GitHub #3168) throws on a non-converged
251 // split -- its usual cause is a stability false-positive at a single-phase state,
252 // where publishing the unconverged split would be a wrong answer -- and we fall
253 // back to single phase. Any OTHER failure (e.g. an upstream density-solve loss)
254 // is not evidence of a single phase, so it is re-thrown rather than silently
255 // masked.
256 try {
257 solver.solve();
258 } catch (const CoolProp::CoolPropBaseError&) {
259 if (!o.nonconvergence) {
260 throw;
261 }
262 do_twophase = false;
263 }
264 }
265 }
266
267 if (do_twophase) {
268 // Fallback block: trivial splits (beta ~ 0 or 1) from boundary floating-point
269 // noise in the Michelsen TPD solver collapse to single-phase.
270 if (o.beta < 1e-10) {
271 HEOS.update_DmolarT_direct(o.rhomolar_liq, HEOS.T());
273 HEOS._Q = -1;
274 } else if (o.beta > 1.0 - 1e-10) {
275 HEOS.update_DmolarT_direct(o.rhomolar_vap, HEOS.T());
277 HEOS._Q = -1;
278 } else {
279 HEOS._phase = iphase_twophase;
280 HEOS._Q = o.beta;
281 HEOS._rhomolar = 1.0 / (o.beta / o.rhomolar_vap + (1.0 - o.beta) / o.rhomolar_liq);
282 }
283 } else {
284 // Single-phase: a stable feed, or a speculative / non-converged split rejected above.
285 HEOS.unspecify_phase();
286 solve_single_phase();
287 }
288 } else {
289 // It's single-phase, and phase is imposed
290 const CoolPropDbl T_saved = HEOS.T();
291 const CoolPropDbl p_saved = HEOS.p();
292 double rho = HEOS.solver_rho_Tp(T_saved, p_saved);
293 HEOS.update_DmolarT_direct(rho, T_saved);
294 HEOS._Q = -1;
295 HEOS._phase = HEOS.imposed_phase_index;
296 }
297}
299 if (HEOS.is_pure_or_pseudopure) {
300 // At the critical point dP/drho -> 0, so solver_rho_Tp is ill-conditioned: a tight
301 // pressure residual does not imply a tight density. Short-circuit when (T, p)
302 // matches the critical point within 1e-10 relative on both axes. See issue #2738.
303 CoolPropDbl Tc = HEOS.T_critical();
304 CoolPropDbl pc = HEOS.p_critical();
305 if (is_in_closed_range(Tc * (1 - 1e-10), Tc * (1 + 1e-10), static_cast<CoolPropDbl>(HEOS._T))
306 && is_in_closed_range(pc * (1 - 1e-10), pc * (1 + 1e-10), static_cast<CoolPropDbl>(HEOS._p))) {
308 HEOS._T = Tc;
309 HEOS._p = pc;
310 HEOS._rhomolar = HEOS.rhomolar_critical();
311 HEOS._Q = -1;
312 return;
313 }
314 if (HEOS.imposed_phase_index == iphase_not_imposed) // If no phase index is imposed (see set_components function)
315 {
316 // At very low temperature (near the triple point temp), the isotherms are VERY steep
317 // Thus it can be very difficult to determine state based on ps = f(T)
318 // So in this case, we do a phase determination based on p, generally it will be useful enough
319 if (HEOS._T < 0.9 * HEOS.Ttriple() + 0.1 * HEOS.calc_Tmax_sat()) {
320 // Find the phase, while updating all internal variables possible using the pressure
321 bool saturation_called = false;
322 HEOS.p_phase_determination_pure_or_pseudopure(iT, HEOS._T, saturation_called);
323 } else {
324 // Find the phase, while updating all internal variables possible using the temperature
326 }
327 // Check if twophase solution
328 if (!HEOS.isHomogeneousPhase()) {
329 throw ValueError("twophase not implemented yet");
330 }
331 } else {
332 // Phase is imposed. Update _phase in case it was reset elsewhere by another call
333 HEOS._phase = HEOS.imposed_phase_index;
334 }
335 // Find density
336 HEOS._rhomolar = HEOS.solver_rho_Tp(HEOS._T, HEOS._p);
337 HEOS._Q = -1;
338 } else {
339 PT_flash_mixtures(HEOS);
340 }
341}
342
343// Define the residual to be driven to zero
345{
346 public:
350 double call(double T) override {
352 CoolPropDbl peos = HEOS->p();
353 CoolPropDbl r = (peos - p) / p;
354 return r;
355 };
356 double deriv(double T) override {
357 // dp/dT|rho / pspecified
358 return HEOS->first_partial_deriv(iP, iT, iDmolar) / p;
359 };
360 double second_deriv(double T) override {
361 // d2p/dT2|rho / pspecified
363 };
364};
365
366/***
367\f[
368\begin{array}{l}
369p = \frac{{RT}}{{v - b}} - \frac{{a\alpha }}{{v\left( {v + b} \right)}}\\
370\alpha = \left( {1 + \kappa \left( {1 - \sqrt {{T_r}} } \right)} \right)\left( {1 + \kappa \left( {1 - \sqrt {{T_r}} } \right)} \right) = 1 + 2\kappa \left( {1 - \sqrt {{T_r}} } \right) + {\kappa ^2}{\left( {1 - \sqrt {{T_r}} } \right)^2}\\
371\alpha = 1 + 2\kappa \left( {1 - \sqrt {{T_r}} } \right) + {\kappa ^2}{\left( {1 - \sqrt {{T_r}} } \right)^2}\\
372\alpha = 1 + 2\kappa - 2\kappa \sqrt {{T_r}} + {\kappa ^2}\left[ {1 - 2\sqrt {{T_r}} + {T_r}} \right]\\
373T = {T_r}{T_c}\\
374p = \frac{{R{T_r}{T_c}}}{{v - b}} - \frac{{a\left( {1 + 2\kappa - 2\kappa \sqrt {{T_r}} + {\kappa ^2}\left[ {1 - 2\sqrt {{T_r}} + {T_r}} \right]} \right)}}{{v\left( {v + b} \right)}}\\
375\\
376{\rm{Factor in terms of }}\sqrt {{T_r}} \\
377\\
378p = \frac{{R{T_r}{T_c}}}{{v - b}} - \frac{{a\left( {1 + 2\kappa + {\kappa ^2} - 2\kappa \sqrt {{T_r}} + {\kappa ^2}\left[ { - 2\sqrt {{T_r}} + {T_r}} \right]} \right)}}{{v\left( {v + b} \right)}}\\
379p = \frac{{R{T_r}{T_c}}}{{v - b}} - \frac{{a\left( {1 + 2\kappa + {\kappa ^2} - 2\kappa (1 + \kappa )\sqrt {{T_r}} + {\kappa ^2}{T_r}} \right)}}{{v\left( {v + b} \right)}}\\
380p = \frac{{R{T_r}{T_c}}}{{v - b}} - \frac{{a\left( {1 + 2\kappa + {\kappa ^2}} \right)}}{{v\left( {v + b} \right)}} + \frac{{2a\kappa (1 + \kappa )}}{{v\left( {v + b} \right)}}\sqrt {{T_r}} - \frac{{a{\kappa ^2}}}{{v\left( {v + b} \right)}}{T_r}\\
3810 = \left[ {\frac{{R{T_c}}}{{v - b}} - \frac{{a{\kappa ^2}}}{{v\left( {v + b} \right)}}} \right]{T_r} + \frac{{2a\kappa (1 + \kappa )}}{{v\left( {v + b} \right)}}\sqrt {{T_r}} - \frac{{a\left( {1 + 2\kappa + {\kappa ^2}} \right)}}{{v\left( {v + b} \right)}} - p
382\end{array}
383\f]
384 */
385double FlashRoutines::T_DP_PengRobinson(HelmholtzEOSMixtureBackend& HEOS, double rhomolar, double p) {
386 double omega = NAN, R = NAN, kappa = NAN, a = NAN, b = NAN, A = NAN, B = NAN, C = NAN, Tc = NAN, pc = NAN, V = 1 / rhomolar;
387 omega = HEOS.acentric_factor();
388 Tc = HEOS.T_critical();
389 pc = HEOS.p_critical();
390 R = HEOS.gas_constant();
391
392 kappa = 0.37464 + 1.54226 * omega - 0.26992 * omega * omega;
393 a = 0.457235 * R * R * Tc * Tc / pc;
394 b = 0.077796 * R * Tc / pc;
395 double den = V * V + 2 * b * V - b * b;
396
397 // A sqrt(Tr)^2 + B sqrt(Tr) + C = 0
398 A = R * Tc / (V - b) - a * kappa * kappa / (den);
399 B = +2 * a * kappa * (1 + kappa) / (den);
400 C = -a * (1 + 2 * kappa + kappa * kappa) / (den)-p;
401
402 //D = B*B-4*A*C;
403
404 double sqrt_Tr1 = (-B + sqrt(B * B - 4 * A * C)) / (2 * A);
405 //double sqrt_Tr2 = (-B-sqrt(B*B-4*A*C))/(2*A);
406 return sqrt_Tr1 * sqrt_Tr1 * Tc;
407};
408
410 // Comment out the check for an imposed phase. There's no code to handle if it is!
411 // Solver below and flash calculations (if two phase) have to be called anyway.
412 //
413 // if (HEOS.imposed_phase_index == iphase_not_imposed) // If no phase index is imposed (see set_components function)
414 // {
415 if (HEOS.is_pure_or_pseudopure) {
416 // Find the phase, while updating all internal variables possible using the pressure
417 bool saturation_called = false;
418 HEOS.p_phase_determination_pure_or_pseudopure(iDmolar, HEOS._rhomolar, saturation_called);
419
420 if (HEOS.isHomogeneousPhase()) {
421 CoolPropDbl T0 = NAN;
422 if (HEOS._phase == iphase_liquid) {
423 // If it is a liquid, start off at the ancillary value
424 if (saturation_called) {
425 T0 = HEOS.SatL->T();
426 } else {
427 T0 = HEOS._TLanc.pt();
428 }
429 } else if (HEOS._phase == iphase_supercritical_liquid) {
430 // If it is a supercritical
431 T0 = 1.1 * HEOS.T_critical();
432 } else if (HEOS._phase == iphase_gas || HEOS._phase == iphase_supercritical_gas || HEOS._phase == iphase_supercritical) {
433 // First, get a guess for density from Peng-Robinson
434 T0 = T_DP_PengRobinson(HEOS, HEOS.rhomolar(), HEOS.p());
435 } else {
436 throw ValueError("I should never get here");
437 }
438 if (!std::isfinite(T0)) {
439 throw ValueError("Starting value of T0 is not valid in DP_flash");
440 }
441 // Then, do the solver using the full EOS
442 solver_DP_resid resid(&HEOS, HEOS.rhomolar(), HEOS.p());
443 bool dp_ok = false;
444 try {
445 Halley(resid, T0, 1e-10, 100);
446 // Halley is unbounded: from a poor PR/ancillary seed at extreme (rho, p)
447 // it can diverge to a nonphysical (negative / out-of-range) temperature, or
448 // stall at a range-valid point that does not satisfy the residual. Accept
449 // only a finite, positive, in-range root that actually converged (re-checking
450 // the normalized pressure residual, which also leaves the state consistent).
451 if (ValidNumber(HEOS._T) && HEOS._T > 0 && HEOS._T <= 1.5 * HEOS.Tmax() && std::abs(resid.call(HEOS._T)) < 1e-7) {
452 dp_ok = true;
453 }
454 } catch (...) {
455 dp_ok = false; // Halley threw (non-convergence); the bracketed fallback below handles it.
456 }
457 if (!dp_ok) {
458 // p(T, rho) is monotonic in T at fixed rho, so bracket on the EOS
459 // temperature range and solve with TOMS748 (asymptotically optimal,
460 // matching the other P+X legs in this file). Use the residual's
461 // captured (rho, p): the failed Halley above may have left _p corrupted.
462 const CoolPropDbl Tlo = HEOS.Tmin(), Thi = 1.5 * HEOS.Tmax();
463 CoolPropDbl flo = NAN, fhi = NAN;
464 try {
465 flo = resid.call(Tlo);
466 fhi = resid.call(Thi);
467 } catch (...) {
468 // Endpoint EOS evaluation threw; force the NaN guard below to treat it as "no bracket".
469 flo = fhi = NAN;
470 }
471 if (ValidNumber(flo) && ValidNumber(fhi) && flo * fhi < 0) {
472 try {
473 boost::math::uintmax_t max_iter = 100;
474 auto f = [&resid](const double T) { return resid.call(T); };
475 auto [l, r] = toms748_solve(f, static_cast<double>(Tlo), static_cast<double>(Thi), static_cast<double>(flo),
476 static_cast<double>(fhi), boost::math::tools::eps_tolerance<double>(30), max_iter);
477 // Re-evaluate at the bracket midpoint so HEOS is left at the converged state.
478 resid.call(0.5 * (l + r));
479 } catch (...) {
480 // An interior EOS evaluation threw inside the bracket; degrade to a clean
481 // ValueError rather than letting a non-ValueError exception escape the flash.
482 throw ValueError(format("DP_flash TOMS748 fallback failed for rho=%Lg mol/m^3, p=%Lg Pa",
483 static_cast<CoolPropDbl>(resid.rhomolar), static_cast<CoolPropDbl>(resid.p)));
484 }
485 } else {
486 throw ValueError(format("DP_flash could not bracket T for rho=%Lg mol/m^3, p=%Lg Pa (Halley diverged)",
487 static_cast<CoolPropDbl>(resid.rhomolar), static_cast<CoolPropDbl>(resid.p)));
488 }
489 }
490 HEOS._Q = -1;
491 // Update the state for conditions where the state was guessed
493 if (!get_config_bool(DONT_CHECK_PROPERTY_LIMITS) && HEOS._T > 1.5 * HEOS.Tmax()) {
494 throw CoolProp::OutOfRangeError(format("DP yielded T > 1.5Tmax w/ T (%g) K").c_str());
495 }
496 } else {
497 // Nothing to do here; phase determination has handled this already
498 }
499 } else {
500 throw NotImplementedError("DP_flash not ready for mixtures");
501 }
502 // }
503 // TO DO: Put the imposed phase check back in
504 // and provide the else code here if it is imposed.
505}
506
508{
509 public:
513 double call(double T) override {
514 HEOS.update(QT_INPUTS, 0, T); // Doesn't matter whether liquid or vapor, we are just doing a full VLE call for given T
518 return (1 / rhomolar - 1 / rhoL) / (1 / rhoV - 1 / rhoL) - Q_target;
519 }
520 double deriv(double T) override {
521 return _HUGE;
522 }
523 double second_deriv(double T) override {
524 return _HUGE;
525 }
526};
527
528// Tolerance on Q to be treated as exactly 0 or exactly 1 for the superancillary
529// strict-mode path. Centralized so the various callers stay in lockstep (#2773).
530static constexpr double Q_BOUNDARY_TOL = 1e-10;
531
532// Tolerance for deduplicating near-identical T-roots returned by get_x_for_y in
533// adjacent monotonic intervals at an extremum (#2773 review I1).
534static constexpr double T_ROOT_DEDUP_TOL = 1e-6;
535
536// Sum the two IdealHelmholtzEnthalpyEntropyOffset contributions
537// (EnthalpyEntropyOffsetCore from JSON parse and the user-mutable
538// EnthalpyEntropyOffset) and apply the alpha0 prefactor. Disabled offsets
539// canonicalize to (0, 0). The returned (a1, a2) is what goes into the
540// SuperAncillary stamp and into the shift formula (Δh = R·T_red·Δa2,
541// Δs = −R·Δa1). See #2773.
543 const auto& alpha0 = HEOS.get_components()[0].EOS().alpha0;
544 const auto& core = alpha0.EnthalpyEntropyOffsetCore;
545 const auto& user = alpha0.EnthalpyEntropyOffset;
546 const double prefactor = alpha0.get_prefactor();
547 const double a1_sum =
548 (core.is_enabled() ? static_cast<double>(core.get_a1()) : 0.0) + (user.is_enabled() ? static_cast<double>(user.get_a1()) : 0.0);
549 const double a2_sum =
550 (core.is_enabled() ? static_cast<double>(core.get_a2()) : 0.0) + (user.is_enabled() ? static_cast<double>(user.get_a2()) : 0.0);
551 return {prefactor * a1_sum, prefactor * a2_sum};
552}
553
554// Has the fluid got a superancillary AND is the requested Q exactly 0 or 1?
555// Used by the no-guess default flashes to decide whether to route through the
556// strict-mode superancillary path. Pseudo-pure fluids are rejected here too,
557// because their caloric superancillaries are not built (see #2773 review I3).
559 if (!HEOS.is_pure()) return false;
560 auto p = HEOS.get_superanc();
561 if (!p) return false;
562 const double Q = HEOS._Q;
563 return std::abs(Q) < Q_BOUNDARY_TOL || std::abs(Q - 1) < Q_BOUNDARY_TOL;
564}
565
567 if (!HEOS.is_pure_or_pseudopure) {
568 throw NotImplementedError("DQ_flash not ready for mixtures");
569 }
571 const double rhomolar = HEOS._rhomolar;
572 const double Q = HEOS._Q;
573 // Strict-mode superancillary path for Q ∈ {0, 1} on pure fluids: enumerate
574 // every T-root and refuse to silently pick when there is more than one.
575 // See GitHub #2773 / #2834.
576 if (sat_superanc_path_applies(HEOS)) {
577 const double T_super = resolve_T_via_superancillary(HEOS, iDmolar, rhomolar, std::nullopt, "DQ_flash");
578 HEOS._T = T_super;
579 QT_flash(HEOS);
580 HEOS._rhomolar = rhomolar;
581 HEOS._Q = Q;
582 HEOS._phase = iphase_twophase;
583 return;
584 }
585 // Fallback: Brent over [Tmin, Tmax] for fractional Q or fluids without
586 // a superancillary. This was the original DQ_flash.
588 options.use_logdelta = false;
589 double Tmax = HEOS.T_critical() - 0.1;
590 double Tmin = HEOS.Tmin() + 0.1;
591 const double eps = 1e-12;
592 if (rhomolar >= (HEOS.rhomolar_critical() + eps) && Q > (0 + eps)) {
594 format("DQ inputs are not defined for density (%g) above critical density (%g) and Q>0", rhomolar, HEOS.rhomolar_critical()).c_str());
595 }
596 DQ_flash_residual resid(HEOS, rhomolar, Q);
597 Brent(resid, Tmin, Tmax, DBL_EPSILON, 1e-10, 100);
598 HEOS._p = HEOS.SatV->p();
599 HEOS._T = HEOS.SatV->T();
600 HEOS._rhomolar = rhomolar;
601 HEOS._Q = Q;
602 HEOS._phase = iphase_twophase;
603}
605 if (!HEOS.is_pure_or_pseudopure) {
606 throw NotImplementedError("HQ_flash not ready for mixtures");
607 }
608 if (std::abs(HEOS.Q() - 1) > 1e-10) {
609 throw ValueError(format("non-unity quality not currently allowed for HQ_flash"));
610 }
612 const double hmolar = HEOS._hmolar;
613 // Strict-mode superancillary path. See GitHub #2773 / #2834. Use the same
614 // gate as DQ_flash and QS_flash so all three default flashes route
615 // consistently (review I3 follow-up).
616 if (sat_superanc_path_applies(HEOS)) {
617 const double T_super = resolve_T_via_superancillary(HEOS, iHmolar, hmolar, std::nullopt, "HQ_flash");
618 HEOS._T = T_super;
619 QT_flash(HEOS);
620 HEOS._hmolar = hmolar;
621 HEOS._phase = iphase_twophase;
622 return;
623 }
624 // Fallback: ancillary-seeded saturation_PHSU_pure for fluids without a
625 // superancillary. (Note: ignores Tguess for branch selection — that
626 // capability is exposed via update_with_guesses + HQ_flash_with_guesses.)
627 (void)Tguess;
629 options.use_logdelta = false;
631 SaturationSolvers::saturation_PHSU_pure(HEOS, hmolar, options);
632 HEOS._p = HEOS.SatV->p();
633 HEOS._T = HEOS.SatV->T();
634 HEOS._rhomolar = HEOS.SatV->rhomolar();
635 HEOS._phase = iphase_twophase;
636}
638 if (!HEOS.is_pure_or_pseudopure) {
639 throw NotImplementedError("QS_flash not ready for mixtures");
640 }
641 if (std::abs(HEOS.smolar() - HEOS.get_state("reducing").smolar) < 0.001) {
642 HEOS._p = HEOS.p_critical();
643 HEOS._T = HEOS.T_critical();
644 HEOS._rhomolar = HEOS.rhomolar_critical();
646 return;
647 }
649 const double smolar = HEOS._smolar;
650 // Strict-mode superancillary path. See GitHub #2773 / #2834.
651 if (sat_superanc_path_applies(HEOS)) {
652 const double T_super = resolve_T_via_superancillary(HEOS, iSmolar, smolar, std::nullopt, "QS_flash");
653 HEOS._T = T_super;
654 QT_flash(HEOS);
655 HEOS._smolar = smolar;
656 HEOS._phase = iphase_twophase;
657 return;
658 }
659 // Fallback: ancillary-seeded saturation_PHSU_pure path.
660 if (std::abs(HEOS.Q()) < 1e-10) {
663 options.use_logdelta = false;
664 SaturationSolvers::saturation_PHSU_pure(HEOS, smolar, options);
665 HEOS._p = HEOS.SatL->p();
666 HEOS._T = HEOS.SatL->T();
667 HEOS._rhomolar = HEOS.SatL->rhomolar();
668 } else if (std::abs(HEOS.Q() - 1) < 1e-10) {
671 options.use_logdelta = false;
672 SaturationSolvers::saturation_PHSU_pure(HEOS, smolar, options);
673 HEOS._p = HEOS.SatV->p();
674 HEOS._T = HEOS.SatV->T();
675 HEOS._rhomolar = HEOS.SatV->rhomolar();
676 } else {
677 throw ValueError(format("non-zero or 1 quality not currently allowed for QS_flash"));
678 }
679 HEOS._phase = iphase_twophase;
680}
681
682// Translate a CoolProp parameters enum to the SuperAncillary's char key.
683// Supports the three caloric saturation properties used by the #2773 paths.
684static char param_to_superanc_key(parameters key) {
685 switch (key) {
686 case iDmolar:
687 return 'D';
688 case iHmolar:
689 return 'H';
690 case iSmolar:
691 return 'S';
692 case iUmolar:
693 return 'U';
694 default:
695 throw ValueError(format("Unsupported parameters key for saturation superancillary: %d", static_cast<int>(key)));
696 }
697}
698
699// Unified helper for both the no-guess default flashes and the
700// *_flash_with_guesses paths (#2773). Looks up the saturation superancillary
701// for property `key`, handles caloric lazy-build, and returns the
702// disambiguated T-root.
703//
704// If guess_T is set, picks the monotonic sub-interval whose temperature range
705// contains guess_T (with tie-break by midpoint distance for boundary cases)
706// and TOMS748-solves there. If guess_T is std::nullopt, enumerates every root,
707// dedups near-identical roots produced at extrema by adjacent intervals, and
708// either returns the single root, throws MultipleSolutionsError, or throws
709// OutOfRangeError. The TOMS748 result is accepted as-is — superancillaries are
710// accurate to ~1e-12 relative to the multi-precision EOS reference, well below
711// the precision of any downstream EOS evaluation.
713 std::optional<double> guess_T, const char* fn_name) {
714 if (guess_T.has_value() && (!ValidNumber(*guess_T) || *guess_T <= 0)) {
715 throw ValueError(format("%s requires a positive guess.T", fn_name));
716 }
717 if (!HEOS.is_pure_or_pseudopure) {
718 throw NotImplementedError(format("%s not ready for mixtures", fn_name));
719 }
720 if (!HEOS.is_pure()) {
721 throw NotImplementedError(format("%s: superancillary path is not supported for pseudo-pure fluids", fn_name));
722 }
723 auto superanc_ptr = HEOS.get_superanc();
724 if (!superanc_ptr) {
725 throw NotImplementedError(format("%s requires a superancillary; this fluid has none", fn_name));
726 }
727 const double Q = HEOS._Q;
728 short Q_key = -1;
729 if (std::abs(Q) < Q_BOUNDARY_TOL) {
730 Q_key = 0;
731 } else if (std::abs(Q - 1) < Q_BOUNDARY_TOL) {
732 Q_key = 1;
733 } else {
734 throw ValueError(format("%s currently requires Q=0 or Q=1; got Q=%g", fn_name, Q));
735 }
736 const char k = param_to_superanc_key(key);
737 if (k == 'H' || k == 'S' || k == 'U') {
739 }
740 auto& superanc = *superanc_ptr;
741 if (!superanc.has_variable(k)) {
742 throw NotImplementedError(format("%s: %c-superancillary unavailable", fn_name, k));
743 }
744 const auto& approx = superanc.get_approx1d(k, Q_key);
745 const char* phase_name = (Q_key == 0) ? "liquid" : "vapor";
746
747 // Shift the user's target value into the cache's reference frame for H
748 // and S. The IdealHelmholtzEnthalpyEntropyOffset's effect on h(T) and
749 // s(T) along the saturation curve is exactly a constant — Δh = R·T_red·Δa2
750 // and Δs = −R·Δa1, with (a1, a2) summed across both EnthalpyEntropyOffset
751 // and EnthalpyEntropyOffsetCore and scaled by the alpha0 prefactor. So
752 // we only need to translate the target, never rebuild. See
753 // ensure_HS_under_lock comment and #2773. For D the cache is
754 // reference-state-independent intrinsically (no shift needed).
755 double target_in_cache_frame = target_value;
756 if (k == 'H' || k == 'S' || k == 'U') {
757 const auto stamp = superanc.get_caloric_alpha0_stamp();
758 if (stamp.has_value()) {
759 const auto [caller_a1, caller_a2] = alpha0_offset_total(HEOS);
760 const double cache_a1 = stamp->first;
761 const double cache_a2 = stamp->second;
762 if (k == 'H' || k == 'U') {
763 // h_cache(T) = h_caller(T) + R·T_red·(a2_cache − a2_caller); U
764 // inherits the H shift since u = h − p/rho and p/rho is offset-free.
765 const double R = HEOS.gas_constant();
766 const double T_red = HEOS.get_reducing_state().T;
767 target_in_cache_frame = target_value + R * T_red * (cache_a2 - caller_a2);
768 } else { // 'S'
769 // s_cache(T) = s_caller(T) + (−R)·(a1_cache − a1_caller)
770 const double R = HEOS.gas_constant();
771 target_in_cache_frame = target_value - R * (cache_a1 - caller_a1);
772 }
773 }
774 }
775
776 if (guess_T.has_value()) {
777 // Pick the monotonic sub-interval whose temperature range contains
778 // guess_T (and which can reach target_value in its y-range), then
779 // TOMS748 the unique root inside it. If guess_T sits exactly on an
780 // interval boundary (an extremum), tie-break by midpoint distance
781 // toward the interval the user is more plausibly aiming at.
782 const double gT = *guess_T;
783 const auto& intervals = approx.get_monotonic_intervals();
784 const auto& expansions = approx.get_expansions();
785 const auto* chosen = [&]() -> const auto* {
786 const auto* best_match = static_cast<decltype(intervals.data())>(nullptr);
787 double best_midpoint_dist = std::numeric_limits<double>::infinity();
788 for (const auto& iv : intervals) {
789 if (!iv.contains_y(target_in_cache_frame)) continue;
790 if (gT >= iv.xmin && gT <= iv.xmax) {
791 const double mid_dist = std::abs(0.5 * (iv.xmin + iv.xmax) - gT);
792 if (mid_dist < best_midpoint_dist) {
793 best_midpoint_dist = mid_dist;
794 best_match = &iv;
795 }
796 }
797 }
798 return best_match;
799 }();
800 if (chosen != nullptr) {
801 for (const auto& ei : chosen->expansioninfo) {
802 if (ei.contains_y(target_in_cache_frame)) {
803 const auto& e = expansions[ei.idx];
804 auto [xvalue, num_steps] = e.solve_for_x_count(target_in_cache_frame, ei.xmin, ei.xmax, 64, 100U, 1e-10);
805 (void)num_steps;
806 return xvalue;
807 }
808 }
809 }
810 throw SolutionError(format("%s: no T-root on saturated %s in the monotonic sub-interval containing guess.T=%g K for %c=%g; "
811 "superancillary range [%g, %g] K",
812 fn_name, phase_name, gT, k, target_value, approx.xmin(), approx.xmax()));
813 }
814
815 // No guess: enumerate every root, dedup near-identical roots from adjacent
816 // intervals at an extremum, then strict-mode-or-throw.
817 auto solns = approx.get_x_for_y(target_in_cache_frame, 64, 100U, 1e-10);
818 if (solns.empty()) {
819 throw OutOfRangeError(format("%s: no T-root on saturated %s for %c=%g; superancillary range [%g, %g] K", fn_name, phase_name, k, target_value,
820 approx.xmin(), approx.xmax()));
821 }
822 std::sort(solns.begin(), solns.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
823 std::vector<double> Ts;
824 Ts.reserve(solns.size());
825 for (const auto& s : solns) {
826 if (Ts.empty() || std::abs(s.first - Ts.back()) > T_ROOT_DEDUP_TOL) {
827 Ts.push_back(s.first);
828 }
829 }
830 if (Ts.size() > 1) {
831 std::string Ts_str;
832 for (std::size_t i = 0; i < Ts.size(); ++i) {
833 Ts_str += format("%g K", Ts[i]);
834 if (i + 1 < Ts.size()) Ts_str += ", ";
835 }
837 format("%s: %c=%g on saturated %s has %zu T-roots (%s); use update_with_guesses with guess.T to pick a branch (see GitHub #2773)", fn_name,
838 k, target_value, phase_name, Ts.size(), Ts_str.c_str()));
839 }
840 return Ts[0];
841}
842
843// Branch-disambiguating variant of DQ_flash (see GitHub #2773).
844// rho_L(T) on water and D2O is non-monotonic near 4 °C / 11 °C, so the DQ flash
845// can have two T-solutions for the same density. Use the rho_sat superancillary
846// to pick the monotonic sub-interval whose x-range contains guess.T and TOMS748-solve there.
848 const double rhomolar = HEOS._rhomolar;
849 const double T_super = resolve_T_via_superancillary(HEOS, iDmolar, rhomolar, guess.T, "DQ_flash_with_guesses");
850 HEOS._T = T_super;
852 // Despite the name, for a pure fluid with superancillaries this is a fast
853 // path: QT_flash dispatches to the rho_sat / p_sat superancillary at T —
854 // no iterative VLE solve. Sub-microsecond per call.
855 QT_flash(HEOS);
856 HEOS._rhomolar = rhomolar;
857 HEOS._phase = iphase_twophase;
858}
859
860// Branch-disambiguating variant of HQ_flash (see GitHub #2773).
861// The default HQ_flash routes through saturation_PHSU_pure with IMPOSED_HV,
862// which silently picks one of the two T-roots when h_g(T) is non-monotonic
863// (water saturated-vapor enthalpy peaks near 540 K). This variant uses the
864// h_sat superancillary (built lazily on first use) to disambiguate.
866 const double hmolar = HEOS._hmolar;
867 const double T_super = resolve_T_via_superancillary(HEOS, iHmolar, hmolar, guess.T, "HQ_flash_with_guesses");
868 HEOS._T = T_super;
870 // Fast path: superancillary eval at T, not an iterative VLE solve. See DQ_flash_with_guesses for details.
871 QT_flash(HEOS);
872 HEOS._hmolar = hmolar;
873 HEOS._phase = iphase_twophase;
874}
875
876// Branch-disambiguating variant of QS_flash. Mirrors HQ_flash_with_guesses.
878 const double smolar = HEOS._smolar;
879 const double T_super = resolve_T_via_superancillary(HEOS, iSmolar, smolar, guess.T, "QS_flash_with_guesses");
880 HEOS._T = T_super;
882 // Fast path: superancillary eval at T, not an iterative VLE solve. See DQ_flash_with_guesses for details.
883 QT_flash(HEOS);
884 HEOS._smolar = smolar;
885 HEOS._phase = iphase_twophase;
886}
887
889 CoolPropDbl T = HEOS._T;
890 CoolPropDbl Q = HEOS._Q;
891 if (HEOS.is_pure_or_pseudopure) {
892
893 if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
894 auto superanc_ptr = HEOS.get_superanc();
895 if (superanc_ptr) {
896 auto& superanc = *superanc_ptr;
897
898 CoolPropDbl Tcrit_num = superanc.get_Tcrit_num();
899 if (T > Tcrit_num) {
900 throw ValueError(
901 format("Temperature to QT_flash [%0.8Lg K] may not be above the numerical critical point of %0.15Lg K", T, Tcrit_num));
902 }
903 auto rhoL = superanc.eval_sat(T, 'D', 0);
904 auto rhoV = superanc.eval_sat(T, 'D', 1);
905 auto p = superanc.eval_sat(T, 'P', 1);
906 HEOS.SatL->update_TDmolarP_unchecked(T, rhoL, p);
907 HEOS.SatV->update_TDmolarP_unchecked(T, rhoV, p);
908 HEOS._p = p;
909 HEOS._rhomolar = 1 / (Q / rhoV + (1 - Q) / rhoL);
910 HEOS._phase = iphase_twophase;
911 return;
912 }
913 }
914
915 // The maximum possible saturation temperature
916 // Critical point for pure fluids, slightly different for pseudo-pure, very different for mixtures
917 CoolPropDbl Tmax_sat = HEOS.calc_Tmax_sat() + 1e-13;
918
919 // Check what the minimum limits for the equation of state are
920 CoolPropDbl Tmin_satL = NAN, Tmin_satV = NAN, Tmin_sat = NAN;
921 HEOS.calc_Tmin_sat(Tmin_satL, Tmin_satV);
922 Tmin_sat = std::max(Tmin_satL, Tmin_satV) - 1e-13;
923
924 // Get a reference to keep the code a bit cleaner
925 const CriticalRegionSplines& splines = HEOS.components[0].EOS().critical_region_splines;
926
927 if ((get_config_bool(CRITICAL_WITHIN_1UK) && std::abs(T - Tmax_sat) < 1e-6) || std::abs(T - Tmax_sat) < 1e-12) {
928 // If exactly(ish) at the critical temperature, liquid and vapor have the critial density
929 HEOS.SatL->update(DmolarT_INPUTS, HEOS.rhomolar_critical(), HEOS._T);
930 HEOS.SatV->update(DmolarT_INPUTS, HEOS.rhomolar_critical(), HEOS._T);
931 HEOS._rhomolar = HEOS.rhomolar_critical();
932 HEOS._p = 0.5 * HEOS.SatV->p() + 0.5 * HEOS.SatL->p();
933 } else if (!is_in_closed_range(Tmin_sat - 0.1, Tmax_sat, T) && (CoolProp::get_config_bool(DONT_CHECK_PROPERTY_LIMITS) == false)) {
934 throw ValueError(format("Temperature to QT_flash [%0.8Lg K] must be in range [%0.8Lg K, %0.8Lg K]", T, Tmin_sat - 0.1, Tmax_sat));
935 } else if (get_config_bool(CRITICAL_SPLINES_ENABLED) && splines.enabled && HEOS._T > splines.T_min) {
936 double rhoL = _HUGE, rhoV = _HUGE;
937 // Use critical region spline if it has it and temperature is in its range
938 splines.get_densities(T, splines.rhomolar_min, HEOS.rhomolar_critical(), splines.rhomolar_max, rhoL, rhoV);
939 HEOS.SatL->update(DmolarT_INPUTS, rhoL, HEOS._T);
940 HEOS.SatV->update(DmolarT_INPUTS, rhoV, HEOS._T);
941 HEOS._p = 0.5 * HEOS.SatV->p() + 0.5 * HEOS.SatL->p();
942 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
943 } else if (!(HEOS.components[0].EOS().pseudo_pure)) {
944 // Set some input options
946
947 // Actually call the solver
949
950 HEOS._p = 0.5 * HEOS.SatV->p() + 0.5 * HEOS.SatL->p();
951 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
952 } else {
953 // Pseudo-pure fluid
954 CoolPropDbl rhoLanc = _HUGE, rhoVanc = _HUGE, rhoLsat = _HUGE, rhoVsat = _HUGE;
955 if (std::abs(HEOS._Q) < DBL_EPSILON) {
956 HEOS._p = HEOS.components[0].ancillaries.pL.evaluate(HEOS._T); // These ancillaries are used explicitly
957 rhoLanc = HEOS.components[0].ancillaries.rhoL.evaluate(HEOS._T);
958 HEOS.SatL->update_TP_guessrho(HEOS._T, HEOS._p, rhoLanc);
959 HEOS._rhomolar = HEOS.SatL->rhomolar();
960 } else if (std::abs(HEOS._Q - 1) < DBL_EPSILON) {
961 HEOS._p = HEOS.components[0].ancillaries.pV.evaluate(HEOS._T); // These ancillaries are used explicitly
962 rhoVanc = HEOS.components[0].ancillaries.rhoV.evaluate(HEOS._T);
963 HEOS.SatV->update_TP_guessrho(HEOS._T, HEOS._p, rhoVanc);
964 HEOS._rhomolar = HEOS.SatV->rhomolar();
965 } else {
966 throw CoolProp::ValueError(format("For pseudo-pure fluid, quality must be equal to 0 or 1. Two-phase quality is not defined"));
967 }
968
969 try {
970 } catch (...) {
971 // Near the critical point, the behavior is not very nice, so we will just use the ancillary
972 rhoLsat = rhoLanc;
973 rhoVsat = rhoVanc;
974 }
975 }
976 // Load the outputs
977 HEOS._phase = iphase_twophase;
978 } else {
979 // With a phase envelope available, try the envelope-guided fast path: it warm-starts
980 // the dew/bubble and two-phase solves from the envelope. The two-phase solver now
981 // damps its Newton step and reports non-convergence rather than returning a stale
982 // guess (GH #3192); as a safety net, any failure falls back to the blind
983 // successive-substitution + newton_raphson_saturation path, which is robust to ~1e-9%.
984 bool solved_with_envelope = false;
985 if (HEOS.PhaseEnvelope.built) {
986 // PT_Q_flash_mixtures mutates HEOS._T/_p while seeding its solve. Save the caller's
987 // inputs and restore them on failure so the blind fallback runs from the original
988 // state. (The blind QT path only reads the imposed HEOS._T, which the seed preserves,
989 // so this is defensive; it keeps the contract explicit if either side changes.)
990 const CoolPropDbl T_in = HEOS._T, p_in = HEOS._p;
991 try {
992 PT_Q_flash_mixtures(HEOS, iT, HEOS._T);
993 solved_with_envelope = true;
994 } catch (...) {
995 HEOS._T = T_in;
996 HEOS._p = p_in;
997 solved_with_envelope = false; // fall back to the blind solver below
998 }
999 }
1000 if (!solved_with_envelope) {
1001 // Set some input options
1004 options.Nstep_max = 20;
1005
1006 // Get an extremely rough guess by interpolation of ln(p) v. T curve where the limits are mole-fraction-weighted
1008
1009 // Use Wilson iteration to obtain updated guess for pressure
1011
1012 // Actually call the successive substitution solver
1013 SaturationSolvers::successive_substitution(HEOS, HEOS._Q, HEOS._T, pguess, HEOS.mole_fractions, HEOS.K, options);
1014
1015 // -----
1016 // Newton-Raphson
1017 // -----
1018
1021
1022 IO.bubble_point = (HEOS._Q < 0.5);
1023
1024 IO.x = options.x;
1025 IO.y = options.y;
1026 IO.rhomolar_liq = options.rhomolar_liq;
1027 IO.rhomolar_vap = options.rhomolar_vap;
1028 IO.T = options.T;
1029 IO.p = options.p;
1030 IO.Nstep_max = 30;
1031
1033
1034 if (IO.bubble_point) {
1035 // Compositions are z, z_incipient
1036 NR.call(HEOS, IO.x, IO.y, IO);
1037 } else {
1038 // Compositions are z, z_incipient
1039 NR.call(HEOS, IO.y, IO.x, IO);
1040 }
1041
1042 HEOS._p = IO.p;
1043 HEOS._rhomolar = 1 / (HEOS._Q / IO.rhomolar_vap + (1 - HEOS._Q) / IO.rhomolar_liq);
1044 }
1045 // Load the outputs
1046 HEOS._phase = iphase_twophase;
1047 HEOS._p = HEOS.SatV->p();
1048 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
1049 HEOS._T = HEOS.SatL->T();
1050 }
1051}
1052
1053void get_Henrys_coeffs_FP(const std::string& CAS, double& A, double& B, double& C, double& Tmin, double& Tmax) {
1054 // Coeffs from Fernandez-Prini JPCRD 2003 DOI: 10.1063/1.1564818
1055 if (CAS == "7440-59-7") //Helium
1056 {
1057 A = -3.52839;
1058 B = 7.12983;
1059 C = 4.47770;
1060 Tmin = 273.21;
1061 Tmax = 553.18;
1062 } else if (CAS == "7440-01-9") // Ne
1063 {
1064 A = -3.18301;
1065 B = 5.31448;
1066 C = 5.43774;
1067 Tmin = 273.20;
1068 Tmax = 543.36;
1069 } else if (CAS == "7440-37-1") // Ar
1070 {
1071 A = -8.40954;
1072 B = 4.29587;
1073 C = 10.52779;
1074 Tmin = 273.19;
1075 Tmax = 568.36;
1076 } else if (CAS == "7439-90-9") // Kr
1077 {
1078 A = -8.97358;
1079 B = 3.61508;
1080 C = 11.29963;
1081 Tmin = 273.19;
1082 Tmax = 525.56;
1083 } else if (CAS == "7440-63-3") // Xe
1084 {
1085 A = -14.21635;
1086 B = 4.00041;
1087 C = 15.60999;
1088 Tmin = 273.22;
1089 Tmax = 574.85;
1090 } else if (CAS == "1333-74-0") // H2
1091 {
1092 A = -4.73284;
1093 B = 6.08954;
1094 C = 6.06066;
1095 Tmin = 273.15;
1096 Tmax = 636.09;
1097 } else if (CAS == "7727-37-9") // N2
1098 {
1099 A = -9.67578;
1100 B = 4.72162;
1101 C = 11.70585;
1102 Tmin = 278.12;
1103 Tmax = 636.46;
1104 } else if (CAS == "7782-44-7") // O2
1105 {
1106 A = -9.44833;
1107 B = 4.43822;
1108 C = 11.42005;
1109 Tmin = 274.15;
1110 Tmax = 616.52;
1111 } else if (CAS == "630-08-0") // CO
1112 {
1113 A = -10.52862;
1114 B = 5.13259;
1115 C = 12.01421;
1116 Tmin = 278.15;
1117 Tmax = 588.67;
1118 } else if (CAS == "124-38-9") // CO2
1119 {
1120 A = -8.55445;
1121 B = 4.01195;
1122 C = 9.52345;
1123 Tmin = 274.19;
1124 Tmax = 642.66;
1125 } else if (CAS == "7783-06-4") // H2S
1126 {
1127 A = -4.51499;
1128 B = 5.23538;
1129 C = 4.42126;
1130 Tmin = 273.15;
1131 Tmax = 533.09;
1132 } else if (CAS == "74-82-8") // CH4
1133 {
1134 A = -10.44708;
1135 B = 4.66491;
1136 C = 12.12986;
1137 Tmin = 275.46;
1138 Tmax = 633.11;
1139 } else if (CAS == "74-84-0") // C2H6
1140 {
1141 A = -19.67563;
1142 B = 4.51222;
1143 C = 20.62567;
1144 Tmin = 275.44;
1145 Tmax = 473.46;
1146 } else if (CAS == "2551-62-4") // SF6
1147 {
1148 A = -16.56118;
1149 B = 2.15289;
1150 C = 20.35440;
1151 Tmin = 283.14;
1152 Tmax = 505.55;
1153 } else {
1154 throw ValueError("Bad component in Henry's law constants");
1155 }
1156}
1158 if (HEOS.is_pure_or_pseudopure) {
1159
1160 if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
1161 auto superanc_ptr = HEOS.get_superanc();
1162 if (superanc_ptr) {
1163 auto& superanc = *superanc_ptr;
1164 CoolPropDbl pmax_num = superanc.get_pmax();
1165 if (HEOS._p > pmax_num) {
1166 throw ValueError(
1167 format("Pressure to PQ_flash [%0.8Lg Pa] may not be above the numerical critical point of %0.15Lg Pa", HEOS._p, pmax_num));
1168 }
1169 auto T = superanc.get_T_from_p(HEOS._p);
1170 auto rhoL = superanc.eval_sat(T, 'D', 0);
1171 auto rhoV = superanc.eval_sat(T, 'D', 1);
1172 auto p = HEOS._p;
1173 HEOS.SatL->update_TDmolarP_unchecked(T, rhoL, p);
1174 HEOS.SatV->update_TDmolarP_unchecked(T, rhoV, p);
1175 HEOS._T = T;
1176 HEOS._p = p;
1177 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
1178 HEOS._phase = iphase_twophase;
1179 return;
1180 }
1181 }
1182
1183 if (HEOS.components[0].EOS().pseudo_pure) {
1184 // It is a pseudo-pure mixture
1185
1186 HEOS._TLanc = HEOS.components[0].ancillaries.pL.invert(HEOS._p);
1187 HEOS._TVanc = HEOS.components[0].ancillaries.pV.invert(HEOS._p);
1188 // Get guesses for the ancillaries for density
1189 CoolPropDbl rhoL = HEOS.components[0].ancillaries.rhoL.evaluate(HEOS._TLanc);
1190 CoolPropDbl rhoV = HEOS.components[0].ancillaries.rhoV.evaluate(HEOS._TVanc);
1191 // Solve for the density
1192 HEOS.SatL->update_TP_guessrho(HEOS._TLanc, HEOS._p, rhoL);
1193 HEOS.SatV->update_TP_guessrho(HEOS._TVanc, HEOS._p, rhoV);
1194
1195 // Load the outputs
1196 HEOS._phase = iphase_twophase;
1197 HEOS._p = HEOS._Q * HEOS.SatV->p() + (1 - HEOS._Q) * HEOS.SatL->p();
1198 HEOS._T = HEOS._Q * HEOS.SatV->T() + (1 - HEOS._Q) * HEOS.SatL->T();
1199 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
1200 } else {
1201 // Critical point for pure fluids, slightly different for pseudo-pure, very different for mixtures
1202 CoolPropDbl pmax_sat = HEOS.calc_pmax_sat();
1203
1204 // Check what the minimum limits for the equation of state are
1205 CoolPropDbl pmin_satL = NAN, pmin_satV = NAN, pmin_sat = NAN;
1206 HEOS.calc_pmin_sat(pmin_satL, pmin_satV);
1207 pmin_sat = std::max(pmin_satL, pmin_satV);
1208
1209 // Check for being AT the critical point
1210 if (is_in_closed_range(pmax_sat * (1 - 1e-10), pmax_sat * (1 + 1e-10), static_cast<CoolPropDbl>(HEOS._p))) {
1211 // Load the outputs
1213 HEOS._p = HEOS.p_critical();
1214 HEOS._rhomolar = HEOS.rhomolar_critical();
1215 HEOS._T = HEOS.T_critical();
1216 return;
1217 }
1218
1219 // Check limits
1220 if (CoolProp::get_config_bool(DONT_CHECK_PROPERTY_LIMITS) == false) {
1221 if (!is_in_closed_range(pmin_sat * 0.999999, pmax_sat * 1.000001, static_cast<CoolPropDbl>(HEOS._p))) {
1222 throw ValueError(format("Pressure to PQ_flash [%6g Pa] must be in range [%8Lg Pa, %8Lg Pa]", HEOS._p, pmin_sat, pmax_sat));
1223 }
1224 }
1225 // ------------------
1226 // It is a pure fluid
1227 // ------------------
1228
1229 // Set some input options
1231 // Specified variable is pressure
1233 // Use logarithm of delta as independent variables
1234 options.use_logdelta = false;
1235
1236 double increment = 0.4;
1237
1238 try {
1239 // Newton-step damping: 3 iters (omega = 1.0, 0.6, 0.2)
1240 // — bounded loop, no accumulation issue worth rewriting.
1241 for (double omega = 1.0; omega > 0; omega -= increment) { // NOLINT(cert-flp30-c)
1242 try {
1243 options.omega = omega;
1244
1245 // Actually call the solver
1246 SaturationSolvers::saturation_PHSU_pure(HEOS, HEOS._p, options);
1247
1248 // If you get here, there was no error, all is well
1249 break;
1250 } catch (...) {
1251 if (omega < 1.1 * increment) {
1252 throw;
1253 }
1254 // else we are going to try again with a smaller omega
1255 }
1256 }
1257 } catch (...) {
1258 // We may need to polish the solution at low pressure
1259 SaturationSolvers::saturation_P_pure_1D_T(HEOS, HEOS._p, options);
1260 }
1261
1262 // Load the outputs
1263 HEOS._phase = iphase_twophase;
1264 HEOS._p = HEOS._Q * HEOS.SatV->p() + (1 - HEOS._Q) * HEOS.SatL->p();
1265 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
1266 HEOS._T = HEOS.SatL->T();
1267 }
1268 } else {
1269 // Try the envelope-guided fast path when an envelope is available; on any failure fall
1270 // back to the blind successive-substitution + newton_raphson_saturation path. See
1271 // QT_flash above (GH #3192).
1272 bool solved_with_envelope = false;
1273 if (HEOS.PhaseEnvelope.built) {
1274 // Save/restore the caller's inputs around the envelope solve (see QT_flash): the blind
1275 // PQ fallback reads the imposed HEOS._p (preserved by the seed), so this is defensive.
1276 const CoolPropDbl T_in = HEOS._T, p_in = HEOS._p;
1277 try {
1278 PT_Q_flash_mixtures(HEOS, iP, HEOS._p);
1279 solved_with_envelope = true;
1280 } catch (...) {
1281 HEOS._T = T_in;
1282 HEOS._p = p_in;
1283 solved_with_envelope = false; // fall back to the blind solver below
1284 }
1285 }
1286 if (!solved_with_envelope) {
1287
1288 // Set some input options
1291 io.Nstep_max = 10;
1292
1293 // Get an extremely rough guess by interpolation of ln(p) v. T curve where the limits are mole-fraction-weighted
1295
1296 // Use Wilson iteration to obtain updated guess for temperature
1298
1299 std::vector<CoolPropDbl> K = HEOS.K;
1300
1301 if (get_config_bool(HENRYS_LAW_TO_GENERATE_VLE_GUESSES) && std::abs(HEOS._Q - 1) < 1e-10) {
1302 const std::vector<CoolPropFluid>& components = HEOS.get_components();
1303 std::size_t iWater = 0;
1304 double p1star = PropsSI("P", "T", Tguess, "Q", 1, "Water");
1305 const std::vector<CoolPropDbl> y = HEOS.mole_fractions;
1306 std::vector<CoolPropDbl> x(y.size());
1307 for (std::size_t i = 0; i < components.size(); ++i) {
1308 if (components[i].CAS == "7732-18-5") {
1309 iWater = i;
1310 continue;
1311 } else {
1312 double A = NAN, B = NAN, C = NAN, Tmin = NAN, Tmax = NAN;
1313 get_Henrys_coeffs_FP(components[i].CAS, A, B, C, Tmin, Tmax);
1314 double T_R = Tguess / 647.096, tau = 1 - T_R;
1315 double k_H = p1star * exp(A / T_R + B * pow(tau, 0.355) / T_R + C * pow(T_R, -0.41) * exp(tau));
1316 x[i] = y[i] * HEOS._p / k_H;
1317 //
1318 K[i] = y[i] / x[i];
1319 }
1320 }
1321 // Update water K factor
1322 double summer = 0;
1323 for (std::size_t i = 0; i < y.size(); ++i) {
1324 if (i != iWater) {
1325 summer += x[i];
1326 }
1327 }
1328 x[iWater] = summer;
1329 K[iWater] = y[iWater] / x[iWater];
1330 }
1331
1332 // Actually call the successive substitution solver
1333 SaturationSolvers::successive_substitution(HEOS, HEOS._Q, Tguess, HEOS._p, HEOS.mole_fractions, K, io);
1334
1335 // -----
1336 // Newton-Raphson
1337 // -----
1338
1341
1342 IO.bubble_point = (HEOS._Q < 0.5);
1343 IO.x = io.x;
1344 IO.y = io.y;
1345 IO.rhomolar_liq = io.rhomolar_liq;
1346 IO.rhomolar_vap = io.rhomolar_vap;
1347 IO.T = io.T;
1348 IO.p = io.p;
1349 IO.Nstep_max = 30;
1351
1352 if (IO.bubble_point) {
1353 // Compositions are z, z_incipient
1354 NR.call(HEOS, IO.x, IO.y, IO);
1355 } else {
1356 // Compositions are z, z_incipient
1357 NR.call(HEOS, IO.y, IO.x, IO);
1358 }
1359 }
1360
1361 // Load the outputs
1362 HEOS._phase = iphase_twophase;
1363 HEOS._p = HEOS.SatV->p();
1364 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
1365 HEOS._T = HEOS.SatL->T();
1366 }
1367}
1368
1372 IO.rhomolar_liq = guess.rhomolar_liq;
1373 IO.rhomolar_vap = guess.rhomolar_vap;
1374 IO.x = std::vector<CoolPropDbl>(guess.x.begin(), guess.x.end());
1375 IO.y = std::vector<CoolPropDbl>(guess.y.begin(), guess.y.end());
1376 IO.T = guess.T;
1377 IO.p = HEOS._p;
1378 IO.bubble_point = false;
1380
1381 if (std::abs(HEOS.Q()) < 1e-10) {
1382 IO.bubble_point = true;
1383 NR.call(HEOS, IO.x, IO.y, IO);
1384 } else if (std::abs(HEOS.Q() - 1) < 1e-10) {
1385 IO.bubble_point = false;
1386 NR.call(HEOS, IO.y, IO.x, IO);
1387 } else {
1388 throw ValueError(format("Quality must be 0 or 1"));
1389 }
1390
1391 // Load the other outputs
1392 HEOS._phase = iphase_twophase;
1393 HEOS._rhomolar = 1 / (HEOS._Q / IO.rhomolar_vap + (1 - HEOS._Q) / IO.rhomolar_liq);
1394 HEOS._T = IO.T;
1395}
1399 IO.rhomolar_liq = guess.rhomolar_liq;
1400 IO.rhomolar_vap = guess.rhomolar_vap;
1401 IO.x = std::vector<CoolPropDbl>(guess.x.begin(), guess.x.end());
1402 IO.y = std::vector<CoolPropDbl>(guess.y.begin(), guess.y.end());
1403 IO.T = HEOS._T;
1404 IO.p = guess.p;
1405 IO.bubble_point = false;
1407
1408 if (get_debug_level() > 9) {
1409 std::cout << format(" QT w/ guess p %g T %g dl %g dv %g x %s y %s\n", IO.p, IO.T, IO.rhomolar_liq, IO.rhomolar_vap,
1410 vec_to_string(IO.x, "%g").c_str(), vec_to_string(IO.y, "%g").c_str());
1411 }
1412
1413 if (std::abs(HEOS.Q()) < 1e-10) {
1414 IO.bubble_point = true;
1415 NR.call(HEOS, IO.x, IO.y, IO);
1416 } else if (std::abs(HEOS.Q() - 1) < 1e-10) {
1417 IO.bubble_point = false;
1418 NR.call(HEOS, IO.y, IO.x, IO);
1419 } else {
1420 throw ValueError(format("Quality must be 0 or 1"));
1421 }
1422
1423 // Load the other outputs
1424 HEOS._p = IO.p;
1425 HEOS._phase = iphase_twophase;
1426 HEOS._rhomolar = 1 / (HEOS._Q / IO.rhomolar_vap + (1 - HEOS._Q) / IO.rhomolar_liq);
1427}
1428
1430 HEOS.solver_rho_Tp(HEOS.T(), HEOS.p(), guess.rhomolar);
1431 // Load the other outputs
1432 HEOS._phase = iphase_gas; // Guessed for mixtures
1433 if (HEOS.is_pure_or_pseudopure) {
1434 if (HEOS._p > HEOS.p_critical()) {
1435 if (HEOS._T > HEOS.T_critical()) {
1437 } else {
1439 }
1440 } else {
1441 if (HEOS._T > HEOS.T_critical()) {
1443 } else if (HEOS._rhomolar > HEOS.rhomolar_critical()) {
1444 HEOS._phase = iphase_liquid;
1445 } else {
1446 HEOS._phase = iphase_gas;
1447 }
1448 }
1449 }
1450
1451 HEOS._Q = -1;
1452}
1453
1455
1456 // Find the intersections in the phase envelope
1457 std::vector<std::pair<std::size_t, std::size_t>> intersections =
1459
1460 PhaseEnvelopeData& env = HEOS.PhaseEnvelope;
1461
1462 // The CubicInterp guesses below use a 4-point stencil [i-1 .. i+2], so the envelope must
1463 // hold at least 4 points for the index clamps to keep that stencil in range. A
1464 // successfully built envelope always has many more, but guard defensively: too few points
1465 // -> throw, so the caller's try/catch falls back to the blind solver (GH #3192).
1466 if (env.T.size() < 4) {
1467 throw ValueError(format("Phase envelope has too few points (%d) for envelope-guided flash", static_cast<int>(env.T.size())));
1468 }
1469
1470 enum quality_options
1471 {
1472 SATURATED_LIQUID,
1473 SATURATED_VAPOR,
1474 TWO_PHASE
1475 };
1476 quality_options quality;
1477 if (std::abs(HEOS._Q) < 100 * DBL_EPSILON) {
1478 quality = SATURATED_LIQUID;
1479 } else if (std::abs(HEOS._Q - 1) < 100 * DBL_EPSILON) {
1480 quality = SATURATED_VAPOR;
1481 } else if (HEOS._Q > 0 && HEOS._Q < 1) {
1482 quality = TWO_PHASE;
1483 } else {
1484 throw ValueError("Quality is not within 0 and 1");
1485 }
1486
1487 if (quality == SATURATED_LIQUID || quality == SATURATED_VAPOR) {
1488 // *********************************************************
1489 // Bubble- or dew-point calculation
1490 // *********************************************************
1491 // Find the correct solution
1492 std::vector<std::size_t> solutions;
1493 for (const auto& intersection : intersections) {
1494 if (std::abs(env.Q[intersection.first] - HEOS._Q) < 10 * DBL_EPSILON
1495 && std::abs(env.Q[intersection.second] - HEOS._Q) < 10 * DBL_EPSILON) {
1496 solutions.push_back(intersection.first);
1497 }
1498 }
1499
1500 if (solutions.size() == 1) {
1501
1502 std::size_t& imax = solutions[0];
1503
1504 // Clamp imax so the 4-point CubicInterp stencil [imax-1 .. imax+2] stays within
1505 // [0, env.T.size()-1]. env.T.size() >= 4 is guaranteed above, so size-3 >= 1 and
1506 // the two clamps are consistent (ported from PR #2720, GH #3192). The previous
1507 // imax--/imax++ shift could still leave an index out of range for short envelopes.
1508 imax = std::min(imax, env.T.size() - 3); // ensures imax+2 <= env.T.size()-1
1509 imax = std::max(imax, std::size_t(1)); // ensures imax-1 >= 0
1510
1513
1514 if (other == iP) {
1515 IO.p = HEOS._p;
1517 // p -> rhomolar_vap
1518 IO.rhomolar_vap = CubicInterp(env.p, env.rhomolar_vap, imax - 1, imax, imax + 1, imax + 2, static_cast<CoolPropDbl>(IO.p));
1519 IO.T = CubicInterp(env.rhomolar_vap, env.T, imax - 1, imax, imax + 1, imax + 2, IO.rhomolar_vap);
1520 } else if (other == iT) {
1521 IO.T = HEOS._T;
1523 // T -> rhomolar_vap
1524 IO.rhomolar_vap = CubicInterp(env.T, env.rhomolar_vap, imax - 1, imax, imax + 1, imax + 2, static_cast<CoolPropDbl>(IO.T));
1525 IO.p = CubicInterp(env.rhomolar_vap, env.p, imax - 1, imax, imax + 1, imax + 2, IO.rhomolar_vap);
1526 } else {
1527 throw ValueError();
1528 }
1529 IO.rhomolar_liq = CubicInterp(env.rhomolar_vap, env.rhomolar_liq, imax - 1, imax, imax + 1, imax + 2, IO.rhomolar_vap);
1530
1531 if (quality == SATURATED_VAPOR) {
1532 IO.bubble_point = false;
1533 IO.y = HEOS.get_mole_fractions(); // Because Q = 1
1534 IO.x.resize(IO.y.size());
1535 for (std::size_t i = 0; i < IO.x.size() - 1; ++i) // First N-1 elements
1536 {
1537 IO.x[i] = CubicInterp(env.rhomolar_vap, env.x[i], imax - 1, imax, imax + 1, imax + 2, IO.rhomolar_vap);
1538 }
1539 IO.x[IO.x.size() - 1] = 1 - std::accumulate(IO.x.begin(), IO.x.end() - 1, 0.0);
1540 NR.call(HEOS, IO.y, IO.x, IO);
1541 } else {
1542 IO.bubble_point = true;
1543 IO.x = HEOS.get_mole_fractions(); // Because Q = 0
1544 IO.y.resize(IO.x.size());
1545 // Phases are inverted, so "liquid" is actually the lighter phase
1546 std::swap(IO.rhomolar_liq, IO.rhomolar_vap);
1547 for (std::size_t i = 0; i < IO.y.size() - 1; ++i) // First N-1 elements
1548 {
1549 // Phases are inverted, so liquid mole fraction (x) of phase envelope is actually the vapor phase mole fraction
1550 // Use the liquid density as well
1551 IO.y[i] = CubicInterp(env.rhomolar_vap, env.x[i], imax - 1, imax, imax + 1, imax + 2, IO.rhomolar_liq);
1552 }
1553 IO.y[IO.y.size() - 1] = 1 - std::accumulate(IO.y.begin(), IO.y.end() - 1, 0.0);
1554 NR.call(HEOS, IO.x, IO.y, IO);
1555 }
1556 } else if (solutions.size() == 0) {
1557 throw ValueError("No solution was found in PQ_flash");
1558 } else {
1559 throw ValueError("More than 1 solution was found in PQ_flash");
1560 }
1561 } else {
1562 // *********************************************************
1563 // Two-phase calculation for given vapor quality
1564 // *********************************************************
1565
1566 // Find the correct solution
1567 std::vector<std::size_t> liquid_solutions, vapor_solutions;
1568 for (const auto& intersection : intersections) {
1569 if (std::abs(env.Q[intersection.first] - 0) < 10 * DBL_EPSILON && std::abs(env.Q[intersection.second] - 0) < 10 * DBL_EPSILON) {
1570 liquid_solutions.push_back(intersection.first);
1571 }
1572 if (std::abs(env.Q[intersection.first] - 1) < 10 * DBL_EPSILON && std::abs(env.Q[intersection.second] - 1) < 10 * DBL_EPSILON) {
1573 vapor_solutions.push_back(intersection.first);
1574 }
1575 }
1576
1577 if (liquid_solutions.size() != 1 || vapor_solutions.size() != 1) {
1578 throw ValueError(format("Number liquid solutions [%d] or vapor solutions [%d] != 1", liquid_solutions.size(), vapor_solutions.size()));
1579 }
1580 std::size_t iliq = liquid_solutions[0], ivap = vapor_solutions[0];
1581
1582 // Clamp ivap and iliq so the 4-point CubicInterp stencil [i-1 .. i+2] below stays
1583 // within [0, env.T.size()-1]. Without this an envelope intersection at index 0 or
1584 // near the end indexes out of bounds (size_t underflow on i-1) -> UB, which the
1585 // try/catch fallback cannot trap. env.T.size() >= 4 is guaranteed above, so
1586 // size-3 >= 1 and the min/max clamps are consistent (ported from PR #2720, GH #3192).
1587 const std::size_t Nenv = env.T.size();
1588 ivap = std::min(ivap, Nenv - 3);
1589 iliq = std::min(iliq, Nenv - 3);
1590 ivap = std::max(ivap, std::size_t(1));
1591 iliq = std::max(iliq, std::size_t(1));
1592
1595 IO.beta = HEOS._Q;
1596
1597 CoolPropDbl rhomolar_vap_sat_vap = NAN, T_sat_vap = NAN, rhomolar_liq_sat_vap = NAN, rhomolar_liq_sat_liq = NAN, T_sat_liq = NAN,
1598 rhomolar_vap_sat_liq = NAN, p_sat_liq = NAN, p_sat_vap = NAN;
1599
1600 if (other == iP) {
1601 IO.p = HEOS._p;
1602 p_sat_liq = IO.p;
1603 p_sat_vap = IO.p;
1605
1606 // Calculate the interpolated values for beta = 0 and beta = 1
1607 rhomolar_vap_sat_vap = CubicInterp(env.p, env.rhomolar_vap, ivap - 1, ivap, ivap + 1, ivap + 2, static_cast<CoolPropDbl>(IO.p));
1608 T_sat_vap = CubicInterp(env.rhomolar_vap, env.T, ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1609 rhomolar_liq_sat_vap = CubicInterp(env.rhomolar_vap, env.rhomolar_liq, ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1610
1611 // Phase inversion for liquid solution (liquid is vapor and vice versa)
1612 rhomolar_liq_sat_liq = CubicInterp(env.p, env.rhomolar_vap, iliq - 1, iliq, iliq + 1, iliq + 2, static_cast<CoolPropDbl>(IO.p));
1613 T_sat_liq = CubicInterp(env.rhomolar_vap, env.T, iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1614 rhomolar_vap_sat_liq = CubicInterp(env.rhomolar_vap, env.rhomolar_liq, iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1615 } else if (other == iT) {
1616 IO.T = HEOS._T;
1617 T_sat_liq = IO.T;
1618 T_sat_vap = IO.T;
1620
1621 // Calculate the interpolated values for beta = 0 and beta = 1
1622 rhomolar_vap_sat_vap = CubicInterp(env.T, env.rhomolar_vap, ivap - 1, ivap, ivap + 1, ivap + 2, static_cast<CoolPropDbl>(IO.T));
1623 p_sat_vap = CubicInterp(env.rhomolar_vap, env.p, ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1624 rhomolar_liq_sat_vap = CubicInterp(env.rhomolar_vap, env.rhomolar_liq, ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1625
1626 // Phase inversion for liquid solution (liquid is vapor and vice versa)
1627 rhomolar_liq_sat_liq = CubicInterp(env.T, env.rhomolar_vap, iliq - 1, iliq, iliq + 1, iliq + 2, static_cast<CoolPropDbl>(IO.T));
1628 p_sat_liq = CubicInterp(env.rhomolar_vap, env.p, iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1629 rhomolar_vap_sat_liq = CubicInterp(env.rhomolar_vap, env.rhomolar_liq, iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1630 } else {
1631 throw ValueError();
1632 }
1633
1634 // Weight the guesses by the vapor mole fraction
1635 IO.rhomolar_vap = IO.beta * rhomolar_vap_sat_vap + (1 - IO.beta) * rhomolar_vap_sat_liq;
1636 IO.rhomolar_liq = IO.beta * rhomolar_liq_sat_vap + (1 - IO.beta) * rhomolar_liq_sat_liq;
1637 IO.T = IO.beta * T_sat_vap + (1 - IO.beta) * T_sat_liq;
1638 IO.p = IO.beta * p_sat_vap + (1 - IO.beta) * p_sat_liq;
1639
1640 IO.z = HEOS.get_mole_fractions();
1641 IO.x.resize(IO.z.size());
1642 IO.y.resize(IO.z.size());
1643
1644 for (std::size_t i = 0; i < IO.x.size() - 1; ++i) // First N-1 elements
1645 {
1646 CoolPropDbl x_sat_vap = CubicInterp(env.rhomolar_vap, env.x[i], ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1647 CoolPropDbl y_sat_vap = CubicInterp(env.rhomolar_vap, env.y[i], ivap - 1, ivap, ivap + 1, ivap + 2, rhomolar_vap_sat_vap);
1648
1649 CoolPropDbl x_sat_liq = CubicInterp(env.rhomolar_vap, env.y[i], iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1650 CoolPropDbl y_sat_liq = CubicInterp(env.rhomolar_vap, env.x[i], iliq - 1, iliq, iliq + 1, iliq + 2, rhomolar_liq_sat_liq);
1651
1652 IO.x[i] = IO.beta * x_sat_vap + (1 - IO.beta) * x_sat_liq;
1653 IO.y[i] = IO.beta * y_sat_vap + (1 - IO.beta) * y_sat_liq;
1654 }
1655 IO.x[IO.x.size() - 1] = 1 - std::accumulate(IO.x.begin(), IO.x.end() - 1, 0.0);
1656 IO.y[IO.y.size() - 1] = 1 - std::accumulate(IO.y.begin(), IO.y.end() - 1, 0.0);
1657
1658 // Refine the envelope-interpolated composition/density guess with a few bounded
1659 // successive-substitution steps before the Newton solve. SS updates x,y via
1660 // x_and_y_from_K + normalize, so they stay inside the simplex by construction and the
1661 // (damped) Newton-Raphson gets a feasible, fugacity-consistent start, improving
1662 // robustness and the round-trip accuracy (ported from PR #2720, GH #3192). SS runs at
1663 // fixed (T,p), so seed both from the interpolated guess; T (or p) is then refined by NR.
1664 HEOS._T = IO.T;
1665 HEOS._p = IO.p;
1667
1668 NR.call(HEOS, IO);
1669 }
1670}
1672 class Residual : public FuncWrapper1D
1673 {
1674
1675 public:
1677 CoolPropDbl rhomolar_spec; // Specified value for density
1678 parameters other; // Key for other value
1679 CoolPropDbl value; // value for S,H,U
1680 CoolPropDbl Qd; // Quality from density
1681 Residual(HelmholtzEOSMixtureBackend& HEOS, CoolPropDbl rhomolar_spec, parameters other, CoolPropDbl value)
1682 : HEOS(HEOS), rhomolar_spec(rhomolar_spec), other(other), value(value), Qd(_HUGE) {
1683
1684 };
1685 double call(double T) override {
1686 HEOS.update(QT_INPUTS, 0, T);
1687 HelmholtzEOSMixtureBackend &SatL = HEOS.get_SatL(), &SatV = HEOS.get_SatV();
1688 // Quality from density
1689 Qd = (1 / rhomolar_spec - 1 / SatL.rhomolar()) / (1 / SatV.rhomolar() - 1 / SatL.rhomolar());
1690 // Quality from other parameter (H,S,U)
1691 CoolPropDbl Qo = (value - SatL.keyed_output(other)) / (SatV.keyed_output(other) - SatL.keyed_output(other));
1692 // Residual is the difference between the two
1693 return Qo - Qd;
1694 }
1695 } resid(HEOS, rhomolar_spec, other, value);
1696
1697 // Critical point for pure fluids, slightly different for pseudo-pure, very different for mixtures
1698 CoolPropDbl Tmax_sat = HEOS.calc_Tmax_sat() - 1e-13;
1699
1700 // Check what the minimum limits for the equation of state are
1701 CoolPropDbl Tmin_satL = NAN, Tmin_satV = NAN, Tmin_sat = NAN;
1702 HEOS.calc_Tmin_sat(Tmin_satL, Tmin_satV);
1703 Tmin_sat = std::max(Tmin_satL, Tmin_satV) - 1e-13;
1704
1705 Brent(resid, Tmin_sat, Tmax_sat - 0.01, DBL_EPSILON, 1e-12, 20);
1706 // Solve once more with the final vapor quality
1707 HEOS.update(QT_INPUTS, resid.Qd, HEOS.T());
1708}
1709// D given and one of P,H,S,U
1711 // Two-phase residual for the superancillary "happy path": for a trial
1712 // saturation temperature T it reads the saturated densities (and pressure)
1713 // straight off the superancillary, sets the SatL/SatV sub-states, and
1714 // returns the difference between the vapor quality implied by the density
1715 // and the vapor quality implied by the caloric input (H/S/U). Driving
1716 // this to zero locates the two-phase saturation temperature. See
1717 // CoolProp-j3n; ported from the saD_HSU branch.
1718 class solver_resid_2phase : public FuncWrapper1D
1719 {
1720 public:
1721 // Reference members mirror the sibling residual classes in this file
1722 // (e.g. HSU_D_flash_twophase::Residual); the object is a short-lived
1723 // local that never outlives HEOS/superanc.
1724 // NOLINTNEXTLINE(cppcoreguidelines-avoid-const-or-ref-data-members)
1726 // NOLINTNEXTLINE(cppcoreguidelines-avoid-const-or-ref-data-members)
1728 CoolPropDbl rhomolar_spec; // Specified density
1729 parameters other; // Key for the caloric input (EOS-mode)
1730 CoolPropDbl value; // Value of H/S/U in the CALLER reference frame
1731 // Fast caloric-superancillary mode: read the saturated caloric values
1732 // straight off the H/S/U caloric superancillary (Chebyshev, ~35 ns)
1733 // instead of a full EOS property evaluation (~1.3 us). ca_key is 'H',
1734 // 'S' or 'U'. value_cache is `value` shifted into the superancillary's
1735 // reference frame. When use_ca is false the residual
1736 // uses the full EOS (fallback for fluids whose caloric SA is absent).
1737 bool use_ca;
1738 char ca_key;
1739 CoolPropDbl value_cache;
1740 CoolPropDbl Qd; // Quality implied by density (output)
1741 solver_resid_2phase(HelmholtzEOSMixtureBackend& HEOS, CoolPropDbl rhomolar_spec, parameters other, CoolPropDbl value,
1742 EquationOfState::SuperAncillary_t& superanc, bool use_ca, char ca_key, CoolPropDbl value_cache)
1743 : HEOS(HEOS),
1744 superanc(superanc),
1745 rhomolar_spec(rhomolar_spec),
1746 other(other),
1747 value(value),
1748 use_ca(use_ca),
1749 ca_key(ca_key),
1750 value_cache(value_cache),
1751 Qd(_HUGE) {};
1752 double call(double T) override {
1753 const double rhoL = superanc.eval_sat(T, 'D', 0);
1754 const double rhoV = superanc.eval_sat(T, 'D', 1);
1755 // Quality from the specified density
1756 Qd = (1 / rhomolar_spec - 1 / rhoL) / (1 / rhoV - 1 / rhoL);
1757 CoolPropDbl Qo;
1758 if (use_ca) {
1759 // Saturated caloric values straight off the superancillary (H, S or U),
1760 // in its (stamped) reference frame; value_cache is the target in the
1761 // same frame.
1762 const double yL = superanc.eval_sat(T, ca_key, 0);
1763 const double yV = superanc.eval_sat(T, ca_key, 1);
1764 Qo = (value_cache - yL) / (yV - yL);
1765 } else {
1766 // Full-EOS fallback.
1767 const double p = superanc.eval_sat(T, 'P', 1);
1768 HEOS.SatL->update_TDmolarP_unchecked(T, rhoL, p);
1769 HEOS.SatV->update_TDmolarP_unchecked(T, rhoV, p);
1770 const double yL = HEOS.SatL->keyed_output(other);
1771 const double yV = HEOS.SatV->keyed_output(other);
1772 Qo = (value - yL) / (yV - yL);
1773 }
1774 const double resid = Qo - Qd;
1775 if (!std::isfinite(resid)) {
1776 throw ValueError(format("HSU_D superancillary resid not finite @ T=%g K; Qo=%g; Qd=%g", T, Qo, Qd));
1777 }
1778 return resid;
1779 }
1780 };
1781
1782 // Define the residual to be driven to zero
1783 class solver_resid : public FuncWrapper1DWithTwoDerivs
1784 {
1785 public:
1787 CoolPropDbl rhomolar, value;
1788 parameters other;
1789 CoolPropDbl Tmin, Tmax;
1790
1791 solver_resid(HelmholtzEOSMixtureBackend* HEOS, CoolPropDbl rhomolar, CoolPropDbl value, parameters other, CoolPropDbl Tmin, CoolPropDbl Tmax)
1792 : HEOS(HEOS), rhomolar(rhomolar), value(value), other(other), Tmin(Tmin), Tmax(Tmax) {
1795 };
1796 // RAII: ensure the imposed gas phase is cleared on every exit
1797 // path, including exceptions thrown from Halley/Brent. Without
1798 // this, the imposed phase leaks into subsequent state queries,
1799 // which take fast paths that assume gas-phase ideal-gas
1800 // guesses and converge to metastable roots in the compressed-
1801 // liquid region (e.g. CO2 LIQUID). See solver_rho_Tp; #2738.
1802 ~solver_resid() {
1803 // This RAII cleanup runs from a destructor (implicitly noexcept):
1804 // a throw escaping here would call std::terminate. unspecify_phase()
1805 // only resets a flag and shouldn't throw, but guard it so a future
1806 // change can't silently turn this into a crash.
1807 try {
1808 HEOS->unspecify_phase();
1809 } catch (...) { // NOLINT(bugprone-empty-catch) — a dtor must not let an exception escape
1810 }
1811 }
1812 double call(double T) override {
1813 HEOS->update_DmolarT_direct(rhomolar, T);
1814 double eos = HEOS->keyed_output(other);
1815 if (other == iP) {
1816 // For p, should use fractional error
1817 return (eos - value) / value;
1818 } else {
1819 // For everything else, use absolute error
1820 return eos - value;
1821 }
1822 };
1823 double deriv(double T) override {
1824 if (other == iP) {
1825 return HEOS->first_partial_deriv(other, iT, iDmolar) / value;
1826 }
1827 return HEOS->first_partial_deriv(other, iT, iDmolar);
1828 };
1829 double second_deriv(double T) override {
1830 if (other == iP) {
1831 return HEOS->second_partial_deriv(other, iT, iDmolar, iT, iDmolar) / value;
1832 }
1833 return HEOS->second_partial_deriv(other, iT, iDmolar, iT, iDmolar);
1834 };
1835 bool input_not_in_range(double T) override {
1836 return (T < Tmin || T > Tmax);
1837 }
1838 };
1839
1840 if (HEOS.is_pure_or_pseudopure) {
1841
1842 // ===================== superancillary "happy path" =====================
1843 // For a pure fluid with a built superancillary and the caloric inputs
1844 // H/S/U, use the superancillary saturation-density roots to robustly
1845 // decide single- vs two-phase and to bracket the temperature solve.
1846 // This avoids the saturation_D_pure instability that makes the legacy
1847 // routine fail near the critical density, and clamps the quality at the
1848 // saturation boundary. Any failure here falls through to the legacy
1849 // ancillary "sad path" below. See CoolProp-j3n (ported from saD_HSU).
1850 //
1851 // Escape hatch: COOLPROP_DISABLE_SUPERANC_HSU_D (read once) forces the
1852 // legacy path -- a field kill-switch and an A/B regression-test handle.
1853 static const bool superanc_hsu_d_disabled = (std::getenv("COOLPROP_DISABLE_SUPERANC_HSU_D") != nullptr);
1854 if (!superanc_hsu_d_disabled && get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()
1855 && (other == iHmolar || other == iSmolar || other == iUmolar)) {
1856 std::shared_ptr<EquationOfState::SuperAncillary_t> sa_ptr = HEOS.get_superanc();
1857 if (sa_ptr) {
1858 // Capture the inputs OUTSIDE the try so the catch can restore them.
1859 // Only read the two members the flash actually consumes -- the density
1860 // and the one relevant caloric input -- never the others (which are
1861 // uninitialized for a D+X flash). The solves overwrite these members at
1862 // trial states, and both the legacy path and later candidate intervals
1863 // re-read them, so restore them on every non-committing exit.
1864 const double rho_in = HEOS._rhomolar;
1865 const CoolPropDbl value = (other == iHmolar) ? HEOS._hmolar : ((other == iSmolar) ? HEOS._smolar : HEOS._umolar);
1866 auto restore_inputs = [&]() {
1867 HEOS._rhomolar = rho_in;
1868 if (other == iHmolar) {
1869 HEOS._hmolar = value;
1870 } else if (other == iSmolar) {
1871 HEOS._smolar = value;
1872 } else {
1873 HEOS._umolar = value;
1874 }
1875 HEOS.unspecify_phase();
1876 };
1877 try {
1878 EquationOfState::SuperAncillary_t& superanc = *sa_ptr;
1879
1880 // Decide once whether the two-phase residual can read the saturated
1881 // caloric values straight off the (fast) caloric superancillary
1882 // rather than the EOS. H, S and U all have superancillaries (built
1883 // together; U shares H's reference frame since u = h - p/rho). The
1884 // cached caloric SA is expressed in a stamped reference frame, so
1885 // shift the target `value` into that frame by the constant offset
1886 // (see resolve_T_via_superancillary / #2773).
1887 const char ca_key = (other == iHmolar) ? 'H' : ((other == iSmolar) ? 'S' : 'U');
1888 bool use_ca = false;
1889 CoolPropDbl value_cache = value;
1891 if (superanc.has_variable(ca_key)) {
1892 double delta = 0.0; // caller -> cache frame shift (constant in T)
1893 const auto stamp = superanc.get_caloric_alpha0_stamp();
1894 if (stamp.has_value()) {
1895 const auto [caller_a1, caller_a2] = alpha0_offset_total(HEOS);
1896 const double R = HEOS.gas_constant();
1897 if (ca_key == 'S') {
1898 delta = -R * (stamp->first - caller_a1);
1899 } else { // 'H' or 'U': u inherits the H enthalpy shift (p/rho is offset-free)
1900 const double T_red = HEOS.get_reducing_state().T;
1901 delta = R * T_red * (stamp->second - caller_a2);
1902 }
1903 }
1904 value_cache = value + delta;
1905 use_ca = true;
1906 }
1907
1908 const double Tmin_sa = superanc.get_Tmin();
1909 const double Tmax_1phase = HEOS.Tmax() * 1.5;
1910 const double tol = 1e-12;
1911 // 44 correct bits -> tolerance 2^(1-44) ~ 1.14e-13
1912 auto eps44 = boost::math::tools::eps_tolerance<double>(44);
1913
1914 // Finalize a converged single-phase state at the current (rho, T).
1915 auto finalize_1phase = [](HelmholtzEOSMixtureBackend& H) {
1916 H._Q = 10000;
1917 H._p = H.calc_pressure_nocache(H.T(), H.rhomolar());
1918 H.unspecify_phase();
1919 H.recalculate_singlephase_phase();
1920 };
1921 // Final safety gate: the committed state must reproduce BOTH inputs
1922 // (density and the caloric value). A solve can occasionally report
1923 // success on a spurious root -- e.g. where X(rho,T) is non-monotonic
1924 // at extreme compression and a bracket collapses onto a boundary
1925 // temperature. Refusing such a state here turns a silent wrong
1926 // answer into a clean fall-through to the legacy path.
1927 auto committed_ok = [&]() -> bool {
1928 const double dout = HEOS.rhomolar();
1929 if (!std::isfinite(dout) || std::abs(dout / rho_in - 1) > 1e-7) return false;
1930 const double xout = HEOS.keyed_output(other);
1931 return std::isfinite(xout) && std::abs(xout - value) <= 1e-6 * std::abs(value) + 1e-3;
1932 };
1933 // True if (rho, T) lies strictly inside the two-phase dome,
1934 // i.e. a single-phase root there would be metastable. Only
1935 // meaningful below the critical temperature.
1936 auto inside_dome = [&](double T) -> bool {
1937 if (T >= superanc.get_Tcrit_num()) return false;
1938 const double rhoV = superanc.eval_sat(T, 'D', 1);
1939 const double rhoL = superanc.eval_sat(T, 'D', 0);
1940 return HEOS._rhomolar > rhoV && HEOS._rhomolar < rhoL;
1941 };
1942 // Bracketed single-phase solve on [Ta, Tb]; returns true if it
1943 // converged to a genuine (non-metastable) single-phase root.
1944 auto solve_1phase = [&](double Ta, double Tb) -> bool {
1945 try {
1946 solver_resid resid(&HEOS, HEOS._rhomolar, value, other, Ta, Tb);
1947 const double fa = resid.call(Ta), fb = resid.call(Tb);
1948 double Tconv;
1949 if (std::abs(fa) < tol) {
1950 Tconv = Ta;
1951 } else if (std::abs(fb) < tol) {
1952 Tconv = Tb;
1953 } else if (fa * fb < 0) {
1954 boost::math::uintmax_t max_iter = 100;
1955 auto f = [&resid](double T) { return resid.call(T); };
1956 auto [l, r] = toms748_solve(f, Ta, Tb, fa, fb, eps44, max_iter);
1957 Tconv = 0.5 * (l + r);
1958 } else {
1959 return false;
1960 }
1961 // Belt-and-suspenders: reject a converged root that lies
1962 // inside the dome (metastable). Interval classification
1963 // below should never hand us such a bracket, but the
1964 // superancillary/EOS saturation curves differ at the ~1e-8
1965 // level, so guard the boundary anyway.
1966 if (inside_dome(Tconv)) return false;
1967 HEOS.update_DmolarT_direct(HEOS._rhomolar, Tconv);
1968 finalize_1phase(HEOS);
1969 return true;
1970 } catch (const std::exception&) {
1971 return false;
1972 }
1973 };
1974 // Bracketed two-phase solve on [Ta, Tb]; returns true if it converged
1975 // to a genuine two-phase state.
1976 auto solve_2phase = [&](double Ta, double Tb) -> bool {
1977 try {
1978 solver_resid_2phase resid(HEOS, HEOS._rhomolar, other, value, superanc, use_ca, ca_key, value_cache);
1979 const double fa = resid.call(Ta), fb = resid.call(Tb);
1980 double Tsol;
1981 if (std::abs(fa) < tol) {
1982 Tsol = Ta;
1983 } else if (std::abs(fb) < tol) {
1984 Tsol = Tb;
1985 } else if (fa * fb < 0) {
1986 boost::math::uintmax_t max_iter = 100;
1987 auto f = [&resid](double T) { return resid.call(T); };
1988 auto [l, r] = toms748_solve(f, Ta, Tb, fa, fb, eps44, max_iter);
1989 Tsol = 0.5 * (l + r);
1990 } else {
1991 return false;
1992 }
1993 // EOS polish: the fast path read the saturated caloric
1994 // values off the superancillary, which carries a ~1e-8
1995 // deviation from the EOS. T_sol is an excellent seed, so a
1996 // couple of secant steps on the EOS-caloric residual (full
1997 // EOS h/s/u of each phase) clean the solution up to EOS
1998 // precision at a few EOS evaluations -- vs the ~12 the fast
1999 // path saved. U already uses the EOS, so nothing to polish.
2000 // The polish is on by default (EOS-exact) but can be turned
2001 // off via HSU_D_TWOPHASE_EOS_POLISH for callers that prefer
2002 // the faster raw superancillary solution (~1e-8 deviation).
2003 double Qd_final;
2004 if (use_ca && get_config_bool(HSU_D_TWOPHASE_EOS_POLISH)) {
2005 solver_resid_2phase resid_eos(HEOS, HEOS._rhomolar, other, value, superanc, /*use_ca=*/false, ca_key, value_cache);
2006 double t0 = Tsol, r0 = resid_eos.call(t0);
2007 // Probe just above Tsol, but keep the second secant seed
2008 // strictly inside the two-phase bracket [Ta, Tb]: when the
2009 // raw solve lands on a boundary root, Tsol*(1+1e-7) can step
2010 // past Tb and make resid_eos.call() fail, needlessly kicking
2011 // a valid happy-path state back to the legacy path.
2012 double t1 = Tsol * (1.0 + 1e-7);
2013 if (t1 <= Ta || t1 >= Tb) {
2014 t1 = 0.5 * (Tsol + ((Tsol - Ta) > (Tb - Tsol) ? Ta : Tb));
2015 }
2016 double r1 = resid_eos.call(t1);
2017 for (int it = 0; it < 4; ++it) {
2018 if (!std::isfinite(r1) || std::abs(r1) < tol || std::abs(t1 - t0) <= 1e-13 * t1) break;
2019 // Secant step; a vanishing denominator (r1 == r0) makes tn
2020 // non-finite, which the guard below turns into a clean break
2021 // -- avoids the exact float compare CodeQL flags.
2022 const double tn = t1 - r1 * (t1 - t0) / (r1 - r0);
2023 if (!std::isfinite(tn)) break;
2024 t0 = t1;
2025 r0 = r1;
2026 t1 = tn;
2027 r1 = resid_eos.call(t1);
2028 }
2029 Tsol = t1;
2030 resid_eos.call(Tsol);
2031 Qd_final = resid_eos.Qd;
2032 } else {
2033 resid.call(Tsol); // refresh Qd at the solution temperature
2034 Qd_final = resid.Qd;
2035 }
2036 // Reject a spurious crossing: if the implied quality is outside
2037 // [0, 1] the specified density is NOT within the two-phase band
2038 // at Tsol, so this is a single-phase point whose quality residual
2039 // merely happened to cross zero. Clamping Qd here would return a
2040 // saturated-boundary density (~rho_crit) instead of the input
2041 // density. Reject so the loop proceeds to the single-phase
2042 // interval. (Dual of the inside_dome guard in solve_1phase.)
2043 constexpr double Qeps = 1e-8;
2044 if (Qd_final < -Qeps || Qd_final > 1.0 + Qeps) return false;
2045 HEOS.update(QT_INPUTS, std::clamp(Qd_final, 0.0, 1.0), Tsol);
2046 return true;
2047 } catch (const std::exception&) {
2048 return false;
2049 }
2050 };
2051
2052 // Reliable saturation temperatures at the specified density.
2053 // These are the ONLY temperatures at which the density crosses
2054 // the saturation curve, so dome membership is constant between
2055 // consecutive roots. Build candidate intervals
2056 // [Tmin_sa .. roots .. Tcrit .. Tmax], classify each by testing
2057 // its midpoint with inside_dome(), and run the matching solve.
2058 // This never hands the single-phase solver a dome-interior
2059 // bracket, and it handles uniformly: the normal topology (one
2060 // root), the rho ~ rho_crit collapse (roots pile up at Tcrit, so
2061 // the whole sub-critical span is one two-phase interval), and the
2062 // water/heavy-water liquid density anomaly (a non-monotonic
2063 // liquid branch yields multiple liquid-side roots).
2064 auto Tsats = superanc.get_all_intersections('D', HEOS._rhomolar, 48, 100, 1e-13);
2065 std::sort(Tsats.begin(), Tsats.end());
2066
2067 const double Tcrit = superanc.get_Tcrit_num();
2068 // rhoV and rhoL coalesce at Tcrit and the quality ratio degenerates
2069 // to 0/0, so keep two-phase brackets just shy of it.
2070 const double Tcrit_2phase = Tcrit - std::max(1e-6, 1e-9 * Tcrit);
2071
2072 std::vector<double> edges{Tmin_sa};
2073 for (const auto& pr : Tsats) {
2074 if (pr.first > Tmin_sa && pr.first < Tcrit) edges.push_back(pr.first);
2075 }
2076 edges.push_back(Tcrit);
2077 edges.push_back(Tmax_1phase);
2078
2079 for (std::size_t i = 0; i + 1 < edges.size(); ++i) {
2080 const double a = edges[i], b = edges[i + 1];
2081 if (b - a < 1e-10) continue;
2082 const double mid = 0.5 * (a + b);
2083 // Clamp the two-phase upper bound just shy of Tcrit, but if that
2084 // collapses the bracket (ub <= a, i.e. the last saturation
2085 // intersection lands inside the critical guard band) fall back to
2086 // the single-phase solve rather than hand solve_2phase an inverted
2087 // interval -- which would fail immediately, exactly the near-critical
2088 // case this path is meant to recover.
2089 bool solved;
2090 if (mid < Tcrit && inside_dome(mid)) {
2091 const double ub = std::min(b, Tcrit_2phase);
2092 solved = (ub > a) ? solve_2phase(a, ub) : solve_1phase(a, b);
2093 } else {
2094 solved = solve_1phase(a, b);
2095 }
2096 if (solved) {
2097 if (committed_ok()) return;
2098 // Converged but did not reproduce the inputs (a spurious root);
2099 // undo the state mutation before trying the next interval.
2100 restore_inputs();
2101 }
2102 }
2103 throw ValueError("HSU_D superancillary: no candidate interval reproduced the inputs");
2104 } catch (const std::exception& e) {
2105 if (get_debug_level() > 0) {
2106 std::cout << "HSU_D_flash superancillary path failed (" << e.what() << "); using legacy path\n";
2107 }
2108 // Restore the inputs (the happy path may have mutated HEOS), then
2109 // fall through to the legacy ancillary path below.
2110 restore_inputs();
2111 }
2112 }
2113 }
2114 // ===================== legacy ancillary "sad path" =====================
2115
2116 CoolPropFluid& component = HEOS.components[0];
2117
2118 // Supercritical single-phase fast path. The region logic below is anchored on the
2119 // triple-point states (unset for pseudo-pure fluids like Air, where they default to
2120 // -1) and on saturation_D_pure, which is numerically unstable near rho_c. Together
2121 // these route Air's near-critical supercritical D+X back-flashes to a branch whose
2122 // Ttriple-anchored temperature bracket spans the two-phase dome, producing
2123 // "p is not a valid number". But if the caloric input lies above its value on the
2124 // critical isotherm at this density, the solution temperature is above Tc, so the
2125 // state is unambiguously single-phase and needs no saturation: h, s and u all
2126 // increase with T at fixed rho, so bracket the residual directly on [Tc, 1.5*Tmax]
2127 // and solve with TOMS748. Any failure falls through to the legacy region logic.
2128 if (other == iHmolar || other == iSmolar || other == iUmolar) {
2129 const double rho_in = HEOS._rhomolar;
2130 const double value_in = (other == iHmolar) ? HEOS._hmolar : ((other == iSmolar) ? HEOS._smolar : HEOS._umolar);
2131 bool fast_ok = false;
2132 try {
2133 const double Tc_ = HEOS.T_critical();
2134 const double Tb = HEOS.Tmax() * 1.5;
2135 // Restrict to rho < 2*rho_c. The Air D+X failures sit at rho/rho_c ~ 0.8-1.005,
2136 // so the bound must reach just past rho_c, but it must stay well below the dense-
2137 // liquid extrapolation (rho >> rho_c, e.g. ~3*rho_c) where the critical-isotherm
2138 // probe X(Tc, rho) is unreliable/non-monotonic and could mis-fire onto a spurious
2139 // root. In [rho_c, 2*rho_c] a subcritical state still has value < X(Tc, rho) (X is
2140 // monotonic in T there), so the probe only fires for genuinely supercritical states.
2141 if (rho_in > 0 && rho_in < 2.0 * HEOS.rhomolar_critical()) {
2142 const double X_Tc = (other == iHmolar) ? HEOS.calc_hmolar_nocache(Tc_, rho_in)
2143 : (other == iSmolar) ? HEOS.calc_smolar_nocache(Tc_, rho_in)
2144 : HEOS.calc_umolar_nocache(Tc_, rho_in);
2145 if (ValidNumber(X_Tc) && ValidNumber(value_in) && value_in > X_Tc) {
2146 solver_resid resid(&HEOS, rho_in, value_in, other, Tc_, Tb);
2147 const double fa = resid.call(Tc_), fb = resid.call(Tb);
2148 if (ValidNumber(fa) && ValidNumber(fb) && fa * fb < 0) {
2149 boost::math::uintmax_t max_iter = 100;
2150 auto f = [&resid](double T) { return resid.call(T); };
2151 auto [l, r] = toms748_solve(f, Tc_, Tb, fa, fb, boost::math::tools::eps_tolerance<double>(44), max_iter);
2152 HEOS.update_DmolarT_direct(rho_in, 0.5 * (l + r));
2153 // Accept only a state that reproduces the caloric input (and rho, which
2154 // update_DmolarT_direct restored by construction); otherwise fall through.
2155 const double xout = HEOS.keyed_output(other);
2156 if (ValidNumber(xout) && std::abs(xout - value_in) <= 1e-6 * std::abs(value_in) + 1e-3) {
2157 HEOS._p = HEOS.calc_pressure_nocache(HEOS.T(), HEOS.rhomolar());
2158 HEOS._Q = 10000;
2159 HEOS.unspecify_phase();
2161 fast_ok = true;
2162 }
2163 }
2164 }
2165 }
2166 } catch (...) { // NOLINT(bugprone-empty-catch) -- fall through to the legacy region logic below
2167 }
2168 if (fast_ok) {
2169 return;
2170 }
2171 // The probes above (solver_resid::call -> update_DmolarT_direct -> clear()) overwrite
2172 // the cached caloric inputs; restore the originals so the legacy path below solves for
2173 // the correct target instead of a corrupted one.
2174 HEOS._rhomolar = rho_in;
2175 if (other == iHmolar) {
2176 HEOS._hmolar = value_in;
2177 } else if (other == iSmolar) {
2178 HEOS._smolar = value_in;
2179 } else {
2180 HEOS._umolar = value_in;
2181 }
2182 HEOS.unspecify_phase();
2183 }
2184
2185 shared_ptr<HelmholtzEOSMixtureBackend> Sat;
2186 CoolPropDbl rhoLtriple = component.triple_liquid.rhomolar;
2187 CoolPropDbl rhoVtriple = component.triple_vapor.rhomolar;
2188 // Check if in the "normal" region
2189 if (HEOS._rhomolar >= rhoVtriple && HEOS._rhomolar <= rhoLtriple) {
2190 CoolPropDbl yL = NAN, yV = NAN, value = NAN, y_solid = NAN;
2191 CoolPropDbl TLtriple = component.triple_liquid.T;
2192 CoolPropDbl TVtriple = component.triple_vapor.T;
2193
2194 // First check if solid (below the line connecting the triple point values) - this is an error for now
2195 switch (other) {
2196 case iSmolar:
2197 yL = HEOS.calc_smolar_nocache(TLtriple, rhoLtriple);
2198 yV = HEOS.calc_smolar_nocache(TVtriple, rhoVtriple);
2199 value = HEOS._smolar;
2200 break;
2201 case iHmolar:
2202 yL = HEOS.calc_hmolar_nocache(TLtriple, rhoLtriple);
2203 yV = HEOS.calc_hmolar_nocache(TVtriple, rhoVtriple);
2204 value = HEOS._hmolar;
2205 break;
2206 case iUmolar:
2207 yL = HEOS.calc_umolar_nocache(TLtriple, rhoLtriple);
2208 yV = HEOS.calc_umolar_nocache(TVtriple, rhoVtriple);
2209 value = HEOS._umolar;
2210 break;
2211 case iP:
2212 yL = HEOS.calc_pressure_nocache(TLtriple, rhoLtriple);
2213 yV = HEOS.calc_pressure_nocache(TVtriple, rhoVtriple);
2214 value = HEOS._p;
2215 break;
2216 default:
2217 throw ValueError(format("Input is invalid"));
2218 }
2219 y_solid = (yV - yL) / (1 / rhoVtriple - 1 / rhoLtriple) * (1 / HEOS._rhomolar - 1 / rhoLtriple) + yL;
2220
2221 if (value < y_solid) {
2222 throw ValueError(format("Other input [%d:%g] is solid", other, value));
2223 }
2224
2225 // Check if other is above the saturation value.
2227 optionsD.omega = 1;
2228 optionsD.use_logdelta = false;
2229 optionsD.max_iterations = 200;
2230 for (int i_try = 0; i_try < 7; i_try++) {
2231 try {
2232 if (HEOS._rhomolar > HEOS.rhomolar_critical()) {
2234 SaturationSolvers::saturation_D_pure(HEOS, HEOS._rhomolar, optionsD);
2235 // SatL and SatV have the saturation values
2236 Sat = HEOS.SatL;
2237 } else {
2239 SaturationSolvers::saturation_D_pure(HEOS, HEOS._rhomolar, optionsD);
2240 // SatL and SatV have the saturation values
2241 Sat = HEOS.SatV;
2242 }
2243 break; // good solve
2244 } catch (const CoolPropBaseError&) {
2245 optionsD.omega /= 2;
2246 optionsD.max_iterations *= 2;
2247 if (i_try >= 6) {
2248 throw;
2249 }
2250 }
2251 }
2252
2253 // If it is above, it is not two-phase and either liquid, vapor or supercritical
2254 if (value > Sat->keyed_output(other)) {
2255 solver_resid resid(&HEOS, HEOS._rhomolar, value, other, Sat->keyed_output(iT), HEOS.Tmax() * 1.5);
2256 CoolPropDbl T_converged;
2257 try {
2258 T_converged = Halley(resid, 0.5 * (Sat->keyed_output(iT) + HEOS.Tmax() * 1.5), 1e-10, 100);
2259 } catch (...) {
2260 T_converged = Brent(resid, Sat->keyed_output(iT), HEOS.Tmax() * 1.5, DBL_EPSILON, 1e-12, 100);
2261 }
2262 HEOS.unspecify_phase();
2263 // Re-evaluate state at the converged (rho, T) so the alphar
2264 // cache matches the final temperature, not the last solver
2265 // iterate. Without this the next h/s/p query returns values
2266 // computed from stale alphar derivatives (#1907).
2267 HEOS.update_DmolarT_direct(HEOS._rhomolar, T_converged);
2268 HEOS._Q = 10000;
2269 // Update the phase flag
2271 } else {
2272 // Now we know that temperature is between Tsat(D) +- tolerance and the minimum temperature for the fluid
2273 if (other == iP) {
2274 // Iterate to find T(p), its just a saturation call
2275
2276 // Set some input options
2278 // Specified variable is pressure
2280 // Use logarithm of delta as independent variables
2281 optionsPHSU.use_logdelta = false;
2282
2283 // Actually call the solver
2284 SaturationSolvers::saturation_PHSU_pure(HEOS, HEOS._p, optionsPHSU);
2285
2286 // Load the outputs
2287 HEOS._phase = iphase_twophase;
2288 HEOS._Q = (1 / HEOS._rhomolar - 1 / HEOS.SatL->rhomolar()) / (1 / HEOS.SatV->rhomolar() - 1 / HEOS.SatL->rhomolar());
2289 HEOS._T = HEOS.SatL->T();
2290 } else {
2291 // Residual is difference in quality calculated from density and quality calculated from the other parameter
2292 // Iterate to find T
2293 HSU_D_flash_twophase(HEOS, HEOS._rhomolar, other, value);
2294 HEOS._phase = iphase_twophase;
2295 }
2296 }
2297 }
2298 // Check if vapor/solid region below triple point vapor density
2299 else if (HEOS._rhomolar < component.triple_vapor.rhomolar) {
2300 CoolPropDbl y = NAN, value = NAN;
2301 CoolPropDbl TVtriple = component.triple_vapor.T; //TODO: separate TL and TV for ppure
2302
2303 // If value is above the value calculated from X(Ttriple, _rhomolar), it is vapor
2304 switch (other) {
2305 case iSmolar:
2306 y = HEOS.calc_smolar_nocache(TVtriple, HEOS._rhomolar);
2307 value = HEOS._smolar;
2308 break;
2309 case iHmolar:
2310 y = HEOS.calc_hmolar_nocache(TVtriple, HEOS._rhomolar);
2311 value = HEOS._hmolar;
2312 break;
2313 case iUmolar:
2314 y = HEOS.calc_umolar_nocache(TVtriple, HEOS._rhomolar);
2315 value = HEOS._umolar;
2316 break;
2317 case iP:
2318 y = HEOS.calc_pressure_nocache(TVtriple, HEOS._rhomolar);
2319 value = HEOS._p;
2320 break;
2321 default:
2322 throw ValueError(format("Input is invalid"));
2323 }
2324 if (value > y) {
2325 solver_resid resid(&HEOS, HEOS._rhomolar, value, other, TVtriple, HEOS.Tmax() * 1.5);
2326 HEOS._phase = iphase_gas;
2327 CoolPropDbl T_converged;
2328 try {
2329 T_converged = Halley(resid, 0.5 * (TVtriple + HEOS.Tmax() * 1.5), DBL_EPSILON, 100);
2330 } catch (...) {
2331 T_converged = Brent(resid, TVtriple, HEOS.Tmax() * 1.5, DBL_EPSILON, 1e-12, 100);
2332 }
2333 // Re-evaluate at converged (rho, T) so alphar cache is
2334 // consistent with the final state (#1907).
2335 HEOS.unspecify_phase();
2336 HEOS.update_DmolarT_direct(HEOS._rhomolar, T_converged);
2337 HEOS._phase = iphase_gas;
2338 HEOS._Q = 10000;
2339 } else {
2340 throw ValueError(format("D < DLtriple %g %g", value, y));
2341 }
2342
2343 }
2344 // Check in the liquid/solid region above the triple point density
2345 else {
2346 CoolPropDbl y = NAN, value = NAN;
2347 CoolPropDbl TLtriple = component.EOS().Ttriple;
2348
2349 // If value is above the value calculated from X(Ttriple, _rhomolar), it is vapor
2350 switch (other) {
2351 case iSmolar:
2352 y = HEOS.calc_smolar_nocache(TLtriple, HEOS._rhomolar);
2353 value = HEOS._smolar;
2354 break;
2355 case iHmolar:
2356 y = HEOS.calc_hmolar_nocache(TLtriple, HEOS._rhomolar);
2357 value = HEOS._hmolar;
2358 break;
2359 case iUmolar:
2360 y = HEOS.calc_umolar_nocache(TLtriple, HEOS._rhomolar);
2361 value = HEOS._umolar;
2362 break;
2363 case iP:
2364 y = HEOS.calc_pressure_nocache(TLtriple, HEOS._rhomolar);
2365 value = HEOS._p;
2366 break;
2367 default:
2368 throw ValueError(format("Input is invalid"));
2369 }
2370 if (value > y) {
2371 solver_resid resid(&HEOS, HEOS._rhomolar, value, other, TLtriple, HEOS.Tmax() * 1.5);
2372 HEOS._phase = iphase_liquid;
2373 CoolPropDbl T_converged;
2374 try {
2375 T_converged = Halley(resid, 0.5 * (TLtriple + HEOS.Tmax() * 1.5), DBL_EPSILON, 100);
2376 } catch (...) {
2377 T_converged = Brent(resid, TLtriple, HEOS.Tmax() * 1.5, DBL_EPSILON, 1e-12, 100);
2378 }
2379 // Re-evaluate at converged (rho, T) so alphar cache is
2380 // consistent with the final state (#1907).
2381 HEOS.unspecify_phase();
2382 HEOS.update_DmolarT_direct(HEOS._rhomolar, T_converged);
2383 HEOS._phase = iphase_liquid;
2384 HEOS._Q = 10000;
2385 } else {
2386 // value <= y means the requested state sits below the triple-point
2387 // isochore (T < Ttriple). Compressed liquid still exists there
2388 // above the melting curve (e.g. water down to ~251 K near the
2389 // 209 MPa melting minimum; IAPWS-95 stays valid), so extend the
2390 // single-phase solve below the triple point to the coldest point
2391 // on the melting line rather than rejecting outright. Only fluids
2392 // with a melting line have a defined sub-triple liquid region
2393 // (CoolProp-lgk).
2394 if (!HEOS.has_melting_line()) {
2395 throw ValueError(format("D < DLtriple %g %g", value, y));
2396 }
2397 // Coldest liquid temperature = the global minimum of the melting
2398 // curve. The curve can be non-monotonic in p (water dips to
2399 // ~251 K near 209 MPa) and its minimum can sit at a non-smooth
2400 // segment corner, so neither the endpoint-based
2401 // MeltingLineVariables::Tmin nor a single coarse scan pinpoints
2402 // it: sweep coarsely in log(p), then refine linearly around the
2403 // best point. For normal fluids whose melting temperature rises
2404 // monotonically with pressure this stays at ~Ttriple, so the sign
2405 // check below still rejects (correctly -- they have no sub-triple
2406 // liquid region).
2408 auto Tmelt_at = [&](CoolPropDbl p) -> CoolPropDbl {
2409 try {
2410 return HEOS.melting_line(iT, iP, p);
2411 } catch (...) {
2412 return _HUGE; // p outside this curve's valid range; ignore this sample
2413 }
2414 };
2415 CoolPropDbl T_lo = ml.Tmin;
2416 if (ml.pmin > 0 && ml.pmax > ml.pmin) {
2417 const int Ncoarse = 100;
2418 CoolPropDbl p_best = ml.pmin;
2419 for (int i = 0; i <= Ncoarse; ++i) {
2420 const CoolPropDbl p_scan = ml.pmin * std::pow(ml.pmax / ml.pmin, static_cast<CoolPropDbl>(i) / Ncoarse);
2421 const CoolPropDbl Tm = Tmelt_at(p_scan);
2422 if (Tm < T_lo) {
2423 T_lo = Tm;
2424 p_best = p_scan;
2425 }
2426 }
2427 // Refine within +/- one coarse log-step of the best pressure.
2428 const CoolPropDbl step = std::pow(ml.pmax / ml.pmin, 1.0 / Ncoarse);
2429 const CoolPropDbl p_a = (std::max)(ml.pmin, p_best / step);
2430 const CoolPropDbl p_b = (std::min)(ml.pmax, p_best * step);
2431 const int Nfine = 200;
2432 for (int i = 0; i <= Nfine; ++i) {
2433 const CoolPropDbl p_scan = p_a + (p_b - p_a) * static_cast<CoolPropDbl>(i) / Nfine;
2434 const CoolPropDbl Tm = Tmelt_at(p_scan);
2435 if (Tm < T_lo) {
2436 T_lo = Tm;
2437 }
2438 }
2439 }
2440 // y_lo is the caloric value on the requested isochore at the
2441 // coldest liquid temperature. Using the curve's global minimum
2442 // (rather than the melting point at this exact density) makes the
2443 // floor conservative on rejection: states below it are genuinely
2444 // solid, while a thin metastable sliver just below the per-density
2445 // melting point may still be accepted -- it round-trips correctly.
2446 CoolPropDbl y_lo = NAN;
2447 switch (other) {
2448 case iSmolar:
2449 y_lo = HEOS.calc_smolar_nocache(T_lo, HEOS._rhomolar);
2450 break;
2451 case iHmolar:
2452 y_lo = HEOS.calc_hmolar_nocache(T_lo, HEOS._rhomolar);
2453 break;
2454 case iUmolar:
2455 y_lo = HEOS.calc_umolar_nocache(T_lo, HEOS._rhomolar);
2456 break;
2457 default: // iP
2458 y_lo = HEOS.calc_pressure_nocache(T_lo, HEOS._rhomolar);
2459 break;
2460 }
2461 // Floor tolerance: the consistency grid lands points exactly ON the
2462 // melting line, where `value` (from the forward path) and `y_lo`
2463 // (recomputed here via calc_*_nocache) differ only at ULP level. A
2464 // strict `value < y_lo` then rejects a valid melting-line state on
2465 // last-bit noise. Allow a small relative slack so boundary states pass
2466 // (consistent with the conservative-floor intent noted above) while
2467 // states genuinely below the melting line are still rejected.
2468 const CoolPropDbl floor_tol = 1e-8 * std::abs(y_lo) + 1e-10;
2469 if (!ValidNumber(y_lo) || value < y_lo - floor_tol) {
2470 // Colder than the coldest liquid attainable at this density:
2471 // genuinely below the melting line (solid / out of range).
2472 throw ValueError(format("D+X below melting line [other=%d]: %g < %g at Tmelt_min %g K", other, value, y_lo, T_lo));
2473 }
2474 solver_resid resid(&HEOS, HEOS._rhomolar, value, other, T_lo, TLtriple);
2475 HEOS._phase = iphase_liquid;
2476 CoolPropDbl T_converged;
2477 try {
2478 T_converged = Halley(resid, 0.5 * (T_lo + TLtriple), DBL_EPSILON, 100);
2479 } catch (...) {
2480 T_converged = Brent(resid, T_lo, TLtriple, DBL_EPSILON, 1e-12, 100);
2481 }
2482 // Re-evaluate at converged (rho, T) so alphar cache is
2483 // consistent with the final state (#1907).
2484 HEOS.unspecify_phase();
2485 HEOS.update_DmolarT_direct(HEOS._rhomolar, T_converged);
2486 HEOS._phase = iphase_liquid;
2487 HEOS._Q = 10000;
2488 }
2489 }
2490 // Update the state for conditions where the state was guessed
2491 if (HEOS.phase() != iphase_twophase) {
2493 }
2494 } else {
2495 // Mixtures: HSU_D flash with two-phase support.
2496 // Strategy:
2497 // Fast path — T-sweep at fixed rho using nocache functions.
2498 // If root found and stability confirms single-phase, accept.
2499 // Slow path — nested 1D: outer T-sweep, inner P-sweep with PT flash.
2500 // The inner P-sweep finds P where rho(T,P) = D_target.
2501 // The outer T-sweep finds T where X(T,P(T)) = X_target.
2502
2503 CoolPropDbl rho_target = HEOS._rhomolar;
2504 CoolPropDbl value;
2505 switch (other) {
2506 case iHmolar:
2507 value = HEOS._hmolar;
2508 break;
2509 case iSmolar:
2510 value = HEOS._smolar;
2511 break;
2512 case iUmolar:
2513 value = HEOS._umolar;
2514 break;
2515 default:
2516 throw ValueError("Invalid input for HSU_D_flash mixture");
2517 }
2518
2519 bool solved = false;
2520
2521 // --- Fast path: T-sweep at fixed rho using nocache ---
2522 // Sweep T in [Tmin, Tmax]. At each T, evaluate the caloric property
2523 // directly from the EOS at (T, rho_target) using nocache functions.
2524 // This is O(1) per evaluation (no flash).
2525 {
2526 CoolPropDbl Tmin = HEOS.calc_Tmin();
2527 CoolPropDbl Tmax = HEOS.calc_Tmax();
2528
2529 auto nocache_resid = [&](double T) -> double {
2530 switch (other) {
2531 case iHmolar:
2532 return HEOS.calc_hmolar_nocache(T, rho_target) - value;
2533 case iSmolar:
2534 return HEOS.calc_smolar_nocache(T, rho_target) - value;
2535 case iUmolar:
2536 return HEOS.calc_umolar_nocache(T, rho_target) - value;
2537 default:
2538 return _HUGE;
2539 }
2540 };
2541
2542 // Mechanical stability check: dP/drho_T > 0
2543 auto is_mechanically_stable = [&](double T) -> bool {
2544 double eps = std::max(static_cast<double>(rho_target) * 1e-6, 1e-10);
2545 double P_lo = HEOS.calc_pressure_nocache(T, rho_target - eps);
2546 double P_hi = HEOS.calc_pressure_nocache(T, rho_target + eps);
2547 return (P_hi - P_lo) > 0;
2548 };
2549
2550 try {
2551 std::uintmax_t max_iter = 100;
2552 auto bracket = boost::math::tools::toms748_solve(nocache_resid, static_cast<double>(Tmin), static_cast<double>(Tmax),
2553 boost::math::tools::eps_tolerance<double>(40), max_iter);
2554 double T_cand = (bracket.first + bracket.second) / 2.0;
2555
2556 // Validate: P > 0, mechanically stable, thermodynamically stable
2557 double P_eos = HEOS.calc_pressure_nocache(T_cand, rho_target);
2558 if (P_eos > 0 && is_mechanically_stable(T_cand)) {
2559 try {
2560 HEOS._T = T_cand;
2561 HEOS._p = P_eos;
2563 stab.set_TP(T_cand, P_eos);
2564 if (stab.is_stable()) {
2565 HEOS.update_DmolarT_direct(rho_target, T_cand);
2566 HEOS._Q = -1;
2568 solved = true;
2569 }
2570 } catch (...) {
2571 // Stability check failed — fall through to slow path
2572 }
2573 }
2574 } catch (...) {
2575 // No bracket or solver failure — fall through to slow path
2576 }
2577 }
2578
2579 // --- Slow path: nested 1D (outer T, inner P) ---
2580 if (!solved) {
2581 CoolPropDbl Tmin = HEOS.calc_Tmin();
2582 CoolPropDbl Tmax = HEOS.calc_Tmax();
2583 CoolPropDbl Pmin_bound = HEOS.calc_p_triple();
2584 CoolPropDbl Pmax_bound = HEOS.calc_pmax();
2585 if (Pmin_bound < 100) Pmin_bound = 100;
2586
2587 // Inner solver: given T, find P where rho(T, P) = rho_target.
2588 // Returns the caloric property value at the found state, or NaN
2589 // if no bracket found.
2590 // Uses logarithmic P-scan + TOMS748, identical to DHSU_T P-sweep.
2591 auto solve_for_caloric_at_T = [&](double T) -> double {
2592 auto rho_resid = [&](double P) -> double {
2593 HEOS.update(PT_INPUTS, P, T);
2594 return HEOS.keyed_output(iDmolar) - rho_target;
2595 };
2596
2597 // Log-scan to find P bracket
2598 double logPmin = std::log10(static_cast<double>(Pmin_bound));
2599 double logPmax = std::log10(static_cast<double>(Pmax_bound));
2600 double dlogP = 0.5;
2601 int nsteps = static_cast<int>((logPmax - logPmin) / dlogP) + 1;
2602 double P_lo = -1, P_hi = -1;
2603 double f_prev = 0, P_prev = 0;
2604 bool have_prev = false;
2605
2606 for (int i = 0; i <= nsteps; i++) {
2607 double logP = logPmin + i * dlogP;
2608 if (logP > logPmax) logP = logPmax;
2609 double P = std::pow(10.0, logP);
2610 double f;
2611 try {
2612 f = rho_resid(P);
2613 } catch (...) {
2614 have_prev = false;
2615 continue;
2616 }
2617 if (have_prev && f_prev * f < 0) {
2618 P_lo = P_prev;
2619 P_hi = P;
2620 break;
2621 }
2622 P_prev = P;
2623 f_prev = f;
2624 have_prev = true;
2625 }
2626
2627 if (P_lo < 0 || P_hi < 0) return std::numeric_limits<double>::quiet_NaN();
2628
2629 std::uintmax_t max_iter = 100;
2630 auto bracket = boost::math::tools::toms748_solve(rho_resid, P_lo, P_hi, boost::math::tools::eps_tolerance<double>(40), max_iter);
2631 double P_sol = (bracket.first + bracket.second) / 2.0;
2632 HEOS.update(PT_INPUTS, P_sol, T);
2633 return HEOS.keyed_output(other);
2634 };
2635
2636 // Outer T-sweep: logarithmic scan to find T bracket where
2637 // X(T) - X_target changes sign.
2638 double logTmin = std::log10(static_cast<double>(Tmin));
2639 double logTmax = std::log10(static_cast<double>(Tmax));
2640 double dlogT = 0.1; // ~10 steps per decade
2641 int nsteps_T = static_cast<int>((logTmax - logTmin) / dlogT) + 1;
2642
2643 double T_lo = -1, T_hi = -1;
2644 double f_prev_T = 0, T_prev = 0;
2645 bool have_prev_T = false;
2646
2647 for (int i = 0; i <= nsteps_T; i++) {
2648 double logT = logTmin + i * dlogT;
2649 if (logT > logTmax) logT = logTmax;
2650 double T = std::pow(10.0, logT);
2651 double X;
2652 try {
2653 X = solve_for_caloric_at_T(T);
2654 } catch (...) {
2655 have_prev_T = false;
2656 continue;
2657 }
2658 if (std::isnan(X)) {
2659 have_prev_T = false;
2660 continue;
2661 }
2662 double f = X - value;
2663 if (have_prev_T && f_prev_T * f < 0) {
2664 T_lo = T_prev;
2665 T_hi = T;
2666 break;
2667 }
2668 T_prev = T;
2669 f_prev_T = f;
2670 have_prev_T = true;
2671 }
2672
2673 if (T_lo > 0 && T_hi > 0) {
2674 auto outer_resid = [&](double T) -> double {
2675 double X = solve_for_caloric_at_T(T);
2676 if (std::isnan(X)) throw ValueError("inner P-sweep failed during T-sweep");
2677 return X - value;
2678 };
2679 std::uintmax_t max_iter = 100;
2680 auto bracket = boost::math::tools::toms748_solve(outer_resid, T_lo, T_hi, boost::math::tools::eps_tolerance<double>(40), max_iter);
2681 double T_sol = (bracket.first + bracket.second) / 2.0;
2682 solve_for_caloric_at_T(T_sol);
2683 // HEOS is now set at the converged (T, P) state
2684 } else {
2685 throw ValueError(format("HSU_D_flash for mixture: no T bracket found in [%Lg, %Lg] "
2686 "for target %s=%Lg at rho=%Lg",
2687 Tmin, Tmax, get_parameter_information(other, "short").c_str(), value, rho_target));
2688 }
2689 }
2690
2691 // Verify the mixture result reproduces BOTH requested inputs (#3148/#3192): the density
2692 // (matched by the inner P-sweep) and the caloric H/S/U (matched by the outer T-sweep).
2693 // TOMS748 can return a bracket endpoint where these are not actually hit (e.g. a
2694 // discontinuity from a phase misclassification), so without this check the flash could
2695 // SILENTLY return a wrong state. Mirrors the HSU_P / DHSU_T guards.
2696 {
2697 const auto resid_cal = static_cast<double>(HEOS.keyed_output(other) - value);
2698 const auto resid_rho = static_cast<double>(HEOS.keyed_output(iDmolar) - rho_target);
2699 const double scale_cal = std::abs(static_cast<double>(value)) + 1.0;
2700 const double scale_rho = std::abs(static_cast<double>(rho_target)) + 1.0;
2701 if (!ValidNumber(resid_cal) || std::abs(resid_cal) > 1e-6 * scale_cal || !ValidNumber(resid_rho)
2702 || std::abs(resid_rho) > 1e-6 * scale_rho) {
2703 throw ValueError(format("HSU_D_flash for mixture did not converge to the specification: caloric residual %g "
2704 "(target %s=%g), density residual %g (target %g) -- the (T,p) flash is misclassifying the phase",
2705 resid_cal, get_parameter_information(other, "short").c_str(), static_cast<double>(value), resid_rho,
2706 static_cast<double>(rho_target)));
2707 }
2708 }
2709 }
2710}
2711
2713 double A[2][2], B[2][2];
2714 CoolPropDbl y = _HUGE;
2716 _HEOS.update(DmolarT_INPUTS, rhomolar0, T0);
2717 CoolPropDbl Tc = HEOS.calc_T_critical();
2718 CoolPropDbl rhoc = HEOS.calc_rhomolar_critical();
2719 CoolPropDbl R = HEOS.gas_constant();
2720 CoolPropDbl p = HEOS.p();
2721 switch (other) {
2722 case iHmolar:
2723 y = HEOS.hmolar();
2724 break;
2725 case iSmolar:
2726 y = HEOS.smolar();
2727 break;
2728 default:
2729 throw ValueError("other is invalid in HSU_P_flash_singlephase_Newton");
2730 }
2731
2732 CoolPropDbl worst_error = 999;
2733 int iter = 0;
2734 bool failed = false;
2735 CoolPropDbl omega = 1.0, f2 = NAN, df2_dtau = NAN, df2_ddelta = NAN;
2736 CoolPropDbl tau = _HEOS.tau(), delta = _HEOS.delta();
2737 while (worst_error > 1e-6 && failed == false) {
2738
2739 // All the required partial derivatives
2740 CoolPropDbl a0 = _HEOS.calc_alpha0_deriv_nocache(0, 0, HEOS.mole_fractions, tau, delta, Tc, rhoc);
2741 CoolPropDbl da0_ddelta = _HEOS.calc_alpha0_deriv_nocache(0, 1, HEOS.mole_fractions, tau, delta, Tc, rhoc);
2742 CoolPropDbl da0_dtau = _HEOS.calc_alpha0_deriv_nocache(1, 0, HEOS.mole_fractions, tau, delta, Tc, rhoc);
2743 CoolPropDbl d2a0_dtau2 = _HEOS.calc_alpha0_deriv_nocache(2, 0, HEOS.mole_fractions, tau, delta, Tc, rhoc);
2744 CoolPropDbl d2a0_ddelta_dtau = 0.0;
2745
2746 CoolPropDbl ar = _HEOS.calc_alphar_deriv_nocache(0, 0, HEOS.mole_fractions, tau, delta);
2747 CoolPropDbl dar_dtau = _HEOS.calc_alphar_deriv_nocache(1, 0, HEOS.mole_fractions, tau, delta);
2748 CoolPropDbl dar_ddelta = _HEOS.calc_alphar_deriv_nocache(0, 1, HEOS.mole_fractions, tau, delta);
2749 CoolPropDbl d2ar_ddelta_dtau = _HEOS.calc_alphar_deriv_nocache(1, 1, HEOS.mole_fractions, tau, delta);
2750 CoolPropDbl d2ar_ddelta2 = _HEOS.calc_alphar_deriv_nocache(0, 2, HEOS.mole_fractions, tau, delta);
2751 CoolPropDbl d2ar_dtau2 = _HEOS.calc_alphar_deriv_nocache(2, 0, HEOS.mole_fractions, tau, delta);
2752
2753 CoolPropDbl f1 = delta / tau * (1 + delta * dar_ddelta) - p / (rhoc * R * Tc);
2754 CoolPropDbl df1_dtau = (1 + delta * dar_ddelta) * (-delta / tau / tau) + delta / tau * (delta * d2ar_ddelta_dtau);
2755 CoolPropDbl df1_ddelta = (1.0 / tau) * (1 + 2.0 * delta * dar_ddelta + delta * delta * d2ar_ddelta2);
2756 switch (other) {
2757 case iHmolar: {
2758 f2 = (1 + delta * dar_ddelta) + tau * (da0_dtau + dar_dtau) - tau * y / (R * Tc);
2759 df2_dtau = delta * d2ar_ddelta_dtau + da0_dtau + dar_dtau + tau * (d2a0_dtau2 + d2ar_dtau2) - y / (R * Tc);
2760 df2_ddelta = (dar_ddelta + delta * d2ar_ddelta2) + tau * (d2a0_ddelta_dtau + d2ar_ddelta_dtau);
2761 break;
2762 }
2763 case iSmolar: {
2764 f2 = tau * (da0_dtau + dar_dtau) - ar - a0 - y / R;
2765 df2_dtau = tau * (d2a0_dtau2 + d2ar_dtau2) + (da0_dtau + dar_dtau) - dar_dtau - da0_dtau;
2766 df2_ddelta = tau * (d2a0_ddelta_dtau + d2ar_ddelta_dtau) - dar_ddelta - da0_ddelta;
2767 break;
2768 }
2769 default:
2770 throw ValueError("other variable in HSU_P_flash_singlephase_Newton is invalid");
2771 }
2772
2773 //First index is the row, second index is the column
2774 A[0][0] = df1_dtau;
2775 A[0][1] = df1_ddelta;
2776 A[1][0] = df2_dtau;
2777 A[1][1] = df2_ddelta;
2778
2779 //double det = A[0][0]*A[1][1]-A[1][0]*A[0][1];
2780
2781 MatInv_2(A, B);
2782 tau -= omega * (B[0][0] * f1 + B[0][1] * f2);
2783 delta -= omega * (B[1][0] * f1 + B[1][1] * f2);
2784
2785 if (std::abs(f1) > std::abs(f2))
2786 worst_error = std::abs(f1);
2787 else
2788 worst_error = std::abs(f2);
2789
2790 if (!ValidNumber(f1) || !ValidNumber(f2)) {
2791 throw SolutionError(format("Invalid values for inputs p=%g y=%g for fluid %s in HSU_P_flash_singlephase", p, y, _HEOS.name().c_str()));
2792 }
2793
2794 iter += 1;
2795 if (iter > 100) {
2796 throw SolutionError(format("HSU_P_flash_singlephase did not converge with inputs p=%g h=%g for fluid %s", p, y, _HEOS.name().c_str()));
2797 }
2798 }
2799
2800 HEOS.update(DmolarT_INPUTS, rhoc * delta, Tc / tau);
2801}
2803 CoolPropDbl Tmax, phases phase) {
2804 if (!ValidNumber(HEOS._p)) {
2805 throw ValueError("value for p in HSU_P_flash_singlephase_Brent is invalid");
2806 };
2807 if (!ValidNumber(value)) {
2808 throw ValueError("value for other in HSU_P_flash_singlephase_Brent is invalid");
2809 };
2810 // Save the target pressure at function entry. HEOS._p can be CORRUPTED by
2811 // the inner density solve (update(PT_INPUTS, p, T)) if it throws part-way
2812 // through its Newton iteration -- it can be left holding a garbage value
2813 // (e.g. a negative pressure). The exception-fallback guard below must test
2814 // the *intended* pressure, not this possibly-corrupted live value, otherwise
2815 // it branches on garbage and re-throws instead of running the 2-D Newton
2816 // fallback. Always read p_target (not HEOS._p) in the fallback guard.
2817 const CoolPropDbl p_target = HEOS._p;
2818
2819 // Env toggles for the direct-EOS cache-bypass path (CoolProp-cdj).
2820 //
2821 // Default: ON for pure fluids — bit-equivalent within ULP to master, ~17%
2822 // faster end-to-end on the PXcdj_sweep, validated against 362,004 grid
2823 // points with zero new failures. Opt out by setting PXFLASH_DIRECT_EOS=0.
2824 //
2825 // PXFLASH_DIRECT_EOS=0 - disable the direct path; uses master's full
2826 // cached path. All other values (incl. unset)
2827 // keep the default-on direct path.
2828 // (Cold probes always keep master's
2829 // update(PT_INPUTS) so the SRK + Householder4
2830 // basin classification is preserved exactly.)
2831 // PXFLASH_INNER_NEWTON - OFF (default) = Householder3 inner solver;
2832 // ON = plain Newton (no 2nd/3rd derivs).
2833 //
2834 // is_pure() gate excludes mixtures and pseudo-pure here -- the property
2835 // reconstruction formulas below are written for a single residual block.
2836 static const bool pxflash_direct_eos = []() {
2837 const char* env = std::getenv("PXFLASH_DIRECT_EOS");
2838 if (env == nullptr) return true;
2839 return std::string(env) != "0";
2840 }();
2841 static const bool pxflash_inner_newton = (std::getenv("PXFLASH_INNER_NEWTON") != nullptr);
2842 const bool direct_path_enabled = pxflash_direct_eos && HEOS.is_pure();
2843
2844 class solver_resid : public FuncWrapper1DWithTwoDerivs
2845 {
2846 public:
2848 CoolPropDbl p, value;
2849 parameters other;
2850 int iter;
2851 CoolPropDbl eos0, eos1, rhomolar, rhomolar0, rhomolar1;
2852 CoolPropDbl Tmin, Tmax;
2853 bool direct_path_enabled;
2854 // Critical pressure, used to gate the warm-density shortcut off for
2855 // supercritical-pressure probes — see the note in call().
2856 CoolPropDbl p_crit;
2857
2858 solver_resid(HelmholtzEOSMixtureBackend* HEOS, CoolPropDbl p, CoolPropDbl value, parameters other, double Tmin, double Tmax,
2859 bool direct_path_enabled)
2860 : HEOS(HEOS),
2861 p(p),
2862 value(value),
2863 other(other),
2864 iter(0),
2865 eos0(-_HUGE),
2866 eos1(-_HUGE),
2867 rhomolar(_HUGE),
2868 rhomolar0(_HUGE),
2869 rhomolar1(_HUGE),
2870 Tmin(Tmin),
2871 Tmax(Tmax),
2872 direct_path_enabled(direct_path_enabled),
2873 p_crit(HEOS->p_critical()) {
2874 // Specify the state to avoid saturation calls, but only if phase is subcritical
2875 switch (CoolProp::phases phase = HEOS->phase()) {
2876 case iphase_liquid:
2877 case iphase_gas:
2878 HEOS->specify_phase(phase);
2879 default:
2880 // Otherwise don't do anything (this is to make compiler happy)
2881 {}
2882 }
2883 }
2884 // Inner-density Newton: solve p(T, rho) = p_target using only
2885 // residual_helmholtz->all (bypasses CachedElement). Derivatives
2886 // match SolverTPResid::deriv/second_deriv/third_deriv exactly.
2887 // Throws on failure so the caller can fall back to the master path.
2888 //
2889 // ar_out / a0_out: populated with the HelmholtzDerivatives (residual
2890 // and ideal-gas) evaluated AT the converged rho. Loop checks
2891 // convergence on (rho, ar) BEFORE taking the step so ar_out is never
2892 // stale; a0_out is computed once at the converged rho immediately
2893 // before return. This makes direct_density_newton the sole producer
2894 // of derivative sets and lets direct_property_eval be a pure consumer
2895 // (no ->all(), no calc_all_alpha0_derivs_nocache).
2896 static double direct_density_newton(HelmholtzEOSMixtureBackend& H, const std::vector<CoolPropDbl>& x, double Tr, double rhor, double R,
2897 double T, double p_target, double rho_initial, bool use_plain_newton, HelmholtzDerivatives& ar_out,
2898 HelmholtzDerivatives& a0_out) {
2899 const double tau = Tr / T;
2900 double rho = rho_initial;
2901 for (int it = 0; it < 15; ++it) {
2902 const double delta = rho / rhor;
2903 ar_out = H.residual_helmholtz->all(H, x, tau, delta, false);
2904 const double alphar_d = ar_out.dalphar_ddelta;
2905 const double alphar_dd = ar_out.d2alphar_ddelta2;
2906 const double p = rho * R * T * (1.0 + delta * alphar_d);
2907 const double f = p - p_target;
2908 const double dpdrho = R * T * (1.0 + 2.0 * delta * alphar_d + delta * delta * alphar_dd);
2909 if (dpdrho <= 0.0) {
2910 // Mechanically unstable region (inside spinodal); the cached path's
2911 // solver_rho_Tp has phase-imposed basin-recovery for this case.
2912 // Throw to fall back to master's cached path via the catch in call().
2913 throw ValueError("direct-EOS Newton: dpdrho <= 0 (unstable region)");
2914 }
2915 // Residual convergence: ar_out matches the returned rho exactly.
2916 if (std::abs(f) < 1e-12 * std::abs(p_target)) {
2917 a0_out = H.calc_all_alpha0_derivs_nocache(x, tau, delta, Tr, rhor);
2918 return rho;
2919 }
2920 double dx;
2921 if (use_plain_newton) {
2922 if (!std::isfinite(dpdrho) || std::abs(dpdrho) < 1e-300) {
2923 throw ValueError("direct-EOS density Newton: singular dpdrho");
2924 }
2925 dx = -f / dpdrho;
2926 } else {
2927 const double alphar_ddd = ar_out.d3alphar_ddelta3;
2928 const double alphar_dddd = ar_out.d4alphar_ddelta4;
2929 const double d2pdrho2 = (R * T / rhor) * (2.0 * alphar_d + 4.0 * delta * alphar_dd + delta * delta * alphar_ddd);
2930 const double d3pdrho3 = (R * T / (rhor * rhor)) * (6.0 * alphar_dd + 6.0 * delta * alphar_ddd + delta * delta * alphar_dddd);
2931 // Householder3 step (same form as solvers.cpp Householder4 body)
2932 const double num = f * (dpdrho * dpdrho - f * d2pdrho2 / 2.0);
2933 const double den = dpdrho * dpdrho * dpdrho - f * dpdrho * d2pdrho2 + d3pdrho3 * f * f / 6.0;
2934 dx = (!std::isfinite(den) || std::abs(den) < 1e-300) ? (-f / dpdrho) : (-num / den);
2935 }
2936 if (!std::isfinite(dx)) throw ValueError("direct-EOS density Newton: non-finite step");
2937 double scale = 1.0;
2938 while (rho + scale * dx <= 0.0 && scale > 1e-6)
2939 scale *= 0.5;
2940 const double rho_new = rho + scale * dx;
2941 if (!std::isfinite(rho_new) || rho_new <= 0.0) throw ValueError("direct-EOS density Newton: non-finite rho");
2942 // Step-size convergence: the step was taken (rho moved), so the
2943 // ar_out from BEFORE the step is now stale w.r.t. rho_new.
2944 // Refresh ar_out AND a0_out at rho_new before returning so the
2945 // caller can safely consume them. This is the rare path (most
2946 // converge on |f| < tol above); one extra ->all() + one extra
2947 // alpha0 vs. master's ~9 ->all()s saved.
2948 if (std::abs(scale * dx) / rho_new < 1e-12) {
2949 rho = rho_new;
2950 const double delta_new = rho / rhor;
2951 ar_out = H.residual_helmholtz->all(H, x, tau, delta_new, false);
2952 a0_out = H.calc_all_alpha0_derivs_nocache(x, tau, delta_new, Tr, rhor);
2953 return rho;
2954 }
2955 rho = rho_new;
2956 }
2957 throw ValueError("direct-EOS density Newton: max iters");
2958 }
2959 // Direct property reconstruction at (T, rho) using HelmholtzDerivatives
2960 // sets supplied by direct_density_newton at convergence. Matches
2961 // calc_hmolar_nocache / calc_smolar_nocache / calc_umolar_nocache exactly.
2962 // Pure consumer: no ->all() and no calc_all_alpha0_derivs_nocache here;
2963 // direct_density_newton is the sole producer of derivative sets so the
2964 // cost-model accounting stays honest (one ar + one a0 per outer probe).
2965 static double direct_property_eval(HelmholtzEOSMixtureBackend& H, const std::vector<CoolPropDbl>& x, double Tr, double rhor, double R,
2966 double T, double rho, parameters other, const HelmholtzDerivatives& ar, const HelmholtzDerivatives& a0) {
2967 const double tau = Tr / T;
2968 const double delta = rho / rhor;
2969 const double atau_total = a0.dalphar_dtau + ar.dalphar_dtau;
2970 const double a_total = a0.alphar + ar.alphar;
2971 switch (other) {
2972 case iHmolar:
2973 return R * T * (1.0 + tau * atau_total + delta * ar.dalphar_ddelta);
2974 case iSmolar:
2975 return R * (tau * atau_total - a_total);
2976 case iUmolar:
2977 return R * T * tau * atau_total;
2978 default:
2979 throw ValueError("direct-EOS property: unsupported `other` key");
2980 }
2981 }
2982 double call(double T) override {
2983 // Direct-path WARM branch only: cold probes go through master's
2984 // update(PT_INPUTS) so the SRK seed + Householder4 + basin-recovery
2985 // (iphase_liquid/gas retries inside solver_rho_Tp) are byte-identical
2986 // to master. iter < 2 cold iters are amortized; the win is from
2987 // bypassing the cached inner Householder4 on the warm probes that
2988 // dominate TOMS748's iteration count.
2989 //
2990 // EXCEPTION (CoolProp-5sh8): for supercritical pressure (p > p_crit)
2991 // the warm carried-rho density Newton is unreliable across the whole
2992 // near-critical region, in TWO distinct ways:
2993 // * below T_crit the isotherm p(rho, T) still has a van der Waals
2994 // loop, so the warm Newton (seeded from a neighbouring probe's
2995 // rho) can converge to the spurious low-density root — observed
2996 // for Argon PSmass: T = 127 K returned for a 152 K state;
2997 // * just above T_crit dp/drho -> 0, so the Newton step blows up and
2998 // converges to a garbage density (observed for Oxygen PSmass:
2999 // rho ~ 2600 kg/m3, a negative entropy).
3000 // Either way the property evaluated at the bad density is wrong, the
3001 // outer residual becomes NON-MONOTONIC in T, and Brent latches onto a
3002 // false sign change at the wrong T. But for p > p_crit the residual
3003 // is PHYSICALLY monotone in T (e.g. ds/dT|p = cp/T > 0), so force the
3004 // robust cold update(PT_INPUTS) path — whose phase-imposed
3005 // solver_rho_Tp (with the #3176 dense-branch TOMS748 fallback)
3006 // returns the correct single-phase root — for every supercritical-
3007 // pressure probe. Subcritical p keeps the warm shortcut (single
3008 // density branch per imposed phase, no loop).
3009 const bool force_robust_density = (p > p_crit);
3010 const bool want_warm = (iter >= 2 && std::abs(rhomolar1 / rhomolar0 - 1) <= 0.05);
3011 if (direct_path_enabled && want_warm && !force_robust_density) {
3012 try {
3013 // WARM: bypass the cached inner solve; reuse the carried
3014 // rhomolar (identical to what update_TP_guessrho would seed).
3015 // ar AND a0 are filled by direct_density_newton at the
3016 // converged rho and reused by direct_property_eval -- saves
3017 // one ->all() + one alpha0 call per outer probe at the same
3018 // (tau, delta). direct_density_newton is the sole producer;
3019 // direct_property_eval is a pure consumer (symmetry).
3020 HelmholtzDerivatives ar, a0;
3021 const double rho_solved =
3022 direct_density_newton(*HEOS, HEOS->get_mole_fractions_ref(), HEOS->get_reducing_state().T, HEOS->get_reducing_state().rhomolar,
3023 HEOS->gas_constant(), T, p, rhomolar, pxflash_inner_newton, ar, a0);
3024 CoolPropDbl eos = direct_property_eval(*HEOS, HEOS->get_mole_fractions_ref(), HEOS->get_reducing_state().T,
3025 HEOS->get_reducing_state().rhomolar, HEOS->gas_constant(), T, rho_solved, other, ar, a0);
3026 // Commit (T, rho, p) to the HEOS cache so deriv/second_deriv
3027 // (used by Halley) and any downstream readers see the
3028 // converged state. We use update_TDmolarP_unchecked
3029 // instead of update_DmolarT_direct because the latter
3030 // re-derives p from (T, rho) via calc_pressure() (one more
3031 // ->dalphar_dDelta()), whereas here p == p_target by
3032 // construction of the Newton convergence test.
3033 HEOS->update_TDmolarP_unchecked(T, rho_solved, p);
3034 rhomolar = rho_solved;
3035
3036 if (verbosity > 0 && iter == 0) {
3037 std::cout << format("T: %0.15g rho: %0.15g eos: %0.15g", T, rhomolar, eos);
3038 }
3039 CoolPropDbl r = eos - value;
3040 if (iter == 0) {
3041 eos0 = eos;
3042 rhomolar0 = rhomolar;
3043 } else if (iter == 1) {
3044 eos1 = eos;
3045 rhomolar1 = rhomolar;
3046 } else {
3047 eos0 = eos1;
3048 eos1 = eos;
3049 rhomolar0 = rhomolar1;
3050 rhomolar1 = rhomolar;
3051 }
3052 iter++;
3053 return r;
3054 } catch (...) {
3055 // Defensive per-probe fallback to the master cached path.
3056 // fall through
3057 }
3058 }
3059
3060 if (iter < 2 || std::abs(rhomolar1 / rhomolar0 - 1) > 0.05 || force_robust_density) {
3061 // Run the solver with T,P as inputs; but only if the last change in density was greater than a few percent
3062 // (or we are in the supercritical-pressure vdW-loop region, where the carried-rho guess can hop branches).
3063 HEOS->update(PT_INPUTS, p, T);
3064 } else {
3065 // Run the solver with T,P as inputs; but use the guess value for density from before
3066 HEOS->update_TP_guessrho(T, p, rhomolar);
3067 }
3068
3069 // Get the value of the desired variable
3070 CoolPropDbl eos = HEOS->keyed_output(other);
3071
3072 // Store the value of density
3073 rhomolar = HEOS->rhomolar();
3074
3075 if (verbosity > 0 && iter == 0) {
3076 std::cout << format("T: %0.15g rho: %0.15g eos: %0.15g", T, rhomolar, eos);
3077 }
3078
3079 // Difference between the two is to be driven to zero
3080 CoolPropDbl r = eos - value;
3081
3082 // Store values for later use if there are errors
3083 if (iter == 0) {
3084 eos0 = eos;
3085 rhomolar0 = rhomolar;
3086 } else if (iter == 1) {
3087 eos1 = eos;
3088 rhomolar1 = rhomolar;
3089 } else {
3090 eos0 = eos1;
3091 eos1 = eos;
3092 rhomolar0 = rhomolar1;
3093 rhomolar1 = rhomolar;
3094 }
3095
3096 iter++;
3097 return r;
3098 };
3099 double deriv(double T) override {
3100 return HEOS->first_partial_deriv(other, iT, iP);
3101 }
3102 double second_deriv(double T) override {
3103 return HEOS->second_partial_deriv(other, iT, iP, iT, iP);
3104 }
3105 bool input_not_in_range(double x) override {
3106 return (x < Tmin || x > Tmax);
3107 }
3108 };
3109 solver_resid resid(&HEOS, HEOS._p, value, other, Tmin, Tmax, direct_path_enabled);
3110
3111 class resid_2D : public FuncWrapperND
3112 {
3113 public:
3115 CoolPropDbl p, value;
3116 parameters other;
3117 int iter;
3118 std::vector<std::vector<double>> J = {{-1.0, -1.0}, {-1.0, -1.0}};
3119
3120 resid_2D(HelmholtzEOSMixtureBackend* HEOS, double p, CoolPropDbl value, parameters other, double Tmin, double Tmax)
3121 : HEOS(HEOS), p(p), value(value), other(other), iter(0) {}
3122 std::vector<double> call(const std::vector<double>& x) override {
3123 double T = x[0], rhomolar = x[1];
3124 HEOS->update_DmolarT_direct(rhomolar, T);
3125 J[0][0] = HEOS->first_partial_deriv(iP, iT, iDmolar) / p;
3126 J[0][1] = HEOS->first_partial_deriv(iP, iDmolar, iT) / p;
3127 J[1][0] = HEOS->first_partial_deriv(other, iT, iDmolar);
3128 J[1][1] = HEOS->first_partial_deriv(other, iDmolar, iT);
3129
3130 return {(HEOS->p() - p) / p, HEOS->keyed_output(other) - value};
3131 }
3132 std::vector<std::vector<double>> Jacobian(const std::vector<double>& /*x*/) override {
3133 // pre-calculated in call function
3134 return J;
3135 }
3136 };
3137 resid_2D solver_resid2d(&HEOS, HEOS._p, value, other, Tmin, Tmax);
3138
3139 // Get residual values at the bounds
3140 double resid_Tmin = resid.call(Tmin);
3141 double rhomolar_Tmin = HEOS.rhomolar();
3142
3143 double resid_Tmax = resid.call(Tmax);
3144 double rhomolar_Tmax = HEOS.rhomolar();
3145
3146 // For the derivative-based methods, figure out which point to start from
3147 bool use_min = std::abs(resid_Tmin) < std::abs(resid_Tmax);
3148 double Tstart = use_min ? Tmin : Tmax;
3149 double rhomolarstart = use_min ? rhomolar_Tmin : rhomolar_Tmax;
3150
3151 try {
3152 if (get_debug_level() > 0) {
3153 resid.verbosity = 1;
3154 }
3155 if (resid_Tmin * resid_Tmax < 0) {
3156 // The residual values bound zero, use the TOMS748 method (no derivatives)
3157 // See: https://www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/internals1/roots2.html#math_toolkit.internals1.roots2.algorithm_toms_748_alefeld_potra
3158 //
3159 // It is like a supercharged version of Brent's method, which is practically guaranteed
3160 // to converge for any continuous function, and take the optimal step among bisection
3161 // and higher-order methods
3162 resid.iter = 0;
3163 boost::math::uintmax_t max_iter = 100;
3164
3165 auto f = [&resid](const double T) { return resid.call(T); };
3166 // TOMS748 correct-bits tolerance is 2^(1-bits). 44 bits (~1.1e-13)
3167 // is far tighter than thermophysical properties need; 30 bits
3168 // (~9.3e-10 on T) keeps the worst P+X round-trip well under the 1e-6
3169 // hard limit while cutting ~7% of the outer iterations.
3170 // >>> 2**(1-30) == 9.31e-10
3171 auto [l, r] = toms748_solve(f, Tmin, Tmax, resid_Tmin, resid_Tmax, boost::math::tools::eps_tolerance<double>(30), max_iter);
3172 // Re-evaluate at the midpoint of the final bracket so HEOS is left at
3173 // the converged state (TOMS748's last probe may be at a slightly
3174 // different point within the bracket). Same discipline as the
3175 // supercritical-cold narrow-TOMS748 path below.
3176 resid.iter = 0;
3177 resid.call(0.5 * (l + r));
3178 if (!is_in_closed_range(Tmin, Tmax, static_cast<CoolPropDbl>(resid.HEOS->T()))) {
3179 throw ValueError(format("TOMS748 method yielded out of bound T of %g", static_cast<CoolPropDbl>(resid.HEOS->T())));
3180 }
3181
3182 // Un-specify the phase of the fluid
3183 HEOS.unspecify_phase();
3185 } else {
3186 resid.iter = 0;
3187 Halley(resid, Tstart, 1e-12, 100);
3188 if (!is_in_closed_range(Tmin, Tmax, static_cast<CoolPropDbl>(resid.HEOS->T())) || resid.HEOS->phase() != phase) {
3189 throw ValueError("Halley's method was unable to find a solution in HSU_P_flash_singlephase_Brent");
3190 }
3191 // Un-specify the phase of the fluid
3192 HEOS.unspecify_phase();
3194 }
3195 } catch (...) {
3196 try {
3197 double p_critical_ = HEOS.p_critical();
3198 // Decide whether to attempt the 2-D Newton fallback. IMPORTANT: test
3199 // p_target (saved at entry) NOT HEOS._p -- the inner density solve can
3200 // leave HEOS._p corrupted when it throws mid-Newton, and branching on
3201 // that garbage pressure is what caused supercritical-cold P+X states to
3202 // re-throw instead of recovering (see the p_target comment above).
3203 // - near-critical (0.95*pc < p <= pc): the original fallback case.
3204 // - supercritical (p > pc): compressed-liquid corner where TOMS748
3205 // can't bracket zero across the wide T range; engage the fallback
3206 // here too (this is the Nitrogen/PS fix).
3207 const bool in_near_critical = (p_target > 0.95 * p_critical_ && p_target <= p_critical_);
3208 const bool in_supercritical = (p_target > p_critical_);
3209 if (!in_near_critical && !in_supercritical) {
3210 throw;
3211 }
3212 if (get_debug_level() > 0) {
3213 std::cout << resid.errstring << '\n';
3214 }
3215 if (in_supercritical) {
3216 // Supercritical-cold strategy. TOMS748 threw because the EOS has
3217 // numerical issues at high T inside the wide supercritical bracket;
3218 // the solution is a compressed-liquid state at T << T_crit.
3219 // Step 1: try a narrowed TOMS748 on [Tmin, 1.05*T_crit], which
3220 // avoids the high-T instability and the non-monotone region.
3221 // Step 2: if that bracket doesn't sign-change (or throws), fall
3222 // back to the 2-D Newton from the high-density Tmin seed.
3223 const double T_crit = HEOS.T_critical();
3224 const double Tnarrow = T_crit * 1.05;
3225 bool narrow_toms748_ok = false;
3226 if (Tnarrow < Tmax) {
3227 resid.iter = 0;
3228 double resid_Tnarrow = 0.0;
3229 bool have_narrow_bracket = false;
3230 try {
3231 resid_Tnarrow = resid.call(Tnarrow);
3232 have_narrow_bracket = (resid_Tnarrow * resid_Tmin < 0);
3233 } catch (const std::exception& e) {
3234 if (get_debug_level() > 0) {
3235 std::cout << "narrow TOMS748 probe threw: " << e.what() << '\n';
3236 }
3237 // narrow_toms748_ok stays false -> fall through to 2-D Newton backstop
3238 }
3239 if (have_narrow_bracket) {
3240 resid.iter = 0;
3241 boost::math::uintmax_t max_iter_narrow = 100;
3242 auto fn = [&resid](const double T) { return resid.call(T); };
3243 try {
3244 auto [nl, nr] = toms748_solve(fn, Tmin, Tnarrow, resid_Tmin, resid_Tnarrow, boost::math::tools::eps_tolerance<double>(30),
3245 max_iter_narrow);
3246 // Re-evaluate at the midpoint of the final bracket so HEOS
3247 // is left at the converged state (TOMS748's last call may
3248 // be at a slightly different point).
3249 const double T_conv = 0.5 * (nl + nr);
3250 resid.iter = 0;
3251 resid.call(T_conv);
3252 if (is_in_closed_range(Tmin, Tmax, static_cast<CoolPropDbl>(resid.HEOS->T()))) {
3253 HEOS.unspecify_phase();
3255 narrow_toms748_ok = true;
3256 }
3257 } catch (const std::exception& e) {
3258 // narrow TOMS748 also threw -- intentionally swallow and fall
3259 // through to the 2-D Newton fallback below (narrow_toms748_ok
3260 // stays false). Surface the reason only at debug level.
3261 if (get_debug_level() > 0) {
3262 std::cout << "narrow TOMS748 fallback threw: " << e.what() << '\n';
3263 }
3264 }
3265 }
3266 }
3267 if (!narrow_toms748_ok) {
3268 // 2-D Newton from the high-density Tmin seed (liquid-like basin).
3269 std::vector<double> x0 = {Tmin, rhomolar_Tmin};
3270 NDNewtonRaphson_Jacobian(&solver_resid2d, x0, 1e-12, 20, 1.0);
3271 // Acceptance check: T in range AND both residuals small.
3272 // NDNewtonRaphson_Jacobian can early-exit on a tiny step while
3273 // the residual is still large; verify the state actually reproduces
3274 // the inputs rather than returning a silently-wrong root.
3275 {
3276 const double T_sol = static_cast<CoolPropDbl>(solver_resid2d.HEOS->T());
3277 const double p_resid_rel = std::abs(HEOS.p() - p_target) / p_target;
3278 const double other_scale = std::abs(value) > 1.0 ? std::abs(value) : 1.0;
3279 const double other_resid_rel = std::abs(HEOS.keyed_output(other) - value) / other_scale;
3280 if (!is_in_closed_range(Tmin, Tmax, T_sol) || p_resid_rel > 1e-6 || other_resid_rel > 1e-6) {
3281 throw ValueError("2D Newton method was unable to find a solution in HSU_P_flash_singlephase_Brent");
3282 }
3283 }
3284 HEOS.unspecify_phase();
3286 }
3287 } else {
3288 std::vector<double> x0 = {Tstart, rhomolarstart};
3289 NDNewtonRaphson_Jacobian(&solver_resid2d, x0, 1e-12, 20, 1.0);
3290 const double T_sol = static_cast<CoolPropDbl>(solver_resid2d.HEOS->T());
3291 const double p_resid_rel = std::abs(HEOS.p() - p_target) / p_target;
3292 const double other_scale = std::abs(value) > 1.0 ? std::abs(value) : 1.0;
3293 const double other_resid_rel = std::abs(HEOS.keyed_output(other) - value) / other_scale;
3294 if (!is_in_closed_range(Tmin, Tmax, T_sol) || solver_resid2d.HEOS->phase() != phase || p_resid_rel > 1e-6 || other_resid_rel > 1e-6) {
3295 throw ValueError("2D Newton method was unable to find a solution in HSU_P_flash_singlephase_Brent");
3296 }
3297 // Un-specify the phase of the fluid
3298 HEOS.unspecify_phase();
3300 }
3301 } catch (...) {
3302 if (get_debug_level() > 0) {
3303 std::cout << resid.errstring << '\n';
3304 }
3305 // Un-specify the phase of the fluid
3306 HEOS.unspecify_phase();
3307
3308 // Determine why you were out of range if you can
3309 //
3310 CoolPropDbl eos0 = resid.eos0, eos1 = resid.eos1;
3311 std::string name = get_parameter_information(other, "short");
3312 std::string units = get_parameter_information(other, "units");
3313 if (eos1 > eos0 && value > eos1) {
3314 throw ValueError(
3315 format("HSU_P_flash_singlephase_Brent could not find a solution because %s [%Lg %s] is above the maximum value of %0.12Lg %s",
3316 name.c_str(), value, units.c_str(), eos1, units.c_str()));
3317 }
3318 if (eos1 > eos0 && value < eos0) {
3319 throw ValueError(
3320 format("HSU_P_flash_singlephase_Brent could not find a solution because %s [%Lg %s] is below the minimum value of %0.12Lg %s",
3321 name.c_str(), value, units.c_str(), eos0, units.c_str()));
3322 }
3323 throw;
3324 }
3325 }
3326}
3327
3328// P given and one of H, S, or U
3330 bool saturation_called = false;
3331 CoolPropDbl value = NAN;
3332
3333 // Find the phase, while updating all internal variables possible
3334 switch (other) {
3335 case iSmolar:
3336 value = HEOS.smolar();
3337 break;
3338 case iHmolar:
3339 value = HEOS.hmolar();
3340 break;
3341 case iUmolar:
3342 value = HEOS.umolar();
3343 break;
3344 default:
3345 throw ValueError(format("Input for other [%s] is invalid", get_parameter_information(other, "long").c_str()));
3346 }
3347 if (HEOS.is_pure_or_pseudopure) {
3348
3349 // Find the phase, while updating all internal variables possible
3350 HEOS.p_phase_determination_pure_or_pseudopure(other, value, saturation_called);
3351
3352 if (HEOS.isHomogeneousPhase()) {
3353 // Now we use the single-phase solver to find T,rho given P,Y using a
3354 // bounded 1D solver by adjusting T and using given value of p
3355 CoolPropDbl Tmin = NAN, Tmax = NAN;
3356 switch (HEOS._phase) {
3357 case iphase_gas: {
3358 Tmax = 1.5 * HEOS.Tmax();
3359 if (HEOS._p < HEOS.p_triple()) {
3360 Tmin = std::max(HEOS.Tmin(), HEOS.Ttriple());
3361 } else {
3362
3363 if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
3364 auto superanc_ptr = HEOS.get_superanc();
3365 if (superanc_ptr) {
3366 auto& superanc = *superanc_ptr;
3367 CoolPropDbl pmax_num = superanc.get_pmax();
3368 if (HEOS._p > pmax_num) {
3369 throw ValueError(
3370 format("Pressure to PQ_flash [%0.8Lg Pa] may not be above the numerical critical point of %0.15Lg Pa", HEOS._p,
3371 pmax_num));
3372 }
3373 Tmin = superanc.get_T_from_p(HEOS._p);
3374 break;
3375 }
3376 }
3377 if (saturation_called) {
3378 Tmin = HEOS.SatV->T();
3379 } else {
3380 Tmin = HEOS._TVanc.pt() - 0.01;
3381 }
3382 }
3383 break;
3384 }
3385 case iphase_liquid: {
3386
3387 // Sometimes the minimum pressure for the melting line is a bit above the triple point pressure
3388 if (HEOS.has_melting_line() && HEOS._p > HEOS.calc_melting_line(iP_min, -1, -1)) {
3389 Tmin = HEOS.calc_melting_line(iT, iP, HEOS._p) - 1e-3;
3390 } else {
3391 Tmin = HEOS.Tmin() - 1e-3;
3392 }
3393
3394 if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
3395 auto superanc_ptr = HEOS.get_superanc();
3396 if (superanc_ptr) {
3397 auto& superanc = *superanc_ptr;
3398 CoolPropDbl pmax_num = superanc.get_pmax();
3399 if (HEOS._p > pmax_num) {
3400 throw ValueError(format(
3401 "Pressure to PQ_flash [%0.8Lg Pa] may not be above the numerical critical point of %0.15Lg Pa", HEOS._p, pmax_num));
3402 }
3403 Tmax = superanc.get_T_from_p(HEOS._p);
3404 break;
3405 }
3406 }
3407
3408 if (saturation_called) {
3409 Tmax = HEOS.SatL->T();
3410 } else {
3411 Tmax = HEOS._TLanc.pt() + 0.01;
3412 }
3413
3414 break;
3415 }
3418 case iphase_supercritical: {
3419 Tmax = 1.5 * HEOS.Tmax();
3420 // Sometimes the minimum pressure for the melting line is a bit above the triple point pressure
3421 if (HEOS.has_melting_line() && HEOS._p > HEOS.calc_melting_line(iP_min, -1, -1)) {
3422 Tmin = HEOS.calc_melting_line(iT, iP, HEOS._p) - 1e-3;
3423 } else {
3424 Tmin = HEOS.Tmin() - 1e-3;
3425 }
3426 break;
3427 }
3428 default: {
3429 throw ValueError(format("Not a valid homogeneous state"));
3430 }
3431 }
3432 try {
3433 HSU_P_flash_singlephase_Brent(HEOS, other, value, Tmin, Tmax, HEOS._phase);
3434 } catch (std::exception& e) {
3435 throw ValueError(format("unable to solve 1phase PY flash with Tmin=%Lg, Tmax=%Lg due to error: %s", Tmin, Tmax, e.what()));
3436 }
3437 HEOS._Q = -1;
3438 // Update the state for conditions where the state was guessed
3440 }
3441 } else {
3442 // Mixture: bracket-solve for T at fixed P using TOMS748.
3443 // Each iteration calls update(PT_INPUTS, ...) which dispatches to
3444 // PT_flash_mixtures (stability analysis + two-phase solver). The
3445 // full update path ensures SatL/SatV are set for two-phase states,
3446 // so keyed_output(other) returns correct H/S/U for any phase.
3447 CoolPropDbl Tmin = HEOS.calc_Tmin();
3448 CoolPropDbl Tmax = HEOS.calc_Tmax();
3449 CoolPropDbl p = HEOS._p;
3450
3451 // --- Change 1: PQ-based bracket narrowing ---
3452 // Try PQ flash at Q=0 (bubble) and Q=1 (dew) to narrow the
3453 // bracket and potentially shortcut exact saturation states.
3454 CoolPropDbl T_bubble = -1, T_dew = -1;
3455 CoolPropDbl val_bubble = _HUGE, val_dew = _HUGE;
3456 bool pq_ok = false;
3457 try {
3458 HEOS.update(PQ_INPUTS, p, 0.0);
3459 T_bubble = HEOS.T();
3460 val_bubble = HEOS.keyed_output(other);
3461 HEOS.update(PQ_INPUTS, p, 1.0);
3462 T_dew = HEOS.T();
3463 val_dew = HEOS.keyed_output(other);
3464 pq_ok = std::isfinite(static_cast<double>(T_bubble)) && std::isfinite(static_cast<double>(T_dew))
3465 && std::isfinite(static_cast<double>(val_bubble)) && std::isfinite(static_cast<double>(val_dew));
3466 } catch (...) {
3467 pq_ok = false;
3468 }
3469
3470 if (pq_ok) {
3471 // Shortcut: exact saturation match (bubble)
3472 double tol_sat = 1e-6 * (std::abs(static_cast<double>(value)) + 1.0);
3473 if (std::abs(static_cast<double>(value - val_bubble)) < tol_sat) {
3474 HEOS.update(PQ_INPUTS, p, 0.0);
3475 return;
3476 }
3477 // Shortcut: exact saturation match (dew)
3478 if (std::abs(static_cast<double>(value - val_dew)) < tol_sat) {
3479 HEOS.update(PQ_INPUTS, p, 1.0);
3480 return;
3481 }
3482
3483 // Classify phase and narrow bracket.
3484 // Use actual computed values — don't assume val_bubble < val_dew.
3485 CoolPropDbl val_lo = (val_bubble < val_dew) ? val_bubble : val_dew;
3486 CoolPropDbl val_hi = (val_bubble < val_dew) ? val_dew : val_bubble;
3487 CoolPropDbl T_at_lo = (val_bubble < val_dew) ? T_bubble : T_dew;
3488 CoolPropDbl T_at_hi = (val_bubble < val_dew) ? T_dew : T_bubble;
3489
3490 if (value < val_lo) {
3491 // Below both saturation values → on the low-T side
3492 Tmax = T_at_lo;
3493 } else if (value > val_hi) {
3494 // Above both saturation values → on the high-T side
3495 Tmin = T_at_hi;
3496 }
3497 // else: value between val_lo and val_hi → two-phase region;
3498 // sweep over [T_bubble, T_dew] (PT flash handles two-phase)
3499 // Keep [Tmin, Tmax] as-is so the resid functor can find it.
3500 }
3501 // When !pq_ok (supercritical P, PQ solver failure): fall through
3502 // to the full [Tmin, Tmax] bracket — no regression.
3503
3504 // Residual functor: given T, do PT flash and return Y - Y_target
3505 auto resid = [&](double T) -> double {
3506 HEOS.update(PT_INPUTS, p, T);
3507 return HEOS.keyed_output(other) - value;
3508 };
3509
3510 // Pre-evaluate endpoints to verify they bracket a root before
3511 // calling toms748_solve (which throws an opaque domain_error if
3512 // the signs are the same). On failure, fall back to the endpoint
3513 // whose residual is closest to zero.
3514 double resid_lo = _HUGE, resid_hi = _HUGE;
3515 bool lo_ok = false, hi_ok = false;
3516 try {
3517 resid_lo = resid(static_cast<double>(Tmin));
3518 lo_ok = std::isfinite(resid_lo);
3519 } catch (...) {
3520 }
3521 try {
3522 resid_hi = resid(static_cast<double>(Tmax));
3523 hi_ok = std::isfinite(resid_hi);
3524 } catch (...) {
3525 }
3526
3527 // --- Change 2: Binary-search validity recovery ---
3528 // When one endpoint fails (e.g., Tmin below melting line), bisect
3529 // inward from the failing endpoint toward the working one.
3530 if (!lo_ok && hi_ok) {
3531 double a = static_cast<double>(Tmin), b = static_cast<double>(Tmax);
3532 for (int i = 0; i < 50 && (b - a) > 0.01; ++i) {
3533 double mid = 0.5 * (a + b);
3534 try {
3535 resid_lo = resid(mid);
3536 if (std::isfinite(resid_lo)) {
3537 lo_ok = true;
3538 Tmin = mid;
3539 b = mid; // tighten toward the boundary
3540 } else {
3541 a = mid;
3542 }
3543 } catch (...) {
3544 a = mid;
3545 }
3546 }
3547 } else if (!hi_ok && lo_ok) {
3548 double a = static_cast<double>(Tmin), b = static_cast<double>(Tmax);
3549 for (int i = 0; i < 50 && (b - a) > 0.01; ++i) {
3550 double mid = 0.5 * (a + b);
3551 try {
3552 resid_hi = resid(mid);
3553 if (std::isfinite(resid_hi)) {
3554 hi_ok = true;
3555 Tmax = mid;
3556 a = mid; // tighten toward the boundary
3557 } else {
3558 b = mid;
3559 }
3560 } catch (...) {
3561 b = mid;
3562 }
3563 }
3564 }
3565
3566 if (lo_ok && hi_ok && resid_lo * resid_hi < 0) {
3567 // Endpoints bracket the root — use TOMS748 with pre-computed values
3568 try {
3569 std::uintmax_t max_iter = 100;
3570 auto bracket = boost::math::tools::toms748_solve(resid, static_cast<double>(Tmin), static_cast<double>(Tmax), resid_lo, resid_hi,
3571 boost::math::tools::eps_tolerance<double>(40), max_iter);
3572 double T_sol = (bracket.first + bracket.second) / 2.0;
3573
3574 // Final PT flash at the converged temperature to set HEOS state
3575 HEOS.update(PT_INPUTS, p, T_sol);
3576 } catch (std::exception& e) {
3577 throw ValueError(format("HSU_P_flash for mixture failed with Tmin=%Lg, Tmax=%Lg, p=%Lg: %s", Tmin, Tmax, p, e.what()));
3578 }
3579 } else if (lo_ok || hi_ok) {
3580 // Endpoints do not bracket — pick the one with smaller |residual|
3581 double T_best = (!hi_ok || (lo_ok && std::abs(resid_lo) <= std::abs(resid_hi))) ? static_cast<double>(Tmin) : static_cast<double>(Tmax);
3582 try {
3583 HEOS.update(PT_INPUTS, p, T_best);
3584 } catch (std::exception& e) {
3585 throw ValueError(format("HSU_P_flash for mixture: endpoints do not bracket (resid_lo=%g, resid_hi=%g) "
3586 "and fallback at T=%g failed: %s",
3587 resid_lo, resid_hi, T_best, e.what()));
3588 }
3589 } else {
3590 throw ValueError(format("HSU_P_flash for mixture: neither endpoint evaluable (Tmin=%Lg, Tmax=%Lg, p=%Lg)", Tmin, Tmax, p));
3591 }
3592
3593 // Verify the result actually satisfies the specification (#3192). TOMS748 returns a
3594 // bracket endpoint even when the residual never reached zero -- e.g. keyed_output(T) is
3595 // discontinuous across a phase misclassification, so the sign flips without a true root --
3596 // and the non-bracketing fallback returns a closest-endpoint guess by construction.
3597 // Without this check the flash SILENTLY returns a wrong T (the GitHub #3148 CO2/Water
3598 // flaw: a ~15 K error with H(T_returned) != H_target). Fail loudly instead so the caller
3599 // is never handed a wrong state that passes for success.
3600 const auto resid_final = static_cast<double>(HEOS.keyed_output(other) - value);
3601 const double resid_scale = std::abs(static_cast<double>(value)) + 1.0;
3602 if (!ValidNumber(resid_final) || std::abs(resid_final) > 1e-6 * resid_scale) {
3603 throw ValueError(format("HSU_P_flash for mixture did not converge to the specification: residual %g (target %g) "
3604 "at T=%g K, p=%g Pa -- the (T,p) flash is misclassifying the phase for this mixture",
3605 resid_final, static_cast<double>(value), static_cast<double>(HEOS.T()), static_cast<double>(p)));
3606 }
3607 }
3608}
3610 // Define the residual to be driven to zero
3611 class solver_resid : public FuncWrapper1DWithTwoDerivs
3612 {
3613 public:
3615 CoolPropDbl T, value;
3616 parameters other;
3617
3618 solver_resid(HelmholtzEOSMixtureBackend* HEOS, CoolPropDbl T, CoolPropDbl value, parameters other)
3619 : HEOS(HEOS), T(T), value(value), other(other) {}
3620 double call(double rhomolar) override {
3621 HEOS->update_DmolarT_direct(rhomolar, T);
3622 double eos = HEOS->keyed_output(other);
3623 return eos - value;
3624 };
3625 double deriv(double rhomolar) override {
3626 return HEOS->first_partial_deriv(other, iDmolar, iT);
3627 }
3628 double second_deriv(double rhomolar) override {
3629 return HEOS->second_partial_deriv(other, iDmolar, iT, iDmolar, iT);
3630 }
3631 };
3632 solver_resid resid(&HEOS, T, value, other);
3633
3634 double T_critical_ = (HEOS.is_pure_or_pseudopure) ? HEOS.T_critical() : HEOS._crit.T;
3635
3636 // Supercritical temperature
3637 if (HEOS._T > T_critical_) {
3638 CoolPropDbl yc = NAN, ymin = NAN, y = NAN;
3640 CoolPropDbl rhomin = 1e-10;
3641
3642 // Determine limits for the other variable
3643 switch (other) {
3644 case iSmolar: {
3645 yc = HEOS.calc_smolar_nocache(HEOS._T, rhoc);
3646 ymin = HEOS.calc_smolar_nocache(HEOS._T, rhomin);
3647 y = HEOS._smolar;
3648 break;
3649 }
3650 case iHmolar: {
3651 yc = HEOS.calc_hmolar_nocache(HEOS._T, rhoc);
3652 ymin = HEOS.calc_hmolar_nocache(HEOS._T, rhomin);
3653 y = HEOS._hmolar;
3654 break;
3655 }
3656 case iUmolar: {
3657 yc = HEOS.calc_umolar_nocache(HEOS._T, rhoc);
3658 ymin = HEOS.calc_umolar_nocache(HEOS._T, rhomin);
3659 y = HEOS._umolar;
3660 break;
3661 }
3662 default:
3663 throw ValueError();
3664 }
3665 if (is_in_closed_range(yc, ymin, y)) {
3666 Brent(resid, rhoc, rhomin, LDBL_EPSILON, 1e-9, 100);
3667 } else if (y < yc) {
3668 // Increase rhomelt until it bounds the solution
3669 int step_count = 0;
3670 while (!is_in_closed_range(ymin, yc, y)) {
3671 rhoc *= 1.1; // Increase density by a few percent
3672 switch (other) {
3673 case iSmolar:
3674 yc = HEOS.calc_smolar_nocache(HEOS._T, rhoc);
3675 break;
3676 case iHmolar:
3677 yc = HEOS.calc_hmolar_nocache(HEOS._T, rhoc);
3678 break;
3679 case iUmolar:
3680 yc = HEOS.calc_umolar_nocache(HEOS._T, rhoc);
3681 break;
3682 default:
3683 throw ValueError(format("Input is invalid"));
3684 }
3685 if (step_count > 30) {
3686 throw ValueError(format("Even by increasing rhoc, not able to bound input; input %Lg is not in range %Lg,%Lg", y, yc, ymin));
3687 }
3688 step_count++;
3689 }
3690 Brent(resid, rhomin, rhoc, LDBL_EPSILON, 1e-9, 100);
3691 } else {
3692 throw ValueError(format("input %Lg is not in range %Lg,%Lg,%Lg", y, yc, ymin));
3693 }
3694 // Update the state (T > Tc). Honor an imposed phase here: a caller who used
3695 // specify_phase(iphase_supercritical_gas/liquid) would otherwise have it
3696 // silently rewritten by this density-based classifier. (#2718)
3698 if (HEOS._p < HEOS.p_critical()) {
3700 } else {
3702 }
3703 }
3704 }
3705 // Subcritical temperature liquid
3706 else if ((HEOS._phase == iphase_liquid) || (HEOS._phase == iphase_supercritical_liquid)) {
3707 CoolPropDbl ymelt = NAN, yL = NAN, y = NAN;
3708 CoolPropDbl rhomelt = HEOS.components[0].triple_liquid.rhomolar;
3709 // Self-contained seed evaluation: prefer the cached ancillary set by
3710 // upstream phase-determination, otherwise fall through to superancillary
3711 // (numerical-critical-point accurate), then to the polynomial ancillary.
3712 // Lets DHSU_T_flash skip the unconditional ancillary evaluation on the
3713 // imposed-phase fast path without breaking this consumer (#2718).
3714 CoolPropDbl rhoL = NAN;
3715 if (HEOS._rhoLanc) {
3716 rhoL = static_cast<double>(HEOS._rhoLanc);
3717 } else if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
3718 auto superanc_ptr = HEOS.get_superanc();
3719 if (superanc_ptr) {
3720 // get_superanc() only reports cache existence, not domain coverage —
3721 // eval_sat() can still throw near the numerical critical point or a
3722 // domain edge. Swallow the throw so the polynomial fallback below stays
3723 // reachable for previously valid subcritical flashes.
3724 try {
3725 rhoL = superanc_ptr->eval_sat(HEOS._T, 'D', 0);
3726 } catch (...) {
3727 rhoL = NAN;
3728 }
3729 }
3730 }
3731 if (!ValidNumber(rhoL)) {
3732 rhoL = HEOS.components[0].ancillaries.rhoL.evaluate(HEOS._T);
3733 }
3734
3735 switch (other) {
3736 case iSmolar: {
3737 ymelt = HEOS.calc_smolar_nocache(HEOS._T, rhomelt);
3738 yL = HEOS.calc_smolar_nocache(HEOS._T, rhoL);
3739 y = HEOS._smolar;
3740 break;
3741 }
3742 case iHmolar: {
3743 ymelt = HEOS.calc_hmolar_nocache(HEOS._T, rhomelt);
3744 yL = HEOS.calc_hmolar_nocache(HEOS._T, rhoL);
3745 y = HEOS._hmolar;
3746 break;
3747 }
3748 case iUmolar: {
3749 ymelt = HEOS.calc_umolar_nocache(HEOS._T, rhomelt);
3750 yL = HEOS.calc_umolar_nocache(HEOS._T, rhoL);
3751 y = HEOS._umolar;
3752 break;
3753 }
3754 default:
3755 throw ValueError();
3756 }
3757
3758 CoolPropDbl rhomolar_guess = (rhomelt - rhoL) / (ymelt - yL) * (y - yL) + rhoL;
3759
3760 try {
3761 Halley(resid, rhomolar_guess, 1e-8, 100);
3762 } catch (...) {
3763 Secant(resid, rhomolar_guess, 0.0001 * rhomolar_guess, 1e-12, 100);
3764 }
3765 }
3766 // Subcritical temperature gas
3767 else if (HEOS._phase == iphase_gas) {
3768 CoolPropDbl rhomin = 1e-14;
3769 // See companion block in the liquid branch above (#2718).
3770 CoolPropDbl rhoV = NAN;
3771 if (HEOS._rhoVanc) {
3772 rhoV = static_cast<double>(HEOS._rhoVanc);
3773 } else if (get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
3774 auto superanc_ptr = HEOS.get_superanc();
3775 if (superanc_ptr) {
3776 // See companion try/catch in the liquid branch above.
3777 try {
3778 rhoV = superanc_ptr->eval_sat(HEOS._T, 'D', 1);
3779 } catch (...) {
3780 rhoV = NAN;
3781 }
3782 }
3783 }
3784 if (!ValidNumber(rhoV)) {
3785 rhoV = HEOS.components[0].ancillaries.rhoV.evaluate(HEOS._T);
3786 }
3787
3788 try {
3789 Halley(resid, 0.5 * (rhomin + rhoV), 1e-8, 100);
3790 } catch (...) {
3791 try {
3792 Brent(resid, rhomin, rhoV, LDBL_EPSILON, 1e-12, 100);
3793 } catch (...) {
3794 throw ValueError();
3795 }
3796 }
3797 } else {
3798 throw ValueError(format("phase to solver_for_rho_given_T_oneof_HSU is invalid"));
3799 }
3800};
3801
3804 // Use the phase defined by the imposed phase
3805 HEOS._phase = HEOS.imposed_phase_index;
3806 double T_critical_ = (HEOS.is_pure_or_pseudopure) ? HEOS.T_critical() : HEOS._crit.T;
3807 // The remaining code in this branch was added to set some needed parameters if phase is imposed,
3808 // since HEOS.T_phase_determination_pure_or_pseudopure() is not being called.
3809 if (HEOS._T < T_critical_) //
3810 {
3811 if (HEOS._phase == iphase_liquid) {
3812 HEOS._Q = -1000;
3813 } else if (HEOS._phase == iphase_gas) {
3814 HEOS._Q = 1000;
3815 } else if (HEOS._phase == iphase_twophase) {
3816 // The ancillary rhoL/rhoV densities are only needed by the twophase
3817 // sub-branch (and only when other != iDmolar — they seed SatL/SatV
3818 // updates). Keep the evaluation localized so the cheaper single-phase
3819 // imposed-phase paths (liquid, gas, supercritical_liquid) avoid the
3820 // two polynomial evaluations on every update() (#2718).
3821 // TODO: is it a bug that this branch can be accessed for mixtures?
3822 HEOS._rhoVanc = HEOS.components[0].ancillaries.rhoV.evaluate(HEOS._T);
3823 HEOS._rhoLanc = HEOS.components[0].ancillaries.rhoL.evaluate(HEOS._T);
3824 // Actually have to use saturation information sadly
3825 // For the given temperature, find the saturation state
3826 // Run the saturation routines to determine the saturation densities and pressures
3829 SaturationSolvers::saturation_T_pure(HEOS1, HEOS._T, options);
3830
3831 if (other != iDmolar) {
3832 // Update the states
3833 if (HEOS.SatL) HEOS.SatL->update(DmolarT_INPUTS, HEOS._rhoLanc, HEOS._T);
3834 if (HEOS.SatV) HEOS.SatV->update(DmolarT_INPUTS, HEOS._rhoVanc, HEOS._T);
3835 // Update the two-Phase variables
3836 HEOS._rhoLmolar = HEOS.SatL->rhomolar();
3837 HEOS._rhoVmolar = HEOS.SatV->rhomolar();
3838 }
3839
3840 CoolPropDbl Q = NAN;
3841
3842 switch (other) {
3843 case iDmolar:
3844 Q = (1 / HEOS.rhomolar() - 1 / HEOS1.SatL->rhomolar()) / (1 / HEOS1.SatV->rhomolar() - 1 / HEOS1.SatL->rhomolar());
3845 break;
3846 case iSmolar:
3847 Q = (HEOS.smolar() - HEOS1.SatL->smolar()) / (HEOS1.SatV->smolar() - HEOS1.SatL->smolar());
3848 break;
3849 case iHmolar:
3850 Q = (HEOS.hmolar() - HEOS1.SatL->hmolar()) / (HEOS1.SatV->hmolar() - HEOS1.SatL->hmolar());
3851 break;
3852 case iUmolar:
3853 Q = (HEOS.umolar() - HEOS1.SatL->umolar()) / (HEOS1.SatV->umolar() - HEOS1.SatL->umolar());
3854 break;
3855 default:
3856 throw ValueError(format("bad input for other"));
3857 }
3858 if (Q < 0) {
3859 HEOS._Q = -1;
3860 } else if (Q > 1) {
3861 HEOS._Q = 1;
3862 } else {
3863 HEOS._Q = Q;
3864 // Load the outputs
3865 HEOS._p = HEOS._Q * HEOS1.SatV->p() + (1 - HEOS._Q) * HEOS1.SatL->p();
3866 HEOS._rhomolar = 1 / (HEOS._Q / HEOS.SatV->rhomolar() + (1 - HEOS._Q) / HEOS.SatL->rhomolar());
3867 }
3868 } else if (HEOS._phase == iphase_supercritical_liquid) {
3869 HEOS._Q = -1000;
3870 } else
3871 throw ValueError(format("Temperature specified is not the imposed phase region."));
3872 } else if (HEOS._T > T_critical_ && HEOS._T > HEOS.components[0].EOS().Ttriple) {
3873 HEOS._Q = 1e9;
3874 }
3875 } else {
3876 if (HEOS.is_pure_or_pseudopure) {
3877 // Find the phase, while updating all internal variables possible
3878 switch (other) {
3879 case iDmolar:
3881 break;
3882 case iSmolar:
3884 break;
3885 case iHmolar:
3887 break;
3888 case iUmolar:
3890 break;
3891 default:
3892 throw ValueError(format("Input is invalid"));
3893 }
3894 } else {
3895 // Mixtures: DHSU_T flash with two-phase support.
3896 //
3897 // Strategy:
3898 // Fast path — try single-phase nocache density sweep (existing approach).
3899 // If a root is found and stability analysis confirms it is
3900 // in a single-phase region, accept it.
3901 // Slow path — sweep P at fixed T using TOMS748. Each evaluation calls
3902 // HEOS.update(PT_INPUTS, P, T), which dispatches to
3903 // PT_flash_mixtures (stability analysis + phase split).
3904 // The PT flash sets correct lever-rule properties for
3905 // two-phase states automatically.
3906 //
3907 // This mirrors the HSU_P mixture implementation (line ~2902) which sweeps
3908 // T at fixed P with PT flash inside.
3909
3910 CoolPropDbl T = HEOS._T;
3911 CoolPropDbl value;
3912 switch (other) {
3913 case iDmolar:
3914 value = HEOS._rhomolar;
3915 break;
3916 case iHmolar:
3917 value = HEOS._hmolar;
3918 break;
3919 case iSmolar:
3920 value = HEOS._smolar;
3921 break;
3922 case iUmolar:
3923 value = HEOS._umolar;
3924 break;
3925 default:
3926 throw ValueError(format("Invalid input for DHSU_T_flash mixture"));
3927 }
3928
3929 bool solved = false;
3930
3931 // --- Fast path: single-phase nocache approach ---
3932 if (other == iDmolar) {
3933 // DT: compute P from EOS at (T, rho). If P > 0, check stability.
3934 CoolPropDbl P_eos = HEOS.calc_pressure_nocache(T, value);
3935 if (P_eos > 0) {
3936 try {
3937 // Set T and P on the HEOS object for the stability tester
3938 HEOS._T = T;
3939 HEOS._p = P_eos;
3940 StabilityRoutines::StabilityEvaluationClass stability_tester(HEOS);
3941 stability_tester.set_TP(T, P_eos);
3942 if (stability_tester.is_stable()) {
3943 HEOS.update_DmolarT_direct(value, T);
3944 HEOS._Q = -1;
3946 solved = true;
3947 }
3948 } catch (...) {
3949 // Stability check failed — fall through to P-sweep
3950 }
3951 }
3952 // If P_eos <= 0 or unstable, fall through to P-sweep
3953 } else {
3954 // HSU_T: try nocache density sweep in gas and liquid ranges.
3955 // Roots are validated with a mechanical stability check
3956 // (dP/drho_T > 0) to reject Van der Waals loop solutions.
3957 CoolPropDbl rho_min = 1e-10;
3958 CoolPropDbl rho_max = HEOS.calc_rhomolar_max_bound();
3959 CoolPropDbl rho_reducing = HEOS.rhomolar_reducing();
3960
3961 // Check whether a density root is mechanically stable:
3962 // dP/drho_T > 0 (positive compressibility).
3963 auto is_mechanically_stable = [&](double rho) -> bool {
3964 double eps_rho = std::max(rho * 1e-6, 1e-10);
3965 double P_lo = HEOS.calc_pressure_nocache(T, rho - eps_rho);
3966 double P_hi = HEOS.calc_pressure_nocache(T, rho + eps_rho);
3967 return (P_hi - P_lo) > 0; // dP/drho > 0
3968 };
3969
3970 auto rho_resid = [&](double rho) -> double {
3971 switch (other) {
3972 case iHmolar:
3973 return HEOS.calc_hmolar_nocache(T, rho) - value;
3974 case iSmolar:
3975 return HEOS.calc_smolar_nocache(T, rho) - value;
3976 case iUmolar:
3977 return HEOS.calc_umolar_nocache(T, rho) - value;
3978 default:
3979 return _HUGE;
3980 }
3981 };
3982
3983 // Try gas density range [rho_min, rho_reducing]
3984 double rho_gas = -1;
3985 try {
3986 std::uintmax_t max_iter = 100;
3987 auto bracket = boost::math::tools::toms748_solve(rho_resid, static_cast<double>(rho_min), static_cast<double>(rho_reducing),
3988 boost::math::tools::eps_tolerance<double>(40), max_iter);
3989 double rho_cand = (bracket.first + bracket.second) / 2.0;
3990 if (HEOS.calc_pressure_nocache(T, rho_cand) > 0 && is_mechanically_stable(rho_cand)) {
3991 rho_gas = rho_cand;
3992 }
3993 } catch (...) {
3994 }
3995
3996 // Try liquid density range [rho_reducing, rho_max]
3997 double rho_liq = -1;
3998 try {
3999 std::uintmax_t max_iter = 100;
4000 auto bracket = boost::math::tools::toms748_solve(rho_resid, static_cast<double>(rho_reducing), static_cast<double>(rho_max),
4001 boost::math::tools::eps_tolerance<double>(40), max_iter);
4002 double rho_cand = (bracket.first + bracket.second) / 2.0;
4003 if (HEOS.calc_pressure_nocache(T, rho_cand) > 0 && is_mechanically_stable(rho_cand)) {
4004 rho_liq = rho_cand;
4005 }
4006 } catch (...) {
4007 }
4008
4009 // Fallback: locate liquid spinodal if no liquid root found
4010 if (rho_liq < 0) {
4011 double rho_neg = -1, rho_pos = -1;
4012 double rho_prev = rho_max;
4013 double P_prev = HEOS.calc_pressure_nocache(T, rho_prev);
4014 for (double rho_scan = rho_max * 0.95; rho_scan > rho_reducing; rho_scan *= 0.95) {
4015 double P_scan = HEOS.calc_pressure_nocache(T, rho_scan);
4016 if (P_prev > 0 && P_scan < 0) {
4017 rho_pos = rho_prev;
4018 rho_neg = rho_scan;
4019 break;
4020 }
4021 rho_prev = rho_scan;
4022 P_prev = P_scan;
4023 }
4024 if (rho_neg > 0 && rho_pos > 0) {
4025 for (int i = 0; i < 30; i++) {
4026 double rho_mid = (rho_neg + rho_pos) / 2.0;
4027 if (HEOS.calc_pressure_nocache(T, rho_mid) > 0) {
4028 rho_pos = rho_mid;
4029 } else {
4030 rho_neg = rho_mid;
4031 }
4032 }
4033 try {
4034 std::uintmax_t max_iter = 100;
4035 auto bracket = boost::math::tools::toms748_solve(rho_resid, rho_pos, static_cast<double>(rho_max),
4036 boost::math::tools::eps_tolerance<double>(40), max_iter);
4037 double rho_cand = (bracket.first + bracket.second) / 2.0;
4038 if (is_mechanically_stable(rho_cand)) {
4039 rho_liq = rho_cand;
4040 }
4041 } catch (...) {
4042 }
4043 }
4044 }
4045
4046 // Accept mechanically stable root(s).
4047 // If both gas and liquid roots survive the stability filter,
4048 // pick the one with lower Gibbs energy (standard VLE criterion).
4049 double rho_cand = -1;
4050 if (rho_gas > 0 && rho_liq > 0) {
4051 double G_gas = HEOS.calc_gibbsmolar_nocache(T, rho_gas);
4052 double G_liq = HEOS.calc_gibbsmolar_nocache(T, rho_liq);
4053 rho_cand = (G_liq <= G_gas) ? rho_liq : rho_gas;
4054 } else if (rho_gas > 0) {
4055 rho_cand = rho_gas;
4056 } else if (rho_liq > 0) {
4057 rho_cand = rho_liq;
4058 }
4059
4060 if (rho_cand > 0) {
4061 // Validate via thermodynamic stability analysis
4062 CoolPropDbl P_eos = HEOS.calc_pressure_nocache(T, rho_cand);
4063 if (P_eos > 0) {
4064 try {
4065 HEOS._T = T;
4066 HEOS._p = P_eos;
4067 StabilityRoutines::StabilityEvaluationClass stability_tester(HEOS);
4068 stability_tester.set_TP(T, P_eos);
4069 if (stability_tester.is_stable()) {
4070 HEOS.update_DmolarT_direct(rho_cand, T);
4071 HEOS._Q = -1;
4073 solved = true;
4074 }
4075 } catch (...) {
4076 // Stability check failed — fall through to P-sweep
4077 }
4078 }
4079 }
4080 }
4081
4082 // --- Slow path: P-sweep with PT flash ---
4083 // Sweep P at fixed T. At each P, HEOS.update(PT_INPUTS, P, T) runs
4084 // the full PT flash (stability analysis + phase split), so
4085 // keyed_output(target) returns the correct value for any phase.
4086 //
4087 // H(P), S(P), rho(P), U(P) are NOT globally monotonic over
4088 // [Ptriple, Pmax]: at extreme compressed-liquid pressures the
4089 // properties swing back. So we cannot blindly bracket [Pmin, Pmax].
4090 // Instead, do a coarse logarithmic scan to locate a sign change in
4091 // the residual, then refine with TOMS748 on the tight sub-interval.
4092 if (!solved) {
4093 CoolPropDbl Pmin_bound = HEOS.calc_p_triple();
4094 CoolPropDbl Pmax_bound = HEOS.calc_pmax();
4095 if (Pmin_bound < 100) Pmin_bound = 100;
4096
4097 auto p_resid = [&](double P) -> double {
4098 HEOS.update(PT_INPUTS, P, T);
4099 return HEOS.keyed_output(other) - value;
4100 };
4101
4102 // Logarithmic scan to find a bracket where f changes sign.
4103 // Half-decade steps (2 points per decade, ~14 total for 7 decades).
4104 double logPmin = std::log10(static_cast<double>(Pmin_bound));
4105 double logPmax = std::log10(static_cast<double>(Pmax_bound));
4106 double dlogP = 0.5; // half-decade steps
4107 int nsteps = static_cast<int>((logPmax - logPmin) / dlogP) + 1;
4108
4109 double P_lo = -1, P_hi = -1; // bracket endpoints
4110 double f_prev = 0;
4111 double P_prev = 0;
4112 bool have_prev = false;
4113
4114 for (int i = 0; i <= nsteps; i++) {
4115 double logP = logPmin + i * dlogP;
4116 if (logP > logPmax) logP = logPmax;
4117 double P = std::pow(10.0, logP);
4118 double f;
4119 try {
4120 f = p_resid(P);
4121 } catch (...) {
4122 have_prev = false;
4123 continue;
4124 }
4125 if (have_prev && f_prev * f < 0) {
4126 // Sign change detected — bracket found
4127 P_lo = P_prev;
4128 P_hi = P;
4129 break;
4130 }
4131 P_prev = P;
4132 f_prev = f;
4133 have_prev = true;
4134 }
4135
4136 if (P_lo > 0 && P_hi > 0) {
4137 try {
4138 std::uintmax_t max_iter = 100;
4139 auto bracket =
4140 boost::math::tools::toms748_solve(p_resid, P_lo, P_hi, boost::math::tools::eps_tolerance<double>(40), max_iter);
4141 double P_sol = (bracket.first + bracket.second) / 2.0;
4142 HEOS.update(PT_INPUTS, P_sol, T);
4143 } catch (std::exception& e) {
4144 throw ValueError(format("DHSU_T_flash P-sweep TOMS748 for mixture failed: T=%Lg, target=%s, value=%Lg, "
4145 "bracket=[%g, %g]: %s",
4146 T, get_parameter_information(other, "short").c_str(), value, P_lo, P_hi, e.what()));
4147 }
4148 } else {
4149 throw ValueError(format("DHSU_T_flash P-sweep for mixture: no bracket found scanning P in [%Lg, %Lg] at T=%Lg "
4150 "for target %s=%Lg",
4151 Pmin_bound, Pmax_bound, T, get_parameter_information(other, "short").c_str(), value));
4152 }
4153 }
4154
4155 // Verify the mixture result reproduces the requested property (#3148/#3192). The
4156 // P-sweep + TOMS748 (and the fast-path density sweep) can return a state where
4157 // keyed_output(other) != value -- e.g. a discontinuity from a phase misclassification
4158 // -- so without this check the flash could SILENTLY return a wrong state. D+T is a
4159 // direct evaluation and needs no check; H/S/U at fixed T solve for the state and must
4160 // be verified. Mirrors the HSU_P guard.
4161 if (other != iDmolar) {
4162 const auto resid_dhsu = static_cast<double>(HEOS.keyed_output(other) - value);
4163 const double scale_dhsu = std::abs(static_cast<double>(value)) + 1.0;
4164 if (!ValidNumber(resid_dhsu) || std::abs(resid_dhsu) > 1e-6 * scale_dhsu) {
4165 throw ValueError(format("DHSU_T_flash for mixture did not converge to the specification: residual %g "
4166 "(target %s=%g) at T=%g K -- the (T,p) flash is misclassifying the phase for this mixture",
4167 resid_dhsu, get_parameter_information(other, "short").c_str(), static_cast<double>(value),
4168 static_cast<double>(HEOS.T())));
4169 }
4170 }
4171 }
4172 }
4173
4174 //if (HEOS.isHomogeneousPhase() && !ValidNumber(HEOS._p)) // original, pre 1352
4175 // only the solver requires single phase
4176 if (((other == iDmolar) || HEOS.isHomogeneousPhase()) && !ValidNumber(HEOS._p)) // post 1352
4177 {
4178 switch (other) {
4179 case iDmolar:
4180 break;
4181 case iHmolar:
4183 break;
4184 case iSmolar:
4186 break;
4187 case iUmolar:
4189 break;
4190 default:
4191 break;
4192 }
4193 HEOS.calc_pressure();
4194 HEOS._Q = -1;
4195 }
4197 // Update the state for conditions where the state was guessed. When the user
4198 // imposed a phase via specify_phase(), honor it: recalculate_singlephase_phase()
4199 // would silently overwrite the caller's choice (e.g. flip iphase_gas to
4200 // iphase_liquid above rhomolar_critical), and the overwrite is also wasted
4201 // work on the imposed-phase fast path (#2718).
4203 }
4204}
4206 HS_flash_twophaseOptions& options) {
4207 class Residual : public FuncWrapper1D
4208 {
4209
4210 public:
4212 CoolPropDbl hmolar, smolar, Qs;
4213 Residual(HelmholtzEOSMixtureBackend& HEOS, CoolPropDbl hmolar_spec, CoolPropDbl smolar_spec)
4214 : HEOS(HEOS), hmolar(hmolar_spec), smolar(smolar_spec), Qs(_HUGE) {};
4215 double call(double T) override {
4216 HEOS.update(QT_INPUTS, 0, T);
4217 HelmholtzEOSMixtureBackend &SatL = HEOS.get_SatL(), &SatV = HEOS.get_SatV();
4218 // Quality from entropy
4219 Qs = (smolar - SatL.smolar()) / (SatV.smolar() - SatL.smolar());
4220 // Quality from enthalpy
4221 CoolPropDbl Qh = (hmolar - SatL.hmolar()) / (SatV.hmolar() - SatL.hmolar());
4222 // Residual is the difference between the two
4223 return Qh - Qs;
4224 }
4225 } resid(HEOS, hmolar_spec, smolar_spec);
4226
4227 // Critical point for pure fluids, slightly different for pseudo-pure, very different for mixtures
4228 CoolPropDbl Tmax_sat = HEOS.calc_Tmax_sat() - 1e-13;
4229
4230 // Check what the minimum limits for the equation of state are
4231 CoolPropDbl Tmin_satL = NAN, Tmin_satV = NAN, Tmin_sat = NAN;
4232 HEOS.calc_Tmin_sat(Tmin_satL, Tmin_satV);
4233 Tmin_sat = std::max(Tmin_satL, Tmin_satV) - 1e-13;
4234
4235 Brent(resid, Tmin_sat, Tmax_sat - 0.01, DBL_EPSILON, 1e-12, 20);
4236 // Run once more with the final vapor quality
4237 HEOS.update(QT_INPUTS, resid.Qs, HEOS.T());
4238}
4240 HS_flash_singlephaseOptions& options) {
4241 int iter = 0;
4242 double resid = 9e30, resid_old = 9e30;
4243 CoolProp::SimpleState reducing = HEOS.get_state("reducing");
4244 do {
4245 // Independent variables are T0 and rhomolar0, residuals are matching h and s
4246 Eigen::Vector2d r;
4247 Eigen::Matrix2d J;
4248 r(0) = HEOS.hmolar() - hmolar_spec;
4249 r(1) = HEOS.smolar() - smolar_spec;
4250 J(0, 0) = HEOS.first_partial_deriv(iHmolar, iTau, iDelta);
4251 J(0, 1) = HEOS.first_partial_deriv(iHmolar, iDelta, iTau);
4252 J(1, 0) = HEOS.first_partial_deriv(iSmolar, iTau, iDelta);
4253 J(1, 1) = HEOS.first_partial_deriv(iSmolar, iDelta, iTau);
4254 // Step in v obtained from Jv = -r
4255 Eigen::Vector2d v = J.colPivHouseholderQr().solve(-r);
4256 bool good_solution = false;
4257 double tau0 = HEOS.tau(), delta0 = HEOS.delta();
4258 // Calculate the old residual after the last step
4259 resid_old = sqrt(POW2(HEOS.hmolar() - hmolar_spec) + POW2(HEOS.smolar() - smolar_spec));
4260 // Geometric halving of the Newton step length (10 iters from
4261 // 1.0 down to ~1/1024) — geometric, not summation; no FP
4262 // counter-accumulation issue.
4263 for (double frac = 1.0; frac > 0.001; frac /= 2) { // NOLINT(cert-flp30-c)
4264 try {
4265 // Calculate new values
4266 double tau_new = tau0 + options.omega * frac * v(0);
4267 double delta_new = delta0 + options.omega * frac * v(1);
4268 double T_new = reducing.T / tau_new;
4269 double rhomolar_new = delta_new * reducing.rhomolar;
4270 // Update state with step
4271 HEOS.update(DmolarT_INPUTS, rhomolar_new, T_new);
4272 resid = sqrt(POW2(HEOS.hmolar() - hmolar_spec) + POW2(HEOS.smolar() - smolar_spec));
4273 if (resid > resid_old) {
4274 throw ValueError(format("residual not decreasing; frac: %g, resid: %g, resid_old: %g", frac, resid, resid_old));
4275 }
4276 good_solution = true;
4277 break;
4278 } catch (...) {
4279 HEOS.clear();
4280 continue;
4281 }
4282 }
4283 if (!good_solution) {
4284 throw ValueError(format("Not able to get a solution"));
4285 }
4286 iter++;
4287 if (iter > 50) {
4288 throw ValueError(format("HS_flash_singlephase took too many iterations; residual is %g; prior was %g", resid, resid_old));
4289 }
4290 } while (std::abs(resid) > 1e-9);
4291}
4293 // Randomly obtain a starting value that is single-phase
4294 double logp = ((double)rand() / (double)RAND_MAX) * (log(HEOS.pmax()) - log(HEOS.p_triple())) + log(HEOS.p_triple());
4295 T = ((double)rand() / (double)RAND_MAX) * (HEOS.Tmax() - HEOS.Ttriple()) + HEOS.Ttriple();
4296 p = exp(logp);
4297}
4298namespace {
4299// ===========================================================================
4300// Single-phase H,S superancillary "happy path" (the cascade). Ported from the
4301// validated prototype in src/Tests/CoolProp-Tests-HS.cpp (bd CoolProp-j3n.4).
4302//
4303// Three legs, each a homotopy that anchors where a state of the target entropy
4304// provably exists and then marches in (T, ln rho) holding (h,s) on a chord:
4305// 1) saturation-anchor : anchor = saturated state whose entropy == s_t
4306// 2) supercritical isentrope : anchor on the T=Tmax isotherm (dome-free)
4307// 3) ideal-gas departure : anchor at lambda=0 (no dome at all)
4308// A candidate is accepted only if it reproduces (h,s) AND is fully intrinsically
4309// stable (dp/drho|_T > 0 AND cv > 0) within [Tmin,Tmax]; that makes the stable
4310// (h,s)->(T,rho) root unique, so no knowledge of the true (T,rho) is needed.
4311// See the docs notebook (Web/coolprop/HSFlash.ipynb) for the full derivation.
4312// ===========================================================================
4313using HS_SA_t = EquationOfState::SuperAncillary_t;
4314
4315// RAII: impose single phase so update_DmolarT_direct + h/s/derivative queries
4316// take the single-phase EOS path everywhere (including metastable iterates).
4317struct HSGasGuard
4318{
4319 HelmholtzEOSMixtureBackend& H; // NOLINT(cppcoreguidelines-avoid-const-or-ref-data-members)
4320 explicit HSGasGuard(HelmholtzEOSMixtureBackend& H) : H(H) {
4321 H.specify_phase(iphase_gas);
4322 }
4323 // unspecify_phase() only resets an enum flag and does not throw.
4324 // NOLINTNEXTLINE(bugprone-exception-escape)
4325 ~HSGasGuard() {
4326 H.unspecify_phase();
4327 }
4328 HSGasGuard(const HSGasGuard&) = delete;
4329 HSGasGuard& operator=(const HSGasGuard&) = delete;
4330 HSGasGuard(HSGasGuard&&) = delete;
4331 HSGasGuard& operator=(HSGasGuard&&) = delete;
4332};
4333
4334// Shared (T, w=ln rho) homotopy corrector: homotope (h,s) linearly from the
4335// anchor's values to the target with adaptive subdivision; the dp/drho|_T>0 guard
4336// keeps the corrector on the mechanically stable branch. Caller imposes the gas
4337// phase. Returns true and fills T_out/rho_out on success.
4338bool hs_corrector(HelmholtzEOSMixtureBackend& H, double T0, double rho0, double h_t, double s_t, double& T_out, double& rho_out,
4339 double Tlo_override = -1.0) {
4340 const double Rgas = H.gas_constant(), Tsc = H.T_critical();
4341 const double hscale = Rgas * Tsc, sscale = Rgas; // dimensional scales (h passes through 0 at the ref state)
4342 const double Tlo = (Tlo_override > 0.0) ? Tlo_override : H.Tmin() * (1.0 - 2e-2);
4343 const double Thi = 1.5 * H.Tmax(); // 2% sub-Tmin slack default; melting leg passes a lower floor
4344 auto eval = [&](double T, double rho) { H.update_DmolarT_direct(rho, T); };
4345 eval(T0, rho0);
4346 const double h0 = H.hmolar(), s0 = H.smolar();
4347 for (int N = 1; N <= 128; N *= 2) {
4348 double T = T0, w = std::log(rho0);
4349 bool failed = false;
4350 for (int k = 1; k <= N && !failed; ++k) {
4351 const double lam = static_cast<double>(k) / N;
4352 const double ht = h0 + lam * (h_t - h0), st = s0 + lam * (s_t - s0);
4353 bool conv = false;
4354 double best_norm = 1e300, best_T = T, best_w = w;
4355 for (int iter = 0; iter < 40; ++iter) {
4356 const double rho = std::exp(w);
4357 eval(T, rho);
4358 const double rh = H.hmolar() - ht, rs = H.smolar() - st;
4359 const double norm = std::abs(rh) / hscale + std::abs(rs) / sscale;
4360 if (norm < best_norm) {
4361 best_norm = norm;
4362 best_T = T;
4363 best_w = w;
4364 }
4365 if (norm < 1e-11) {
4366 conv = true;
4367 break;
4368 }
4371 const double a11 = hT, a12 = rho * hr, a21 = sT, a22 = rho * sr;
4372 const double det = a11 * a22 - a12 * a21;
4373 if (!std::isfinite(det) || std::abs(det) < 1e-300) break;
4374 const double dT = -(a22 * rh - a12 * rs) / det, dw = -(-a21 * rh + a11 * rs) / det;
4375 double f = 1.0;
4376 while ((T + f * dT < Tlo || T + f * dT > Thi || std::abs(f * dw) > 2.0) && f > 1e-6)
4377 f *= 0.5;
4378 for (int g = 0; g < 8 && f > 1e-3; ++g) { // stay on the mechanically stable branch
4379 eval(T + f * dT, std::exp(w + f * dw));
4380 if (H.first_partial_deriv(iP, iDmolar, iT) > 0) break;
4381 f *= 0.5;
4382 }
4383 T += f * dT;
4384 w += f * dw;
4385 }
4386 if (!conv && best_norm < 1e-8) {
4387 T = best_T;
4388 w = best_w;
4389 conv = true;
4390 }
4391 if (!conv) failed = true;
4392 }
4393 if (!failed) {
4394 T_out = T;
4395 rho_out = std::exp(w);
4396 return true;
4397 }
4398 }
4399 return false;
4400}
4401
4402// Target entropy shifted into the caloric superancillary's stamped frame (#2773).
4403double hs_s_to_cache(HelmholtzEOSMixtureBackend& H, HS_SA_t& sa, double s_t) {
4404 const auto stamp = sa.get_caloric_alpha0_stamp();
4405 if (!stamp.has_value()) return s_t;
4406 const double a1 = FlashRoutines::alpha0_offset_total(H).first;
4407 return s_t - H.gas_constant() * (stamp->first - a1);
4408}
4409// Target enthalpy shifted into the caloric superancillary's stamped frame (#2773).
4410double hs_h_to_cache(HelmholtzEOSMixtureBackend& H, HS_SA_t& sa, double h_t) {
4411 const auto stamp = sa.get_caloric_alpha0_stamp();
4412 if (!stamp.has_value()) return h_t;
4413 const double a2 = FlashRoutines::alpha0_offset_total(H).second;
4414 return h_t + H.gas_constant() * H.get_reducing_state().T * (stamp->second - a2);
4415}
4416
4417// Leg 1: saturation anchor. Anchor at the saturated state whose entropy equals
4418// the target's (found by the superancillary's native S-inversion), then march.
4419bool hs_leg_saturation(HelmholtzEOSMixtureBackend& H, HS_SA_t& sa, double h_t, double s_t, double& T_out, double& rho_out) {
4420 HSGasGuard guard(H);
4421 const double Tc = sa.get_Tcrit_num(), Tmin = sa.get_Tmin();
4422 const double Tcap = Tc - std::max(1e-4, 1e-6 * Tc);
4423 const double a = Tmin + 1e-3, b = Tcap;
4425 const double s_cache = hs_s_to_cache(H, sa, s_t);
4426 // Disambiguate the saturation branch by enthalpy closeness to the target. When
4427 // the caloric H superancillary is present, read the saturated enthalpy straight
4428 // off it (Chebyshev, no EOS call) instead of evaluating the EOS at each
4429 // candidate -- this is the dominant leg, so it trims EOS evaluations off the
4430 // common case (and several for retrograde fluids with multiple roots).
4431 const bool use_ca_h = sa.has_variable('H');
4432 const double h_cache = use_ca_h ? hs_h_to_cache(H, sa, h_t) : h_t;
4433 double T0 = 0, rho0 = 0, best_hgap = 1e300;
4434 bool found = false, near_critical = false;
4435 auto consider = [&](double Tcand, short Q) {
4436 const double rho = sa.eval_sat(Tcand, 'D', Q);
4437 double hgap;
4438 if (use_ca_h) {
4439 hgap = std::abs(sa.eval_sat(Tcand, 'H', Q) - h_cache);
4440 } else {
4441 H.update_DmolarT_direct(rho, Tcand);
4442 hgap = std::abs(H.hmolar() - h_t);
4443 }
4444 if (std::isfinite(hgap) && hgap < best_hgap) {
4445 best_hgap = hgap;
4446 T0 = Tcand;
4447 rho0 = rho;
4448 found = true;
4449 }
4450 };
4451 for (const auto& pr : sa.get_all_intersections('S', s_cache, 48, 100, 1e-12)) {
4452 const double Tcand = pr.first;
4453 if (Tcand >= b) near_critical = true; // anchor pinned against Tc => effectively critical/supercritical entropy
4454 if (Tcand <= a || Tcand >= b) continue;
4455 const short Qroot = (std::abs(sa.eval_sat(Tcand, 'S', 0) - s_cache) <= std::abs(sa.eval_sat(Tcand, 'S', 1) - s_cache)) ? 0 : 1;
4456 consider(Tcand, Qroot);
4457 }
4458 if (!found) {
4459 if (near_critical) return false; // hand to the supercritical isentrope leg
4460 const double s_crit = [&] {
4461 H.update_DmolarT_direct(sa.eval_sat(Tcap, 'D', 0), Tcap);
4462 return H.smolar();
4463 }();
4464 if (s_t > s_crit) { // dilute gas: T from the monotone 1D enthalpy inversion
4465 rho0 = sa.get_rhocrit_num() * 1e-4;
4466 auto hres = [&](double T) {
4467 H.update_DmolarT_direct(rho0, T);
4468 return H.hmolar() - h_t;
4469 };
4470 double Ta = Tmin + 1e-3, Tb = 1.5 * H.Tmax(), fa = hres(Ta), fb = hres(Tb);
4471 if (fa * fb <= 0) {
4472 boost::math::uintmax_t it = 60;
4473 auto [l, r] = toms748_solve(hres, Ta, Tb, fa, fb, boost::math::tools::eps_tolerance<double>(30), it);
4474 T0 = 0.5 * (l + r);
4475 } else {
4476 T0 = (std::abs(fa) <= std::abs(fb)) ? Ta : Tb;
4477 }
4478 } else { // cold compressed liquid: triple-point saturated liquid
4479 T0 = a;
4480 rho0 = sa.eval_sat(a, 'D', 0);
4481 }
4482 }
4483 return hs_corrector(H, T0, rho0, h_t, s_t, T_out, rho_out);
4484}
4485
4486// Leg 2: supercritical isentrope. Anchor on the dome-free T=Tmax isotherm at the
4487// density whose entropy equals s_t, then march (holding s == s_t).
4488bool hs_leg_isentrope(HelmholtzEOSMixtureBackend& H, double h_t, double s_t, double& T_out, double& rho_out) {
4489 HSGasGuard guard(H);
4490 const double Ta = H.Tmax(), rhoc = H.rhomolar_critical();
4491 auto sres = [&](double rho) {
4492 H.update_DmolarT_direct(rho, Ta);
4493 return H.smolar() - s_t;
4494 };
4495 const double ra = 1e-6 * rhoc, ga = sres(ra);
4496 double rb = 6.0 * rhoc, gb = sres(rb);
4497 for (int e = 0; e < 12 && ga * gb > 0; ++e) {
4498 const double rb_next = rb * 2.0;
4499 double gb_next;
4500 try {
4501 gb_next = sres(rb_next);
4502 } catch (...) {
4503 break;
4504 }
4505 if (!std::isfinite(gb_next)) break;
4506 rb = rb_next;
4507 gb = gb_next;
4508 }
4509 double rho0;
4510 if (ga * gb > 0) {
4511 rho0 = (std::abs(ga) <= std::abs(gb)) ? ra : rb;
4512 } else {
4513 boost::math::uintmax_t it = 80;
4514 auto [l, r] = toms748_solve(sres, ra, rb, ga, gb, boost::math::tools::eps_tolerance<double>(40), it);
4515 rho0 = 0.5 * (l + r);
4516 }
4517 return hs_corrector(H, Ta, rho0, h_t, s_t, T_out, rho_out);
4518}
4519
4520// Leg 3: ideal-gas departure. Scale residual Helmholtz by lambda; anchor at
4521// lambda=0 (ideal gas, no dome) and continue lambda:0->1. Self-contained.
4522bool hs_leg_departure(HelmholtzEOSMixtureBackend& H, double h_t, double s_t, double& T_out, double& rho_out) {
4523 HSGasGuard guard(H);
4524 const double Rg = H.gas_constant();
4525 const auto& red = H.get_reducing_state();
4526 const double Tr = red.T, rhor = red.rhomolar;
4527 const auto& x = H.get_mole_fractions_ref();
4528 const double Tmin = H.Tmin(), Tmax = H.Tmax();
4529 // Properties (and the Jacobian entries the Newton step needs) of the
4530 // lambda-scaled model alpha = alpha0 + lam*alphar: h, s, the stability
4531 // marker prho (= dp/drho|_T, must stay > 0), and the partials
4532 // hT=dh/dT, hr=dh/drho, sT=ds/dT, sr=ds/drho.
4533 struct LP
4534 {
4535 double h, s, prho, hT, hr, sT, sr;
4536 };
4537 // Evaluate the above at (T, rho) for continuation parameter lam in [0, 1].
4538 auto lprops = [&](double T, double rho, double lam) -> LP {
4539 const double tau = Tr / T, delta = rho / rhor;
4540 // Each calc_*_deriv_nocache computes the FULL HelmholtzDerivatives set and
4541 // returns one component, so the per-term calls recompute everything 6-9x.
4542 // Evaluate the ideal-gas and residual blocks once each instead.
4543 const HelmholtzDerivatives a0d = H.calc_all_alpha0_derivs_nocache(x, tau, delta, Tr, rhor);
4544 const HelmholtzDerivatives ard_all = H.residual_helmholtz->all(H, x, tau, delta, false);
4545 const double a0 = a0d.alphar, a0t = a0d.dalphar_dtau, a0tt = a0d.d2alphar_dtau2;
4546 const double ar = ard_all.alphar, art = ard_all.dalphar_dtau, ard = ard_all.dalphar_ddelta;
4547 const double artt = ard_all.d2alphar_dtau2, ardd = ard_all.d2alphar_ddelta2, artd = ard_all.d2alphar_ddelta_dtau;
4548 const double at = a0t + lam * art, att = a0tt + lam * artt;
4549 const double Hh = 1.0 + tau * at + delta * lam * ard;
4550 LP L{};
4551 L.h = Rg * T * Hh;
4552 L.s = Rg * (tau * at - (a0 + lam * ar));
4553 L.prho = Rg * T * (1.0 + 2.0 * delta * lam * ard + delta * delta * lam * ardd);
4554 const double dHdtau = at + tau * att + delta * lam * artd;
4555 const double dHddelta = lam * ard + tau * lam * artd + delta * lam * ardd;
4556 L.hT = Rg * (Hh - tau * dHdtau);
4557 L.hr = Rg * T * dHddelta / rhor;
4558 L.sT = -Rg * tau * tau * att / T;
4559 L.sr = Rg * (tau * lam * artd - 1.0 / delta - lam * ard) / rhor;
4560 // A non-finite property/derivative (e.g. a degenerate EOS evaluation) must
4561 // not leak a NaN into the continuation/Newton loop; throw so the cascade
4562 // defers this leg cleanly (the per-term calc_*_deriv_nocache previously
4563 // threw on a non-finite alpha0 -- the batched accessor does not).
4564 if (!std::isfinite(L.h) || !std::isfinite(L.s) || !std::isfinite(L.prho) || !std::isfinite(L.hT) || !std::isfinite(L.hr)
4565 || !std::isfinite(L.sT) || !std::isfinite(L.sr)) {
4566 throw ValueError("hs_leg_departure: non-finite lambda-model property/derivative");
4567 }
4568 return L;
4569 };
4570 // lambda=0 anchor: the ideal-gas limit has no dome, so (h,s)->(T,rho) is a
4571 // pair of decoupled 1-D solves -- T from h(T) at the reducing density, then
4572 // rho from s(T0, rho). This seeds the continuation below.
4573 double T0 = 0, rho0 = 0;
4574 {
4575 auto hig = [&](double T) { return lprops(T, rhor, 0.0).h - h_t; };
4576 double Ta = 0.3 * Tmin, Tb = 3.0 * Tmax, fa = hig(Ta), fb = hig(Tb);
4577 if (fa * fb > 0) return false;
4578 boost::math::uintmax_t it = 80;
4579 auto [l, r] = toms748_solve(hig, Ta, Tb, fa, fb, boost::math::tools::eps_tolerance<double>(40), it);
4580 T0 = 0.5 * (l + r);
4581 auto sig = [&](double rho) { return lprops(T0, rho, 0.0).s - s_t; };
4582 double ra = 1e-8 * rhor, rb = 50.0 * rhor, ga = sig(ra), gb = sig(rb);
4583 if (ga * gb > 0) {
4584 rho0 = (std::abs(ga) <= std::abs(gb)) ? ra : rb;
4585 } else {
4586 boost::math::uintmax_t it2 = 80;
4587 auto [l2, r2] = toms748_solve(sig, ra, rb, ga, gb, boost::math::tools::eps_tolerance<double>(40), it2);
4588 rho0 = 0.5 * (l2 + r2);
4589 }
4590 }
4591 // Continuation lam: 0 -> 1 in N equal steps, solving in (T, w=log rho) so rho
4592 // stays positive. Retry with a finer schedule (N doubling) if a coarse one
4593 // loses the path; first N that reaches lam=1 wins.
4594 const double hscale = Rg * Tr, sscale = Rg;
4595 for (int N = 1; N <= 256; N *= 2) {
4596 double T = T0, w = std::log(rho0);
4597 bool failed = false;
4598 for (int k = 1; k <= N && !failed; ++k) {
4599 const double lam = static_cast<double>(k) / N;
4600 bool conv = false;
4601 double best_norm = 1e300, best_T = T, best_w = w;
4602 for (int iter = 0; iter < 40; ++iter) {
4603 const double rho = std::exp(w);
4604 LP L = lprops(T, rho, lam);
4605 const double rh = L.h - h_t, rs = L.s - s_t;
4606 const double norm = std::abs(rh) / hscale + std::abs(rs) / sscale;
4607 if (norm < best_norm) {
4608 best_norm = norm;
4609 best_T = T;
4610 best_w = w;
4611 }
4612 if (norm < 1e-11) {
4613 conv = true;
4614 break;
4615 }
4616 // 2x2 Newton step on (rh, rs); columns scaled by rho since we
4617 // step in w = log rho (drho = rho*dw).
4618 const double a11 = L.hT, a12 = rho * L.hr, a21 = L.sT, a22 = rho * L.sr;
4619 const double det = a11 * a22 - a12 * a21;
4620 if (!std::isfinite(det) || std::abs(det) < 1e-300) break;
4621 const double dT = -(a22 * rh - a12 * rs) / det, dw = -(-a21 * rh + a11 * rs) / det;
4622 // Damp the step: first keep T in range and cap the log-rho move,
4623 // then back off until the trial state is mechanically stable (prho>0).
4624 double f = 1.0;
4625 while ((T + f * dT <= 0 || T + f * dT > 3.0 * Tmax || std::abs(f * dw) > 2.0) && f > 1e-6)
4626 f *= 0.5;
4627 for (int g = 0; g < 8 && f > 1e-3; ++g) {
4628 if (lprops(T + f * dT, std::exp(w + f * dw), lam).prho > 0) break;
4629 f *= 0.5;
4630 }
4631 T += f * dT;
4632 w += f * dw;
4633 }
4634 if (!conv && best_norm < 1e-8) {
4635 T = best_T;
4636 w = best_w;
4637 conv = true;
4638 }
4639 if (!conv) failed = true;
4640 }
4641 if (!failed) {
4642 T_out = T;
4643 rho_out = std::exp(w);
4644 return true;
4645 }
4646 }
4647 return false;
4648}
4649
4650// Accept only a faithful (h,s) reproduction that is in-range and fully stable
4651// (dp/drho|_T>0 AND cv>0): over that region the (h,s)->(T,rho) map is injective,
4652// so a stable+matching root IS the unique physical root.
4653// Tmin_override: if > 0, replaces H.Tmin() as the lower bound (used by the
4654// melting-line leg to allow sub-triple compressed-liquid states whose EOS IS
4655// valid below the triple-point temperature).
4656bool hs_accept(HelmholtzEOSMixtureBackend& H, double T, double rho, double h_t, double s_t, double Tmin_override = -1.0) {
4657 if (!std::isfinite(T) || !std::isfinite(rho) || rho <= 0) return false;
4658 const double Rg = H.gas_constant(), Tc = H.T_critical();
4659 const double Tmin_eff = (Tmin_override > 0) ? Tmin_override : H.Tmin();
4660 if (T < Tmin_eff * (1 - 1e-6) || T > H.Tmax() * (1 + 1e-6)) return false;
4661 HSGasGuard guard(H);
4663 if (std::abs(H.hmolar() - h_t) > 1e-6 * Rg * Tc || std::abs(H.smolar() - s_t) > 1e-6 * Rg) return false;
4664 double cv = -1;
4665 try {
4666 cv = H.cvmolar();
4667 } catch (...) {
4668 return false;
4669 }
4670 if (!std::isfinite(cv) || cv <= 0) return false;
4671 return H.first_partial_deriv(iP, iDmolar, iT) > 0;
4672}
4673
4674// True if (T,rho) lies strictly inside the two-phase dome (a metastable single-
4675// phase extension). Such a root reproduces a two-phase (h,s) but is NOT the
4676// physical answer, so the cascade must reject it and defer to the two-phase solve.
4677bool hs_inside_dome(HS_SA_t& sa, double T, double rho) {
4678 if (T >= sa.get_Tcrit_num()) return false;
4679 return rho > sa.eval_sat(T, 'D', 1) && rho < sa.eval_sat(T, 'D', 0);
4680}
4681
4682// Shift a target h/s into a MeltingCaloric's stamped build frame (mirror of
4683// hs_h_to_cache/hs_s_to_cache, reading the melting caloric's own stamp).
4684double meltcal_s_to_cache(HelmholtzEOSMixtureBackend& H, const MeltingCaloric& mc, double s_t) {
4685 const auto stamp = mc.stamp();
4686 if (!stamp.has_value()) return s_t;
4687 const double a1 = FlashRoutines::alpha0_offset_total(H).first;
4688 return s_t - H.gas_constant() * (stamp->first - a1);
4689}
4690double meltcal_h_to_cache(HelmholtzEOSMixtureBackend& H, const MeltingCaloric& mc, double h_t) {
4691 const auto stamp = mc.stamp();
4692 if (!stamp.has_value()) return h_t;
4693 const double a2 = FlashRoutines::alpha0_offset_total(H).second;
4694 return h_t + H.gas_constant() * H.get_reducing_state().T * (stamp->second - a2);
4695}
4696
4697// Leg 4: melting-line anchor for the cold compressed-liquid corner (incl. sub-triple T).
4698bool hs_leg_melting(HelmholtzEOSMixtureBackend& H, const MeltingCaloric& mc, double h_t, double s_t, double& T_out, double& rho_out) {
4699 HSGasGuard guard(H); // corrector requires the caller to impose the gas phase (mirror hs_leg_saturation)
4700 const double s_cache = meltcal_s_to_cache(H, mc, s_t);
4701 const double h_cache = meltcal_h_to_cache(H, mc, h_t);
4702 double T0 = 0, rho0 = 0;
4703 if (!mc.seed_for_hs(s_cache, h_cache, T0, rho0)) return false;
4704 // Floor at the melting-curve minimum temperature (water folds to ~251 K),
4705 // minus a small slack, so the corrector can reach the sub-triple region.
4706 double Tlo = -1.0;
4707 const double cTmin = mc.curve_Tmin();
4708 if (std::isfinite(cTmin) && cTmin > 0) Tlo = cTmin * (1.0 - 1e-3);
4709 return hs_corrector(H, T0, rho0, h_t, s_t, T_out, rho_out, Tlo);
4710}
4711
4712// Try the three legs in order; accept the first result that is stable, matching,
4713// AND outside the dome (a genuine single-phase state). An in-dome (metastable)
4714// root means the input is actually two-phase, so reject it -- the dispatcher then
4715// falls through to the two-phase solve.
4716bool hs_cascade(HelmholtzEOSMixtureBackend& H, HS_SA_t* sa, double h_t, double s_t, double& T_out, double& rho_out) {
4717 auto good = [&](double T, double rho) { return hs_accept(H, T, rho, h_t, s_t) && !(sa != nullptr && hs_inside_dome(*sa, T, rho)); };
4718 // Run one leg; on success commit T_out/rho_out. A leg whose rootfind wanders
4719 // out of the EOS domain can throw -- that just defers to the next leg.
4720 auto run = [&](auto&& leg) -> bool {
4721 try {
4722 double T = 0, rho = 0;
4723 if (leg(T, rho) && good(T, rho)) {
4724 T_out = T;
4725 rho_out = rho;
4726 return true;
4727 }
4728 } catch (...) { // NOLINT(bugprone-empty-catch): a throwing leg defers to the next
4729 }
4730 return false;
4731 };
4732 if (sa != nullptr && run([&](double& T, double& rho) { return hs_leg_saturation(H, *sa, h_t, s_t, T, rho); })) return true;
4733 if (run([&](double& T, double& rho) { return hs_leg_isentrope(H, h_t, s_t, T, rho); })) return true;
4734 if (run([&](double& T, double& rho) { return hs_leg_departure(H, h_t, s_t, T, rho); })) return true;
4735 // Leg 4: melting-line anchor for the cold compressed-liquid corner. Gated by
4736 // config + env kill-switch; built lazily and cached.
4737 // Uses a dedicated run_melt that accepts a lower Tmin bound (the melting-curve
4738 // minimum temperature, ~251 K for water) instead of H.Tmin() (~273 K), so
4739 // sub-triple compressed-liquid states -- which are physically valid EOS states
4740 // but lie below the triple-point temperature -- pass the acceptance gate.
4741 // All other acceptance criteria (h/s residual, dp/drho > 0, cv > 0, outside
4742 // dome) are identical to the standard run()/good() path.
4743 static const bool meltcal_disabled = (std::getenv("COOLPROP_DISABLE_MELTING_CALORIC_HS") != nullptr);
4744 if (!meltcal_disabled && get_config_bool(ENABLE_MELTING_CALORIC_HS)) {
4745 if (auto mc = get_melting_caloric_cached(H)) {
4746 const double melt_Tmin = mc->curve_Tmin();
4747 auto good_melt = [&](double T, double rho) {
4748 return hs_accept(H, T, rho, h_t, s_t, melt_Tmin) && !(sa != nullptr && hs_inside_dome(*sa, T, rho));
4749 };
4750 auto run_melt = [&](auto&& leg) -> bool {
4751 try {
4752 double T = 0, rho = 0;
4753 if (leg(T, rho) && good_melt(T, rho)) {
4754 T_out = T;
4755 rho_out = rho;
4756 return true;
4757 }
4758 } catch (...) { // NOLINT(bugprone-empty-catch): a throwing leg defers to the next
4759 }
4760 return false;
4761 };
4762 if (run_melt([&](double& T, double& rho) { return hs_leg_melting(H, *mc, h_t, s_t, T, rho); })) return true;
4763 }
4764 }
4765 return false;
4766}
4767
4768// Cheap superancillary two-phase detector (no EOS): scan the Qh==Qs residual on
4769// the caloric superancillary for a sign change with quality strictly inside (0,1).
4770// Used only as an OPTIMIZATION -- a true routes the point straight to the
4771// EOS-exact two-phase solve, skipping the (doomed) single-phase cascade. It is
4772// deliberately conservative: a false negative (or a near-critical miss) is safe
4773// because the cascade rejects the resulting in-dome metastable root and the
4774// dispatcher's two-phase fallback then runs; a false positive is safe because the
4775// two-phase solve fails to reproduce (h,s) and we fall through to the cascade.
4776bool hs_two_phase_likely(HelmholtzEOSMixtureBackend& H, HS_SA_t& sa, double h_t, double s_t) {
4778 if (!sa.has_variable('H') || !sa.has_variable('S')) return false;
4779 const double hc = hs_h_to_cache(H, sa, h_t), sc = hs_s_to_cache(H, sa, s_t);
4780 const double Tmin = sa.get_Tmin(), Tc = sa.get_Tcrit_num();
4781 auto Qh_minus_Qs = [&](double T) -> double {
4782 const double sL = sa.eval_sat(T, 'S', 0), sV = sa.eval_sat(T, 'S', 1);
4783 const double hL = sa.eval_sat(T, 'H', 0), hV = sa.eval_sat(T, 'H', 1);
4784 return (hc - hL) / (hV - hL) - (sc - sL) / (sV - sL);
4785 };
4786 // Coarse scan for a sign change: the endpoint near Tc is ill-conditioned
4787 // (rhoL->rhoV, the quality ratios degenerate), so scan rather than bracket the
4788 // ends -- this is what a naive two-endpoint test missed at low quality.
4789 const int M = 40;
4790 const double Tlo = Tmin + 1e-3, Thi = Tc - std::max(0.5, 1e-3 * Tc);
4791 double Tprev = Tlo, fprev = Qh_minus_Qs(Tlo);
4792 for (int i = 1; i <= M; ++i) {
4793 const double T = Tlo + (Thi - Tlo) * i / M;
4794 const double f = Qh_minus_Qs(T);
4795 if (std::isfinite(fprev) && std::isfinite(f) && fprev * f <= 0) {
4796 boost::math::uintmax_t it = 60;
4797 auto [l, r] = toms748_solve(Qh_minus_Qs, Tprev, T, fprev, f, boost::math::tools::eps_tolerance<double>(40), it);
4798 const double Tsol = 0.5 * (l + r);
4799 const double sL = sa.eval_sat(Tsol, 'S', 0), sV = sa.eval_sat(Tsol, 'S', 1);
4800 const double Q = (sc - sL) / (sV - sL);
4801 if (Q > 1e-6 && Q < 1.0 - 1e-6) return true; // strictly interior => two-phase
4802 }
4803 Tprev = T;
4804 fprev = f;
4805 }
4806 return false;
4807}
4808
4809} // namespace
4810
4811bool FlashRoutines::hs_corrector_probe(HelmholtzEOSMixtureBackend& H, double T0, double rho0, double h_t, double s_t, double& T_out, double& rho_out,
4812 double Tlo_override) {
4813 HSGasGuard guard(H); // hs_corrector requires caller to impose single-phase
4814 return hs_corrector(H, T0, rho0, h_t, s_t, T_out, rho_out, Tlo_override);
4815}
4816
4818 // ===================== superancillary "happy path" =====================
4819 // For a pure fluid with a built superancillary, robustly classify two-phase
4820 // vs single-phase from (h,s) and solve each fast: two-phase by the Qh==Qs
4821 // saturation solve (mirror of the D+X happy path), single-phase by the
4822 // cascade above. Any failure falls through to the legacy "sad path" below.
4823 // Kill-switch COOLPROP_DISABLE_SUPERANC_HS forces the legacy path.
4824 {
4825 static const bool hs_disabled = (std::getenv("COOLPROP_DISABLE_SUPERANC_HS") != nullptr);
4826 if (!hs_disabled && get_config_bool(ENABLE_SUPERANCILLARIES) && HEOS.is_pure()) {
4827 std::shared_ptr<EquationOfState::SuperAncillary_t> sa_ptr = HEOS.get_superanc();
4828 if (sa_ptr) {
4829 const double h_t = HEOS._hmolar, s_t = HEOS._smolar;
4830 auto restore_inputs = [&]() {
4831 HEOS._hmolar = h_t;
4832 HEOS._smolar = s_t;
4833 HEOS.unspecify_phase();
4834 };
4835 auto reproduces = [&]() -> bool {
4836 const double hh = HEOS.hmolar(), ss = HEOS.smolar();
4837 return std::isfinite(hh) && std::isfinite(ss) && std::abs(hh - h_t) <= 1e-6 * std::abs(h_t) + 1e-3
4838 && std::abs(ss - s_t) <= 1e-6 * std::abs(s_t) + 1e-5;
4839 };
4840 try {
4842 bool tried_2ph = false;
4843 auto try_twophase = [&]() -> bool {
4845 HS_flash_twophase(HEOS, h_t, s_t, opt);
4846 tried_2ph = true;
4847 return reproduces();
4848 };
4849 // (0) Fast two-phase screen (no EOS): if (h,s) is clearly inside the
4850 // dome, solve two-phase directly and skip the doomed single-phase
4851 // cascade (~50-100 wasted evals for two-phase inputs).
4852 if (hs_two_phase_likely(HEOS, sa, h_t, s_t)) {
4853 if (try_twophase()) return;
4854 }
4855 // (1) Single-phase cascade (the common case, ~16 evals). It accepts
4856 // only a stable root OUTSIDE the dome, so for a two-phase (h,s) every
4857 // leg lands on an in-dome metastable root and is rejected -> the
4858 // cascade fails and we fall through to (2). This dome-rejection is
4859 // the robust safety net behind the conservative screen in (0).
4860 double T = 0, rho = 0;
4861 if (hs_cascade(HEOS, sa_ptr.get(), h_t, s_t, T, rho)) {
4862 HEOS.update_DmolarT_direct(rho, T);
4863 HEOS._Q = 10000;
4864 HEOS._p = HEOS.calc_pressure_nocache(HEOS.T(), HEOS.rhomolar());
4865 HEOS.unspecify_phase();
4867 if (reproduces()) return;
4868 }
4869 // (2) Two-phase fallback (if the screen did not already try it): the
4870 // cascade found no stable single-phase root, so solve the EOS-exact
4871 // Qh==Qs problem and accept only if it reproduces (h,s).
4872 if (!tried_2ph) {
4873 if (try_twophase()) return;
4874 }
4875 // Every fall-through to the legacy path below MUST see the original
4876 // inputs: the cascade / two-phase attempts mutate HEOS._hmolar,
4877 // _smolar (via update_DmolarT_direct -> clear()), so restore them
4878 // unconditionally here. (Skipping this on the screen-fired +
4879 // cascade-failed path corrupted the legacy solve's target.)
4880 restore_inputs();
4881 } catch (const std::exception& e) {
4882 if (get_debug_level() > 0) {
4883 std::cout << "HS_flash superancillary path failed (" << e.what() << "); using legacy path\n";
4884 }
4885 restore_inputs();
4886 }
4887 }
4888 }
4889 }
4890 // ============ superancillary-free single-phase happy path =============
4891 // Pseudo-pure fluids (e.g. Air) and any pure fluid without a built superancillary
4892 // skip the block above, and would otherwise hit the ~50-iteration blind T-scan in the
4893 // legacy sad path below (Air HS measured ~160 ms/call). But the cascade's dome-free
4894 // legs -- supercritical isentrope (T=Tmax anchor), ideal-gas departure, and melting --
4895 // need no saturation curve, and their stability-based acceptance (hs_accept: reproduces
4896 // (h,s) AND dp/drho|_T>0 AND cv>0) makes the single-phase (h,s)->(T,rho) root unique.
4897 // So run the cascade with a NULL superancillary for the common single-phase case
4898 // (~tens of us, like the pure-fluid happy path). Two-phase or cascade-miss inputs are
4899 // rejected by the acceptance gate and fall through to the sad path.
4900 {
4901 static const bool hs_disabled_na = (std::getenv("COOLPROP_DISABLE_SUPERANC_HS") != nullptr);
4902 // Restrict this block to fluids that genuinely HAVE no superancillary, so that
4903 // any fluid which does have one keeps its dome veto (the cascade only applies
4904 // hs_inside_dome when sa != nullptr; without it, hs_accept can admit an in-dome
4905 // METASTABLE root for a two-phase (h,s) input). The probe is on superancillary
4906 // *existence*, NOT the ENABLE_SUPERANCILLARIES config flag: a pure fluid that has
4907 // a superancillary built must never enter the null-sa cascade even when the
4908 // config is off -- it falls to the legacy two-phase-aware path instead.
4909 // get_superanc() THROWS for a pseudo-pure fluid, so guard it behind is_pure();
4910 // for pseudo-pure (Air, R407C, ...) superanc_available stays false by design.
4911 bool superanc_available = false;
4912 if (HEOS.is_pure()) {
4913 try {
4914 superanc_available = (HEOS.get_superanc() != nullptr);
4915 } catch (...) { // NOLINT(bugprone-empty-catch) -- treat as "no superancillary"
4916 }
4917 }
4918 if (!hs_disabled_na && HEOS.is_pure_or_pseudopure && !superanc_available) {
4919 const double h_t = HEOS._hmolar, s_t = HEOS._smolar;
4920 bool ok = false;
4921 try {
4922 double T = 0, rho = 0;
4923 if (hs_cascade(HEOS, nullptr, h_t, s_t, T, rho)) {
4924 HEOS.update_DmolarT_direct(rho, T);
4925 HEOS._Q = 10000;
4926 HEOS._p = HEOS.calc_pressure_nocache(HEOS.T(), HEOS.rhomolar());
4927 HEOS.unspecify_phase();
4929 const double hh = HEOS.hmolar(), ss = HEOS.smolar();
4930 ok = std::isfinite(hh) && std::isfinite(ss) && std::abs(hh - h_t) <= 1e-6 * std::abs(h_t) + 1e-3
4931 && std::abs(ss - s_t) <= 1e-6 * std::abs(s_t) + 1e-5;
4932 }
4933 } catch (...) { // NOLINT(bugprone-empty-catch) -- fall through to the sad path
4934 }
4935 if (ok) {
4936 return;
4937 }
4938 // The cascade's probes (update_DmolarT_direct -> clear) overwrite the cached
4939 // caloric inputs; restore them so the sad path solves for the original target.
4940 HEOS._hmolar = h_t;
4941 HEOS._smolar = s_t;
4942 HEOS.unspecify_phase();
4943 }
4944 }
4945 // ===================== legacy "sad path" =====================
4946 // Use TS flash and iterate on T (known to be between Tmin and Tmax)
4947 // in order to find H
4948 double hmolar = HEOS.hmolar(), smolar = HEOS.smolar();
4949 class Residual : public FuncWrapper1D
4950 {
4951 public:
4953 CoolPropDbl hmolar, smolar;
4954 Residual(HelmholtzEOSMixtureBackend& HEOS, CoolPropDbl hmolar_spec, CoolPropDbl smolar_spec)
4955 : HEOS(HEOS), hmolar(hmolar_spec), smolar(smolar_spec) {};
4956 double call(double T) override {
4957 HEOS.update(SmolarT_INPUTS, smolar, T);
4958 double r = HEOS.hmolar() - hmolar;
4959 return r;
4960 }
4961 } resid(HEOS, hmolar, smolar);
4962 // Find minimum temperature
4963 bool good_Tmin = false;
4964 double Tmin = HEOS.Ttriple();
4965 double rmin = NAN;
4966 do {
4967 try {
4968 rmin = resid.call(Tmin);
4969 good_Tmin = true;
4970 } catch (...) {
4971 Tmin += 0.5;
4972 }
4973 if (Tmin > HEOS.Tmax()) {
4974 throw ValueError("Cannot find good Tmin");
4975 }
4976 } while (!good_Tmin);
4977
4978 // Find maximum temperature
4979 bool good_Tmax = false;
4980 double Tmax = HEOS.Tmax() * 1.01; // Just a little above, so if we use Tmax as input, it should still work
4981 double rmax = NAN;
4982 do {
4983 try {
4984 rmax = resid.call(Tmax);
4985 good_Tmax = true;
4986 } catch (...) {
4987 Tmax -= 0.1;
4988 }
4989 if (Tmax < Tmin) {
4990 throw ValueError("Cannot find good Tmax");
4991 }
4992 } while (!good_Tmax);
4993 if (rmin * rmax > 0 && std::abs(rmax) < std::abs(rmin)) {
4994 throw CoolProp::ValueError(format("HS inputs correspond to temperature above maximum temperature of EOS [%g K]", HEOS.Tmax()));
4995 }
4996 // The residual h(SmolarT(s, T)) - h_target is frequently NON-MONOTONIC: the inner
4997 // SmolarT solve degrades (or returns a wildly different branch) at high T for
4998 // low-entropy cold-liquid inputs, so f(Tmin) and f(Tmax) often share a sign while
4999 // the true root sits just above Tmin -- a plain Brent(Tmin, Tmax) then reports
5000 // "do not bracket". Scan upward from Tmin for the FIRST sign change and solve that
5001 // sub-interval with TOMS748, skipping temperatures where the inner solve throws and
5002 // never reaching the pathological high-T region.
5003 bool hs_solved = false;
5004 if (ValidNumber(rmin)) {
5005 const int Nscan = 50;
5006 double T_prev = Tmin, f_prev = rmin;
5007 int i_prev = 0; // grid index of the last VALID sample (Tmin is index 0)
5008 for (int i = 1; i <= Nscan; ++i) {
5009 const double Tt = Tmin + (Tmax - Tmin) * static_cast<double>(i) / Nscan;
5010 double ft = NAN;
5011 try {
5012 ft = resid.call(Tt);
5013 } catch (...) {
5014 continue; // inner SmolarT solve failed at this T; skip and keep scanning
5015 }
5016 if (!ValidNumber(ft)) {
5017 continue;
5018 }
5019 // Only bracket between ADJACENT valid samples (i == i_prev + 1). If a sample was
5020 // skipped in between, the interval (T_prev, Tt) spans temperatures where the inner
5021 // solve throws, so TOMS748's interior probes there would escape -- don't bracket
5022 // across that gap; advance and look for a clean adjacent sign change instead.
5023 if (i == i_prev + 1 && f_prev * ft <= 0) {
5024 try {
5025 boost::math::uintmax_t max_iter = 100;
5026 auto f = [&resid](const double T) { return resid.call(T); };
5027 auto [l, r] = toms748_solve(f, T_prev, Tt, f_prev, ft, boost::math::tools::eps_tolerance<double>(30), max_iter);
5028 resid.call(0.5 * (l + r)); // leave HEOS at the converged state
5029 hs_solved = true;
5030 break;
5031 } catch (...) { // NOLINT(bugprone-empty-catch) -- intentional: keep scanning, then fall to Brent
5032 }
5033 }
5034 T_prev = Tt;
5035 f_prev = ft;
5036 i_prev = i;
5037 }
5038 }
5039 if (!hs_solved) {
5040 // No interior sign change found by the scan; fall back to the original assumption
5041 // that [Tmin, Tmax] brackets the root.
5042 Brent(resid, Tmin, Tmax, DBL_EPSILON, 1e-10, 100);
5043 }
5044}
5045
5046#if defined(ENABLE_CATCH)
5047
5048TEST_CASE("PD with T very large should yield error", "[PDflash]") {
5049 shared_ptr<HelmholtzEOSBackend> HEOS = std::make_shared<HelmholtzEOSBackend>("R134a");
5050 double Tc = HEOS->T_critical();
5051 HEOS->update(DmassT_INPUTS, 1.1, 1.5 * Tc);
5052 CHECK_THROWS(HEOS->update(DmassP_INPUTS, 2, 5 * HEOS->p()));
5053}
5054
5055TEST_CASE("Stability testing", "[stability]") {
5056 shared_ptr<HelmholtzEOSMixtureBackend> HEOS =
5057 std::make_shared<HelmholtzEOSMixtureBackend>(strsplit("n-Propane&n-Butane&n-Pentane&n-Hexane", '&'));
5058 std::vector<double> z(4);
5059 z[0] = 0.1;
5060 z[1] = 0.2;
5061 z[2] = 0.3;
5062 z[3] = 0.4;
5063 HEOS->set_mole_fractions(z);
5064
5065 HEOS->update(PQ_INPUTS, 101325, 0);
5066 double TL = HEOS->T();
5067
5068 HEOS->update(PQ_INPUTS, 101325, 1);
5069 double TV = HEOS->T();
5070
5071 SECTION("Liquid (feed is stable)") {
5072 StabilityRoutines::StabilityEvaluationClass stability_tester(*HEOS);
5073 // Unit step over an integer range — T values are bit-exact doubles, no accumulation.
5074 for (double T = TL - 1; T >= 100; T -= 1) { // NOLINT(cert-flp30-c)
5075 stability_tester.set_TP(T, 101325);
5076 CAPTURE(T);
5077 CHECK_NOTHROW(stability_tester.is_stable());
5078 }
5079 }
5080 SECTION("Vapor (feed is stable)") {
5081 StabilityRoutines::StabilityEvaluationClass stability_tester(*HEOS);
5082 // Unit step over an integer range — see liquid section above.
5083 for (double T = TV + 1; T <= 500; T += 1) { // NOLINT(cert-flp30-c)
5084 stability_tester.set_TP(T, 101325);
5085 CAPTURE(T);
5086 CHECK_NOTHROW(stability_tester.is_stable());
5087 }
5088 }
5089 SECTION("Two-phase (feed is unstable)") {
5090 StabilityRoutines::StabilityEvaluationClass stability_tester(*HEOS);
5091 stability_tester.set_TP((TV + TL) / 2.0, 101325);
5092 CHECK(stability_tester.is_stable() == false);
5093 }
5094}
5095
5096TEST_CASE("Test critical points for methane + H2S", "[critical_points]") {
5097 shared_ptr<HelmholtzEOSMixtureBackend> HEOS = std::make_shared<HelmholtzEOSMixtureBackend>(strsplit("Methane&H2S", '&'));
5098
5099 double zz[] = {0.998, 0.97, 0.9475, 0.94, 0.93, 0.86, 0.85, 0.84, 0.75, 0.53, 0.52, 0.51, 0.49, 0.36, 0.24, 0.229, 0.09};
5100 int Npts[] = {2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 2, 2, 2, 1, 1, 1, 1}; // Number of critical points that should be found
5101 int imax = sizeof(zz) / sizeof(double);
5102
5103 for (int i = 0; i < imax; ++i) {
5104 double z0 = zz[i];
5105 std::vector<double> z(2);
5106 z[0] = z0;
5107 z[1] = 1 - z0;
5108 HEOS->set_mole_fractions(z);
5109 CAPTURE(z0);
5110 std::vector<CriticalState> pts = HEOS->all_critical_points();
5111 CHECK(pts.size() == Npts[i]);
5112 }
5113}
5114
5115TEST_CASE("Test critical points for nitrogen + ethane with HEOS", "[critical_points]") {
5116 shared_ptr<HelmholtzEOSMixtureBackend> HEOS = std::make_shared<HelmholtzEOSMixtureBackend>(strsplit("Nitrogen&Ethane", '&'));
5117 std::vector<double> zz = linspace(0.001, 0.999, 21);
5118 int failure_count = 0;
5119 for (double z0 : zz) {
5120 std::vector<double> z(2);
5121 z[0] = z0;
5122 z[1] = 1 - z0;
5123 HEOS->set_mole_fractions(z);
5124 CAPTURE(z0);
5125 std::vector<CriticalState> pts;
5126 try {
5127 pts = HEOS->all_critical_points();
5128 } catch (std::exception& e) {
5129 CAPTURE(e.what());
5130 failure_count++;
5131 }
5132 }
5133 // Only an error if more than half fail;
5134 CHECK(failure_count < 10);
5135}
5136
5137TEST_CASE("Test critical points for nitrogen + ethane with PR", "[critical_points]") {
5138 shared_ptr<PengRobinsonBackend> HEOS = std::make_shared<PengRobinsonBackend>(strsplit("Nitrogen&Ethane", '&'));
5139 HEOS->set_binary_interaction_double(0, 1, "kij", 0.0407); // Ramırez-Jimenez et al.
5140 std::vector<double> zz = linspace(0.001, 0.999, 21);
5141 for (double z0 : zz) {
5142 std::vector<double> z(2);
5143 z[0] = z0;
5144 z[1] = 1 - z0;
5145 HEOS->set_mole_fractions(z);
5146 CAPTURE(z0);
5147 std::vector<CriticalState> pts;
5148 CHECK_NOTHROW(pts = HEOS->all_critical_points());
5149 }
5150}
5151
5152#endif
5153
5154} /* namespace CoolProp */