flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_collocated.cpp
Go to the documentation of this file.
1// VoF rung V8 — the COLLOCATED path: variable density and surface tension in the ABC approximate
2// projection through the mass-adjoint pair (doc/collocated_varrho_forces.md), colour advection from
3// the projected face field.
4//
5// The collocated solver's pressure coupling is the approximate projection: map the cell velocities
6// onto a MAC face field, project THAT exactly, correct the cell field. On this path
7//
8// * the TRANSPORT half: `uf_/vf_/wf_` is exactly discretely divergence-free, which is precisely
9// what Weymouth-Yue's conservation proof needs;
10// * the FORCE half: the pressure, the CSF and `set_body_force` (a mean pressure gradient) enter
11// the IMPLICIT momentum predictor as the finite-volume face integral
12// rho_c * avg_faces(o (f - (P(i)-P(i-s)))/rho_f); a per-cell force enters at the cell value
13// times the reconstruction's weight sum; the constraint reads the MOMENTUM-weighted face
14// velocity (rho_L u_L + rho_R u_R)/(rho_L + rho_R). Then M Gamma = -C^T: stable at every dt and
15// ratio, dt-independent steady state (tests/python/test_collocated_stability_guard.py).
16// The retired WO-T form -- every force a face acceleration added AFTER the viscous solve
17// (Basilisk centered.h) -- was balanced per step but non-incremental and unstable above
18// mu dt/(rho h^2) = 1/12; do not reintroduce it.
19//
20// Balance at rest. With the balanced-force projection (doc §4.6; the DEFAULT on this path) a static
21// balance -- hydrostatic column, constant-kappa drop -- is exact from the first step. With it
22// explicitly OFF the balance is still an exact FIXED POINT, but the pressure absorbs a gradient
23// force only through the step, and the transient from the initial P decays. Both are gated: the
24// T1/T1b/T2 blocks below run explicitly OFF; "ON" re-runs them at the default.
25//
26// Gates, in the order they run:
27//
28// T1 HYDROSTATIC, through a hand-set rho and through C, at ratio 1000, in a triply periodic box
29// (the uniform offset <rho> g as set_body_force) and in a walled column. Staggered and
30// collocated side by side. Collocated: the FACE field at round-off; the cell field and
31// dP/dz are the decaying transient -- reported, with decay required (400 steps < 100 steps).
32//
33// T1b THE INVISIBLE SUBSPACE, measured. A cell-field checkerboard is annihilated by the uniform
34// centre-to-face average (`½(U(i)+U(i-1))` kills the odd-even mode), so the approximate
35// projection cannot see it and cannot remove it -- a PRE-EXISTING property of the collocated
36// grid, measured on the constant-density control beside the V8 column. The transient of the
37// column lives largely in that mode, which is why it decays slowly.
38//
39// T2 STATIONARY DROPLET with a CONSTANT curvature: the CSF is exactly a face gradient, so the
40// balance is a fixed point; ratio 1..1000 and the ratio-1000 mu sweep must not throw, and the
41// face field must decay (90 steps < 30 steps).
42//
43// T3 CONSTANT-DENSITY EQUIVALENCE. A uniform-rho V8 run reproduces the constant-density
44// collocated run to round-off: the face integral of a uniform rho IS the central difference.
45//
46// T4 THE BRIDGE (G6). The colour transport on the collocated grid is the SAME kernel on the SAME
47// faces as on the staggered grid: `uf_(i)` sits at i-1/2, exactly where the staggered `u(i)`
48// sits, so a staggered solver handed the collocated projected face field reproduces the
49// collocated colour BITWISE.
50//
51// T5 SCOPE. Variable density / VoF on the collocated grid with an immersed solid, with
52// `enable_vof_momentum`, with `set_rho_face_harmonic` or with the non-incremental pressure,
53// must throw.
54#include <cmath>
55#include <cstdio>
56#include <Kokkos_Core.hpp>
57#include <memory>
58#include <stdexcept>
59#include <string>
60#include <vector>
61
62#include "flow_ibm.hpp"
63
64namespace {
71
72int failures = 0;
73#define CHECK(cond) \
74 do { \
75 if (!(cond)) { \
76 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
77 ++failures; \
78 } \
79 } while (0)
80
81std::size_t idx(int x, int y, int z, int nx, int ny) {
82 return (std::size_t)x + (std::size_t)y * nx + (std::size_t)z * (std::size_t)nx * ny;
83}
84double maxAbs(const std::vector<double>& v) {
85 double m = 0;
86 for (double x : v)
87 m = std::fmax(m, std::fabs(x));
88 return m;
89}
90double maxAbsDiff(const std::vector<double>& a, const std::vector<double>& b) {
91 // WO-R2: NaN-PROPAGATING. `std::fmax(m, NaN) == m`, so the obvious loop returns 0.000e+00 for a
92 // field that has gone entirely NaN and every bitwise gate built on it passes (WO-R found this on
93 // a drained open-boundary run). A non-finite difference must fail, so return it.
94 double m = 0;
95 for (std::size_t i = 0; i < a.size(); ++i) {
96 const double d = std::fabs(a[i] - b[i]);
97 if (!(d == d))
98 return d; // NaN
99 m = std::fmax(m, d);
100 }
101 return m;
102}
103template <class S>
104double maxVel(S& s) {
105 return std::fmax(std::fmax(maxAbs(s.getVelocity(0)), maxAbs(s.getVelocity(1))),
106 maxAbs(s.getVelocity(2)));
107}
108template <class S>
109double maxFaceVel(S& s) {
110 return std::fmax(std::fmax(maxAbs(s.getFaceVelocity(0)), maxAbs(s.getFaceVelocity(1))),
111 maxAbs(s.getFaceVelocity(2)));
112}
113// Odd-even (checkerboard) amplitude of a z-column of a cell field: max |f(k) - ½(f(k-1)+f(k+1))|,
114// which reads 2A for a pure checkerboard of amplitude A and 0 for anything smooth-and-linear.
115double checkerboardZ(const std::vector<double>& f, int nx, int ny, int nz) {
116 double m = 0;
117 const int xc = nx / 2, yc = ny / 2;
118 for (int z = 1; z + 1 < nz; ++z)
119 m = std::fmax(m, std::fabs(f[idx(xc, yc, z, nx, ny)] - 0.5 * (f[idx(xc, yc, z - 1, nx, ny)] +
120 f[idx(xc, yc, z + 1, nx, ny)])));
121 return m;
122}
123
124// ---------------------------------------------------------------- T1: hydrostatic
125//
126// A two-layer stratified column at rest. `periodic` runs it in a triply periodic box with the
127// ZERO-MEAN force f = -(rho - <rho>) g (the only hydrostatic problem a periodic box admits; the
128// reported dP/dz is then -(rho_f - <rho>) g). `walled` reproduces the staggered acid test of
129// `test_vardensity_projection.cpp` verbatim (walls +-z), which is the direct staggered column.
130struct HydroResult {
131 double cellU = 0, faceU = 0, pErr = 0, cb = 0;
132 long iters = 0;
133};
134
135template <class S>
136// `bfp`: -1 = the solver's default (balanced-force projection ON on the collocated V8 path, OFF on
137// the staggered grid), 0 = explicitly OFF, 1 = explicitly ON.
138HydroResult hydrostatic(double ratio, double mu, int steps, bool periodic, bool throughColour,
139 int bfp = -1, double chebRtol = -1.0) {
140 const int N = 8, NZ = 24;
141 const double g = 0.1;
142 S s(N, N, NZ);
143 s.setRho(1.0);
144 s.setMu(mu);
145 s.setDt(1.0);
146 s.setVelocityResidualTolerance(0.0); // machine-precision gates: legacy fixed-sweep momentum loop
147 if (!periodic) {
148 s.setDomainBc(4, 1, 0, 0, 0);
149 s.setDomainBc(5, 1, 0, 0, 0);
150 }
151 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * NZ, 10.0));
152 // Heavy BELOW in the walled column (the stable stratification of the staggered acid test); a
153 // heavy slab in the middle quarter in the periodic box, where the density is a FROZEN hand-set
154 // field so no Rayleigh-Taylor mode exists (a periodic two-layer column always has one unstably
155 // stratified interface — with a frozen rho it is simply not a degree of freedom).
156 auto heavy = [&](int z) { return periodic ? (z >= NZ / 4 && z < 3 * NZ / 4) : (z < NZ / 2); };
157 std::vector<double> fld((std::size_t)N * N * NZ);
158 for (int z = 0; z < NZ; ++z)
159 for (int y = 0; y < N; ++y)
160 for (int x = 0; x < N; ++x)
161 fld[idx(x, y, z, N, N)] =
162 heavy(z) ? (throughColour ? 1.0 : ratio) : (throughColour ? 0.0 : 1.0);
163 double rbar = 0;
164 for (int z = 0; z < NZ; ++z)
165 rbar += (heavy(z) ? ratio : 1.0) / NZ;
166 if (throughColour) {
167 // FROZEN interface (the `freeze` route of test_vof_twophase.cpp gate B2): register "C" as a
168 // plain field and let the closure read it, without enable_vof, so C supplies rho and nothing
169 // else. With the interface free the rest state is not a fixed point of the discrete system at
170 // all (an interfacial gravity-wave mode with loop gain g*drho*dt/rho_g), which is a property of
171 // the physics and not of this rung.
172 s.addField("C");
173 s.setField("C", fld);
174 s.exchangeField("C");
175 s.setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {1.0, ratio - 1.0});
176 } else {
177 s.addField("rho");
178 s.setField("rho", fld);
179 s.setDensityMode(true);
180 }
181 // gravity f_z = -rho g as a per-cell field (volumetric: the cell value on the collocated grid);
182 // in the periodic box the offset +<rho> g that makes the pressure periodic is a UNIFORM drive,
183 // i.e. a mean pressure gradient, so it is set_body_force (a surface force on the collocated
184 // variable-density path, doc/collocated_varrho_forces.md U1 / §5) and not part of the field.
185 s.setPropertyModel("force_z", ClosureKind::LinearMix, "rho", "", std::vector<double>{0.0, -g});
186 if (periodic)
187 s.setBodyForce(0.0, 0.0, rbar * g);
188 if (bfp >= 0)
189 s.setBalancedForceProjection(bfp == 1);
190 if (chebRtol > 0.0) // the driver last (set_density_mode re-selects Chebyshev)
191 s.setPressureChebyshev(true, 4000, chebRtol);
192 for (int k = 0; k < steps; ++k)
193 s.step();
194 HydroResult r;
195 r.cellU = maxVel(s);
196 r.faceU = maxFaceVel(s);
197 r.iters = s.lastPressureIterations();
198 r.cb = checkerboardZ(s.getVelocity(2), N, N, NZ);
199 const auto p = s.getPressure();
200 const double off = periodic ? rbar * g : 0.0;
201 for (int z = 1; z < NZ; ++z) {
202 const double dp = p[idx(N / 2, N / 2, z, N, N)] - p[idx(N / 2, N / 2, z - 1, N, N)];
203 const double rf = 0.5 * ((heavy(z) ? ratio : 1.0) + (heavy(z - 1) ? ratio : 1.0));
204 r.pErr = std::fmax(r.pErr, std::fabs(dp + g * rf - off) / (g * ratio));
205 }
206 return r;
207}
208
209void gateHydrostatic() {
210 std::printf(
211 "\n=== T1 hydrostatic at ratio 1000, mu = 0: dP/dz == -rho_f g. Staggered: exact. "
212 "Collocated:\n"
213 " the FACE field at round-off; the balance is an exact fixed point, so the cell "
214 "field\n"
215 " and dP/dz are the transient from P = 0 -- reported, and required to DECAY.\n");
216 struct Case {
217 const char* name;
218 bool periodic, colour;
219 };
220 const Case cases[3] = {{"periodic, hand-set rho, frozen", true, false},
221 {"walled, hand-set rho ", false, false},
222 {"walled, through C (frozen) ", false, true}};
223 for (const Case& c : cases) {
224 const auto st = hydrostatic<Stag>(1000.0, 0.0, 100, c.periodic, c.colour);
225 const auto co = hydrostatic<Colo>(1000.0, 0.0, 100, c.periodic, c.colour, 0);
226 const auto co4 = hydrostatic<Colo>(1000.0, 0.0, 400, c.periodic, c.colour, 0);
227 std::printf(" %s staggered cell %.3e face %.3e dP/dz %.3e cb %.3e it %ld\n", c.name,
228 st.cellU, st.faceU, st.pErr, st.cb, st.iters);
229 std::printf(
230 " %s COLLOCATED cell %.3e face %.3e dP/dz %.3e cb %.3e it %ld (400 steps: "
231 "cell %.3e dP/dz %.3e)\n",
232 c.name, co.cellU, co.faceU, co.pErr, co.cb, co.iters, co4.cellU, co4.pErr);
233 CHECK(st.faceU < 1e-12);
234 CHECK(st.pErr < 1e-11);
235 // Collocated: the FACE field is the projected field of a velocity that is itself the decaying
236 // transient, so it sits at the pressure solve's round-off of that transient. Walled: the
237 // note's 1e-12 holds (3.2e-13 measured, double operator storage). Periodic: FROZEN at WO-V1
238 // (2026-09-25) at 10x the measured 1.79e-10 (doc §9 G4 measure-then-freeze; the note's 1e-10
239 // missed by 1.8x; the periodic box carries a 2.3e-1 cell checkerboard transient at mu = 0).
240 CHECK(co.faceU < (c.periodic ? 1.8e-9 : 1e-12));
241 CHECK(co4.cellU < co.cellU); // the transient decays ...
242 CHECK(co4.pErr < co.pErr); // ... in the velocity and in the pressure gradient
243 CHECK(co.iters < 200); // rule 3b: no capped solve
244 }
245 std::printf(
246 "\n The mu sweep (walled, hand-set rho, ratio 1000, 100 steps). The staggered balance is\n"
247 " only approached at mu > 0 (A = rho_f/dt - mu*Lap does not commute with the discrete\n"
248 " gradient at variable rho: WO-P's mu*dt^2 residue).\n");
249 for (double mu : {0.0, 1e-3, 1e-2, 1e-1}) {
250 const auto st = hydrostatic<Stag>(1000.0, mu, 100, false, false);
251 const auto co = hydrostatic<Colo>(1000.0, mu, 100, false, false, 0);
252 std::printf(
253 " mu = %-6g staggered face %.3e dP/dz %.3e | COLLOCATED face %.3e "
254 "dP/dz %.3e\n",
255 mu, st.faceU, st.pErr, co.faceU, co.pErr);
256 CHECK(co.faceU < 1e-12); // measured 4.2e-14 .. 3.2e-13 (double operator storage)
257 }
258}
259
260void gateInvisibleSubspace() {
261 std::printf(
262 "\n=== T1b the invisible subspace: a CELL checkerboard is annihilated by centerToFace\n"
263 " (1/2(U(i)+U(i-1)) kills the odd-even mode), so the approximate projection cannot\n"
264 " see it and cannot remove it. CONTROL = the VALIDATED constant-density collocated\n"
265 " path with a plain body force, where rung V8 is completely inert. The V8 column's\n"
266 " transient lives largely in that mode, and must decay.\n");
267 const int N = 8, NZ = 24;
268 const double g = 0.1;
269 for (double mu : {0.0, 0.01}) {
270 Colo s(N, N, NZ);
271 s.setRho(1.0);
272 s.setMu(mu);
273 s.setDt(1.0);
274 s.setVelocityResidualTolerance(0.0);
275 s.setDomainBc(4, 1, 0, 0, 0);
276 s.setDomainBc(5, 1, 0, 0, 0);
277 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * NZ, 10.0));
278 s.setBodyForce(0.0, 0.0, -g);
279 for (int k = 0; k < 100; ++k)
280 s.step();
281 std::printf(
282 " CONTROL constant-rho collocated, body force, mu = %-5g cell |u| %.3e face |uf| %.3e"
283 " cb %.3e\n",
284 mu, maxVel(s), maxFaceVel(s), checkerboardZ(s.getVelocity(2), N, N, NZ));
285 }
286 const auto a = hydrostatic<Colo>(1000.0, 0.0, 100, false, false, 0);
287 const auto b = hydrostatic<Colo>(1000.0, 0.0, 400, false, false, 0);
288 std::printf(
289 " V8 walled ratio 1000 mu = 0: 100 steps cell %.3e (cb %.3e), 400 steps cell %.3e "
290 "(cb %.3e) -> decayed %.2fx\n",
291 a.cellU, a.cb, b.cellU, b.cb, a.cellU / std::fmax(b.cellU, 1e-300));
292 CHECK(b.cellU < a.cellU); // the mode DECAYS; it is a transient remnant, not an instability
293}
294
295// ---------------------------------------------------------------- T2: the exactness gate
296//
297// Volume fractions of a sphere: exact in z, sub x sub sampling in (x,y) (the sampler of
298// test_vof_surface_tension.cpp, kept identical so the two gates are on the same scene).
299std::vector<double> sphereC(int n, double R, double cx, double cy, double cz, int sub = 24) {
300 std::vector<double> C((std::size_t)n * n * n, 0.0);
301 const double w = 1.0 / sub;
302 for (int k = 0; k < n; ++k)
303 for (int j = 0; j < n; ++j)
304 for (int i = 0; i < n; ++i) {
305 double acc = 0.0;
306 for (int b = 0; b < sub; ++b)
307 for (int a = 0; a < sub; ++a) {
308 const double px = i + (a + 0.5) * w, py = j + (b + 0.5) * w;
309 const double r2 = R * R - (px - cx) * (px - cx) - (py - cy) * (py - cy);
310 if (r2 <= 0.0)
311 continue;
312 const double h = std::sqrt(r2);
313 const double lo = std::fmax(cz - h, (double)k), hi = std::fmin(cz + h, (double)k + 1);
314 if (hi > lo)
315 acc += hi - lo;
316 }
317 C[idx(i, j, k, n, n)] = acc / (sub * sub);
318 }
319 return C;
320}
321
322template <class S>
323std::unique_ptr<S> makeDroplet(int n, double R, double sigma, double mu, double rhoG, double rhoL,
324 double kappa, double dtFac, int bfp = -1) {
325 auto s = std::make_unique<S>(n, n, n);
326 s->setRho(rhoL);
327 s->setMu(mu);
328 s->setDt(1.0);
329 s->setVelocityResidualTolerance(0.0);
330 s->setPressureGeometry(std::vector<double>((std::size_t)n * n * n, 10.0));
331 s->setPressureChebyshev(true, 500, 1e-14);
332 s->enableVof();
333 s->setVof(sphereC(n, R, n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11));
334 s->setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {rhoG, rhoL - rhoG});
335 s->setSurfaceTension(sigma);
336 if (kappa >= 0.0)
337 s->setVofKappaConstant(kappa);
338 s->setDt(dtFac * s->capillaryDt());
339 if (bfp >= 0)
340 s->setBalancedForceProjection(bfp == 1);
341 return s;
342}
343
344struct DropResult {
345 double cellU = 0, faceU = 0, face30 = 0;
346 long iters = 0;
347 bool threw = false;
348 std::string what;
349};
350
351template <class S>
352DropResult runDroplet(int n, double R, double sigma, double mu, double ratio, double kappa,
353 double dtFac, int steps, int bfp = -1) {
354 DropResult r;
355 try {
356 auto s = makeDroplet<S>(n, R, sigma, mu, 1.0, ratio, kappa, dtFac, bfp);
357 for (int k = 0; k < steps; ++k) {
358 s->step();
359 if (k + 1 == 30)
360 r.face30 = maxFaceVel(*s);
361 }
362 r.cellU = maxVel(*s);
363 r.faceU = maxFaceVel(*s);
364 r.iters = s->lastPressureIterations();
365 } catch (const std::exception& e) {
366 r.threw = true;
367 r.what = e.what();
368 }
369 return r;
370}
371
372void gateStaticDroplet() {
373 std::printf(
374 "\n=== T2 static droplet, CONSTANT kappa: the face force is exactly grad(sigma*kappa*C), "
375 "so\n"
376 " the balance is an exact fixed point. On the STAGGERED grid the force goes through\n"
377 " A = rho_f/dt - mu*Lap, which does not commute with the discrete gradient at "
378 "variable\n"
379 " rho or near walls, so the balance is only approached (WO-P's mu*dt^2 residue). The\n"
380 " collocated step (balanced-force projection OFF) no longer annihilates "
381 "constant-kappa\n"
382 " CSF per step: a decaying transient residue remains (doc §4.6.5 model: 2.2e-4 at\n"
383 " ratio 1 after 30 steps, 1.6e-5 at ratio 1000, decaying to 2.4e-17 / 2.1e-7 in 300\n"
384 " steps; 1-2 decades above staggered at ratio 1000). Exact from step 1 needs the\n"
385 " balanced-force projection. Gated here: no throw, decay 30 -> 90 steps, and the\n"
386 " 30-step face frozen at 10x its WO-V1 value.\n");
387 // FROZEN at WO-V1 (2026-09-25, host-openmp): the collocated face |uf| after 30 steps, measured
388 // 9.99e-5 / 2.40e-4 / 1.46e-4 / 6.22e-5 for ratio 1 / 10 / 100 / 1000 (mu = 0.1); the gate is
389 // 10x that (doc §9 G4-OFF).
390 const double ratios[4] = {1.0, 10.0, 100.0, 1000.0};
391 const double frozen30[4] = {9.99e-4, 2.40e-3, 1.46e-3, 6.22e-4};
392 for (int q = 0; q < 4; ++q) {
393 const double ratio = ratios[q];
394 const auto st = runDroplet<Stag>(32, 8.0, 1.0, 0.1, ratio, 0.25, 0.5, 30);
395 const auto co = runDroplet<Colo>(32, 8.0, 1.0, 0.1, ratio, 0.25, 0.5, 90, 0);
396 if (st.threw)
397 std::printf(" ratio %6g staggered THREW: %.90s\n", ratio, st.what.c_str());
398 else
399 std::printf(" ratio %6g staggered cell %.4e face %.4e it %ld\n", ratio, st.cellU,
400 st.faceU, st.iters);
401 if (co.threw)
402 std::printf(" ratio %6g COLLOCATED THREW: %.90s\n", ratio, co.what.c_str());
403 else
404 std::printf(
405 " ratio %6g COLLOCATED face %.4e (30 steps) -> %.4e (90 steps), cell %.4e "
406 "it %ld\n",
407 ratio, co.face30, co.faceU, co.cellU, co.iters);
408 if (ratio == 1.0) // the exactness statement of the staggered grid
409 CHECK(!st.threw && st.faceU < 1e-14);
410 CHECK(!co.threw);
411 CHECK(co.faceU < co.face30);
412 CHECK(co.face30 <= frozen30[q]);
413 CHECK(co.iters < 500);
414 }
415 std::printf(
416 "\n The ratio-1000 mu sweep (the WO-T face-acceleration form went unstable at mu = 0.1,\n"
417 " ~4x per step; the predictor form is stable at every mu dt). Decay 30 -> 90 steps.\n");
418 // FROZEN at WO-V1: 30-step collocated face for mu = 0 / 0.01 / 0.1, measured 6.74e-5 / 6.50e-5 /
419 // 6.22e-5; the gate is 10x that.
420 const double mus[3] = {0.0, 0.01, 0.1};
421 const double frozenMu[3] = {6.74e-4, 6.50e-4, 6.22e-4};
422 for (int q = 0; q < 3; ++q) {
423 const double mu = mus[q];
424 const auto st = runDroplet<Stag>(32, 8.0, 1.0, mu, 1000.0, 0.25, 0.5, 30);
425 const auto co = runDroplet<Colo>(32, 8.0, 1.0, mu, 1000.0, 0.25, 0.5, 90, 0);
426 std::printf(
427 " ratio 1000, mu = %-5g staggered face %.4e%s | COLLOCATED face %.4e -> "
428 "%.4e%s\n",
429 mu, st.faceU, st.threw ? " (THREW)" : "", co.face30, co.faceU, co.threw ? " (THREW)" : "");
430 CHECK(!co.threw);
431 CHECK(co.faceU < co.face30);
432 CHECK(co.face30 <= frozenMu[q]);
433 }
434}
435
436// ---------------------------------------------------------------- T1/T1b/T2 with the option ON
437//
438// The balanced-force projection (doc/collocated_varrho_forces.md §4.6) is the DEFAULT on this path
439// (U2): once per step it moves the gradient part of the forces into P with the projection's own
440// operator, so a static balance is exact from the FIRST step. These are the settled V8 exactness
441// statements (constant-kappa CSF annihilated; hydrostatic dP/dz exact) at the solver default.
442void gateBalancedOn() {
443 std::printf("\n=== T1/T1b/T2 ON (the V8 default): static balances exact from step 1\n");
444 struct Case {
445 const char* name;
446 bool periodic, colour;
447 };
448 const Case cases[3] = {{"periodic, hand-set rho, frozen", true, false},
449 {"walled, hand-set rho ", false, false},
450 {"walled, through C (frozen) ", false, true}};
451 // The PERIODIC box at the default Chebyshev rtol 1e-9 misses by more than 10x (after 1 step: face
452 // 5.6e-10, cell 2.0e-9, dP/dz 4.3e-11; after 100: face 9.9e-12) -- and passes at 1e-14 (face
453 // 3.0e-14, cell 8.9e-14, dP/dz 1.2e-14), so it is the solve tolerance, not the scheme (doc §9 G4:
454 // re-run at Chebyshev 1e-14; only a miss there is conceptual). It is gated at 1e-14; the walled
455 // cases pass at the default. WO-P5 (the increment solve, full-RHS stop) does not change the
456 // first step -- one solve at rtol 1e-9 relative to the full right-hand side -- and after it the
457 // pre-projection skips (1e-9: 100 steps face 2.2e-10; 1e-14: 13 iterations at step 1, then 0,
458 // never the cap).
459 for (const Case& c : cases)
460 for (int steps : {1, 100}) {
461 const auto co = hydrostatic<Colo>(1000.0, 0.0, steps, c.periodic, c.colour, -1,
462 c.periodic ? 1e-14 : -1.0);
463 std::printf(" T1 %s %3d step(s) cell %.3e face %.3e dP/dz %.3e it %ld\n", c.name, steps,
464 co.cellU, co.faceU, co.pErr, co.iters);
465 CHECK(co.faceU < 1e-12);
466 CHECK(co.cellU < 1e-10);
467 CHECK(co.pErr < 1e-10);
468 }
469 { // T1 periodic at the DEFAULT Chebyshev rtol (1e-9), 100 steps: the frozen step-1 error.
470 // Why 1e-9 and not the 1e-12 above: the pre-projection solves once at step 1 to rtol * |b|
471 // relative to the FULL right-hand side (WO-P5), and |b| = |D(O c beta)| is large here (the
472 // ratio-1000 hydrostatic force), so rtol * |b| leaves ~1e-10 in X; from step 2 the previous
473 // split already meets that test and the solve is SKIPPED, which freezes that step-1 error in
474 // the face field instead of letting later solves shave it. Measured 2.2e-10 (WO-P5); gated
475 // at 1e-9 so a real regression (a second copy of the balanced pressure, a lost skip) shows.
476 // The pre-projection tolerance stays EQUAL to the main solve's (DECIDED 2026-09-25): do not
477 // tighten it to pass a tighter gate; the 1e-14 cases above are the exactness statement.
478 const auto d = hydrostatic<Colo>(1000.0, 0.0, 100, true, false, -1, -1.0);
479 std::printf(" T1 periodic at the default rtol, 100 steps: face %.3e cell %.3e dP/dz %.3e\n",
480 d.faceU, d.cellU, d.pErr);
481 CHECK(d.faceU < 1e-9);
482 }
483 { // T1b: the walled column's cell field is exact too (the transient never exists)
484 const auto a = hydrostatic<Colo>(1000.0, 0.0, 100, false, false);
485 std::printf(" T1b walled ratio 1000, 100 steps: cell %.3e (cb %.3e)\n", a.cellU, a.cb);
486 CHECK(a.cellU <= 1e-10);
487 }
488 const double ratios[4] = {1.0, 10.0, 100.0, 1000.0};
489 for (double ratio : ratios) {
490 const auto co = runDroplet<Colo>(32, 8.0, 1.0, 0.1, ratio, 0.25, 0.5, 30);
491 std::printf(" T2 ratio %6g cell %.4e face %.4e it %ld%s\n", ratio, co.cellU, co.faceU,
492 co.iters, co.threw ? " THREW" : "");
493 const double tol = (ratio == 1.0) ? 1e-14 : 1e-13;
494 CHECK(!co.threw && co.faceU < tol && co.cellU < tol);
495 }
496 for (double mu : {0.0, 0.01, 0.1}) {
497 const auto st = runDroplet<Stag>(32, 8.0, 1.0, mu, 1000.0, 0.25, 0.5, 30);
498 const auto co = runDroplet<Colo>(32, 8.0, 1.0, mu, 1000.0, 0.25, 0.5, 30);
499 std::printf(
500 " T2 ratio 1000 mu %-5g staggered (OFF) face %.4e | COLLOCATED face %.4e "
501 "cell %.4e\n",
502 mu, st.faceU, co.faceU, co.cellU);
503 CHECK(!co.threw && co.faceU < 1e-13);
504 CHECK(co.faceU < st.faceU);
505 }
506}
507
508// ---------------------------------------------------------------- T3: constant-density equivalence
509void gateUniformReduction() {
510 std::printf(
511 "\n=== T3 uniform rho, mu = 0: the V8 face-force predictor IS the validated cell-force one\n"
512 " (avg_f(P(i)-P(i-s)) is the central difference) -> agreement at round-off\n");
513 const int N = 16, NZ = 8;
514 std::vector<std::vector<double>> uu, pp;
515 for (int var = 0; var < 2; ++var) {
516 Colo s(N, N, NZ);
517 s.setRho(2.0);
518 s.setMu(0.0);
519 s.setDt(4.0);
520 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * NZ, 10.0));
521 s.setBodyForce(1e-2, 3e-3, 0.0);
522 if (var) {
523 s.addField("rho");
524 s.setField("rho", std::vector<double>((std::size_t)N * N * NZ, 2.0));
525 s.setDensityMode(true);
526 s.setPressurePcg(true, 400, 1e-14);
527 } else {
528 s.setPressurePcg(true, 400, 1e-14);
529 }
530 for (int k = 0; k < 30; ++k)
531 s.step();
532 uu.push_back(s.getVelocity(0));
533 pp.push_back(s.getPressure());
534 }
535 const double du = maxAbsDiff(uu[0], uu[1]), dp = maxAbsDiff(pp[0], pp[1]);
536 std::printf(" max|du| %.3e (|u| %.3e) max|dP| %.3e (|P| %.3e)\n", du, maxAbs(uu[0]), dp,
537 maxAbs(pp[0]));
538 CHECK(du < 1e-13 * std::fmax(maxAbs(uu[0]), 1e-30));
539 CHECK(dp < 1e-11 * std::fmax(maxAbs(pp[0]), 1e-30));
540}
541
542// ---------------------------------------------------------------- T4: the bridge (G6)
543void gateBridge() {
544 std::printf(
545 "\n=== T4 the bridge (G6): the collocated colour transport is the SAME kernel on the SAME\n"
546 " faces. uf_(i) sits at i-1/2 = flow's staggered u(i), so a staggered solver handed\n"
547 " the collocated projected face field must reproduce the colour BITWISE.\n");
548 const int N = 24;
549 const int steps = 20;
550 // A collocated run whose cell velocity is a prescribed smooth solenoidal field; one step()
551 // projects it onto uf_/vf_/wf_, and every advect_vof afterwards reuses that frozen face field.
552 Colo co(N, N, N);
553 co.setRho(1.0);
554 co.setMu(0.0);
555 co.setDt(0.1); // the ABC field peaks at ~1.7, so the interface CFL is ~0.17 < the WY cap 0.25
556 co.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, 10.0));
557 co.setPressurePcg(true, 400, 1e-14);
558 co.enableVof();
559 {
560 std::vector<double> c0((std::size_t)N * N * N, 0.0);
561 for (int z = 0; z < N; ++z)
562 for (int y = 0; y < N; ++y)
563 for (int x = 0; x < N; ++x) {
564 const double dx = x + 0.5 - (N / 2 + 0.13), dy = y + 0.5 - (N / 2 + 0.27),
565 dz = z + 0.5 - (N / 2 + 0.11);
566 c0[idx(x, y, z, N, N)] = (dx * dx + dy * dy + dz * dz < 36.0) ? 1.0 : 0.0;
567 }
568 co.setVof(c0);
569 }
570 { // an ABC-flow cell velocity: every component varies along all three axes (a uniform or a
571 // solid-body field would not detect the axial half of the index shift)
572 std::vector<double> u((std::size_t)N * N * N), v(u.size()), w(u.size());
573 const double k = 2.0 * M_PI / N;
574 for (int z = 0; z < N; ++z)
575 for (int y = 0; y < N; ++y)
576 for (int x = 0; x < N; ++x) {
577 const double X = x + 0.5, Y = y + 0.5, Z = z + 0.5;
578 u[idx(x, y, z, N, N)] = std::sin(k * Z) + 0.7 * std::cos(k * Y);
579 v[idx(x, y, z, N, N)] = std::sin(k * X) + 0.7 * std::cos(k * Z);
580 w[idx(x, y, z, N, N)] = std::sin(k * Y) + 0.7 * std::cos(k * X);
581 }
582 co.setField("u", u);
583 co.setField("v", v);
584 co.setField("w", w);
585 }
586 co.step(); // populates + projects uf_/vf_/wf_ (and advances C once)
587 const std::vector<double> uf = co.getFaceVelocity(0), vf = co.getFaceVelocity(1),
588 wf = co.getFaceVelocity(2);
589 const std::vector<double> c1 = co.getVof();
590 std::printf(" collocated max|div(open uf)| after the seeding step = %.3e\n",
591 co.maxOpenDivergence());
592 CHECK(co.maxOpenDivergence() < 1e-10);
593
594 // --- (a) the same faces on the STAGGERED solver, whose u(i) IS the low face -------------------
595 Stag st(N, N, N);
596 st.setRho(1.0);
597 st.setMu(0.0);
598 st.setDt(0.1);
599 st.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, 10.0));
600 st.enableVof();
601 st.setVof(c1);
602 st.setField("u", uf);
603 st.setField("v", vf);
604 st.setField("w", wf);
605 st.setVofStepParity(co.vofStepParity()); // the sweep permutation must start at the same index
606
607 for (int k = 0; k < steps; ++k) {
608 co.advectVofKinematic(0.1);
609 st.advectVofKinematic(0.1);
610 }
611 const double dC = maxAbsDiff(co.getVof(), st.getVof());
612 std::printf(
613 " collocated vs staggered on the SAME face field, %d kinematic steps: max|dC| = "
614 "%.3e\n",
615 steps, dC);
616 CHECK(dC == 0.0);
617 // and the colour actually moved (a bitwise match on a frozen field would be vacuous)
618 std::printf(" max|C - C0| over the run = %.3e (sum C: collocated %.15e, staggered %.15e)\n",
619 maxAbsDiff(co.getVof(), c1), co.vofDiagnostics().sumC, st.vofDiagnostics().sumC);
620 CHECK(maxAbsDiff(co.getVof(), c1) > 0.1);
621}
622
623// ---------------------------------------------------------------- T5: scope
624void gateScope() {
625 std::printf("\n=== T5 scope: what the rung refuses, loudly\n");
626 const int N = 16;
627 std::vector<double> solid((std::size_t)N * N * N);
628 for (int z = 0; z < N; ++z)
629 for (int y = 0; y < N; ++y)
630 for (int x = 0; x < N; ++x) {
631 const double dx = x + 0.5 - N / 2.0, dy = y + 0.5 - N / 2.0, dz = z + 0.5 - N / 2.0;
632 solid[idx(x, y, z, N, N)] = std::sqrt(dx * dx + dy * dy + dz * dz) - 4.0;
633 }
634 { // enable_vof with an immersed solid
635 Colo s(N, N, N);
636 s.setRho(1.0);
637 s.setMu(0.01);
638 s.setDt(1.0);
639 s.setSolid(solid, true);
640 bool threw = false;
641 try {
642 s.enableVof();
643 } catch (const std::exception&) {
644 threw = true;
645 }
646 std::printf(" enable_vof + immersed solid (collocated) -> throws: %s\n",
647 threw ? "yes" : "NO");
648 CHECK(threw);
649 }
650 { // variable density with an immersed solid: caught at the first project()
651 Colo s(N, N, N);
652 s.setRho(1.0);
653 s.setMu(0.01);
654 s.setDt(1.0);
655 s.setSolid(solid, true);
656 s.addField("rho");
657 s.setField("rho", std::vector<double>((std::size_t)N * N * N, 1.0));
658 s.setDensityMode(true);
659 bool threw = false;
660 try {
661 s.step();
662 } catch (const std::exception&) {
663 threw = true;
664 }
665 std::printf(" set_density_mode + immersed solid -> throws: %s\n",
666 threw ? "yes" : "NO");
667 CHECK(threw);
668 }
669 { // momentum consistency stays staggered-only
670 Colo s(N, N, N);
671 s.setRho(1.0);
672 s.setMu(0.01);
673 s.setDt(1.0);
674 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, 10.0));
675 bool threw = false;
676 try {
677 s.enableVofMomentum(1.0, 10.0);
678 } catch (const std::exception&) {
679 threw = true;
680 }
681 std::printf(" enable_vof_momentum (collocated) -> throws: %s\n",
682 threw ? "yes" : "NO");
683 CHECK(threw);
684 }
685 { // the harmonic rho_f knob is not wired into the face acceleration
686 Colo s(N, N, N);
687 s.setRho(1.0);
688 s.setMu(0.01);
689 s.setDt(1.0);
690 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, 10.0));
691 s.addField("rho");
692 s.setField("rho", std::vector<double>((std::size_t)N * N * N, 1.0));
693 s.setDensityMode(true);
694 s.setRhoFaceHarmonic(true);
695 bool threw = false;
696 try {
697 s.step();
698 } catch (const std::exception&) {
699 threw = true;
700 }
701 std::printf(" set_rho_face_harmonic (collocated) -> throws: %s\n",
702 threw ? "yes" : "NO");
703 CHECK(threw);
704 }
705 { // the pressure force lives in the implicit predictor: the non-incremental step has none
706 Colo s(N, N, N);
707 s.setRho(1.0);
708 s.setMu(0.01);
709 s.setDt(1.0);
710 s.setPressureGeometry(std::vector<double>((std::size_t)N * N * N, 10.0));
711 s.addField("rho");
712 s.setField("rho", std::vector<double>((std::size_t)N * N * N, 1.0));
713 s.setDensityMode(true);
714 s.setIncrementalPressure(false);
715 bool threw = false;
716 try {
717 s.step();
718 } catch (const std::exception&) {
719 threw = true;
720 }
721 std::printf(" non-incremental pressure (collocated V8) -> throws: %s\n",
722 threw ? "yes" : "NO");
723 CHECK(threw);
724 }
725}
726
727} // namespace
728
729int main(int argc, char** argv) {
730 Kokkos::initialize(argc, argv);
731 {
732 gateHydrostatic();
733 gateInvisibleSubspace();
734 gateStaticDroplet();
735 gateBalancedOn();
736 gateUniformReduction();
737 gateBridge();
738 gateScope();
739 }
740 Kokkos::finalize();
741 if (failures) {
742 std::fprintf(stderr, "\n%d CHECK(s) failed\n", failures);
743 return 1;
744 }
745 std::printf("\nAll rung V8 collocated gates passed.\n");
746 return 0;
747}
Weymouth-Yue split advection of a colour field on an extended (inner + ghost) block.
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
float solid
Definition owner_gate.py:49
long L3(int x, int y, int z, I3 e)
list iters
Definition prof.py:105
hydrostatic(ratio, mu=0.0, steps=100, g=0.1, dt=1.0, N=8, NZ=24)
static constexpr int NZ
static constexpr int N
static double maxAbs(const std::vector< double > &a)
static double maxAbsDiff(const std::vector< double > &a, const std::vector< double > &b)
static std::vector< double > colour(int m)
#define CHECK(cond)