flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_curvature.cpp
Go to the documentation of this file.
1// VoF rung V3 (WO-O) — gate battery for the interface-curvature cascade
2// (src/vof/curvature.hpp + src/vof/curvature_field.hpp). Standalone: no Solver, no MPI.
3//
4// A geometry primitives : the plane^cube polygon against the analytic dV/dalpha area and the
5// analytic interface centroid -dV/dm / dV/dalpha; the Green's-theorem
6// monomial integrals against closed forms and a quadrature oracle
7// B exact-fraction sphere: kappa vs the analytic 2/R at 16^3 / 32^3 / 64^3 -- L1 AND max error
8// reported separately, convergence order, per-branch census; plus an
9// initialization ablation showing the octree fractions are not the
10// limiting error
11// B2 shape sanity : a PLANE gives kappa = 0 and a CYLINDER gives 1/R, so the factor in
12// kappa = 2H is gated and not assumed
13// C resolution sweep : D/dx from ~3 to ~40, including D/dx < 5 where the cascade MUST fall
14// back; branch fractions per resolution; assertion that no interfacial
15// cell returns NaN, and none returns kCurvNoEstimate (a curvature of
16// exactly zero at an interfacial cell is only ever legal when the
17// branch field says the interface really is flat)
18// D translating droplet : advection-realistic volume fractions from the V1 Weymouth-Yue
19// advector; the error PLATEAU and the C*dx at which it sets in --
20// a measurement, not a pass/fail (VOF_PLAN.md 6)
21// E device / host : the same kernels in a Kokkos parallel_for and in a serial host loop,
22// compared bitwise on the same backend
23// F fallback in isolation: the PV branch forced on every interfacial cell, so its own accuracy
24// is a measured number rather than an inference from the mixture
25// G tier 2b ablation : the mixed height-position fit (VofCurvature::useMixedHeightFit) on
26// and off on the SAME sphere -- the measurement that put it off by
27// default, regenerated every run so it cannot go stale in a doc
28#include <algorithm>
29#include <array>
30#include <cmath>
31#include <cstdio>
32#include <cstdlib>
33#include <cstring>
34#include <Kokkos_Core.hpp>
35#include <string>
36#include <type_traits>
37#include <utility>
38#include <vector>
39
40#include "vof/advect_wy.hpp"
41#include "vof/block_container.hpp" // VofBlockSet::blockCurvProto (the clip is block-only)
42#include "vof/curvature.hpp"
44#include "vof_advect_scenes.hpp"
45
46namespace {
52using vofscene::Block;
53
54namespace vf = peclet::flow::vof;
55
56int failures = 0;
57#define CHECK(cond) \
58 do { \
59 if (!(cond)) { \
60 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
61 ++failures; \
62 } \
63 } while (0)
64
65double order(double coarse, double fine) {
66 return (fine > 0.0 && coarse > 0.0) ? std::log2(coarse / fine) : 0.0;
67}
68
70struct Case {
71 WyAdvector adv;
72 VofCurvature curv;
73 Block blk;
74
75 void setup(int nx, int ny, int nz, double h) {
76 adv.init(nx, ny, nz, h, 3);
77 curv.init(nx, ny, nz, 3);
78 blk = vofscene::blockOf(adv, I3{0, 0, 0});
79 const I3 e = adv.extent();
80 const int g = adv.ghost();
81 adv.exchange = [e, g](SField f) { vofscene::periodicFill(f, e, g, true, true, true); };
82 // Measurement overrides for the tier-2b study recorded in the WO-O findings entry.
83 if (const char* v = std::getenv("PECLET_VOF_CURV_PTW"))
84 curv.ptWeightWidth = std::atof(v);
85 if (const char* v = std::getenv("PECLET_VOF_CURV_FIT"))
86 curv.useMixedHeightFit = (std::atoi(v) != 0);
87 }
88 SField c() const { return adv.colour(); }
89 I3 e() const { return adv.extent(); }
90 I3 n() const { return adv.inner(); }
91 int g() const { return adv.ghost(); }
92};
93
95struct Err {
96 double l1 = 0.0;
97 double max = 0.0;
98 long n = 0;
99 long nan = 0;
100};
101
105Err curvError(const Case& cs, double kExact, int margin = 0) {
106 const I3 e = cs.e(), n = cs.n();
107 const int g = cs.g() + margin;
108 SField kap = cs.curv.kappa(), br = cs.curv.branch();
109 // kExact = 0 (a flat interface) makes a relative error meaningless; report the absolute one.
110 const double den = (kExact != 0.0) ? std::fabs(kExact) : 1.0;
111 double sum = 0.0, mx = 0.0;
112 long cnt = 0, nan = 0;
113 Kokkos::parallel_reduce(
114 "curv::err",
116 peclet::flow::SExec(), {g, g, g},
117 {g + n.x - 2 * margin, g + n.y - 2 * margin, g + n.z - 2 * margin}),
118 KOKKOS_LAMBDA(int x, int y, int z, double& acc, double& m, long& c, long& bad) {
119 const long i = L3(x, y, z, e);
120 if (static_cast<int>(br(i)) == vf::kCurvNone)
121 return;
122 const double k = kap(i);
123 if (!(k == k)) { // NaN
124 ++bad;
125 return;
126 }
127 const double r = Kokkos::fabs(k - kExact) / den;
128 acc += r;
129 m = Kokkos::fmax(m, r);
130 ++c;
131 },
132 sum, Kokkos::Max<double>(mx), cnt, nan);
133 Kokkos::fence();
134 Err r;
135 r.n = cnt;
136 r.nan = nan;
137 r.l1 = cnt ? sum / static_cast<double>(cnt) : 0.0;
138 r.max = mx;
139 return r;
140}
141
142void printStats(const char* tag, const VofCurvature::Stats& s) {
143 const double t = s.interfacial ? static_cast<double>(s.interfacial) : 1.0;
144 std::printf(
145 " %s interfacial %5ld | HF %5.2f%% HFdir %5.2f%% HFfit %5.2f%% PV %5.2f%%"
146 " PVred %5.3f%% none %ld clipped %ld\n",
147 tag, s.interfacial, 100.0 * s.hf / t, 100.0 * s.hfMixed / t, 100.0 * s.hfFit / t,
148 100.0 * s.pv / t, 100.0 * s.pvReduced / t, s.noEstimate, s.clipped);
149}
150
151// ========================================================== gate A: the geometry primitives
152void gateGeometry() {
153 std::printf("\n=== A geometry primitives (polygon, area/centroid, Green monomials)\n");
154
155 // A1/A2: polygon area and centroid of the plane^cube section, against the analytic identities
156 // |Gamma| = |m|_2 dV/dalpha and xbar_i = -(dV/dm_i) / (dV/dalpha)
157 // both derived from V(m,alpha) = int_cell H(alpha - m.x) dx. Independent of the polygon code.
158 unsigned s = 12345u;
159 auto rnd = [&s]() {
160 s = 1664525u * s + 1013904223u;
161 return static_cast<double>(s >> 8) / 16777216.0;
162 };
163 double maxA = 0.0, maxC = 0.0;
164 int nsamp = 0;
165 for (int t = 0; t < 4000; ++t) {
166 double m[3] = {2.0 * rnd() - 1.0, 2.0 * rnd() - 1.0, 2.0 * rnd() - 1.0};
167 const double l1 = std::fabs(m[0]) + std::fabs(m[1]) + std::fabs(m[2]);
168 if (l1 < 1e-3)
169 continue;
170 for (int k = 0; k < 3; ++k)
171 m[k] /= l1;
172 const double V = 0.05 + 0.9 * rnd();
173 const double al = vf::plicAlpha(m[0], m[1], m[2], V);
174 double v[8][3], ctr[3], area;
175 const int nv = vf::plicPolygon(m[0], m[1], m[2], al, v);
176 if (nv < 3)
177 continue;
178 vf::polygonAreaCentroid(v, nv, ctr, area);
179 const double d = 1e-6;
180 const double dVda =
181 (vf::plicVolume(m[0], m[1], m[2], al + d) - vf::plicVolume(m[0], m[1], m[2], al - d)) /
182 (2 * d);
183 const double l2 = std::sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
184 maxA = std::max(maxA, std::fabs(area - l2 * dVda) / std::max(area, 1e-3));
185 for (int k = 0; k < 3; ++k) {
186 double mp[3] = {m[0], m[1], m[2]}, mm[3] = {m[0], m[1], m[2]};
187 mp[k] += d;
188 mm[k] -= d;
189 const double dVdm =
190 (vf::plicVolume(mp[0], mp[1], mp[2], al) - vf::plicVolume(mm[0], mm[1], mm[2], al)) /
191 (2 * d);
192 maxC = std::max(maxC, std::fabs(ctr[k] - (-dVdm / dVda)));
193 }
194 ++nsamp;
195 }
196 std::printf(" %d random planes: max rel |Gamma| error %.3e max centroid error %.3e\n", nsamp,
197 maxA, maxC);
198 CHECK(nsamp > 3000);
199 CHECK(maxA < 1e-6); // limited by the finite difference, not by the polygon
200 CHECK(maxC < 1e-5);
201
202 // A3: the Green's-theorem monomials on the unit square, closed form.
203 {
204 double xy[8][2] = {{0, 0}, {1, 0}, {1, 1}, {0, 1}};
205 double q[6];
206 vf::polygonMoments2d(xy, 4, q);
207 const double ex[6] = {1.0, 0.5, 0.5, 1.0 / 3.0, 0.25, 1.0 / 3.0};
208 double m = 0.0;
209 for (int i = 0; i < 6; ++i)
210 m = std::max(m, std::fabs(q[i] - ex[i]));
211 std::printf(" unit square monomials (1,x,y,x2,xy,y2) = (%g,%g,%g,%g,%g,%g) max err %.3e\n",
212 q[0], q[1], q[2], q[3], q[4], q[5], m);
213 CHECK(m < 1e-15);
214 }
215 // A3b: an offset/rotated random convex polygon vs a dense midpoint quadrature oracle.
216 {
217 double xy[8][2];
218 const int nv = 5;
219 for (int k = 0; k < nv; ++k) {
220 const double a = 2.0 * M_PI * k / nv + 0.37;
221 xy[k][0] = 1.3 + 0.9 * std::cos(a);
222 xy[k][1] = -0.6 + 1.4 * std::sin(a);
223 }
224 double q[6];
225 vf::polygonMoments2d(xy, nv, q);
226 // oracle: midpoint quadrature over the bounding box with a convex inside test
227 double lo[2] = {1e30, 1e30}, hi[2] = {-1e30, -1e30};
228 for (int k = 0; k < nv; ++k)
229 for (int d = 0; d < 2; ++d) {
230 lo[d] = std::min(lo[d], xy[k][d]);
231 hi[d] = std::max(hi[d], xy[k][d]);
232 }
233 const int NQ = 2000;
234 const double dx = (hi[0] - lo[0]) / NQ, dy = (hi[1] - lo[1]) / NQ, dA = dx * dy;
235 double o[6] = {0, 0, 0, 0, 0, 0};
236 for (int j = 0; j < NQ; ++j)
237 for (int i = 0; i < NQ; ++i) {
238 const double x = lo[0] + (i + 0.5) * dx, y = lo[1] + (j + 0.5) * dy;
239 bool in = true;
240 for (int k = 0; k < nv && in; ++k) {
241 const int k1 = (k + 1) % nv;
242 const double cr =
243 (xy[k1][0] - xy[k][0]) * (y - xy[k][1]) - (xy[k1][1] - xy[k][1]) * (x - xy[k][0]);
244 if (cr < 0.0)
245 in = false;
246 }
247 if (!in)
248 continue;
249 o[0] += dA;
250 o[1] += x * dA;
251 o[2] += y * dA;
252 o[3] += x * x * dA;
253 o[4] += x * y * dA;
254 o[5] += y * y * dA;
255 }
256 double m = 0.0;
257 for (int i = 0; i < 6; ++i)
258 m = std::max(m, std::fabs(q[i] - o[i]) / std::max(std::fabs(o[i]), 1e-3));
259 std::printf(" random pentagon vs %dx%d quadrature: max rel err %.3e\n", NQ, NQ, m);
260 CHECK(m < 5e-4);
261 }
262}
263
264// ==================================================== gate B2: plane and cylinder shape sanity
265void gateShapes() {
266 std::printf("\n=== B2 shape sanity (plane -> 0, cylinder -> 1/R, i.e. kappa = 2H)\n");
267 const int N = 48;
268 const double h = 1.0 / N;
269
270 // an oblique plane: kappa must be 0
271 {
272 Case cs;
273 cs.setup(N, N, N, h);
274 double m[3] = {0.47, -0.31, 0.86};
275 const double l1 = std::fabs(m[0]) + std::fabs(m[1]) + std::fabs(m[2]);
276 for (int k = 0; k < 3; ++k)
277 m[k] /= l1;
278 const double m0 = m[0], m1 = m[1], m2 = m[2];
279 SField cc = cs.c();
281 cs.blk, KOKKOS_LAMBDA(long i, int gx, int gy, int gz) {
282 cc(i) =
283 vf::planeCellFraction(m0, m1, m2, 0.5 * (m0 + m1 + m2), gx * h, gy * h, gz * h, h);
284 });
285 cs.adv.syncGhosts();
286 const auto st = cs.curv.compute(cs.c());
287 // margin 8: an oblique plane cannot be periodic, so the wrap planes carry a spurious second
288 // interface. The census below covers the whole block (and reports it), the error metric only
289 // the interior where the manufactured field is the plane it claims to be.
290 const Err er = curvError(cs, 0.0, 8); // kExact = 0 -> absolute error, in 1/cell
291 printStats("plane ", st);
292 std::printf(" kappa (interior only): L1 %.3e max %.3e (analytic 0) cells %ld NaN %ld\n",
293 er.l1, er.max, er.n, er.nan);
294 CHECK(er.nan == 0);
295 CHECK(er.n > 500);
296 CHECK(er.max < 1e-9);
297 }
298
299 // a cylinder along z of radius R: kappa = 1/R (one principal curvature is zero)
300 {
301 Case cs;
302 cs.setup(N, N, N, h);
303 const double R = 0.3, cx = 0.5, cy = 0.5;
304 const int NS = 128;
305 SField cc = cs.c();
307 cs.blk, KOKKOS_LAMBDA(long i, int gx, int gy, int) {
308 const double x0 = gx * h, y0 = gy * h, w = h / NS;
309 int in = 0;
310 for (int q = 0; q < NS; ++q) {
311 const double y = y0 + (q + 0.5) * w - cy;
312 for (int p = 0; p < NS; ++p) {
313 const double x = x0 + (p + 0.5) * w - cx;
314 if (x * x + y * y < R * R)
315 ++in;
316 }
317 }
318 cc(i) = static_cast<double>(in) / (static_cast<double>(NS) * NS);
319 });
320 cs.adv.syncGhosts();
321 const auto st = cs.curv.compute(cs.c());
322 const double kEx = h / R; // 1/R in cell units
323 const Err er = curvError(cs, kEx);
324 printStats("cylinder", st);
325 std::printf(" kappa: L1 %.3e max %.3e (analytic %.6f = 1/R) NaN %ld\n", er.l1, er.max,
326 kEx, er.nan);
327 CHECK(er.nan == 0);
328 CHECK(st.noEstimate == 0);
329 // 1 % is far inside the factor-2 that would separate 1/R from 2/R -- this gate exists to pin
330 // the mean-curvature convention, not to measure accuracy.
331 CHECK(er.l1 < 0.02);
332 }
333}
334
335// ============================================ gate B: exact-fraction sphere, static convergence
336void gateStaticSphere() {
337 std::printf("\n=== B exact-fraction sphere: static convergence of kappa vs 2/R\n");
338 const int Ns[3] = {16, 32, 64};
339 const int LEV = 6;
340 double l1[3], mx[3];
341 for (int q = 0; q < 3; ++q) {
342 const int N = Ns[q];
343 const double h = 1.0 / N;
344 const double R = 0.3;
345 Case cs;
346 cs.setup(N, N, N, h);
347 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, LEV);
348 cs.adv.syncGhosts();
349 const auto st = cs.curv.compute(cs.c());
350 const double kEx = 2.0 * h / R;
351 const Err er = curvError(cs, kEx);
352 l1[q] = er.l1;
353 mx[q] = er.max;
354 std::printf(" N = %3d D/dx = %5.1f C*dx = %.4f\n", N, 2.0 * R / h, h / R);
355 printStats(" ", st);
356 std::printf(" kappa rel error: L1 %.4e max %.4e NaN %ld\n", er.l1, er.max, er.nan);
357 CHECK(er.nan == 0);
358 CHECK(st.noEstimate == 0);
359 }
360 std::printf(" convergence order L1: %.2f, %.2f max: %.2f, %.2f\n", order(l1[0], l1[1]),
361 order(l1[1], l1[2]), order(mx[0], mx[1]), order(mx[1], mx[2]));
362 const double oL1 = order(l1[0], l1[2]) / 2.0;
363 std::printf(" fitted order over 16->64: L1 %.2f max %.2f\n", oL1, order(mx[0], mx[2]) / 2.0);
364 CHECK(oL1 > 1.7);
365
366 // initialization ablation: if the octree fractions were the limiting error the measured error
367 // would move with `levels`. Run 32^3 at levels 4 and 8 and print all three.
368 double abl[3];
369 const int levs[3] = {4, 6, 8};
370 for (int q = 0; q < 3; ++q) {
371 const int N = 32;
372 const double h = 1.0 / N, R = 0.3;
373 Case cs;
374 cs.setup(N, N, N, h);
375 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, levs[q]);
376 cs.adv.syncGhosts();
377 cs.curv.compute(cs.c());
378 abl[q] = curvError(cs, 2.0 * h / R).l1;
379 }
380 std::printf(" init ablation at 32^3, octree levels 4/6/8: L1 %.4e / %.4e / %.4e\n", abl[0],
381 abl[1], abl[2]);
382 CHECK(std::fabs(abl[1] - abl[2]) < 0.2 * abl[2]); // levels 6 is not the limiting error
383}
384
385// ======================================================= gate C: resolution sweep incl. D/dx < 5
386void gateSweep() {
387 std::printf("\n=== C sphere sweep incl. D/dx < 5 (the fallback MUST engage)\n");
388 const double Rc[6] = {1.4, 2.2, 3.5, 6.0, 12.0, 24.0}; // radius in CELLS
389 for (int q = 0; q < 6; ++q) {
390 const double Rcell = Rc[q];
391 const int N = static_cast<int>(std::ceil(2.6 * Rcell)) + 8;
392 const double h = 1.0 / N, R = Rcell * h;
393 Case cs;
394 cs.setup(N, N, N, h);
395 // off-centre by an irrational fraction of a cell so the sphere is never mesh-aligned
396 vofscene::initSphere(cs.c(), cs.blk, h, 0.5 + 0.137 * h, 0.5 - 0.311 * h, 0.5 + 0.241 * h, R,
397 6);
398 cs.adv.syncGhosts();
399 const auto st = cs.curv.compute(cs.c());
400 const Err er = curvError(cs, 2.0 * h / R);
401 std::printf(" D/dx = %5.1f C*dx = %.4f N = %3d\n", 2.0 * Rcell, 1.0 / Rcell, N);
402 printStats(" ", st);
403 std::printf(" kappa rel error: L1 %.4e max %.4e NaN %ld\n", er.l1, er.max, er.nan);
404 CHECK(er.nan == 0);
405 CHECK(st.noEstimate == 0);
406 CHECK(st.interfacial > 0);
407 CHECK(st.hf + st.hfMixed + st.hfFit + st.pv + st.pvReduced == st.interfacial);
408 if (2.0 * Rcell < 5.0)
409 CHECK(st.pv + st.pvReduced > 0); // below ~5 cells/diameter the HF cannot always close
410 }
411}
412
413// ================================================= gate D: translating droplet, realistic
414// fractions
415void gateTranslating() {
416 std::printf("\n=== D translating droplet (advection-realistic fractions) -- a MEASUREMENT\n");
417 std::printf(
418 " the plateau is the method's physics (Han/Evrard/Desjardins 2024 3; "
419 "VOF_PLAN.md 6)\n");
420 const int Ns[3] = {16, 32, 64};
421 double l1[3], mx[3], l1e[3];
422 for (int q = 0; q < 3; ++q) {
423 const int N = Ns[q];
424 const double h = 1.0 / N, R = 0.2;
425 Case cs;
426 cs.setup(N, N, N, h);
427 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, 6);
428 cs.adv.syncGhosts();
429 // curvature of the EXACT initial field, for reference
430 cs.curv.compute(cs.c());
431 l1e[q] = curvError(cs, 2.0 * h / R).l1;
432 // transport it one full diameter along the diagonal at CFL 0.25
433 const double dt = 0.25 * h;
434 vofscene::fillUniform(cs.adv, cs.blk, 1.0, 1.0, 1.0);
435 const long steps = static_cast<long>(std::lround(2.0 * R / (0.25 * h)));
436 for (long s = 0; s < steps; ++s)
437 cs.adv.advect(dt, s);
438 const auto st = cs.curv.compute(cs.c());
439 const Err er = curvError(cs, 2.0 * h / R);
440 l1[q] = er.l1;
441 mx[q] = er.max;
442 std::printf(" N = %3d D/dx = %5.1f C*dx = %.4f after %ld steps (1 diameter)\n", N,
443 2.0 * R / h, h / R, steps);
444 printStats(" ", st);
445 std::printf(" kappa rel error: L1 %.4e (exact fractions %.4e) max %.4e NaN %ld\n",
446 er.l1, l1e[q], er.max, er.nan);
447 CHECK(er.nan == 0);
448 CHECK(st.noEstimate == 0);
449 }
450 std::printf(" ADVECTED order L1: %.2f, %.2f max: %.2f, %.2f\n", order(l1[0], l1[1]),
451 order(l1[1], l1[2]), order(mx[0], mx[1]), order(mx[1], mx[2]));
452 std::printf(" EXACT order L1: %.2f, %.2f\n", order(l1e[0], l1e[1]), order(l1e[1], l1e[2]));
453 std::printf(" ratio advected/exact L1: %.1fx, %.1fx, %.1fx at C*dx %.3f, %.3f, %.3f\n",
454 l1[0] / l1e[0], l1[1] / l1e[1], l1[2] / l1e[2], 1.0 / (0.2 * Ns[0]),
455 1.0 / (0.2 * Ns[1]), 1.0 / (0.2 * Ns[2]));
456 // No pass/fail on the order: the WO says report it. What IS gated is that the exact-fraction
457 // reference on the same geometry converges, so a flat advected curve is transport noise and not
458 // a broken estimator.
459 CHECK(order(l1e[0], l1e[2]) / 2.0 > 1.5);
460}
461
462// ================================================================ gate E: device vs host oracle
463// A serial host mirror of the driver, calling the SAME KOKKOS_INLINE_FUNCTIONs. Deliberately
464// written out rather than shared with the driver, so it is an independent transcription of the
465// cascade rather than the same code twice.
466void gateDeviceHost() {
467 std::printf("\n=== E device kernel vs serial host loop (same backend, bitwise)\n");
468 // Tier 3 of the oracle is the moment form the driver ships (design G WO-3); the pre-WO-3
469 // Green's-theorem form is evaluated alongside and reported as INFO.
470 const int N = 32;
471 const double h = 1.0 / N, R = 0.3;
472 Case cs;
473 cs.setup(N, N, N, h);
474 // The oracle below mirrors the SHIPPED cascade (tier 1 -> tier 2a -> tier 3); tier 2b is off by
475 // default and is pinned off here so the two transcriptions describe the same algorithm.
476 cs.curv.useMixedHeightFit = false;
477 vofscene::initSphere(cs.c(), cs.blk, h, 0.5 + 0.1 * h, 0.5, 0.5 - 0.2 * h, R, 5);
478 cs.adv.syncGhosts();
479 cs.curv.compute(cs.c());
480
481 const I3 e = cs.e();
482 const int g = cs.g();
483 auto ch = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), cs.c());
484 auto kh = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), cs.curv.kappa());
485 auto bh = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), cs.curv.branch());
486 const long st[3] = {1, e.x, static_cast<long>(e.x) * e.y};
487 auto C = [&](long i) { return ch(i); };
488
489 long bitDiff = 0, cmp = 0;
490 double maxDiff = 0.0;
491 // INFO: the pre-WO-3 tier 3 (per-target Green's-theorem terms, `pvFitAdd`), which the shipped
492 // moment form matches to round-off (design G WO-3: 495/32768 cells, max 8.6e-16 at the change).
493 long oldDiff = 0, oldBranch = 0;
494 double oldMax = 0.0;
495 const int gr = vf::kPvHalf;
496 const vf::VofMetric gm = cs.curv.metric;
497 const double dW = cs.curv.weightWidth * gm.maxH();
498 // host planes and their PLIC polygons' area moments over the inner region grown by kPvHalf
499 std::vector<double> hm(3 * ch.extent(0), 0.0), ha(ch.extent(0), 0.0);
500 std::vector<vf::PvMoments> hp(ch.extent(0));
501 for (int z = g - gr; z < g + N + gr; ++z)
502 for (int y = g - gr; y < g + N + gr; ++y)
503 for (int x = g - gr; x < g + N + gr; ++x) {
504 const long i = L3(x, y, z, e);
505 if (!vf::wyIsMixed(C(i)))
506 continue;
507 double stc[27], m[3];
508 for (int kk = -1; kk <= 1; ++kk)
509 for (int jj = -1; jj <= 1; ++jj)
510 for (int ii = -1; ii <= 1; ++ii)
511 stc[vf::plicSt(ii + 1, jj + 1, kk + 1)] = C(i + ii * st[0] + jj * st[1] + kk * st[2]);
512 vf::mycNormal(stc, m);
513 hm[3 * i] = m[0];
514 hm[3 * i + 1] = m[1];
515 hm[3 * i + 2] = m[2];
516 ha[i] = vf::plicAlpha(m[0], m[1], m[2], C(i));
517 vf::pvMomentsBuild(m[0], m[1], m[2], ha[i], gm, hp[i]);
518 }
519
520 for (int z = g; z < g + N; ++z)
521 for (int y = g; y < g + N; ++y)
522 for (int x = g; x < g + N; ++x) {
523 const long i = L3(x, y, z, e);
524 double kappa = 0.0;
525 int branch = vf::kCurvNone;
526 if (vf::wyIsMixed(C(i))) {
527 const double am[3] = {std::fabs(hm[3 * i]), std::fabs(hm[3 * i + 1]),
528 std::fabs(hm[3 * i + 2])};
529 int ord[3] = {0, 1, 2};
530 for (int p = 1; p < 3; ++p)
531 for (int qq = p; qq > 0 && am[ord[qq]] > am[ord[qq - 1]]; --qq)
532 std::swap(ord[qq], ord[qq - 1]);
533 branch = -1;
534 for (int t = 0; t < 3 && branch < 0; ++t) {
535 const int d = ord[t], d1 = (d + 1) % 3, d2 = (d + 2) % 3;
536 double hh[9];
537 int orient0 = 0;
538 bool ok = true;
539 for (int qq = 0; qq < 3 && ok; ++qq)
540 for (int p = 0; p < 3 && ok; ++p) {
541 const long base = i + (p - 1) * st[d1] + (qq - 1) * st[d2];
542 double col[vf::kHfColumn];
543 for (int k = 0; k < vf::kHfColumn; ++k)
544 col[k] = C(base + (k - vf::kHfColumn / 2) * st[d]);
545 double hv;
546 int orient;
547 if (!vf::hfColumnHeight(col, vf::kHfColumn, hv, orient, cs.curv.monoTol)) {
548 ok = false;
549 break;
550 }
551 if (orient0 == 0)
552 orient0 = orient;
553 else if (orient != orient0) {
554 ok = false;
555 break;
556 }
557 hh[p + 3 * qq] = hv;
558 }
559 if (!ok)
560 continue;
561 kappa = vf::hfPatchKappa(hh);
562 branch = (t == 0) ? vf::kCurvHf : vf::kCurvHfMixed;
563 }
564 if (branch < 0) {
565 // tier 3 as shipped since design G WO-3: the cells' polygon area moments transformed
566 // into the target's frame (`pvFrameMoments`, `pvTermMoments`), full accumulation.
567 double nn[3], t1[3], t2[3], org[3];
568 if (!vf::pvFrameMoments(hp[i], gm, nn, t1, t2, org)) {
569 branch = vf::kCurvNoEstimate;
570 } else {
571 vf::PvFit fit;
572 vf::pvFitInit(fit);
573 for (int oz = -gr; oz <= gr; ++oz)
574 for (int oy = -gr; oy <= gr; ++oy)
575 for (int ox = -gr; ox <= gr; ++ox) {
576 const long j = L3(x + ox, y + oy, z + oz, e);
577 if (!vf::wyIsMixed(C(j)))
578 continue;
579 const double off[3] = {static_cast<double>(ox), static_cast<double>(oy),
580 static_cast<double>(oz)};
581 vf::PvTerm t;
582 if (vf::pvTermMoments(t, hp[j], off, org, t1, t2, nn, dW, cs.curv.cosMin, gm))
583 vf::pvFitAccum(fit, t);
584 }
585 double a[6];
586 bool red = false;
587 if (!vf::pvFitSolve(fit, a, red)) {
588 branch = vf::kCurvNoEstimate;
589 } else {
590 kappa = vf::paraboloidKappa(a);
591 branch = red ? vf::kCurvPvReduced : vf::kCurvPv;
592 }
593 }
594 // INFO: the same target through the pre-WO-3 Green's-theorem terms (`pvFitAdd`).
595 double kOld = 0.0;
596 int bOld = vf::kCurvNoEstimate;
597 {
598 const double m0 = hm[3 * i], m1 = hm[3 * i + 1], m2 = hm[3 * i + 2];
599 const double n2 = m0 * m0 + m1 * m1 + m2 * m2;
600 if (!(n2 > 0.0)) {
601 bOld = vf::kCurvNoEstimate;
602 } else {
603 const double invn = 1.0 / std::sqrt(n2);
604 const double nn[3] = {m0 * invn, m1 * invn, m2 * invn};
605 double t1[3], t2[3];
606 vf::curvFrame(nn, t1, t2);
607 double v[8][3], ctr[3], area;
608 const int nv = vf::plicPolygon(m0, m1, m2, ha[i], v);
609 vf::polygonAreaCentroid(v, nv, ctr, area);
610 const double org[3] = {ctr[0] - 0.5, ctr[1] - 0.5, ctr[2] - 0.5};
611 vf::PvFit fit;
612 vf::pvFitInit(fit);
613 for (int oz = -gr; oz <= gr; ++oz)
614 for (int oy = -gr; oy <= gr; ++oy)
615 for (int ox = -gr; ox <= gr; ++ox) {
616 const long j = L3(x + ox, y + oy, z + oz, e);
617 if (!vf::wyIsMixed(C(j)))
618 continue;
619 const double off[3] = {static_cast<double>(ox), static_cast<double>(oy),
620 static_cast<double>(oz)};
621 vf::pvFitAdd(fit, hm[3 * j], hm[3 * j + 1], hm[3 * j + 2], ha[j], off, org,
622 t1, t2, nn, cs.curv.weightWidth, cs.curv.cosMin);
623 }
624 double a[6];
625 bool red = false;
626 if (!vf::pvFitSolve(fit, a, red)) {
627 bOld = vf::kCurvNoEstimate;
628 } else {
629 kOld = vf::paraboloidKappa(a);
630 bOld = red ? vf::kCurvPvReduced : vf::kCurvPv;
631 }
632 }
633 }
634 if (bOld != branch)
635 ++oldBranch;
636 else if (kOld != kappa) {
637 ++oldDiff;
638 oldMax = std::max(oldMax, std::fabs(kOld - kappa));
639 }
640 }
641 }
642 ++cmp;
643 if (kh(i) != kappa) {
644 ++bitDiff;
645 maxDiff = std::max(maxDiff, std::fabs(kh(i) - kappa));
646 }
647 CHECK(static_cast<int>(bh(i)) == branch);
648 }
649 std::printf(" %ld cells compared: %ld bitwise identical, %ld differing (max |d| %.3e)\n", cmp,
650 cmp - bitDiff, bitDiff, maxDiff);
651 std::printf(
652 " INFO vs the pre-WO-3 Green's-theorem oracle: %ld kappa differing (max |d| %.3e), "
653 "%ld branch flips\n",
654 oldDiff, oldMax, oldBranch);
655 if (std::is_same<peclet::flow::SMem, Kokkos::HostSpace>::value) {
656 CHECK(bitDiff == 0);
657 } else {
658 // Cross-backend (device kernel vs host loop) is a tolerance comparison by the suite policy
659 // (`vof_workorders.md` shared preamble rule 2); WO-D measured the same 1e-14-class spread.
660 CHECK(maxDiff < 1e-11);
661 }
662}
663
664// ============================================= gate F: the fallback branch measured in isolation
665void gateFallbackAlone() {
666 std::printf("\n=== F the PV fallback forced on EVERY interfacial cell (its own accuracy)\n");
667 const int Ns[3] = {16, 32, 64};
668 double l1[3];
669 for (int q = 0; q < 3; ++q) {
670 const int N = Ns[q];
671 const double h = 1.0 / N, R = 0.3;
672 Case cs;
673 cs.setup(N, N, N, h);
674 cs.curv.debugForceFallback = true;
675 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, 6);
676 cs.adv.syncGhosts();
677 const auto st = cs.curv.compute(cs.c());
678 const Err er = curvError(cs, 2.0 * h / R);
679 l1[q] = er.l1;
680 std::printf(" N = %3d C*dx = %.4f", N, h / R);
681 std::printf(" PV %ld PV-reduced %ld none %ld L1 %.4e max %.4e\n", st.pv, st.pvReduced,
682 st.noEstimate, er.l1, er.max);
683 CHECK(st.noEstimate == 0);
684 CHECK(er.nan == 0);
685 CHECK(st.hf == 0 && st.hfMixed == 0 && st.hfFit == 0);
686 }
687 std::printf(" PV-only convergence order L1: %.2f, %.2f\n", order(l1[0], l1[1]),
688 order(l1[1], l1[2]));
689 CHECK(l1[2] < l1[0]); // it must at least improve with refinement
690}
691
692// ================================= gate G: tier 2b (mixed height fit) on vs off, same geometry
693void gateMixedHeightFitAblation() {
694 std::printf("\n=== G tier 2b (mixed height-position fit) ON vs OFF -- why it ships OFF\n");
695 const int Ns[3] = {16, 32, 64};
696 double l1[2][3], mx[2][3];
697 for (int mode = 0; mode < 2; ++mode)
698 for (int q = 0; q < 3; ++q) {
699 const int N = Ns[q];
700 const double h = 1.0 / N, R = 0.3;
701 Case cs;
702 cs.setup(N, N, N, h);
703 cs.curv.useMixedHeightFit = (mode == 1);
704 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, 6);
705 cs.adv.syncGhosts();
706 cs.curv.compute(cs.c());
707 const Err er = curvError(cs, 2.0 * h / R);
708 l1[mode][q] = er.l1;
709 mx[mode][q] = er.max;
710 }
711 for (int mode = 0; mode < 2; ++mode)
712 std::printf(
713 " tier 2b %-3s L1 %.3e / %.3e / %.3e (order %.2f) max %.3e / %.3e / %.3e"
714 " (order %.2f)\n",
715 mode ? "ON" : "OFF", l1[mode][0], l1[mode][1], l1[mode][2],
716 order(l1[mode][0], l1[mode][2]) / 2.0, mx[mode][0], mx[mode][1], mx[mode][2],
717 order(mx[mode][0], mx[mode][2]) / 2.0);
718 std::printf(
719 " the fit serves the cells tier 1 cannot; its data set is the columns that CLOSED,"
720 " a slope-selected\n and therefore asymmetric subset, so its bias is scale"
721 " invariant. The PV fallback's 5^3 PLIC\n polygons exist at every slope and are"
722 " not.\n");
723 // The shipped default must beat the alternative in the max norm, and must converge there.
724 CHECK(order(mx[0][0], mx[0][2]) / 2.0 > 1.5);
725 CHECK(order(mx[1][0], mx[1][2]) / 2.0 < 1.0);
726 CHECK(mx[0][2] < 0.25 * mx[1][2]);
727}
728
729// ======================================================= PHASE 3 GATE K2: anisotropic cells
730//
731// `flow/doc/anisotropic_vof.md` §4 (decision V2) and §11 K2. The claim: the height-function
732// cascade needs the metric in exactly three places — the two transverse spacings and the column
733// spacing of `hfPatchKappa`, and the physical frame of the two paraboloid fits — and with those it
734// returns a PHYSICAL curvature (1/hRef) on a stretched grid.
735//
736// Everything below is stated in units of `hRef` (the smallest cell size), which is the unit the
737// solver's internal `kappa'` lives in: `metric.h[a] = h_a/hRef >= 1`, with 1.0 on the finest axis.
738
742double sphereFracAniso(double cx, double cy, double cz, double R, double x0, double y0, double z0,
743 const double h[3], int sub = 24) {
744 const double wx = h[0] / sub, wy = h[1] / sub;
745 double acc = 0.0;
746 for (int b = 0; b < sub; ++b)
747 for (int a = 0; a < sub; ++a) {
748 const double px = x0 + (a + 0.5) * wx, py = y0 + (b + 0.5) * wy;
749 const double r2 = R * R - (px - cx) * (px - cx) - (py - cy) * (py - cy);
750 if (r2 <= 0.0)
751 continue;
752 const double hh = std::sqrt(r2);
753 const double lo = std::fmax(cz - hh, z0), hi = std::fmin(cz + hh, z0 + h[2]);
754 if (hi > lo)
755 acc += hi - lo;
756 }
757 return acc / (sub * sub * h[2]);
758}
759
763void fillSphereAniso(Case& cs, const double h[3], double R, const double ctr[3], int sub = 24) {
764 const I3 e = cs.e();
765 const int g = cs.g();
766 auto host = Kokkos::create_mirror_view(cs.c());
767 for (int z = 0; z < e.z; ++z)
768 for (int y = 0; y < e.y; ++y)
769 for (int x = 0; x < e.x; ++x)
770 host(L3(x, y, z, e)) = sphereFracAniso(ctr[0], ctr[1], ctr[2], R, (x - g) * h[0],
771 (y - g) * h[1], (z - g) * h[2], h, sub);
772 Kokkos::deep_copy(cs.c(), host);
773}
774
775void gateAnisotropic() {
776 std::printf("\n=== K2 (Phase 3) ANISOTROPIC cells: kappa in 1/hRef on a stretched grid\n");
777
778 // ---- (a) a PLANE through a stretched lattice: kappa must be 0 whatever the aspect ratio.
779 //
780 // Each stretched case is run TWICE: once on the metric, and once on a CUBIC grid carrying the
781 // plane whose INDEX normal is the same `H n`. The two see the same column geometry and the same
782 // branch mix, so the pair separates "the metric is wrong" from "this orientation lands in the PV
783 // fallback, whose normal-equations solve has its own floor" — the latter is a property of the
784 // V3 cascade that the anisotropic gate must not be blamed for.
785 {
786 double pmax[4] = {0, 0, 0, 0};
787 const double hs[4][3] = {{1.0, 2.0, 4.0}, {1.0, 1.0, 1.0}, {2.5, 1.0, 1.7}, {1.0, 1.0, 1.0}};
788 for (int q = 0; q < 4; ++q) {
789 const double* h = hs[q];
790 const int N = 24;
791 Case cs;
792 cs.setup(N, N, N, 1.0);
793 const vf::VofMetric g{{h[0], h[1], h[2]}};
794 cs.adv.metric = g;
795 cs.curv.metric = g;
796 // an oblique plane n.x = d with a generic (non-lattice) normal, exact fractions. On the
797 // CUBIC control rungs (q odd) the normal is the index normal `H n` of the stretched rung
798 // just above, so the two runs present the cascade with the SAME index-space geometry.
799 double n[3] = {0.4243, -0.7071, 0.5657};
800 if (q & 1)
801 for (int d = 0; d < 3; ++d)
802 n[d] *= hs[q - 1][d];
803 const double ctr[3] = {0.5 * N * h[0], 0.5 * N * h[1], 0.5 * N * h[2]};
804 const double d = n[0] * ctr[0] + n[1] * ctr[1] + n[2] * ctr[2];
805 const I3 e = cs.e();
806 const int gg = cs.g();
807 auto host = Kokkos::create_mirror_view(cs.c());
808 for (int z = 0; z < e.z; ++z)
809 for (int y = 0; y < e.y; ++y)
810 for (int x = 0; x < e.x; ++x) {
811 const double x0 = (x - gg) * h[0], y0 = (y - gg) * h[1], z0 = (z - gg) * h[2];
812 host(L3(x, y, z, e)) = vf::planeCellFractionAniso(n[0], n[1], n[2], d, x0, y0, z0, g);
813 }
814 Kokkos::deep_copy(cs.c(), host);
815 cs.adv.syncGhosts();
816 const auto st = cs.curv.compute(cs.c());
817 const Err er = curvError(cs, 0.0, 4); // margin: the plane wraps
818 std::printf(" plane on h = (%.3g, %.3g, %.3g): max |kappa| %.3e L1 %.3e\n", h[0], h[1],
819 h[2], er.max, er.l1);
820 printStats(" ", st);
821 CHECK(er.nan == 0);
822 CHECK(st.noEstimate == 0);
823 pmax[q] = er.max;
824 }
825 std::printf(
826 " plane: stretched vs its CUBIC index-equivalent control -> %.3e vs %.3e, "
827 "%.3e vs %.3e\n",
828 pmax[0], pmax[1], pmax[2], pmax[3]);
829 // A plane has zero curvature, so the metric must not introduce one BEYOND what the same
830 // index-space geometry already costs on a cubic grid.
831 CHECK(pmax[0] <= 4.0 * std::fmax(pmax[1], 1e-15));
832 CHECK(pmax[2] <= 4.0 * std::fmax(pmax[3], 1e-15));
833 }
834
835 // ---- (b) a SPHERE ladder at aspect ratio 2 — the convergence statement.
836 {
837 const double h[3] = {1.0, 1.0, 2.0};
838 const int base[3] = {16, 32, 64}; // cells along the FINEST axes
839 double l1[3], mx[3];
840 for (int q = 0; q < 3; ++q) {
841 const int nx = base[q], ny = base[q], nz = base[q] / 2;
842 const double R = 0.25 * nx * h[0]; // D/h_min = nx/2, D/h_z = nx/4
843 const double ctr[3] = {0.5 * nx * h[0] + 0.13, 0.5 * ny * h[1] + 0.27,
844 0.5 * nz * h[2] + 0.11};
845 Case cs;
846 cs.setup(nx, ny, nz, 1.0);
847 const vf::VofMetric g{{h[0], h[1], h[2]}};
848 cs.adv.metric = g;
849 cs.curv.metric = g;
850 fillSphereAniso(cs, h, R, ctr);
851 cs.adv.syncGhosts();
852 const auto st = cs.curv.compute(cs.c());
853 const Err er = curvError(cs, 2.0 / R);
854 l1[q] = er.l1;
855 mx[q] = er.max;
856 std::printf(" sphere h = (1,1,2) n = %dx%dx%d D/h_min = %.0f\n", nx, ny, nz, 2.0 * R);
857 printStats(" ", st);
858 std::printf(" kappa rel error: L1 %.4e max %.4e NaN %ld\n", er.l1, er.max, er.nan);
859 CHECK(er.nan == 0);
860 CHECK(st.noEstimate == 0);
861 }
862 const double oL1 = order(l1[0], l1[2]) / 2.0, oMx = order(mx[0], mx[2]) / 2.0;
863 std::printf(" fitted order over 16->64: L1 %.2f max %.2f (cubic: 2.26 / 1.86)\n", oL1,
864 oMx);
865 CHECK(oL1 > 1.5);
866 CHECK(mx[2] < mx[0]);
867 }
868
869 // ---- (c) a CYLINDER along the LONG and along the SHORT axis: kappa = 1/R either way.
870 {
871 const double h[3] = {1.0, 1.0, 2.0};
872 for (int axis = 0; axis < 3; axis += 2) { // x (fine) and z (coarse)
873 const int N = 48;
874 const int nx = (axis == 0) ? 16 : N, ny = N, nz = (axis == 2) ? 16 : N / 2;
875 const double R = 8.0;
876 Case cs;
877 cs.setup(nx, ny, nz, 1.0);
878 const vf::VofMetric g{{h[0], h[1], h[2]}};
879 cs.adv.metric = g;
880 cs.curv.metric = g;
881 const I3 e = cs.e();
882 const int gg = cs.g();
883 const int t1 = (axis + 1) % 3, t2 = (axis + 2) % 3;
884 const int nn[3] = {nx, ny, nz};
885 const double c1 = 0.5 * nn[t1] * h[t1] + 0.13, c2 = 0.5 * nn[t2] * h[t2] + 0.27;
886 auto host = Kokkos::create_mirror_view(cs.c());
887 const int sub = 32;
888 for (int z = 0; z < e.z; ++z)
889 for (int y = 0; y < e.y; ++y)
890 for (int x = 0; x < e.x; ++x) {
891 const int gi[3] = {x - gg, y - gg, z - gg};
892 const double a0 = gi[t1] * h[t1], b0 = gi[t2] * h[t2];
893 int in = 0;
894 for (int q = 0; q < sub; ++q)
895 for (int pp = 0; pp < sub; ++pp) {
896 const double pa = a0 + (pp + 0.5) * h[t1] / sub;
897 const double pb = b0 + (q + 0.5) * h[t2] / sub;
898 if ((pa - c1) * (pa - c1) + (pb - c2) * (pb - c2) < R * R)
899 ++in;
900 }
901 host(L3(x, y, z, e)) = (double)in / (sub * sub);
902 }
903 Kokkos::deep_copy(cs.c(), host);
904 cs.adv.syncGhosts();
905 const auto st = cs.curv.compute(cs.c());
906 const Err er = curvError(cs, 1.0 / R);
907 std::printf(" cylinder along axis %d (h_axis = %.3g): L1 %.3e max %.3e noEstimate %ld\n",
908 axis, h[axis], er.l1, er.max, st.noEstimate);
909 CHECK(er.nan == 0);
910 CHECK(st.noEstimate == 0);
911 CHECK(er.l1 < 5e-2);
912 }
913 }
914
915 // ---- (d) RULE B: the unit metric is the pre-Phase-3 arithmetic, BITWISE. Same scene, the
916 // metric set explicitly to {1,1,1} against a default-constructed driver.
917 {
918 const int N = 32;
919 const double h = 1.0 / N, R = 0.3;
920 double kap[2][2] = {{0, 0}, {0, 0}};
921 std::vector<double> f[2];
922 for (int q = 0; q < 2; ++q) {
923 Case cs;
924 cs.setup(N, N, N, h);
925 if (q == 1)
926 cs.curv.metric = vf::VofMetric{{1.0, 1.0, 1.0}};
927 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, 6);
928 cs.adv.syncGhosts();
929 cs.curv.compute(cs.c());
930 auto host = Kokkos::create_mirror_view(cs.curv.kappa());
931 Kokkos::deep_copy(host, cs.curv.kappa());
932 f[q].assign(host.data(), host.data() + host.extent(0));
933 (void)kap;
934 }
935 long diff = 0;
936 for (std::size_t i = 0; i < f[0].size(); ++i)
937 if (!(f[0][i] == f[1][i]))
938 ++diff;
939 std::printf(" RULE B: default vs explicit unit metric, differing kappa cells: %ld / %zu\n",
940 diff, f[0].size());
941 CHECK(diff == 0);
942 }
943}
944
959void gatePurityAgreement() {
960 std::printf("\n=== H advector/cascade purity agreement (the silent-HF-collapse gate)\n");
961 const double WISP = 1e-8; // what enable_vof sets
962 const double DEFICIT = 5e-9; // a bulk cell inside it, but 50x outside the 1e-10 floor
963
964 // --- H1: the kernel contract, in isolation. One column, liquid low.
965 {
966 double col[vf::kHfColumn] = {1.0, 1.0, 1.0, 0.63, 0.0, 0.0, 0.0};
967 double hClean = 0.0, hDrift = 0.0;
968 int oClean = 0, oDrift = 0;
969 const bool okClean = vf::hfColumnHeight(col, vf::kHfColumn, hClean, oClean, 1e-6);
970 CHECK(okClean);
971 for (int k = 0; k < vf::kHfColumn; ++k) // the bulk drifts off 1.0, inside the advector's eps
972 if (col[k] == 1.0)
973 col[k] = 1.0 - DEFICIT;
974 // At the 1e-10 floor the drifted column is REJECTED -- the bug, pinned so it stays visible.
975 double hj = 0.0;
976 int oj = 0;
977 CHECK(!vf::hfColumnHeight(col, vf::kHfColumn, hj, oj, 1e-6));
978 // Told the advector's tolerance, it closes again, and the height moves by O(DEFICIT).
979 const bool okDrift = vf::hfColumnHeight(col, vf::kHfColumn, hDrift, oDrift, 1e-6, WISP);
980 CHECK(okDrift);
981 CHECK(oDrift == oClean);
982 std::printf(" H1 column: clean h = %.15f, drifted h = %.15f, |dh| = %.3e (deficit %.1e)\n",
983 hClean, hDrift, std::fabs(hDrift - hClean), DEFICIT);
984 CHECK(std::fabs(hDrift - hClean) < 4.0 * DEFICIT);
985 }
986
987 // --- H2: the driver. A sphere whose BULK liquid sits at 1 - DEFICIT, as an advected field does.
988 {
989 const int N = 48;
990 const double h = 1.0 / N, R = 0.25;
991 vf::VofCurvature::Stats st[2];
992 double kerr[2] = {0.0, 0.0};
993 for (int q = 0; q < 2; ++q) { // q = 0: floor only (the bug); q = 1: told wispEps
994 Case cs;
995 cs.setup(N, N, N, h);
996 vofscene::initSphere(cs.c(), cs.blk, h, 0.5, 0.5, 0.5, R, 6);
997 // Push every FULL cell just inside the advector's tolerance.
998 auto hc = Kokkos::create_mirror_view(cs.c());
999 Kokkos::deep_copy(hc, cs.c());
1000 for (std::size_t i = 0; i < hc.extent(0); ++i)
1001 if (hc(i) >= 1.0 - 1e-12)
1002 hc(i) = 1.0 - DEFICIT;
1003 Kokkos::deep_copy(cs.c(), hc);
1004 cs.adv.syncGhosts();
1005 cs.curv.interfaceEps = WISP; // as enable_vof does under surface tension
1006 cs.curv.pureEps = (q == 0) ? 0.0 : WISP;
1007 st[q] = cs.curv.compute(cs.c());
1008 auto kh = Kokkos::create_mirror_view(cs.curv.kappa());
1009 auto bh = Kokkos::create_mirror_view(cs.curv.branch());
1010 Kokkos::deep_copy(kh, cs.curv.kappa());
1011 Kokkos::deep_copy(bh, cs.curv.branch());
1012 const double kExact = 2.0 / (R / h); // kappa = 2/R in CELL units
1013 double acc = 0.0;
1014 long n = 0;
1015 for (std::size_t i = 0; i < kh.extent(0); ++i)
1016 if (bh(i) > 0.0) {
1017 acc += std::fabs(kh(i) - kExact) / kExact;
1018 ++n;
1019 }
1020 kerr[q] = n ? acc / n : 0.0;
1021 }
1022 const long hf0 = st[0].hf + st[0].hfMixed + st[0].hfFit;
1023 const long hf1 = st[1].hf + st[1].hfMixed + st[1].hfFit;
1024 std::printf(
1025 " H2 sphere with bulk at 1 - %.0e: floor-only HF = %ld / %ld (%.1f %% fallback), "
1026 "L1 kappa err %.4f\n",
1027 DEFICIT, hf0, st[0].interfacial,
1028 100.0 * double(st[0].pv + st[0].pvReduced) / double(st[0].interfacial), kerr[0]);
1029 std::printf(
1030 " shared HF = %ld / %ld (%.1f %% fallback), "
1031 "L1 kappa err %.4f\n",
1032 hf1, st[1].interfacial,
1033 100.0 * double(st[1].pv + st[1].pvReduced) / double(st[1].interfacial), kerr[1]);
1034 // The contract: a deficit INSIDE the advector's tolerance must not cost the HF tier.
1035 CHECK(hf1 > 4 * hf0);
1036 CHECK(st[1].noEstimate == 0);
1037 CHECK(kerr[1] < kerr[0]);
1038 }
1039}
1040
1049void gateClipLive() {
1050 std::printf("\n=== I curvature admissibility clip is live (a detached cell at C = 1e-6)\n");
1051 const int N = 32;
1052 const double h = 1.0 / N, R = 6.0 * h;
1053 const int px = 8, py = 17, pz = 16; // two cells outside the surface, -x side
1054 std::vector<double> kv[2];
1055 long target[2] = {0, 0};
1056 vf::VofCurvature::Stats st[2];
1057 for (int q = 0; q < 2; ++q) { // q = 0: clip off (kappaMax = 0); q = 1: the default clip
1058 Case cs;
1059 cs.setup(N, N, N, h);
1060 vofscene::initSphere(cs.c(), cs.blk, h, 16.13 * h, 15.77 * h, 16.31 * h, R, 6);
1061 auto hc = Kokkos::create_mirror_view(cs.c());
1062 Kokkos::deep_copy(hc, cs.c());
1063 const long it = L3(px + cs.g(), py + cs.g(), pz + cs.g(), cs.e());
1064 CHECK(hc(it) == 0.0); // the cell really is detached
1065 hc(it) = 1e-6;
1066 Kokkos::deep_copy(cs.c(), hc);
1067 cs.adv.syncGhosts();
1068 // The clip is a BLOCK-path rule (doc/vof_overlap_design.md §11): the class default is off,
1069 // and the block prototype turns it on at 1/Delta_min -- which is what q = 1 reproduces.
1070 cs.curv.kappaMax = (q == 0) ? 0.0 : vf::VofBlockSet::blockCurvProto().kappaMax;
1071 st[q] = cs.curv.compute(cs.c());
1072 printStats(q == 0 ? "clip off" : "clip on ", st[q]);
1073 auto kh = Kokkos::create_mirror_view(cs.curv.kappa());
1074 Kokkos::deep_copy(kh, cs.curv.kappa());
1075 kv[q].assign(kh.data(), kh.data() + kh.extent(0));
1076 target[q] = it;
1077 }
1078 const double k0 = kv[0][target[0]], k1 = kv[1][target[1]];
1079 long above = 0, differ = 0, differAbove = 0;
1080 for (std::size_t i = 0; i < kv[0].size(); ++i) {
1081 const bool big = std::fabs(kv[0][i]) > 1.0;
1082 above += big ? 1 : 0;
1083 if (!(kv[0][i] == kv[1][i])) {
1084 ++differ;
1085 differAbove += big ? 1 : 0;
1086 }
1087 }
1088 std::printf(
1089 " detached cell kappa: clip off %.4f, clip on %.4f; cells above 1/Delta without the "
1090 "clip %ld, cells changed by the clip %ld (all of them above: %s)\n",
1091 k0, k1, above, differ, differ == differAbove ? "yes" : "NO");
1092 CHECK(std::fabs(k0) > 1.0); // the degenerate fit really is unbounded without the clip
1093 CHECK(std::fabs(k1) <= 1.0); // ... and bounded with it
1094 CHECK(st[0].clipped == 0); // off means off
1095 CHECK(st[1].clipped == above); // it fires exactly where |kappa| > 1/Delta
1096 CHECK(differ == differAbove); // and nowhere else (bitwise)
1097 CHECK(st[1].clipped >= 1);
1098}
1099} // namespace
1100
1101int main(int argc, char** argv) {
1102 Kokkos::initialize(argc, argv);
1103 {
1104 std::printf("VoF rung V3 (WO-O) - HF curvature cascade + PV fallback, backend: %s\n",
1105 peclet::flow::SExec::name());
1106 gateGeometry();
1107 gateShapes();
1108 gateStaticSphere();
1109 gateSweep();
1110 gateTranslating();
1111 gateDeviceHost();
1112 gateFallbackAlone();
1113 gateMixedHeightFitAblation();
1114 gateAnisotropic();
1115 gatePurityAgreement();
1116 gateClipLive();
1117 std::printf("\n%s (%d failure%s)\n", failures ? "FAILED" : "PASSED", failures,
1118 failures == 1 ? "" : "s");
1119 }
1120 Kokkos::finalize();
1121 return failures ? 1 : 0;
1122}
flow — VoF rung V1: Weymouth & Yue (2010) directionally-split, exactly volume-conserving geometric ad...
flow — VoF Part III rung W0: the per-bubble VoF BLOCK, a third container over the same L1 kernels (su...
Curvature of the colour field on an extended (inner + ghost) block.
Weymouth-Yue split advection of a colour field on an extended (inner + ghost) block.
flow — thin shim: the container-free VoF height-function / paraboloid curvature kernels (rung V3) now...
flow — VoF rung V3 (WO-O): the view-level driver for the curvature cascade.
int main()
Definition dbg_scene.cpp:13
Definition cmp.py:1
x
Definition cmp.py:24
y
Definition cmp.py:24
branch(c, f, a)
bool wyIsMixed(double c)
The mixed-cell predicate.
Definition advect_wy.hpp:95
long L3(int x, int y, int z, I3 e)
Kokkos::DefaultExecutionSpace SExec
Kokkos::View< double *, SMem > SField
fit(a, t, t0=40.0, t1=160.0)
col(path, ncol)
void forEachExtended(Block b, F fn)
Visit every cell of the extended block with (local linear index, global cell index).
void periodicFill(SField f, I3 e, int g, bool px, bool py, bool pz)
Periodic wrap of a single-block extended field (serial reference path; the MPI path uses core's GridH...
void fillUniform(WyAdvector &a, Block b, double ux, double uy, double uz)
Uniform translation. Discrete divergence is EXACTLY zero (identical values differenced).
void initSphere(SField c, Block b, double h, double cx, double cy, double cz, double r, int levels=4)
Sphere of fluid (C = 1 inside), exact to ~5e-7 relative volume at R/h = 16 via the octree helper in p...
Block blockOf(const WyAdvector &a, I3 origin)
Where this block sits in the global grid: o is the global index of inner cell (0,0,...
static constexpr int N
static double maxDiff(const std::vector< double > &a, const std::vector< double > &b)
#define CHECK(cond)