flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_bc_mpi.cpp
Go to the documentation of this file.
1// flow — multi-rank validation of the two-phase OPEN BOUNDARIES (VoF rung V-BC, WO-R): the inflow
2// colour ghost, the inletOutlet backflow rule, the out-of-domain flux mask and the per-face
3// boundary liquid ledger, on a decomposition that CUTS the inflow and the outflow face.
4//
5// Three things can only break here:
6//
7// 1. **the mask must be built on GLOBAL indices.** `vof::buildOutsideMask` marks a ghost cell
8// only when its GLOBAL index is outside the global grid, exactly as `clampFill` clamps
9// globally. A per-block test would mark every rank's own block boundary and the interior rank
10// boundaries would take the algebraic flux instead of the geometric one — a silent
11// decomposition-dependent advection.
12// 2. **the boundary colour must be applied only by the rank that OWNS the face**
13// (`touchesGlobalFace`, the WO-F rule). Without it every rank would inject the inflow colour
14// into its own block's -z ghost band, i.e. into the middle of the domain.
15// 3. **the ledger must be a per-rank partial sum.** Each rank accumulates the boundary fluxes of
16// the faces it owns; the caller MPI_SUMs them. The budget identity then closes globally.
17//
18// Four configurations on 16x16x32 (the aligned ORB cuts the long z axis at np = 2 and 4). Two are
19// KINEMATIC and gated BITWISE — `slug-kin` and `packing-kin`, which is where the decomposition
20// exactness of this path is actually asserted; two are COUPLED and gated on conservation and
21// cancellation rather than pointwise agreement, because the colour is not a Lipschitz function of
22// the velocity (the long comment on case C, and docs/wo_vof_mpi_parity_gates.md):
23//
24// * `slug-kin` — the WO-R gate G1 budget, KINEMATIC: uniform inflow at -z, outflow at +z, walls
25// elsewhere, the advecting field the exactly-divergence-free uniform w. A liquid slug is
26// injected and then flushed. No pressure solve and no reduction anywhere in the update, so the
27// colour field and the ledger must be BITWISE identical to the single-rank reference at every
28// np, and the global budget must close to round-off.
29//
30// * `jet-coupled` — a liquid inflow into a gas domain at density ratio 100 with the full
31// coupled step (rho(C) closure, variable-density projection). What is gated BITWISE here is
32// that the inflow ghost DENSITY is the inlet fluid's on every rank that owns the face and
33// untouched on every rank that does not; the colour gets the coupled pair of gates (see below).
34//
35// * `packing-kin` — V5a x V-BC COMPOSED and kinematic: the sphere packing of the case below,
36// one sphere cutting the outlet plane, driven by a prescribed velocity that varies in all
37// three directions. BITWISE at every np and thread count. This is the gate on the composed
38// cut-cell x open-boundary colour transport, and the one the coupled case cannot be.
39//
40// * `packing` — the same scene under the full coupled step. Gated on the CONSERVATIVE content:
41// the WO-R budget identity, the solid-colour rule, and a signed-sum gate saying the colour
42// difference CANCELS. Its pointwise colour gate is a bound on the field going wrong, not a
43// reduction floor — the colour is not a Lipschitz function of the velocity, and the long
44// comment there carries the trace that shows why.
45#include <mpi.h>
46
47#include <cmath>
48#include <cstdio>
49#include <cstdlib>
50#include <cstring>
51#include <Kokkos_Core.hpp>
52#include <vector>
53
54#include "flow_ibm.hpp"
55#include "peclet/core/common/types.hpp"
56#include "peclet/core/decomp/block_decomposer.hpp"
57
60
61static constexpr int NX = 16, NY = 16, NZ = 32;
62static constexpr std::size_t GCELLS = (std::size_t)NX * NY * NZ;
63static constexpr double WIN = 1.0; // inflow speed (cells/s)
64static constexpr double DT = 0.2; // WY Courant 0.2
65
66static std::vector<double> gatherGlobal(const std::vector<double>& local, int ox, int oy, int oz,
67 int lnx, int lny, int lnz, int rank, int size) {
68 std::vector<double> global;
69 if (rank == 0)
70 global.assign(GCELLS, 0.0);
71 for (int r = 0; r < size; ++r) {
72 int meta[6] = {ox, oy, oz, lnx, lny, lnz};
73 if (r == 0) {
74 if (rank == 0)
75 for (int z = 0; z < lnz; ++z)
76 for (int y = 0; y < lny; ++y)
77 std::memcpy(&global[(std::size_t)ox + (std::size_t)(y + oy) * NX +
78 (std::size_t)(z + oz) * NX * NY],
79 &local[(std::size_t)y * lnx + (std::size_t)z * lnx * lny],
80 (std::size_t)lnx * sizeof(double));
81 continue;
82 }
83 if (rank == r) {
84 MPI_Send(meta, 6, MPI_INT, 0, 100 + r, MPI_COMM_WORLD);
85 MPI_Send(local.data(), (int)local.size(), MPI_DOUBLE, 0, 200 + r, MPI_COMM_WORLD);
86 } else if (rank == 0) {
88 std::vector<double> buf((std::size_t)meta[3] * meta[4] * meta[5]);
89 MPI_Recv(buf.data(), (int)buf.size(), MPI_DOUBLE, r, 200 + r, MPI_COMM_WORLD,
91 for (int z = 0; z < meta[5]; ++z)
92 for (int y = 0; y < meta[4]; ++y)
93 std::memcpy(&global[(std::size_t)meta[0] + (std::size_t)(y + meta[1]) * NX +
94 (std::size_t)(z + meta[2]) * NX * NY],
95 &buf[(std::size_t)y * meta[3] + (std::size_t)z * meta[3] * meta[4]],
96 (std::size_t)meta[3] * sizeof(double));
97 }
98 }
99 return global;
100}
101
102// NaN-AWARE. `std::fmax(m, NaN)` returns m, so the obvious loop silently reports 0.000e+00 for a
103// field that has gone NaN — a gate that cannot fail. Propagate instead.
104static double maxAbsDiff(const std::vector<double>& a, const std::vector<double>& b) {
105 double m = 0;
106 for (std::size_t i = 0; i < b.size(); ++i) {
107 const double d = std::fabs(a[i] - b[i]);
108 if (!(d == d))
109 return d; // NaN
110 m = std::fmax(m, d);
111 }
112 return m;
113}
114// The SIGNED sum of the difference. `maxAbsDiff` asks whether the two fields agree; this asks
115// whether whatever they disagree about was CONSERVED — a difference that cancels is colour moved
116// between cells (a flux that landed differently), a difference that does not is colour created or
117// destroyed, which is what a decomposition defect does. See the tolerance comment on case C.
118static double signedSumDiff(const std::vector<double>& a, const std::vector<double>& b) {
119 double s = 0;
120 for (std::size_t i = 0; i < b.size(); ++i)
121 s += a[i] - b[i];
122 return s;
123}
124static long countNonFinite(const std::vector<double>& a) {
125 long n = 0;
126 for (double v : a)
127 if (!(v - v == 0.0))
128 ++n;
129 return n;
130}
131static double sumOf(const std::vector<double>& a) {
132 double s = 0;
133 for (double v : a)
134 s += v;
135 return s;
136}
137
138// ------------------------------------------------------------------- config A: the kinematic slug
139static void configureSlug(IbmSolver& s, int lnx, int lny, int lnz) {
140 s.setRho(1.0);
141 s.setMu(0.0);
142 s.setDt(DT);
143 for (int f = 0; f < 4; ++f)
144 s.setDomainBc(f, 1, 0, 0, 0);
145 s.setDomainBc(4, 2, 0.0, 0.0, WIN);
146 s.setDomainBc(5, 3, 0, 0, 0);
147 s.setPressureGeometry(std::vector<double>((std::size_t)lnx * lny * lnz, 10.0));
148 s.enableVof();
149 s.setVof(std::vector<double>((std::size_t)lnx * lny * lnz, 0.0));
150 s.setField("w", std::vector<double>((std::size_t)lnx * lny * lnz, WIN));
151 s.setVofInflow(4, 1.0);
152 s.setVofBackflow(5, 0.0);
153}
154
155// ------------------------------------------------------------------- config B: the coupled jet
156static void configureJet(IbmSolver& s, int lnx, int lny, int lnz) {
157 const double rhoG = 1.0, rhoL = 100.0;
158 s.setRho(rhoL);
159 s.setMu(0.2);
160 s.setDt(0.05);
161 for (int f = 0; f < 4; ++f)
162 s.setDomainBc(f, 1, 0, 0, 0);
163 s.setDomainBc(4, 2, 0.0, 0.0, 0.5);
164 s.setDomainBc(5, 3, 0, 0, 0);
165 // the validated open-boundary duct settings (scripts/verify_channel_sdflow.py)
168 s.setPressureGeometry(std::vector<double>((std::size_t)lnx * lny * lnz, 1e30));
169 s.enableVof();
170 s.setVof(std::vector<double>((std::size_t)lnx * lny * lnz, 0.0)); // a gas domain
172 {rhoG, rhoL - rhoG, 0.0, 0.0});
173 // THE DRIVER IS SELECTED LAST: setPropertyModel("rho", ...) fires setDensityMode, which
174 // reselects Chebyshev and silently discards a driver chosen earlier (the WO-H "capped at 120"
175 // tell). Without this the distributed run falls back to `presIters_` standalone V-cycles.
176 s.setPressureFcg(true, 400, 1e-11);
177 s.setVofInflow(4, 1.0); // fed by liquid
178 s.setVofBackflow(5, 0.0);
179}
180
181// ------------------------------ config D: the COLLOCATED bridge through an open boundary
182// All-fluid duct, -z inflow fed by liquid, +z outflow, colour initially filling the upper half.
183// Density ratio 1 on purpose: the colour rides as a passive tracer, so the CONSERVATION BUDGET is
184// a statement about the transport and the boundary ledger alone, with no two-phase pressure
185// coupling in the way.
186static void configureColoJet(Colo& s, int oz, int lnx, int lny, int lnz) {
187 s.setRho(1.0);
188 s.setMu(0.05);
189 s.setDt(0.05);
190 for (int f = 0; f < 4; ++f)
191 s.setDomainBc(f, 1, 0, 0, 0);
192 s.setDomainBc(4, 2, 0.0, 0.0, 0.5); // -z inflow
193 s.setDomainBc(5, 3, 0, 0, 0); // +z outflow
197 s.setPressureGeometry(std::vector<double>((std::size_t)lnx * lny * lnz, 1e30));
198 s.enableVof();
199 std::vector<double> c0((std::size_t)lnx * lny * lnz, 0.0);
200 for (int z = 0; z < lnz; ++z)
201 if (z + oz >= NZ / 2)
202 for (int y = 0; y < lny; ++y)
203 for (int x = 0; x < lnx; ++x)
204 c0[(std::size_t)x + (std::size_t)y * lnx + (std::size_t)z * lnx * lny] = 1.0;
205 s.setVof(c0);
206 s.setVofInflow(4, 1.0);
207 s.setVofBackflow(5, 0.0);
208}
209
210// ---------------------------------------------- config C: V5a x V-BC composed (WO-R2 item 2)
211// A sphere array inside the open-boundary duct, one sphere CUTTING the outlet plane, so the
212// cut-cell flux path and the out-of-domain donor rule act on the same faces. The SDF is built from
213// GLOBAL cell centres, so every rank derives its own block's geometry from the same scene.
214static std::vector<double> packingSdf(int ox, int oy, int oz, int lnx, int lny, int lnz) {
215 const double sp[5][4] = {{4.5, 4.5, 11, 3.4},
216 {11.5, 11.5, 11, 3.4},
217 {4.5, 11.5, 20, 3.4},
218 {11.5, 4.5, 20, 3.4},
219 {8.0, 8.0, (double)NZ, 3.6}}; // the last one cuts the +z outlet plane
220 std::vector<double> f((std::size_t)lnx * lny * lnz, 1e30);
221 for (int z = 0; z < lnz; ++z)
222 for (int y = 0; y < lny; ++y)
223 for (int x = 0; x < lnx; ++x) {
224 double d = 1e30;
225 for (const auto& q : sp) {
226 const double dx = x + ox + 0.5 - q[0], dy = y + oy + 0.5 - q[1], dz = z + oz + 0.5 - q[2];
227 d = std::fmin(d, std::sqrt(dx * dx + dy * dy + dz * dz) - q[3]);
228 }
229 f[(std::size_t)x + (std::size_t)y * lnx + (std::size_t)z * lnx * lny] = d;
230 }
231 return f;
232}
233// The KINEMATIC driver for the composed scene: a smooth velocity built from GLOBAL cell centres,
234// so every decomposition prescribes the SAME field and any decomposition dependence left in the
235// colour transport is the transport's own. A UNIFORM field would not do: with every value equal, a
236// ghost band filled from the wrong side is indistinguishable from one filled from the right side,
237// and the whole halo/bridge path would go untested. It is NOT discretely solenoidal — see the
238// budget note on case C0, which is why that case gates reproducibility and not conservation.
239static void packingVelocity(IbmSolver& s, int ox, int oy, int oz, int lnx, int lny, int lnz) {
240 const double pi = 3.14159265358979323846;
241 std::vector<double> fu((std::size_t)lnx * lny * lnz, 0.0), fv(fu), fw(fu);
242 for (int z = 0; z < lnz; ++z)
243 for (int y = 0; y < lny; ++y)
244 for (int x = 0; x < lnx; ++x) {
245 const std::size_t k = (std::size_t)x + (std::size_t)y * lnx + (std::size_t)z * lnx * lny;
246 const double gx = x + ox + 0.5, gy = y + oy + 0.5, gz = z + oz + 0.5;
247 fu[k] =
248 0.10 * std::sin(2 * pi * gx / NX) * std::cos(2 * pi * gy / NY) * std::cos(pi * gz / NZ);
249 fv[k] =
250 0.10 * std::cos(2 * pi * gx / NX) * std::sin(2 * pi * gy / NY) * std::cos(pi * gz / NZ);
251 fw[k] = 0.50 + 0.10 * std::cos(2 * pi * gx / NX) * std::cos(2 * pi * gy / NY) *
252 std::sin(pi * gz / NZ);
253 }
254 s.setField("u", fu);
255 s.setField("v", fv);
256 s.setField("w", fw);
257}
258
259static void configurePacking(IbmSolver& s, int ox, int oy, int oz, int lnx, int lny, int lnz) {
260 s.setRho(1.0);
261 s.setMu(0.5);
262 s.setDt(0.1);
263 for (int f = 0; f < 4; ++f)
264 s.setDomainBc(f, 1, 0, 0, 0);
265 s.setDomainBc(4, 2, 0.0, 0.0, 0.5); // inflow at -z
266 s.setDomainBc(5, 3, 0, 0, 0); // outflow at +z
270 s.setSolid(packingSdf(ox, oy, oz, lnx, lny, lnz), true);
271 s.enableVof();
272 std::vector<double> c0((std::size_t)lnx * lny * lnz, 0.0);
273 for (int z = 0; z < lnz; ++z)
274 if (z + oz >= NZ / 2)
275 for (int y = 0; y < lny; ++y)
276 for (int x = 0; x < lnx; ++x)
277 c0[(std::size_t)x + (std::size_t)y * lnx + (std::size_t)z * lnx * lny] = 1.0;
278 s.setVof(c0);
279 s.setVofInflow(4, 1.0);
280 s.setVofBackflow(5, 0.0);
281}
282
283int main(int argc, char** argv) {
284 MPI_Init(&argc, &argv);
285 Kokkos::initialize(argc, argv);
286 int fail = 0;
287 {
288 int rank = 0, size = 1;
291 auto dec = peclet::flow::CutcellMG::decomposition(static_cast<std::size_t>(size), NX, NY, NZ);
292 auto blk = dec.block(rank);
293 const int ox = (int)blk.origin[0], oy = (int)blk.origin[1], oz = (int)blk.origin[2];
294 const int lnx = (int)blk.size[0], lny = (int)blk.size[1], lnz = (int)blk.size[2];
295 const int gn[3] = {NX, NY, NZ};
296 bool cut[3] = {false, false, false};
297 for (const auto& sz : dec.sizes())
298 for (int a = 0; a < 3; ++a)
299 if ((int)sz[a] != gn[a])
300 cut[a] = true;
301 if (rank == 0)
302 std::printf("VOF BC MPI np=%d grid %dx%dx%d block %dx%dx%d cut axes: %s%s%s\n", size, NX,
303 NY, NZ, lnx, lny, lnz, cut[0] ? "x" : "", cut[1] ? "y" : "", cut[2] ? "z" : "");
304 if (size > 1 && !cut[2]) {
305 if (rank == 0)
306 std::printf(" FAIL — the decomposition does NOT cut z (the inflow/outflow axis)\n");
307 fail = 1;
308 }
309
310 // ---------------------------------------------------------------- A: kinematic slug budget
311 {
312 // 20 slug steps then 480 more: at W = 1 and dt = 0.2 the slug's tail clears the 32-cell
313 // domain by step ~182 (so the budget is fully exercised, in == out == 1024) and the run then
314 // continues 300 steps INTO THE EMPTIED-DOMAIN REGIME, which is the gate on WO-R2 item 4.
315 // WO-R stopped at 185 because it had to: once C is nothing but +-1e-18 round-off,
316 // `wyIsMixed` called those cells mixed, the MYC normal of a ~1e-18 stencil is degenerate and
317 // `plicAlpha` divided by it — measured on nvidia-cuda at np = 1 (`PECLET_VOF_BC_TRACE=1`,
318 // this file): C went -inf at step 186 and NaN at 187. With the wisp guard
319 // (`WyAdvector::wispEps`, 1e-8 once `enable_vof` is called) those cells are pure for
320 // reconstruction and fluxed algebraically, and the drain runs indefinitely.
321 const int nSlug = 20, nAfter = 480;
323 sd.initMpi(dec, MPI_COMM_WORLD);
325 double ledger = 0.0;
326 for (int i = 0; i < nSlug + nAfter; ++i) {
327 if (i == nSlug)
328 sd.setVofInflow(4, 0.0);
329 sd.advectVof();
330 const auto v = sd.vofBcVolumes();
331 if (std::getenv("PECLET_VOF_BC_TRACE") && rank == 0) {
332 const auto cc = sd.getVof();
333 double sm = 0;
334 for (double q : cc)
335 sm += q;
336 std::printf(" trace step %3d: face %.6g %.6g | C sum %.6g nonfinite %ld\n", i, v[4],
337 v[5], sm, countNonFinite(cc));
338 }
339 for (int f = 0; f < 6; ++f)
340 ledger += v[f];
341 }
342 const double locSum = sumOf(sd.getVof());
343 double gSum = 0.0, gLedger = 0.0;
346 const auto tot = sd.vofBcVolumesTotal();
347 double gIn = 0.0, gOut = 0.0;
348 double lIn = tot[4], lOut = -tot[5];
351 const std::vector<double> gc =
352 gatherGlobal(sd.getVof(), ox, oy, oz, lnx, lny, lnz, rank, size);
353 if (rank == 0) {
354 IbmSolver ref(NX, NY, NZ);
355 configureSlug(ref, NX, NY, NZ);
356 double rLedger = 0.0;
357 for (int i = 0; i < nSlug + nAfter; ++i) {
358 if (i == nSlug)
359 ref.setVofInflow(4, 0.0);
360 ref.advectVof();
361 const auto v = ref.vofBcVolumes();
362 for (int f = 0; f < 6; ++f)
363 rLedger += v[f];
364 }
365 const double dc = maxAbsDiff(gc, ref.getVof());
366 const long nbad = countNonFinite(gc) + countNonFinite(ref.getVof());
367 const double dl = std::fabs(gLedger - rLedger);
368 if (nbad != 0) {
369 std::printf(" [slug-kin np=%d] FAIL — %ld non-finite colour cells\n", size, nbad);
370 fail = 1;
371 }
372 if (!(gLedger == gLedger) || !(gIn == gIn) || !(gOut == gOut)) {
373 std::printf(" [slug-kin np=%d] FAIL — the boundary ledger is not finite\n", size);
374 fail = 1;
375 }
376 const double budget = std::fabs(gSum - gLedger);
377 std::printf(" [slug-kin np=%d] colour vs single-rank %.3e (BITWISE required)\n", size,
378 dc);
379 std::printf(
380 " [slug-kin np=%d] ledger vs single-rank %.3e; global budget "
381 "|sum(C) - ledger| %.3e; in %.10g out %.10g\n",
382 size, dl, budget, gIn, gOut);
383 if (!(dc == 0.0)) {
384 std::printf(" [slug-kin np=%d] FAIL — colour is not bitwise\n", size);
385 fail = 1;
386 }
387 if (!(budget < 1e-8)) {
388 std::printf(" [slug-kin np=%d] FAIL — the global budget does not close\n", size);
389 fail = 1;
390 }
391 if (!(std::fabs(gIn - gOut) < 1e-9 * gIn) || !(gIn > 0.0)) {
392 std::printf(" [slug-kin np=%d] FAIL — in/out do not balance\n", size);
393 fail = 1;
394 }
395 }
396 }
397
398 // ------------------------------------------------------- B: the coupled jet + the rho ghost
399 {
400 const int steps = 30;
402 sd.initMpi(dec, MPI_COMM_WORLD);
404 long itmax = 0;
405 double divmax = 0.0;
406 for (int i = 0; i < steps; ++i) {
407 sd.step();
408 itmax = std::max<long>(itmax, sd.lastPressureIterations());
409 divmax = std::fmax(divmax, sd.maxOpenDivergence());
410 }
411 // the inflow ghost density: rho_liquid on a rank that OWNS the -z face, untouched elsewhere
412 auto rv = sd.fieldView("rho");
413 auto hr = Kokkos::create_mirror_view(rv);
414 Kokkos::deep_copy(hr, rv);
415 const int G = 2, ex = lnx + 2 * G, ey = lny + 2 * G;
416 const double ghost = hr((std::size_t)(G + lnx / 2) + (std::size_t)(G + lny / 2) * ex +
417 (std::size_t)(G - 1) * ex * ey);
418 const bool owns = (oz == 0);
419 int bad = (owns && std::fabs(ghost - 100.0) > 1e-9) ? 1 : 0;
420 int badAll = 0;
422 const std::vector<double> gc =
423 gatherGlobal(sd.getVof(), ox, oy, oz, lnx, lny, lnz, rank, size);
424 long gItmax = 0;
426 if (rank == 0) {
427 IbmSolver ref(NX, NY, NZ);
428 configureJet(ref, NX, NY, NZ);
429 for (int i = 0; i < steps; ++i)
430 ref.step();
431 const double dc = maxAbsDiff(gc, ref.getVof());
432 const double dsum = signedSumDiff(gc, ref.getVof());
433 const double csum = sumOf(ref.getVof());
434 std::printf(
435 " [jet-coupl np=%d] colour vs single-rank %.3e (signed sum %.3e); inflow rho ghost "
436 "owners wrong on %d rank(s); pressure %ld/400, max|div| %.3e\n",
437 size, dc, dsum, badAll, gItmax, divmax);
438 // The SAME pair of gates as case C, for the same reason and with the same derivation — see
439 // the long comment there. This case happens to sit at 1.3e-15 today, but it is a coupled
440 // run through an inflow face whose colour front is axis-aligned, i.e. exactly the
441 // configuration in which `mycNormal`'s estimator selection ties; nothing about it earns a
442 // tighter pointwise gate than case C, and a tighter one here would only mean this file
443 // fails on a different machine instead of the same one.
444 const double tol = (size == 1) ? 0.0 : 1e-6;
445 if (!(dc <= tol)) {
446 std::printf(" [jet-coupl np=%d] FAIL — the colour field is wrong (tol %.1e)\n", size,
447 tol);
448 fail = 1;
449 }
450 const double tolSum = (size == 1) ? 0.0 : 1e-11 * std::fabs(csum);
451 if (!(std::fabs(dsum) <= tolSum)) {
452 std::printf(
453 " [jet-coupl np=%d] FAIL — the colour difference does not CANCEL: signed sum %.3e "
454 "> %.3e\n",
455 size, dsum, tolSum);
456 fail = 1;
457 }
458 if (badAll != 0) {
459 std::printf(" [jet-coupl np=%d] FAIL — the inflow rho ghost is not the inlet fluid's\n",
460 size);
461 fail = 1;
462 }
463 if (gItmax >= 400) {
464 std::printf(" [jet-coupl np=%d] FAIL — the pressure solve CAPPED (run invalid)\n", size);
465 fail = 1;
466 }
467 }
468 }
469 // ------------------------- C0: V5a x V-BC composed, KINEMATIC (the bitwise gate on this path)
470 //
471 // The SAME composed scene as case C — the same packing SDF, the same inflow/outflow faces, the
472 // same initial colour — driven by a PRESCRIBED velocity instead of the coupled step. Nothing
473 // in the update then goes through a reduction, so the colour must be BITWISE identical to the
474 // single-rank reference at every np, exactly as `slug-kin` is.
475 //
476 // WHY THIS CASE EXISTS. Case C below cannot be gated bitwise, and (WO 2026-09-14) it cannot be
477 // gated at a reduction floor either — see the long comment there. That left the composed
478 // cut-cell x open-boundary path with no exact gate at all, which is the coverage this restores:
479 // the out-of-domain mask on GLOBAL indices, the owner-only boundary colour, the openness-
480 // weighted domain-face flux and the per-rank ledger are all exercised here, against an
481 // absolute standard, with a velocity that VARIES in all three directions so a mis-filled ghost
482 // cannot hide behind a uniform field.
483 //
484 // What it does NOT cover, and case C still must: `setField` clears `outflowCorrValid_`, so the
485 // bridge takes its `doOutflow = true` branch here. The outflow-face correction the projection
486 // makes lives only on the coupled path.
487 {
488 const int steps = 20;
490 sd.initMpi(dec, MPI_COMM_WORLD);
491 configurePacking(sd, ox, oy, oz, lnx, lny, lnz);
492 packingVelocity(sd, ox, oy, oz, lnx, lny, lnz);
493 double ledger = 0.0, solidSum = 0.0;
494 const double vol0loc = sd.vofDiagnostics().volume;
495 for (int i = 0; i < steps; ++i) {
496 sd.advectVof();
497 const auto v = sd.vofBcVolumes();
498 for (int f = 0; f < 6; ++f)
499 ledger += v[f];
500 solidSum = std::fmax(solidSum, std::fabs(sd.vofDiagnostics().solidSumC));
501 }
502 const double vol1loc = sd.vofDiagnostics().volume;
503 double g0 = 0, g1 = 0, gLed = 0, gSolid = 0;
508 const std::vector<double> gc =
509 gatherGlobal(sd.getVof(), ox, oy, oz, lnx, lny, lnz, rank, size);
510 if (rank == 0) {
511 IbmSolver ref(NX, NY, NZ);
512 configurePacking(ref, 0, 0, 0, NX, NY, NZ);
513 packingVelocity(ref, 0, 0, 0, NX, NY, NZ);
514 for (int i = 0; i < steps; ++i)
515 ref.advectVof();
516 const double dc = maxAbsDiff(gc, ref.getVof());
517 const double budget = std::fabs((g1 - g0) - gLed);
518 const long nbad = countNonFinite(gc) + countNonFinite(ref.getVof());
519 // The budget is REPORTED, not gated, and this is the one thing the case gives up: the
520 // Weymouth-Yue conservation identity rests on the advecting field being discretely
521 // divergence-free in the OPENNESS-WEIGHTED sense, and a prescribed analytic field is not
522 // (2.6e-03 relative over these 20 steps). The identity is gated where its hypothesis holds
523 // — on `slug-kin`, whose uniform w is exactly solenoidal, and on case C below, whose field
524 // comes out of the projection. What this case gates is the one thing those two cannot:
525 // that the composed cut-cell x open-boundary colour transport is decomposition-EXACT.
526 std::printf(
527 " [packing-kin np=%d] colour vs single-rank %.3e (BITWISE required); budget "
528 "|d sum(eps_eff C) - ledger| %.3e (rel %.3e, NOT gated — the prescribed field is not "
529 "discretely solenoidal); solid colour %.3e\n",
530 size, dc, budget, budget / g0, gSolid);
531 if (!(dc == 0.0)) {
532 std::printf(" [packing-kin np=%d] FAIL — the composed colour is not bitwise\n", size);
533 fail = 1;
534 }
535 if (nbad != 0) {
536 std::printf(" [packing-kin np=%d] FAIL — %ld non-finite colour cells\n", size, nbad);
537 fail = 1;
538 }
539 if (!(gSolid == 0.0)) {
540 std::printf(" [packing-kin np=%d] FAIL — colour leaked into solid cells\n", size);
541 fail = 1;
542 }
543 }
544 }
545
546 // ------------------------------------------- C: V5a x V-BC composed (WO-R2 item 2), coupled
547 //
548 // WHY THIS ONE IS AT THE REDUCTION FLOOR AND NOT BITWISE. The kinematic pattern the two rungs
549 // use separately (`vof_cutcell_mpi`: run the reference on the full grid, slice its velocity
550 // into every decomposition with `setField`) cannot be used through an OPEN boundary: the
551 // outflow-face correction lives on a ghost face index that `setField` does not carry, and the
552 // ghost fill `setField` triggers is exactly the one that erases it (`outflowCorrValid_`,
553 // WO-R). So the composed scene is driven by `step()` on both sides and gated at the
554 // allreduce-order floor, like the coupled jet above. The BUDGET identity is gated absolutely.
555 {
556 const int steps = 40;
558 sd.initMpi(dec, MPI_COMM_WORLD);
559 configurePacking(sd, ox, oy, oz, lnx, lny, lnz);
560 long itmax = 0;
561 double ledger = 0.0, solidSum = 0.0;
562 const double vol0loc = sd.vofDiagnostics().volume;
563 for (int i = 0; i < steps; ++i) {
564 sd.step();
565 itmax = std::max<long>(itmax, sd.lastPressureIterations());
566 const auto v = sd.vofBcVolumes();
567 for (int f = 0; f < 6; ++f)
568 ledger += v[f];
569 solidSum = std::fmax(solidSum, std::fabs(sd.vofDiagnostics().solidSumC));
570 }
571 const double vol1loc = sd.vofDiagnostics().volume;
572 double g0 = 0, g1 = 0, gLed = 0, gSolid = 0;
573 long gIt = 0;
579 const std::vector<double> gc =
580 gatherGlobal(sd.getVof(), ox, oy, oz, lnx, lny, lnz, rank, size);
581 if (rank == 0) {
582 IbmSolver ref(NX, NY, NZ);
583 configurePacking(ref, 0, 0, 0, NX, NY, NZ);
584 for (int i = 0; i < steps; ++i)
585 ref.step();
586 const double dc = maxAbsDiff(gc, ref.getVof());
587 const double budget = std::fabs((g1 - g0) - gLed);
588 const double dsum = signedSumDiff(gc, ref.getVof());
589 const double csum = sumOf(ref.getVof());
590 std::printf(
591 " [packing np=%d] colour vs single-rank %.3e (signed sum %.3e, rel %.3e); budget "
592 "|d sum(eps_eff C) - ledger| %.3e (rel %.3e); solid colour %.3e; "
593 "pressure %ld/400\n",
594 size, dc, dsum, dsum / csum, budget, budget / g0, gSolid, gIt);
595 // THE COLOUR IS NOT A LIPSCHITZ FUNCTION OF THE VELOCITY, so at np > 1 there is no
596 // reduction floor to gate the POINTWISE field against. Measured, on this configuration
597 // (WO docs/wo_vof_mpi_parity_gates.md, 2026-09-14):
598 //
599 // * the composed VoF path itself is decomposition-EXACT — `packing-kin` above drives
600 // this very scene with a prescribed 3-D velocity and is bitwise at np = 2 and 4 and at
601 // 1..16 OpenMP threads;
602 // * under `step()` every field the solver carries agrees at the allreduce floor: u, v, w
603 // and their ghosts to 1.4e-14, p to 1.4e-13 on |p| = 1.9e2;
604 // * and yet the colour parts company by 4.795e-09 in exactly TWO adjacent cells, equal
605 // and opposite, i.e. one x-face flux.
606 //
607 // The mechanism is `mycNormal`'s estimator selection (core/vof/plic.hpp), which is a STRICT
608 // comparison — `if (fabs(mm[cn][cn]) > t0) cn = 3;` — between the centred and the Youngs
609 // candidate. At a near-axis-aligned interface the two candidates carry the same dominant
610 // component, so that comparison is an exact tie: traced here, the centred candidate scored
611 // 0.99986893026188128 against Youngs' 0.99986893026188106 at np = 2 (ONE ULP apart, Youngs
612 // loses) and 0.99986893026188106 against 0.99986893026188106 single-rank (equal, so `>` is
613 // false and the centred one wins). The two winners are the same plane to 1e-6 in their
614 // dominant component and differ by 2.9e-06 in the transverse ones; re-tilting the plane by
615 // that much moves the liquid volume in the |a| = 3.4e-03 slab of that face by ~a^2 s
616 // = 1.5e-09, and the sweep by 4.795e-09. ONE ULP of colour in, 4.8e-09 out — and which way
617 // the tie breaks is decided by bits that a reduction order is entitled to change.
618 //
619 // The 1e-11 this gate used to ask for was therefore never a property of the code: it was a
620 // coin toss. It came up heads in CI and on 1 thread, and tails everywhere else — measured
621 // over 24 (ranks, threads) pairs, np = 1 is bitwise at 1..16 threads, np = 2 flips at 2..16
622 // threads and not at 1, and np = 4 flips at 2, 3, 4, 6 and 12 threads but NOT at 1, 8 and
623 // 16, which is the only reason `vof_bc_mpi_np4` passes on the 48-core box at all.
624 //
625 // So the pointwise gate is no longer a reduction-floor gate and does not pretend to be. The
626 // reduction-floor content moves to the SIGNED SUM below, which is Lipschitz and is what a
627 // real defect breaks; the pointwise number becomes a bound on the field going WRONG. It is
628 // set at 1e-6: three orders above the largest flip this scene can produce (a tie needs the
629 // two candidates to agree in the dominant component, which bounds their transverse mass
630 // s = 1 - max|m| and hence the flux jump at ~cfl^2 s), and far below what a real defect
631 // does here. That second half is MEASURED, not asserted — the WO-F owner rule was deleted
632 // (`vofApplyColourBc`'s `touchesGlobalFace` test, so every rank injects the inflow colour
633 // into its own block's -z ghost band) and re-run at np = 2: this gate reports 1.000e+00
634 // against its 1e-06, the signed sum 5.141e+02 against its 3.886e-08, and `packing-kin`
635 // 1.000e+00 against bitwise. Nine orders of margin, not one.
636 //
637 // The OTHER defect named in this file's header — the out-of-domain mask built on per-block
638 // instead of global indices — was injected too, and is worth recording precisely because
639 // this case does NOT see it: `packing` stayed at 3.174e-09 and `packing-kin` at exactly
640 // zero, while `slug-kin`'s bitwise gate caught it at 2.769e-37. It is caught by the
641 // bitwise cases, which is where that coverage lives; the 1e-11 this case used to carry
642 // never had it either.
643 const double tol = (size == 1) ? 0.0 : 1e-6;
644 if (!(dc <= tol)) {
645 std::printf(" [packing np=%d] FAIL — the colour field is wrong (tol %.1e)\n", size,
646 tol);
647 fail = 1;
648 }
649 // The reduction floor, on the quantity that HAS one. An 8192-term naive sum of O(1) values
650 // drifts by O(N eps) ~ 2e-12 relative; 1e-11 is five times that, and a flux that a
651 // decomposition lost or double-counted does not cancel at all.
652 const double tolSum = (size == 1) ? 0.0 : 1e-11 * std::fabs(csum);
653 if (!(std::fabs(dsum) <= tolSum)) {
654 std::printf(
655 " [packing np=%d] FAIL — the colour difference does not CANCEL: signed sum %.3e "
656 "> %.3e (colour created or destroyed, not moved)\n",
657 size, dsum, tolSum);
658 fail = 1;
659 }
660 if (!(budget / g0 < 1e-10)) {
661 std::printf(" [packing np=%d] FAIL — the composed budget does not close\n", size);
662 fail = 1;
663 }
664 if (!(gSolid == 0.0)) {
665 std::printf(" [packing np=%d] FAIL — colour leaked into solid cells\n", size);
666 fail = 1;
667 }
668 if (gIt >= 400) {
669 std::printf(" [packing np=%d] FAIL — the pressure solve CAPPED (run invalid)\n", size);
670 fail = 1;
671 }
672 }
673 }
674
675 // ------------------------------------ D: the COLLOCATED bridge through an open boundary
676 //
677 // The staggered composed case above gates this identity on `IbmSolver`; nothing gated it on
678 // `SolverColocated`, whose VoF bridge takes a DIFFERENT path -- it hands the advector the FACE
679 // field `uf_/vf_/wf_` and re-fills its ghosts first. `fillGhosts` is BC-unaware and wraps every
680 // axis, and the outflow face is a GHOST index, so that fill overwrites the mass-conserving
681 // value `bcCorrectOutflow` had just written with the opposite boundary's -- single-rank as
682 // well as distributed, since the single-rank fill wraps periodically too.
683 //
684 // The conserved functional is summed from the CANONICAL colour field, not from the advector
685 // census: `volume` (sum eps_eff*C) is populated only with cut-cell geometry attached, and
686 // `diagnostics()` returns what the last `advect()` stored, so neither is meaningful BEFORE the
687 // first step. This duct is all-fluid, so sum C over the inner block is the right quantity.
688 {
689 const int steps = 30;
690 Colo sd(lnx, lny, lnz);
691 sd.initMpi(dec, MPI_COMM_WORLD);
692 configureColoJet(sd, oz, lnx, lny, lnz);
693 double colLedger = 0.0;
694 const double colC0 = sumOf(sd.getVof());
695 long colIt = 0;
696 for (int i = 0; i < steps; ++i) {
697 sd.step();
698 colIt = std::max<long>(colIt, sd.lastPressureIterations());
699 const auto v = sd.vofBcVolumes();
700 for (int f = 0; f < 6; ++f)
701 colLedger += v[f];
702 }
703 const double colC1 = sumOf(sd.getVof());
704 double gC0 = 0, gC1 = 0, gColLed = 0;
705 long gColIt = 0;
710 const std::vector<double> gcol =
711 gatherGlobal(sd.getVof(), ox, oy, oz, lnx, lny, lnz, rank, size);
712 if (rank == 0) {
713 Colo ref(NX, NY, NZ);
714 configureColoJet(ref, 0, NX, NY, NZ);
715 for (int i = 0; i < steps; ++i)
716 ref.step();
717 const double dcol = maxAbsDiff(gcol, ref.getVof());
718 const double budget = std::fabs((gC1 - gC0) - gColLed);
719 const double csum = sumOf(ref.getVof());
720 std::printf(
721 " [colo-jet np=%d] colour vs single-rank %.3e; budget |d sum(C) - ledger| %.3e "
722 "(rel %.3e); pressure %ld/400\n",
723 size, dcol, budget, budget / gC0, gColIt);
724 const double tolCol = (size == 1) ? 0.0 : 1e-9 * std::fabs(csum);
725 if (!(dcol <= tolCol)) {
726 std::printf(" [colo-jet np=%d] FAIL — the colour field is wrong (tol %.1e)\n", size,
727 tolCol);
728 fail = 1;
729 }
730 if (!(budget / gC0 < 1e-10)) {
731 std::printf(" [colo-jet np=%d] FAIL — the boundary budget does not close\n", size);
732 fail = 1;
733 }
734 if (gColIt >= 400) {
735 std::printf(" [colo-jet np=%d] FAIL — the pressure solve CAPPED (run invalid)\n", size);
736 fail = 1;
737 }
738 }
739 }
740 int gfail = 0;
742 fail = gfail;
743 if (rank == 0)
744 std::printf(fail ? "FAILED\n" : "OK\n");
745 }
746 Kokkos::finalize();
747 MPI_Finalize();
748 return fail;
749}
void setSolid(const std::vector< double > &sdfInner, bool cutcellPressure)
Host entry point: upload the inner SDF once and delegate.
void setDomainBc(int face, int type, double vx, double vy, double vz)
std::vector< double > vofBcVolumes() const
Signed liquid volume that crossed each of the six domain faces during the LAST colour advection,...
std::vector< double > getVof()
void setPropertyModel(const std::string &target, ClosureKind kind, const std::string &in0, const std::string &in1, const std::vector< double > &params)
void setVelocityIterations(int it)
void setVof(const std::vector< double > &c)
void setPressureLevels(int levels)
void setPressureGeometry(const std::vector< double > &sdfInner)
void setVofBackflow(int f, double value)
inletOutlet backflow colour on outflow face f (default 0 = gas): where the boundary face velocity poi...
void setField(const std::string &name, const std::vector< double > &v)
std::vector< double > vofBcVolumesTotal() const
The same, accumulated since enable_vof() (or the last resetVofBcVolumes()).
void setVofInflow(int f, double value)
Colour of the fluid entering through inflow face f (0..5 = -x,+x,-y,+y,-z,+z), in [0,...
void setMu(double m)
Dynamic viscosity, in the caller's units.
void setPressureIterations(int it)
CCField fieldView(const std::string &name)
void setRho(double r)
Fluid density, in the caller's units.
vof::WyAdvector::Diagnostics vofDiagnostics()
void setPressureFcg(bool on, int maxit, double rtol)
int main()
Definition dbg_scene.cpp:13
flow — host-facing Kokkos IBM Navier-Stokes solver (drop-in flow-style API).
double solidSumC
filled by the SOLVER from the canonical G=2 colour field
double volume
sum of eps_eff*C over inner FLUID cells: the EXACTLY conserved functional of the cut-cell scheme (cut...
static constexpr int G
static constexpr int NY
static std::vector< double > gatherGlobal(const std::vector< double > &local, int ox, int oy, int oz, int lnx, int lny, int lnz, int rank, int size)
static void configureSlug(IbmSolver &s, int lnx, int lny, int lnz)
static void configureJet(IbmSolver &s, int lnx, int lny, int lnz)
static double signedSumDiff(const std::vector< double > &a, const std::vector< double > &b)
static long countNonFinite(const std::vector< double > &a)
static double maxAbsDiff(const std::vector< double > &a, const std::vector< double > &b)
static void packingVelocity(IbmSolver &s, int ox, int oy, int oz, int lnx, int lny, int lnz)
static double sumOf(const std::vector< double > &a)
static std::vector< double > packingSdf(int ox, int oy, int oz, int lnx, int lny, int lnz)
static constexpr double DT
static void configurePacking(IbmSolver &s, int ox, int oy, int oz, int lnx, int lny, int lnz)
static constexpr int NX
static constexpr double WIN
static void configureColoJet(Colo &s, int oz, int lnx, int lny, int lnz)
static constexpr std::size_t GCELLS
static constexpr int NZ
static std::vector< double > ledger(IbmSolver &s)