flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_units.cpp
Go to the documentation of this file.
1// PHYSICAL DOMAINS — the two gates of suite/docs/PHYSICAL_UNITS_PLAN.md Phase 1.
2//
3// Run as `test_units identity` and `test_units scale` (two ctests off one binary).
4//
5// GATE 1 — units_identity. The physical constructor at `extent == cells` reproduces the cell-unit
6// solver exactly: every accessor reports the historical numbers, and with rho = 1 and dt = 1 (where
7// all three reference scales are 1) the fields come back BITWISE equal to an extent=None run. This
8// is the anchor of the whole refactor: an armed solver that happens to sit on the unit lattice must
9// be the unit-lattice solver.
10//
11// GATE 2 — units_scale_invariance. The SAME physical problem, written in two unit systems whose
12// length unit differs by a factor 1000, must give the same physical answer. Under a pure change of
13// length unit (time and mass units fixed, numerical lengths divided by lam):
14//
15// L -> L/lam u -> u/lam rho -> rho*lam^3 mu -> mu*lam F -> F*lam^2 p -> p*lam
16// dt unchanged sdf -> sdf/lam
17//
18// Substituting those into the boundary conversions of `Solver::UnitScales` leaves EVERY internal
19// quantity unchanged (mu' = mu*tRef/(rhoRef*hRef^2) is invariant, and so are dt', rho', F', the
20// index velocity and the index SDF), so the two runs solve the identical discrete system and can
21// only differ by the round-off of forming the conversion factors themselves. Any surviving `h` in
22// a kernel or at the API boundary breaks that invariance immediately — which is exactly what this
23// gate is for (plan §5.2, target 1e-13 relative).
24//
25// Both a SAMPLED SDF (set_solid) and an ANALYTIC SCENE (set_scene + set_solid_from_scene) are
26// carried through the scale change, on Poiseuille and on a periodic sphere in Stokes flow.
27//
28// GATE 4 — units_anisotropic_poiseuille (`test_units aniso`). Phase 2 (anisotropic cells),
29// flow/doc/anisotropic_metric.md §8.2. Plane Poiseuille on cells (16, 40, 8), with the cut-cell
30// walls exactly on two y cell CENTRES — where the second difference of a quadratic is exact, so
31// the discrete solution IS the analytic parabola u(y) = F (y - ylo)(yhi - y)/(2 mu) and the only
32// thing the comparison can measure is the operator. TWO stretched configurations, making two
33// DIFFERENT statements:
34//
35// (a) EXACTNESS, extent (16, 10, 16) -> spacing (1, 0.25, 2), hRef = 0.25 on `y` (a non-trivial
36// check that hRef is min_a h_a and not h_x), h' = (4, 1, 8), w = (1/16, 1, 1/64) and
37// mu' = mu*tRef/hRef^2 = 80 — every one of them exactly representable in float, so the
38// stored operator carries no rounding of its own and the metric is on trial alone. Bound
39// 1e-9 of u_max = F H^2/(8 mu) = 0.2 (measured 1.388e-15).
40//
41// (b) THE FLOAT-STORAGE TRIPWIRE, extent (16, 12, 16) -> spacing (1, 0.3, 2), h' = (10/3, 1,
42// 20/3), w = (0.09, 1, 0.0225) — the §5.3 configuration, and a production-SHAPED one: with
43// hRef = 0.3 neither mu' = 55.5555… nor AC = 1 + 2((5 + 55.5555…) + 1.25) = 124.6111… is
44// representable, so the six off-diagonals no longer sum to AC - idiag exactly and the whole
45// profile is scaled by 1 - 1.06e-07 (that ratio is CONSTANT across all fifteen fluid rows,
46// which is what identifies it). Bound 1e-7, measured 3.052e-08 — and 8.674e-15 when the
47// identical source is built with -DPECLET_FLOW_OPERATOR_DOUBLE. The discretisation is
48// pointwise exact here too; this row watches the WO-M float operator-storage floor
49// (docs/SCALING_ISSUES.md #1), it is NOT an exactness statement and NOT a metric defect.
50//
51// Both configurations also require max|v|, max|w| <= 1e-12 u_max and the wall/solid u rows exactly
52// 0. The isotropic control on (16, 16, 16) at extent == cells meets the same bounds and reports
53// the EXACT identity metric (hp = w = (1,1,1), vol = 1, aniso false) from the §1.4 snap. The gate
54// also asserts the §7 refusals: the collocated policy, `enable_vof` and the hydro force integrals
55// each throw on an anisotropic domain with the three spacings in the message.
56//
57// GATE 3 — units_vof_sigma (`test_units vof`). The V4 surface-tension gates with a PHYSICAL sigma
58// at extent = 1e-2 * cells (plan §9.3 U5, §5.6). Surface tension is a force per unit LENGTH, i.e.
59// mass per time squared, so under a pure change of LENGTH unit sigma is numerically INVARIANT while
60// mu -> mu*lam, rho -> rho*lam^3, kappa -> kappa*lam, p -> p*lam and u -> u/lam. Two checks, the
61// two the V4 rung rests on: the exactness identity (a CONSTANT curvature makes the CSF the exact
62// discrete gradient of sigma*kappa*C, which the projection annihilates, so a static droplet stays
63// at machine zero) and Young-Laplace (the pressure jump equals sigma*kappa). Both must give the
64// same PHYSICS in the two unit systems.
65#include <algorithm>
66#include <cmath>
67#include <cstdio>
68#include <cstring>
69#include <Kokkos_Core.hpp>
70#include <string>
71#include <vector>
72
73#include "flow_ibm.hpp"
74
75namespace {
76int failures = 0;
77#define CHECK(cond) \
78 do { \
79 if (!(cond)) { \
80 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
81 ++failures; \
82 } \
83 } while (0)
84
85void checkClose(double a, double b, double tol, const char* what) {
86 if (!(std::fabs(a - b) <= tol)) {
87 std::fprintf(stderr, "CHECK_CLOSE failed (%s): %.17g vs %.17g (|d| = %.3e > %.3e)\n", what, a,
88 b, std::fabs(a - b), tol);
89 ++failures;
90 }
91}
92
93bool bitwiseEqual(const std::vector<double>& a, const std::vector<double>& b) {
94 if (a.size() != b.size())
95 return false;
96 return std::memcmp(a.data(), b.data(), a.size() * sizeof(double)) == 0;
97}
98
99double maxAbs(const std::vector<double>& a) {
100 double m = 0.0;
101 for (double v : a)
102 if (!(std::fabs(v) <= m))
103 m = std::fabs(v);
104 return m;
105}
106
109double relDiffScaled(const std::vector<double>& a, const std::vector<double>& b, double k) {
110 const double den = maxAbs(a);
111 double m = 0.0;
112 for (std::size_t i = 0; i < a.size(); ++i) {
113 const double d = std::fabs(a[i] - k * b[i]);
114 if (!(d <= m))
115 m = d; // NaN-propagating
116 }
117 return den > 0.0 ? m / den : m;
118}
119
120// --------------------------------------------------------------------------------------------
121// The two problems, both written in ONE unit system parameterised by `lam` (lam = 1 is the
122// cell-unit system: side N, spacing 1). Everything below is stated physically; nothing computes
123// with a cell size.
124// --------------------------------------------------------------------------------------------
125struct Fields {
126 std::vector<double> u, v, w, p;
127 std::array<double, 3> spacing{};
128 double div = 0.0;
129};
130
132template <class Grid>
133Fields runChannel(int nx, int ny, int nz, double lam, bool arm) {
134 const double RHO = 1.0 * lam * lam * lam, MU = 0.1 * lam, F = 0.01 * lam * lam, DT = 50.0;
135 const double H = 1.0 / lam; // one cell, physically
136 const double ylo = (std::round(0.30 * ny) + 0.5) * H, yhi = (std::round(0.70 * ny) + 0.5) * H;
137 peclet::flow::Solver<Grid> s(nx, ny, nz);
138 if (arm)
139 s.setPhysicalDomain({nx * H, ny * H, nz * H}, {0.0, 0.0, 0.0}, {nx, ny, nz});
140 s.setRho(RHO);
141 s.setMu(MU);
142 s.setDt(DT);
143 s.setBodyForce(F, 0.0, 0.0);
144 s.setVelocityIterations(400);
145 s.setVelocityResidualTolerance(1e-14);
146 s.setPressurePcg(true, 80, 1e-13);
147 // The SDF is a PHYSICAL signed distance sampled at the physical cell centres.
148 const std::vector<double> cx = s.cellCentres(0), cy = s.cellCentres(1), cz = s.cellCentres(2);
149 std::vector<double> sdf((std::size_t)nx * ny * nz);
150 for (int z = 0; z < nz; ++z)
151 for (int y = 0; y < ny; ++y)
152 for (int x = 0; x < nx; ++x)
153 sdf[(std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny] =
154 std::min(cy[y] - ylo, yhi - cy[y]);
155 (void)cx;
156 (void)cz;
157 s.setSolid(sdf, /*cutcellPressure=*/false);
158 for (int it = 0; it < 300; ++it)
159 s.step();
160 Fields f;
161 f.u = s.getVelocity(0);
162 f.v = s.getVelocity(1);
163 f.w = s.getVelocity(2);
164 f.p = s.getPressure();
165 f.spacing = s.spacing();
166 return f;
167}
168
171template <class Grid>
172Fields runSphere(int N, double lam, bool arm, bool scene) {
173 const double RHO = 1.0 * lam * lam * lam, MU = 0.1 * lam, F = 1e-3 * lam * lam, DT = 60.0;
174 const double H = 1.0 / lam;
175 const double R = 0.30 * N * H;
177 if (arm)
178 s.setPhysicalDomain({N * H, N * H, N * H}, {0.0, 0.0, 0.0}, {N, N, N});
179 s.setRho(RHO);
180 s.setMu(MU);
181 s.setDt(DT);
182 s.setBodyForce(F, 0.0, 0.0);
183 s.setAdvection(false);
184 s.setVelocityIterations(200);
185 s.setVelocityResidualTolerance(1e-14);
186 s.setPressurePcg(true, 120, 1e-13);
187 if (scene) {
188 namespace g = peclet::core::geom;
189 g::SceneBuilder<double> b;
190 b.addLeaf(g::kSphere, {R});
191 g::Transform<double> tr;
192 // Centre of the box, in PHYSICAL coordinates.
193 tr.translation = peclet::core::Vec3<double>{0.5 * N * H, 0.5 * N * H, 0.5 * N * H};
194 b.addInstance(0, tr);
195 std::vector<int> ni, ii;
196 std::vector<double> nr, ir;
197 b.encode(ni, nr, ii, ir);
198 s.setScene(ni, nr, ii, ir, /*periodic=*/true);
199 s.setSolidFromScene(/*cutcellPressure=*/true);
200 } else {
201 const std::vector<double> cx = s.cellCentres(0), cy = s.cellCentres(1), cz = s.cellCentres(2);
202 const double c = 0.5 * N * H;
203 std::vector<double> sdf((std::size_t)N * N * N);
204 for (int z = 0; z < N; ++z)
205 for (int y = 0; y < N; ++y)
206 for (int x = 0; x < N; ++x)
207 sdf[(std::size_t)x + (std::size_t)y * N + (std::size_t)z * N * N] =
208 std::sqrt((cx[x] - c) * (cx[x] - c) + (cy[y] - c) * (cy[y] - c) +
209 (cz[z] - c) * (cz[z] - c)) -
210 R;
211 s.setSolid(sdf, /*cutcellPressure=*/true);
212 }
213 for (int it = 0; it < 60; ++it)
214 s.step();
215 Fields f;
216 f.u = s.getVelocity(0);
217 f.v = s.getVelocity(1);
218 f.w = s.getVelocity(2);
219 f.p = s.getPressure();
220 f.spacing = s.spacing();
221 f.div = s.maxOpenDivergence();
222 return f;
223}
224
229std::vector<double> sphereColour(int n, double R, double cx, double cy, double cz, int sub = 24) {
230 std::vector<double> C((std::size_t)n * n * n, 0.0);
231 const double w = 1.0 / sub;
232 for (int k = 0; k < n; ++k)
233 for (int j = 0; j < n; ++j)
234 for (int i = 0; i < n; ++i) {
235 double acc = 0.0;
236 for (int b = 0; b < sub; ++b)
237 for (int a = 0; a < sub; ++a) {
238 const double px = i + (a + 0.5) * w, py = j + (b + 0.5) * w;
239 const double r2 = R * R - (px - cx) * (px - cx) - (py - cy) * (py - cy);
240 if (r2 <= 0.0)
241 continue;
242 const double hh = std::sqrt(r2);
243 const double lo = std::fmax(cz - hh, (double)k), hi = std::fmin(cz + hh, (double)k + 1);
244 if (hi > lo)
245 acc += hi - lo;
246 }
247 C[(std::size_t)i + (std::size_t)j * n + (std::size_t)k * n * n] = acc / (sub * sub);
248 }
249 return C;
250}
251
252struct Drop {
253 double maxU = 0.0;
254 double dp = 0.0;
255 double kappa = 0.0;
256 double dtCap = 0.0;
257};
258
261Drop runDroplet(int n, double Rcells, double lam, bool constantKappa, int steps) {
262 const double H = 1.0 / lam; // one cell, physically
263 const double SIGMA = 1.0; // invariant under a change of LENGTH unit
264 const double MU = 0.1 * lam, RHO = 1.0 * lam * lam * lam;
266 s.setPhysicalDomain({n * H, n * H, n * H}, {0.0, 0.0, 0.0}, {n, n, n});
267 s.setRho(RHO);
268 s.setMu(MU);
269 s.setDt(1.0);
270 s.setVelocityResidualTolerance(0.0); // machine-precision gate: keep the fixed-sweep loop
271 s.setPressureGeometry(std::vector<double>((std::size_t)n * n * n, 10.0 * H));
272 s.setPressureChebyshev(true, 500, 1e-14);
273 s.enableVof();
274 const std::vector<double> C = sphereColour(n, Rcells, n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11);
275 s.setVof(C);
276 s.setPropertyModel("rho", peclet::flow::ClosureKind::LinearMix, "C", "", {RHO, 0.0});
277 s.setSurfaceTension(SIGMA);
278 Drop d;
279 d.kappa = 2.0 / (Rcells * H); // physical 1/length
280 if (constantKappa)
281 s.setVofKappaConstant(d.kappa);
282 else
283 s.computeVofCurvature();
284 d.dtCap = s.capillaryDt();
285 s.setDt(0.5 * d.dtCap);
286 for (int k = 0; k < steps; ++k)
287 s.step();
288 for (int c = 0; c < 3; ++c)
289 d.maxU = std::fmax(d.maxU, maxAbs(s.getVelocity(c)));
290 // Young-Laplace: the pressure inside the drop minus the pressure well outside it.
291 const std::vector<double> p = s.getPressure();
292 const std::size_t inside =
293 (std::size_t)(n / 2) + (std::size_t)(n / 2) * n + (std::size_t)(n / 2) * n * n;
294 const std::size_t outside = 1 + (std::size_t)1 * n + (std::size_t)1 * n * n;
295 d.dp = p[inside] - p[outside];
296 return d;
297}
298
299// --------------------------------------------------------------------------------------------
300void gateVofSigma() {
301 std::printf("=== units_vof_sigma ===\n");
302 const int N = 32;
303 const double Rc = 8.0;
304 const double lam = 1e2; // extent = 1e-2 * cells
305
306 // (a) the exactness identity, in both unit systems.
307 const Drop a = runDroplet(N, Rc, 1.0, true, 20);
308 const Drop b = runDroplet(N, Rc, lam, true, 20);
309 std::printf(" constant kappa: max|u| %.3e (lam=1) vs %.3e (lam=%g) -> %.3e rescaled\n", a.maxU,
310 b.maxU, lam, b.maxU * lam);
311 // A velocity of exactly zero would also pass, so pin the SCALE the currents are measured against:
312 // sigma*kappa/mu is the capillary velocity the naive CSF would produce.
313 const double uScale = 1.0 * a.kappa / 0.1;
314 CHECK(a.maxU < 1e-14 * uScale);
315 CHECK(b.maxU * lam < 1e-14 * uScale);
316 checkClose(a.dp, 1.0 * a.kappa, 1e-9 * a.kappa, "Young-Laplace at lam=1");
317 checkClose(b.dp / lam, a.dp, 1e-9 * a.kappa, "Young-Laplace at lam=1e2 (rescaled)");
318 std::printf(" Young-Laplace: dp %.9e vs sigma*kappa %.9e (lam=%g: %.9e rescaled)\n", a.dp,
319 a.kappa, lam, b.dp / lam);
320 checkClose(b.kappa / lam, a.kappa, 1e-12 * a.kappa, "kappa scales as 1/length");
321 checkClose(b.dtCap, a.dtCap, 1e-12 * a.dtCap,
322 "capillary_dt invariant under a length-unit change");
323 std::printf(" capillary_dt %.9e vs %.9e (invariant: sigma and the time unit are)\n", a.dtCap,
324 b.dtCap);
325
326 // (b) the COMPUTED curvature: the physical spurious currents and the jump must agree.
327 const Drop ca = runDroplet(N, Rc, 1.0, false, 20);
328 const Drop cb = runDroplet(N, Rc, lam, false, 20);
329 const double ru = std::fabs(ca.maxU - cb.maxU * lam) / std::fabs(ca.maxU);
330 const double rp = std::fabs(ca.dp - cb.dp / lam) / std::fabs(ca.dp);
331 std::printf(
332 " computed kappa: max|u| %.6e vs %.6e rescaled (rel %.3e); dp %.6e vs %.6e "
333 "(rel %.3e)\n",
334 ca.maxU, cb.maxU * lam, ru, ca.dp, cb.dp / lam, rp);
335 CHECK(ru <= 1e-12);
336 CHECK(rp <= 1e-12);
337}
338
339// --------------------------------------------------------------------------------------------
340// Phase 2 gate G1 (doc/anisotropic_metric.md §8.2).
341// --------------------------------------------------------------------------------------------
342
346struct Channel {
347 Fields f;
348 std::vector<double> yc;
349 double ylo = 0.0, yhi = 0.0;
350 std::array<double, 3> w{1.0, 1.0, 1.0};
351 bool aniso = false;
352};
353
354Channel runChannelBox(int nx, int ny, int nz, const std::array<double, 3>& extent, int jlo, int jhi,
355 bool arm) {
356 const double RHO = 1.0, MU = 0.1, F = 0.01, DT = 50.0;
358 if (arm)
359 s.setPhysicalDomain(extent, {0.0, 0.0, 0.0}, {nx, ny, nz});
360 s.setRho(RHO);
361 s.setMu(MU);
362 s.setDt(DT);
363 s.setBodyForce(F, 0.0, 0.0);
364 s.setVelocityIterations(400);
365 s.setVelocityResidualTolerance(1e-14);
366 s.setPressurePcg(true, 80, 1e-13);
367 Channel ch;
368 ch.yc = s.cellCentres(1);
369 ch.ylo = ch.yc[jlo];
370 ch.yhi = ch.yc[jhi];
371 std::vector<double> sdf((std::size_t)nx * ny * nz);
372 for (int z = 0; z < nz; ++z)
373 for (int y = 0; y < ny; ++y)
374 for (int x = 0; x < nx; ++x)
375 sdf[(std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny] =
376 std::min(ch.yc[y] - ch.ylo, ch.yhi - ch.yc[y]);
377 s.setSolid(sdf, /*cutcellPressure=*/false);
378 for (int it = 0; it < 300; ++it)
379 s.step();
380 ch.f.u = s.getVelocity(0);
381 ch.f.v = s.getVelocity(1);
382 ch.f.w = s.getVelocity(2);
383 ch.f.p = s.getPressure();
384 ch.f.spacing = s.spacing();
385 const auto& u = s.unitScales();
386 ch.w = {u.w[0], u.w[1], u.w[2]};
387 ch.aniso = u.aniso;
388 return ch;
389}
390
394void checkChannel(const char* what, const Channel& ch, int nx, int ny, int nz, int jlo, int jhi,
395 double bound) {
396 const double MU = 0.1, F = 0.01;
397 const double H = ch.yhi - ch.ylo;
398 const double umax = F * H * H / (8.0 * MU);
399 std::printf(" %s: spacing (%.17g, %.17g, %.17g) aniso %d w (%.17g, %.17g, %.17g)\n", what,
400 ch.f.spacing[0], ch.f.spacing[1], ch.f.spacing[2], (int)ch.aniso, ch.w[0], ch.w[1],
401 ch.w[2]);
402 double relErr = 0.0, wallMax = 0.0;
403 for (int z = 0; z < nz; ++z)
404 for (int y = 0; y < ny; ++y)
405 for (int x = 0; x < nx; ++x) {
406 const std::size_t i = (std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny;
407 const double uu = ch.f.u[i];
408 if (y <= jlo || y >= jhi) { // wall and solid rows
409 if (!(std::fabs(uu) <= wallMax))
410 wallMax = std::fabs(uu);
411 continue;
412 }
413 const double ex = F * (ch.yc[y] - ch.ylo) * (ch.yhi - ch.yc[y]) / (2.0 * MU);
414 const double d = std::fabs(uu - ex) / umax;
415 if (!(d <= relErr))
416 relErr = d;
417 }
418 const double mv = maxAbs(ch.f.v), mw = maxAbs(ch.f.w);
419 std::printf(
420 " H %.17g u_max %.17g max rel |u - parabola| %.3e (bound %.0e) max|v| %.3e "
421 "max|w| %.3e max|u| on the wall/solid rows %.3e\n",
422 H, umax, relErr, bound, mv, mw, wallMax);
423 if (!(relErr <= bound))
424 std::printf(
425 " NOTE: a miss that is a SINGLE one-signed multiplicative factor across the "
426 "whole profile (rel err proportional to u, one constant ratio on every fluid row) "
427 "is the FLOAT momentum-operator storage (`IbmSolver::FV`, MReal; WO-M / "
428 "docs/SCALING_ISSUES.md #1), not the metric -- rebuild with "
429 "-DPECLET_FLOW_OPERATOR_DOUBLE to separate the two.\n");
430 CHECK(relErr <= bound);
431 CHECK(wallMax == 0.0);
432 CHECK(mv <= 1e-12 * umax);
433 CHECK(mw <= 1e-12 * umax);
434}
435
436void gateAnisoPoiseuille() {
437 std::printf("=== units_anisotropic_poiseuille ===\n");
438 // (a) EXACTNESS. cells (16, 40, 8) over (16, 10, 16) -> spacing (1, 0.25, 2), hRef = 0.25 on the
439 // y axis, h' = (4, 1, 8), w = (1/16, 1, 1/64), mu' = 80 -- all float-representable, so the
440 // stored operator adds no rounding and the metric is on trial alone. Walls on the y cell
441 // centres 12.5*0.25 = 3.125 and 28.5*0.25 = 7.125 (H = 4).
442 {
443 const Channel ch = runChannelBox(16, 40, 8, {16.0, 10.0, 16.0}, 12, 28, /*arm=*/true);
444 CHECK(ch.aniso);
445 CHECK(ch.f.spacing[0] == 1.0 && ch.f.spacing[1] == 0.25 && ch.f.spacing[2] == 2.0);
446 CHECK(ch.w[0] == 0.0625 && ch.w[1] == 1.0 && ch.w[2] == 0.015625);
447 CHECK(ch.ylo == 3.125 && ch.yhi == 7.125);
448 checkChannel("stretched EXACTNESS (16, 40, 8) on (16, 10, 16)", ch, 16, 40, 8, 12, 28, 1e-9);
449 }
450 // (b) THE FLOAT-STORAGE TRIPWIRE. The same grid over (16, 12, 16) -> spacing (1, 0.3, 2),
451 // hRef = 0.3, h' = (10/3, 1, 20/3), w = (0.09, 1, 0.0225); walls at 12.5*0.3 = 3.75 and
452 // 28.5*0.3 = 8.55 (H = 4.8). mu' = 55.5555... and AC = 124.6111... are not representable in
453 // the float operator storage, which scales the whole profile by 1 - 1.06e-07: measured
454 // 3.052e-08 here and 8.674e-15 with -DPECLET_FLOW_OPERATOR_DOUBLE. Bound 1e-7 (see the
455 // header).
456 {
457 const Channel ch = runChannelBox(16, 40, 8, {16.0, 12.0, 16.0}, 12, 28, /*arm=*/true);
458 CHECK(ch.aniso);
459 checkClose(ch.f.spacing[0], 1.0, 0.0, "aniso spacing x");
460 checkClose(ch.f.spacing[1], 0.3, 1e-17, "aniso spacing y");
461 checkClose(ch.f.spacing[2], 2.0, 0.0, "aniso spacing z");
462 checkClose(ch.ylo, 3.75, 1e-14, "aniso wall ylo");
463 checkClose(ch.yhi, 8.55, 1e-14, "aniso wall yhi");
464 checkChannel("stretched FLOAT-FLOOR (16, 40, 8) on (16, 12, 16)", ch, 16, 40, 8, 12, 28, 1e-7);
465 }
466 // (c) The isotropic control on (16, 16, 16) at extent == cells, with `runChannel`'s own wall
467 // convention (round(0.30*ny) + 0.5, round(0.70*ny) + 0.5 in cells): the same bound, and the
468 // §1.4 snap gives it EXACTLY the identity metric. (It is not BITWISE the extent=None run of
469 // the same problem, and must not be asserted to be: dt = 50 pins tRef = 50, so the armed run
470 // computes with dt' = 1 and mu' = 50 mu while the cell-unit one computes with dt' = 50 and
471 // mu' = mu -- the same physics, a different internal scaling. `units_identity` is the
472 // bitwise statement, and it fixes rho = dt = 1 for exactly this reason.)
473 {
474 const Channel a = runChannelBox(16, 16, 16, {16.0, 16.0, 16.0}, 5, 11, /*arm=*/true);
475 CHECK(!a.aniso);
476 CHECK(a.w[0] == 1.0 && a.w[1] == 1.0 && a.w[2] == 1.0);
477 CHECK(a.f.spacing[0] == 1.0 && a.f.spacing[1] == 1.0 && a.f.spacing[2] == 1.0);
478 checkChannel("isotropic control (16, 16, 16) at extent == cells", a, 16, 16, 16, 5, 11, 1e-9);
479 }
480 // (d) The §7 table, as commit C4 leaves it. ADMITTED (C4 lifted them): the collocated policy
481 // and the hydro force integrals. STILL REFUSED, with the three spacings in the message:
482 // enable_vof (Phase 3), and hydro_force_torque_reaction only on the v3 moving-wall torque
483 // path (doc/units_escalation.md E3), which needs a moving scene and is not reachable here.
484 {
485 bool threw = false;
486 try {
488 sc.setPhysicalDomain({16.0, 12.0, 16.0}, {0.0, 0.0, 0.0}, {16, 40, 8});
489 const auto sp = sc.spacing();
490 std::printf(" ADMITTED (collocated): spacing (%.17g, %.17g, %.17g), aniso %d\n", sp[0],
491 sp[1], sp[2], (int)sc.unitScales().aniso);
492 CHECK(sc.unitScales().aniso);
493 } catch (const std::exception& ex) {
494 threw = true;
495 std::printf(" UNEXPECTED refusal (collocated): %s\n", ex.what());
496 }
497 CHECK(!threw);
498 threw = false;
499 try {
500 // PHASE 3 INVERTED THIS ASSERTION. Phase 2 wrote it as `CHECK(threw)` — `enable_vof` was
501 // the one entry point its §7 still refused on an anisotropic domain, and the gate pinned
502 // that refusal so it could not be lifted by accident. Phase 3 (flow/doc/anisotropic_vof.md)
503 // is what lifts it: the colour transport is metric-free because a stretched cell IS the unit
504 // cube of the index coordinates, and everything that reads a direction, a length or an area
505 // — the height-function curvature, the paraboloid frames, the CSF face force's per-axis
506 // weight `w_a`, the wetting rotation, the phase-change normals/areas/V_cell — now carries
507 // the metric. `units_vof_aniso` is the gate that holds it: the balanced-force exactness
508 // identity is at machine zero (2.3e-17 at aspect 2, 1.9e-17 at aspect 4) and Young-Laplace
509 // is exact, which is only true if the CSF weight is EXACTLY this file's `w_a`.
511 ss.setPhysicalDomain({16.0, 12.0, 16.0}, {0.0, 0.0, 0.0}, {16, 40, 8});
512 ss.enableVof();
513 std::printf(" ADMITTED (enable_vof — Phase 3; see units_vof_aniso)\n");
514 } catch (const std::exception& ex) {
515 threw = true;
516 std::printf(" UNEXPECTED refusal (enable_vof): %s\n", ex.what());
517 }
518 CHECK(!threw);
519 threw = false;
520 try {
522 ss.setPhysicalDomain({16.0, 12.0, 16.0}, {0.0, 0.0, 0.0}, {16, 40, 8});
523 ss.hydroForceTorque(); // C4: admitted (no scene here, so it returns an empty result)
524 std::printf(" ADMITTED (hydro_force_torque)\n");
525 } catch (const std::exception& ex) {
526 threw = true;
527 std::printf(" UNEXPECTED refusal (hydro_force_torque): %s\n", ex.what());
528 }
529 CHECK(!threw);
530 }
531}
532
533// --------------------------------------------------------------------------------------------
534void gateIdentity() {
535 std::printf("=== units_identity ===\n");
536
537 // (a) No domain: the historical cell-unit reports.
538 {
540 CHECK(!s.hasPhysicalDomain());
541 const auto h = s.spacing();
542 CHECK(h[0] == 1.0 && h[1] == 1.0 && h[2] == 1.0);
543 const auto e = s.domainExtent();
544 CHECK(e[0] == 6.0 && e[1] == 8.0 && e[2] == 10.0);
545 const auto o = s.domainOrigin();
546 CHECK(o[0] == 0.0 && o[1] == 0.0 && o[2] == 0.0);
547 const auto c = s.cellCentres(1);
548 CHECK(c.size() == 8u);
549 CHECK(c[0] == 0.5 && c[7] == 7.5);
550 }
551
552 // (b) extent == cells with a shifted origin: spacing 1, centres shifted, scales all 1.
553 {
555 s.setPhysicalDomain({6.0, 8.0, 10.0}, {-2.0, 0.5, 100.0}, {6, 8, 10});
556 CHECK(s.hasPhysicalDomain());
557 const auto h = s.spacing();
558 CHECK(h[0] == 1.0 && h[1] == 1.0 && h[2] == 1.0);
559 const auto o = s.domainOrigin();
560 CHECK(o[0] == -2.0 && o[1] == 0.5 && o[2] == 100.0);
561 const auto cx = s.cellCentres(0);
562 CHECK(cx[0] == -1.5 && cx[5] == 3.5);
563 const auto cz = s.cellCentres(2);
564 CHECK(cz[0] == 100.5 && cz[9] == 109.5);
565 const auto& u = s.unitScales();
566 CHECK(u.hRef == 1.0);
567 s.setRho(1.0);
568 s.setDt(1.0);
569 CHECK(u.rhoRef == 1.0 && u.tRef == 1.0);
570 CHECK(u.muToInt() == 1.0 && u.pToInt() == 1.0 && u.forceToInt(0) == 1.0);
571 CHECK(u.velToInt(2) == 1.0 && u.lenToInt() == 1.0 && u.sigmaToInt() == 1.0);
572 }
573
574 // (c) BITWISE: an armed run at extent == cells with rho = dt = 1 against extent=None.
575 {
576 auto run = [](bool arm) {
577 const int nx = 8, ny = 24, nz = 8;
579 if (arm)
580 s.setPhysicalDomain({(double)nx, (double)ny, (double)nz}, {0.0, 0.0, 0.0}, {nx, ny, nz});
581 s.setRho(1.0);
582 s.setMu(0.1);
583 s.setDt(1.0);
584 s.setBodyForce(0.01, 0.0, 0.0);
585 s.setVelocityIterations(60);
586 s.setPressureIterations(4);
587 const double ylo = 7.5, yhi = ny - 7.5;
588 std::vector<double> sdf((std::size_t)nx * ny * nz);
589 for (int z = 0; z < nz; ++z)
590 for (int y = 0; y < ny; ++y)
591 for (int x = 0; x < nx; ++x)
592 sdf[(std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny] =
593 std::min((y + 0.5) - ylo, yhi - (y + 0.5));
594 s.setSolid(sdf, /*cutcellPressure=*/true);
595 for (int it = 0; it < 30; ++it)
596 s.step();
597 Fields f;
598 f.u = s.getVelocity(0);
599 f.v = s.getVelocity(1);
600 f.w = s.getVelocity(2);
601 f.p = s.getPressure();
602 return f;
603 };
604 const Fields a = run(false), b = run(true);
605 CHECK(maxAbs(a.u) > 0.0);
606 CHECK(bitwiseEqual(a.u, b.u));
607 CHECK(bitwiseEqual(a.v, b.v));
608 CHECK(bitwiseEqual(a.w, b.w));
609 CHECK(bitwiseEqual(a.p, b.p));
610 std::printf(" extent == cells, rho = dt = 1: bitwise identical to the cell-unit run\n");
611 }
612}
613
614// --------------------------------------------------------------------------------------------
615void gateScale() {
616 std::printf("=== units_scale_invariance ===\n");
617 const double lam = 1e3; // the second unit system's length unit is 1000x larger
618 const double TOL = 1e-13;
619
620 {
621 const int nx = 8, ny = 24, nz = 8;
622 const Fields a = runChannel<peclet::flow::Staggered>(nx, ny, nz, 1.0, true);
623 const Fields b = runChannel<peclet::flow::Staggered>(nx, ny, nz, lam, true);
624 checkClose(a.spacing[0], 1.0, 0.0, "channel spacing (system A)");
625 checkClose(b.spacing[0], 1.0 / lam, 1e-18, "channel spacing (system B)");
626 // u_B = u_A / lam and p_B = p_A * lam, exactly.
627 const double ru = relDiffScaled(a.u, b.u, lam);
628 const double rp = relDiffScaled(a.p, b.p, 1.0 / lam);
629 std::printf(" Poiseuille (sampled SDF): u %.3e p %.3e\n", ru, rp);
630 CHECK(maxAbs(a.u) > 0.0);
631 CHECK(ru <= TOL);
632 CHECK(rp <= TOL);
633 }
634
635 for (int scene = 0; scene < 2; ++scene) {
636 const int N = 24;
637 const Fields a = runSphere<peclet::flow::Staggered>(N, 1.0, true, scene != 0);
638 const Fields b = runSphere<peclet::flow::Staggered>(N, lam, true, scene != 0);
639 const double ru = relDiffScaled(a.u, b.u, lam);
640 const double rv = relDiffScaled(a.v, b.v, lam);
641 const double rp = relDiffScaled(a.p, b.p, 1.0 / lam);
642 // The divergence diagnostic is 1/time and the time unit did not change, so it is invariant —
643 // but it is a solver RESIDUAL sitting at ~1e-12, not a component of the solution, so the
644 // meaningful statement is that both runs are converged and that the two residuals agree to
645 // round-off OF THAT RESIDUAL (measured 1.8e-18 absolute), not to 1e-13 of themselves.
646 const double rd = std::fabs(a.div - b.div);
647 std::printf(" sphere Stokes (%s): u %.3e v %.3e p %.3e div %.3e vs %.3e (d %.3e)\n",
648 scene ? "analytic scene" : "sampled SDF", ru, rv, rp, a.div, b.div, rd);
649 CHECK(maxAbs(a.u) > 0.0);
650 CHECK(ru <= TOL);
651 CHECK(rv <= TOL);
652 CHECK(rp <= TOL);
653 CHECK(a.div < 1e-9 && b.div < 1e-9); // both converged
654 CHECK(rd <= 1e-15); // and the residuals agree to their own round-off
655 }
656}
657// --------------------------------------------------------------------------------------------
658// GATE 5 - units_anisotropic_sphere (`test_units sphere`). Phase 2 gate G2,
659// doc/anisotropic_metric.md §8.3. The regression suite's Zick & Homsy sphere (phi = 0.216,
660// K_ref = 7.442) with `tests/regression/perf_baseline.json`'s own configuration - rho 1, mu 0.1,
661// dt 60, F 1e-3, 80 velocity sweeps, MG-PCG 300 / 1e-8, cut-cell pressure, advection off, the same
662// convergence rule on mean(u) - on the CUBE L^3 resolved by cells (N, 2N, N/2), i.e.
663// h = (dx, dx/2, 2dx), with the SDF sampled at the PHYSICAL cell centres. Four requirements:
664// (i) the stretched errors |K_s(N) - K_ref|/K_ref are STRICTLY DECREASING in N;
665// (ii) err_s(N) <= 4 err_c(N) + 0.2 % at every N (the z axis is twice as coarse, and second
666// order allows 4x);
667// (iii) the least-squares order of the stretched ERROR sequence, p_s >= 1.5;
668// (iv) the isotropic control (N, N, N) in CELL UNITS reproduces the recorded baseline: the step
669// counts 60 / 80 / 75 and the pressure iterations per step 6 / 7 / 7 EXACTLY, and the drag
670// factors 7.299689474225098 / 7.389063051924422 / 7.416167928926183 to 1e-6 relative.
671// Plus the §4.4 HYDRO-FORCE gate that C4 also lifts: on a periodic Stokes cell the total force on
672// the body equals the body force integrated over the fluid volume the momentum rows cover,
673// F_x * (h_x h_y h_z) * (# fluid u-DOFs) - the periodic Stokes identity. Measured on the cubic and
674// on the stretched grid; the stretched grid must reach the tolerance the cubic one does.
675//
676// Two notes on (iii) and (iv). `fit_order` (the regression's f(N) = f_inf + C N^-p grid search) is
677// DEGENERATE on three grids - two free linear parameters fit three points exactly for every p - so
678// the ctest gates the log-log least-squares slope of the errors instead, and
679// `scripts/verify_anisotropic_spheres.py` runs the five-grid ladder where `fit_order` is the same
680// estimator the regression uses.
681//
682// And "bitwise" in §8.3 (iv) is not attainable against `perf_baseline.json`, for two reasons that
683// have nothing to do with the metric: the baseline is recorded on the CUDA build (the regression's
684// documented tree) while this ctest also runs on host-openmp, and its `u.mean()` is numpy's
685// PAIRWISE summation against a sequential C++ sum here. Both perturb the drag factor at the
686// PRESSURE SOLVE'S OWN STOPPING TOLERANCE - the baseline configuration is MG-PCG rtol 1e-8 - and
687// the measured gap is 1.4e-08 / 9.7e-10 / 1.8e-09, i.e. exactly there. What IS exactly
688// reproducible is the discrete part of the record, so the gate asserts the step counts and the
689// pressure iterations per step EXACTLY (60 / 80 / 75 at 6 / 7 / 7) and the drag factor to 1e-6,
690// which still pins every digit `perf_baseline.json` prints, and it prints all 17 either way.
691//
692// GATE 6 - units_anisotropic_tgv (`test_units tgv`). Phase 2 gate G3, §8.4. Stokes Taylor-Green
693// on a periodic box L x L x L_z with cells (N, 2N, 4), rho = dt = 1 (so every reference scale but
694// hRef is 1 and the isotropic control can be compared BITWISE to the cell-unit run), mu = 0.1. The
695// mu = 0.25 is chosen so that BOTH assembled operators are exactly representable in the shipped
696// FLOAT operator storage (`IbmSolver::FV`, MReal; WO-M / docs/SCALING_ISSUES.md #1) and the metric
697// is therefore on trial alone -- the same move E1 of doc/units_escalation.md made for G1. Isotropic
698// control: hRef = 1, mu' = 0.25, b = (1/4, 1/4, 1/4), AC = 1 + 2((b+b)+b) = 5/2. Stretched:
699// hRef = 0.5, hp = (2, 1, 2), w = (1/4, 1, 1/4), mu' = 1, b = (1/4, 1, 1/4), AC = 4. Every one of
700// them dyadic. At mu = 0.1 the same gate reads 1.5e-07 (isotropic control) and 2.9e-07 (stretched)
701// -- the float floor, in the isotropic control too, so not a metric statement.
702// The plain TG field is NOT discretely divergence-free on a stretched staggered grid; the initial
703// field is the one that is,
704// u = a_x cos(kx) sin(ky), v = -a_y sin(kx) cos(ky),
705// a_x sin(k h_x/2)/h_x == a_y sin(k h_y/2)/h_y,
706// which is also an eigenvector of the anisotropic 7-point operator, so the backward-Euler amplitude
707// ratio per step is EXACTLY r = 1/(1 + dt nu Lambda) with
708// Lambda = 2(1-cos k h_x)/h_x^2 + 2(1-cos k h_y)/h_y^2.
709struct Zh {
710 double K = 0.0, umean = 0.0, div = 0.0, R = 0.0, pIterStep = 0.0;
711 int steps = 0;
712 std::array<double, 3> spacing{};
713 bool aniso = false;
714};
715
718Zh runZhSphere(int nx, int ny, int nz, bool arm, double L, int levels) {
719 const double RHO = 1.0, MU = 0.1, DT = 60.0, FX = 1e-3, PHI = 0.216;
720 const double R = std::pow(PHI * 3.0 / (4.0 * M_PI), 1.0 / 3.0) * L;
722 if (arm)
723 s.setPhysicalDomain({L, L, L}, {0.0, 0.0, 0.0}, {nx, ny, nz});
724 s.setRho(RHO);
725 s.setMu(MU);
726 s.setDt(DT);
727 s.setBodyForce(FX, 0.0, 0.0);
728 s.setAdvection(false);
729 s.setVelocityIterations(80);
730 s.setPressureLevels(levels);
731 s.setPressurePcg(true, 300, 1e-8);
732 const std::vector<double> cx = s.cellCentres(0), cy = s.cellCentres(1), cz = s.cellCentres(2);
733 const double c = 0.5 * L;
734 std::vector<double> sdf((std::size_t)nx * ny * nz);
735 for (int z = 0; z < nz; ++z)
736 for (int y = 0; y < ny; ++y)
737 for (int x = 0; x < nx; ++x)
738 sdf[(std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny] =
739 std::sqrt((cx[x] - c) * (cx[x] - c) + (cy[y] - c) * (cy[y] - c) +
740 (cz[z] - c) * (cz[z] - c)) -
741 R;
742 s.setSolid(sdf, /*cutcellPressure=*/true);
743 std::vector<double> pit;
744 double prev = 0.0;
745 Zh r;
746 for (int it = 0; it < 400; ++it) {
747 s.step();
748 ++r.steps;
749 pit.push_back((double)s.lastPressureIterations());
750 if (it % 5 == 4) {
751 const std::vector<double> uu = s.getVelocity(0);
752 double m = 0.0;
753 for (double v : uu)
754 m += v;
755 m /= (double)uu.size();
756 if (it >= 15 && std::fabs(m - prev) < 1e-5 * (std::fabs(m) + 1e-30))
757 break;
758 prev = m;
759 }
760 }
761 const std::vector<double> uu = s.getVelocity(0);
762 double m = 0.0;
763 for (double v : uu)
764 m += v;
765 r.umean = m / (double)uu.size();
766 r.R = R;
767 r.K = FX * (L * L * L) / (6.0 * M_PI * MU * R * r.umean);
768 r.div = s.maxOpenDivergence();
769 r.spacing = s.spacing();
770 r.aniso = s.unitScales().aniso;
771 std::vector<double> half(pit.begin() + (std::ptrdiff_t)(pit.size() / 2), pit.end());
772 std::sort(half.begin(), half.end());
773 const std::size_t nh = half.size();
774 r.pIterStep = (nh % 2) ? half[nh / 2] : 0.5 * (half[nh / 2 - 1] + half[nh / 2]);
775 return r;
776}
777
779double logLogOrder(const std::vector<double>& Ns, const std::vector<double>& err) {
780 double sx = 0, sy = 0, sxx = 0, sxy = 0;
781 const double n = (double)Ns.size();
782 for (std::size_t i = 0; i < Ns.size(); ++i) {
783 const double X = std::log(Ns[i]), Y = std::log(err[i]);
784 sx += X;
785 sy += Y;
786 sxx += X * X;
787 sxy += X * Y;
788 }
789 return -(n * sxy - sx * sy) / (n * sxx - sx * sx);
790}
791
794struct React {
795 double Fx = 0.0, Fref = 0.0, rel = 0.0;
796 long nFluid = 0;
797 std::array<double, 3> spacing{};
798};
799
800React runReactionSphere(int nx, int ny, int nz, bool arm, double L, int levels, int nsteps) {
801 const double RHO = 1.0, MU = 0.1, DT = 60.0, FX = 1e-3, PHI = 0.216;
802 const double R = std::pow(PHI * 3.0 / (4.0 * M_PI), 1.0 / 3.0) * L;
804 if (arm)
805 s.setPhysicalDomain({L, L, L}, {0.0, 0.0, 0.0}, {nx, ny, nz});
806 s.setRho(RHO);
807 s.setMu(MU);
808 s.setDt(DT);
809 s.setBodyForce(FX, 0.0, 0.0);
810 s.setAdvection(false);
811 s.setVelocityIterations(200);
812 s.setVelocityResidualTolerance(1e-12);
813 s.setPressureLevels(levels);
814 s.setPressurePcg(true, 300, 1e-10);
815 {
816 namespace g = peclet::core::geom;
817 g::SceneBuilder<double> b;
818 b.addLeaf(g::kSphere, {R});
819 g::Transform<double> tr;
820 tr.translation = peclet::core::Vec3<double>{0.5 * L, 0.5 * L, 0.5 * L};
821 b.addInstance(0, tr);
822 std::vector<int> ni, ii;
823 std::vector<double> nr, ir;
824 b.encode(ni, nr, ii, ir);
825 s.setScene(ni, nr, ii, ir, /*periodic=*/true);
826 s.setSolidFromScene(/*cutcellPressure=*/true);
827 }
828 for (int it = 0; it < nsteps; ++it)
829 s.step();
830 React out;
831 out.spacing = s.spacing();
832 // The fluid u-DOF count, from the solver's OWN sampled SDF and `ibmSolidMask`'s rule: the
833 // staggered sample at (x-1/2, y, z) is the mean of the two adjacent cell values (periodic wrap),
834 // and the DOF is SOLID for `sd <= 0`.
835 const std::vector<double> sd = s.getField("sdf");
836 long nf = 0;
837 for (int z = 0; z < nz; ++z)
838 for (int y = 0; y < ny; ++y)
839 for (int x = 0; x < nx; ++x) {
840 const int xm = (x + nx - 1) % nx;
841 const double a = sd[(std::size_t)xm + (std::size_t)y * nx + (std::size_t)z * nx * ny];
842 const double b2 = sd[(std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny];
843 if (0.5 * (a + b2) > 0.0)
844 ++nf;
845 }
846 out.nFluid = nf;
847 const double vcell = out.spacing[0] * out.spacing[1] * out.spacing[2];
848 out.Fref = FX * vcell * (double)nf;
849 const std::vector<double> fr = s.hydroForceTorqueReaction();
850 out.Fx = fr.empty() ? 0.0 : fr[0];
851 out.rel = std::fabs(out.Fx - out.Fref) / std::fabs(out.Fref);
852 return out;
853}
854
855void gateAnisoSphere() {
856 std::printf("=== units_anisotropic_sphere ===\n");
857 const double KREF = 7.442;
858 const double REC[3] = {7.299689474225098, 7.389063051924422, 7.416167928926183};
859 const int NS[3] = {16, 24, 32};
860 std::vector<double> Ns, errS, errC;
861 for (int i = 0; i < 3; ++i) {
862 const int N = NS[i];
863 const int levels = std::max(2, (int)std::floor(std::log2((double)N)) - 1);
864 const Zh cub = runZhSphere(N, N, N, /*arm=*/false, (double)N, levels);
865 const Zh str = runZhSphere(N, 2 * N, N / 2, /*arm=*/true, (double)N, levels);
866 const double ec = std::fabs(cub.K - KREF) / KREF, es = std::fabs(str.K - KREF) / KREF;
867 Ns.push_back((double)N);
868 errC.push_back(ec);
869 errS.push_back(es);
870 std::printf(" N=%2d cubic K %.17g err %.4f %% iters/step %.1f steps %3d div %.2e\n", N,
871 cub.K, 100.0 * ec, cub.pIterStep, cub.steps, cub.div);
872 std::printf(
873 " stretched (%d,%d,%d) spacing (%g, %g, %g) aniso %d K %.17g err %.4f %%"
874 " iters/step %.1f steps %3d div %.2e\n",
875 N, 2 * N, N / 2, str.spacing[0], str.spacing[1], str.spacing[2], (int)str.aniso, str.K,
876 100.0 * es, str.pIterStep, str.steps, str.div);
877 // (iv) the cubic control reproduces the recorded baseline value to every printed digit.
878 const double dRec = std::fabs(cub.K - REC[i]) / REC[i];
879 const int RECSTEPS[3] = {60, 80, 75};
880 const double RECITERS[3] = {6.0, 7.0, 7.0};
881 std::printf(
882 " cubic vs perf_baseline.json %.17g rel %.3e (bound 1e-6) steps %d "
883 "(recorded %d) iters/step %.1f (recorded %.1f)\n",
884 REC[i], dRec, cub.steps, RECSTEPS[i], cub.pIterStep, RECITERS[i]);
885 CHECK(dRec <= 1e-6);
886 CHECK(cub.steps == RECSTEPS[i]);
887 CHECK(cub.pIterStep == RECITERS[i]);
888 // (ii) err_s <= 4 err_c + 0.2 %
889 CHECK(es <= 4.0 * ec + 0.002);
890 // pressure iterations on the stretched grid: <= cubic + 2 (the §5 aspect rule)
891 std::printf(" pressure iters/step cubic %.1f stretched %.1f (bound cubic + 2)\n",
892 cub.pIterStep, str.pIterStep);
893 CHECK(str.pIterStep <= cub.pIterStep + 2.0);
894 }
895 // (i) strictly decreasing stretched errors
896 CHECK(errS[1] < errS[0]);
897 CHECK(errS[2] < errS[1]);
898 // (iii) least-squares order of the stretched error sequence
899 const double ps = logLogOrder(Ns, errS), pc = logLogOrder(Ns, errC);
900 std::printf(" LS order of the ERROR sequence: stretched %.4f (bound >= 1.5), cubic %.4f\n", ps,
901 pc);
902 CHECK(ps >= 1.5);
903
904 // --- §4.4, the hydro-force gate C4 lifts: the periodic Stokes identity F = F_body * V_fluid ---
905 {
906 const int N = 24;
907 const int levels = std::max(2, (int)std::floor(std::log2((double)N)) - 1);
908 const React rc = runReactionSphere(N, N, N, /*arm=*/false, (double)N, levels, 300);
909 const React rs = runReactionSphere(N, 2 * N, N / 2, /*arm=*/true, (double)N, levels, 300);
910 std::printf(" hydro_force_torque_reaction, periodic Stokes identity F = F_body*V_fluid:\n");
911 std::printf(
912 " cubic (%d,%d,%d) spacing (%g,%g,%g) F_x %.17g identity %.17g rel %.3e"
913 " (%ld fluid u-DOFs)\n",
914 N, N, N, rc.spacing[0], rc.spacing[1], rc.spacing[2], rc.Fx, rc.Fref, rc.rel, rc.nFluid);
915 std::printf(
916 " stretched (%d,%d,%d) spacing (%g,%g,%g) F_x %.17g identity %.17g rel %.3e"
917 " (%ld fluid u-DOFs)\n",
918 N, 2 * N, N / 2, rs.spacing[0], rs.spacing[1], rs.spacing[2], rs.Fx, rs.Fref, rs.rel,
919 rs.nFluid);
920 // "to the same relative tolerance the cubic grid achieves" (§8.3), gated as ONE shared bound
921 // rather than a ratio: both grids are at the march's own residual floor here (300 steps of the
922 // regression's dt = 60 configuration, momentum residual stop 1e-12, MG-PCG 1e-10), and 1e-5 is
923 // the order the cubic grid reaches with a decade of margin.
924 CHECK(rs.rel <= 1e-5);
925 CHECK(rc.rel <= 1e-5);
926 std::printf(" stretched/cubic relative-gap ratio %.3f (both gated at 1e-5)\n",
927 rs.rel / rc.rel);
928 }
929}
930
931// --------------------------------------------------------------------------------------------
932struct Tgv {
933 double ratio = 0.0, rExact = 0.0, relRate = 0.0, div = 0.0, prof = 0.0, pmax = 0.0;
934 std::vector<double> u, v, w, p;
935 std::array<double, 3> spacing{};
936 bool aniso = false;
937};
938
942Tgv runTgv(int nx, int ny, int nz, bool arm, double Lx, double Ly, double Lz, bool advect,
943 int nsteps, double DT = 1.0) {
944 const double RHO = 1.0, MU = 0.25;
945 const double hx = arm ? Lx / nx : 1.0, hy = arm ? Ly / ny : 1.0, hz = arm ? Lz / nz : 1.0;
946 const double Lw = arm ? Lx : (double)nx; // the vortex wavelength (x and y share it)
947 const double k = 2.0 * M_PI / Lw;
948 const double ax = 1.0;
949 const double ay = ax * (std::sin(0.5 * k * hx) / hx) * (hy / std::sin(0.5 * k * hy));
951 if (arm)
952 s.setPhysicalDomain({Lx, Ly, Lz}, {0.0, 0.0, 0.0}, {nx, ny, nz});
953 s.setRho(RHO);
954 s.setMu(MU);
955 s.setDt(DT);
956 s.setAdvection(advect);
957 s.setVelocityIterations(400);
958 s.setVelocityResidualTolerance(1e-14);
959 s.setPressurePcg(true, 300, 1e-13);
960 s.setPressureGeometry(std::vector<double>((std::size_t)nx * ny * nz, 10.0));
961 const std::size_t n = (std::size_t)nx * ny * nz;
962 std::vector<double> u(n), v(n), w(n, 0.0);
963 for (int z = 0; z < nz; ++z)
964 for (int y = 0; y < ny; ++y)
965 for (int x = 0; x < nx; ++x) {
966 const std::size_t i = (std::size_t)x + (std::size_t)y * nx + (std::size_t)z * nx * ny;
967 u[i] = ax * std::cos(k * (x * hx)) * std::sin(k * ((y + 0.5) * hy));
968 v[i] = -ay * std::sin(k * ((x + 0.5) * hx)) * std::cos(k * (y * hy));
969 }
970 s.uploadVelocity(u, v, w);
971 Tgv r;
972 const double Lambda =
973 2.0 * (1.0 - std::cos(k * hx)) / (hx * hx) + 2.0 * (1.0 - std::cos(k * hy)) / (hy * hy);
974 r.rExact = std::pow(1.0 / (1.0 + DT * (MU / RHO) * Lambda), (double)nsteps);
975 for (int it = 0; it < nsteps; ++it)
976 s.step();
977 r.u = s.getVelocity(0);
978 r.v = s.getVelocity(1);
979 r.w = s.getVelocity(2);
980 r.p = s.getPressure();
981 r.spacing = s.spacing();
982 r.aniso = s.unitScales().aniso;
983 double num = 0.0, den = 0.0;
984 for (std::size_t i = 0; i < n; ++i) {
985 num += r.u[i] * u[i];
986 den += u[i] * u[i];
987 }
988 r.ratio = num / den;
989 double pmaxu = 0.0, perr = 0.0;
990 for (std::size_t i = 0; i < n; ++i) {
991 pmaxu = std::fmax(pmaxu, std::fabs(u[i]));
992 perr = std::fmax(perr, std::fabs(r.u[i] - r.ratio * u[i]));
993 }
994 r.prof = perr / pmaxu;
995 r.relRate = std::fabs(r.ratio - r.rExact) / r.rExact;
996 r.div = s.maxOpenDivergence();
997 r.pmax = maxAbs(r.p);
998 (void)hz;
999 return r;
1000}
1001
1002void gateAnisoTgv() {
1003 std::printf("=== units_anisotropic_tgv ===\n");
1004 const int N = 16, NS = 10;
1005 // (a) STOKES on the stretched box L x L x L_z with cells (N, 2N, 4): h = (1, 0.5, 1).
1006 {
1007 const Tgv t =
1008 runTgv(N, 2 * N, 4, /*arm=*/true, (double)N, (double)N, 4.0, /*advect=*/false, NS);
1009 std::printf(" stretched Stokes (%d,%d,4) spacing (%g, %g, %g) aniso %d\n", N, 2 * N,
1010 t.spacing[0], t.spacing[1], t.spacing[2], (int)t.aniso);
1011 std::printf(" amplitude ratio %.17g exact %.17g rel %.3e (bound 1e-10)\n", t.ratio,
1012 t.rExact, t.relRate);
1013 std::printf(
1014 " max|div_o| %.3e (bound 1e-12) profile %.3e (bound 1e-10) max|P| %.3e "
1015 "(the phi proxy, bound 1e-12)\n",
1016 t.div, t.prof, t.pmax);
1017 CHECK(t.aniso);
1018 CHECK(t.relRate <= 1e-10);
1019 CHECK(t.div <= 1e-12);
1020 CHECK(t.prof <= 1e-10);
1021 CHECK(t.pmax <= 1e-12);
1022 }
1023 // (b) the same with advection ON: the NS run.
1024 {
1025 // dt = 0.05 here, `sdflow_tg`'s own step, which is what makes the advective CFL 0.05: at the
1026 // dt = 1 of the Stokes rows the TG amplitude gives CFL = 1 and the discrete nonlinear term's
1027 // imbalance with the pressure gradient is 2.0e-2, i.e. the O(CFL^2) advection error, not a
1028 // metric defect. dt is free here -- the Stokes rows own the exactness statement and the
1029 // bitwise control, both of which need dt = 1 (it pins tRef = 1).
1030 const Tgv t =
1031 runTgv(N, 2 * N, 4, /*arm=*/true, (double)N, (double)N, 4.0, /*advect=*/true, NS, 0.05);
1032 std::printf(
1033 " stretched NS (%d,%d,4) dt 0.05 amplitude ratio %.17g exact %.17g "
1034 "rel %.3e (bound 5e-3) max|div| %.3e (bound 1e-9)\n",
1035 N, 2 * N, t.ratio, t.rExact, t.relRate, t.div);
1036 CHECK(t.relRate <= 5e-3);
1037 CHECK(t.div <= 1e-9);
1038 }
1039 // (c) the isotropic control (N, N, 4) at extent == cells: the same bounds, and BITWISE the
1040 // cell-unit run of the same problem (rho = dt = 1 pins every reference scale to 1).
1041 {
1042 const Tgv a = runTgv(N, N, 4, /*arm=*/true, (double)N, (double)N, 4.0, /*advect=*/false, NS);
1043 const Tgv b = runTgv(N, N, 4, /*arm=*/false, (double)N, (double)N, 4.0, /*advect=*/false, NS);
1044 std::printf(" isotropic control (%d,%d,4) spacing (%g, %g, %g) aniso %d\n", N, N, a.spacing[0],
1045 a.spacing[1], a.spacing[2], (int)a.aniso);
1046 std::printf(
1047 " amplitude ratio %.17g exact %.17g rel %.3e max|div_o| %.3e profile %.3e "
1048 " max|P| %.3e\n",
1049 a.ratio, a.rExact, a.relRate, a.div, a.prof, a.pmax);
1050 CHECK(!a.aniso);
1051 CHECK(a.relRate <= 1e-10);
1052 CHECK(a.div <= 1e-12);
1053 CHECK(a.prof <= 1e-10);
1054 CHECK(a.pmax <= 1e-12);
1055 const bool bit = bitwiseEqual(a.u, b.u) && bitwiseEqual(a.v, b.v) && bitwiseEqual(a.w, b.w) &&
1056 bitwiseEqual(a.p, b.p);
1057 std::printf(" armed at extent == cells vs the cell-unit run: bitwise %s\n",
1058 bit ? "EQUAL" : "DIFFERENT");
1059 CHECK(bit);
1060 }
1061}
1062} // namespace
1063
1064// =============================================================== units_vof_aniso (PHASE 3, S2)
1065//
1066// `flow/doc/anisotropic_vof.md` §11 S2 — the gate the Phase 2 merge finally makes constructible,
1067// because `enable_vof` refused an anisotropic domain until Phase 3 landed.
1068//
1069// A stationary droplet on cells that are NOT cubes. Two statements, and they are independent:
1070//
1071// (a) THE BALANCED-FORCE IDENTITY SURVIVES THE METRIC. With a CONSTANT curvature the CSF force
1072// is `sigma' kappa' (C(i) - C(i-s_a)) * w_a` — the SAME per-axis weight `w_a = 1/h_a'^2` the
1073// momentum RHS puts on `-(P(i) - P(i-s_a))` (Phase 2 §1.3). So it is again exactly the
1074// discrete weighted gradient of `sigma' kappa' C`, the projection annihilates it, and the
1075// drop stays at machine zero. Get the weight wrong on ONE axis and this floors at the
1076// spurious-current level instead — which is precisely what the gate is for.
1077//
1078// (b) YOUNG-LAPLACE IS THE PHYSICAL JUMP. `dp = sigma * kappa` in the caller's units, on a mesh
1079// whose cells differ by a factor 4 between axes.
1080//
1081// The colour is the EXACT fraction of a physical sphere over anisotropic cells, so the geometry is
1082// the same physical problem at every aspect ratio and only the mesh changes.
1084 double maxU = 0.0, dp = 0.0, kappa = 0.0, dtCap = 0.0, hMin = 0.0;
1085};
1086
1089double sphereFracBox(double cx, double cy, double cz, double R, double x0, double y0, double z0,
1090 const double h[3], int sub = 24) {
1091 const double wx = h[0] / sub, wy = h[1] / sub;
1092 double acc = 0.0;
1093 for (int b = 0; b < sub; ++b)
1094 for (int a = 0; a < sub; ++a) {
1095 const double px = x0 + (a + 0.5) * wx, py = y0 + (b + 0.5) * wy;
1096 const double r2 = R * R - (px - cx) * (px - cx) - (py - cy) * (py - cy);
1097 if (r2 <= 0.0)
1098 continue;
1099 const double hh = std::sqrt(r2);
1100 const double lo = std::fmax(cz - hh, z0), hi = std::fmin(cz + hh, z0 + h[2]);
1101 if (hi > lo)
1102 acc += hi - lo;
1103 }
1104 return acc / (sub * sub * h[2]);
1105}
1106
1108AnisoDrop runDropletAniso(const int n[3], const double h[3], double R, bool constantKappa,
1109 int steps) {
1110 const double SIGMA = 1.0, MU = 0.1, RHO = 1.0;
1112 s.setPhysicalDomain({n[0] * h[0], n[1] * h[1], n[2] * h[2]}, {0.0, 0.0, 0.0}, {n[0], n[1], n[2]});
1113 s.setRho(RHO);
1114 s.setMu(MU);
1115 s.setDt(1.0);
1116 s.setVelocityResidualTolerance(0.0); // machine-precision gate: keep the fixed-sweep loop
1117 const std::size_t nc = (std::size_t)n[0] * n[1] * n[2];
1118 const double hMin = std::fmin(h[0], std::fmin(h[1], h[2]));
1119 s.setPressureGeometry(std::vector<double>(nc, 10.0 * hMin));
1120 s.setPressureChebyshev(true, 500, 1e-14);
1121 s.enableVof(); // <- refused before Phase 3; the whole point of this gate
1122 // the drop, off-centre by an irrational fraction of a cell on every axis
1123 const double ctr[3] = {0.5 * n[0] * h[0] + 0.13 * h[0], 0.5 * n[1] * h[1] + 0.27 * h[1],
1124 0.5 * n[2] * h[2] + 0.11 * h[2]};
1125 std::vector<double> C(nc);
1126 for (int k = 0; k < n[2]; ++k)
1127 for (int j = 0; j < n[1]; ++j)
1128 for (int i = 0; i < n[0]; ++i)
1129 C[(std::size_t)i + (std::size_t)j * n[0] + (std::size_t)k * n[0] * n[1]] =
1130 sphereFracBox(ctr[0], ctr[1], ctr[2], R, i * h[0], j * h[1], k * h[2], h);
1131 s.setVof(C);
1132 s.setPropertyModel("rho", peclet::flow::ClosureKind::LinearMix, "C", "", {RHO, 0.0});
1133 s.setSurfaceTension(SIGMA);
1134 AnisoDrop d;
1135 d.hMin = hMin;
1136 d.kappa = 2.0 / R; // physical 1/length
1137 if (constantKappa)
1138 s.setVofKappaConstant(d.kappa);
1139 else
1140 s.computeVofCurvature();
1141 d.dtCap = s.capillaryDt();
1142 s.setDt(0.5 * d.dtCap);
1143 for (int q = 0; q < steps; ++q)
1144 s.step();
1145 for (int c = 0; c < 3; ++c)
1146 d.maxU = std::fmax(d.maxU, maxAbs(s.getVelocity(c)));
1147 const std::vector<double> p = s.getPressure();
1148 const std::size_t inside = (std::size_t)(n[0] / 2) + (std::size_t)(n[1] / 2) * n[0] +
1149 (std::size_t)(n[2] / 2) * n[0] * n[1];
1150 const std::size_t outside = 1 + (std::size_t)1 * n[0] + (std::size_t)1 * n[0] * n[1];
1151 d.dp = p[inside] - p[outside];
1152 return d;
1153}
1154
1156 std::printf("=== units_vof_aniso (Phase 3 S2: the balanced-force CSF on BOX cells) ===\n");
1157 // Three meshes carrying the SAME physical droplet: cubic, aspect 2, aspect 4. The cell counts
1158 // keep the physical box cubic, so only the cell SHAPE changes.
1159 const double base = 1.0 / 32.0;
1160 struct Mesh {
1161 const char* name;
1162 int n[3];
1163 double h[3];
1164 };
1165 const Mesh meshes[3] = {
1166 {"cubic ", {32, 32, 32}, {base, base, base}},
1167 {"aspect 2 ", {32, 64, 32}, {base, base / 2, base}},
1168 {"aspect 4 ", {32, 64, 128}, {base, base / 2, base / 4}},
1169 };
1170 const double R = 8.0 * base; // D/h = 16 on the coarsest axis
1171 const double uScale = 1.0 * (2.0 / R) / 0.1; // sigma*kappa/mu, the naive-CSF current
1172
1173 for (int q = 0; q < 3; ++q) {
1174 const Mesh& m = meshes[q];
1175 // (a) the EXACTNESS identity: constant kappa -> the projection annihilates the force.
1176 const AnisoDrop a = runDropletAniso(m.n, m.h, R, true, 20);
1177 // (b) Young-Laplace with the same constant curvature.
1178 std::printf(" %s n = (%3d,%3d,%3d) h'/hMin = (%.2f, %.2f, %.2f)\n", m.name, m.n[0], m.n[1],
1179 m.n[2], m.h[0] / a.hMin, m.h[1] / a.hMin, m.h[2] / a.hMin);
1180 std::printf(" exactness max|u| = %.3e (gate %.3e = 1e-14 * sigma*kappa/mu)\n", a.maxU,
1181 1e-14 * uScale);
1182 std::printf(" Young-Laplace dp = %.9e vs sigma*kappa = %.9e (rel %.2e)\n", a.dp, a.kappa,
1183 std::fabs(a.dp - a.kappa) / a.kappa);
1184 std::printf(" capillary_dt = %.9e (h_min = %.6e)\n", a.dtCap, a.hMin);
1185 CHECK(a.maxU < 1e-14 * uScale);
1186 checkClose(a.dp, a.kappa, 1e-9 * a.kappa, "anisotropic Young-Laplace");
1187 // The Brackbill limit is set by the SMALLEST cell: refining one axis by 2 must shrink it by
1188 // 2^{3/2}, which is the statement that `capillary_dt` takes `min_a h_a` and not `h_x`.
1189 if (q > 0) {
1190 const double want = meshes[0].h[0] / a.hMin; // hMin ratio vs the cubic mesh
1191 const double got = runDropletAniso(meshes[0].n, meshes[0].h, R, true, 0).dtCap / a.dtCap;
1192 std::printf(" dt_sigma(cubic)/dt_sigma(this) = %.4f (h^{3/2} predicts %.4f)\n", got,
1193 std::pow(want, 1.5));
1194 CHECK(std::fabs(got - std::pow(want, 1.5)) < 1e-9 * std::pow(want, 1.5));
1195 }
1196 }
1197
1198 // (c) the COMPUTED curvature. Everything above is EXACT because a constant kappa makes the CSF
1199 // the discrete weighted gradient the projection annihilates; with the cascade's own kappa the
1200 // residual currents are the curvature error and nothing else. MEASURED: the box mesh costs a
1201 // constant factor, it does not diverge —
1202 //
1203 // spurious Ca cubic 2.499e-04 aspect 2 7.869e-04 (ratio 3.15)
1204 //
1205 // A factor ~3 at aspect 2, on a mesh whose sphere is resolved BETTER on the refined axis. The
1206 // likely mechanism is the V2.1 decision (the column direction is ordered by the INDEX normal,
1207 // which is the right frame for whether 7 cells can close a column but not for how much PHYSICAL
1208 // interface those 7 cells span) — so a stretched mesh routes more cells to the PV fallback than
1209 // its cell count suggests. NOT isolated here: confirming it needs the branch census per mesh,
1210 // and it is an accuracy characteristic rather than a defect, because the property this rung
1211 // rests on — the exactness identity above — is at machine zero on all three meshes.
1212 // The gate is therefore set from the measurement (4x) and the number is printed every run.
1213 const AnisoDrop cc = runDropletAniso(meshes[0].n, meshes[0].h, R, false, 20);
1214 const AnisoDrop ca = runDropletAniso(meshes[1].n, meshes[1].h, R, false, 20);
1215 const double caC = 0.1 * cc.maxU / 1.0, caA = 0.1 * ca.maxU / 1.0;
1216 std::printf(" computed kappa: spurious Ca cubic %.3e vs aspect-2 %.3e (ratio %.2f, gate 4)\n",
1217 caC, caA, caA / caC);
1218 CHECK(caA < 4.0 * caC);
1219 checkClose(ca.dp, ca.kappa, 5e-2 * ca.kappa, "anisotropic Young-Laplace (computed kappa)");
1220}
1221
1222namespace {
1223// --------------------------------------------------------------------------------------------
1224// SCALAR TRANSPORT + THE PHASE-CHANGE ENERGY PATH IN PHYSICAL UNITS (2026-10-02).
1225//
1226// PHYSICAL_UNITS_PLAN U3 converted the momentum and VoF setters and missed this surface:
1227// add_scalar's diffusivity, the phase-change densities, latent heat, conductivities, heat
1228// capacities, interfacial resistance and prescribed mass flux, the divergence source, the closures
1229// that read a scalar, and the getters that report them. The three gates below are the ones the
1230// first two ran without — each would have failed before the fix.
1231//
1232// A system is parameterised by its unit factors: the numerical value of a length is divided by
1233// `lam`, of a time by `tau`, of a mass by `mas` (lam = tau = mas = 1 is the reference system). A
1234// transported scalar (temperature, concentration) is never rescaled.
1235struct Units {
1236 double lam = 1.0, tau = 1.0, mas = 1.0;
1237 double len(double x) const { return x / lam; }
1238 double time(double t) const { return t / tau; }
1239 double rho(double r) const { return r * lam * lam * lam / mas; }
1240 double mu(double m) const { return m * lam * tau / mas; }
1241 double diff(double d) const { return d * tau / (lam * lam); }
1242 double vel(double u) const { return u * tau / lam; }
1243 double mdot(double m) const { return m * lam * lam * tau / mas; }
1244 double latent(double h) const { return h * tau * tau / (lam * lam); }
1245 double cond(double k) const { return k * tau * tau * tau / (mas * lam); }
1246 double rcp(double c) const { return c * lam * tau * tau / mas; }
1247 double pres(double p) const { return p * lam * tau * tau / mas; }
1248 double force(double f) const { return f * lam * lam * tau * tau / mas; } // per volume
1249 double area(double a) const { return a / (lam * lam); }
1250 double vol(double v) const { return v / (lam * lam * lam); }
1251 double energy(double e) const { return e * tau * tau / (mas * lam * lam); }
1252 double power(double p) const { return p * tau * tau * tau / (mas * lam * lam); }
1253 double rate(double r) const { return r * tau; } // 1/time
1254};
1255
1256double relDiff(const std::vector<double>& a, const std::vector<double>& b) {
1257 return relDiffScaled(a, b, 1.0);
1258}
1259double relScalar(double a, double b) { // |a - b| / |a| (or |b| when a == 0)
1260 const double den = std::fabs(a) > 0.0 ? std::fabs(a) : std::fabs(b);
1261 return den > 0.0 ? std::fabs(a - b) / den : 0.0;
1262}
1263
1268struct ScalarSine {
1269 std::vector<double> T, x;
1270 double amp = 0.0;
1271};
1272ScalarSine runScalarSine(int N, const std::array<double, 3>& ext, double D, double U, double dt,
1273 int nsteps, const Units& un, bool dtFirst, int scheme = 1) {
1274 const int ny = 4, nz = 4;
1276 s.setPhysicalDomain({un.len(ext[0]), un.len(ext[1]), un.len(ext[2])}, {0.0, 0.0, 0.0},
1277 {N, ny, nz});
1278 s.setRho(un.rho(1.0));
1279 if (dtFirst)
1280 s.setDt(un.time(dt));
1281 s.addScalar("T", un.diff(D), scheme, 200);
1282 if (!dtFirst)
1283 s.setDt(un.time(dt));
1284 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, un.len(1.0)));
1285 const double k = 2.0 * M_PI / un.len(ext[0]);
1286 const std::vector<double> cx = s.cellCentres(0);
1287 std::vector<double> T0((std::size_t)N * ny * nz);
1288 for (int z = 0; z < nz; ++z)
1289 for (int y = 0; y < ny; ++y)
1290 for (int x = 0; x < N; ++x)
1291 T0[(std::size_t)x + (std::size_t)y * N + (std::size_t)z * N * ny] = std::sin(k * cx[x]);
1292 s.setField("T", T0);
1293 if (U != 0.0)
1294 s.setVelocity(0, std::vector<double>((std::size_t)N * ny * nz, un.vel(U)));
1295 for (int n = 0; n < nsteps; ++n)
1296 s.advanceScalars();
1297 ScalarSine r;
1298 r.T = s.getField("T");
1299 r.x = cx;
1300 double num = 0.0, den = 0.0; // projection on the (exactly discrete) sine mode
1301 for (int z = 0; z < nz; ++z)
1302 for (int y = 0; y < ny; ++y)
1303 for (int x = 0; x < N; ++x) {
1304 const double sx = std::sin(k * cx[x]);
1305 num += r.T[(std::size_t)x + (std::size_t)y * N + (std::size_t)z * N * ny] * sx;
1306 den += sx * sx;
1307 }
1308 r.amp = num / den;
1309 return r;
1310}
1311
1313double closureProbe(const Units& un, bool rhoFirst) {
1314 const int N = 8;
1316 s.setPhysicalDomain({un.len(1.0), un.len(1.0), un.len(1.0)}, {0.0, 0.0, 0.0}, {N, N, N});
1317 if (rhoFirst) {
1318 s.setRho(un.rho(998.0));
1319 s.setDt(un.time(1e-3));
1320 }
1321 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, un.len(1.0)));
1322 s.addScalar("T", un.diff(1.4e-7), 1, 10);
1323 s.setField("T", std::vector<double>((std::size_t)N * N * N, 310.0));
1324 // buoyancy rho0 g beta (T - T0): rho0 a density, g an acceleration, beta 1/K
1325 s.setPropertyModel("force_z", peclet::flow::ClosureKind::BoussinesqForce, "T", "",
1326 {un.rho(998.0), un.force(9.81) / un.rho(1.0), 2.1e-4, 300.0});
1327 if (!rhoFirst) {
1328 s.setRho(un.rho(998.0));
1329 s.setDt(un.time(1e-3));
1330 }
1331 s.updateProperties();
1332 return s.getField("force_z")[0];
1333}
1334
1335void gateScalar() {
1336 std::printf("=== units_scalar_scale_invariance ===\n");
1337 const double TOL = 1e-13;
1338 const Units ref; // the reference system
1339 Units big; // a length unit 1000x, a time unit 0.037x, a mass unit 2.9e5x the reference: not
1340 // powers of ten, so no conversion factor is exact by accident
1341 big.lam = 1e3;
1342 big.tau = 3.7e-2;
1343 big.mas = 2.9e5;
1344 const int N = 32;
1345 const std::array<double, 3> ext{2.0, 4.0 * 2.0 / N, 4.0 * 2.0 / N};
1346 const double h = 2.0 / N, D = 0.05, dt = 0.5 * h * h / D;
1347 for (int adv = 0; adv < 2; ++adv) {
1348 const double U = adv ? 0.3 : 0.0; // Courant U dt/h = 0.19
1349 const ScalarSine a = runScalarSine(N, ext, D, U, dt, 40, ref, true);
1350 const ScalarSine b = runScalarSine(N, ext, D, U, dt, 40, big, false);
1351 const double rT = relDiff(a.T, b.T);
1352 std::printf(" %s: max|T_A - T_B|/max|T_A| = %.3e (B registers the scalar before set_dt)\n",
1353 adv ? "advection-diffusion (Koren)" : "diffusion ", rT);
1354 CHECK(maxAbs(a.T) > 0.1);
1355 CHECK(rT <= TOL);
1356 }
1357 // Closures reading a scalar: the Boussinesq force lands in force_z at the same INTERNAL value in
1358 // both systems and in both call orders (a scale pinned after registration re-derives it).
1359 const double fA = closureProbe(ref, true), fB = closureProbe(big, false);
1360 const double fC = closureProbe(big, true);
1361 std::printf(" Boussinesq force_z (internal): A %.17g B(rho/dt after) %.17g B(before) %.17g\n",
1362 fA, fB, fC);
1363 CHECK(std::fabs(fA) > 0.0);
1364 CHECK(relScalar(fA, fB) <= TOL);
1365 CHECK(relScalar(fA, fC) <= TOL);
1366}
1367
1368void gateScalarSine() {
1369 std::printf("=== units_scalar_sine_decay ===\n");
1370 // 1-D diffusion of a sine mode, T = sin(k x) exp(-D k^2 t), on a PHYSICAL box (Lx = 2,
1371 // D = 0.05) that is not the cell count, at two resolutions with the Fourier number D dt/h^2 = 1/4
1372 // fixed (so backward Euler's O(dt) is O(h^2) too), to t = 1/(D k^2). Two statements:
1373 // (a) the amplitude equals the EXACT backward-Euler/second-difference amplitude
1374 // r^n, r = 1/(1 + dt D 4 sin^2(k h/2)/h^2), to round-off — the physical D reaches the
1375 // operator exactly, metric included;
1376 // (b) it converges to exp(-D k^2 t) at second order.
1377 // Both on cubic cells and on BOX cells (h_y = h_z = h_x/2, so hRef = h_x/2 and the diffusing
1378 // axis carries w_x = 1/4).
1379 Units un;
1380 un.lam = 0.37; // a unit system that is neither cells nor the reference
1381 un.tau = 3.0;
1382 un.mas = 2.0;
1383 const double Lx = 2.0, D = 0.05, k = 2.0 * M_PI / Lx, tEnd = 1.0 / (D * k * k);
1384 for (int box = 0; box < 2; ++box) {
1385 double err[2] = {0, 0};
1386 for (int q = 0; q < 2; ++q) {
1387 const int N = q ? 32 : 16;
1388 const double h = Lx / N, dt0 = 0.25 * h * h / D;
1389 const int ns = (int)std::ceil(tEnd / dt0);
1390 const double dt = tEnd / ns;
1391 const double hyz = box ? 0.5 * h : h;
1392 const ScalarSine r = runScalarSine(N, {Lx, 4 * hyz, 4 * hyz}, D, 0.0, dt, ns, un, true);
1393 const double s2 = std::sin(0.5 * k * h);
1394 const double rr = 1.0 / (1.0 + dt * D * 4.0 * s2 * s2 / (h * h));
1395 const double discrete = std::pow(rr, ns), exact = std::exp(-D * k * k * tEnd);
1396 err[q] = std::fabs(r.amp - exact) / exact;
1397 const double eDisc = std::fabs(r.amp - discrete) / discrete;
1398 std::printf(
1399 " %s N=%2d (%3d steps): amplitude %.15f discrete %.15f (rel %.2e) exact %.15f "
1400 "(rel %.3e)\n",
1401 box ? "box " : "cubic", N, ns, r.amp, discrete, eDisc, exact, err[q]);
1402 CHECK(eDisc <= 1e-12);
1403 }
1404 const double order = std::log(err[0] / err[1]) / std::log(2.0);
1405 std::printf(" %s observed order %.3f\n", box ? "box " : "cubic", order);
1406 CHECK(order > 1.9 && order < 2.1);
1407 CHECK(err[1] < 1e-2);
1408 }
1409}
1410
1411// ---- the phase-change energy path ----------------------------------------------------------
1412struct PcRun {
1413 std::vector<double> C, T, u, p;
1414 double layer = 0.0;
1417 double carryDep = 0.0, area = 0.0;
1418};
1419
1420double stefanLambdaU(double St) { // lambda e^{lambda^2} erf(lambda) = St/sqrt(pi)
1421 const double rhs = St / std::sqrt(M_PI);
1422 double a = 1e-8, b = 5.0;
1423 for (int i = 0; i < 200; ++i) {
1424 const double m = 0.5 * (a + b);
1425 ((m * std::exp(m * m) * std::erf(m) - rhs) > 0 ? b : a) = m;
1426 }
1427 return 0.5 * (a + b);
1428}
1429
1434PcRun runStefanUnits(int N, const Units& un, bool energy, bool scalesFirst) {
1435 const double St = 1.0, alpha = 1.0, x0p = 0.10, xep = 0.25, Fo = 0.5;
1436 const double lam = stefanLambdaU(St);
1437 const double t0 = (x0p / (2 * lam)) * (x0p / (2 * lam)) / alpha;
1438 const double te = (xep / (2 * lam)) * (xep / (2 * lam)) / alpha;
1439 const int ny = 4, nz = 4;
1440 const double D = alpha * N * N; // the reference system is the cell-unit one: h = 1
1441 const int ns = (int)std::llround((te - t0) / (Fo / D));
1442 const double dt = (te - t0) / ns;
1443 peclet::flow::IbmSolver s(N, ny, nz);
1444 s.setPhysicalDomain({un.len(N), un.len(ny), un.len(nz)}, {0.0, 0.0, 0.0}, {N, ny, nz});
1445 auto scales = [&] {
1446 s.setRho(un.rho(1.0));
1447 s.setMu(un.mu(1e-3));
1448 s.setDt(un.time(dt));
1449 };
1450 if (scalesFirst)
1451 scales();
1452 s.setDomainBc(0, 1, 0, 0, 0);
1453 s.setDomainBc(1, 1, 0, 0, 0);
1454 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, un.len(1.0)));
1455 const double xg = x0p * N;
1456 std::vector<double> C((std::size_t)N * ny * nz, 0.0), T((std::size_t)N * ny * nz, 0.0);
1457 for (int z = 0; z < nz; ++z)
1458 for (int y = 0; y < ny; ++y)
1459 for (int x = 0; x < N; ++x) {
1460 const std::size_t i = (std::size_t)x + (std::size_t)y * N + (std::size_t)z * N * ny;
1461 C[i] = std::fmin(1.0, std::fmax(0.0, (x + 1) - xg));
1462 const double xp = (x + 0.5) / N;
1463 T[i] = xp < x0p ? 1.0 - std::erf(lam * xp / x0p) / std::erf(lam) : 0.0;
1464 }
1465 s.enableVof();
1466 s.setVof(C);
1467 s.addScalar("T", un.diff(D), 1, 60);
1468 s.setScalarBc("T", 0, 2, 1.0);
1469 s.setScalarBc("T", 1, 2, 0.0);
1470 s.setField("T", T);
1471 s.enablePhaseChange(un.rho(1.0), un.rho(1.0), un.latent(1.0));
1472 // rho c_p = 1 in the reference system, so k = rho c_p D = D
1473 s.setPhaseChangeThermal("T", 0.0, un.cond(D), un.cond(D), 0.0);
1474 if (energy) {
1475 s.setPhaseChangeEnergy(un.rcp(1.0), un.rcp(1.0));
1476 s.setPhaseChangeEnergyOrder(2);
1477 s.setPhaseChangeBudget(true);
1478 }
1479 if (!scalesFirst)
1480 scales();
1481 for (int n = 0; n < ns; ++n) {
1482 s.applyPhaseChange(un.time(dt));
1483 s.advanceScalars();
1484 }
1485 PcRun r;
1486 r.C = s.getVof();
1487 r.T = s.getField("T");
1488 double sum = 0;
1489 for (double q : r.C)
1490 sum += q;
1491 r.layer = un.len(N - sum / (ny * nz));
1492 r.d = s.phaseChangeDiagnostics();
1493 if (energy) {
1494 r.b = s.phaseChangeBudgetValues();
1495 r.carryDep = s.phaseChangeCarryDeposited();
1496 }
1497 r.area = s.vofInterfaceArea();
1498 return r;
1499}
1500
1504PcRun runSuckingUnits(int N, int nsteps, const Units& un) {
1505 const double ratio = 10.0, ja = 1.0, alpha_l = 1.0, x0p = 0.10, Fo = 0.5, cfl = 0.2;
1506 const double rr = 1.0 / ratio;
1507 double b = ja / std::sqrt(M_PI);
1508 for (int i = 0; i < 300; ++i) {
1509 const double g = b * rr;
1510 b = ja * std::exp(-g * g) / (std::sqrt(M_PI) * std::erfc(g));
1511 }
1512 const double t0 = (x0p / (2 * b)) * (x0p / (2 * b)) / alpha_l;
1513 const int ny = 4, nz = 4;
1514 const double al = alpha_l * N * N, rho_l = 1.0, rho_v = rr, cpl = 1.0;
1515 const double k_l = al * rho_l * cpl, k_v = k_l / ratio, dT = 1.0;
1516 const double h_lv = rho_l * cpl * dT / (rho_v * ja);
1517 peclet::flow::IbmSolver s(N, ny, nz);
1518 s.setPhysicalDomain({un.len(N), un.len(ny), un.len(nz)}, {0.0, 0.0, 0.0}, {N, ny, nz});
1519 s.setRho(un.rho(rho_l));
1520 s.setMu(un.mu(1e-3));
1521 s.setDomainBc(0, 1, 0, 0, 0);
1522 s.setDomainBc(1, 3, 0, 0, 0);
1523 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, un.len(1.0)));
1524 const double X0 = x0p * N;
1525 std::vector<double> C((std::size_t)N * ny * nz, 0.0), T((std::size_t)N * ny * nz, 0.0);
1526 for (int z = 0; z < nz; ++z)
1527 for (int y = 0; y < ny; ++y)
1528 for (int x = 0; x < N; ++x) {
1529 const std::size_t i = (std::size_t)x + (std::size_t)y * N + (std::size_t)z * N * ny;
1530 C[i] = std::fmin(1.0, std::fmax(0.0, (x + 1) - X0));
1531 const double xp = (x + 0.5) / N;
1532 if (xp > x0p) {
1533 const double sv = xp / (2 * std::sqrt(alpha_l * t0)) - b * (1.0 - rr);
1534 T[i] = dT - dT * std::erfc(sv) / std::erfc(b * rr);
1535 }
1536 }
1537 s.enableVof();
1538 s.setVof(C);
1539 s.setPropertyModel("rho", peclet::flow::ClosureKind::LinearMix, "C", "",
1540 {un.rho(rho_v), un.rho(rho_l - rho_v)});
1541 s.setPressureFcg(true, 4000, 1e-10);
1542 s.addScalar("T", un.diff(al), 1, 60);
1543 s.setScalarBc("T", 0, 2, 0.0);
1544 auto farT = [&](double tt) {
1545 const double sv = 1.0 / (2 * std::sqrt(alpha_l * tt)) - b * (1.0 - rr);
1546 return dT - dT * std::erfc(sv) / std::erfc(b * rr);
1547 };
1548 s.setScalarBc("T", 1, 2, farT(t0));
1549 s.setField("T", T);
1550 s.enablePhaseChange(un.rho(rho_v), un.rho(rho_l), un.latent(h_lv));
1551 s.setPhaseChangeThermal("T", 0.0, un.cond(k_v), un.cond(k_l), 0.0);
1552 s.setPhaseChangeEnergy(un.rcp(rho_v * cpl), un.rcp(rho_l * cpl));
1553 double tcur = t0;
1554 for (int n = 0; n < nsteps; ++n) {
1555 const double Xd = b * std::sqrt(alpha_l / tcur) * N;
1556 const double dt = std::fmin(cfl / std::fmax(Xd * (1.0 - rr), 1e-30), Fo / al);
1557 s.setDt(un.time(dt)); // the FIRST one pins tRef: every constant above is re-derived
1558 s.setScalarBc("T", 1, 2, farT(tcur + dt));
1559 s.step();
1560 tcur += dt;
1561 }
1562 PcRun r;
1563 r.C = s.getVof();
1564 r.T = s.getField("T");
1565 r.u = s.getVelocity(0);
1566 r.p = s.getPressure();
1567 r.d = s.phaseChangeDiagnostics();
1568 return r;
1569}
1570
1571void gatePhaseChange() {
1572 std::printf("=== units_phase_change_scale_invariance ===\n");
1573 const double TOL = 1e-12;
1574 const Units ref;
1575 Units big; // as in gateScalar
1576 big.lam = 1e3;
1577 big.tau = 3.7e-2;
1578 big.mas = 2.9e5;
1579 // (1) kinematic Stefan, constant-diffusivity energy path (P1'), and (2) with the consistent
1580 // energy transport + the second-order package + the budget instruments.
1581 for (int energy = 0; energy < 2; ++energy) {
1582 const PcRun a = runStefanUnits(32, ref, energy != 0, true);
1583 const PcRun b = runStefanUnits(32, big, energy != 0, false);
1584 const double rC = relDiff(a.C, b.C), rT = relDiff(a.T, b.T);
1585 const double rL = relScalar(big.len(a.layer), b.layer);
1586 const double rM = relScalar(big.mdot(a.d.mdotMean), b.d.mdotMean);
1587 const double rA = relScalar(big.area(a.d.area), b.d.area);
1588 const double rAv = relScalar(big.area(a.area), b.area);
1589 const double rV = relScalar(big.vol(a.d.removedVolume), b.d.removedVolume);
1590 std::printf(
1591 " Stefan (%s): C %.2e T %.2e layer %.2e mdot_mean %.2e interface_area %.2e "
1592 "vof_interface_area %.2e removed_volume %.2e\n",
1593 energy ? "rho c_p T, order 2" : "constant D ", rC, rT, rL, rM, rA, rAv, rV);
1594 CHECK(maxAbs(a.T) > 0.1 && a.d.interfaceCells > 0);
1595 CHECK(rC <= TOL && rT <= TOL && rL <= TOL);
1596 CHECK(rM <= TOL && rA <= TOL && rAv <= TOL && rV <= TOL);
1597 if (energy) {
1598 const double rq = relScalar(big.power(a.d.qOperator), b.d.qOperator);
1599 const double rh = relScalar(big.energy(a.b.hOpen), b.b.hOpen);
1600 const double rg = relScalar(big.power(a.b.qGfm), b.b.qGfm);
1601 std::printf(" q_operator %.2e budget h_open %.2e q_gfm %.2e (carry %.3e vs %.3e)\n", rq,
1602 rh, rg, big.energy(a.carryDep), b.carryDep);
1603 CHECK(a.d.qOperator != 0.0 && a.b.qGfm != 0.0);
1604 CHECK(rq <= TOL && rh <= TOL && rg <= TOL);
1605 CHECK(std::fabs(big.energy(a.carryDep) - b.carryDep) <=
1606 TOL * std::fabs(big.energy(a.b.hOpen)) + 1e-300);
1607 }
1608 }
1609 // (3) the full step(): density ratio 10, the rho closure, the outflow face, the consistent
1610 // energy transport, every constant set before the first set_dt pins tRef.
1611 {
1612 const PcRun a = runSuckingUnits(32, 12, ref);
1613 const PcRun b = runSuckingUnits(32, 12, big);
1614 const double rC = relDiff(a.C, b.C), rT = relDiff(a.T, b.T);
1615 const double ru = relDiffScaled(b.u, a.u, big.vel(1.0)),
1616 rp = relDiffScaled(b.p, a.p, big.pres(1.0));
1617 const double rM = relScalar(big.mdot(a.d.mdotMean), b.d.mdotMean);
1618 std::printf(" sucking interface, 12 full steps: C %.2e T %.2e u %.2e p %.2e mdot %.2e\n",
1619 rC, rT, ru, rp, rM);
1620 CHECK(maxAbs(a.u) > 0.0);
1621 // measured 1.8e-15 / 7.8e-16 / 3.2e-15 / 1.7e-13 / 9.9e-16 on host-openmp: the pressure is the
1622 // FCG's own round-off (rtol 1e-10, the same iteration count in both systems)
1623 CHECK(rC <= 1e-11 && rT <= 1e-11 && ru <= 1e-11 && rp <= 1e-11 && rM <= 1e-11);
1624 }
1625 // (4) vof_interface_area on BOX cells: a plane x = x0 across a (Ly, Lz) box has area Ly*Lz in
1626 // every mode. Before 1c80540 the 'plic' mode summed the unit-cube polygon (one per column of
1627 // cells), i.e. ny*nz*hRef^2 = half the area here.
1628 {
1629 const int nx = 16, ny = 32, nz = 8;
1630 const double Lx = 1.6, Ly = 0.8, Lz = 0.4; // h = (0.1, 0.025, 0.05): three spacings
1631 peclet::flow::IbmSolver s(nx, ny, nz);
1632 s.setPhysicalDomain({Lx, Ly, Lz}, {0.0, 0.0, 0.0}, {nx, ny, nz});
1633 s.setRho(1.0);
1634 s.setDt(1e-3);
1635 s.setPressureGeometry(std::vector<double>((std::size_t)nx * ny * nz, 1.0));
1636 s.enableVof();
1637 std::vector<double> C((std::size_t)nx * ny * nz);
1638 for (std::size_t i = 0; i < C.size(); ++i)
1639 C[i] = std::fmin(1.0, std::fmax(0.0, (double)(i % nx) + 1.0 - 5.3));
1640 s.setVof(C);
1641 const double aDefault = s.vofInterfaceArea();
1642 s.setPhaseChangeArea(peclet::flow::vof::kAreaPlic);
1643 const double aPlic = s.vofInterfaceArea();
1644 std::printf(
1645 " box-cell plane: vof_interface_area %.15g (default sheet), %.15g (plic) vs %.15g\n",
1646 aDefault, aPlic, Ly * Lz);
1647 CHECK(relScalar(aPlic, Ly * Lz) <= 1e-12);
1648 CHECK(relScalar(aDefault, Ly * Lz) <= 1e-12);
1649 }
1650 // (5) the refusals. A prescribed mass flux is written into a field once, so under a physical
1651 // domain it needs the scales pinned; and the operator mdot without the consistent energy
1652 // transport has no heat capacity to be physical with.
1653 {
1654 peclet::flow::IbmSolver s(16, 4, 4);
1655 s.setPhysicalDomain({1.0, 0.25, 0.25}, {0.0, 0.0, 0.0}, {16, 4, 4});
1656 s.setPressureGeometry(std::vector<double>(16 * 4 * 4, 1.0));
1657 s.enablePhaseChange(1.0, 1000.0, 2.26e6);
1658 bool threw = false;
1659 try {
1660 s.setMassFluxUniform(1e-3);
1661 } catch (const std::runtime_error&) {
1662 threw = true;
1663 }
1664 CHECK(threw);
1665 s.setRho(1000.0);
1666 s.setDt(1e-4);
1667 s.setMassFluxUniform(1e-3); // now fine
1668 const double kInt = s.unitScales().mdotToInt();
1669 CHECK(relScalar(s.getField("mdot")[0], 1e-3 * kInt) <= 1e-15);
1670 s.addScalar("T", 1e-7, 1, 10);
1671 s.setPhaseChangeThermal("T", 373.15, 0.025, 0.6, 0.0);
1672 s.setPhaseChangeMdotOperator(true);
1673 std::vector<double> C(16 * 4 * 4, 0.0);
1674 for (int i = 0; i < 16 * 4 * 4; ++i)
1675 C[(std::size_t)i] = std::fmin(1.0, std::fmax(0.0, (i % 16) + 1 - 6.5));
1676 s.setVof(C);
1677 threw = false;
1678 try {
1679 s.applyPhaseChange(1e-4);
1680 } catch (const std::runtime_error&) {
1681 threw = true;
1682 }
1683 std::printf(" refusals: set_mass_flux before the scales, operator mdot without rho c_p: %s\n",
1684 threw ? "both raise" : "MISSING");
1685 CHECK(threw);
1686 }
1687}
1688} // namespace
1689
1690int main(int argc, char** argv) {
1691 const std::string gate = argc > 1 ? argv[1] : "identity";
1692 Kokkos::initialize(argc, argv);
1693 {
1694 if (gate == "identity")
1695 gateIdentity();
1696 else if (gate == "scale")
1697 gateScale();
1698 else if (gate == "vof")
1699 gateVofSigma();
1700 else if (gate == "aniso")
1701 gateAnisoPoiseuille();
1702 else if (gate == "sphere")
1703 gateAnisoSphere();
1704 else if (gate == "tgv")
1705 gateAnisoTgv();
1706 else if (gate == "vofaniso")
1707 gateVofAniso();
1708 else if (gate == "scalar")
1709 gateScalar();
1710 else if (gate == "scalarsine")
1711 gateScalarSine();
1712 else if (gate == "phasechange")
1713 gatePhaseChange();
1714 else {
1715 std::fprintf(stderr,
1716 "usage: test_units [identity|scale|vof|aniso|sphere|tgv|vofaniso|scalar|"
1717 "scalarsine|phasechange]\n");
1718 ++failures;
1719 }
1720 }
1721 Kokkos::finalize();
1722 if (failures) {
1723 std::fprintf(stderr, "%d failure(s)\n", failures);
1724 return 1;
1725 }
1726 std::printf("OK\n");
1727 return 0;
1728}
int main()
Definition dbg_scene.cpp:13
flow — host-facing Kokkos IBM Navier-Stokes solver (drop-in flow-style API).
x
Definition cmp.py:24
y
Definition cmp.py:24
exact(Ds, K, hc, gs, nmax=1)
Definition conj.py:29
@ kAreaPlic
rung P0/P1: plicArea on the MYC normal. The old numbers.
Definition prof.py:1
pres
Definition prof.py:61
vel
Definition prof.py:61
int D
Definition tracers.py:128
WO-P3f — the ENERGY BUDGET of the phase-change energy solve.
static constexpr int N
static double maxAbs(const std::vector< double > &a)
static constexpr double DT
static constexpr double RHO
static constexpr double MU
static constexpr double F
static double relErr(const std::vector< double > &a, const std::vector< double > &b)
static int run(int bc)
static double X0()
static constexpr double U
double sphereFracBox(double cx, double cy, double cz, double R, double x0, double y0, double z0, const double h[3], int sub=24)
Exact liquid fraction of the box cell [x0,x0+hx] x ... inside a sphere: midpoint-subsampled in x and ...
AnisoDrop runDropletAniso(const int n[3], const double h[3], double R, bool constantKappa, int steps)
n cells per axis on a box mesh of cell size h (physical). R is the PHYSICAL radius.
void gateVofAniso()
#define CHECK(cond)
static constexpr double FX
static constexpr double SIGMA