CoolProp 8.0.0
An open-source fluid property and humid air property database
CoolProp-Tests-SVDComponents.cpp
Go to the documentation of this file.
1// Correctness and benchmark tests for the generic SVD + Region
2// components (include/CoolProp/svd, include/CoolProp/region).
3//
4// Built into the existing CatchTestRunner target when
5// COOLPROP_CATCH_MODULE=ON. Correctness tests run by default; the
6// benchmark cases are tagged [!benchmark] so they only execute when
7// invoked explicitly (e.g. `CatchTestRunner "[!benchmark]"`).
8
20
21#if defined(ENABLE_CATCH)
22# include <catch2/catch_all.hpp>
23
24# include <cmath>
25# include <random>
26# include <vector>
27
28namespace cp_svd = CoolProp::svd;
29namespace cp_region = CoolProp::region;
30
31namespace {
32// Local portability constant — M_PI is non-standard on MSVC without
33// _USE_MATH_DEFINES.
34constexpr double kPi = 3.141592653589793238462643383279502884;
35} // namespace
36
37// ============================================================
38// AxisTransform
39// ============================================================
40
41TEST_CASE("AxisTransform linear round-trip", "[SVDComponents][Region][axis_transform]") {
42 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 1.5, 9.5);
43 std::mt19937 rng(1);
44 std::uniform_real_distribution<double> u(1.5, 9.5);
45 for (int k = 0; k < 100; ++k) {
46 const double a = u(rng);
47 const double xi = t.forward(a);
48 REQUIRE(xi >= -1e-15);
49 REQUIRE(xi <= 1.0 + 1e-15);
50 const double a_back = t.inverse(xi);
51 REQUIRE(std::abs(a_back - a) / a < 1e-14);
52 }
53}
54
55TEST_CASE("AxisTransform log round-trip", "[SVDComponents][Region][axis_transform]") {
56 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::LOG, 1e-3, 1e3);
57 std::mt19937 rng(2);
58 std::uniform_real_distribution<double> u(std::log(1e-3), std::log(1e3));
59 for (int k = 0; k < 100; ++k) {
60 const double a = std::exp(u(rng));
61 const double xi = t.forward(a);
62 REQUIRE(xi >= -1e-15);
63 REQUIRE(xi <= 1.0 + 1e-15);
64 const double a_back = t.inverse(xi);
65 REQUIRE(std::abs(a_back - a) / a < 1e-14);
66 }
67}
68
69TEST_CASE("AxisTransform analytic Jacobian", "[SVDComponents][Region][axis_transform]") {
70 const auto t_lin = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 2.0, 10.0);
71 REQUIRE(t_lin.dxi_da(5.0) == Catch::Approx(1.0 / 8.0).margin(1e-15));
72
73 const auto t_log = cp_region::AxisTransform::make(cp_region::AxisScale::LOG, 1.0, 100.0);
74 // Analytic: dxi/da = 1 / (a * log(100)). Cross-check by central FD.
75 const double a = 10.0;
76 const double fd = (t_log.forward(a * (1 + 1e-7)) - t_log.forward(a * (1 - 1e-7))) / (2.0 * a * 1e-7);
77 REQUIRE(t_log.dxi_da(a) == Catch::Approx(fd).epsilon(1e-6));
78}
79
80TEST_CASE("AxisTransform power round-trip", "[SVDComponents][Region][axis_transform]") {
81 // POWER β=1/3 (CoolProp-4u9): xi=0 → a_lo, xi=1 → a_hi, crowds at a_hi.
82 // Use a band typical of NC near-critical use (R245fa-like p range).
83 const double a_lo = 0.9 * 3.651e6; // 0.9·pc
84 const double a_hi = (1.0 - 1e-6) * 3.651e6; // (1 − 1ppm)·pc
85 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::POWER, a_lo, a_hi);
86 std::mt19937 rng(3);
87 std::uniform_real_distribution<double> u(a_lo, a_hi);
88 for (int k = 0; k < 100; ++k) {
89 const double a = u(rng);
90 const double xi = t.forward(a);
91 REQUIRE(xi >= -1e-12);
92 REQUIRE(xi <= 1.0 + 1e-12);
93 const double a_back = t.inverse(xi);
94 REQUIRE(std::abs(a_back - a) / a < 1e-12);
95 }
96 // Endpoints exact.
97 REQUIRE(t.forward(a_lo) == Catch::Approx(0.0).margin(1e-15));
98 REQUIRE(t.forward(a_hi) == Catch::Approx(1.0).margin(1e-15));
99 REQUIRE(t.inverse(0.0) == Catch::Approx(a_lo).margin(1e-15));
100 REQUIRE(t.inverse(1.0) == Catch::Approx(a_hi).margin(1e-15));
101 // Crowding direction: at xi=0.5 the physical a is much closer to a_hi
102 // than to a_lo. (1 − 0.5)³ = 0.125 → a = a_hi − 0.125·(a_hi−a_lo),
103 // i.e. 87.5% of the way to a_hi.
104 const double a_mid = t.inverse(0.5);
105 const double frac = (a_mid - a_lo) / (a_hi - a_lo);
106 REQUIRE(frac == Catch::Approx(0.875).margin(1e-15));
107 // POWER requires a_hi > a_lo (no LOG-style positive-bound check; physical
108 // pressures > 0 are sufficient).
109 REQUIRE_THROWS_AS(cp_region::AxisTransform::make(cp_region::AxisScale::POWER, 1.0, 1.0), std::invalid_argument);
110}
111
112TEST_CASE("AxisTransform power Jacobian vs central FD", "[SVDComponents][Region][axis_transform]") {
113 // POWER's analytic dxi/da diverges as a → a_hi; check at a few
114 // interior points where central FD is well-conditioned.
115 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::POWER, 1.0, 2.0);
116 for (double a : {1.1, 1.3, 1.5, 1.7, 1.9}) {
117 const double h = 1e-7 * a;
118 const double fd = (t.forward(a + h) - t.forward(a - h)) / (2.0 * h);
119 REQUIRE(t.dxi_da(a) == Catch::Approx(fd).epsilon(1e-5));
120 }
121}
122
123TEST_CASE("AxisTransform power_lo round-trip", "[SVDComponents][Region][axis_transform]") {
124 // POWER_LO β=1/3 (CoolProp-4u9): xi=0 → a_lo, xi=1 → a_hi, crowds at a_lo.
125 // Use a band typical of NC_SUPER (R245fa-like, just above pc).
126 const double a_lo = (1.0 + 1e-10) * 3.651e6;
127 const double a_hi = 1.1 * 3.651e6;
128 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::POWER_LO, a_lo, a_hi);
129 std::mt19937 rng(4);
130 std::uniform_real_distribution<double> u(a_lo, a_hi);
131 for (int k = 0; k < 100; ++k) {
132 const double a = u(rng);
133 const double xi = t.forward(a);
134 REQUIRE(xi >= -1e-12);
135 REQUIRE(xi <= 1.0 + 1e-12);
136 const double a_back = t.inverse(xi);
137 REQUIRE(std::abs(a_back - a) / a < 1e-12);
138 }
139 // Endpoints exact.
140 REQUIRE(t.forward(a_lo) == Catch::Approx(0.0).margin(1e-15));
141 REQUIRE(t.forward(a_hi) == Catch::Approx(1.0).margin(1e-15));
142 REQUIRE(t.inverse(0.0) == Catch::Approx(a_lo).margin(1e-15));
143 REQUIRE(t.inverse(1.0) == Catch::Approx(a_hi).margin(1e-15));
144 // Mirror of POWER: at xi=0.5 the physical a is 12.5% of the way to a_hi
145 // (POWER had 87.5%). (0.5)³ = 0.125 → a = a_lo + 0.125·(a_hi−a_lo).
146 const double a_mid = t.inverse(0.5);
147 const double frac = (a_mid - a_lo) / (a_hi - a_lo);
148 REQUIRE(frac == Catch::Approx(0.125).margin(1e-15));
149}
150
151TEST_CASE("AxisTransform power_lo Jacobian vs central FD", "[SVDComponents][Region][axis_transform]") {
152 // POWER_LO's analytic dxi/da diverges as a → a_lo; check interior points.
153 const auto t = cp_region::AxisTransform::make(cp_region::AxisScale::POWER_LO, 1.0, 2.0);
154 for (double a : {1.1, 1.3, 1.5, 1.7, 1.9}) {
155 const double h = 1e-7 * a;
156 const double fd = (t.forward(a + h) - t.forward(a - h)) / (2.0 * h);
157 REQUIRE(t.dxi_da(a) == Catch::Approx(fd).epsilon(1e-5));
158 }
159}
160
161// ============================================================
162// BoundaryCurve: ConstantCurve
163// ============================================================
164
165TEST_CASE("ConstantCurve eval / eval_da / bounds", "[SVDComponents][Region][boundary_curve_constant]") {
166 cp_region::ConstantCurve c(0.0, 5.0, 3.14);
167 REQUIRE(c.eval(2.7) == 3.14);
168 REQUIRE(c.eval_da(2.7) == 0.0);
169 REQUIRE(c.bounds().first == 3.14);
170 REQUIRE(c.bounds().second == 3.14);
171 REQUIRE(c.a_range().first == 0.0);
172 REQUIRE(c.a_range().second == 5.0);
173}
174
175// ============================================================
176// BoundaryCurve: PiecewiseChebyshevCurve
177// ============================================================
178
179TEST_CASE("PiecewiseChebyshevCurve fits a smooth function (LINEAR axis)", "[SVDComponents][Region][boundary_curve_cheb]") {
180 const auto f = [](double a) { return std::sin(a) + 0.5 * a; };
181 auto curve =
182 cp_region::PiecewiseChebyshevCurve::build(0.5, 5.5, /*n_pieces=*/4, /*degree=*/12, cp_region::PiecewiseChebyshevCurve::ParamScale::LINEAR, f);
183
184 std::mt19937 rng(7);
185 std::uniform_real_distribution<double> u(0.6, 5.4);
186 for (int k = 0; k < 100; ++k) {
187 const double a = u(rng);
188 REQUIRE(curve->eval(a) == Catch::Approx(f(a)).epsilon(1e-10));
189 // Analytic eval_da cross-checked against central FD.
190 const double h = 1e-6;
191 const double fd = (curve->eval(a + h) - curve->eval(a - h)) / (2.0 * h);
192 REQUIRE(curve->eval_da(a) == Catch::Approx(fd).epsilon(1e-6));
193 }
194}
195
196TEST_CASE("PiecewiseChebyshevCurve fits a smooth function (LOG axis)", "[SVDComponents][Region][boundary_curve_cheb]") {
197 // Use a function that is smooth in log space.
198 const auto f = [](double a) { return std::log(a) * std::log(a); };
199 auto curve =
200 cp_region::PiecewiseChebyshevCurve::build(1.0, 1e4, /*n_pieces=*/3, /*degree=*/10, cp_region::PiecewiseChebyshevCurve::ParamScale::LOG, f);
201
202 std::mt19937 rng(9);
203 std::uniform_real_distribution<double> u(std::log(1.05), std::log(1e4 * 0.99));
204 for (int k = 0; k < 100; ++k) {
205 const double a = std::exp(u(rng));
206 REQUIRE(curve->eval(a) == Catch::Approx(f(a)).epsilon(1e-8));
207 const double h = a * 1e-6;
208 const double fd = (curve->eval(a + h) - curve->eval(a - h)) / (2.0 * h);
209 REQUIRE(curve->eval_da(a) == Catch::Approx(fd).epsilon(1e-5));
210 }
211}
212
213// ============================================================
214// BoundaryCurve: CubicSplineCurve
215// ============================================================
216
217TEST_CASE("CubicSplineCurve through known knots", "[SVDComponents][Region][boundary_curve_spline]") {
218 // Knots from y = x^2 (a strict-convex test).
219 std::vector<double> x = {0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0};
220 std::vector<double> y;
221 y.reserve(x.size());
222 for (double xi : x) {
223 y.push_back(xi * xi);
224 }
225 auto curve = cp_region::CubicSplineCurve::build(x, y);
226
227 // Spline interpolates each knot exactly (to roundoff).
228 for (std::size_t i = 0; i < x.size(); ++i) {
229 REQUIRE(curve->eval(x[i]) == Catch::Approx(y[i]).margin(1e-13));
230 }
231 // Analytic eval_da matches central FD.
232 std::mt19937 rng(11);
233 std::uniform_real_distribution<double> u(0.05, 2.95);
234 for (int k = 0; k < 50; ++k) {
235 const double a = u(rng);
236 const double h = 1e-6;
237 const double fd = (curve->eval(a + h) - curve->eval(a - h)) / (2.0 * h);
238 REQUIRE(curve->eval_da(a) == Catch::Approx(fd).epsilon(1e-5));
239 }
240 // Tight bounds enclose the knot values.
241 const auto bnds = curve->bounds();
242 REQUIRE(bnds.first <= 0.0);
243 REQUIRE(bnds.second >= 9.0);
244}
245
246// ============================================================
247// Region + RegionAtlas: hand-built thermodynamics-free atlas
248// ============================================================
249
250namespace {
251
252// Build a deliberately overlapping-AABB but disjoint-curve atlas.
253//
254// Primary axis a in [0, 10], linear.
255// Region L: b_lo = 0, b_hi = 0.5 * a (lower triangle)
256// Region V: b_lo = 0.5 * a, b_hi = 10 (upper region above the line)
257//
258// The two regions are disjoint by construction, but their AABBs both
259// span (a in [0,10], b in [0,10]), so the AABB filter admits both.
260// curve_contains is what disambiguates.
261cp_region::RegionAtlas build_test_atlas() {
262 cp_region::RegionAtlas atlas;
263 // LIQUID-like.
264 {
265 auto axis = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 0.0, 10.0);
266 auto lo = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 0.0);
267 auto hi = cp_region::PiecewiseChebyshevCurve::build(0.0, 10.0, 1, 4, cp_region::PiecewiseChebyshevCurve::ParamScale::LINEAR,
268 [](double a) { return 0.5 * a; });
269 atlas.add(cp_region::Region(axis, std::move(lo), std::move(hi)));
270 }
271 // VAPOR-like.
272 {
273 auto axis = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 0.0, 10.0);
274 auto lo = cp_region::PiecewiseChebyshevCurve::build(0.0, 10.0, 1, 4, cp_region::PiecewiseChebyshevCurve::ParamScale::LINEAR,
275 [](double a) { return 0.5 * a; });
276 auto hi = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 10.0);
277 atlas.add(cp_region::Region(axis, std::move(lo), std::move(hi)));
278 }
279 return atlas;
280}
281
282} // namespace
283
284TEST_CASE("RegionAtlas dispatch picks the right region", "[SVDComponents][Region][atlas_dispatch]") {
285 auto atlas = build_test_atlas();
286 REQUIRE(atlas.size() == 2);
287
288 // Clearly inside region 0 (LIQUID-like).
289 REQUIRE(atlas.find_region(2.0, 0.3) == 0);
290 // Clearly inside region 1 (VAPOR-like).
291 REQUIRE(atlas.find_region(2.0, 5.0) == 1);
292 // Outside both (a > 10).
293 REQUIRE(atlas.find_region(11.0, 5.0) == -1);
294 // Outside both (b < 0).
295 REQUIRE(atlas.find_region(2.0, -0.1) == -1);
296}
297
298TEST_CASE("RegionAtlas AABB filter rejects strictly more than curve filter", "[SVDComponents][Region][aabb_first]") {
299 auto atlas = build_test_atlas();
300 // For each region we count how many random (a, b) probes pass the
301 // AABB filter (would *trigger* a curve check) vs how many pass the
302 // curve filter. Since the AABBs of both regions completely overlap
303 // but the curve envelopes are disjoint, AABB hits = 2 * curve hits
304 // for points that land inside the union of the two regions.
305 std::mt19937 rng(13);
306 std::uniform_real_distribution<double> ua(0.0, 10.0);
307 std::uniform_real_distribution<double> ub(0.0, 10.0);
308 int aabb_hits = 0;
309 int curve_hits = 0;
310 for (int k = 0; k < 10000; ++k) {
311 const double a = ua(rng);
312 const double b = ub(rng);
313 for (std::size_t i = 0; i < atlas.size(); ++i) {
314 if (atlas.region(i).aabb_contains(a, b)) {
315 ++aabb_hits;
316 if (atlas.region(i).curve_contains(a, b)) {
317 ++curve_hits;
318 }
319 }
320 }
321 }
322 INFO("aabb_hits=" << aabb_hits << " curve_hits=" << curve_hits);
323 REQUIRE(aabb_hits > curve_hits);
324 // Sanity: every point that passes a curve passed an AABB.
325 REQUIRE(aabb_hits >= curve_hits);
326}
327
328TEST_CASE("RegionAtlas::find_region rejects NaN inputs cleanly", "[SVDComponents][Region][atlas_dispatch][nan]") {
329 auto atlas = build_test_atlas();
330 const double nan_v = std::numeric_limits<double>::quiet_NaN();
331 // The earlier negated AABB form was C++ UB on NaN inputs (every
332 // comparison false → `continue` skipped → locate_piece cast NaN
333 // to ptrdiff_t). Positive form rejects NaN at the filter.
334 REQUIRE(atlas.find_region(nan_v, 1.0) == -1);
335 REQUIRE(atlas.find_region(1.0, nan_v) == -1);
336 REQUIRE(atlas.find_region(nan_v, nan_v) == -1);
337}
338
339TEST_CASE("RegionAtlas handles 0- and 1-region cases", "[SVDComponents][Region][atlas_dispatch]") {
340 cp_region::RegionAtlas empty;
341 REQUIRE(empty.size() == 0);
342 REQUIRE(empty.find_region(1.0, 1.0) == -1);
343
344 cp_region::RegionAtlas one;
345 {
346 auto axis = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 0.0, 10.0);
347 auto lo = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 0.0);
348 auto hi = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 10.0);
349 one.add(cp_region::Region(axis, std::move(lo), std::move(hi)));
350 }
351 REQUIRE(one.size() == 1);
352 REQUIRE(one.find_region(5.0, 5.0) == 0);
353 REQUIRE(one.find_region(11.0, 5.0) == -1); // outside primary axis
354 REQUIRE(one.find_region(5.0, 11.0) == -1); // outside secondary axis
355}
356
357TEST_CASE("Region moved-from instance fails aabb_contains cleanly", "[SVDComponents][Region][move]") {
358 auto axis = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 0.0, 10.0);
359 auto lo = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 0.0);
360 auto hi = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 10.0);
361 cp_region::Region original(axis, std::move(lo), std::move(hi));
362 REQUIRE(original.aabb_contains(5.0, 5.0));
363
364 cp_region::Region moved = std::move(original);
365 REQUIRE(moved.aabb_contains(5.0, 5.0));
366 // CRITICAL: original is moved-from with null curves; the bbox_
367 // must be invalidated so aabb_contains returns false. Without
368 // the custom move op, a subsequent curve_contains call would
369 // dereference a null unique_ptr and segfault.
370 REQUIRE_FALSE(original.aabb_contains(5.0, 5.0));
371 REQUIRE_FALSE(original.aabb_contains(0.0, 0.0));
372}
373
374TEST_CASE("PiecewiseChebyshevCurve::build rejects non-finite samples", "[SVDComponents][Region][boundary_curve_cheb][nan]") {
375 using PCC = cp_region::PiecewiseChebyshevCurve;
376 REQUIRE_THROWS_AS(PCC::build(0.0, 10.0, 2, 6, PCC::ParamScale::LINEAR, [](double /*a*/) { return std::numeric_limits<double>::quiet_NaN(); }),
377 std::invalid_argument);
378 REQUIRE_THROWS_AS(
379 PCC::build(0.0, 10.0, 2, 6, PCC::ParamScale::LINEAR, [](double a) { return a < 5.0 ? 1.0 : std::numeric_limits<double>::infinity(); }),
380 std::invalid_argument);
381}
382
383TEST_CASE("Region::to_normalized returns NaN on degenerate span", "[SVDComponents][Region][to_normalized][degenerate]") {
384 // Two ConstantCurves at the same value — a pinch by construction.
385 auto axis = cp_region::AxisTransform::make(cp_region::AxisScale::LINEAR, 0.0, 10.0);
386 auto lo = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 5.0);
387 auto hi = std::make_unique<cp_region::ConstantCurve>(0.0, 10.0, 5.0);
388 cp_region::Region pinched(axis, std::move(lo), std::move(hi));
389 const auto [xi, eta] = pinched.to_normalized(3.0, 5.0);
390 REQUIRE(std::isfinite(xi)); // primary axis OK
391 REQUIRE(std::isnan(eta)); // secondary span = 0 → NaN, fail-loud
392}
393
394TEST_CASE("PiecewiseChebyshevCurve tight bounds match interior extrema", "[SVDComponents][Region][boundary_curve_cheb][bounds]") {
395 // sin(2 pi a) on [0, 1] has interior extrema at a = 0.25 (+1) and
396 // a = 0.75 (-1) that the sampled-node approach would miss with low
397 // n_pieces. Verify tight bounds capture them.
398 using PCC = cp_region::PiecewiseChebyshevCurve;
399 auto curve = PCC::build(0.0, 1.0, 2, 12, PCC::ParamScale::LINEAR, [](double a) { return std::sin(2.0 * kPi * a); });
400 const auto bnds = curve->bounds();
401 // True extrema are ±1. Tight bounds should match within Chebyshev
402 // truncation tolerance (degree-12 Cheb on sin is ~1e-10).
403 REQUIRE(bnds.first == Catch::Approx(-1.0).margin(1e-6));
404 REQUIRE(bnds.second == Catch::Approx(1.0).margin(1e-6));
405}
406
407TEST_CASE("Region (a, b) <-> (xi, eta) round-trip", "[SVDComponents][Region][round_trip]") {
408 auto atlas = build_test_atlas();
409 std::mt19937 rng(17);
410 std::uniform_real_distribution<double> u(0.0, 1.0);
411 int round_tripped = 0;
412 for (int k = 0; k < 200; ++k) {
413 const double xi = u(rng);
414 const double eta = u(rng);
415 for (std::size_t i = 0; i < atlas.size(); ++i) {
416 const auto ab = atlas.region(i).from_normalized(xi, eta);
417 const auto xe = atlas.region(i).to_normalized(ab.first, ab.second);
418 REQUIRE(xe.first == Catch::Approx(xi).epsilon(1e-12));
419 REQUIRE(xe.second == Catch::Approx(eta).epsilon(1e-12));
420 ++round_tripped;
421 }
422 }
423 REQUIRE(round_tripped > 0);
424}
425
426// ============================================================
427// SVD end-to-end + slope source comparison
428// ============================================================
429
430namespace {
431
432// Test function: f(x, y) = sin(pi x) * (1 + 0.3 sin(pi y)) on [0, 1]^2.
433//
434// Two reasons for this choice:
435// 1. The second derivative of sin(pi z) vanishes at z = 0 and z = 1,
436// so the natural-cubic-spline boundary condition (M = 0 at the
437// endpoints) is *correct* for this function. That makes spline
438// slopes strictly better than central-FD slopes here, which is
439// what we want to assert.
440// 2. The function is exactly separable, so its SVD is dominated by a
441// single mode plus a small correction — rank 4 is overkill for
442// tiny error.
443cp_svd::SVDDecomposition build_test_decomposition(cp_svd::SlopeSource slope_source, std::int32_t rank) {
444 const int N = 64;
445 std::vector<double> xg(N), yg(N), M(N * N);
446 for (int i = 0; i < N; ++i) {
447 xg[i] = static_cast<double>(i) / (N - 1);
448 }
449 for (int j = 0; j < N; ++j) {
450 yg[j] = static_cast<double>(j) / (N - 1);
451 }
452 for (int i = 0; i < N; ++i) {
453 for (int j = 0; j < N; ++j) {
454 M[i * N + j] = std::sin(kPi * xg[i]) * (1.0 + 0.3 * std::sin(kPi * yg[j]));
455 }
456 }
457 cp_svd::SVDBuildOptions opts;
458 opts.rank = rank;
459 opts.out_transform = cp_svd::OutputTransform::IDENTITY;
460 opts.slope_source = slope_source;
461 return cp_svd::build_svd(xg, yg, M, opts);
462}
463
464double test_function_truth(double x, double y) {
465 return std::sin(kPi * x) * (1.0 + 0.3 * std::sin(kPi * y));
466}
467
468} // namespace
469
470TEST_CASE("SVD round-trip on a smooth analytic 2D function", "[SVDComponents][SVD][roundtrip]") {
471 auto d = build_test_decomposition(cp_svd::SlopeSource::NATURAL_CUBIC_SPLINE, 4);
472 cp_svd::SVDEvaluator ev(d);
473 std::mt19937 rng(19);
474 std::uniform_real_distribution<double> u(0.05, 0.95);
475 double max_rel = 0.0;
476 for (int k = 0; k < 2000; ++k) {
477 const double x = u(rng);
478 const double y = u(rng);
479 const double truth = test_function_truth(x, y);
480 const double pred = ev.eval(x, y);
481 const double rel = std::abs(pred - truth) / std::max(std::abs(truth), 1e-12);
482 if (rel > max_rel) {
483 max_rel = rel;
484 }
485 }
486 INFO("max_rel = " << max_rel);
487 REQUIRE(max_rel < 1e-4);
488}
489
490TEST_CASE("SVDEvaluator shared_ptr constructor owns the decomposition", "[SVDComponents][SVD][shared_ptr_ownership]") {
491 auto d_ptr = std::make_shared<cp_svd::SVDDecomposition>(build_test_decomposition(cp_svd::SlopeSource::NATURAL_CUBIC_SPLINE, 4));
492 cp_svd::SVDEvaluator ev{d_ptr};
493 // Drop the caller's reference — owned_ must keep the decomposition
494 // alive past this point.
495 d_ptr.reset();
496 const double truth = test_function_truth(0.3, 0.7);
497 const double pred = ev.eval(0.3, 0.7);
498 REQUIRE(std::abs(pred - truth) / std::max(std::abs(truth), 1e-12) < 1e-4);
499}
500
501TEST_CASE("SVDEvaluator EXP transform stays finite on extrapolation", "[SVDComponents][SVD][exp_overflow]") {
502 // exp(acc) overflows when acc > ~709. The Hermite kernel can
503 // amplify modes well past the build domain — for callers that hit
504 // an out-of-domain probe, the result should at least be a finite
505 // double rather than +inf. Empirical check: probe at (10, 10),
506 // well outside the build range; the result must be finite (we
507 // don't care about correctness, just no inf/NaN).
508 const int N = 32;
509 std::vector<double> xg(N), yg(N), M(N * N);
510 for (int i = 0; i < N; ++i) {
511 xg[i] = static_cast<double>(i) / (N - 1);
512 }
513 for (int j = 0; j < N; ++j) {
514 yg[j] = static_cast<double>(j) / (N - 1);
515 }
516 for (int i = 0; i < N; ++i) {
517 for (int j = 0; j < N; ++j) {
518 // log-space matrix with bounded values (~[-1, 1]).
519 M[i * N + j] = std::sin(kPi * xg[i]) * std::cos(kPi * yg[j]);
520 }
521 }
522 cp_svd::SVDBuildOptions opts;
523 opts.rank = 6;
524 opts.out_transform = cp_svd::OutputTransform::EXP;
525 auto d = cp_svd::build_svd(xg, yg, M, opts);
526 cp_svd::SVDEvaluator ev(d);
527 // At the corners of the build domain, eval should return finite.
528 REQUIRE(std::isfinite(ev.eval(0.001, 0.001)));
529 REQUIRE(std::isfinite(ev.eval(0.999, 0.999)));
530 // Well outside — extrapolation regime. Just assert finite (the
531 // value is meaningless but it shouldn't be inf).
532 REQUIRE(std::isfinite(ev.eval(0.0, 0.0)));
533 REQUIRE(std::isfinite(ev.eval(1.0, 1.0)));
534}
535
536TEST_CASE("SVD natural-cubic-spline slopes outperform Hermite-FD", "[SVDComponents][SVD][slope_source]") {
537 // Compare the two slope sources on a function whose second
538 // derivative vanishes at the build-domain boundaries. Spline's
539 // natural BC is then exact, FD's local-stencil approach is not, so
540 // spline should produce strictly smaller error.
541 auto d_fd = build_test_decomposition(cp_svd::SlopeSource::HERMITE_FD, 4);
542 auto d_spline = build_test_decomposition(cp_svd::SlopeSource::NATURAL_CUBIC_SPLINE, 4);
543 cp_svd::SVDEvaluator ev_fd(d_fd);
544 cp_svd::SVDEvaluator ev_spline(d_spline);
545 std::mt19937 rng(21);
546 std::uniform_real_distribution<double> u(0.05, 0.95);
547 double max_fd = 0.0, max_spline = 0.0;
548 for (int k = 0; k < 2000; ++k) {
549 const double x = u(rng);
550 const double y = u(rng);
551 const double truth = test_function_truth(x, y);
552 const double rel_fd = std::abs(ev_fd.eval(x, y) - truth) / std::max(std::abs(truth), 1e-12);
553 const double rel_spline = std::abs(ev_spline.eval(x, y) - truth) / std::max(std::abs(truth), 1e-12);
554 if (rel_fd > max_fd) {
555 max_fd = rel_fd;
556 }
557 if (rel_spline > max_spline) {
558 max_spline = rel_spline;
559 }
560 }
561 INFO("max_fd=" << max_fd << " max_spline=" << max_spline);
562 REQUIRE(max_spline <= max_fd);
563}
564
565TEST_CASE("SVD EXP transform recovers values from log-space build", "[SVDComponents][SVD][roundtrip]") {
566 // Build the SVD of log(f) and ask the evaluator to apply exp on the
567 // way out — the round-trip should still hit the original f values.
568 const int N = 32;
569 std::vector<double> xg(N), yg(N), M(N * N);
570 for (int i = 0; i < N; ++i) {
571 xg[i] = 0.1 + 4.0 * i / (N - 1);
572 }
573 for (int j = 0; j < N; ++j) {
574 yg[j] = 0.1 + 4.0 * j / (N - 1);
575 }
576 for (int i = 0; i < N; ++i) {
577 for (int j = 0; j < N; ++j) {
578 // f is strictly positive; use log here.
579 M[i * N + j] = std::log(xg[i] * xg[i] + yg[j] * yg[j] + 1.0);
580 }
581 }
582 cp_svd::SVDBuildOptions opts;
583 opts.rank = 6;
584 opts.out_transform = cp_svd::OutputTransform::EXP;
585 auto d = cp_svd::build_svd(xg, yg, M, opts);
586 cp_svd::SVDEvaluator ev(d);
587 std::mt19937 rng(23);
588 std::uniform_real_distribution<double> u(0.2, 3.9);
589 double max_rel = 0.0;
590 for (int k = 0; k < 1000; ++k) {
591 const double x = u(rng);
592 const double y = u(rng);
593 const double truth_log = std::log(x * x + y * y + 1.0);
594 const double truth = std::exp(truth_log);
595 const double pred = ev.eval(x, y);
596 const double rel = std::abs(pred - truth) / truth;
597 if (rel > max_rel) {
598 max_rel = rel;
599 }
600 }
601 REQUIRE(max_rel < 1e-2);
602}
603
604// ============================================================
605// Benchmarks (opt-in via [!benchmark])
606// ============================================================
607
608TEST_CASE("SVDEvaluator::eval timing", "[SVDComponents][!benchmark]") {
609 auto d = build_test_decomposition(cp_svd::SlopeSource::NATURAL_CUBIC_SPLINE, 20);
610 cp_svd::SVDEvaluator ev(d);
611 std::mt19937 rng(101);
612 std::uniform_real_distribution<double> u(0.05, 0.95);
613 std::vector<std::pair<double, double>> probes;
614 probes.reserve(1000);
615 for (int i = 0; i < 1000; ++i) {
616 probes.emplace_back(u(rng), u(rng));
617 }
618
619 BENCHMARK("eval rank-20") {
620 double sink = 0.0;
621 for (const auto& p : probes) {
622 sink += ev.eval(p.first, p.second);
623 }
624 return sink;
625 };
626}
627
628TEST_CASE("RegionAtlas::find_region timing", "[SVDComponents][!benchmark]") {
629 // Use the same hand-built 2-region atlas as the correctness tests.
630 //
631 // Region 0 (LIQUID-like): b_lo = 0, b_hi = 0.5*a on a in [0, 10]
632 // AABB = [0, 10] x [0, 5]
633 // Region 1 (VAPOR-like): b_lo = 0.5*a, b_hi = 10 on a in [0, 10]
634 // AABB = [0, 10] x [0, 10]
635 //
636 // Three benchmark scenarios that isolate the real per-call work:
637 //
638 // 1. atlas_miss — point outside both AABBs: scans 2 AABBs,
639 // no curve_contains call, returns -1.
640 // 2. first_region — point inside region 0's curve envelope:
641 // 1 AABB hit + 1 curve_contains, returns 0.
642 // 3. last_region — point inside region 1's curve envelope
643 // AND inside region 0's AABB: 2 AABB hits +
644 // 2 curve_contains, returns 1.
645 //
646 // The cost of a curve_contains call is dominated by two
647 // PiecewiseChebyshevCurve Clenshaw evaluations (one per side).
648 auto atlas = build_test_atlas();
649 constexpr int N_PROBES = 1000;
650 std::mt19937 rng(103);
651 std::uniform_real_distribution<double> u01(0.0, 1.0);
652
653 std::vector<std::pair<double, double>> miss_probes;
654 std::vector<std::pair<double, double>> first_probes;
655 std::vector<std::pair<double, double>> last_probes;
656 miss_probes.reserve(N_PROBES);
657 first_probes.reserve(N_PROBES);
658 last_probes.reserve(N_PROBES);
659 while (static_cast<int>(miss_probes.size()) < N_PROBES) {
660 // Outside both AABBs: a in [11, 20], b anything.
661 miss_probes.emplace_back(11.0 + 9.0 * u01(rng), 10.0 * u01(rng));
662 }
663 while (static_cast<int>(first_probes.size()) < N_PROBES) {
664 // Inside region 0: 0 <= b <= 0.5 a. Sample a uniformly, then
665 // b uniformly in [0, 0.5 a] so probes are guaranteed in region 0.
666 const double a = 10.0 * u01(rng);
667 const double b = 0.5 * a * u01(rng);
668 first_probes.emplace_back(a, b);
669 }
670 while (static_cast<int>(last_probes.size()) < N_PROBES) {
671 // Inside region 1 but ALSO inside region 0's AABB ([0,10] x [0,5]):
672 // pick a in [0, 10], b in (0.5 a, 5]. If 0.5 a >= 5 the interval
673 // is empty; resample.
674 const double a = 10.0 * u01(rng);
675 const double b_lo = 0.5 * a;
676 if (b_lo >= 5.0) {
677 continue;
678 }
679 const double b = b_lo + (5.0 - b_lo) * u01(rng);
680 last_probes.emplace_back(a, b);
681 }
682
683 BENCHMARK("find_region — atlas-miss (AABB filter only)") {
684 int sink = 0;
685 for (const auto& p : miss_probes) {
686 sink += atlas.find_region(p.first, p.second);
687 }
688 return sink;
689 };
690
691 BENCHMARK("find_region — first-region hit (1 AABB + 1 curve)") {
692 int sink = 0;
693 for (const auto& p : first_probes) {
694 sink += atlas.find_region(p.first, p.second);
695 }
696 return sink;
697 };
698
699 BENCHMARK("find_region — last-region hit (2 AABBs + 2 curves)") {
700 int sink = 0;
701 for (const auto& p : last_probes) {
702 sink += atlas.find_region(p.first, p.second);
703 }
704 return sink;
705 };
706}
707
708#endif // ENABLE_CATCH