CoolProp 8.0.0
An open-source fluid property and humid air property database
SurfacePresets.cpp
Go to the documentation of this file.
1#include <algorithm>
2#include <cmath>
3#include <cstdint>
4#include <optional>
5#include <stdexcept>
6#include <utility>
7
8#include <IF97.h>
9#include "boost/math/tools/toms748_solve.hpp"
10
18#include "CoolProp/Exceptions.h"
19
20namespace CoolProp {
21namespace sbtl {
22namespace presets {
23
24namespace {
25
26// Shared property list for HmassP_INPUTS: h is the input (secondary
27// axis), so the outputs are ρ, T, s, u, w (speed of sound). Density
28// is log-transformed (EXP) since it varies over orders of magnitude
29// across LIQUID + VAPOR; the rest are identity-transformed.
30// Returns true if the source backend can compute the given transport
31// property at a benign single-phase probe point. Used to gate
32// inclusion of η / λ in the tabulated property list — fluids whose
33// HEOS model lacks a transport correlation (e.g. some siloxanes) get
34// a slimmer table rather than an all-NaN sampling failure.
35bool source_supports_transport_(::CoolProp::AbstractState& src, ::CoolProp::parameters key) {
36 const double T = 0.85 * src.T_critical();
37 const double p = 0.50 * src.p_critical();
38 try {
40 double v = (key == ::CoolProp::iviscosity) ? src.viscosity() : src.conductivity();
41 return std::isfinite(v);
42 } catch (...) { // NOLINT(bugprone-empty-catch)
43 return false;
44 }
45}
46
47std::vector<PropertySpec> ph_properties(::CoolProp::AbstractState& src) {
48 std::vector<PropertySpec> ps = {
54 };
55 // Transport properties — IAPWS G13-15 Tables 12 (η) and 13 (λ).
56 // Both span ~5 orders of magnitude across the (p, h) envelope so
57 // they ride the EXP transform alongside density. Only added if
58 // the source backend actually exposes them (some fluids' HEOS
59 // models lack transport correlations entirely).
60 if (source_supports_transport_(src, ::CoolProp::iviscosity)) {
62 }
63 if (source_supports_transport_(src, ::CoolProp::iconductivity)) {
65 }
66 return ps;
67}
68
69// Shared property list for PT_INPUTS: T is the input (secondary
70// axis), so outputs are ρ, h, s, u, w (speed of sound).
71std::vector<PropertySpec> pt_properties(::CoolProp::AbstractState& src) {
72 std::vector<PropertySpec> ps = {
78 };
79 if (source_supports_transport_(src, ::CoolProp::iviscosity)) {
81 }
82 if (source_supports_transport_(src, ::CoolProp::iconductivity)) {
84 }
85 return ps;
86}
87
88// Shared property list for PSmass_INPUTS: s is the input (secondary
89// axis), so the outputs are ρ, T, h, u, w (speed of sound). Mirror of
90// ph_properties with iHmass tabulated where ph tabulated iSmass (the
91// roles of h and s swap between input and output).
92std::vector<PropertySpec> ps_properties(::CoolProp::AbstractState& src) {
93 std::vector<PropertySpec> ps = {
99 };
100 if (source_supports_transport_(src, ::CoolProp::iviscosity)) {
102 }
103 if (source_supports_transport_(src, ::CoolProp::iconductivity)) {
105 }
106 return ps;
107}
108
109// NOTE on the IF97 R2/R3 boundary: T_B23(p) comes straight from the
110// if97 library's IF97::Region23_p(p) — no local copy of the B23
111// coefficients (henningjp, PR #2938). CoolProp builds the if97 dep
112// in Pa units (p_fact = 1e6), matching the Pa-based IF97::psat97 /
113// Tsat97 used in HumidAirProp, so call sites pass p in Pa directly.
114// IF97::Region23_p is valid for p ∈ [16.529, 100] MPa → T ∈
115// [623.15, 863.15] K; callers must gate to that range.
116
117// Build the h = h_B23(p) boundary curve in (p, h) coords by walking p
118// knots and evaluating forward IF97 h(T_B23(p), p) at each. Used to
119// split the SUPER region in ph_subcritical along the IF97 R2/R3 kink
120// when the source backend is IF97 (the kink is intrinsic to IF97's
121// piecewise EOS; HEOS source has no such internal boundary). Valid
122// only for p ∈ [16.529 MPa, 100 MPa] — caller must clamp.
123// Build h = h_IF97(623.15 K, p) along the IF97 R1/R3 isotherm. Used to
124// split SUPER_R3 (above pcrit, below p_B23) into a R1-territory sub-
125// region (T < 623.15 K, compressed-liquid supercritical) and a R3-
126// proper sub-region (T >= 623.15 K, dense supercritical). Same
127// pattern as build_h_B23_curve but on the T isotherm instead of the
128// p_B23(T) curve. Valid for p > p_sat(623.15) = 16.529 MPa.
129std::unique_ptr<region::CubicSplineCurve> build_h_R1R3_curve(::CoolProp::AbstractState& heos, double p_lo, double p_hi) {
130 constexpr double kT_R1R3 = 623.15;
131 constexpr double kP_R1R3_lo_MPa = 16.529;
132 const double p_lo_clamped = std::max(p_lo, kP_R1R3_lo_MPa * 1.0e6);
133 if (!(p_lo_clamped < p_hi)) {
134 throw std::invalid_argument("build_h_R1R3_curve: p range does not overlap IF97 R1/R3 isotherm's validity (p > 16.529 MPa)");
135 }
136 constexpr std::size_t n_knots = 64;
137 std::vector<double> p_knots(n_knots);
138 std::vector<double> h_knots(n_knots);
139 const double log_p_lo = std::log(p_lo_clamped);
140 const double log_p_hi = std::log(p_hi);
141 for (std::size_t k = 0; k < n_knots; ++k) {
142 const double p = std::exp(log_p_lo + static_cast<double>(k) * (log_p_hi - log_p_lo) / static_cast<double>(n_knots - 1));
143 heos.update(::CoolProp::PT_INPUTS, p, kT_R1R3);
144 p_knots[k] = p;
145 h_knots[k] = heos.hmass();
146 }
147 return region::CubicSplineCurve::build(std::move(p_knots), std::move(h_knots));
148}
149
150std::unique_ptr<region::CubicSplineCurve> build_h_B23_curve(::CoolProp::AbstractState& heos, double p_lo, double p_hi) {
151 constexpr double kP_B23_lo_MPa = 16.529;
152 constexpr double kP_B23_hi_MPa = 100.0;
153 const double p_lo_clamped = std::max(p_lo, kP_B23_lo_MPa * 1.0e6);
154 const double p_hi_clamped = std::min(p_hi, kP_B23_hi_MPa * 1.0e6);
155 if (!(p_lo_clamped < p_hi_clamped)) {
156 throw std::invalid_argument("build_h_B23_curve: p range does not overlap IF97 B23 curve's [16.529, 100] MPa validity");
157 }
158 constexpr std::size_t n_knots = 64;
159 std::vector<double> p_knots(n_knots);
160 std::vector<double> h_knots(n_knots);
161 const double log_p_lo = std::log(p_lo_clamped);
162 const double log_p_hi = std::log(p_hi_clamped);
163 for (std::size_t k = 0; k < n_knots; ++k) {
164 const double p = std::exp(log_p_lo + static_cast<double>(k) * (log_p_hi - log_p_lo) / static_cast<double>(n_knots - 1));
165 const double T_B23 = IF97::Region23_p(p);
166 heos.update(::CoolProp::PT_INPUTS, p, T_B23);
167 p_knots[k] = p;
168 h_knots[k] = heos.hmass();
169 }
170 return region::CubicSplineCurve::build(std::move(p_knots), std::move(h_knots));
171}
172
173// Find T such that h(T, p) = h_target via bracketed TOMS748 iteration on
174// the source backend's forward h. Bracketed (vs Newton) so we stay
175// robust across IF97 region boundaries where cp jumps cause Newton to
176// overshoot into the wrong basin — the foi.9.10 root cause that
177// contaminated R3 cells with garbage stored T / s / ρ values up through
178// foi.9.9. Boost's toms748_solve is already used elsewhere in CoolProp
179// (superancillary, AbstractState, FlashRoutines), so no new dependency.
180//
181// Returns the converged T. If the bracket [T_lo, T_hi] doesn't contain
182// a root, returns the nearer endpoint and signals via `ok` so the caller
183// can fall through to the NaN-fill machinery downstream.
184double solve_T_from_h_toms748(::CoolProp::AbstractState& s, double p, double h_target, double T_lo, double T_hi, bool* ok = nullptr) {
185 auto resid = [&s, p, h_target](double T) -> double {
186 try {
188 return s.hmass() - h_target;
189 } catch (...) { // NOLINT(bugprone-empty-catch)
190 // Out-of-range / ε-band rejects bubble up as NaN so the
191 // sign-bracket check below cleanly fails the bracket test.
192 return std::nan("");
193 }
194 };
195 const double r_lo = resid(T_lo);
196 const double r_hi = resid(T_hi);
197 if (!std::isfinite(r_lo) || !std::isfinite(r_hi) || r_lo * r_hi > 0.0) {
198 if (ok != nullptr) {
199 *ok = false;
200 }
201 // Returning the endpoint with smaller residual would re-mutate
202 // `s`; callers that want to fall through to the HmassP_INPUTS
203 // state must check *ok and skip the subsequent PT_INPUTS update.
204 return (std::abs(r_lo) < std::abs(r_hi)) ? T_lo : T_hi;
205 }
206 std::uintmax_t max_iter = 30;
207 const auto bracket = boost::math::tools::toms748_solve(resid, T_lo, T_hi, r_lo, r_hi, boost::math::tools::eps_tolerance<double>(40), max_iter);
208 if (ok != nullptr) {
209 *ok = true;
210 }
211 return 0.5 * (bracket.first + bracket.second);
212}
213
214// Entropy analogs of build_h_R1R3_curve / build_h_B23_curve — return
215// s = s_IF97(T_boundary, p) along the IF97 R1/R3 isotherm (623.15 K)
216// and the R2/R3 B23 curve respectively, used to split the PS SUPER
217// region at the same IF97 kinks ph_subcritical splits at.
218std::unique_ptr<region::CubicSplineCurve> build_s_R1R3_curve(::CoolProp::AbstractState& heos, double p_lo, double p_hi) {
219 constexpr double kT_R1R3 = 623.15;
220 constexpr double kP_R1R3_lo_MPa = 16.529;
221 const double p_lo_clamped = std::max(p_lo, kP_R1R3_lo_MPa * 1.0e6);
222 if (!(p_lo_clamped < p_hi)) {
223 throw std::invalid_argument("build_s_R1R3_curve: p range does not overlap IF97 R1/R3 isotherm's validity (p > 16.529 MPa)");
224 }
225 constexpr std::size_t n_knots = 64;
226 std::vector<double> p_knots(n_knots);
227 std::vector<double> s_knots(n_knots);
228 const double log_p_lo = std::log(p_lo_clamped);
229 const double log_p_hi = std::log(p_hi);
230 for (std::size_t k = 0; k < n_knots; ++k) {
231 const double p = std::exp(log_p_lo + static_cast<double>(k) * (log_p_hi - log_p_lo) / static_cast<double>(n_knots - 1));
232 heos.update(::CoolProp::PT_INPUTS, p, kT_R1R3);
233 p_knots[k] = p;
234 s_knots[k] = heos.smass();
235 }
236 return region::CubicSplineCurve::build(std::move(p_knots), std::move(s_knots));
237}
238
239std::unique_ptr<region::CubicSplineCurve> build_s_B23_curve(::CoolProp::AbstractState& heos, double p_lo, double p_hi) {
240 constexpr double kP_B23_lo_MPa = 16.529;
241 constexpr double kP_B23_hi_MPa = 100.0;
242 const double p_lo_clamped = std::max(p_lo, kP_B23_lo_MPa * 1.0e6);
243 const double p_hi_clamped = std::min(p_hi, kP_B23_hi_MPa * 1.0e6);
244 if (!(p_lo_clamped < p_hi_clamped)) {
245 throw std::invalid_argument("build_s_B23_curve: p range does not overlap IF97 B23 curve's [16.529, 100] MPa validity");
246 }
247 constexpr std::size_t n_knots = 64;
248 std::vector<double> p_knots(n_knots);
249 std::vector<double> s_knots(n_knots);
250 const double log_p_lo = std::log(p_lo_clamped);
251 const double log_p_hi = std::log(p_hi_clamped);
252 for (std::size_t k = 0; k < n_knots; ++k) {
253 const double p = std::exp(log_p_lo + static_cast<double>(k) * (log_p_hi - log_p_lo) / static_cast<double>(n_knots - 1));
254 const double T_B23 = IF97::Region23_p(p);
255 heos.update(::CoolProp::PT_INPUTS, p, T_B23);
256 p_knots[k] = p;
257 s_knots[k] = heos.smass();
258 }
259 return region::CubicSplineCurve::build(std::move(p_knots), std::move(s_knots));
260}
261
262// Entropy analog of solve_T_from_h_toms748. s(T) at fixed p is
263// strictly increasing (ds/dT|p = cp/T > 0), so the bracket is at least
264// as robust as the enthalpy case.
265double solve_T_from_s_toms748(::CoolProp::AbstractState& s, double p, double s_target, double T_lo, double T_hi, bool* ok = nullptr) {
266 auto resid = [&s, p, s_target](double T) -> double {
267 try {
269 return s.smass() - s_target;
270 } catch (...) { // NOLINT(bugprone-empty-catch)
271 return std::nan("");
272 }
273 };
274 const double r_lo = resid(T_lo);
275 const double r_hi = resid(T_hi);
276 if (!std::isfinite(r_lo) || !std::isfinite(r_hi) || r_lo * r_hi > 0.0) {
277 if (ok != nullptr) {
278 *ok = false;
279 }
280 return (std::abs(r_lo) < std::abs(r_hi)) ? T_lo : T_hi;
281 }
282 std::uintmax_t max_iter = 30;
283 const auto bracket = boost::math::tools::toms748_solve(resid, T_lo, T_hi, r_lo, r_hi, boost::math::tools::eps_tolerance<double>(40), max_iter);
284 if (ok != nullptr) {
285 *ok = true;
286 }
287 return 0.5 * (bracket.first + bracket.second);
288}
289
290} // namespace
291
293 const std::size_t NT = opts.NT;
294 const std::size_t NR = opts.NR;
295 const std::int32_t rank = opts.rank;
296 const std::optional<double> p_min_override = opts.p_min;
297 // Name is historical: this preset now also registers a SUPER
298 // region above p_crit so the surface covers the full HEOS
299 // (p, h) envelope. Three regions: LIQUID + VAPOR + SUPER, plus
300 // an optional NC_LIQUID/NC_VAPOR pair near pc when source is
301 // HEOS/REFPROP (CoolProp-4u9).
302 const auto [p_min, p_max_sub_raw] = subcritical_pressure_range(heos, p_min_override);
303 const auto [p_min_sup_raw, p_max_sup] = supercritical_pressure_range(heos);
304 const double T_min_eos = std::max(heos.Ttriple(), heos.Tmin());
305 const double T_max_eos = heos.Tmax();
306 const double pc = heos.p_critical();
307
308 // Near-critical sub-region geometry (CoolProp-4u9). HEOS/REFPROP
309 // sources gain a dedicated NC_LIQUID/NC_VAPOR pair on p ∈ [0.9·pc,
310 // (1 − 1 ppm)·pc] with a POWER(β=1/3) primary axis that crowds
311 // grid lines toward pc. Python diagnostics on R245fa/Water/CO2
312 // showed POWER buys 1000–100000× better off-grid max error vs LOG
313 // and stays flat in p_hi from 0.999·pc to 0.999999·pc — the
314 // critical-scaling singularity gets absorbed by the cube-root
315 // transform (Ising β ≈ 0.326 ≈ 1/3). IF97 source skips this
316 // (G13-15 already passes without it).
317 const bool source_is_if97 = (heos.backend_name() == "IF97Backend");
318 constexpr double kNC_p_lo_mult = 0.9;
319 constexpr double kNC_p_hi_mult = 1.0 - 1.0e-10; // gap of 1e-10·pc below pc — sub-ULP for practical p
320 constexpr double kNC_sup_p_lo_mult = 1.0 + 1.0e-10; // mirror above pc
321 constexpr double kNC_sup_p_hi_mult = 1.1;
322 const bool nc_enabled = !source_is_if97;
323 const double p_nc_lo = nc_enabled ? kNC_p_lo_mult * pc : 0.0;
324 const double p_nc_hi = nc_enabled ? kNC_p_hi_mult * pc : 0.0;
325 const double p_nc_sup_lo = nc_enabled ? kNC_sup_p_lo_mult * pc : 0.0;
326 const double p_nc_sup_hi = nc_enabled ? kNC_sup_p_hi_mult * pc : 0.0;
327 // Clip parent LIQUID/VAPOR p-max to the NC band's lower edge when
328 // NC is enabled — handoff at p = 0.9·pc. Clip parent SUPER p-min
329 // to NC_SUPER's upper edge. When NC disabled (IF97 source), parent
330 // bounds use legacy values.
331 const double p_max_sub = nc_enabled ? std::min(p_max_sub_raw, p_nc_lo) : p_max_sub_raw;
332 const double p_min_sup = nc_enabled ? std::max(p_min_sup_raw, p_nc_sup_hi) : p_min_sup_raw;
333
334 SurfaceSpec spec;
335 spec.fluid_name = heos.fluid_names().empty() ? std::string{} : heos.fluid_names().front();
337 spec.NT = NT;
338 spec.NR = NR;
339 spec.rank = rank;
340 spec.properties = ph_properties(heos);
341
342 // LIQUID region: primary = log p, secondary = h.
343 // b_lo = h(T_min, p) — non-isothermal floor that walks T up
344 // if T_min < T_melt(p) at high p.
345 // b_hi = h_sat,L(p) — liquid side of the saturation dome.
346 if (p_min < p_max_sub) {
347 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max_sub);
348 auto lo = build_h_isotherm_floor(heos, p_min, p_max_sub, T_min_eos);
349 auto hi = build_h_sat_L(heos, p_min, p_max_sub);
350 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
351 }
352 // VAPOR region: primary = log p, secondary = h.
353 // b_lo = h_sat,V(p) — vapor side of the saturation dome.
354 // b_hi = h(T_max - 0.5 K, p) — hot ceiling within the HEOS
355 // validity envelope.
356 if (p_min < p_max_sub) {
357 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max_sub);
358 auto lo = build_h_sat_V(heos, p_min, p_max_sub);
359 auto hi = build_h_isotherm_ceiling(heos, p_min, p_max_sub, T_max_eos - 0.5);
360 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
361 }
362 // NC_LIQUID + NC_VAPOR (CoolProp-4u9): near-critical sub-regions on
363 // p ∈ [0.9·pc, (1 − 1e-10)·pc] with POWER(β=1/3) primary axis.
364 // The 1e-10 gap below pc is sub-ULP for practical p — anything
365 // closer falls inside the critical patch's auto-cal'd (T, p) bbox.
366 // Same boundary curves as the parent regions — the SuperAncillary-
367 // backed sat curves are continuous across the p = 0.9·pc handoff.
368 if (nc_enabled && p_nc_lo < p_nc_hi) {
369 {
370 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
371 auto lo = build_h_isotherm_floor(heos, p_nc_lo, p_nc_hi, T_min_eos);
372 auto hi = build_h_sat_L(heos, p_nc_lo, p_nc_hi);
373 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
374 }
375 {
376 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
377 auto lo = build_h_sat_V(heos, p_nc_lo, p_nc_hi);
378 auto hi = build_h_isotherm_ceiling(heos, p_nc_lo, p_nc_hi, T_max_eos - 0.5);
379 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
380 }
381 }
382 // NC_SUPER (CoolProp-4u9): super-critical near-critical sub-region
383 // on p ∈ [(1 + 1e-10)·pc, 1.1·pc] with POWER_LO(β=1/3) primary
384 // axis that crowds grid lines toward pc. No saturation dome above
385 // pc, so b_lo / b_hi are the same isotherm-floor / isotherm-ceiling
386 // pair used by parent SUPER — they continue smoothly across the
387 // p = 1.1·pc handoff. Single region (not the LIQ/VAP split used
388 // sub-critically) because the dome doesn't bisect this band.
389 // Closes the (h, p) coverage gap exposed by the h=350 kJ/kg sweep
390 // for R245fa: cells at p just above pc with T outside the patch's
391 // (T, p) bbox previously fell through every region and returned
392 // NaN. NC_SUPER claims them; SVD residual ≲ 1e-7 in this band.
393 if (nc_enabled && p_nc_sup_lo < p_nc_sup_hi) {
394 auto axis = region::AxisTransform::make(region::AxisScale::POWER_LO, p_nc_sup_lo, p_nc_sup_hi);
395 auto lo = build_h_isotherm_floor(heos, p_nc_sup_lo, p_nc_sup_hi, T_min_eos);
396 auto hi = build_h_isotherm_ceiling(heos, p_nc_sup_lo, p_nc_sup_hi, T_max_eos - 0.5);
397 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
398 }
399 // SUPER region: p > p_crit (clipped up to 1.1·pc when NC enabled,
400 // so NC_SUPER owns the strip just above pc). Primary = log p over
401 // [max(p_crit, 1.1·pc), p_max_eos*0.99], secondary = h. No sat
402 // dome above p_crit, so
403 // the secondary bounds are the same low-T / high-T isotherms used
404 // by LIQUID / VAPOR — they continue smoothly into supercritical.
405 //
406 // For IF97 source backend, split SUPER along the IF97 R2/R3
407 // boundary curve h_B23(p). IF97 uses different basis equations
408 // (R3: ρ-T fundamental Helmholtz; R2: g-basis) on the two sides
409 // of p = p_B23(T), so the property values match at the boundary
410 // but the derivatives have a kink. A single SVD over a region
411 // straddling that kink encodes it into the modes and the
412 // Hermite-cubic interpolant overshoots, producing R3 worst-case
413 // residuals ~100% in v / ~190 K in T against IAPWS-IF97 — five
414 // orders of magnitude beyond IAPWS G13-15 perm. Splitting the
415 // SUPER region at h_B23(p) puts the kink on a region edge instead
416 // of inside a cell, so each sub-region's SVD only sees cells on
417 // ONE side of the kink and reconstructs smoothly. (CoolProp-foi.9.5
418 // — subagent investigation 2026-05-20 ruled out kernel-level
419 // fixes and converged on this atlas-level structural fix.)
420 //
421 // HEOS source backend has no piecewise structure, so its SUPER
422 // stays one region.
423 constexpr double kP_B23_hi = 100.0e6; // IF97 B23 curve's upper p bound
424 if (source_is_if97 && p_min_sup < kP_B23_hi) {
425 // p range where the kink applies; clamped at the B23 upper bound.
426 const double p_split_hi = std::min(p_max_sup, kP_B23_hi);
427 // SUPER_R1_super: IF97 R1 territory at p > pcrit, h ∈ [h_floor(p),
428 // h_IF97(623.15 K, p)). Separating R1 from R3 in this slab
429 // (foi.9.9) gives the R1-side compressed-liquid SVD its own η
430 // normalization — the previous combined SUPER_R3 region had
431 // R1+R3 sharing modes, and R1 conformance suffered (post-foi.9.5
432 // R1 worst v 0.164%). Subcritical-p R1 (the LIQUID region)
433 // hits 0.008% max v with the same physics; this split brings
434 // SUPER_R3's R1 territory to that level.
435 {
436 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
437 auto lo = build_h_isotherm_floor(heos, p_min_sup, p_split_hi, T_min_eos);
438 auto hi = build_h_R1R3_curve(heos, p_min_sup, p_split_hi);
439 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
440 }
441 // SUPER_R3_proper: IF97 R3 territory at p > pcrit, h ∈ [h_R1R3(p),
442 // h_B23(p)). Only the (T, p, h) region where IF97 actually
443 // evaluates R3.
444 {
445 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
446 auto lo = build_h_R1R3_curve(heos, p_min_sup, p_split_hi);
447 auto hi = build_h_B23_curve(heos, p_min_sup, p_split_hi);
448 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
449 }
450 // SUPER_R2: high-h side of the IF97 R2/R3 kink, same p range.
451 {
452 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
453 auto lo = build_h_B23_curve(heos, p_min_sup, p_split_hi);
454 auto hi = build_h_isotherm_ceiling(heos, p_min_sup, p_split_hi, T_max_eos - 0.5);
455 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
456 }
457 // SUPER_HIGH_P (if any): above the IF97 B23 curve's valid p
458 // range there's no R2/R3 boundary (IF97 R3 extrapolates), so
459 // a single region suffices.
460 if (p_max_sup > kP_B23_hi) {
461 auto axis = region::AxisTransform::make(region::AxisScale::LOG, kP_B23_hi, p_max_sup);
462 auto lo = build_h_isotherm_floor(heos, kP_B23_hi, p_max_sup, T_min_eos);
463 auto hi = build_h_isotherm_ceiling(heos, kP_B23_hi, p_max_sup, T_max_eos - 0.5);
464 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
465 }
466 // SUPER_R5: IAPWS R7-97 Region 5 — extended-T high-temperature
467 // slab. T ∈ [1073.15, 2273.15] K, p ∈ (0, 50] MPa. Closes
468 // CoolProp-pd6. All other SVDSBTL&IF97 regions are bounded
469 // above by IF97::get_Tmax() = 1073.15 K (the R1/R2/R3 ceiling),
470 // so without this slab the conformance plots have a hard
471 // cutoff at 1073 K and the G13-15 Table 11 claim for R5 isn't
472 // actually exercised. IF97Backend dispatches PT_INPUTS with
473 // T > 1073.15 K (and p ≤ 50 MPa) to R5 internally, so the
474 // sampling reuses the same update_state lambda as the rest of
475 // the IF97 path. No internal subdivision: R5 is one closed-
476 // form expansion in IF97 and smooth enough to fit cleanly in a
477 // single SVD region.
478 // Nudge above 1073.15 because IF97's RegionDetermination_TP
479 // dispatches T == 1073.15 K to R2 (the boundary uses
480 // T <= Tmax strictly). Sampling h at T=1073.15 would build
481 // the SVD on R2's reference frame, then runtime queries that
482 // bracket at T=1073.15 would converge to R2 instead of R5
483 // (R2 and R5 use independent ideal-gas references; no
484 // IAPWS R7-97 smoothness condition at the boundary). One
485 // ULP above 1073.15 puts the sample and the runtime bracket
486 // both squarely in R5 territory. Found by code-reviewer
487 // adversarial pass.
488 const double kT_R5_lo = std::nextafter(1073.15, 2273.15);
489 constexpr double kT_R5_hi = 2273.15;
490 constexpr double kP_R5_hi = 50.0e6;
491 // IF97 Pmin = 611.213 Pa (saturation pressure at T_min =
492 // 273.15 K) per IAPWS R7-97; queries below that throw
493 // "Pressure out of range". Use 700 Pa as the SVDSBTL R5
494 // lower bound to leave a small margin against numerical
495 // boundary effects in the log-uniform grid sampling near
496 // p_min. Practical cost: ~50 Pa of R5 coverage lost on the
497 // very low-p side, where nothing physically interesting
498 // happens (R5 is supercritical-vapor at extreme T).
499 constexpr double kP_R5_lo = 700.0;
500 if (kP_R5_lo < kP_R5_hi) {
501 const double p_r5_hi = std::min(p_max_sup, kP_R5_hi);
502 auto axis = region::AxisTransform::make(region::AxisScale::LOG, kP_R5_lo, p_r5_hi);
503 // h_lo / h_hi sampled at the R5 endpoints via the same
504 // isotherm-spline path the rest of the preset uses. R5
505 // is monotone in h(T) at fixed p, so the spline through
506 // 64 knots reproduces the slab cleanly.
507 auto lo = build_h_isotherm_floor(heos, kP_R5_lo, p_r5_hi, kT_R5_lo);
508 auto hi = build_h_isotherm_ceiling(heos, kP_R5_lo, p_r5_hi, kT_R5_hi);
509 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
510 }
511 } else if (p_min_sup < p_max_sup) {
512 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_max_sup);
513 auto lo = build_h_isotherm_floor(heos, p_min_sup, p_max_sup, T_min_eos);
514 auto hi = build_h_isotherm_ceiling(heos, p_min_sup, p_max_sup, T_max_eos - 0.5);
515 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
516 }
517
518 // Thermodynamics glue: PH update + per-property readout.
519 //
520 // For IF97-source sampling we Newton-refine the backwards-equation
521 // T(p, h) against the forward h(T, p) at build time. The IAPWS
522 // R7-97 backwards equations carry a documented ±25 mK residual
523 // (see IF97.h's RegionOutputBackward note), which propagates to
524 // ~0.25 J/(kg·K) in s via ds ≈ cp·dT/T — well above the IAPWS
525 // perm budgets of 1e-3 J/(kg·K). Newton-refining closes that gap
526 // so the SVD grid stores forward-consistent properties. Costs
527 // ~3 extra forward calls per sample (build-time only); query path
528 // unchanged. HEOS / REFPROP sources don't need refinement
529 // because their HmassP path converges to forward precision
530 // internally.
531 //
532 // Newton iterates may land within IF97's 3.3e-5 ε-band of the
533 // saturation curve. Pin the phase across iterations so PT_INPUTS
534 // doesn't trip the ε-band reject.
535 if (source_is_if97) {
536 spec.update_state = [](::CoolProp::AbstractState& s, double p, double h) {
537 // IF97's HmassP_INPUTS only implements closed-form backward
538 // T(p, h) for R1, R2, and R5. R3 (dense supercritical /
539 // near-critical) throws "Pressure out of range" because no
540 // backward equation is wired up. This used to be silently
541 // masked by sample_grid's NaN-fill / median-fill machinery
542 // — every R3 cell in the SUPER region got a garbage stored
543 // value — until the foi.9.5 split shrank SUPER_R3's η-axis
544 // so much that the bad cells dominated the surface and
545 // worst-case T residuals blew out to ~470 K.
546 //
547 // Two-stage h-inversion via TOMS748 (foi.9.10). Bracketed
548 // iteration is essential — Newton from a fixed T=700 K seed
549 // overshoots across IF97 R2/R3 cp-jump boundaries when
550 // T_B23(p) < 700 K and fails to converge, leaving stored
551 // cell values wrong by tens of K (e.g., 65 K T error at
552 // T_target=663.7, p=26.6 MPa, propagating to s, ρ residuals
553 // of >20% downstream).
554 //
555 // 1. HmassP_INPUTS (R7-97 backward) seeds T to ±25 mK,
556 // sets the phase classification. Throws in R3 where
557 // no backward equation is published.
558 // 2. TOMS748 polish on [T_seed − 0.5, T_seed + 0.5], clamped
559 // to IF97's [273.16, 2273.15] T validity, closes the
560 // ±25 mK R7-97 residual to machine precision. Critical
561 // for R2/R5 s conformance (cp·dT/T propagates 25 mK in
562 // T to ~0.25 J/(kg·K) in s — over the 1e-3 perm budget).
563 // If the clamped bracket doesn't span a sign change
564 // (rare: HmassP already at machine precision, or
565 // near-T_min cell with bracket clipped tight), the
566 // helper signals via *ok and we keep the HmassP state.
567 // 3. R3 fallback: TOMS748 on [623.15 K, T_B23(p)].
569 bool single_phase = false;
570 bool seeded_via_hmass = true;
571 try {
573 phase0 = s.phase();
574 single_phase =
577 } catch (...) { // NOLINT(bugprone-empty-catch)
578 seeded_via_hmass = false;
579 }
580 // Phase pin (PR E mechanism) so the TOMS748 polish's
581 // PT_INPUTS evaluations near the sat dome don't trip
582 // IF97's ε-band reject. Exception-safe — AbstractState::
583 // clear() doesn't reset imposed_phase_index in IF97.
584 bool pinned = false;
585 if (single_phase) {
586 s.specify_phase(phase0);
587 pinned = true;
588 }
589 try {
590 if (!seeded_via_hmass) {
591 // Two fallback paths for cells where the R7-97
592 // backward equation can't dispatch:
593 // * R3 — T ∈ [623.15, T_B23(p)], p ∈ [16.529,
594 // 100] MPa (existing path).
595 // * R5 — T ∈ [1073.15, 2273.15], p ≤ 50 MPa
596 // (added for CoolProp-pd6 to support the new
597 // SUPER_R5 region). R5 has no backward
598 // equation either, so we forward-solve via
599 // TOMS748 just like R3.
600 //
601 // Dispatch by h_target magnitude — R5's lowest h
602 // (at T=1073.15) is ~4 MJ/kg, well above R3's
603 // upper h (~2.5 MJ/kg at the R3/R2 boundary).
604 // 3 MJ/kg is a conservative split.
605 // kR5_T_lo nudged one ULP above 1073.15 to skip
606 // IF97's R2/R5 dispatch boundary — see the
607 // matching comment in the SUPER_R5 region build.
608 // Without this, resid(T_lo) evaluates R2 while
609 // resid(T_lo + ε) evaluates R5, and the bracket
610 // straddles a small h discontinuity between the
611 // two formulations.
612 const double kR5_T_lo = std::nextafter(1073.15, 2273.15);
613 constexpr double kR5_T_hi = 2273.15;
614 constexpr double kR5_P_hi = 50.0e6;
615 constexpr double kH_R3_vs_R5_split = 3.0e6;
616 constexpr double kP_B23_lo_Pa = 16.529e6;
617 constexpr double kP_B23_hi_Pa = 100.0e6;
618
619 bool resolved = false;
620 // R5 fallback (try first when h is high and p is
621 // in R5's slab).
622 if (h >= kH_R3_vs_R5_split && p <= kR5_P_hi) {
623 bool ok = false;
624 const double T_R5 = solve_T_from_h_toms748(s, p, h, kR5_T_lo, kR5_T_hi, &ok);
625 if (ok) {
626 s.update(::CoolProp::PT_INPUTS, p, T_R5);
627 resolved = true;
628 }
629 }
630 if (!resolved) {
631 // R3 fallback: bracket on [623.15 K, T_B23(p)].
632 // IF97::Region23_p is only valid for p ∈ [16.529,
633 // 100] MPa; outside that range it returns garbage
634 // (sub-623.15 K or NaN from the negative-argument
635 // sqrt). Above 100 MPa is the SUPER_HIGH_P slab
636 // where there's no R2/R3 boundary at all — the R3
637 // fallback shouldn't run there in the first place,
638 // but guard anyway.
639 if (!(p >= kP_B23_lo_Pa && p <= kP_B23_hi_Pa)) {
641 "ph_subcritical IF97 sampling: neither R5 (h<3MJ/kg or p>50MPa) nor R3 (p outside [16.529, 100] MPa) "
642 "fallback applies — HmassP backward seed failed and no bracket is defined");
643 }
644 const double T_R3_lo = 623.15;
645 const double T_R3_hi = IF97::Region23_p(p);
646 if (!(T_R3_lo < T_R3_hi)) {
647 throw ::CoolProp::ValueError("ph_subcritical IF97 sampling: degenerate R3 bracket (T_B23(p) <= 623.15 K)");
648 }
649 bool ok = false;
650 const double T_R3 = solve_T_from_h_toms748(s, p, h, T_R3_lo, T_R3_hi, &ok);
651 if (!ok) {
652 // Bracket doesn't contain h — propagate as
653 // failure so the SurfaceFactory marks this
654 // cell NaN, rather than committing an endpoint
655 // T as a real state.
656 throw ::CoolProp::ValueError("ph_subcritical IF97 sampling: R3 TOMS748 missed bracket; cell will be NaN-filled");
657 }
658 s.update(::CoolProp::PT_INPUTS, p, T_R3);
659 }
660 } else {
661 // Polish R7-97 backward seed.
662 const double T_seed = s.T();
663 constexpr double kIF97_T_min = 273.16;
664 constexpr double kIF97_T_max = 2273.15;
665 const double T_lo = std::max(T_seed - 0.5, kIF97_T_min);
666 const double T_hi = std::min(T_seed + 0.5, kIF97_T_max);
667 if (T_lo < T_hi) {
668 bool ok = false;
669 const double T_polished = solve_T_from_h_toms748(s, p, h, T_lo, T_hi, &ok);
670 if (ok) {
671 s.update(::CoolProp::PT_INPUTS, p, T_polished);
672 } else {
673 // solve_T_from_h_toms748 mutates `s` via its
674 // bracket-residual probes; on a miss the
675 // last probe leaves s at PT_INPUTS(p, T_hi
676 // or T_lo) instead of the HmassP seed.
677 // Restore so callers downstream see the
678 // backward-equation seed (±25 mK floor)
679 // rather than a near-endpoint state.
680 try {
682 } catch (...) { // NOLINT(bugprone-empty-catch)
683 // Seed-restore failed (the original
684 // HmassP at line 362 succeeded, so this
685 // is unexpected); leave state alone and
686 // let downstream handle as a bad cell.
687 }
688 }
689 }
690 }
691 } catch (...) {
692 if (pinned) s.unspecify_phase();
693 throw;
694 }
695 if (pinned) s.unspecify_phase();
696 };
697 } else {
698 spec.update_state = [](::CoolProp::AbstractState& s, double p, double h) {
699 // SurfaceSpec passes (a, b) where a is the primary axis (p)
700 // and b is the secondary axis (h). HmassP_INPUTS takes
701 // (h, p) in that order.
703 };
704 }
706 switch (key) {
708 return s.rhomass();
710 return s.T();
712 return s.smass();
714 return s.umass();
716 return s.speed_sound();
718 return s.viscosity();
720 return s.conductivity();
721 default:
722 throw std::invalid_argument("ph_subcritical: unsupported output property");
723 }
724 };
725
726 return spec;
727}
728
730 const std::size_t NT = opts.NT;
731 const std::size_t NR = opts.NR;
732 const std::int32_t rank = opts.rank;
733 const std::optional<double> p_min_override = opts.p_min;
734 // Entropy-indexed twin of ph_subcritical. Identical region
735 // geometry — LIQUID + VAPOR + SUPER, optional NC_LIQUID/NC_VAPOR/
736 // NC_SUPER near pc, and (for IF97 source) the R2/R3/R5 SUPER split
737 // — with the secondary axis being s instead of h. See
738 // ph_subcritical for the full rationale behind each region; the
739 // comments here are abbreviated to the entropy-specific points.
740 const auto [p_min, p_max_sub_raw] = subcritical_pressure_range(heos, p_min_override);
741 const auto [p_min_sup_raw, p_max_sup] = supercritical_pressure_range(heos);
742 const double T_min_eos = std::max(heos.Ttriple(), heos.Tmin());
743 const double T_max_eos = heos.Tmax();
744 const double pc = heos.p_critical();
745
746 const bool source_is_if97 = (heos.backend_name() == "IF97Backend");
747 constexpr double kNC_p_lo_mult = 0.9;
748 constexpr double kNC_p_hi_mult = 1.0 - 1.0e-10;
749 constexpr double kNC_sup_p_lo_mult = 1.0 + 1.0e-10;
750 constexpr double kNC_sup_p_hi_mult = 1.1;
751 const bool nc_enabled = !source_is_if97;
752 const double p_nc_lo = nc_enabled ? kNC_p_lo_mult * pc : 0.0;
753 const double p_nc_hi = nc_enabled ? kNC_p_hi_mult * pc : 0.0;
754 const double p_nc_sup_lo = nc_enabled ? kNC_sup_p_lo_mult * pc : 0.0;
755 const double p_nc_sup_hi = nc_enabled ? kNC_sup_p_hi_mult * pc : 0.0;
756 const double p_max_sub = nc_enabled ? std::min(p_max_sub_raw, p_nc_lo) : p_max_sub_raw;
757 const double p_min_sup = nc_enabled ? std::max(p_min_sup_raw, p_nc_sup_hi) : p_min_sup_raw;
758
759 SurfaceSpec spec;
760 spec.fluid_name = heos.fluid_names().empty() ? std::string{} : heos.fluid_names().front();
762 spec.NT = NT;
763 spec.NR = NR;
764 spec.rank = rank;
765 spec.properties = ps_properties(heos);
766
767 // LIQUID region: primary = log p, secondary = s in [s(T_min, p), s_sat,L(p)].
768 if (p_min < p_max_sub) {
769 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max_sub);
770 auto lo = build_s_isotherm_floor(heos, p_min, p_max_sub, T_min_eos);
771 auto hi = build_s_sat_L(heos, p_min, p_max_sub);
772 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
773 }
774 // VAPOR region: secondary = s in [s_sat,V(p), s(T_max - 0.5 K, p)].
775 if (p_min < p_max_sub) {
776 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max_sub);
777 auto lo = build_s_sat_V(heos, p_min, p_max_sub);
778 auto hi = build_s_isotherm_ceiling(heos, p_min, p_max_sub, T_max_eos - 0.5);
779 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
780 }
781 // NC_LIQUID + NC_VAPOR: near-critical POWER(β=1/3) sub-regions.
782 if (nc_enabled && p_nc_lo < p_nc_hi) {
783 {
784 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
785 auto lo = build_s_isotherm_floor(heos, p_nc_lo, p_nc_hi, T_min_eos);
786 auto hi = build_s_sat_L(heos, p_nc_lo, p_nc_hi);
787 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
788 }
789 {
790 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
791 auto lo = build_s_sat_V(heos, p_nc_lo, p_nc_hi);
792 auto hi = build_s_isotherm_ceiling(heos, p_nc_lo, p_nc_hi, T_max_eos - 0.5);
793 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
794 }
795 }
796 // NC_SUPER: super-critical near-critical POWER_LO(β=1/3) sub-region.
797 if (nc_enabled && p_nc_sup_lo < p_nc_sup_hi) {
798 auto axis = region::AxisTransform::make(region::AxisScale::POWER_LO, p_nc_sup_lo, p_nc_sup_hi);
799 auto lo = build_s_isotherm_floor(heos, p_nc_sup_lo, p_nc_sup_hi, T_min_eos);
800 auto hi = build_s_isotherm_ceiling(heos, p_nc_sup_lo, p_nc_sup_hi, T_max_eos - 0.5);
801 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
802 }
803 // SUPER region: p > p_crit. For IF97 source, split along the R1/R3
804 // isotherm and the R2/R3 B23 curve (in entropy coords) and add the
805 // R5 high-T slab, exactly as ph_subcritical does in enthalpy coords.
806 constexpr double kP_B23_hi = 100.0e6;
807 if (source_is_if97 && p_min_sup < kP_B23_hi) {
808 const double p_split_hi = std::min(p_max_sup, kP_B23_hi);
809 // SUPER_R1_super: s ∈ [s_floor(p), s_R1R3(p)).
810 {
811 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
812 auto lo = build_s_isotherm_floor(heos, p_min_sup, p_split_hi, T_min_eos);
813 auto hi = build_s_R1R3_curve(heos, p_min_sup, p_split_hi);
814 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
815 }
816 // SUPER_R3_proper: s ∈ [s_R1R3(p), s_B23(p)).
817 {
818 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
819 auto lo = build_s_R1R3_curve(heos, p_min_sup, p_split_hi);
820 auto hi = build_s_B23_curve(heos, p_min_sup, p_split_hi);
821 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
822 }
823 // SUPER_R2: high-s side of the R2/R3 kink.
824 {
825 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_split_hi);
826 auto lo = build_s_B23_curve(heos, p_min_sup, p_split_hi);
827 auto hi = build_s_isotherm_ceiling(heos, p_min_sup, p_split_hi, T_max_eos - 0.5);
828 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
829 }
830 // SUPER_HIGH_P: above the B23 curve's valid p range, one region.
831 if (p_max_sup > kP_B23_hi) {
832 auto axis = region::AxisTransform::make(region::AxisScale::LOG, kP_B23_hi, p_max_sup);
833 auto lo = build_s_isotherm_floor(heos, kP_B23_hi, p_max_sup, T_min_eos);
834 auto hi = build_s_isotherm_ceiling(heos, kP_B23_hi, p_max_sup, T_max_eos - 0.5);
835 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
836 }
837 // SUPER_R5: IAPWS R7-97 Region 5 high-T slab (mirror of the PH
838 // R5 region; see ph_subcritical for the 1073.15 K dispatch note).
839 const double kT_R5_lo = std::nextafter(1073.15, 2273.15);
840 constexpr double kT_R5_hi = 2273.15;
841 constexpr double kP_R5_hi = 50.0e6;
842 constexpr double kP_R5_lo = 700.0;
843 if (kP_R5_lo < kP_R5_hi) {
844 const double p_r5_hi = std::min(p_max_sup, kP_R5_hi);
845 auto axis = region::AxisTransform::make(region::AxisScale::LOG, kP_R5_lo, p_r5_hi);
846 auto lo = build_s_isotherm_floor(heos, kP_R5_lo, p_r5_hi, kT_R5_lo);
847 auto hi = build_s_isotherm_ceiling(heos, kP_R5_lo, p_r5_hi, kT_R5_hi);
848 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
849 }
850 } else if (p_min_sup < p_max_sup) {
851 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_max_sup);
852 auto lo = build_s_isotherm_floor(heos, p_min_sup, p_max_sup, T_min_eos);
853 auto hi = build_s_isotherm_ceiling(heos, p_min_sup, p_max_sup, T_max_eos - 0.5);
854 spec.regions.emplace_back(axis, std::move(lo), std::move(hi));
855 }
856
857 // Thermodynamics glue: PS update + per-property readout. PSmass_INPUTS
858 // takes (p, s) — pressure first, unlike HmassP_INPUTS's (h, p).
859 if (source_is_if97) {
860 spec.update_state = [](::CoolProp::AbstractState& s_state, double p, double s_val) {
861 // Mirror of ph_subcritical's IF97 sampling lambda, with the
862 // entropy backward equation (IF97::T_psmass) as the seed and
863 // a forward-s TOMS748 polish. R3 has no published backward
864 // equation (same as enthalpy); R5 likewise — both fall back
865 // to a bracketed forward solve.
866 //
867 // The R3-vs-R5 fallback dispatch keys on an entropy threshold
868 // (the enthalpy twin uses 3 MJ/kg). Probed from IF97 (Water):
869 // R3's max s along the B23 boundary is ≈ 5254 J/(kg·K); R5's
870 // global min s (50 MPa, 1073.16 K) is ≈ 6523 J/(kg·K). 5.9e3
871 // is the midpoint of that gap — a conservative split. The
872 // bracket sign-check in solve_T_from_s_toms748 is the real
873 // safety net: a mis-routed target simply fails the R5 bracket
874 // and falls through to the R3 solve.
876 bool single_phase = false;
877 bool seeded_via_smass = true;
878 try {
879 s_state.update(::CoolProp::PSmass_INPUTS, p, s_val);
880 phase0 = s_state.phase();
881 single_phase =
884 } catch (...) { // NOLINT(bugprone-empty-catch)
885 seeded_via_smass = false;
886 }
887 bool pinned = false;
888 if (single_phase) {
889 s_state.specify_phase(phase0);
890 pinned = true;
891 }
892 try {
893 if (!seeded_via_smass) {
894 const double kR5_T_lo = std::nextafter(1073.15, 2273.15);
895 constexpr double kR5_T_hi = 2273.15;
896 constexpr double kR5_P_hi = 50.0e6;
897 constexpr double kS_R3_vs_R5_split = 5.9e3;
898 constexpr double kP_B23_lo_Pa = 16.529e6;
899 constexpr double kP_B23_hi_Pa = 100.0e6;
900
901 bool resolved = false;
902 if (s_val >= kS_R3_vs_R5_split && p <= kR5_P_hi) {
903 bool ok = false;
904 const double T_R5 = solve_T_from_s_toms748(s_state, p, s_val, kR5_T_lo, kR5_T_hi, &ok);
905 if (ok) {
906 s_state.update(::CoolProp::PT_INPUTS, p, T_R5);
907 resolved = true;
908 }
909 }
910 if (!resolved) {
911 if (!(p >= kP_B23_lo_Pa && p <= kP_B23_hi_Pa)) {
913 "ps_subcritical IF97 sampling: neither R5 (s<5.9kJ/kg/K or p>50MPa) nor R3 (p outside [16.529, 100] MPa) "
914 "fallback applies — PSmass backward seed failed and no bracket is defined");
915 }
916 const double T_R3_lo = 623.15;
917 const double T_R3_hi = IF97::Region23_p(p);
918 if (!(T_R3_lo < T_R3_hi)) {
919 throw ::CoolProp::ValueError("ps_subcritical IF97 sampling: degenerate R3 bracket (T_B23(p) <= 623.15 K)");
920 }
921 bool ok = false;
922 const double T_R3 = solve_T_from_s_toms748(s_state, p, s_val, T_R3_lo, T_R3_hi, &ok);
923 if (!ok) {
924 throw ::CoolProp::ValueError("ps_subcritical IF97 sampling: R3 TOMS748 missed bracket; cell will be NaN-filled");
925 }
926 s_state.update(::CoolProp::PT_INPUTS, p, T_R3);
927 }
928 } else {
929 // Polish R7-97 backward seed against forward s.
930 const double T_seed = s_state.T();
931 constexpr double kIF97_T_min = 273.16;
932 constexpr double kIF97_T_max = 2273.15;
933 const double T_lo = std::max(T_seed - 0.5, kIF97_T_min);
934 const double T_hi = std::min(T_seed + 0.5, kIF97_T_max);
935 if (T_lo < T_hi) {
936 bool ok = false;
937 const double T_polished = solve_T_from_s_toms748(s_state, p, s_val, T_lo, T_hi, &ok);
938 if (ok) {
939 s_state.update(::CoolProp::PT_INPUTS, p, T_polished);
940 } else {
941 try {
942 s_state.update(::CoolProp::PSmass_INPUTS, p, s_val);
943 } catch (...) { // NOLINT(bugprone-empty-catch)
944 }
945 }
946 }
947 }
948 } catch (...) {
949 if (pinned) s_state.unspecify_phase();
950 throw;
951 }
952 if (pinned) s_state.unspecify_phase();
953 };
954 } else {
955 spec.update_state = [](::CoolProp::AbstractState& s_state, double p, double s_val) {
956 // SurfaceSpec passes (a, b) = (p, s); PSmass_INPUTS takes
957 // (p, s) in that order.
958 s_state.update(::CoolProp::PSmass_INPUTS, p, s_val);
959 };
960 }
961 spec.read_property = [](::CoolProp::AbstractState& s_state, ::CoolProp::parameters key) -> double {
962 switch (key) {
964 return s_state.rhomass();
966 return s_state.T();
968 return s_state.hmass();
970 return s_state.umass();
972 return s_state.speed_sound();
974 return s_state.viscosity();
976 return s_state.conductivity();
977 default:
978 throw std::invalid_argument("ps_subcritical: unsupported output property");
979 }
980 };
981
982 return spec;
983}
984
985// Build a T-floor CubicSplineCurve for any pressure range, walking T
986// up at each knot to clear the melting line if needed. Reused by
987// both the subcritical LIQUID region and the SUPER region of the PT
988// preset.
989namespace {
990std::unique_ptr<region::CubicSplineCurve> pt_T_floor_curve_(::CoolProp::AbstractState& heos, double p_min, double p_max, double T_min_eos,
991 const SatBoundaryBuildOptions& sbopts) {
992 const double log_p_min = std::log(p_min);
993 const double log_p_max = std::log(p_max);
994 std::vector<double> p_knots(sbopts.n_knots);
995 std::vector<double> y(sbopts.n_knots);
996 const std::size_t walk_steps = sbopts.t_floor_walk_steps;
997 for (std::size_t k = 0; k < sbopts.n_knots; ++k) {
998 const double p = std::exp(log_p_min + static_cast<double>(k) * (log_p_max - log_p_min) / static_cast<double>(sbopts.n_knots - 1));
999 p_knots[k] = p;
1000 double T_found = std::nan("");
1001 for (std::size_t s = 0; s < walk_steps; ++s) {
1002 const double T_try = T_min_eos + 0.5 * static_cast<double>(s);
1003 try {
1004 heos.update(::CoolProp::PT_INPUTS, p, T_try);
1005 T_found = T_try;
1006 break;
1007 } catch (const CoolProp::CoolPropBaseError&) { // NOLINT(bugprone-empty-catch)
1008 // Expected: below the melting line at this p, or
1009 // otherwise outside the HEOS envelope. Bump T up and
1010 // retry; we don't swallow std::exception broadly to
1011 // avoid masking real bugs (allocator failures,
1012 // numeric_cast overflows, etc.).
1013 }
1014 }
1015 if (!std::isfinite(T_found)) {
1016 throw std::runtime_error("pt preset: T-floor unreachable within " + std::to_string(walk_steps / 2) + " K of T_min_eos");
1017 }
1018 y[k] = T_found;
1019 }
1020 return region::CubicSplineCurve::build(std::move(p_knots), std::move(y));
1021}
1022} // namespace
1023
1025 const std::size_t NT = opts.NT;
1026 const std::size_t NR = opts.NR;
1027 const std::int32_t rank = opts.rank;
1028 const std::optional<double> p_min_override = opts.p_min;
1029 // Historical name; now covers the full phase diagram via three
1030 // regions: LIQUID + VAPOR (subcritical) + SUPER (p > p_crit), plus
1031 // an optional NC_LIQUID/NC_VAPOR pair near pc when source is
1032 // HEOS/REFPROP (CoolProp-4u9).
1033 const auto [p_min, p_max_raw] = subcritical_pressure_range(heos, p_min_override);
1034 const auto [p_min_sup_raw, p_max_sup] = supercritical_pressure_range(heos);
1035 const double T_min_eos = std::max(heos.Ttriple(), heos.Tmin());
1036 const double T_max_eos = heos.Tmax();
1037 const double pc = heos.p_critical();
1038
1039 // Near-critical sub-region geometry (mirrors ph_subcritical above).
1040 const bool source_is_if97 = (heos.backend_name() == "IF97Backend");
1041 constexpr double kNC_p_lo_mult = 0.9;
1042 constexpr double kNC_p_hi_mult = 1.0 - 1.0e-10;
1043 constexpr double kNC_sup_p_lo_mult = 1.0 + 1.0e-10;
1044 constexpr double kNC_sup_p_hi_mult = 1.1;
1045 const bool nc_enabled = !source_is_if97;
1046 const double p_nc_lo = nc_enabled ? kNC_p_lo_mult * pc : 0.0;
1047 const double p_nc_hi = nc_enabled ? kNC_p_hi_mult * pc : 0.0;
1048 const double p_nc_sup_lo = nc_enabled ? kNC_sup_p_lo_mult * pc : 0.0;
1049 const double p_nc_sup_hi = nc_enabled ? kNC_sup_p_hi_mult * pc : 0.0;
1050 const double p_max = nc_enabled ? std::min(p_max_raw, p_nc_lo) : p_max_raw;
1051 const double p_min_sup = nc_enabled ? std::max(p_min_sup_raw, p_nc_sup_hi) : p_min_sup_raw;
1052
1053 SurfaceSpec spec;
1054 spec.fluid_name = heos.fluid_names().empty() ? std::string{} : heos.fluid_names().front();
1056 spec.NT = NT;
1057 spec.NR = NR;
1058 spec.rank = rank;
1059 spec.properties = pt_properties(heos);
1060
1061 // For PT_INPUTS the secondary axis is T (instead of h for PH).
1062 // The b_lo / b_hi curves return T values, not h values. Reuse
1063 // SatBoundaryFactory's T-side curves: T_sat(p) is the dome,
1064 // bracketed by [T_min(p), T_sat(p)] for LIQUID and [T_sat(p),
1065 // T_max(p)] for VAPOR. T_min(p) walks up over a melting line
1066 // exactly like the h-floor case.
1067
1068 // LIQUID region. T-floor walks T_min(p) up over the melting
1069 // line. Helper handles the knot loop so the SUPER region below
1070 // can reuse it.
1071 if (p_min < p_max) {
1072 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max);
1073 auto t_lo = pt_T_floor_curve_(heos, p_min, p_max, T_min_eos, SatBoundaryBuildOptions{});
1074 auto t_hi = build_T_sat(heos, p_min, p_max);
1075 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1076 }
1077 // VAPOR region.
1078 if (p_min < p_max) {
1079 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min, p_max);
1080 auto t_lo = build_T_sat(heos, p_min, p_max);
1081 // Constant T_max ceiling — no melting consideration on the
1082 // hot side, so just a flat isotherm.
1083 auto t_hi = std::make_unique<region::ConstantCurve>(p_min, p_max, T_max_eos - 0.5);
1084 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1085 }
1086 // NC_LIQUID + NC_VAPOR (CoolProp-4u9): near-critical sub-regions on
1087 // p ∈ [0.9·pc, (1 − 1e-10)·pc] with POWER(β=1/3) primary axis.
1088 // The 1e-10 gap below pc is sub-ULP for practical p — anything
1089 // closer falls inside the critical patch's auto-cal'd bbox.
1090 if (nc_enabled && p_nc_lo < p_nc_hi) {
1091 {
1092 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
1093 auto t_lo = pt_T_floor_curve_(heos, p_nc_lo, p_nc_hi, T_min_eos, SatBoundaryBuildOptions{});
1094 auto t_hi = build_T_sat(heos, p_nc_lo, p_nc_hi);
1095 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1096 }
1097 {
1098 auto axis = region::AxisTransform::make(region::AxisScale::POWER, p_nc_lo, p_nc_hi);
1099 auto t_lo = build_T_sat(heos, p_nc_lo, p_nc_hi);
1100 auto t_hi = std::make_unique<region::ConstantCurve>(p_nc_lo, p_nc_hi, T_max_eos - 0.5);
1101 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1102 }
1103 }
1104 // NC_SUPER (CoolProp-4u9): super-critical near-critical sub-region
1105 // on p ∈ [(1 + 1e-10)·pc, 1.1·pc] with POWER_LO(β=1/3) primary
1106 // axis crowding toward pc. Single region (no sat dome above pc);
1107 // b_lo / b_hi are the T-floor / constant T_max-0.5 ceiling.
1108 if (nc_enabled && p_nc_sup_lo < p_nc_sup_hi) {
1109 auto axis = region::AxisTransform::make(region::AxisScale::POWER_LO, p_nc_sup_lo, p_nc_sup_hi);
1110 auto t_lo = pt_T_floor_curve_(heos, p_nc_sup_lo, p_nc_sup_hi, T_min_eos, SatBoundaryBuildOptions{});
1111 auto t_hi = std::make_unique<region::ConstantCurve>(p_nc_sup_lo, p_nc_sup_hi, T_max_eos - 0.5);
1112 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1113 }
1114 // SUPER region: p > p_crit (clipped up to 1.1·pc when NC enabled
1115 // so NC_SUPER owns the strip just above pc). T-floor still walks
1116 // T_min up over melting line (the melting curve extends well into
1117 // supercritical for most fluids). Hot ceiling stays at T_max-0.5 K.
1118 if (p_min_sup < p_max_sup) {
1119 auto axis = region::AxisTransform::make(region::AxisScale::LOG, p_min_sup, p_max_sup);
1120 auto t_lo = pt_T_floor_curve_(heos, p_min_sup, p_max_sup, T_min_eos, SatBoundaryBuildOptions{});
1121 auto t_hi = std::make_unique<region::ConstantCurve>(p_min_sup, p_max_sup, T_max_eos - 0.5);
1122 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1123 }
1124 // SUPER_R5: IAPWS R7-97 Region 5 — extended-T high-temperature
1125 // slab. T ∈ [1073.15, 2273.15] K, p ∈ (0, 50] MPa. Closes
1126 // CoolProp-pd6. Mirror of the SUPER_R5 region in ph_subcritical.
1127 // IF97-only because R5 has no HEOS counterpart in CoolProp for
1128 // water (Wagner & Pruss covers 273-1273 K). Constant-T b_lo /
1129 // b_hi: R5 is a flat T-slab, no melting curve consideration.
1130 if (source_is_if97) {
1131 // Nudge above 1073.15 because IF97's RegionDetermination_TP
1132 // dispatches T == 1073.15 K to R2 (the boundary uses
1133 // T <= Tmax strictly). Sampling h at T=1073.15 would build
1134 // the SVD on R2's reference frame, then runtime queries that
1135 // bracket at T=1073.15 would converge to R2 instead of R5
1136 // (R2 and R5 use independent ideal-gas references; no
1137 // IAPWS R7-97 smoothness condition at the boundary). One
1138 // ULP above 1073.15 puts the sample and the runtime bracket
1139 // both squarely in R5 territory. Found by code-reviewer
1140 // adversarial pass.
1141 const double kT_R5_lo = std::nextafter(1073.15, 2273.15);
1142 constexpr double kT_R5_hi = 2273.15;
1143 constexpr double kP_R5_hi = 50.0e6;
1144 // Same kP_R5_lo as ph_subcritical — 700 Pa is just above
1145 // IF97's documented Pmin = 611.213 Pa (sat-p at T_min).
1146 constexpr double kP_R5_lo = 700.0;
1147 if (kP_R5_lo < kP_R5_hi) {
1148 const double p_r5_hi = std::min(p_max_sup, kP_R5_hi);
1149 auto axis = region::AxisTransform::make(region::AxisScale::LOG, kP_R5_lo, p_r5_hi);
1150 auto t_lo = std::make_unique<region::ConstantCurve>(kP_R5_lo, p_r5_hi, kT_R5_lo);
1151 auto t_hi = std::make_unique<region::ConstantCurve>(kP_R5_lo, p_r5_hi, kT_R5_hi);
1152 spec.regions.emplace_back(axis, std::move(t_lo), std::move(t_hi));
1153 }
1154 }
1155
1156 // Thermodynamics glue: PT update + per-property readout.
1157 spec.update_state = [](::CoolProp::AbstractState& s, double p, double T) { s.update(::CoolProp::PT_INPUTS, p, T); };
1159 switch (key) {
1161 return s.rhomass();
1163 return s.hmass();
1165 return s.smass();
1167 return s.umass();
1169 return s.speed_sound();
1171 return s.viscosity();
1173 return s.conductivity();
1174 default:
1175 throw std::invalid_argument("pt_subcritical: unsupported output property");
1176 }
1177 };
1178
1179 return spec;
1180}
1181
1182namespace {
1183// Shared property list for DmassT_INPUTS: (D, T) inputs, so outputs
1184// are p, h, s, u, w (speed of sound). Pressure rides EXP since it
1185// can span ~10 orders of magnitude across the (D, T) envelope (from
1186// rarified vapor at low T to compressed liquid at high p_max_eos).
1187std::vector<PropertySpec> dt_properties(::CoolProp::AbstractState& src) {
1188 std::vector<PropertySpec> ps = {
1194 };
1195 if (source_supports_transport_(src, ::CoolProp::iviscosity)) {
1197 }
1198 if (source_supports_transport_(src, ::CoolProp::iconductivity)) {
1200 }
1201 return ps;
1202}
1203
1204// Per-knot D upper-bound: at each T knot, find the max pressure ≤
1205// p_target that HEOS accepts (binary-search down if p_target itself
1206// is rejected by the melting line at this T) and return D at that
1207// (T, p). Result is a CubicSplineCurve representing the "compressed-
1208// liquid / supercritical-dense ceiling".
1209//
1210// Why per-knot rather than fixed isobar: the melting curve cuts into
1211// the high-p part of the (T, p) envelope for steep-melting fluids
1212// (CO2 T_melt(0.8 GPa) ≈ 327 K > Tc = 304 K), so a single
1213// p_max_eos isobar isn't defined at the cold end of LIQUID or SUPER.
1214// Per-knot walk-down gives the actual reachable D extent — narrower
1215// at cold T (where melting limits p), wider at hot T. The resulting
1216// curve is monotonically non-decreasing in T-region-of-interest and
1217// always above ρ_sat,L (since p_target > p_sat for liquid-side
1218// queries).
1219//
1220// p_target is the EOS-validity ceiling (≤ 0.99 * pmax); p_floor is
1221// the minimum acceptable pressure (typically ≥ pc / 2 or sat pressure
1222// at T to ensure the curve stays above the dome).
1223// Per-knot floor source: 'sat_L' anchors the walk-down at saturation
1224// pressure (Q=0 PQ flash), used for LIQUID-side envelopes that must
1225// stay above the sat dome. 'critical' uses p_critical, used for
1226// SUPER-side envelopes that should stay supercritical.
1227enum class WalkFloor : std::uint8_t
1228{
1229 SAT_L,
1230 CRITICAL,
1231};
1232
1233std::unique_ptr<region::CubicSplineCurve> build_rho_max_envelope(::CoolProp::AbstractState& heos, double T_min, double T_max, double p_target,
1234 WalkFloor floor_source, std::size_t n_knots = 64) {
1235 if (!(T_min > 0.0) || !(T_max > T_min) || n_knots < 2) {
1236 throw std::invalid_argument("build_rho_max_envelope: invalid T range or n_knots");
1237 }
1238 std::vector<double> T_knots(n_knots);
1239 std::vector<double> y(n_knots);
1240 const double dT = (T_max - T_min) / static_cast<double>(n_knots - 1);
1241 const double p_crit = heos.p_critical();
1242 constexpr int kMaxHalvings = 50;
1243 for (std::size_t k = 0; k < n_knots; ++k) {
1244 T_knots[k] = T_min + static_cast<double>(k) * dT;
1245 // Per-knot floor: just above sat pressure for LIQUID-side
1246 // (don't cross the dome into vapor) or just above pc for
1247 // SUPER-side (stay supercritical).
1248 double p_floor = 0.0;
1249 if (floor_source == WalkFloor::SAT_L) {
1250 try {
1251 heos.update(::CoolProp::QT_INPUTS, 0.0, T_knots[k]);
1252 p_floor = heos.p() * 1.001;
1253 } catch (const ::CoolProp::CoolPropBaseError&) {
1254 p_floor = 1.0; // fluid without QT support at this T; defer to walk-down
1255 heos.clear(); // reset the poisoned density guess (CoolProp-i9t8)
1256 }
1257 } else {
1258 p_floor = 1.001 * p_crit;
1259 }
1260 // Find the HIGHEST admissible pressure ≤ p_target at this T (the
1261 // melting line caps it for steep-melting fluids like CO2). Walk
1262 // down from p_target to bracket a reachable pressure, then bisect
1263 // upward: stopping at the first successful halving would freeze
1264 // the envelope at ≤ 0.5·p_target and clip valid dense single-
1265 // phase DT states near the real HEOS limit.
1266 double p_try = std::max(p_target, p_floor);
1267 double rho_found = std::nan("");
1268 double p_ok = std::nan(""); // highest known-reachable p
1269 double p_bad = std::nan(""); // lowest known-unreachable p (> p_ok)
1270 for (int i = 0; i < kMaxHalvings; ++i) {
1271 bool ok = false;
1272 try {
1273 heos.update(::CoolProp::PT_INPUTS, p_try, T_knots[k]);
1274 const double rho = heos.rhomass();
1275 if (std::isfinite(rho)) {
1276 rho_found = rho;
1277 p_ok = p_try;
1278 ok = true;
1279 }
1280 } catch (const ::CoolProp::CoolPropBaseError&) {
1281 // Below the melting line at this (T, p); drop p. Reset the
1282 // state: a failed PT update (e.g. the melting-line reject at
1283 // high p) leaves a corrupted density guess that would make
1284 // the NEXT, valid, lower-p probe fail spuriously with
1285 // "p is not a valid number" on the same reused state
1286 // (CoolProp-i9t8).
1287 heos.clear();
1288 }
1289 if (ok) break;
1290 p_bad = p_try; // this p is unreachable
1291 if (p_try <= p_floor) break;
1292 p_try = std::max(0.5 * p_try, p_floor);
1293 }
1294 // If p_target itself was unreachable and we backed off to a lower
1295 // p_ok, bisect (p_ok, p_bad) to recover the pressure ceiling we
1296 // skipped past, maximizing rho_found.
1297 if (std::isfinite(p_ok) && std::isfinite(p_bad) && p_bad > p_ok) {
1298 for (int i = 0; i < kMaxHalvings; ++i) {
1299 if ((p_bad - p_ok) <= 1e-6 * p_bad) break;
1300 const double p_mid = 0.5 * (p_ok + p_bad);
1301 bool ok = false;
1302 try {
1303 heos.update(::CoolProp::PT_INPUTS, p_mid, T_knots[k]);
1304 const double rho = heos.rhomass();
1305 if (std::isfinite(rho)) {
1306 rho_found = rho;
1307 p_ok = p_mid;
1308 ok = true;
1309 }
1310 } catch (const ::CoolProp::CoolPropBaseError&) {
1311 // (T, p_mid) below the melting line; shrink the bracket.
1312 heos.clear(); // reset poisoned guess (CoolProp-i9t8)
1313 }
1314 if (!ok) p_bad = p_mid;
1315 }
1316 }
1317 if (!std::isfinite(rho_found)) {
1318 throw std::runtime_error("build_rho_max_envelope: no reachable p ∈ [" + std::to_string(p_floor) + ", " + std::to_string(p_target)
1319 + "] at T=" + std::to_string(T_knots[k]));
1320 }
1321 y[k] = rho_found;
1322 }
1323 return region::CubicSplineCurve::build(std::move(T_knots), std::move(y));
1324}
1325
1326// Per-knot D lower-bound: D at (T, p_target) where p_target is a
1327// low-pressure isobar. No walk-down needed here — low p is always
1328// reachable for the supported T range (vapor side; ideal-gas limit).
1329// p_target should be ≤ p_triple for safety.
1330std::unique_ptr<region::CubicSplineCurve> build_rho_min_envelope(::CoolProp::AbstractState& heos, double T_min, double T_max, double p_target,
1331 std::size_t n_knots = 64) {
1332 if (!(T_min > 0.0) || !(T_max > T_min) || n_knots < 2) {
1333 throw std::invalid_argument("build_rho_min_envelope: invalid T range or n_knots");
1334 }
1335 std::vector<double> T_knots(n_knots);
1336 std::vector<double> y(n_knots);
1337 const double dT = (T_max - T_min) / static_cast<double>(n_knots - 1);
1338 for (std::size_t k = 0; k < n_knots; ++k) {
1339 T_knots[k] = T_min + static_cast<double>(k) * dT;
1340 try {
1341 heos.update(::CoolProp::PT_INPUTS, p_target, T_knots[k]);
1342 y[k] = heos.rhomass();
1343 } catch (const ::CoolProp::CoolPropBaseError& e) {
1344 throw std::runtime_error("build_rho_min_envelope: HEOS rejected (T=" + std::to_string(T_knots[k]) + ", p=" + std::to_string(p_target)
1345 + "): " + e.what());
1346 }
1347 if (!std::isfinite(y[k])) {
1348 throw std::runtime_error("build_rho_min_envelope: non-finite rho at (T=" + std::to_string(T_knots[k]) + ", p=" + std::to_string(p_target)
1349 + ")");
1350 }
1351 }
1352 return region::CubicSplineCurve::build(std::move(T_knots), std::move(y));
1353}
1354} // namespace
1355
1357 // DmassT is temperature-indexed, so opts.p_min (the pressure floor)
1358 // does not apply here and is intentionally unused.
1359 const std::size_t NT = opts.NT;
1360 const std::size_t NR = opts.NR;
1361 const std::int32_t rank = opts.rank;
1362 // DT-indexed surface — closes CoolProp-i7j and supersedes the
1363 // BICUBIC invert_single_phase_y patch attempt at #2892 / #1301.
1364 //
1365 // (D, T) is the Helmholtz EOS's native coordinate, so every output
1366 // property (p, h, s, u, w) is a direct evaluation at table-build
1367 // time. No inversion, no critical-region stiffness, no "cells
1368 // with -760 MPa" failure modes.
1369 //
1370 // Three primary regions: LIQUID + VAPOR (sub-critical, dome-
1371 // bounded) and SUPER (T > T_critical, EOS-validity bounded).
1372 // Anomaly handling: when rho_sat,L(T) has interior extrema (water
1373 // ~277 K, heavy water ~284 K), LIQUID is split into N+1 sub-
1374 // regions, each spanning one monotonic piece of rho_sat,L(T).
1375 const double T_triple = heos.Ttriple();
1376 const double T_min_eos = std::max(T_triple, heos.Tmin());
1377 const double T_max_eos = heos.Tmax();
1378 const double Tc = heos.T_critical();
1379
1380 // Sub-critical T range with margins away from triple / critical.
1381 const double T_sub_lo = std::max(T_min_eos, T_triple * 1.001);
1382 const double T_sub_hi = Tc * 0.99; // parent (LINEAR) -> NC (POWER) handoff
1383 // Super-critical T range with margin above critical / below T_max.
1384 const double T_sup_lo = Tc * 1.01; // NC (POWER_LO) -> parent (LINEAR) handoff
1385 const double T_sup_hi = T_max_eos - 0.5;
1386 // NC (near-critical) T sub-band, carried by POWER/POWER_LO regions
1387 // that crowd grid lines toward Tc. The dome-bounded subcritical NC
1388 // stops a hair below Tc (extending the saturation-boundary regions to
1389 // exactly Tc makes the dome degenerate and the SVD build throws for
1390 // some fluids, e.g. Argon). The isobar-bounded "supercritical" NC
1391 // region then extends DOWN across Tc from the same handoff: it has no
1392 // saturation boundary, so the dome sits inside it as a lever-rule-
1393 // routed NaN hole, and it covers the critical isotherm at ALL
1394 // densities — closing the gap with no degenerate-boundary build.
1395 // Handoff sits 1 microK below Tc, NOT at Tc*(1-1e-9): for Helium the
1396 // HEOS PT density solver fails within ~1 nanoK of Tc (critical-region
1397 // stiffness) and a failed PT update poisons the shared state's density
1398 // guess, which would abort the envelope build. 1e-6 clears both; the
1399 // isobar NC region still spans Tc, so the critical isotherm is covered
1400 // by interpolation.
1401 const double T_nc_sub_hi = Tc * (1.0 - 1.0e-6);
1402 const double T_nc_sup_lo = T_nc_sub_hi;
1403 // (Historical note, CoolProp-4z79: the ±0.1%·Tc band used to be left
1404 // uncovered — the dome-bounded LIQUID/VAPOR regions can't extend to Tc
1405 // because ρ_sat,L/ρ_sat,V pinch to ρ_c and the η-normalisation
1406 // degenerates. The NC regions below now close it: POWER/POWER_LO
1407 // sub-regions carry the dome-bounded sides up to ~Tc, and an
1408 // isobar-bounded NC_SUPER region spans Tc itself with the dome as an
1409 // internal lever-rule hole. No DmassT critical-patch / HEOS fallback
1410 // is needed.)
1411
1412 // p envelope for the non-dome D extents. Sub-critical fluids
1413 // have well-defined p_triple from PQ flash at T_triple; use that
1414 // as the low-pressure floor for the vapor / super low-D side.
1415 // p_max_target = 0.99 * heos.pmax() is the EOS-validity ceiling
1416 // — the build_rho_max_envelope helper handles the per-knot
1417 // walk-down to clear the melting line where it cuts into high-p.
1418 heos.update(::CoolProp::QT_INPUTS, 0.0, T_triple * 1.001);
1419 // p_min_eos must stay >= p_triple regardless of region. Below
1420 // p_triple HEOS's PT-input path invokes the melting-curve check
1421 // (the curve's domain starts at p_triple), which throws on
1422 // every fluid with a polynomial-in-Θ melting curve (CO2, water,
1423 // most cryogenic fluids). The thrown error is the same whether
1424 // T is sub- or super-critical, so SUPER's low-D bound is
1425 // similarly constrained. Low-D ideal-gas territory (ρ < ρ_at_
1426 // (T, p_triple)) is NOT covered by this preset's atlas; queries
1427 // there should be added in a follow-up that handles the ideal-
1428 // gas extension (file when needed).
1429 const double p_min_eos = heos.p() * 1.01;
1430 const double p_max_target = 0.99 * heos.pmax();
1431
1432 // Detect rho_sat,L(T) extrema and build a sorted list of LIQUID
1433 // sub-region breakpoints: [T_sub_lo, extrema..., T_sub_hi].
1434 std::vector<double> T_breaks = {T_sub_lo};
1435 for (double T_anom : find_rho_satL_extrema_T(heos, T_sub_lo, T_sub_hi)) {
1436 T_breaks.push_back(T_anom);
1437 }
1438 T_breaks.push_back(T_sub_hi);
1439
1440 SurfaceSpec spec;
1441 spec.fluid_name = heos.fluid_names().empty() ? std::string{} : heos.fluid_names().front();
1443 spec.NT = NT;
1444 spec.NR = NR;
1445 spec.rank = rank;
1446 spec.properties = dt_properties(heos);
1447
1448 // LIQUID sub-region(s). Primary = T (LINEAR — T span is at most
1449 // factor ~3 from triple to critical, no orders-of-magnitude
1450 // variation that would motivate log scaling). Secondary = D in
1451 // [rho_sat,L(T), rho_at(T, p_max_eos)]. Each sub-region spans
1452 // ONE monotonic piece of rho_sat,L(T), so the CubicSplineCurve
1453 // fits cleanly without the anomaly's hairpin.
1454 for (std::size_t i = 0; i + 1 < T_breaks.size(); ++i) {
1455 const double T_lo = T_breaks[i];
1456 const double T_hi = T_breaks[i + 1];
1457 if (!(T_hi > T_lo)) continue;
1459 auto b_lo = build_rho_sat_L(heos, T_lo, T_hi); // dome side
1460 auto b_hi = build_rho_max_envelope(heos, T_lo, T_hi, p_max_target, WalkFloor::SAT_L); // compressed-liquid ceiling
1461 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi));
1462 }
1463 // VAPOR. Secondary = D in [rho_at(T, p_min_eos), rho_sat,V(T)].
1464 // No anomaly handling needed — rho_sat,V is monotone in T for
1465 // every known fluid (increases from triple-vapor toward critical
1466 // density as T → Tc).
1467 if (T_sub_hi > T_sub_lo) {
1468 auto axis = region::AxisTransform::make(region::AxisScale::LINEAR, T_sub_lo, T_sub_hi);
1469 auto b_lo = build_rho_min_envelope(heos, T_sub_lo, T_sub_hi, p_min_eos);
1470 auto b_hi = build_rho_sat_V(heos, T_sub_lo, T_sub_hi);
1471 // LOG secondary: rho spans from the near-ideal-gas floor
1472 // rho(T, p_min_eos) up to rho_sat,V — orders of magnitude at low
1473 // T — and p ∝ rho there. Linear-eta strands that tail below the
1474 // first grid node (CoolProp-wvtz); log-eta resolves it.
1475 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi), region::AxisScale::LOG);
1476 }
1477 // SUPER (T > T_critical). Secondary D bounded by the two HEOS-
1478 // validity isobars. No dome above Tc — single region across the
1479 // full D extent. This is the region that fixes yufang67's #1301
1480 // CO2 supercritical low-density failure: HEOS at low D / high T
1481 // evaluates directly (P = ρ²(∂α/∂ρ)RT), no inverter, no
1482 // rectangular bands, no -760 MPa cells.
1483 if (T_sup_hi > T_sup_lo) {
1484 // LOG primary-T axis (CoolProp-jh6a). The supercritical T range is
1485 // [1.01*Tc, Tmax]; for low-Tc fluids that ratio is huge (Helium
1486 // Tc=5.2 K, Tmax=2000 K -> ~380x), and a LINEAR axis then starves
1487 // the near-Tc supercritical zone (where properties vary fastest) of
1488 // grid lines relative to the vast hot tail -> ~1% pressure error
1489 // across Helium's whole SUPER region. LOG distributes grid lines
1490 // per-decade and drops that to ~1e-8. For modest-ratio fluids
1491 // (Water ~3x, CO2 ~6x) LOG ~ LINEAR, so it is a no-op there.
1492 auto axis = region::AxisTransform::make(region::AxisScale::LOG, T_sup_lo, T_sup_hi);
1493 auto b_lo = build_rho_min_envelope(heos, T_sup_lo, T_sup_hi, p_min_eos);
1494 auto b_hi = build_rho_max_envelope(heos, T_sup_lo, T_sup_hi, p_max_target, WalkFloor::CRITICAL);
1495 // LOG secondary: rho_hi/rho_lo ~ 1e5 here (ideal-gas floor to
1496 // compressed-supercritical ceiling) with p ∝ rho in the tail.
1497 // This is the region whose low-rho edge dominated the DT
1498 // validation error band (CoolProp-wvtz).
1499 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi), region::AxisScale::LOG);
1500 }
1501 // NC (near-critical) sub-regions (CoolProp-4z79). These carry the
1502 // *table* through [0.99, 1.01]*Tc — the band the parent LINEAR regions
1503 // leave as a gap — using a POWER/POWER_LO primary T axis that crowds
1504 // grid lines toward Tc, the direct analog of CoolProp-4u9's PH/PT NC
1505 // regions. The POWER(beta=1/3) crowding resolves the rho_sat
1506 // boundary's vertical tangent (drho_sat/dT -> inf as T->Tc): a plain
1507 // LINEAR extension of the parent regions hits 2-5% error there, POWER
1508 // holds ~1e-6. No critical patch / HEOS fallback is needed — the
1509 // surrogate covers the whole band, including the critical isotherm, to
1510 // ~1e-6..2e-5 (well inside the IAPWS 200 ppm budget) for all fluids.
1511 //
1512 // NC_LIQUID: [T_sub_hi, ~Tc), POWER (crowd toward a_hi=Tc). Dome-side
1513 // boundary rho_sat,L; stops a microK below Tc (see T_nc_sub_hi).
1514 if (T_nc_sub_hi > T_sub_hi) {
1515 auto axis = region::AxisTransform::make(region::AxisScale::POWER, T_sub_hi, T_nc_sub_hi);
1516 auto b_lo = build_rho_sat_L(heos, T_sub_hi, T_nc_sub_hi);
1517 auto b_hi = build_rho_max_envelope(heos, T_sub_hi, T_nc_sub_hi, p_max_target, WalkFloor::SAT_L);
1518 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi), region::AxisScale::LOG);
1519 }
1520 // NC_VAPOR: [T_sub_hi, ~Tc), POWER.
1521 if (T_nc_sub_hi > T_sub_hi) {
1522 auto axis = region::AxisTransform::make(region::AxisScale::POWER, T_sub_hi, T_nc_sub_hi);
1523 auto b_lo = build_rho_min_envelope(heos, T_sub_hi, T_nc_sub_hi, p_min_eos);
1524 auto b_hi = build_rho_sat_V(heos, T_sub_hi, T_nc_sub_hi);
1525 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi), region::AxisScale::LOG);
1526 }
1527 // NC_SUPER: [~Tc, T_sup_lo], POWER_LO (crowd toward a_lo=Tc). This is
1528 // the region that closes the gap AT the critical isotherm: it is
1529 // bounded by the two HEOS-validity isobars (NOT the saturation curve),
1530 // so it spans from a microK below Tc up across Tc to the parent SUPER
1531 // handoff, covering every density. The two-phase dome that lives in
1532 // its lower (sub-Tc) sliver is an internal NaN hole, routed by the
1533 // lever rule before the surface is ever consulted — so extending an
1534 // isobar-bounded region across Tc never touches the degenerate dome
1535 // boundary that makes a saturation-bounded region throw at Tc.
1536 if (T_sup_lo > T_nc_sup_lo) {
1537 auto axis = region::AxisTransform::make(region::AxisScale::POWER_LO, T_nc_sup_lo, T_sup_lo);
1538 auto b_lo = build_rho_min_envelope(heos, T_nc_sup_lo, T_sup_lo, p_min_eos);
1539 auto b_hi = build_rho_max_envelope(heos, T_nc_sup_lo, T_sup_lo, p_max_target, WalkFloor::CRITICAL);
1540 spec.regions.emplace_back(axis, std::move(b_lo), std::move(b_hi), region::AxisScale::LOG);
1541 }
1542
1543 spec.update_state = [](::CoolProp::AbstractState& s, double T, double D) {
1544 // SurfaceSpec passes (a, b) where a is primary (T) and b is
1545 // secondary (D). DmassT_INPUTS takes (D, T) in that order.
1547 };
1549 switch (key) {
1551 return s.p();
1553 return s.hmass();
1555 return s.smass();
1557 return s.umass();
1559 return s.speed_sound();
1561 return s.viscosity();
1563 return s.conductivity();
1564 default:
1565 throw std::invalid_argument("dt_subcritical: unsupported output property");
1566 }
1567 };
1568
1569 return spec;
1570}
1571
1572} // namespace presets
1573} // namespace sbtl
1574} // namespace CoolProp