CoolProp 8.0.0
An open-source fluid property and humid air property database
SVDSurfaceFactory.cpp
Go to the documentation of this file.
2
3#include <algorithm>
4#include <atomic>
5#include <cmath>
6#include <cstdio>
7#include <memory>
8#include <stdexcept>
9#include <thread>
10#include <vector>
11
19
20namespace CoolProp {
21namespace sbtl {
22
23namespace {
24
25// Build the per-region (xn, xi) grids returned to both the sampler and
26// the SVD builder. Single source of truth for the secondary-axis
27// spacing — the SVD's stored y_grid MUST match the η positions where
28// the source was actually sampled, so sample_grid() and the
29// build_surface() loop below both go through here.
30//
31// Secondary axis (xn) uses Chebyshev-cosine spacing: cells crowd toward
32// both η=0 and η=1 boundaries. In LIQUID/VAPOR regions the saturation
33// curve is one of the η boundaries, and properties have sharp curvature
34// in a thin η-strip right against it. Uniform η spacing puts only ~1
35// grid cell of resolution into that strip — measured worst-case error
36// in SVDSBTL&HEOS R2 lives there (h_truth − h_sat,V ≈ 0.33%, η ≈ 0.005,
37// only ~1 grid cell of support at NT=200). Chebyshev squashes bulk
38// cells toward the middle and crowds them at both ends, dropping
39// per-region failure counts by 6-9× on the SVDSBTL&HEOS conformance
40// fail-map (2026-05-18).
41//
42// Tiny pad off the literal 0 / 1 corners keeps boundary-curve evaluation
43// safe — the cubic-spline / Chebyshev sat boundaries can extrapolate
44// epsilon-outside their fit interval at the dome corners, and HmassP
45// flash failures cluster there.
46//
47// Primary axis (xi) stays on uniform spacing — its scale is handled by
48// the region's AxisTransform (linear or log) at from_normalized() time.
49std::pair<std::vector<double>, std::vector<double>> make_grid_axes(std::size_t NT, std::size_t NR) {
50 constexpr double kPadEnd = 0.001;
51 std::vector<double> xn_grid(NT);
52 std::vector<double> xi_grid(NR);
53 for (std::size_t i = 0; i < NT; ++i) {
54 const double t = static_cast<double>(i) / static_cast<double>(NT - 1);
55 const double eta_cheb = 0.5 * (1.0 - std::cos(M_PI * t));
56 xn_grid[i] = kPadEnd + (1.0 - 2.0 * kPadEnd) * eta_cheb;
57 }
58 for (std::size_t j = 0; j < NR; ++j) {
59 xi_grid[j] = static_cast<double>(j) / static_cast<double>(NR - 1);
60 }
61 return {std::move(xn_grid), std::move(xi_grid)};
62}
63
64// Walk the (xnorm, log_p_norm) ∈ [0,1] × [0,1] grid for one Region,
65// sample HEOS per cell via the spec's update_state / read_property
66// callbacks, and fill the per-property matrix dictionary. Cells
67// where HEOS throws (e.g. two-phase) are left NaN and the row-fill
68// patches them. Returns NT × NR row-major matrices indexed by
69// property.
70//
71// Matches dev/svd_sbtl_e2e.cpp's build_region_svd loop layout so
72// numerics agree with Phase 2a's validated output.
73std::vector<std::vector<double>> sample_grid(::CoolProp::AbstractState& heos, const SurfaceSpec& spec, const region::Region& region) {
74 const std::size_t NT = spec.NT;
75 const std::size_t NR = spec.NR;
76 const std::size_t n_props = spec.properties.size();
77
78 const auto [xn_grid, xi_grid] = make_grid_axes(NT, NR);
79
80 // M[prop][i*NR + j] for each output property.
81 std::vector<std::vector<double>> M(n_props);
82 for (auto& m : M) {
83 m.assign(NT * NR, std::nan(""));
84 }
85
86 // Per-cell sampling lambda — captures the grid axes + region by
87 // reference (read-only) and the output matrix M by reference
88 // (per-cell writes are to disjoint indices so concurrent
89 // invocations are safe). `src` is whichever AbstractState the
90 // caller hands in — the original `heos` for the serial path, a
91 // per-thread factory-built clone for the parallel path.
92 auto sample_cell = [&](std::size_t i, std::size_t j, ::CoolProp::AbstractState& src) {
93 // Avoid structured-binding capture (a C++20 feature; the
94 // project compiles at C++17). Use the pair fields directly.
95 const auto ab = region.from_normalized(xi_grid[j], xn_grid[i]);
96 const double a = ab.first;
97 const double b = ab.second;
98 try {
99 spec.update_state(src, a, b);
100 if (src.Q() > 0.0 && src.Q() < 1.0) {
101 return; // two-phase — leave NaN
102 }
103 for (std::size_t p = 0; p < n_props; ++p) {
104 const double v = spec.read_property(src, spec.properties[p].key);
105 if (!std::isfinite(v)) {
106 continue;
107 }
108 // Apply the property's transform (typically LOG
109 // for density via OutputTransform::EXP) before
110 // storing into M.
111 const double stored = (spec.properties[p].transform == svd::OutputTransform::EXP) ? (v > 0.0 ? std::log(v) : std::nan("")) : v;
112 M[p][i * NR + j] = stored;
113 }
114 } catch (...) { // NOLINT(bugprone-empty-catch)
115 // HEOS failures (above melting line, OOB, ...) → NaN.
116 // Row-fill below patches them.
117 }
118 };
119
120 // Decide serial vs parallel. Parallel needs all of:
121 // * config opt-in (SVDSBTL_SAMPLING_THREADS != 1)
122 // * non-REFPROP source (REFPROP's FORTRAN runtime is process-
123 // global; concurrent SETUPdll'd states share state and corrupt
124 // each other under parallel updates)
125 // * a non-empty spec.source_backend + spec.fluid_name (so worker
126 // threads can factory-build their own AbstractState)
127 // * at least 2 effective worker threads after resolving "auto"
128 auto resolve_thread_count = []() -> std::size_t {
129 const int cfg = ::CoolProp::get_config_int(SVDSBTL_SAMPLING_THREADS);
130 if (cfg == 1) return 1; // serial fast-path
131 if (cfg <= 0) {
132 // 0 (or negative) → auto = hardware_concurrency
133 const unsigned hw = std::thread::hardware_concurrency();
134 return hw <= 1 ? 1 : static_cast<std::size_t>(hw);
135 }
136 return static_cast<std::size_t>(cfg);
137 };
138 const std::size_t n_threads_cfg = resolve_thread_count();
139 const bool can_parallelise = n_threads_cfg > 1 && spec.source_backend != "REFPROP" && !spec.source_backend.empty() && !spec.fluid_name.empty();
140
141 if (!can_parallelise) {
142 for (std::size_t j = 0; j < NR; ++j) {
143 for (std::size_t i = 0; i < NT; ++i) {
144 sample_cell(i, j, heos);
145 }
146 }
147 } else {
148 // Cap workers at NT (no point launching more threads than rows
149 // — each thread gets one full row at a time). Each worker
150 // factory-builds its own AbstractState once and reuses across
151 // its row slice; per-thread construction cost (~ms) amortises
152 // across hundreds of cells per row.
153 //
154 // worker_factory_failed flags any thread that couldn't
155 // construct its local source. Silently returning would leave
156 // whole row chunks unsampled, and the NaN-fill below would
157 // produce a corrupted SVD table instead of a build failure.
158 // Fail loudly post-join.
159 const std::size_t n_threads = std::min(n_threads_cfg, NT);
160 std::atomic<bool> worker_factory_failed{false};
161 auto worker = [&](std::size_t i_lo, std::size_t i_hi) {
162 std::unique_ptr<::CoolProp::AbstractState> local_src;
163 try {
164 local_src.reset(::CoolProp::AbstractState::factory(spec.source_backend, spec.fluid_name));
165 } catch (...) { // NOLINT(bugprone-empty-catch)
166 worker_factory_failed.store(true, std::memory_order_relaxed);
167 return;
168 }
169 for (std::size_t i = i_lo; i < i_hi; ++i) {
170 for (std::size_t j = 0; j < NR; ++j) {
171 sample_cell(i, j, *local_src);
172 }
173 }
174 };
175 std::vector<std::thread> workers;
176 workers.reserve(n_threads);
177 const std::size_t chunk = (NT + n_threads - 1) / n_threads;
178 for (std::size_t t = 0; t < n_threads; ++t) {
179 const std::size_t i_lo = t * chunk;
180 const std::size_t i_hi = std::min(i_lo + chunk, NT);
181 if (i_lo >= i_hi) break;
182 workers.emplace_back(worker, i_lo, i_hi);
183 }
184 for (auto& w : workers) {
185 w.join();
186 }
187 if (worker_factory_failed.load(std::memory_order_relaxed)) {
188 throw std::runtime_error("build_surface: failed to construct per-thread source AbstractState (parallel sampling)");
189 }
190 }
191
192 // Row-wise linear fill in log_p for each property (mirrors the
193 // PoC's NaN-patch logic). Used to keep the SVD numerics sane on
194 // cells where HEOS gave up.
195 for (auto& m : M) {
196 for (std::size_t i = 0; i < NT; ++i) {
197 std::size_t first_good = NR, last_good = NR;
198 for (std::size_t j = 0; j < NR; ++j) {
199 if (std::isfinite(m[i * NR + j])) {
200 if (first_good == NR) {
201 first_good = j;
202 }
203 last_good = j;
204 }
205 }
206 if (first_good == NR) {
207 continue;
208 }
209 for (std::size_t j = 0; j < first_good; ++j) {
210 m[i * NR + j] = m[i * NR + first_good];
211 }
212 for (std::size_t j = last_good + 1; j < NR; ++j) {
213 m[i * NR + j] = m[i * NR + last_good];
214 }
215 for (std::size_t j = first_good + 1; j < last_good; ++j) {
216 if (std::isfinite(m[i * NR + j])) {
217 continue;
218 }
219 std::size_t k_lo = j;
220 while (k_lo > 0 && !std::isfinite(m[i * NR + k_lo - 1])) {
221 --k_lo;
222 }
223 --k_lo;
224 std::size_t k_hi = j;
225 while (k_hi < NR && !std::isfinite(m[i * NR + k_hi])) {
226 ++k_hi;
227 }
228 const double t = (xi_grid[j] - xi_grid[k_lo]) / (xi_grid[k_hi] - xi_grid[k_lo]);
229 m[i * NR + j] = (1.0 - t) * m[i * NR + k_lo] + t * m[i * NR + k_hi];
230 }
231 }
232 // Final guard: any remaining NaNs (fully-NaN rows in pathological cases)
233 // get filled with the median. Falling through with NaN would crash the
234 // SVD builder via the finite-value precondition.
235 std::vector<double> finite_vals;
236 finite_vals.reserve(m.size());
237 for (double v : m) {
238 if (std::isfinite(v)) {
239 finite_vals.push_back(v);
240 }
241 }
242 if (finite_vals.size() < m.size()) {
243 if (finite_vals.empty()) {
244 // Every sample for this property in this region was
245 // non-finite — typically means the (T, p) grid walked
246 // outside the HEOS validity envelope for this region's
247 // axis transform. Failing fast beats producing a NaN-
248 // filled "table" and silently corrupting the SVD math.
249 throw std::runtime_error("build_surface: all-NaN property matrix in region (HEOS sampling produced no finite values)");
250 }
251 std::nth_element(finite_vals.begin(), finite_vals.begin() + static_cast<std::ptrdiff_t>(finite_vals.size() / 2), finite_vals.end());
252 const double median = finite_vals[finite_vals.size() / 2];
253 for (double& v : m) {
254 if (!std::isfinite(v)) {
255 v = median;
256 }
257 }
258 }
259 }
260
261 return M;
262}
263
264} // namespace
265
267 if (spec.regions.empty()) {
268 throw std::invalid_argument("build_surface: SurfaceSpec.regions is empty");
269 }
270 if (spec.properties.empty()) {
271 throw std::invalid_argument("build_surface: SurfaceSpec.properties is empty");
272 }
273 if (!spec.update_state || !spec.read_property) {
274 throw std::invalid_argument("build_surface: SurfaceSpec is missing update_state / read_property callbacks");
275 }
276 // Grid axes use (NT - 1) / (NR - 1) as denominators; values < 2 would
277 // emit NaN-shaped grids and crash downstream SVD math. Rank < 1 has
278 // no usable decomposition.
279 if (spec.NT < 2 || spec.NR < 2) {
280 throw std::invalid_argument("build_surface: SurfaceSpec.NT and .NR must each be >= 2");
281 }
282 if (spec.rank < 1) {
283 throw std::invalid_argument("build_surface: SurfaceSpec.rank must be >= 1");
284 }
285
286 // Build the SVDSurface, transferring regions in order, then SVDs.
287 std::vector<::CoolProp::parameters> prop_keys;
288 prop_keys.reserve(spec.properties.size());
289 for (const auto& p : spec.properties) {
290 prop_keys.push_back(p.key);
291 }
292 SVDSurface surface(spec.fluid_name, spec.input_pair, prop_keys);
293
294 const auto [xn_grid, xi_grid] = make_grid_axes(spec.NT, spec.NR);
295
296 for (std::size_t r = 0; r < spec.regions.size(); ++r) {
297 // Move the RegionSpec's boundary curves into a real Region,
298 // then immediately add it to the surface.
299 region::Region region(spec.regions[r].primary, std::move(spec.regions[r].b_lo), std::move(spec.regions[r].b_hi), spec.regions[r].secondary);
300 // Sample HEOS for this region BEFORE moving it into the
301 // surface — we need the (a, b) → from_normalized while we
302 // still hold an immediate reference.
303 const auto M_per_prop = sample_grid(heos, spec, region);
304 const std::size_t region_idx = surface.add_region(std::move(region));
305
306 // Build one SVD per property.
307 for (std::size_t p = 0; p < spec.properties.size(); ++p) {
308 svd::SVDBuildOptions svdopts;
309 svdopts.rank = spec.rank;
310 svdopts.out_transform = spec.properties[p].transform;
311 svdopts.slope_source = opts.slope_source;
312 svd::SVDDecomposition decomp = svd::build_svd(xn_grid, xi_grid, M_per_prop[p], svdopts);
313 surface.add_region_property_svd(region_idx, spec.properties[p].key, std::move(decomp));
314 if (opts.verbose) {
315 // NOLINTNEXTLINE(cppcoreguidelines-pro-type-vararg,hicpp-vararg)
316 std::printf(" region %zu property %d: SVD built (rank=%d)\n", region_idx, static_cast<int>(spec.properties[p].key), spec.rank);
317 }
318 }
319 }
320
321 surface.seal();
322 return surface;
323}
324
325} // namespace sbtl
326} // namespace CoolProp