CoolProp 8.0.0
An open-source fluid property and humid air property database
SVDEvaluator.h
Go to the documentation of this file.
1#ifndef COOLPROP_SVD_SVD_EVALUATOR_H
2#define COOLPROP_SVD_SVD_EVALUATOR_H
3
4#include <cmath>
5#include <cstddef>
6#include <memory>
7#include <stdexcept>
8#include <utility>
9
12
13namespace CoolProp {
14namespace svd {
15
16// Shared per-point setup that all SVDEvaluators over the same (x_grid,
17// y_grid) reuse. In an SVDSurface every per-property evaluator in a
18// region shares the same grids — so the locate() and basis-weight
19// computation can be done ONCE and amortized across N property evals
20// at the same (x, y). See SVDSurface::eval_with_region_multi for the
21// batched-output entry point that uses this.
23{
24 std::size_t i = 0; // x_grid cell index, [0, NX-2]
25 std::size_t j = 0; // y_grid cell index, [0, NY-2]
26 double hx = 0.0;
27 double hy = 0.0;
28 double tx = 0.0;
29 double ty = 0.0;
32};
33
34// Hot-path 2D rank-r SVD evaluator.
35//
36// Holds a non-owning pointer to an SVDDecomposition (built offline by
37// SVDBuilder) and evaluates the truncated reconstruction at arbitrary
38// (x, y). The cubic-Hermite kernel on each axis is C^1 at minimum and
39// C^2 when the decomposition's slopes come from a natural cubic spline
40// (the default).
41//
42// Inner loop, per call:
43// - locate i in x_grid (binary search, ~log NX comparisons)
44// - locate j in y_grid (binary search, ~log NY comparisons)
45// - compute Hermite basis at t_x, t_y (8 mults + 6 adds total)
46// - dot product: acc = sum_{k=0..r-1} u_k(x) * v_s_k(y)
47// - (optional) exp(acc)
48//
49// At rank 20 this is ~40 FMAs in the hot loop; the binary searches and
50// Hermite basis computation dominate the per-call cost.
52{
53 public:
54 // Construct from a borrowed decomposition. The caller must keep the
55 // SVDDecomposition alive *at a stable address* for at least as long
56 // as this evaluator. Rvalue-ref construction is deleted to prevent
57 // the obvious temporary foot-gun:
58 //
59 // SVDEvaluator e(build_svd(...)); // <-- temporary, dangling
60 //
61 // The less-obvious foot-gun involves moving the decomposition out
62 // from under the evaluator via a container. This pattern looks
63 // safe but ISN'T, because vector::push_back moves the inline
64 // `decomp` and the evaluator's borrowed pointer is left dangling:
65 //
66 // struct RegionData {
67 // SVDDecomposition decomp; // INLINE — gets moved!
68 // std::unique_ptr<SVDEvaluator> eval;
69 // };
70 // std::vector<RegionData> regions;
71 // RegionData rd;
72 // rd.decomp = build_svd(...);
73 // rd.eval = std::make_unique<SVDEvaluator>(rd.decomp);
74 // regions.push_back(std::move(rd)); // ⚠ eval->d_ now dangles
75 //
76 // The right pattern is to give the decomposition a stable heap
77 // address — either by storing the decomposition itself behind
78 // std::unique_ptr / std::shared_ptr, or by constructing the
79 // evaluator AFTER push_back from the in-vector decomp:
80 //
81 // struct RegionData {
82 // std::unique_ptr<SVDDecomposition> decomp; // heap = stable
83 // std::unique_ptr<SVDEvaluator> eval;
84 // };
85 //
86 // See dev/svd_sbtl_e2e.cpp for the canonical multi-region setup.
87 //
88 // Invariants on the decomposition (NX, NY, rank, and the sizes of
89 // U/V_S/dU_dx/dV_S_dy/x_grid/y_grid) are checked once at
90 // construction so the eval hot path can assume them.
91 explicit SVDEvaluator(const SVDDecomposition& decomp) : d_(&decomp) {
92 const auto nx = static_cast<std::size_t>(decomp.NX);
93 const auto ny = static_cast<std::size_t>(decomp.NY);
94 const auto r = static_cast<std::size_t>(decomp.rank);
95 if (decomp.NX < 2 || decomp.NY < 2 || decomp.rank <= 0 || decomp.x_grid.size() != nx || decomp.y_grid.size() != ny
96 || decomp.U.size() != nx * r || decomp.dU_dx.size() != nx * r || decomp.V_S.size() != ny * r || decomp.dV_S_dy.size() != ny * r) {
97 throw std::invalid_argument("SVDEvaluator: SVDDecomposition has inconsistent dimensions");
98 }
99 // Grids must be strictly increasing: locate() does a binary
100 // search assuming a sorted axis, and the hx / hy divisions in
101 // eval() would blow up on duplicate or descending knots.
102 // SVDBuilder enforces this at build time, but an
103 // SVDDecomposition can be constructed by hand or loaded from a
104 // file, so re-check on the evaluator side.
105 for (std::size_t i = 1; i < nx; ++i) {
106 if (!(decomp.x_grid[i - 1] < decomp.x_grid[i])) {
107 throw std::invalid_argument("SVDEvaluator: x_grid must be strictly increasing");
108 }
109 }
110 for (std::size_t j = 1; j < ny; ++j) {
111 if (!(decomp.y_grid[j - 1] < decomp.y_grid[j])) {
112 throw std::invalid_argument("SVDEvaluator: y_grid must be strictly increasing");
113 }
114 }
115 }
116 // Forbid construction from a temporary — eval() reads from the
117 // borrowed decomposition after the constructor returns.
119 explicit SVDEvaluator(const SVDDecomposition&&) = delete;
120
121 // Owning constructor: take a shared_ptr to the decomposition and
122 // hold it for the evaluator's lifetime. This is the safer pattern
123 // for callers that can't easily guarantee a stable address (the
124 // dangling-pointer foot-gun documented above). The performance
125 // cost relative to the borrowed-ref form is a single pointer hop
126 // at construction; the eval hot path is identical.
127 explicit SVDEvaluator(std::shared_ptr<const SVDDecomposition> decomp)
128 : SVDEvaluator(decomp ? *decomp
129 : throw std::invalid_argument(
130 "SVDEvaluator: shared_ptr<SVDDecomposition> must not be null")) // delegate to the ref ctor for the invariant check
131 {
132 owned_ = std::move(decomp);
133 }
134
135 // Compute the per-point context (cell indices + Hermite basis
136 // weights) once for an (x, y) input. Any number of SVDEvaluators
137 // sharing the same grids can apply eval_with_context() against the
138 // returned struct — the SVD-multi-eval batched path in SVDSurface
139 // is built on this pair.
140 [[nodiscard]] inline SVDEvalContext make_context(double x, double y) const noexcept {
141 const SVDDecomposition& d = *d_;
143 c.i = locate(d.x_grid, x);
144 c.j = locate(d.y_grid, y);
145 c.hx = d.x_grid[c.i + 1] - d.x_grid[c.i];
146 c.hy = d.y_grid[c.j + 1] - d.y_grid[c.j];
147 c.tx = (x - d.x_grid[c.i]) / c.hx;
148 c.ty = (y - d.y_grid[c.j]) / c.hy;
149 c.bx = hermite_basis(c.tx);
150 c.by = hermite_basis(c.ty);
151 return c;
152 }
153
154 // Evaluate the rank-r reconstruction given a pre-computed context.
155 // Callers that need K outputs at the same (x, y) should reuse one
156 // context across the K eval_with_context calls — the locate +
157 // Hermite-basis setup is then paid once instead of K times.
158 [[nodiscard]] inline double eval_with_context(const SVDEvalContext& c) const noexcept {
159 const SVDDecomposition& d = *d_;
160 const auto r = static_cast<std::size_t>(d.rank);
161 // NOLINTBEGIN(cppcoreguidelines-pro-bounds-pointer-arithmetic)
162 const double* U0 = d.U.data() + c.i * r;
163 const double* U1 = d.U.data() + (c.i + 1) * r;
164 const double* dU0 = d.dU_dx.data() + c.i * r;
165 const double* dU1 = d.dU_dx.data() + (c.i + 1) * r;
166 const double* V0 = d.V_S.data() + c.j * r;
167 const double* V1 = d.V_S.data() + (c.j + 1) * r;
168 const double* dV0 = d.dV_S_dy.data() + c.j * r;
169 const double* dV1 = d.dV_S_dy.data() + (c.j + 1) * r;
170 // NOLINTEND(cppcoreguidelines-pro-bounds-pointer-arithmetic)
171 const double bx10_hx = c.bx.h10 * c.hx;
172 const double bx11_hx = c.bx.h11 * c.hx;
173 const double by10_hy = c.by.h10 * c.hy;
174 const double by11_hy = c.by.h11 * c.hy;
175 double acc = 0.0;
176 // NOLINTBEGIN(cppcoreguidelines-pro-bounds-pointer-arithmetic)
177 for (std::size_t k = 0; k < r; ++k) {
178 const double u_k = c.bx.h00 * U0[k] + bx10_hx * dU0[k] + c.bx.h01 * U1[k] + bx11_hx * dU1[k];
179 const double v_k = c.by.h00 * V0[k] + by10_hy * dV0[k] + c.by.h01 * V1[k] + by11_hy * dV1[k];
180 acc += u_k * v_k;
181 }
182 // NOLINTEND(cppcoreguidelines-pro-bounds-pointer-arithmetic)
183 return (d.out_transform == OutputTransform::EXP) ? std::exp(acc) : acc;
184 }
185
186 // Evaluate the rank-r reconstruction at (x, y). No clamping; if
187 // (x, y) lies outside the grid the Hermite kernel extrapolates from
188 // the boundary cell, which can lose accuracy quickly — callers
189 // should normally gate on the Region they came from. Equivalent
190 // to make_context(x, y) followed by eval_with_context().
191 [[nodiscard]] inline double eval(double x, double y) const noexcept {
192 return eval_with_context(make_context(x, y));
193 }
194
195 private:
196 // Binary search: returns i such that grid[i] <= x <= grid[i+1],
197 // clamped to [0, n-2] so that the i+1 access is always in range.
198 // Closed on both ends at the boundaries — x == grid.back() returns
199 // n-2, so the Hermite kernel sees t = 1 on the last cell rather
200 // than t = 0 on a non-existent next cell.
201 [[nodiscard]] static inline std::size_t locate(const std::vector<double>& g, double x) noexcept {
202 const std::size_t n = g.size();
203 if (x <= g.front()) {
204 return 0;
205 }
206 if (x >= g.back()) {
207 return n - 2;
208 }
209 std::size_t lo = 0;
210 std::size_t hi = n - 1;
211 while (hi - lo > 1) {
212 const std::size_t mid = (lo + hi) / 2;
213 if (g[mid] <= x) {
214 lo = mid;
215 } else {
216 hi = mid;
217 }
218 }
219 return lo;
220 }
221
222 const SVDDecomposition* d_;
223 // Set only when constructed via the shared_ptr overload; the
224 // borrowed-ref constructor leaves this null and d_ aliases the
225 // caller-owned object.
226 std::shared_ptr<const SVDDecomposition> owned_;
227};
228
229} // namespace svd
230} // namespace CoolProp
231
232#endif // COOLPROP_SVD_SVD_EVALUATOR_H