CoolProp 8.0.0
An open-source fluid property and humid air property database
PiecewiseChebyshevCurve.cpp
Go to the documentation of this file.
2
3#include <cmath>
4#include <limits>
5#include <stdexcept>
6
7namespace CoolProp {
8namespace region {
9
10namespace {
11
12// Local portability constant. M_PI is non-standard and MSVC does not
13// define it without _USE_MATH_DEFINES; rather than reach for the
14// project-wide shim in CPnumerics.h (which pulls in unrelated math
15// utilities), keep this file self-contained.
16constexpr double kPi = 3.141592653589793238462643383279502884;
17
18// Compute Chebyshev coefficients c_0..c_N of f(s), s in [-1, 1], from
19// samples taken at the Chebyshev-Lobatto nodes s_j = cos(j*pi/N),
20// j = 0..N. Returns N+1 coefficients such that
21//
22// f(s) ~= sum_{k=0}^{N} c_k T_k(s)
23//
24// using the DCT-I formula
25//
26// c_k = (2/N) * w_k * sum_{j=0..N} w_j * f(s_j) * cos(k j pi / N)
27//
28// where w_j = 1/2 for j in {0, N}, else 1.
29std::vector<double> chebyshev_coeffs_from_samples(const std::vector<double>& fj) {
30 const std::size_t Np1 = fj.size();
31 if (Np1 < 2) {
32 throw std::invalid_argument("chebyshev_coeffs_from_samples: need at least 2 samples");
33 }
34 const std::size_t N = Np1 - 1;
35 std::vector<double> c(Np1, 0.0);
36 const double pi_N = kPi / static_cast<double>(N);
37 for (std::size_t k = 0; k <= N; ++k) {
38 double s = 0.0;
39 for (std::size_t j = 0; j <= N; ++j) {
40 const double wj = (j == 0 || j == N) ? 0.5 : 1.0;
41 s += wj * fj[j] * std::cos(static_cast<double>(k * j) * pi_N);
42 }
43 const double wk = (k == 0 || k == N) ? 0.5 : 1.0;
44 c[k] = (2.0 / static_cast<double>(N)) * wk * s;
45 }
46 return c;
47}
48
49// Clenshaw recurrence: evaluate sum_{k=0}^{N} c_k T_k(s) at s.
50double clenshaw_eval(const std::vector<double>& c, double s) noexcept {
51 if (c.empty()) {
52 return 0.0;
53 }
54 const std::size_t N = c.size() - 1;
55 double b_kp1 = 0.0;
56 double b_kp2 = 0.0;
57 // Iterate k = N, N-1, ..., 1 (inclusive). Using `k > 0` instead of
58 // `k >= 1` for the condition is equivalent for unsigned k and lets
59 // CodeQL see that the loop terminates -- the previous form (which
60 // wrote `k >= 1` with an inner `break` for k == 1) tripped a "comparison
61 // always true" alert because std::size_t is non-negative by
62 // construction.
63 for (std::size_t k = N; k > 0; --k) {
64 const double b_k = 2.0 * s * b_kp1 - b_kp2 + c[k];
65 b_kp2 = b_kp1;
66 b_kp1 = b_k;
67 }
68 return s * b_kp1 - b_kp2 + c[0];
69}
70
71// Compute the coefficient vector of the *derivative* expansion.
72//
73// If f(s) = sum_{k=0}^{N} c_k T_k(s), then df/ds = sum_{k=0}^{N-1} c'_k T_k(s)
74// with c' defined by the standard backward recurrence:
75//
76// c'_N = 0
77// c'_{k-1} = c'_{k+1} + 2 k c_k for k = N, N-1, ..., 1
78// c'_0 /= 2
79//
80// The returned vector has size N (NOT N+1) — the trivially-zero c'_N
81// coefficient is dropped so Clenshaw doesn't iterate over it.
82//
83// Note this gives df/ds, not df/da; the chain rule via dt/da and ds/dt
84// is handled at the call site.
85std::vector<double> chebyshev_derivative_coeffs(const std::vector<double>& c) {
86 const std::size_t Np1 = c.size();
87 if (Np1 < 2) {
88 return {};
89 }
90 const std::size_t N = Np1 - 1;
91 // d has size N (degree-1 expansion); indices 0..N-1.
92 std::vector<double> d(N, 0.0);
93 // Backward recurrence: c'_{k-1} = c'_{k+1} + 2k c_k, k = N..1, with
94 // c'_N implicitly zero (we never write to index N).
95 for (std::size_t kp = N; kp > 0; --kp) {
96 const double d_km1 = (kp + 1 < N ? d[kp + 1] : 0.0) + 2.0 * static_cast<double>(kp) * c[kp];
97 d[kp - 1] = d_km1;
98 }
99 d[0] *= 0.5;
100 return d;
101}
102
103// Tight extremum of the piece f(s) = sum c_k T_k(s) on s in [-1, 1].
104// Drives the AABB so the cheap RegionAtlas filter rejects probes
105// accurately — the conservative `c_0 ± sum |c_k|` bound can inflate
106// the AABB 30–50% beyond the true range and defeat the filter.
107//
108// Strategy: f is smooth, so its extrema on [-1,1] are at s = ±1 or at
109// the roots of f'(s) = 0 in (-1, 1). Evaluate f at both endpoints,
110// then root-find f' in (-1, 1) and evaluate f at each root. Track
111// running min/max.
112//
113// f' has degree N-1, with N+1 = c.size(). We sample f' on a fine
114// grid (8 * N points), look for sign changes, and bisect each
115// bracket to ~1e-10. Cheap and robust for the modest degrees we use
116// (build-time cost only).
117std::pair<double, double> tight_piece_bounds(const std::vector<double>& c, const std::vector<double>& d) noexcept {
118 auto eval_s = [&](double s) { return clenshaw_eval(c, s); };
119 auto eval_deriv = [&](double s) { return clenshaw_eval(d, s); };
120
121 double lo = eval_s(-1.0);
122 double hi = lo;
123 const double at_p1 = eval_s(1.0);
124 if (at_p1 < lo) {
125 lo = at_p1;
126 }
127 if (at_p1 > hi) {
128 hi = at_p1;
129 }
130 // 8x oversample on derivative — sufficient to bracket all roots
131 // of a polynomial of degree (N-1) where N is the curve's degree.
132 const std::size_t N = c.size() - 1;
133 const std::size_t n_samples = 8 * std::max<std::size_t>(N, 4);
134 double prev_s = -1.0;
135 double prev_d = eval_deriv(prev_s);
136 for (std::size_t k = 1; k <= n_samples; ++k) {
137 const double cur_s = -1.0 + 2.0 * static_cast<double>(k) / static_cast<double>(n_samples);
138 const double cur_d = eval_deriv(cur_s);
139 if (prev_d == 0.0 || (prev_d * cur_d < 0.0)) {
140 // Bracket [prev_s, cur_s]; bisect.
141 double a_lo = prev_s, a_hi = cur_s;
142 double f_lo = prev_d;
143 for (int it = 0; it < 60; ++it) {
144 const double mid = 0.5 * (a_lo + a_hi);
145 const double f_mid = eval_deriv(mid);
146 if (f_lo * f_mid <= 0.0) {
147 a_hi = mid;
148 } else {
149 a_lo = mid;
150 f_lo = f_mid;
151 }
152 if (a_hi - a_lo < 1e-12) {
153 break;
154 }
155 }
156 const double root_val = eval_s(0.5 * (a_lo + a_hi));
157 if (root_val < lo) {
158 lo = root_val;
159 }
160 if (root_val > hi) {
161 hi = root_val;
162 }
163 }
164 prev_s = cur_s;
165 prev_d = cur_d;
166 }
167 return {lo, hi};
168}
169
170} // namespace
171
172std::unique_ptr<PiecewiseChebyshevCurve> PiecewiseChebyshevCurve::build(double a_lo, double a_hi, std::size_t n_pieces, std::size_t degree,
173 ParamScale scale, const std::function<double(double)>& f) {
174 if (!(a_hi > a_lo)) {
175 throw std::invalid_argument("PiecewiseChebyshevCurve::build: a_hi must exceed a_lo");
176 }
177 if (scale == ParamScale::LOG && !(a_lo > 0.0)) {
178 throw std::invalid_argument("PiecewiseChebyshevCurve::build: LOG requires a_lo > 0");
179 }
180 if (n_pieces < 1) {
181 throw std::invalid_argument("PiecewiseChebyshevCurve::build: need at least 1 piece");
182 }
183 if (degree < 2) {
184 throw std::invalid_argument("PiecewiseChebyshevCurve::build: degree must be >= 2");
185 }
186
187 const double t_lo = (scale == ParamScale::LOG) ? std::log(a_lo) : a_lo;
188 const double t_hi = (scale == ParamScale::LOG) ? std::log(a_hi) : a_hi;
189 const double dt = (t_hi - t_lo) / static_cast<double>(n_pieces);
190
191 std::vector<Piece> pieces;
192 pieces.reserve(n_pieces);
193 double b_min = std::numeric_limits<double>::infinity();
194 double b_max = -std::numeric_limits<double>::infinity();
195 const std::size_t Np1 = degree + 1;
196 const double pi_over_deg = kPi / static_cast<double>(degree);
197
198 for (std::size_t p = 0; p < n_pieces; ++p) {
199 Piece piece;
200 piece.t_lo = t_lo + static_cast<double>(p) * dt;
201 piece.t_hi = piece.t_lo + dt;
202 piece.t_mid = 0.5 * (piece.t_lo + piece.t_hi);
203 piece.inv_half_span = 2.0 / (piece.t_hi - piece.t_lo);
204 // Sample f at Lobatto nodes s_j = cos(j*pi/N), j = 0..N.
205 // Reject non-finite samples up front — without this check the
206 // build silently produces a curve with NaN coefficients, and
207 // every downstream eval / aabb_contains then returns NaN/false
208 // and the atlas silently mis-dispatches.
209 std::vector<double> fj(Np1);
210 for (std::size_t j = 0; j <= degree; ++j) {
211 const double s_j = std::cos(static_cast<double>(j) * pi_over_deg);
212 const double t_j = piece.t_mid + 0.5 * dt * s_j;
213 const double a_j = (scale == ParamScale::LOG) ? std::exp(t_j) : t_j;
214 const double v = f(a_j);
215 if (!std::isfinite(v)) {
216 throw std::invalid_argument("PiecewiseChebyshevCurve::build: sample function returned non-finite value");
217 }
218 fj[j] = v;
219 }
220 piece.coeffs = chebyshev_coeffs_from_samples(fj);
221 piece.deriv_coeffs = chebyshev_derivative_coeffs(piece.coeffs);
222 // Tight bound on the piece via cubic-derivative root finding
223 // (see tight_piece_bounds): a conservative |c_0| ± Σ|c_k| bound
224 // is correct but pessimistic enough to inflate the AABB 30–50%
225 // beyond the true range, which defeats the cheap RegionAtlas
226 // filter. Tight bounds keep curve_contains rare.
227 const auto [piece_lo, piece_hi] = tight_piece_bounds(piece.coeffs, piece.deriv_coeffs);
228 if (piece_lo < b_min) {
229 b_min = piece_lo;
230 }
231 if (piece_hi > b_max) {
232 b_max = piece_hi;
233 }
234 pieces.push_back(std::move(piece));
235 }
236
237 return std::unique_ptr<PiecewiseChebyshevCurve>(new PiecewiseChebyshevCurve(a_lo, a_hi, scale, std::move(pieces), b_min, b_max));
238}
239
240PiecewiseChebyshevCurve::PiecewiseChebyshevCurve(double a_lo, double a_hi, ParamScale scale, std::vector<Piece> pieces, double b_min, double b_max)
241 : a_lo_(a_lo), a_hi_(a_hi), scale_(scale), pieces_(std::move(pieces)), b_min_(b_min), b_max_(b_max) {}
242
244 State s;
245 s.a_lo = a_lo_;
246 s.a_hi = a_hi_;
247 s.scale = scale_;
248 s.b_min = b_min_;
249 s.b_max = b_max_;
250 s.pieces.reserve(pieces_.size());
251 for (const auto& p : pieces_) {
252 s.pieces.push_back(PieceState{p.t_lo, p.t_hi, p.inv_half_span, p.t_mid, p.coeffs, p.deriv_coeffs});
253 }
254 return s;
255}
256
257std::unique_ptr<PiecewiseChebyshevCurve> PiecewiseChebyshevCurve::from_state(State s) {
258 if (s.pieces.empty()) {
259 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: pieces is empty");
260 }
261 if (!(s.a_hi > s.a_lo)) {
262 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: a_hi must exceed a_lo");
263 }
264 std::vector<Piece> pieces;
265 pieces.reserve(s.pieces.size());
266 for (auto& ps : s.pieces) {
267 if (ps.coeffs.size() < 2) {
268 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: each piece needs >= 2 coefficients");
269 }
270 if (ps.deriv_coeffs.size() + 1 != ps.coeffs.size()) {
271 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: deriv_coeffs size must be coeffs.size() - 1");
272 }
273 if (!(ps.t_hi > ps.t_lo)) {
274 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: each piece must have t_hi > t_lo");
275 }
276 Piece p;
277 p.t_lo = ps.t_lo;
278 p.t_hi = ps.t_hi;
279 p.inv_half_span = ps.inv_half_span;
280 p.t_mid = ps.t_mid;
281 p.coeffs = std::move(ps.coeffs);
282 p.deriv_coeffs = std::move(ps.deriv_coeffs);
283 pieces.push_back(std::move(p));
284 }
285 // `locate_piece` does an O(1) hit assuming pieces are sorted,
286 // contiguous, and uniformly sized in t -- invariants build()
287 // guarantees, but a malformed blob could break any of them and
288 // silently route queries to the wrong piece. Validate up-front so
289 // the O(1) lookup math is always safe.
290 const double dt0 = pieces.front().t_hi - pieces.front().t_lo;
291 const double eps = 1e-9 * std::max(1.0, std::abs(dt0));
292 for (std::size_t i = 0; i < pieces.size(); ++i) {
293 if (i > 0 && std::abs(pieces[i].t_lo - pieces[i - 1].t_hi) > eps) {
294 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: pieces must be contiguous in t (gap or overlap detected)");
295 }
296 const double dt = pieces[i].t_hi - pieces[i].t_lo;
297 if (std::abs(dt - dt0) > eps) {
298 throw std::invalid_argument("PiecewiseChebyshevCurve::from_state: pieces must be uniformly spaced in t (locate_piece assumes this)");
299 }
300 }
301 return std::unique_ptr<PiecewiseChebyshevCurve>(new PiecewiseChebyshevCurve(s.a_lo, s.a_hi, s.scale, std::move(pieces), s.b_min, s.b_max));
302}
303
304double PiecewiseChebyshevCurve::to_t(double a) const noexcept {
305 return (scale_ == ParamScale::LOG) ? std::log(a) : a;
306}
307
308double PiecewiseChebyshevCurve::dt_da(double a) const noexcept {
309 return (scale_ == ParamScale::LOG) ? 1.0 / a : 1.0;
310}
311
312std::size_t PiecewiseChebyshevCurve::locate_piece(double t) const noexcept {
313 const std::size_t n = pieces_.size();
314 if (n == 1) {
315 return 0;
316 }
317 // NaN guard: casting NaN to ptrdiff_t (below) is UB, and the
318 // `t <= t0` / `t >= tn` guards are both false on NaN. Return
319 // index 0 for any non-finite input — callers can detect bogus
320 // queries via the eventual NaN they get back from eval().
321 if (!std::isfinite(t)) {
322 return 0;
323 }
324 // Pieces are uniformly spaced in t (the build guarantees this), so
325 // an O(1) hit is available.
326 const double t0 = pieces_.front().t_lo;
327 const double tn = pieces_.back().t_hi;
328 if (t <= t0) {
329 return 0;
330 }
331 if (t >= tn) {
332 return n - 1;
333 }
334 const double dt = pieces_.front().t_hi - pieces_.front().t_lo;
335 auto idx = static_cast<std::ptrdiff_t>((t - t0) / dt);
336 if (idx < 0) {
337 return 0;
338 }
339 if (static_cast<std::size_t>(idx) >= n) {
340 return n - 1;
341 }
342 return static_cast<std::size_t>(idx);
343}
344
345double PiecewiseChebyshevCurve::eval(double a) const noexcept {
346 const double t = to_t(a);
347 const std::size_t p = locate_piece(t);
348 const Piece& pc = pieces_[p];
349 const double s = (t - pc.t_mid) * pc.inv_half_span;
350 return clenshaw_eval(pc.coeffs, s);
351}
352
353double PiecewiseChebyshevCurve::eval_da(double a) const noexcept {
354 const double t = to_t(a);
355 const std::size_t p = locate_piece(t);
356 const Piece& pc = pieces_[p];
357 const double s = (t - pc.t_mid) * pc.inv_half_span;
358 // df/ds via the precomputed derivative coefficients (built once at
359 // construction; see Piece::deriv_coeffs). Chain rule via ds/dt
360 // and dt/da at the call site.
361 const double df_ds = clenshaw_eval(pc.deriv_coeffs, s);
362 return df_ds * pc.inv_half_span * dt_da(a);
363}
364
365std::pair<double, double> PiecewiseChebyshevCurve::bounds() const noexcept {
366 return {b_min_, b_max_};
367}
368
369std::pair<double, double> PiecewiseChebyshevCurve::a_range() const noexcept {
370 return {a_lo_, a_hi_};
371}
372
373} // namespace region
374} // namespace CoolProp