CoolProp 8.0.0
An open-source fluid property and humid air property database
SuperancillaryBoundaryCurve.h
Go to the documentation of this file.
1#ifndef COOLPROP_REGION_SUPERANCILLARY_BOUNDARY_CURVE_H
2#define COOLPROP_REGION_SUPERANCILLARY_BOUNDARY_CURVE_H
3
4#include <cmath>
5#include <cstddef>
6#include <memory>
7#include <set> // transitively needed by superancillary.h
8#include <utility>
9#include <vector>
10
13
14namespace CoolProp {
15namespace region {
16
17// 1D boundary curve b = f(p) backed by the source backend's SuperAncillary
18// — a stack of two analytic operations, each already machine-precision:
19//
20// 1. T_sat = SuperAncillary::get_T_from_p(p)
21// Solves T from p via the inverse log-p Chebyshev expansion.
22// Inversion noise floor is ~1e-13 for typical fluids.
23//
24// 2. b = SuperAncillary::eval_sat(T_sat, prop_key, Q)
25// Evaluates the saturated property b at T_sat on side Q (0 = liquid,
26// 1 = vapor) via the property's piecewise Chebyshev expansion.
27//
28// Composing the two gives b_sat(p) = eval_sat(get_T_from_p(p), k, Q) — a
29// no-refit curve with O(1e-13) residual against the source HEOS.
30//
31// This is the long-standing TODO from `SatBoundaryFactory.h`'s top
32// comment ("Phase 2c: the true sat boundaries can be a no-refit view
33// onto the existing fluid superancillary..."), realized as a concrete
34// BoundaryCurve subclass. Closes CoolProp-8vg with the path
35// originally identified back in Phase 2a's design notes.
36//
37// SCOPE. Only works for the sat dome curves (Q ∈ {0, 1}, prop_key ∈
38// {'D', 'P', 'H', 'S', 'U'}). Isotherm floors / ceilings outside the
39// dome still need the legacy SatBoundaryFactory path (cubic spline).
40//
41// THREAD SAFETY. SuperAncillary's lazy-build paths use a mutex
42// internally (m_lazy_build_mutex on caloric expansions). Multiple
43// threads reading the same curve are safe.
44//
45// EVAL_DA. Returns d/dp [eval_sat(T_sat(p), k, Q)] via the chain
46// rule:
47//
48// d/dp b_sat(p) = (db/dT)_{Q,k}(T_sat) * dT_sat/dp
49//
50// where (db/dT) is computed via central finite difference on the
51// Chebyshev expansion (cheaper than reaching into the approx1d's
52// derivative-expansion machinery, and the Cheb function is smooth
53// enough that 1e-8 FD step gives ~1e-12 relative error in the
54// derivative — well below anything that matters for the η
55// normalisation we drive). dT_sat/dp is the inverse of
56// d/dT p_sat(T) at the resolved T_sat, also via central FD.
58{
59 public:
61
62 SuperancillaryBoundaryCurve(std::shared_ptr<SuperAncillary_t> sa, double p_min, double p_max, char prop_key, short Q, double output_scale,
63 double b_min, double b_max)
64 : sa_(std::move(sa)), p_min_(p_min), p_max_(p_max), prop_key_(prop_key), Q_(Q), output_scale_(output_scale), b_min_(b_min), b_max_(b_max) {
65 // Fail fast: every public eval / eval_da / eval_fast path
66 // dereferences sa_ unchecked. Allowing null would let a caller
67 // create an object that crashes on first lookup. Both
68 // factories (build, from_state) also null-guard; this catches
69 // the direct-construction path that bypasses them. Must run
70 // before build_surrogate_() — that helper calls eval() at
71 // 1024 probe points.
72 if (!sa_) {
73 throw std::invalid_argument("SuperancillaryBoundaryCurve: null SuperAncillary handle");
74 }
75 // Constructor must also enforce 0 < p_min < p_max before
76 // build_surrogate_() calls std::log(p_min_) and divides by
77 // log_p_step. Factory build() already validates these; the
78 // public constructor path (used by from_state on deserialise)
79 // did not, so a bad cache file could produce -inf / NaN in
80 // log_p_min_ + a div-by-zero in inv_log_p_step_.
81 if (!(p_min_ > 0.0) || !(p_max_ > p_min_)) {
82 throw std::invalid_argument("SuperancillaryBoundaryCurve: need 0 < p_min < p_max");
83 }
84 build_surrogate_();
85 }
86
87 // Factory: probes the SA at p_min / p_max to pre-compute b_min /
88 // b_max for the AABB cheap-reject. Throws if the SuperAncillary
89 // doesn't span [p_min, p_max] or rejects the property key.
90 //
91 // SuperAncillary returns properties in MOLAR units (J/mol for H,
92 // J/(mol K) for S, mol/m^3 for D). Pass `output_scale = 1/M`
93 // (M = molar mass, kg/mol) to convert to mass basis at eval
94 // time. For molar-basis consumers pass 1.0. For property 'P'
95 // (pressure), pass 1.0 (units are already Pa). For property 'D'
96 // (density in mol/m^3 -> kg/m^3), pass `M`, not `1/M`.
97 static std::unique_ptr<SuperancillaryBoundaryCurve> build(std::shared_ptr<SuperAncillary_t> sa, double p_min, double p_max, char prop_key,
98 short Q, double output_scale);
99
100 [[nodiscard]] double eval(double p) const noexcept override {
101 try {
102 const double T_sat = sa_->get_T_from_p(p);
103 return sa_->eval_sat(T_sat, prop_key_, Q_) * output_scale_;
104 } catch (...) { // NOLINT(bugprone-empty-catch)
105 return std::nan("");
106 }
107 }
108
109 [[nodiscard]] double eval_da(double p) const noexcept override {
110 // Central FD on the composed curve. Relative step h = 1e-7 *
111 // p gives ~1e-13 relative error in the derivative — Cheb
112 // expansions are C^∞ in the interior, so FD doesn't introduce
113 // truncation noise of any significance.
114 //
115 // Clamp the ±h endpoints to [p_min_, p_max_] so a query right
116 // at a build-range endpoint doesn't step into the SA's invalid
117 // region and return NaN. When clamped on one side the
118 // effective stencil becomes asymmetric (forward or backward
119 // difference); divide by the actual span to keep the slope
120 // correct.
121 const double h = std::max(1e-7 * std::abs(p), 1.0);
122 const double p_hi = std::min(p + h, p_max_);
123 const double p_lo = std::max(p - h, p_min_);
124 if (!(p_hi > p_lo)) {
125 return std::nan("");
126 }
127 const double y_plus = eval(p_hi);
128 const double y_minus = eval(p_lo);
129 if (!std::isfinite(y_plus) || !std::isfinite(y_minus)) {
130 return std::nan("");
131 }
132 return (y_plus - y_minus) / (p_hi - p_lo);
133 }
134
135 // Fast 1e-6-accurate surrogate via 1024-point log-spaced linear
136 // interpolation table. Used by Region::curve_contains for atlas
137 // dispatch; precision-critical callers (to_normalized) keep using
138 // eval().
139 //
140 // Implementation: precomputed table of b_sat at 1024 log-uniform
141 // p in [p_min, p_max]. eval_fast(p):
142 // 1. fractional bucket index = (log(p) - log_p_min_) * inv_log_p_step_
143 // 2. linear interp between the two adjacent table entries
144 // Out-of-range clamps to the nearest endpoint (atlas's AABB test
145 // already gates this; clamp is defense-in-depth).
146 [[nodiscard]] double eval_fast(double p) const noexcept override {
147 const double lp = std::log(p);
148 const double idx_f = (lp - log_p_min_) * inv_log_p_step_;
149 if (!(idx_f > 0.0)) {
150 return surrogate_table_.front();
151 }
152 const auto last = static_cast<double>(kSurrogatePoints - 1);
153 if (idx_f >= last) {
154 return surrogate_table_.back();
155 }
156 const auto i = static_cast<std::size_t>(idx_f);
157 const double frac = idx_f - static_cast<double>(i);
158 const double y0 = surrogate_table_[i];
159 const double y1 = surrogate_table_[i + 1];
160 return y0 + frac * (y1 - y0);
161 }
162
163 [[nodiscard]] std::pair<double, double> bounds() const noexcept override {
164 return {b_min_, b_max_};
165 }
166 [[nodiscard]] std::pair<double, double> a_range() const noexcept override {
167 return {p_min_, p_max_};
168 }
169
170 // State / from_state for the serializer. We store only the
171 // primitive scalars; on deserialization the caller must supply the
172 // SuperAncillary handle (typically the same instance the SVDSBTL
173 // backend already owns).
174 struct State
175 {
176 double p_min;
177 double p_max;
179 short Q;
181 double b_min;
182 double b_max;
183 };
184 [[nodiscard]] State state() const {
185 return State{p_min_, p_max_, prop_key_, Q_, output_scale_, b_min_, b_max_};
186 }
187 static std::unique_ptr<SuperancillaryBoundaryCurve> from_state(State s, std::shared_ptr<SuperAncillary_t> sa) {
188 // Constructor's eval() dereferences sa_ unchecked; the matching
189 // factory build() already null-guards. Fail fast here too so a
190 // caller passing a null handle gets a clear diagnostic instead
191 // of an eventual segfault on the first lookup.
192 if (!sa) {
193 throw std::invalid_argument("SuperancillaryBoundaryCurve::from_state: null SuperAncillary handle");
194 }
195 return std::make_unique<SuperancillaryBoundaryCurve>(std::move(sa), s.p_min, s.p_max, s.prop_key, s.Q, s.output_scale, s.b_min, s.b_max);
196 }
197
198 private:
199 // 1024 points across the (log) p range gives ~1e-6 max relative
200 // error on a Cheb-smooth saturation curve — comfortably below the
201 // atlas's sign-only requirement. ~8 KB per curve; 4 SA-backed
202 // curves per fluid (LIQUID b_hi + VAPOR b_lo, for each of HmassP /
203 // PT input pairs) = ~32 KB / fluid. Negligible.
204 static constexpr std::size_t kSurrogatePoints = 1024;
205
206 // Build the surrogate table by sampling eval() at 1024 log-uniform
207 // points in [p_min_, p_max_]. One-shot cost ~80 ns × 1024 ≈ 80 μs
208 // per curve, paid once at construction. Failed eval() at any
209 // probe leaves the table entry NaN; eval_fast then propagates the
210 // NaN (curve_contains returns false for NaN, which is the correct
211 // behavior for an unrepresentable region of the curve).
212 // NOT noexcept: surrogate_table_.resize(kSurrogatePoints) below
213 // can throw std::bad_alloc. Marking this noexcept would call
214 // std::terminate on allocation failure instead of letting the
215 // exception propagate up to the constructor's caller — caller
216 // can catch and either retry or fall back to a non-SA path.
217 void build_surrogate_() {
218 surrogate_table_.resize(kSurrogatePoints);
219 log_p_min_ = std::log(p_min_);
220 const double log_p_max = std::log(p_max_);
221 const double log_p_step = (log_p_max - log_p_min_) / static_cast<double>(kSurrogatePoints - 1);
222 inv_log_p_step_ = 1.0 / log_p_step;
223 for (std::size_t k = 0; k < kSurrogatePoints; ++k) {
224 const double lp = log_p_min_ + static_cast<double>(k) * log_p_step;
225 surrogate_table_[k] = eval(std::exp(lp));
226 }
227 }
228
229 std::shared_ptr<SuperAncillary_t> sa_;
230 double p_min_;
231 double p_max_;
232 char prop_key_;
233 short Q_;
234 double output_scale_;
235 double b_min_;
236 double b_max_;
237 // Surrogate-table state; populated by build_surrogate_() in the
238 // constructor (or by from_state for cache reloads).
239 std::vector<double> surrogate_table_;
240 double log_p_min_ = 0.0;
241 double inv_log_p_step_ = 0.0;
242};
243
244} // namespace region
245} // namespace CoolProp
246
247#endif // COOLPROP_REGION_SUPERANCILLARY_BOUNDARY_CURVE_H