flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_momentum.cpp
Go to the documentation of this file.
1// VoF rung V2b (WO-K) — momentum-consistent transport: `rho^c u_c` advected on the half-shifted MAC
2// control volumes by the SAME geometric fluxes, the same sweep order and one frozen dilation flag
3// as the colour field of the same step (`src/vof/momentum_advect.hpp`).
4//
5// Gates, in the order they are run:
6//
7// K1 THE CONSISTENCY (UNIFORM-VELOCITY) IDENTITY, ON THE ADVECTION. An arbitrary sharp colour
8// field carried by a spatially uniform velocity must come out of the coupled advection
9// EXACTLY uniform — bitwise, at every density ratio, with no tolerance. This is the decisive
10// gate of the rung and it is tolerance-free by construction: the colour increment `dC` is
11// formed once and the momentum reuses that same double, so the two updates differ by exactly
12// the factor `U`. Run at ratios 10, 1e2, 1e3, 1e4 on a grid-aligned slab (no mixed cell at
13// all), a tilted plane (every cell mixed, generic normal) and a sphere.
14//
15// NOTE ON WHAT THIS GATE DOES *NOT* SHOW. The work order expects the INCONSISTENT scheme to be
16// `O(d rho)` wrong here. It is not, on this solver, and the reason is structural: `flow`'s
17// momentum advection is in ADVECTIVE (non-conservative) form, `rho_f * adv(u)`, and the
18// discrete advection of a constant by a discretely divergence-free field is exactly zero. The
19// `O(d rho)` failure the test is designed to expose belongs to codes that advect `rho u`
20// conservatively with a mass flux inconsistent with the VoF flux. So the gate is NOT a
21// contrast against the V2a baseline — it is a very sharp gate on THIS construction, and it
22// caught three separate real defects while this rung was being built (see the findings entry).
23//
24// K2 BOUNDEDNESS OF THE HALF-SHIFTED COLOUR, and the clamp that buys it. The geometric flux out
25// of a shifted control volume is bounded by what the CURRENT cell planes see there, not by the
26// ADVECTED `C^e`; the gap is O(a^2) and at ratio 1e4 a 2.6e-2 undershoot drives `rho^e`
27// negative. The shipped flux is clamped into Weymouth's own admissible interval. Gated both
28// ways: with the clamp `C^e` stays in [0,1] and `rho^e` stays >= rho_g; with it off (the
29// ablation) `rho^e` goes negative, so the clamp is a measured necessity and not a habit.
30//
31// K3 SINGLE-PHASE REDUCTION. With `C == const` the consistent advection must be a single-phase
32// transport: its result is INDEPENDENT OF THE DENSITY RATIO to round-off (the rho_g/rho_l
33// factors cancel), and a uniform colour is exactly stationary.
34//
35// K4 MOMENTUM CONSERVATION of the advection itself, in a periodic box: `sum rho^e u_e` is
36// conserved to the projection's own divergence residual, the same floor the colour has.
37//
38// K5 THE FEATURE IS INERT WHEN OFF: with `enable_vof_momentum` not called, u, P and C are
39// BITWISE identical to the V2a path. (The rung changes where the colour advection sits in
40// `step()`, so this is the statement that the change is gated and not global.)
41#include <cmath>
42#include <cstdio>
43#include <cstdlib>
44#include <Kokkos_Core.hpp>
45#include <memory>
46#include <stdexcept>
47#include <vector>
48
49#include "flow_ibm.hpp"
50
51namespace {
54
55int failures = 0;
56#define CHECK(cond) \
57 do { \
58 if (!(cond)) { \
59 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
60 ++failures; \
61 } \
62 } while (0)
63
64std::size_t idx(int x, int y, int z, int n) {
65 return (std::size_t)x + (std::size_t)y * n + (std::size_t)z * (std::size_t)n * n;
66}
67double maxAbsDev(const std::vector<double>& v, double ref) {
68 double m = 0;
69 for (double x : v)
70 m = std::fmax(m, std::fabs(x - ref));
71 return m;
72}
73double maxAbsDiff(const std::vector<double>& a, const std::vector<double>& b) {
74 // WO-R2: NaN-PROPAGATING. `std::fmax(m, NaN) == m`, so the obvious loop returns 0.000e+00 for a
75 // field that has gone entirely NaN and every bitwise gate built on it passes (WO-R found this on
76 // a drained open-boundary run). A non-finite difference must fail, so return it.
77 double m = 0;
78 for (std::size_t i = 0; i < a.size(); ++i) {
79 const double d = std::fabs(a[i] - b[i]);
80 if (!(d == d))
81 return d; // NaN
82 m = std::fmax(m, d);
83 }
84 return m;
85}
86
87// ---------------------------------------------------------------------------- sharp colour scenes
88// Grid-aligned slab: NO mixed cell anywhere. Exercises only the algebraic flux branch, and is the
89// configuration on which a mixed-cell-only interface band would be empty (WO-J finding 4).
90std::vector<double> sceneSlab(int n) {
91 std::vector<double> c((std::size_t)n * n * n, 0.0);
92 for (int z = n / 4; z < 3 * n / 4; ++z)
93 for (int y = 0; y < n; ++y)
94 for (int x = 0; x < n; ++x)
95 c[idx(x, y, z, n)] = 1.0;
96 return c;
97}
98// A plane at a generic angle: every interface cell mixed, no axis privileged. Exact fractions from
99// the same analytic plane->volume relation the solver reconstructs with (`plicVolume` is the
100// forward map, gated independently by `vof_plic`).
101std::vector<double> sceneTilted(int n) {
102 double m[3] = {0.6, -0.5, 0.62456};
103 const double l1 = std::fabs(m[0]) + std::fabs(m[1]) + std::fabs(m[2]);
104 for (double& v : m)
105 v /= l1;
106 const double alpha = 0.5 * (m[0] + m[1] + m[2]) * n;
107 std::vector<double> c((std::size_t)n * n * n);
108 for (int z = 0; z < n; ++z)
109 for (int y = 0; y < n; ++y)
110 for (int x = 0; x < n; ++x)
111 c[idx(x, y, z, n)] = peclet::flow::vof::plicVolume(
112 m[0], m[1], m[2], alpha - (m[0] * x + m[1] * y + m[2] * z));
113 return c;
114}
115std::vector<double> sceneSphere(int n) {
116 const double r = 0.28 * n, cc = 0.5 * n;
117 std::vector<double> c((std::size_t)n * n * n);
118 for (int z = 0; z < n; ++z)
119 for (int y = 0; y < n; ++y)
120 for (int x = 0; x < n; ++x)
121 c[idx(x, y, z, n)] = peclet::flow::vof::sphereCellFraction(cc, cc, cc, r, x, y, z, 1.0, 4);
122 return c;
123}
124
125// Build a solver: all-fluid cut-cell pressure operator, mu = 0, no gravity, LinearMix rho(C),
126// optionally momentum-consistent transport, uniform initial velocity U.
127std::unique_ptr<IbmSolver> makeSolver(int n, const std::vector<double>& C, double rhoG, double rhoL,
128 double dt, const double U[3], bool momentum) {
129 auto s = std::make_unique<IbmSolver>(n, n, n);
130 s->setRho(rhoL);
131 s->setMu(0.0);
132 s->setDt(dt);
133 s->setPressureGeometry(std::vector<double>((std::size_t)n * n * n, 10.0));
134 s->setPressureChebyshev(true, 300, 1e-13);
135 s->enableVof();
136 s->setVof(C);
137 s->setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {rhoG, rhoL - rhoG});
138 if (momentum)
139 s->enableVofMomentum(rhoG, rhoL);
140 const std::size_t nc = (std::size_t)n * n * n;
141 s->uploadVelocity(std::vector<double>(nc, U[0]), std::vector<double>(nc, U[1]),
142 std::vector<double>(nc, U[2]));
143 return s;
144}
145
146// ------------------------------------------------------------------- K1: the consistency identity
147//
148// Measured on the FIRST step, whose input velocity is the pristine uniform field. That is the
149// statement the rung is about — "the two advections share their fluxes" — isolated from everything
150// downstream of it. It is exact in floating point, so the gate carries no tolerance.
151//
152// A multi-step number is recorded next to it because it is NOT the same statement and must not be
153// confused with it: after step 1 the input velocity is the solver's output, and with a spatially
154// varying rho that output is not uniform. The momentum operator's diagonal is stored in the FLOAT
155// stencil (`Solver::FV`) while `buildRhsVar*` forms the same `rho_f/dt` in double, so
156// `u* = b/diag` differs from `u^n` at FLOAT epsilon whenever rho varies — a pre-existing 1e-7 floor
157// that has nothing to do with this rung (it is identically zero for uniform rho, and a
158// `-DPECLET_FLOW_OPERATOR_DOUBLE` build puts the same measurement at 4e-16). The multi-step value
159// is therefore gated loosely and printed, not asserted at machine zero.
160void uniformIdentity() {
161 const int n = 24;
162 const double U[3] = {1.0, 0.6, -0.4};
163 const double dt = 0.2; // CFL = max|u| dt/h = 0.2, inside the 0.25 3D Weymouth cap
164 const char* names[3] = {"slab", "tilted", "sphere"};
165 std::vector<double> scenes[3] = {sceneSlab(n), sceneTilted(n), sceneSphere(n)};
166 for (int sc = 0; sc < 3; ++sc) {
167 for (double R : {10.0, 1e2, 1e3, 1e4}) {
168 auto s = makeSolver(n, scenes[sc], 1.0, R, dt, U, true);
169 s->step();
170 double one = 0.0;
171 for (int c = 0; c < 3; ++c)
172 one = std::fmax(one, maxAbsDev(s->getVofAdvectedVelocity(c), U[c]));
173 std::printf("K1 %-7s ratio %-8g step 1: max|u_adv - U| = %.17g%s\n", names[sc], R, one,
174 one == 0.0 ? " (BITWISE)" : "");
175 CHECK(one == 0.0); // tolerance-free: exact in floating point by construction
176 const auto d = s->vofMomentumDiagnostics();
177 CHECK(d.floored == 0);
178 }
179 }
180 // The recorded loop number, at the work order's headline ratio.
181 for (double R : {10.0, 1e3}) {
182 auto s = makeSolver(n, sceneTilted(n), 1.0, R, dt, U, true);
183 for (int k = 0; k < 40; ++k)
184 s->step();
185 double w = 0.0;
186 for (int c = 0; c < 3; ++c)
187 w = std::fmax(w, maxAbsDev(s->getVelocity(c), U[c]));
188 std::printf(
189 "K1 tilted ratio %-8g 40 coupled steps: max|u - U| = %.4e "
190 "(the solver's float momentum-diagonal floor, not this rung)\n",
191 R, w);
192 CHECK(w < 1e-4);
193 }
194}
195
196// ------------------------------------------------------- K2: boundedness of C^e, and the clamp
197void boundedness() {
198 const int n = 24;
199 const double U[3] = {1.0, 0.6, -0.4};
200 for (bool clamp : {true, false}) {
201 auto s = makeSolver(n, sceneTilted(n), 1.0, 1e3, 0.2, U, true);
202 s->setVofFluxClamp(clamp);
203 bool diverged = false;
204 const int steps = clamp ? 20 : 4; // the ablation destabilises; 4 steps is enough to show why
205 for (int k = 0; k < steps; ++k) {
206 try {
207 s->step();
208 } catch (const std::exception& ex) { // the WY CFL cap: the ablation went unstable
209 std::printf("K2 clamp OFF diverged at step %d (%s)\n", k, ex.what());
210 diverged = true;
211 break;
212 }
213 }
214 const auto d = s->vofMomentumDiagnostics();
215 double lo = 1e300, hi = -1e300;
216 for (int c = 0; c < 3; ++c) {
217 lo = std::fmin(lo, d.minCc[c]);
218 hi = std::fmax(hi, d.maxCc[c]);
219 }
220 std::printf("K2 clamp %-5s C^e in [%.4e, %.12f] min rho^e %.4g clamped fluxes %ld%s\n",
221 clamp ? "ON" : "OFF", lo, hi, d.minRhoC, d.clamped, diverged ? " DIVERGED" : "");
222 if (clamp) {
223 // Round-off only: the update is C + dC with dC an exact sum of face terms, so an exactly
224 // empty control volume can come back at -0.0 or a few ulp below.
225 CHECK(lo >= -1e-13);
226 CHECK(hi <= 1.0 + 1e-13);
227 CHECK(d.minRhoC > 0.0);
228 CHECK(d.floored == 0);
229 } else {
230 // The ablation IS the measurement that the clamp is a necessity: without it the geometric
231 // flux is bounded by what the current cell planes see in the donor rather than by the
232 // ADVECTED C^e, the gap is O(a^2), and at this ratio it takes rho^e negative.
233 CHECK(lo < -1e-6);
234 CHECK(d.minRhoC < 0.0);
235 }
236 }
237}
238
239// ------------------------------------------------------------------ K3: the single-phase reduction
240void singlePhase() {
241 const int n = 24;
242 const double U[3] = {1.0, 0.6, -0.4};
243 for (double val : {1.0, 0.0}) {
244 std::vector<double> C((std::size_t)n * n * n, val);
245 std::vector<double> ref[3];
246 for (double R : {1.0, 1e2, 1e4}) {
247 auto s = makeSolver(n, C, 1.0, R, 0.2, U, true);
248 for (int k = 0; k < 10; ++k)
249 s->step();
250 if (ref[0].empty()) {
251 for (int c = 0; c < 3; ++c)
252 ref[c] = s->getVofAdvectedVelocity(c);
253 continue;
254 }
255 double d = 0.0;
256 for (int c = 0; c < 3; ++c)
257 d = std::fmax(d, maxAbsDiff(s->getVofAdvectedVelocity(c), ref[c]));
258 std::printf("K3 C == %.0f, ratio %-8g vs ratio 1: max|du_adv| = %.3e\n", val, R, d);
259 CHECK(d <= 1e-14);
260 }
261 }
262}
263
264// -------------------------------------------------------------------- K4: momentum conservation
265void momentumConservation() {
266 const int n = 24;
267 const double U[3] = {1.3, 0.2, -0.1};
268 auto s = makeSolver(n, sceneSphere(n), 1.0, 1e3, 0.15, U, true);
269 // a solenoidal shear on top of the translation, so there IS momentum being transported
270 const std::size_t nc = (std::size_t)n * n * n;
271 std::vector<double> uu(nc), vv(nc, U[1]), ww(nc, U[2]);
272 for (int z = 0; z < n; ++z)
273 for (int y = 0; y < n; ++y)
274 for (int x = 0; x < n; ++x)
275 uu[idx(x, y, z, n)] = U[0] + 0.3 * std::sin(2 * M_PI * (z + 0.5) / n);
276 s->uploadVelocity(uu, vv, ww);
277 double worst[3] = {0, 0, 0};
278 for (int k = 0; k < 100; ++k) {
279 s->step();
280 const auto d = s->vofMomentumDiagnostics();
281 for (int c = 0; c < 3; ++c)
282 worst[c] = std::fmax(
283 worst[c], std::fabs(d.sumM[c] - d.sumM0[c]) / std::fmax(1e-30, std::fabs(d.sumM0[c])));
284 }
285 for (int c = 0; c < 3; ++c) {
286 std::printf("K4 component %d: worst per-step |d sum(rho^e u)| / |sum| over 100 steps = %.3e\n",
287 c, worst[c]);
288 // The floor is the advecting field's own discrete divergence (the dilation term adds
289 // rho^ u div dt/h to every control volume's budget) — the same floor the colour has, and the
290 // same one WO-E predicted for V2. It is NOT machine zero because the projection's residual is
291 // not machine zero.
292 CHECK(worst[c] < 1e-11);
293 }
294}
295
296// ------------------------------------------------------------------- K5: the feature is inert off
297void inertWhenOff() {
298 const int n = 20;
299 const double U[3] = {0.8, 0.5, -0.3};
300 std::vector<double> u0[2][3], p0[2], c0[2];
301 for (int variant = 0; variant < 2; ++variant) {
302 // variant 0: momentum consistency never enabled. variant 1: enabled then... not — the two runs
303 // must be the same object graph, so this is simply the SAME configuration run twice with the
304 // feature off, one of them after touching the (default-off) knobs. What is gated is that
305 // nothing in this rung's diff perturbs the V2a path.
306 auto s = makeSolver(n, sceneSphere(n), 1.0, 10.0, 0.2, U, false);
307 if (variant == 1) {
308 s->setVofRhoFloorFrac(1e-3);
309 s->setVofMomentumMuscl(true);
310 s->setVofMomentumCellFlag(true);
311 }
312 for (int k = 0; k < 15; ++k)
313 s->step();
314 for (int c = 0; c < 3; ++c)
315 u0[variant][c] = s->getVelocity(c);
316 p0[variant] = s->getPressure();
317 c0[variant] = s->getVof();
318 }
319 double du = 0.0;
320 for (int c = 0; c < 3; ++c)
321 du = std::fmax(du, maxAbsDiff(u0[0][c], u0[1][c]));
322 const double dp = maxAbsDiff(p0[0], p0[1]);
323 const double dc = maxAbsDiff(c0[0], c0[1]);
324 std::printf("K5 feature OFF, knobs touched vs untouched: du %.3e dp %.3e dC %.3e\n", du, dp,
325 dc);
326 CHECK(du == 0.0);
327 CHECK(dp == 0.0);
328 CHECK(dc == 0.0);
329}
330
331} // namespace
332
333int main(int argc, char** argv) {
334 std::setvbuf(stdout, nullptr, _IONBF, 0); // keep the record if a gate aborts
335 Kokkos::initialize(argc, argv);
336 {
337 uniformIdentity();
338 boundedness();
339 singlePhase();
340 momentumConservation();
341 inertWhenOff();
342 }
343 Kokkos::finalize();
344 if (failures == 0) {
345 std::printf("OK\n");
346 return 0;
347 }
348 std::fprintf(stderr, "%d failure(s)\n", failures);
349 return 1;
350}
int main()
Definition dbg_scene.cpp:13
flow — host-facing Kokkos IBM Navier-Stokes solver (drop-in flow-style API).
float worst
Definition cmp.py:18
x
Definition cmp.py:24
y
Definition cmp.py:24
one(timed)
Definition prof.py:109
static double maxAbsDiff(const std::vector< double > &a, const std::vector< double > &b)
static constexpr double U
#define CHECK(cond)
static double maxAbsDev(const std::vector< double > &a, double r)