CoolProp 8.0.0
An open-source fluid property and humid air property database
SatBoundaryFactory.cpp
Go to the documentation of this file.
2
3#include <algorithm> // std::sort / std::min / std::max in find_rho_satL_extrema_T
4#include <cmath>
5#include <functional>
6#include <stdexcept>
7#include <vector>
8
9#include "boost/math/tools/toms748_solve.hpp"
10
12#include "CoolProp/Exceptions.h"
17
18namespace CoolProp {
19namespace sbtl {
20
21namespace {
22
23// Try to pull a SuperAncillary handle off the source AbstractState.
24// Returns null when the source isn't a HelmholtzEOSMixtureBackend
25// (e.g. IF97, REFPROP) or when the fluid's HEOS model doesn't ship a
26// SuperAncillary expansion (some pseudo-pure fluids / mixtures).
27// Side effect: triggers caloric superancillary lazy-build so the
28// downstream eval_sat('H', ...) calls don't pay that cost per-eval.
29std::shared_ptr<region::SuperancillaryBoundaryCurve::SuperAncillary_t> try_get_superanc(::CoolProp::AbstractState& src, bool need_caloric) {
30 auto* helmholtz = dynamic_cast<::CoolProp::HelmholtzEOSMixtureBackend*>(&src);
31 if (helmholtz == nullptr) return nullptr;
32 std::shared_ptr<region::SuperancillaryBoundaryCurve::SuperAncillary_t> sa;
33 try {
34 sa = helmholtz->get_superanc();
35 } catch (...) { // NOLINT(bugprone-empty-catch)
36 // get_superanc throws for mixtures / pseudo-pures. Treat as
37 // "no SA available" and fall through to the spline path.
38 }
39 if (!sa) return nullptr;
40 if (need_caloric) {
41 try {
42 helmholtz->ensure_caloric_superancillaries();
43 } catch (...) { // NOLINT(bugprone-empty-catch)
44 // Caloric build failed (rare: superanc rejection). Keep
45 // the SA handle for non-caloric properties but the caller
46 // will null-check on the property key.
47 return nullptr;
48 }
49 }
50 return sa;
51}
52
53// Sample `f(p)` at `n_knots` log-uniform points in [p_min, p_max] and
54// build a CubicSplineCurve through the resulting (p, f(p)) knots.
55// This is the common skeleton every factory in this file shares;
56// extracting it once keeps the per-factory code 4–5 lines.
57std::unique_ptr<region::CubicSplineCurve> spline_through_log_p_samples(double p_min, double p_max, std::size_t n_knots,
58 const std::function<double(double)>& f) {
59 // p_min must be strictly positive for log-space sampling; otherwise
60 // std::log(p_min) silently feeds -inf into the spline knots and we'd
61 // cascade into hard-to-debug downstream failures.
62 if (!(p_min > 0.0) || !(p_max > p_min) || n_knots < 2) {
63 throw std::invalid_argument("SatBoundaryFactory: invalid p range or n_knots (need 0 < p_min < p_max and n_knots >= 2)");
64 }
65 std::vector<double> p_knots(n_knots);
66 std::vector<double> y(n_knots);
67 const double log_p_min = std::log(p_min);
68 const double log_p_max = std::log(p_max);
69 for (std::size_t k = 0; k < n_knots; ++k) {
70 const double log_p = log_p_min + static_cast<double>(k) * (log_p_max - log_p_min) / static_cast<double>(n_knots - 1);
71 p_knots[k] = std::exp(log_p);
72 y[k] = f(p_knots[k]);
73 }
74 return region::CubicSplineCurve::build(std::move(p_knots), std::move(y));
75}
76
77} // namespace
78
79std::unique_ptr<region::BoundaryCurve> build_h_sat_L(::CoolProp::AbstractState& heos, double p_min, double p_max,
80 const SatBoundaryBuildOptions& opts) {
81 if (auto sa = try_get_superanc(heos, /*need_caloric=*/true)) {
82 try {
83 // SuperAncillary returns molar enthalpy (J/mol); SBTL
84 // stores mass-basis (J/kg). h_mass = h_mol / M.
85 const double inv_M = 1.0 / heos.molar_mass();
86 return region::SuperancillaryBoundaryCurve::build(sa, p_min, p_max, 'H', 0, inv_M);
87 } catch (...) { // NOLINT(bugprone-empty-catch)
88 // Fall through to the spline path on SA range mismatch.
89 }
90 }
91 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
92 heos.update(::CoolProp::PQ_INPUTS, p, 0.0);
93 return heos.hmass();
94 });
95}
96
97std::unique_ptr<region::BoundaryCurve> build_h_sat_V(::CoolProp::AbstractState& heos, double p_min, double p_max,
98 const SatBoundaryBuildOptions& opts) {
99 if (auto sa = try_get_superanc(heos, /*need_caloric=*/true)) {
100 try {
101 const double inv_M = 1.0 / heos.molar_mass();
102 return region::SuperancillaryBoundaryCurve::build(sa, p_min, p_max, 'H', 1, inv_M);
103 } catch (...) { // NOLINT(bugprone-empty-catch)
104 }
105 }
106 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
107 heos.update(::CoolProp::PQ_INPUTS, p, 1.0);
108 return heos.hmass();
109 });
110}
111
112std::unique_ptr<region::BoundaryCurve> build_s_sat_L(::CoolProp::AbstractState& heos, double p_min, double p_max,
113 const SatBoundaryBuildOptions& opts) {
114 if (auto sa = try_get_superanc(heos, /*need_caloric=*/true)) {
115 try {
116 // SuperAncillary returns molar entropy (J/mol/K); SBTL
117 // stores mass-basis (J/kg/K). s_mass = s_mol / M.
118 const double inv_M = 1.0 / heos.molar_mass();
119 return region::SuperancillaryBoundaryCurve::build(sa, p_min, p_max, 'S', 0, inv_M);
120 } catch (...) { // NOLINT(bugprone-empty-catch)
121 // Fall through to the spline path on SA range mismatch.
122 }
123 }
124 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
125 heos.update(::CoolProp::PQ_INPUTS, p, 0.0);
126 return heos.smass();
127 });
128}
129
130std::unique_ptr<region::BoundaryCurve> build_s_sat_V(::CoolProp::AbstractState& heos, double p_min, double p_max,
131 const SatBoundaryBuildOptions& opts) {
132 if (auto sa = try_get_superanc(heos, /*need_caloric=*/true)) {
133 try {
134 const double inv_M = 1.0 / heos.molar_mass();
135 return region::SuperancillaryBoundaryCurve::build(sa, p_min, p_max, 'S', 1, inv_M);
136 } catch (...) { // NOLINT(bugprone-empty-catch)
137 }
138 }
139 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
140 heos.update(::CoolProp::PQ_INPUTS, p, 1.0);
141 return heos.smass();
142 });
143}
144
145namespace {
146// T-parameterized cubic-spline sampler. Mirror of
147// spline_through_log_p_samples, but linear-T spacing since the T span
148// over a dome arc is at most factor ~3 and log spacing would over-
149// concentrate knots at low T where rho_sat curves are nearly linear.
150std::unique_ptr<region::CubicSplineCurve> spline_through_linear_T_samples(double T_min, double T_max, std::size_t n_knots,
151 const std::function<double(double)>& f) {
152 if (!(T_min > 0.0) || !(T_max > T_min) || n_knots < 2) {
153 throw std::invalid_argument("SatBoundaryFactory: invalid T range or n_knots (need 0 < T_min < T_max and n_knots >= 2)");
154 }
155 std::vector<double> T_knots(n_knots);
156 std::vector<double> y(n_knots);
157 const double dT = (T_max - T_min) / static_cast<double>(n_knots - 1);
158 for (std::size_t k = 0; k < n_knots; ++k) {
159 T_knots[k] = T_min + static_cast<double>(k) * dT;
160 y[k] = f(T_knots[k]);
161 }
162 return region::CubicSplineCurve::build(std::move(T_knots), std::move(y));
163}
164} // namespace
165
166std::unique_ptr<region::BoundaryCurve> build_rho_sat_L(::CoolProp::AbstractState& heos, double T_min, double T_max,
167 const SatBoundaryBuildOptions& opts) {
168 if (auto sa = try_get_superanc(heos, /*need_caloric=*/false)) {
169 try {
170 // SuperAncillary returns molar density (mol/m^3); SBTL
171 // stores mass-basis (kg/m^3). rho_mass = rho_mol * M.
172 const double M = heos.molar_mass();
173 return region::SuperancillaryTemperatureBoundaryCurve::build(sa, T_min, T_max, 'D', 0, M);
174 } catch (...) { // NOLINT(bugprone-empty-catch)
175 // Fall through to the spline path on SA range mismatch.
176 }
177 }
178 return spline_through_linear_T_samples(T_min, T_max, opts.n_knots, [&](double T) {
179 heos.update(::CoolProp::QT_INPUTS, 0.0, T);
180 return heos.rhomass();
181 });
182}
183
184std::unique_ptr<region::BoundaryCurve> build_rho_sat_V(::CoolProp::AbstractState& heos, double T_min, double T_max,
185 const SatBoundaryBuildOptions& opts) {
186 if (auto sa = try_get_superanc(heos, /*need_caloric=*/false)) {
187 try {
188 const double M = heos.molar_mass();
189 return region::SuperancillaryTemperatureBoundaryCurve::build(sa, T_min, T_max, 'D', 1, M);
190 } catch (...) { // NOLINT(bugprone-empty-catch)
191 }
192 }
193 return spline_through_linear_T_samples(T_min, T_max, opts.n_knots, [&](double T) {
194 heos.update(::CoolProp::QT_INPUTS, 1.0, T);
195 return heos.rhomass();
196 });
197}
198
199std::unique_ptr<region::BoundaryCurve> build_T_sat(::CoolProp::AbstractState& heos, double p_min, double p_max, const SatBoundaryBuildOptions& opts) {
200 // No prop_key in the SuperAncillary for T directly — it's the
201 // *result* of get_T_from_p, not a tabulated property. Build a
202 // small wrapper class would be possible but for now keep the
203 // spline path here; T_sat is only used by IF97-source presets
204 // (which don't have an SA anyway).
205 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
206 heos.update(::CoolProp::PQ_INPUTS, p, 0.0);
207 return heos.T();
208 });
209}
210
211std::unique_ptr<region::CubicSplineCurve> build_h_isotherm_floor(::CoolProp::AbstractState& heos, double p_min, double p_max, double T_min,
212 const SatBoundaryBuildOptions& opts) {
213 // For fluids with steep melting curves the requested T_min may be
214 // below T_melt(p) at high p; walk T up in 0.5 K steps until HEOS
215 // accepts the (T, p) state. Matches Phase 2a's `h_lo_liq` lambda.
216 const std::size_t walk_steps = opts.t_floor_walk_steps;
217 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&, walk_steps](double p) {
218 for (std::size_t k = 0; k < walk_steps; ++k) {
219 const double T_try = T_min + 0.5 * static_cast<double>(k);
220 try {
221 heos.update(::CoolProp::PT_INPUTS, p, T_try);
222 return heos.hmass();
223 } catch (...) {
224 // Below melting line at this p; bump T up and retry. Reset
225 // the state first: a failed update can leave a poisoned
226 // guess that makes the next (T_try, p) probe on this same
227 // reused state fail spuriously — the same retry-on-melting-
228 // reject hazard as build_rho_max_envelope (CoolProp-i9t8).
229 heos.clear();
230 }
231 }
232 throw std::runtime_error("build_h_isotherm_floor: floor unreachable within " + std::to_string(walk_steps / 2) + " K of T_min");
233 });
234}
235
236std::unique_ptr<region::CubicSplineCurve> build_h_isotherm_ceiling(::CoolProp::AbstractState& heos, double p_min, double p_max, double T_max,
237 const SatBoundaryBuildOptions& opts) {
238 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
239 heos.update(::CoolProp::PT_INPUTS, p, T_max);
240 return heos.hmass();
241 });
242}
243
244std::unique_ptr<region::CubicSplineCurve> build_s_isotherm_floor(::CoolProp::AbstractState& heos, double p_min, double p_max, double T_min,
245 const SatBoundaryBuildOptions& opts) {
246 // Entropy analog of build_h_isotherm_floor — same melting-line
247 // T-walk + heos.clear() reset on reject (CoolProp-i9t8), reads
248 // smass() instead of hmass().
249 const std::size_t walk_steps = opts.t_floor_walk_steps;
250 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&, walk_steps](double p) {
251 for (std::size_t k = 0; k < walk_steps; ++k) {
252 const double T_try = T_min + 0.5 * static_cast<double>(k);
253 try {
254 heos.update(::CoolProp::PT_INPUTS, p, T_try);
255 return heos.smass();
256 } catch (...) {
257 heos.clear();
258 }
259 }
260 throw std::runtime_error("build_s_isotherm_floor: floor unreachable within " + std::to_string(walk_steps / 2) + " K of T_min");
261 });
262}
263
264std::unique_ptr<region::CubicSplineCurve> build_s_isotherm_ceiling(::CoolProp::AbstractState& heos, double p_min, double p_max, double T_max,
265 const SatBoundaryBuildOptions& opts) {
266 return spline_through_log_p_samples(p_min, p_max, opts.n_knots, [&](double p) {
267 heos.update(::CoolProp::PT_INPUTS, p, T_max);
268 return heos.smass();
269 });
270}
271
272std::pair<double, double> subcritical_pressure_range(::CoolProp::AbstractState& heos, std::optional<double> p_min_override) {
273 const double p_crit = heos.p_critical();
274 // True triple-point pressure. Prefer the fluid's stored p_triple()
275 // (the same value users get back and query against), falling back to
276 // a QT flash at the triple temperature when that is not available
277 // (NaN / non-positive). The lowest tabulated isobar sits exactly
278 // here so a PT query at p == p_triple() resolves (CoolProp-naqt /
279 // issue #3189): RegionAtlas containment is inclusive and
280 // PQ(p_triple, 0) converges, so build_T_sat can sample the bottom
281 // knot. The fallback samples at exactly Ttriple (QT there converges
282 // to the true p_triple for every tested fluid); an elevated estimate
283 // would reintroduce the very floor this fix removes.
284 double p_triple = heos.p_triple();
285 if (!std::isfinite(p_triple) || p_triple <= 0.0) {
286 heos.update(::CoolProp::QT_INPUTS, 0.0, heos.Ttriple());
287 p_triple = heos.p();
288 }
289 if (!std::isfinite(p_triple) || p_triple <= 0.0) {
290 throw ValueError("SVDSBTL: unable to resolve a finite, positive triple-point pressure for the subcritical floor.");
291 }
292 double p_min = p_triple;
293 if (p_min_override) {
294 // The subcritical regions are bounded above (in T) by the
295 // liquid-vapour saturation curve, which does not exist below the
296 // triple line. Reject a non-finite / sub-triple floor loudly
297 // rather than let build_T_sat fail obscurely on a PQ flash below
298 // p_triple, or let a NaN slip past the `< p_triple` test (NaN
299 // compares false) into the axis transform.
300 if (!std::isfinite(*p_min_override) || *p_min_override <= 0.0) {
301 throw ValueError(format("SVDSBTL: pmin (%g Pa) must be a finite, positive pressure.", *p_min_override));
302 }
303 if (*p_min_override < p_triple) {
304 throw ValueError(format("SVDSBTL: pmin (%g Pa) is below the triple-point pressure (%g Pa); the subcritical "
305 "surfaces have no saturation boundary below the triple line.",
306 *p_min_override, p_triple));
307 }
308 p_min = *p_min_override;
309 }
310 return {p_min, 0.999 * p_crit};
311}
312
314 const double p_crit = heos.p_critical();
315 const double p_max_eos = heos.pmax();
316 return {1.001 * p_crit, 0.99 * p_max_eos};
317}
318
319std::vector<double> find_rho_satL_extrema_T(::CoolProp::AbstractState& heos, double T_min, double T_max) {
320 if (!(T_max > T_min)) {
321 return {};
322 }
323 // SuperAncillary path — pieces are constructed to be monotonic in
324 // the dependent variable, so get_x_at_extrema() returns precisely
325 // the T values where drho_sat,L/dT = 0.
326 if (auto sa = try_get_superanc(heos, /*need_caloric=*/false)) {
327 try {
328 const auto& approx = sa->get_approx1d('D', /*Q=*/0);
329 std::vector<double> hits;
330 for (double T : approx.get_x_at_extrema()) {
331 if (T > T_min && T < T_max) {
332 hits.push_back(T);
333 }
334 }
335 std::sort(hits.begin(), hits.end());
336 return hits;
337 } catch (...) { // NOLINT(bugprone-empty-catch)
338 // SA may reject the property key (no rhoL expansion).
339 // Fall through to the QT_INPUTS walk.
340 }
341 }
342 // Fallback: walk QT_INPUTS at 64 T-knots, finite-difference
343 // drho_sat,L/dT, bisect each sign change. Cheap (one-shot at
344 // preset construction).
345 constexpr std::size_t kProbes = 64;
346 std::vector<double> T_grid(kProbes);
347 std::vector<double> rhoL(kProbes);
348 const double dT_grid = (T_max - T_min) / static_cast<double>(kProbes - 1);
349 for (std::size_t k = 0; k < kProbes; ++k) {
350 T_grid[k] = T_min + static_cast<double>(k) * dT_grid;
351 try {
352 heos.update(::CoolProp::QT_INPUTS, 0.0, T_grid[k]);
353 rhoL[k] = heos.rhomass();
354 } catch (...) { // NOLINT(bugprone-empty-catch)
355 rhoL[k] = std::nan("");
356 }
357 }
358 // Slope sign at each interior knot (central FD). Skip non-finite
359 // entries (rare — only at very-near-critical T) by treating them
360 // as "no information".
361 auto slope_sign = [&](std::size_t i) -> int {
362 if (i == 0 || i + 1 >= kProbes) return 0;
363 const double rl = rhoL[i - 1];
364 const double rr = rhoL[i + 1];
365 if (!std::isfinite(rl) || !std::isfinite(rr)) return 0;
366 const double dr = rr - rl;
367 return (dr > 0.0) ? 1 : (dr < 0.0 ? -1 : 0);
368 };
369 std::vector<double> hits;
370 for (std::size_t i = 1; i + 1 < kProbes; ++i) {
371 const int s_lo = slope_sign(i);
372 const int s_hi = slope_sign(i + 1);
373 if (s_lo == 0 || s_hi == 0 || s_lo == s_hi) continue;
374 // Sign change between knot i and i+1; bisect on the slope sign.
375 try {
376 auto residual = [&](double T) -> double {
377 // Central FD of rho_sat,L(T) at T; reuse the kProbes
378 // grid spacing as the step (well above the noise floor
379 // of QT_INPUTS).
380 const double h = 0.5 * dT_grid;
381 heos.update(::CoolProp::QT_INPUTS, 0.0, T - h);
382 const double rl = heos.rhomass();
383 heos.update(::CoolProp::QT_INPUTS, 0.0, T + h);
384 const double rr = heos.rhomass();
385 return rr - rl;
386 };
387 boost::math::tools::eps_tolerance<double> tol(48);
388 std::uintmax_t max_iter = 64;
389 auto root = boost::math::tools::toms748_solve(residual, T_grid[i], T_grid[i + 1], tol, max_iter);
390 hits.push_back(0.5 * (root.first + root.second));
391 } catch (...) { // NOLINT(bugprone-empty-catch)
392 // Bisection failed (rare; could indicate the bracketed
393 // sign change is numerical noise rather than a real
394 // extremum). Skip.
395 }
396 }
397 std::sort(hits.begin(), hits.end());
398 // Fallback bracketing can return near-duplicate roots under FD
399 // noise; collapse them so downstream LIQUID splitting never gets a
400 // zero-width sub-region.
401 hits.erase(std::unique(hits.begin(), hits.end(),
402 [](double x, double y) {
403 const double tol = 1e-8 * std::max({1.0, std::abs(x), std::abs(y)});
404 return std::abs(x - y) <= tol;
405 }),
406 hits.end());
407 return hits;
408}
409
410} // namespace sbtl
411} // namespace CoolProp