flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_surface_tension.cpp
Go to the documentation of this file.
1// VoF rung V4 (WO-P) — balanced-force continuum surface force + the capillary time step.
2//
3// The one rule (Francois et al., JCP 213:141 (2006); Popinet, JCP 228:5838 (2009)): `sigma*kappa*
4// grad C` must be evaluated at the same location and with the same discrete gradient operator as
5// the pressure gradient. On this staggered grid that is the face difference `C(i) - C(i - s_c)`,
6// the one `buildRhsVar` already applies to P. See `src/vof/surface_tension.hpp`.
7//
8// Gates, in the order they run:
9//
10// P1 THE EXACTNESS GATE — a stationary droplet with a CONSTANT curvature. The force is then
11// exactly the discrete gradient of `sigma*kappa*C`, so the projection must annihilate it
12// completely and `max|u|` must stay at MACHINE ZERO, independent of viscosity and of
13// resolution. This is the momentum analogue of the hydrostatic acid test and it is the gate
14// that fails loudly on any force/pressure operator mismatch. Run at three resolutions and four
15// viscosities spanning three decades.
16//
17// P2 THE ABLATION THAT MAKES P1 MEAN SOMETHING — the identical configuration with the identical
18// curvature, forced by a CELL-CENTRED sigma*kappa*grad(C) face-interpolated the way a rho*g
19// body force is (`set_csf_mode(1)`). Same physics, same accuracy order, one wrong operator
20// pairing: the currents must jump by many orders. Without this, P1 could be passing for the
21// wrong reason.
22//
23// P3 YOUNG-LAPLACE — the discrete equilibrium pressure jump across the interface must be
24// sigma*kappa, and with the CONSTANT-kappa force the equilibrium field must be exactly
25// `P = sigma*kappa*C + const` (which is the algebraic statement P1 measures dynamically).
26//
27// P4 THE CAPILLARY TIME STEP — `capillary_dt()` against the closed form
28// sqrt((rho1+rho2) h^3/(4 pi sigma)) (Brackbill 1992; Denner & van Wachem 2015), for the
29// declared phase pair and for the min+max of a closure-driven density field; and `step()`
30// must REFUSE a dt above it.
31//
32// P5 INERT WHEN OFF — with `set_surface_tension` never called, u, P and C are BITWISE identical
33// to the V2a/V2b path. (The rung adds a kernel to the RHS loop and a curvature pass to the
34// head of the step; this is the statement that both are gated.)
35//
36// P6 THE WISP GUARD, and the measurement that put it there. Weymouth-Yue leaves round-off colour
37// residue in every cell its sweeps touch; those cells satisfy `0 < C < 1`, so the curvature
38// cascade builds a zero-area PLIC polygon for them and returns |kappa| up to 1e8. Gated both
39// ways: with the guard (default eps = 1e-8) a real droplet's spurious currents DECAY, with
40// `set_vof_interface_eps(0)` they grow by three orders in 20 steps. An unguarded run that
41// is destroyed outright -- its velocity crosses the Weymouth-Yue CFL cap and `step()` throws
42// -- is the expected outcome too; any other exception still fails.
43//
44// P7 SPURIOUS CURRENTS WITH THE REAL CURVATURE — the capillary number a resolved static droplet
45// actually settles at, and its convergence with resolution. A measurement, reported and
46// loosely bounded, not a pass/fail on a number the estimator cannot deliver.
47#include <cmath>
48#include <cstdio>
49#include <cstdlib>
50#include <cstring>
51#include <Kokkos_Core.hpp>
52#include <memory>
53#include <stdexcept>
54#include <vector>
55
56#include "flow_ibm.hpp"
57
58namespace {
61
62int failures = 0;
63#define CHECK(cond) \
64 do { \
65 if (!(cond)) { \
66 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
67 ++failures; \
68 } \
69 } while (0)
70
71std::size_t idx(int x, int y, int z, int n) {
72 return (std::size_t)x + (std::size_t)y * n + (std::size_t)z * (std::size_t)n * n;
73}
74
75// Volume fractions of a sphere: exact in z, sub x sub sampling in (x,y). Accurate to ~1e-6 in C,
76// which matters — a crudely sampled sphere quantizes C and manufactures full/empty cell PAIRS whose
77// shared face carries an interface no cell knows about (measured: 468 such faces at sub = 8 in all
78// three directions, 0 with this construction).
79std::vector<double> sphereC(int n, double R, double cx, double cy, double cz, int sub = 24) {
80 std::vector<double> C((std::size_t)n * n * n, 0.0);
81 const double w = 1.0 / sub;
82 for (int k = 0; k < n; ++k)
83 for (int j = 0; j < n; ++j)
84 for (int i = 0; i < n; ++i) {
85 double acc = 0.0;
86 for (int b = 0; b < sub; ++b)
87 for (int a = 0; a < sub; ++a) {
88 const double px = i + (a + 0.5) * w, py = j + (b + 0.5) * w;
89 const double r2 = R * R - (px - cx) * (px - cx) - (py - cy) * (py - cy);
90 if (r2 <= 0.0)
91 continue;
92 const double h = std::sqrt(r2);
93 const double lo = std::fmax(cz - h, (double)k), hi = std::fmin(cz + h, (double)k + 1);
94 if (hi > lo)
95 acc += hi - lo;
96 }
97 C[idx(i, j, k, n)] = acc / (sub * sub);
98 }
99 return C;
100}
101
102double maxAbs(const std::vector<double>& v) {
103 double m = 0;
104 for (double x : v)
105 m = std::fmax(m, std::fabs(x));
106 return m;
107}
108double maxVel(IbmSolver& s) {
109 return std::fmax(std::fmax(maxAbs(s.getVelocity(0)), maxAbs(s.getVelocity(1))),
110 maxAbs(s.getVelocity(2)));
111}
112double maxAbsDiff(const std::vector<double>& a, const std::vector<double>& b) {
113 // WO-R2: NaN-PROPAGATING. `std::fmax(m, NaN) == m`, so the obvious loop returns 0.000e+00 for a
114 // field that has gone entirely NaN and every bitwise gate built on it passes (WO-R found this on
115 // a drained open-boundary run). A non-finite difference must fail, so return it.
116 double m = 0;
117 for (std::size_t i = 0; i < a.size(); ++i) {
118 const double d = std::fabs(a[i] - b[i]);
119 if (!(d == d))
120 return d; // NaN
121 m = std::fmax(m, d);
122 }
123 return m;
124}
125
126struct Cfg {
127 int n = 32;
128 double R = 8.0;
129 double sigma = 1.0;
130 double mu = 0.1;
131 double rhoG = 1.0;
132 double rhoL = 1.0;
133 double dtFac = 0.5; // fraction of the capillary dt
134 double kappa = -1.0; // >= 0: freeze kappa at this constant (the exactness gate)
135 int mode = 0; // set_csf_mode
136 double eps = -1.0; // >= 0: set_vof_interface_eps
137};
138
139// A stationary droplet in a triply periodic box, off-centre by an irrational fraction of a cell so
140// nothing is mesh-aligned.
141std::unique_ptr<IbmSolver> makeDroplet(const Cfg& c) {
142 auto s = std::make_unique<IbmSolver>(c.n, c.n, c.n);
143 s->setRho(c.rhoL);
144 s->setMu(c.mu);
145 s->setDt(1.0);
146 // bitwise / machine-precision gates: keep the legacy fixed-sweep momentum loop (the default
147 // residual stop, 1e-5 since 2026-09-02, leaves an O(1e-12) momentum residual)
148 s->setVelocityResidualTolerance(0.0);
149 s->setPressureGeometry(std::vector<double>((std::size_t)c.n * c.n * c.n, 10.0));
150 s->setPressureChebyshev(true, 500, 1e-14);
151 s->enableVof();
152 s->setVof(sphereC(c.n, c.R, c.n / 2 + 0.13, c.n / 2 + 0.27, c.n / 2 + 0.11));
153 s->setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {c.rhoG, c.rhoL - c.rhoG});
154 s->setSurfaceTension(c.sigma);
155 if (c.eps >= 0.0)
156 s->setVofInterfaceEps(c.eps);
157 s->setCsfMode(c.mode);
158 if (c.kappa >= 0.0)
159 s->setVofKappaConstant(c.kappa);
160 s->setDt(c.dtFac * s->capillaryDt());
161 return s;
162}
163
164double runDroplet(const Cfg& c, int steps, IbmSolver** keep = nullptr) {
165 auto s = makeDroplet(c);
166 for (int k = 0; k < steps; ++k)
167 s->step();
168 const double m = maxVel(*s);
169 if (keep)
170 *keep = s.release();
171 return m;
172}
173
174// ------------------------------------------------------------------ P1 + P2
175void gateExactBalance() {
176 std::printf("\n=== P1 THE EXACTNESS GATE: constant kappa -> max|u| at machine zero\n");
177 const int ns[3] = {16, 32, 48};
178 const double Rs[3] = {4.0, 8.0, 12.0};
179 for (int i = 0; i < 3; ++i) {
180 Cfg c;
181 c.n = ns[i];
182 c.R = Rs[i];
183 c.kappa = 2.0 / Rs[i];
184 const double m = runDroplet(c, 30);
185 std::printf(" n = %2d R = %4.1f mu = 0.1 max|u| = %.4e (Ca = %.2e)\n", ns[i], Rs[i],
186 m, c.mu * m / c.sigma);
187 CHECK(m < 1e-14);
188 }
189 const double mus[4] = {1e-3, 1e-2, 1e-1, 1.0};
190 for (double mu : mus) {
191 Cfg c;
192 c.mu = mu;
193 c.kappa = 0.25;
194 const double m = runDroplet(c, 30);
195 std::printf(" n = 32 R = 8.0 mu = %-8.4g max|u| = %.4e\n", mu, m);
196 CHECK(m < 1e-14);
197 }
198}
199
200void gateAblation() {
201 std::printf(
202 "\n=== P2 the ablation: the SAME constant kappa, forced by a cell-centred sigma*kappa*grad "
203 "C\n"
204 " face-interpolated like a body force (set_csf_mode(1))\n");
205 Cfg bal;
206 bal.kappa = 0.25;
207 Cfg abl = bal;
208 abl.mode = 1;
209 const double mb = runDroplet(bal, 30), ma = runDroplet(abl, 30);
210 std::printf(" balanced-force (face difference) max|u| = %.4e Ca = %.2e\n", mb,
211 bal.mu * mb / bal.sigma);
212 std::printf(" cell-centred force, interpolated max|u| = %.4e Ca = %.2e -> %.1e x worse\n",
213 ma, abl.mu * ma / abl.sigma, ma / std::fmax(mb, 1e-300));
214 CHECK(mb < 1e-14);
215 CHECK(ma > 1e6 * std::fmax(mb, 1e-300)); // the mismatch must be loud, not marginal
216 CHECK(ma > 1e-4);
217}
218
219// ------------------------------------------------------------------ P3
220void gateYoungLaplace() {
221 std::printf("\n=== P3 Young-Laplace: the equilibrium pressure field is sigma*kappa*C + const\n");
222 Cfg c;
223 c.n = 32;
224 c.R = 8.0;
225 c.kappa = 0.25;
226 IbmSolver* s = nullptr;
227 runDroplet(c, 40, &s);
228 std::unique_ptr<IbmSolver> own(s);
229 const auto P = s->getPressure();
230 const auto C = s->getVof();
231 // Fit the offset from the gas (C == 0) cells, then measure the residual of P - sigma*kappa*C.
232 double p0 = 0.0;
233 long ng = 0;
234 for (std::size_t i = 0; i < C.size(); ++i)
235 if (C[i] == 0.0) {
236 p0 += P[i];
237 ++ng;
238 }
239 p0 /= (double)ng;
240 double res = 0.0, jump = 0.0;
241 long nl = 0;
242 double pl = 0.0;
243 for (std::size_t i = 0; i < C.size(); ++i) {
244 res = std::fmax(res, std::fabs(P[i] - p0 - c.sigma * c.kappa * C[i]));
245 if (C[i] == 1.0) {
246 pl += P[i];
247 ++nl;
248 }
249 }
250 jump = pl / (double)nl - p0;
251 const double exact = c.sigma * c.kappa;
252 std::printf(" dP(liquid - gas) = %.10f sigma*kappa = %.10f rel err %.3e\n", jump, exact,
253 std::fabs(jump - exact) / exact);
254 std::printf(" max |P - p0 - sigma*kappa*C| over the WHOLE field = %.3e\n", res);
255 CHECK(std::fabs(jump - exact) / exact < 1e-12);
256 CHECK(res < 1e-11);
257}
258
259// ------------------------------------------------------------------ P4
260void gateCapillaryDt() {
261 std::printf("\n=== P4 the capillary time step\n");
262 const double sigma = 2.0, rg = 1.0, rl = 700.0;
263 const int n = 24;
264 auto s = std::make_unique<IbmSolver>(n, n, n);
265 s->setRho(rl);
266 s->setMu(0.01);
267 s->setDt(1e-3);
268 s->setPressureGeometry(std::vector<double>((std::size_t)n * n * n, 10.0));
269 s->setPressureChebyshev(true, 300, 1e-13);
270 s->enableVof();
271 s->setVof(sphereC(n, 6.0, n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11));
272 s->setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {rg, rl - rg});
273 s->setSurfaceTension(sigma);
274 const double want = std::sqrt((rg + rl) * 1.0 / (4.0 * M_PI * sigma));
275 const double got = s->capillaryDt(); // from min+max of the closure-driven rho field
276 std::printf(" closure-driven rho field: dt_sigma = %.12g closed form = %.12g rel %.2e\n",
277 got, want, std::fabs(got - want) / want);
278 CHECK(std::fabs(got - want) / want < 1e-14);
279 // The declared phase pair (momentum consistency) must give the same number.
280 s->enableVofMomentum(rg, rl);
281 const double got2 = s->capillaryDt();
282 std::printf(" declared phase pair: dt_sigma = %.12g\n", got2);
283 CHECK(std::fabs(got2 - want) / want < 1e-14);
284 // ... and step() must refuse a dt above it.
285 s->setDt(1.01 * want);
286 bool threw = false;
287 try {
288 s->step();
289 } catch (const std::exception& e) {
290 threw = true;
291 std::printf(" step() at 1.01 dt_sigma REFUSED, as it must\n");
292 }
293 CHECK(threw);
294 s->setCapillaryCfl(1e30);
295 s->setDt(0.5 * want);
296 s->step(); // and the escape hatch works
297 std::printf(" set_capillary_cfl(1e30) disables the check\n");
298}
299
300// ------------------------------------------------------------------ P5
301void gateInert() {
302 std::printf("\n=== P5 inert when off: bitwise identical to the rung-V2a path\n");
303 const int n = 24;
304 auto build = [&](bool st) {
305 auto s = std::make_unique<IbmSolver>(n, n, n);
306 s->setRho(10.0);
307 s->setMu(0.05);
308 s->setDt(0.2);
309 s->setPressureGeometry(std::vector<double>((std::size_t)n * n * n, 10.0));
310 s->setPressureChebyshev(true, 300, 1e-13);
311 s->enableVof();
312 s->setVof(sphereC(n, 6.0, n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11));
313 s->setPropertyModel("rho", ClosureKind::LinearMix, "C", "", {1.0, 9.0});
314 if (st) {
315 s->setSurfaceTension(0.0); // explicitly OFF: must not arm anything
316 s->setVofInterfaceEps(1e-8);
317 s->setCsfMode(1);
318 }
319 const std::size_t nc = (std::size_t)n * n * n;
320 std::vector<double> u(nc), v(nc), w(nc);
321 for (int k = 0; k < n; ++k)
322 for (int j = 0; j < n; ++j)
323 for (int i = 0; i < n; ++i) {
324 u[idx(i, j, k, n)] = 0.02 * std::sin(2 * M_PI * (j + 0.5) / n);
325 v[idx(i, j, k, n)] = 0.0;
326 w[idx(i, j, k, n)] = 0.0;
327 }
328 s->uploadVelocity(u, v, w);
329 return s;
330 };
331 auto a = build(false), b = build(true);
332 for (int k = 0; k < 12; ++k) {
333 a->step();
334 b->step();
335 }
336 const double du = std::fmax(maxAbsDiff(a->getVelocity(0), b->getVelocity(0)),
337 maxAbsDiff(a->getVelocity(2), b->getVelocity(2)));
338 const double dp = maxAbsDiff(a->getPressure(), b->getPressure());
339 const double dc = maxAbsDiff(a->getVof(), b->getVof());
340 std::printf(" du = %.3e dp = %.3e dC = %.3e\n", du, dp, dc);
341 CHECK(du == 0.0 && dp == 0.0 && dc == 0.0);
342}
343
344// ------------------------------------------------------------------ P6
345void gateWispGuard() {
346 std::printf(
347 "\n=== P6 the wisp guard: kappa must not be computed where there is no interface\n"
348 " The GATE is on the mechanism (max|kappa| over the cells the cascade served), which\n"
349 " is deterministic and separated by five orders. The velocity CONSEQUENCE is "
350 "reported\n"
351 " rather than gated: whether a given run is merely degraded or destroyed depends on\n"
352 " whether a garbage-kappa cell happens to sit next to a real one, so it is loud but\n"
353 " configuration-dependent (measured at 64^3, 60 steps: 1.2e-2 unguarded and STILL\n"
354 " GROWING against 1.4e-4 decaying — see the study script).\n");
355 const int ns[2] = {32, 48};
356 const double Rs[2] = {8.0, 12.0};
357 for (int i = 0; i < 2; ++i)
358 for (double eps : {1e-8, 0.0}) {
359 Cfg c;
360 c.n = ns[i];
361 c.R = Rs[i];
362 c.eps = eps;
363 auto s = makeDroplet(c);
364 double u0 = 0, uMax = 0, kmax = 0;
365 for (int k = 0; k < 40; ++k) {
366 if (eps > 0.0) {
367 s->step();
368 } else {
369 // Unguarded: the garbage curvature may drive the velocity past the CFL cap, and the
370 // advector refuses the step. That is the arm's "destroyed" outcome, not a failure.
371 try {
372 s->step();
373 } catch (const std::runtime_error& e) {
374 if (!std::strstr(e.what(), "exceeds the Weymouth-Yue boundedness cap"))
375 throw;
376 std::printf(" n = %2d eps = %-6g DESTROYED at step %d (peak max|u| %.3e): %s\n",
377 ns[i], eps, k + 1, uMax, e.what());
378 uMax = -1.0; // marks the destroyed run: the expected outcome, nothing more to gate
379 break;
380 }
381 }
382 const double u = maxVel(*s);
383 if (k == 0)
384 u0 = u;
385 uMax = std::fmax(uMax, u);
386 }
387 if (uMax < 0.0)
388 continue;
389 const double uEnd = maxVel(*s);
390 const auto ka = s->getVofCurvature();
391 const auto br = s->getVofCurvatureBranch();
392 long interf = 0;
393 for (std::size_t j = 0; j < ka.size(); ++j)
394 if (br[j] > 0.5 && br[j] < 5.5) {
395 ++interf;
396 kmax = std::fmax(kmax, std::fabs(ka[j]));
397 }
398 std::printf(
399 " n = %2d eps = %-6g max|kappa| = %.4e (2/R = %.4f) cells served %4ld "
400 "max|u| %.3e -> %.3e (peak %.3e)\n",
401 ns[i], eps, kmax, 2.0 / c.R, interf, u0, uEnd, uMax);
402 if (eps > 0.0) {
403 CHECK(kmax < 1.5 * 2.0 / c.R); // no curvature beyond the sphere's own survives
404 CHECK(uEnd < u0); // and the spurious currents decay
405 } else {
406 CHECK(kmax > 100.0 * 2.0 / c.R); // the mechanism, five orders wide
407 }
408 }
409}
410
411// ------------------------------------------------------------------ P7
412void gateSpuriousCurrents() {
413 std::printf(
414 "\n=== P7 spurious currents with the REAL curvature (a measurement, not a pass/fail)\n"
415 " Ca = mu max|u| / sigma after 60 steps; La = sigma rho D / mu^2\n");
416 const int ns[3] = {16, 32, 48};
417 const double Rs[3] = {4.0, 8.0, 12.0};
418 double prev = 0.0;
419 for (int i = 0; i < 3; ++i) {
420 Cfg c;
421 c.n = ns[i];
422 c.R = Rs[i];
423 IbmSolver* s = nullptr;
424 const double m = runDroplet(c, 60, &s);
425 std::unique_ptr<IbmSolver> own(s);
426 const auto d = s->csfDiagnostics();
427 const double ca = c.mu * m / c.sigma;
428 std::printf(" n = %2d D/dx = %4.1f La = %6.0f Ca = %.3e%s orphan faces %ld/%ld/%ld\n",
429 ns[i], 2 * Rs[i], c.sigma * c.rhoL * 2 * Rs[i] / (c.mu * c.mu), ca,
430 i ? "" : " ", d.orphanFaces[0], d.orphanFaces[1], d.orphanFaces[2]);
431 if (i)
432 std::printf(" ratio to the coarser rung: %.2f x\n", prev / ca);
433 prev = ca;
434 CHECK(ca < 1e-3);
435 CHECK(d.orphanFaces[0] + d.orphanFaces[1] + d.orphanFaces[2] < 20);
436 }
437}
438
439} // namespace
440
441int main(int argc, char** argv) {
442 Kokkos::initialize(argc, argv);
443 {
444 gateExactBalance();
445 gateAblation();
446 gateYoungLaplace();
447 gateCapillaryDt();
448 gateInert();
449 gateWispGuard();
450 gateSpuriousCurrents();
451 }
452 Kokkos::finalize();
453 if (failures) {
454 std::fprintf(stderr, "\nFAILED (%d)\n", failures);
455 return 1;
456 }
457 std::printf("\nPASSED (0 failures)\n");
458 return 0;
459}
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
exact(Ds, K, hc, gs, nmax=1)
Definition conj.py:29
dict res
Definition disc_bc.py:230
static double maxAbs(const std::vector< double > &a)
static double maxAbsDiff(const std::vector< double > &a, const std::vector< double > &b)
#define CHECK(cond)