flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vof_phase_change.cpp
Go to the documentation of this file.
1// VoF Part II, rungs P0 + P1 (WO-P01) — phase change in planar form.
2//
3// Gates, in the order they are run:
4//
5// K1 THE PLIC POLYGON AREA. `plicArea` is the one genuinely new piece of geometry: the
6// regression removes `mdot * A * dt / rho_l` per cell, so an area that is wrong by a factor
7// is an interface that moves at the wrong speed and nothing downstream would notice. It is
8// checked against three CLOSED-FORM references (an axis-aligned plane 1, the face diagonal
9// sqrt(2), the centred hexagon 3 sqrt(3)/4 — the last two are the classic unit-cube
10// cross-sections) and against `|m|_2 dV/dalpha` by central difference over a sweep of
11// normals and volumes. The work order suggested the finite difference AS the implementation;
12// it is only exact where V(alpha) is locally linear, which is why the analytic derivative is
13// what ships and the difference is only the cross-check.
14//
15// K2 THE ONE-SIDED GRADIENT FIT. On an analytic LINEAR temperature field the weighted
16// least-squares fit through the interface value must return the exact directional derivative,
17// for an arbitrary (non-axis-aligned) interface normal. This is a pure kernel statement: it
18// pins the sign of the normal distance (positive on the GAS side), the through-the-interface
19// form of the fit, and Malan's collinearity weight.
20//
21// P0a REGRESSION ONLY (rho_g = rho_l, no source). A planar interface under a uniform prescribed
22// mdot, 1000 kinematic steps: the interface position must follow x(t) = x0 - mdot t/rho_l,
23// which is EXACT by construction, and the colour must stay in [0,1]. The gate is 1e-12 on
24// the position; the clip-and-redistribute ledger crosses twenty cell boundaries during the
25// run and is what makes it exact rather than merely close.
26//
27// P0b THE DIVERGENCE SOURCE (density ratio 100). A CLOSED column — walls on +-x, periodic in
28// y/z — with the net vapour production balanced by a prescribed sink plane, so the domain is
29// compatible without an outflow (the varRho outflow operator is the subject of WO-R2). The
30// exact 1-D solution has the liquid at rest, the interfacial cell's own faces at the LIQUID
31// velocity (0) and the gas beyond the deposit cell at mdot(1/rho_g - 1/rho_l). All three are
32// gated, together with max|div(u) - S| at the projection floor.
33//
34// P1 THE STEFAN PROBLEM. A vapour layer growing from a superheated wall into saturated liquid,
35// the mass flux computed from the one-sided gradients of a transported temperature with the
36// interfacial cells pinned at T_sat by the new per-cell Dirichlet mask. Gated here at N = 64
37// (the convergence ladder 64/128/256 lives in `tests/study/vof_stefan.py`).
38//
39// INERT With `mdot == 0` every phase-change kernel runs and the colour field must come back
40// BITWISE unchanged — the branch-level statement that the machinery adds nothing of itself.
41#include <cmath>
42#include <cstdio>
43#include <cstdlib>
44#include <Kokkos_Core.hpp>
45#include <vector>
46
47#include "flow_ibm.hpp"
48
49namespace {
61using peclet::flow::vof::McVertex; // WO-P3d
68using peclet::flow::vof::plicAlpha;
71using peclet::flow::vof::plicNormalizeL1;
72using peclet::flow::vof::plicVolume;
74using peclet::flow::vof::vofIndexNormal;
75using peclet::flow::vof::VofMetric; // Phase 3 (K4/K5)
76using peclet::flow::vof::vofPhysNormal;
77
78int failures = 0;
79#define CHECK(cond) \
80 do { \
81 if (!(cond)) { \
82 std::fprintf(stderr, "CHECK failed: %s\n at %s:%d\n", #cond, __FILE__, __LINE__); \
83 ++failures; \
84 } \
85 } while (0)
86
87std::size_t idx(int x, int y, int z, int nx, int ny) {
88 return (std::size_t)x + (std::size_t)y * nx + (std::size_t)z * (std::size_t)nx * ny;
89}
90
91// WO-P3c: run every scene below with a non-default interfacial-AREA geometry
92// (`set_phase_change_area`) when `PECLET_P3C_AREA` is set, so the planar rungs can be re-taken on
93// the cascade area without a second binary. Inert (and byte-identical) when the variable is unset.
94template <class S>
95void applyAreaModeEnv(S& s) {
96 if (const char* e = std::getenv("PECLET_P3C_AREA"))
97 s.setPhaseChangeArea(std::atoi(e));
98}
99
100// WO-P3f: the same hook for this work order's two OPTIONS, so the planar rungs and the MPI
101// decomposition gate can be re-taken with them on without a second binary. Both are inert (and
102// byte-identical) when the variables are unset. `PECLET_P3F_CARRY=1` turns on the
103// enthalpy-conserving Dirichlet overwrite (a fixed-order gather over face neighbours: the piece
104// with a real neighbour dependency, hence the one the decomposition gate is about);
105// `PECLET_P3F_KAPPA=<k>` prescribes the curvature the one-sided fits correct their sample
106// distances with (a purely per-cell function of the existing stencil, no reduction).
107template <class S>
108void applyP3fEnv(S& s) {
109 if (const char* e = std::getenv("PECLET_P3F_CARRY"))
110 s.setPhaseChangeCarryConserve(e[0] != '0');
111 if (const char* e = std::getenv("PECLET_P3F_KAPPA"))
112 s.setPhaseChangeFitCurvature(std::atof(e));
113 // WO-P3g: the same hook for the second-order interfacial energy operator, so the planar rungs
114 // and the MPI decomposition gate can be re-taken on it without a second binary. Inert (and
115 // byte-identical) when the variables are unset. `PECLET_P3G_ORDER=2` is the package;
116 // the three `PECLET_P3G_*` singles are the ablation.
117 if (const char* e = std::getenv("PECLET_P3G_ORDER"))
118 s.setPhaseChangeEnergyOrder(std::atoi(e));
119 if (const char* e = std::getenv("PECLET_P3G_OPMDOT"))
120 s.setPhaseChangeMdotOperator(e[0] != '0');
121 if (const char* e = std::getenv("PECLET_P3G_GFMORDER"))
122 s.setPhaseChangeGfmOrder(std::atoi(e));
123 if (const char* e = std::getenv("PECLET_P3G_CURVDIST"))
124 s.setPhaseChangeCurvatureDistance(e[0] != '0');
125}
126
127// ============================================================ K1: the PLIC polygon area
128void areaGate() {
129 const double s2 = std::sqrt(2.0), hex = 3.0 * std::sqrt(3.0) / 4.0;
130 CHECK(plicArea(1.0, 0.0, 0.0, plicAlpha(1.0, 0.0, 0.0, 0.5)) == 1.0);
131 CHECK(plicArea(0.0, 0.0, -1.0, plicAlpha(0.0, 0.0, -1.0, 0.3)) == 1.0);
132 CHECK(std::fabs(plicArea(0.5, 0.5, 0.0, plicAlpha(0.5, 0.5, 0.0, 0.5)) - s2) < 1e-15);
133 const double t = 1.0 / 3.0;
134 CHECK(std::fabs(plicArea(t, t, t, plicAlpha(t, t, t, 0.5)) - hex) < 1e-15);
135 // a plane that misses the cell has no polygon
136 CHECK(plicArea(1.0, 0.0, 0.0, -0.5) == 0.0);
137 CHECK(plicArea(1.0, 0.0, 0.0, 1.5) == 0.0);
138
139 const double ms[][3] = {{1, 0, 0}, {0.5, 0.5, 0}, {0.02, 0.98, 0},
140 {t, t, t}, {0.2, 0.3, 0.5}, {-0.2, 0.3, -0.5},
141 {1e-9, 0.4, 0.6}, {0.1, 0.1, 0.8}, {0.45, 0.45, 0.1}};
142 double worst = 0.0;
143 for (const auto& m : ms)
144 for (double v = 0.02; v < 0.999; v += 0.02) {
145 const double al = plicAlpha(m[0], m[1], m[2], v);
146 const double q = std::sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
147 const double d = 1e-6;
148 const double ref =
149 q * (plicVolume(m[0], m[1], m[2], al + d) - plicVolume(m[0], m[1], m[2], al - d)) /
150 (2 * d);
151 worst = std::fmax(worst, std::fabs(plicArea(m[0], m[1], m[2], al) - ref));
152 }
153 std::printf("K1 plicArea: max |analytic - |m|2 dV/dalpha (FD)| = %.3e\n", worst);
154 CHECK(worst < 1e-5); // the FD's own truncation, not the kernel's error
155}
156
157// ============================================================ K6: the WO-P3d joined sheet
158//
159// `set_phase_change_area` 4..7 replaces the sum of per-cell pieces by ONE watertight sheet:
160// marching tetrahedra (Kuhn's 6-tet split) on the dual cube whose 8 corners are cell centres. Two
161// statements are gated here, both of which are what the construction is FOR:
162//
163// (a) EXACT ON A PLANE, TILTED OR NOT. Give the 8 corners the exact signed distance of a plane
164// and that plane as their PLIC normal; the sheet inside the dual cube must then be exactly
165// the plane's cross-section of that cube, whose area is `plicArea` of the same plane on the
166// unit cube. This is the tilted-plane gate of the work order at kernel level, where it needs
167// no box, no ghost policy and no edge convention. (The C = 1/2 source cannot pass it and is
168// not asked to: `C(d)` is the SZ piecewise cubic, so linear interpolation of it along the
169// tets' sqrt(2) and sqrt(3) edges misplaces the vertex. It is asked for the axis-aligned
170// case, where `C` IS linear.)
171//
172// (b) THE DEPOSIT IS A PARTITION. Summed over the 8 corners, both deposit rules return the same
173// total, and they still do when some corners are non-interfacial and their pieces are
174// RETARGETED — the retarget moves area between cells, never loses it. That identity is what
175// makes `Sigma_cells A` a property of the sheet and not of the booking rule.
176void mcSheetGate() {
177 const double ns[][3] = {{1, 0, 0}, {0, 0, -1}, {1, 1, 0}, {1, -1, 0},
178 {1, 1, 1}, {1, 2, 3}, {0.2, 0.3, 0.5}, {3, 1, -2},
179 {0.05, 1.0, 0.02}, {1, 0.5, 0.25}};
180 double worstPlic = 0.0, worstSplit = 0.0, worstPart = 0.0, worstColourAxis = 0.0;
181 int rows = 0, axisRows = 0;
182 for (const auto& nn : ns) {
183 const double q = std::sqrt(nn[0] * nn[0] + nn[1] * nn[1] + nn[2] * nn[2]);
184 const double u[3] = {nn[0] / q, nn[1] / q, nn[2] / q}; // unit, gas-ward
185 const bool axis = (std::fabs(u[0]) == 1.0 || std::fabs(u[1]) == 1.0 || std::fabs(u[2]) == 1.0);
186 for (double off = -0.86; off < 0.87; off += 0.07) {
187 // gas-positive distance at the dual cube's corner k, for the plane through the cube centre
188 // displaced by `off` along u: phi(x) = u . (x - (1/2,1/2,1/2)) - off
189 McVertex v[8];
190 bool crossed = false;
191 for (int k = 0; k < 8; ++k) {
192 double p[3];
194 const double phi = u[0] * (p[0] - 0.5) + u[1] * (p[1] - 0.5) + u[2] * (p[2] - 0.5) - off;
195 v[k].psi = phi;
196 v[k].d = phi;
197 v[k].n[0] = u[0];
198 v[k].n[1] = u[1];
199 v[k].n[2] = u[2];
200 v[k].has = true;
201 if (k && ((v[k].psi < 0.0) != (v[0].psi < 0.0)))
202 crossed = true;
203 }
204 if (!crossed)
205 continue;
206 // the exact cross-section area: the same plane on the unit cube, L1-normalized for plicArea
207 double m[3] = {u[0], u[1], u[2]};
208 const double l1 = plicNormalizeL1(m);
209 const double alpha = (0.5 * (m[0] + m[1] + m[2])) + off / l1;
210 const double exact = plicArea(m[0], m[1], m[2], alpha);
211 if (!(exact > 1e-6))
212 continue;
213 ++rows;
214 double sumC = 0.0, sumS = 0.0;
215 for (int lc = 0; lc < 8; ++lc) {
216 sumC += mcCubeCornerArea(v, lc, kMcSrcPlic, kMcDepositCentroid);
217 sumS += mcCubeCornerArea(v, lc, kMcSrcPlic, kMcDepositSplit);
218 }
219 worstPlic = std::fmax(worstPlic, std::fabs(sumC - exact) / exact);
220 worstSplit = std::fmax(worstSplit, std::fabs(sumS - sumC) / exact);
221 // the retarget is a permutation of the booking, not a loss: clear a few corners' `has`
222 for (int drop = 1; drop < 8; drop += 3) {
223 McVertex w[8];
224 for (int k = 0; k < 8; ++k) {
225 w[k] = v[k];
226 if ((k & drop) == drop && k != 0)
227 w[k].has = false;
228 }
229 double sc = 0.0, ss = 0.0;
230 for (int lc = 0; lc < 8; ++lc) {
231 sc += mcCubeCornerArea(w, lc, kMcSrcPlic, kMcDepositCentroid);
232 ss += mcCubeCornerArea(w, lc, kMcSrcPlic, kMcDepositSplit);
233 }
234 worstPart = std::fmax(worstPart, std::fabs(sc - sumC) / exact);
235 worstPart = std::fmax(worstPart, std::fabs(ss - sumC) / exact);
236 }
237 // the raw-C source, where it is licensed: an AXIS-ALIGNED plane, whose colour is linear in
238 // the centre distance, so the interpolated crossing is the plane and the sheet is flat.
239 if (axis && std::fabs(off) < 0.4) {
240 ++axisRows;
241 // for a unit normal along an axis the liquid fraction of a unit cell at gas-distance phi
242 // is exactly clamp(1/2 - phi, 0, 1), so psi = 1/2 - C is the distance itself inside the
243 // band and the linear interpolation of it IS the plane. (Outside the band C saturates,
244 // which is why the sweep is restricted to a crossing well inside the cube.)
245 for (int k = 0; k < 8; ++k) {
246 double p[3];
248 const double phi = u[0] * (p[0] - 0.5) + u[1] * (p[1] - 0.5) + u[2] * (p[2] - 0.5) - off;
249 const double cc = std::fmin(1.0, std::fmax(0.0, 0.5 - phi));
250 v[k].psi = 0.5 - cc;
251 }
252 double sa = 0.0;
253 for (int lc = 0; lc < 8; ++lc)
254 sa += mcCubeCornerArea(v, lc, kMcSrcColour, kMcDepositCentroid);
255 worstColourAxis = std::fmax(worstColourAxis, std::fabs(sa - exact) / exact);
256 }
257 }
258 }
259 std::printf(
260 "K6 joined sheet on a PLANE (%d rows, %d axis-aligned): max rel |PLIC-source sum - "
261 "exact| %.3e, |split - centroid| %.3e, |retargeted - plain| %.3e, "
262 "|colour-source axis - exact| %.3e\n",
263 rows, axisRows, worstPlic, worstSplit, worstPart, worstColourAxis);
264 CHECK(rows > 100);
265 // ~1.7e-14 relative, i.e. about 75 eps: the sheet's total is a sum of a dozen triangle areas,
266 // each a square root of a cross product, so this is the round-off of the SUM and not an error of
267 // the construction. The axis-aligned rows, where every factor is an exact 1, are BITWISE.
268 CHECK(worstPlic < 1e-13);
269 CHECK(worstSplit < 1e-13);
270 CHECK(worstPart < 1e-13);
271 CHECK(worstColourAxis == 0.0);
272}
273
274// ============================================================ K5: the WO-P3c area constructions
275//
276// A_Gamma may now come from the V3 curvature cascade's geometry instead of the MYC PLIC polygon
277// (`set_phase_change_area`). The gate is that on a PLANE the three modes are the SAME number —
278// which is what keeps every planar rung (P0a, P0b, P1, P2) where it was — and that the two new
279// kernels are the exact objects they claim to be:
280//
281// * `hfAreaElement` on the height patch a plane with normal n produces = |m|_2/|m_d| = 1/|n_d|,
282// the metric factor of the graph x_d = f(x_d1, x_d2);
283// * `hfSurfaceNormal` on the same patch returns +-n (the sign is free: `plicArea` is invariant
284// under m -> -m with alpha re-solved from the same colour, the two planes being point
285// reflections through the cell centre);
286// * `pvSurfaceNormal` of a paraboloid with no linear terms is the frame's own normal.
287void areaModeGate() {
288 const double ms[][3] = {{1, 0, 0}, {0, -1, 0}, {0.5, 0.5, 0}, {0.02, 0.98, 0},
289 {0.2, 0.3, 0.5}, {-0.2, 0.3, -0.5}, {0.1, 0.1, 0.8}, {0.45, 0.45, 0.1}};
290 double worst1 = 0.0, worst2 = 0.0, worstMetric = 0.0, worstNrm = 0.0;
291 int exactAxis = 0;
292 for (const auto& m : ms) {
293 const double q = std::sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
294 // the column direction the cascade would pick: the largest |m_d|
295 int d = 0;
296 for (int k = 1; k < 3; ++k)
297 if (std::fabs(m[k]) > std::fabs(m[d]))
298 d = k;
299 const int d1 = (d + 1) % 3, d2 = (d + 2) % 3;
300 for (double v = 0.05; v < 0.999; v += 0.05) {
301 const double al = plicAlpha(m[0], m[1], m[2], v);
302 // the 3x3 height patch of the SAME plane: x_d = (alpha - m_d1 x1 - m_d2 x2)/m_d
303 double hh[9];
304 for (int qq = 0; qq < 3; ++qq)
305 for (int pp = 0; pp < 3; ++pp)
306 hh[pp + 3 * qq] = -(m[d1] * (pp - 1) + m[d2] * (qq - 1)) / m[d];
307 const double metric = hfAreaElement(hh);
308 worstMetric = std::fmax(worstMetric, std::fabs(metric - q / std::fabs(m[d])));
309 double ns[3];
310 hfSurfaceNormal(hh, d, ns);
311 const double dot = (ns[0] * m[0] + ns[1] * m[1] + ns[2] * m[2]) / q;
312 worstNrm = std::fmax(worstNrm, std::fabs(std::fabs(dot) - 1.0));
313 const double a0 = interfaceAreaFromNormal(kAreaPlic, m[0], m[1], m[2], al, v, ns);
314 const double a1 = interfaceAreaFromNormal(kAreaMetric, m[0], m[1], m[2], al, v, ns);
315 const double a2 = interfaceAreaFromNormal(kAreaNormal, m[0], m[1], m[2], al, v, ns);
316 CHECK(a0 == plicArea(m[0], m[1], m[2], al));
317 worst1 = std::fmax(worst1, std::fabs(a1 - a0) / a0);
318 worst2 = std::fmax(worst2, std::fabs(a2 - a0) / a0);
319 if (std::fabs(m[d]) == q) { // axis-aligned: every factor is an exact 1.0
320 if (a1 == a0 && a2 == a0)
321 ++exactAxis;
322 else
323 CHECK(false);
324 }
325 }
326 }
327 std::printf(
328 "K5 area modes on a PLANE: max rel |metric - 1| %.3e, |normal| %.3e, "
329 "mode1 vs mode0 %.3e, mode2 vs mode0 %.3e (%d axis-aligned rows BITWISE)\n",
330 worstMetric, worstNrm, worst1, worst2, exactAxis);
331 CHECK(worstMetric < 1e-15);
332 CHECK(worstNrm < 1e-15);
333 CHECK(worst1 < 1e-14);
334 CHECK(worst2 < 1e-14);
335
336 // the paraboloid branch: no linear terms => the frame normal itself
337 const double t1[3] = {0.0, 1.0, 0.0}, t2[3] = {0.0, 0.0, 1.0}, nn[3] = {1.0, 0.0, 0.0};
338 double a[6] = {0.1, 0.0, 0.0, 0.05, 0.0, 0.05}, ns[3];
339 pvSurfaceNormal(a, t1, t2, nn, ns);
340 CHECK(ns[0] == 1.0 && ns[1] == 0.0 && ns[2] == 0.0);
341 a[1] = 0.3; // a slope in t1 tilts it by exactly that much
342 pvSurfaceNormal(a, t1, t2, nn, ns);
343 CHECK(std::fabs(ns[1] + 0.3 / std::sqrt(1.09)) < 1e-15);
344}
345
346// ============================================================ K2: the one-sided gradient fit
347void gradientGate() {
348 // an arbitrary normal and an arbitrary linear field T = T0 + g . x
349 double m[3] = {0.37, -0.51, 0.28};
350 double n[3] = {1, 0, 0};
351 peclet::flow::vof::pcUnitNormal(m[0], m[1], m[2], n);
352 const double alpha = plicAlpha(m[0], m[1], m[2], 0.4);
353 const double phic = peclet::flow::vof::pcCentreDistance(m[0], m[1], m[2], alpha);
354 const double g[3] = {0.7, -1.3, 2.1};
355 const double Tsat = 3.5;
356 const double exact = g[0] * n[0] + g[1] * n[1] + g[2] * n[2]; // dT/dn
357 // sample "pure gas" cells on the phi > 0 side of the 5^3 stencil
359 for (int dz = -2; dz <= 2; ++dz)
360 for (int dy = -2; dy <= 2; ++dy)
361 for (int dx = -2; dx <= 2; ++dx) {
362 if (!dx && !dy && !dz)
363 continue;
364 const double w = peclet::flow::vof::pcGradWeight(dx, dy, dz, n);
365 if (!(w > 0.0))
366 continue;
367 const double phi = peclet::flow::vof::pcOffsetDistance(phic, n, dx, dy, dz);
368 if (!(phi > 0.0))
369 continue;
370 // T at that cell centre, expressed through its normal distance: the linear field restricted
371 // to the sample points is T_sat + exact*phi + (tangential part), and a fit THROUGH the
372 // interface value can only be exact if the tangential part is absent — so sample the
373 // one-dimensional profile the model assumes.
374 peclet::flow::vof::pcGradAdd(fg, w, phi, Tsat + exact * phi, Tsat);
375 }
376 const double fit = peclet::flow::vof::pcGradSolve(fg);
377 std::printf("K2 one-sided gradient fit: %.17g vs exact %.17g (n = %.4f %.4f %.4f, %d samples)\n",
378 fit, exact, n[0], n[1], n[2], fg.n);
379 CHECK(fg.n > 10);
380 CHECK(std::fabs(fit - exact) < 1e-14);
381}
382
383// ============================================================ K3: the quadratic one-sided fit
384// The linear fit above is exact only on a LINEAR profile; a curved one biases it by O(T'' h), which
385// (once the plane-anchored Dirichlet has removed the cell-centre mismatch) is the leading error of
386// the whole rung. The quadratic fit must return the exact slope AT THE INTERFACE of a quadratic
387// profile, and the linear one must not — the second half is what makes this a discriminating gate.
388void quadraticGradientGate() {
389 double m[3] = {0.37, -0.51, 0.28};
390 double n[3] = {1, 0, 0};
391 peclet::flow::vof::pcUnitNormal(m[0], m[1], m[2], n);
392 const double alpha = plicAlpha(m[0], m[1], m[2], 0.4);
393 const double phic = peclet::flow::vof::pcCentreDistance(m[0], m[1], m[2], alpha);
394 const double Tsat = 3.5, g = 2.19, q = -0.83; // T = Tsat + g phi + q phi^2
396 for (int dz = -2; dz <= 2; ++dz)
397 for (int dy = -2; dy <= 2; ++dy)
398 for (int dx = -2; dx <= 2; ++dx) {
399 if (!dx && !dy && !dz)
400 continue;
401 const double w = peclet::flow::vof::pcGradWeight(dx, dy, dz, n);
402 if (!(w > 0.0))
403 continue;
404 const double phi = peclet::flow::vof::pcOffsetDistance(phic, n, dx, dy, dz);
405 if (!(phi > 0.0))
406 continue;
407 peclet::flow::vof::pcGradAdd(f, w, phi, Tsat + g * phi + q * phi * phi, Tsat);
408 }
409 const double lin = peclet::flow::vof::pcGradSolve(f);
410 const double quad = peclet::flow::vof::pcGradSolve2(f);
411 std::printf(
412 "K3 quadratic fit on a curved profile: quad %.17g vs exact %.17g (lin %.17g, "
413 "%d samples)\n",
414 quad, g, lin, f.n);
415 CHECK(std::fabs(quad - g) < 1e-13);
416 CHECK(std::fabs(lin - g) > 0.1); // the linear fit is genuinely biased here
417}
418
419// ============================================================ K4: the plane-anchored GFM distance
420void gfmThetaGate() {
421 // A grid-aligned plane: liquid at high x, so n = -x_hat and phi_c = 1/2 - C. The pure GAS cell is
422 // at i - e_x (step s = +1 from it to the interfacial cell) and sits phi_c + 1 = 3/2 - C cells
423 // from the plane; the pure LIQUID cell is at i + e_x (s = -1) and sits 1/2 + C cells from it.
424 for (double C : {0.15, 0.5, 0.87}) {
425 const double phic = 0.5 - C, nd = -1.0;
426 const double thGas = peclet::flow::vof::pcGfmTheta(phic, nd, +1.0, 0.1, 1.9);
427 const double thLiq = peclet::flow::vof::pcGfmTheta(phic, nd, -1.0, 0.1, 1.9);
428 std::printf("K4 GFM theta at C = %.2f: gas %.17g (exact %.17g), liquid %.17g (exact %.17g)\n",
429 C, thGas, 1.5 - C, thLiq, 0.5 + C);
430 CHECK(std::fabs(thGas - (1.5 - C)) < 1e-15);
431 CHECK(std::fabs(thLiq - (0.5 + C)) < 1e-15);
432 }
433 // A plane nearly PARALLEL to this face's axis puts the interface far along the grid line, so the
434 // limit is thetaMax and the function must reach it CONTINUOUSLY — a jump there is a round-off
435 // amplifier (see the note on `pcGfmTheta`; measured 4.5e-4 vs 7e-14 on the P2 MPI gate).
436 CHECK(peclet::flow::vof::pcGfmTheta(0.3, 0.0, 1.0, 0.1, 1.9) == 1.9);
437 double prev = peclet::flow::vof::pcGfmTheta(0.3, 1e-3, 1.0, 0.1, 1.9);
438 for (double nd = 1e-3; nd > 1e-14; nd *= 0.5) {
439 const double th = peclet::flow::vof::pcGfmTheta(0.3, nd, 1.0, 0.1, 1.9);
440 CHECK(std::fabs(th - prev) < 1e-3); // no jump anywhere on the way to the limit
441 prev = th;
442 }
443 std::printf("K4 GFM theta continuity to n_d -> 0: limit %.17g (thetaMax 1.9)\n", prev);
444}
445
446// ============================================================ K5 (WO-P3g gate (a)):
447// the Gibou-Fedkiw second-order row, on a 1-D profile against a FIXED plane at every theta.
448//
449// Geometry, in the row's own terms: the pure cell sits at x = 0, the interfacial cell at x = +1,
450// the interface plane at x = theta (0 < theta <= 1 + sqrt(3)/2), and the third point -- the cell
451// BEHIND -- at x = -1. `pcGfmRow(theta, true, order)` returns the pair (a_Gamma, a_behind) so the
452// axis contributes `a_Gamma (T_Gamma - T_0) + a_behind (T_-1 - T_0)`.
453//
454// Two statements, both exact and both first measured here:
455//
456// (a1) the row REPRODUCES d2T/dx2 of a quadratic exactly at every theta -- it is the non-uniform
457// three-point second difference, so the divided difference 2 f[x_L, x_0, x_R] is the leading
458// coefficient of the interpolant and hence exactly T''. The shipped two-point row is exact
459// only for a LINEAR profile;
460// (a2) the total heat the row transfers across the interfacial face,
461// `Q = a_Gamma (T_0 - T_Gamma) + (a_behind - 1) (T_0 - T_-1)` -- the Dirichlet coupling PLUS
462// the one-sided rescaling of the band behind, which is the part cell -1's own row does not
463// mirror -- equals the exact conductive flux `-dT/dx` through that face at every theta.
464// With the Dirichlet coupling alone it is off by a factor `2/(1+theta)`, i.e. a factor 2 as
465// theta -> 0, which is why item 1's flux has to carry both halves.
466//
467// The corresponding failure of the shipped row is printed next to each pass, because it is the
468// measurement WO-P3f's -17 % / -5 % GFM flux deficit predicted.
469void gfmSecondOrderGate() {
470 const double a = 0.83, b = -1.47, c = 0.62; // T(x) = a + b x + c x^2
471 double worstLap = 0.0, worstFlux = 0.0, worstLin = 0.0, worst1 = 0.0;
472 std::printf("K5 second-order GFM row (T = %.2f %+.2f x %+.2f x^2, exact T'' = %.4f):\n", a, b, c,
473 2 * c);
474 for (int i = 1; i <= 19; ++i) {
475 const double th = 0.05 * i; // theta in {0.05 ... 0.95}
476 const double Tg = a + b * th + c * th * th, T0 = a, Tm = a - b + c;
477 const auto r2 = peclet::flow::vof::pcGfmRow(th, true, 2);
478 const auto r1 = peclet::flow::vof::pcGfmRow(th, true, 1);
479 // (a1) the Laplacian on the quadratic
480 const double lap = r2.aGamma * (Tg - T0) + r2.aBehind * (Tm - T0);
481 // (a2) the flux through the face at x = 1/2, exact value -dT/dx(1/2) = -(b + c)
482 const double q2 = r2.aGamma * (T0 - Tg) + (r2.aBehind - 1.0) * (T0 - Tm);
483 const double q1 = r1.aGamma * (T0 - Tg) + (r1.aBehind - 1.0) * (T0 - Tm);
484 // the same row on a LINEAR profile (c = 0): both orders must be exact there
485 const double qlin = r2.aGamma * (0.0 - b * th) + (r2.aBehind - 1.0) * (0.0 - (-b));
486 worstLap = std::fmax(worstLap, std::fabs(lap - 2 * c));
487 worstFlux = std::fmax(worstFlux, std::fabs(q2 + (b + c)));
488 worstLin = std::fmax(worstLin, std::fabs(qlin + b));
489 worst1 = std::fmax(worst1, std::fabs(q1 + (b + c)));
490 if (i % 4 == 1)
491 std::printf(
492 " theta %.2f a_G %8.4f a_B %6.4f | T'' %.17g (err %.2e) Q %.17g "
493 "(err %.2e) | order-1 Q err %+.3e\n",
494 th, r2.aGamma, r2.aBehind, lap, std::fabs(lap - 2 * c), q2, std::fabs(q2 + (b + c)),
495 q1 + (b + c));
496 }
497 std::printf(
498 "K5 worst over theta in [0.05, 0.95]: |T''_row - T''| %.3e, |Q - Q_exact| %.3e "
499 "(linear profile %.3e); the SHIPPED two-point row: %.3e\n",
500 worstLap, worstFlux, worstLin, worst1);
501 CHECK(worstLap < 1e-14);
502 CHECK(worstFlux < 1e-14);
503 CHECK(worstLin < 1e-14);
504 CHECK(worst1 > 0.1); // the shipped row is genuinely first order here
505 // theta = 1 must reduce to the interior row BITWISE (2/2 = 1 both ways)
506 const auto r = peclet::flow::vof::pcGfmRow(1.0, true, 2);
507 CHECK(r.aGamma == 1.0 && r.aBehind == 1.0);
508 // no third point (the cell behind is itself an identity row, or that face is closed): the row
509 // falls back to the shipped two-point form, bitwise.
510 for (double th : {0.1, 0.5, 1.3, 1.9}) {
511 const auto rf = peclet::flow::vof::pcGfmRow(th, false, 2);
512 CHECK(rf.aGamma == 1.0 / th && rf.aBehind == 1.0);
513 }
514 // the curvature-consistent distance is bitwise inert at kappa == 0 and matches the closed form
515 // otherwise (a gas sphere of radius R, kappa = -2/R, cell at lateral offset rho^2 = 1 - n_d^2)
516 for (double nd : {-1.0, -0.8, -0.5, 0.6, 0.9}) {
517 CHECK(peclet::flow::vof::pcGfmThetaK(0.3, nd, 1.0, 0.0, 0.1, 1.9) ==
518 peclet::flow::vof::pcGfmTheta(0.3, nd, 1.0, 0.1, 1.9));
519 const double kap = -2.0 / 12.0;
520 const double want = std::fabs(0.3 - 1.0 * nd + 0.25 * kap * (1.0 - nd * nd)) / std::fabs(nd);
521 const double got = peclet::flow::vof::pcGfmThetaK(0.3, nd, 1.0, kap, 0.1, 1.9);
522 CHECK(std::fabs(got - std::fmin(std::fmax(want, 0.1), 1.9)) < 1e-15);
523 }
524 std::printf("K5 curvature-consistent theta: inert at kappa = 0, closed form otherwise\n");
525}
526
527// ============================================================ P0a: regression only
528void p0a() {
529 const int nx = 64, ny = 4, nz = 4;
530 const double x0 = 32.25, mdot = 0.02, dt = 1.0;
531 peclet::flow::IbmSolver s(nx, ny, nz);
532 s.setRho(1.0);
533 s.setMu(0.01);
534 s.setDt(dt);
535 std::vector<double> C((std::size_t)nx * ny * nz, 0.0);
536 for (int z = 0; z < nz; ++z)
537 for (int y = 0; y < ny; ++y)
538 for (int x = 0; x < nx; ++x)
539 C[idx(x, y, z, nx, ny)] = std::fmin(1.0, std::fmax(0.0, x0 - x));
540 s.enableVof();
541 s.setVof(C);
542 s.enablePhaseChange(1.0, 1.0, 1.0);
544 applyP3fEnv(s);
545 s.setMassFluxUniform(mdot);
546 auto pos = [&]() {
547 const auto c = s.getVof();
548 double sum = 0;
549 for (double v : c)
550 sum += v;
551 return sum / (ny * nz);
552 };
553 double worst = 0.0, lo = 1e30, hi = -1e30;
554 long clips = 0;
555 double redist = 0.0;
556 for (int k = 1; k <= 1000; ++k) {
557 s.applyPhaseChange(dt);
558 worst = std::fmax(worst, std::fabs(pos() - (x0 - mdot * dt * k)));
559 const auto d = s.phaseChangeDiagnostics();
560 lo = std::fmin(lo, d.minC);
561 hi = std::fmax(hi, d.maxC);
562 clips += d.deficitCells;
563 redist += d.redistributed;
564 }
565 std::printf(
566 "P0a planar regression, 1000 steps: max |x - (x0 - mdot t/rho_l)| = %.3e ; C in [%.17g, "
567 "%.17g] ; %ld cell-crossings clipped, |redistributed| = %.6g\n",
568 worst, lo, hi, clips, redist);
569 CHECK(worst < 1e-12);
570 CHECK(lo >= 0.0);
571 CHECK(hi <= 1.0);
572 CHECK(clips > 0); // the run MUST cross cell boundaries or it never exercises the redistribution
573}
574
575// ============================================================ P0b: the divergence source
576void p0b() {
577 const int nx = 64, ny = 4, nz = 4;
578 const double rg = 1.0, rl = 100.0, mdot = 0.01, x0 = 32.25;
579 peclet::flow::IbmSolver s(nx, ny, nz);
580 s.setRho(rg);
581 s.setMu(1e-3);
582 s.setDt(1.0);
583 s.setDomainBc(0, 1, 0, 0, 0);
584 s.setDomainBc(1, 1, 0, 0, 0);
585 s.setPressureGeometry(std::vector<double>((std::size_t)nx * ny * nz, 1.0));
586 std::vector<double> C((std::size_t)nx * ny * nz, 0.0);
587 for (int z = 0; z < nz; ++z)
588 for (int y = 0; y < ny; ++y)
589 for (int x = 0; x < nx; ++x)
590 C[idx(x, y, z, nx, ny)] = std::fmin(1.0, std::fmax(0.0, x0 - x));
591 s.enableVof();
592 s.setVof(C);
593 s.setPropertyModel("rho", peclet::flow::ClosureKind::LinearMix, "C", "", {rg, rl - rg});
594 s.enablePhaseChange(rg, rl, 1.0);
596 applyP3fEnv(s);
597 s.setMassFluxUniform(mdot);
598 const double Sc = mdot * (1.0 / rg - 1.0 / rl); // per interfacial cell (A = 1 here)
599 std::vector<double> sink((std::size_t)nx * ny * nz, 0.0);
600 for (int z = 0; z < nz; ++z)
601 for (int y = 0; y < ny; ++y)
602 sink[idx(nx - 6, y, z, nx, ny)] = -Sc;
603 s.setDivergenceSource(sink);
604 s.setPressureFcg(true, 400, 1e-12);
605 for (int k = 0; k < 20; ++k)
606 s.step();
607 const auto u = s.getVelocity(0);
608 const auto src = s.getField("pc_source");
609 const auto usr = s.getField("div_source");
610 const auto v = s.getVelocity(1), w = s.getVelocity(2);
611 const double ug = mdot * (1.0 / rg - 1.0 / rl);
612 double liq = 0.0, plat = 0.0, spread = 0.0, dmax = 0.0;
613 double p0 = u[idx(40, 0, 0, nx, ny)];
614 for (int x = 2; x < 30; ++x)
615 liq = std::fmax(liq, std::fabs(u[idx(x, 0, 0, nx, ny)]));
616 for (int x = 35; x < 56; ++x) {
617 plat += u[idx(x, 0, 0, nx, ny)] / 21.0;
618 spread = std::fmax(spread, std::fabs(u[idx(x, 0, 0, nx, ny)] - p0));
619 }
620 for (int z = 0; z + 1 < nz; ++z)
621 for (int y = 0; y + 1 < ny; ++y)
622 for (int x = 0; x + 1 < nx; ++x) {
623 const double d = (u[idx(x + 1, y, z, nx, ny)] - u[idx(x, y, z, nx, ny)]) +
624 (v[idx(x, y + 1, z, nx, ny)] - v[idx(x, y, z, nx, ny)]) +
625 (w[idx(x, y, z + 1, nx, ny)] - w[idx(x, y, z, nx, ny)]);
626 dmax =
627 std::fmax(dmax, std::fabs(d - src[idx(x, y, z, nx, ny)] - usr[idx(x, y, z, nx, ny)]));
628 }
629 const auto d = s.phaseChangeDiagnostics();
630 std::printf(
631 "P0b ratio 100, closed column + balanced sink: u_gas = %.17g (exact %.17g, rel %.3e), "
632 "plateau spread %.3e, max|u_liquid| %.3e, max|div - S| %.3e, source sum %.6g into %ld "
633 "cells, pressure iters %d\n",
634 plat, ug, (plat - ug) / ug, spread, liq, dmax, d.sourceSum, d.sourceCells,
635 s.lastPressureIterations());
636 CHECK(std::fabs((plat - ug) / ug) < 1e-10);
637 CHECK(liq < 1e-12 * ug + 1e-15);
638 CHECK(dmax < 1e-12);
639 CHECK(s.lastPressureIterations() < 400);
640 // the interfacial cell's own faces carry the LIQUID velocity
641 CHECK(std::fabs(u[idx(32, 0, 0, nx, ny)]) < 1e-15);
642 CHECK(std::fabs(u[idx(33, 0, 0, nx, ny)]) < 1e-15);
643}
644
645// ============================================================ P1: the Stefan problem
646double stefanLambda(double St) { // lambda e^{lambda^2} erf(lambda) = St/sqrt(pi)
647 const double rhs = St / std::sqrt(M_PI);
648 double a = 1e-8, b = 5.0;
649 for (int i = 0; i < 200; ++i) {
650 const double m = 0.5 * (a + b);
651 ((m * std::exp(m * m) * std::erf(m) - rhs) > 0 ? b : a) = m;
652 }
653 return 0.5 * (a + b);
654}
655
656double stefanRun(int N, double& exact) {
657 const double St = 1.0, alpha = 1.0, x0p = 0.10, xep = 0.25, Fo = 0.5;
658 const double lam = stefanLambda(St);
659 const double t0 = (x0p / (2 * lam)) * (x0p / (2 * lam)) / alpha;
660 const double te = (xep / (2 * lam)) * (xep / (2 * lam)) / alpha;
661 const int ny = 4, nz = 4;
662 const double D = alpha * N * N;
663 int ns = (int)std::llround((te - t0) / (Fo / D));
664 const double dt = (te - t0) / ns;
665 peclet::flow::IbmSolver s(N, ny, nz);
666 s.setRho(1.0);
667 s.setMu(1e-3);
668 s.setDt(dt);
669 s.setDomainBc(0, 1, 0, 0, 0);
670 s.setDomainBc(1, 1, 0, 0, 0);
671 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, 1.0));
672 const double xg = x0p * N; // interface position in cells; LIQUID at high x
673 std::vector<double> C((std::size_t)N * ny * nz, 0.0), T((std::size_t)N * ny * nz, 0.0);
674 for (int z = 0; z < nz; ++z)
675 for (int y = 0; y < ny; ++y)
676 for (int x = 0; x < N; ++x) {
677 C[idx(x, y, z, N, ny)] = std::fmin(1.0, std::fmax(0.0, (x + 1) - xg));
678 const double xp = (x + 0.5) / N;
679 T[idx(x, y, z, N, ny)] = xp < x0p ? 1.0 - std::erf(lam * xp / x0p) / std::erf(lam) : 0.0;
680 }
681 s.enableVof();
682 s.setVof(C);
683 s.addScalar("T", D, 1, 60);
684 s.setScalarBc("T", 0, 2, 1.0); // superheated wall
685 s.setScalarBc("T", 1, 2, 0.0); // saturated far field
686 s.setField("T", T);
687 s.enablePhaseChange(1.0, 1.0, 1.0);
689 applyP3fEnv(s);
690 s.setPhaseChangeThermal("T", 0.0, D, D, 0.0);
691 // The rung P0/P1 treatment, pinned explicitly: WO-P23 makes the plane-anchored Dirichlet and the
692 // quadratic fit the DEFAULTS, and this function is kept as the ablation that reproduces the P01
693 // record (its P23 twin is `stefanRunP23`).
694 s.setPhaseChangePlaneDirichlet(false);
695 s.setPhaseChangeQuadraticFit(false);
696 for (int k = 0; k < ns; ++k) {
697 s.applyPhaseChange(dt);
698 s.advanceScalars();
699 }
700 const auto c = s.getVof();
701 double sum = 0;
702 for (double q : c)
703 sum += q;
704 exact = 2 * lam * std::sqrt(alpha * te) * N;
705 const auto d = s.phaseChangeDiagnostics();
706 std::printf(
707 "P1 Stefan N=%d (%d steps, St=1, lambda=%.6f): layer = %.5f cells, exact %.5f, rel %+.4f %% "
708 "; C in [%.3e, %.17g], unresolved %.3e, fallback %ld\n",
709 N, ns, lam, N - sum / (ny * nz), exact, 100.0 * ((N - sum / (ny * nz)) - exact) / exact,
710 d.minC, d.maxC, d.unresolved, d.fallbackCells);
711 CHECK(d.minC >= 0.0);
712 CHECK(d.maxC <= 1.0);
713 return N - sum / (ny * nz);
714}
715
716void p1() {
717 double exact = 0;
718 const double x = stefanRun(64, exact);
719 CHECK(std::fabs(x - exact) / exact < 0.02); // 1.31 % at N = 64; the ladder is in tests/study
720}
721
722// ============================================================ P1 with the WO-P23 defaults
723// The same problem with the plane-anchored (ghost-fluid) Dirichlet and the quadratic one-sided fit.
724// This is a DISCRIMINATING gate, not a repeat: the rung P0/P1 treatment is 1.31 % at N = 64 and the
725// two together are two orders of magnitude better, so a tolerance that only the pair can meet is
726// the statement.
727double stefanRunP23(int N, double& exact) {
728 const double St = 1.0, alpha = 1.0, x0p = 0.10, xep = 0.25, Fo = 0.5;
729 const double lam = stefanLambda(St);
730 const double t0 = (x0p / (2 * lam)) * (x0p / (2 * lam)) / alpha;
731 const double te = (xep / (2 * lam)) * (xep / (2 * lam)) / alpha;
732 const int ny = 4, nz = 4;
733 const double D = alpha * N * N;
734 int ns = (int)std::llround((te - t0) / (Fo / D));
735 const double dt = (te - t0) / ns;
736 peclet::flow::IbmSolver s(N, ny, nz);
737 s.setRho(1.0);
738 s.setMu(1e-3);
739 s.setDt(dt);
740 s.setDomainBc(0, 1, 0, 0, 0);
741 s.setDomainBc(1, 1, 0, 0, 0);
742 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, 1.0));
743 const double xg = x0p * N;
744 std::vector<double> C((std::size_t)N * ny * nz, 0.0), T((std::size_t)N * ny * nz, 0.0);
745 for (int z = 0; z < nz; ++z)
746 for (int y = 0; y < ny; ++y)
747 for (int x = 0; x < N; ++x) {
748 C[idx(x, y, z, N, ny)] = std::fmin(1.0, std::fmax(0.0, (x + 1) - xg));
749 const double xp = (x + 0.5) / N;
750 T[idx(x, y, z, N, ny)] = xp < x0p ? 1.0 - std::erf(lam * xp / x0p) / std::erf(lam) : 0.0;
751 }
752 s.enableVof();
753 s.setVof(C);
754 s.addScalar("T", D, 1, 60);
755 s.setScalarBc("T", 0, 2, 1.0);
756 s.setScalarBc("T", 1, 2, 0.0);
757 s.setField("T", T);
758 s.enablePhaseChange(1.0, 1.0, 1.0);
760 applyP3fEnv(s);
761 s.setPhaseChangeThermal("T", 0.0, D, D, 0.0);
762 s.setPhaseChangePlaneDirichlet(true);
763 s.setPhaseChangeQuadraticFit(true);
764 for (int k = 0; k < ns; ++k) {
765 s.applyPhaseChange(dt);
766 s.advanceScalars();
767 }
768 const auto c = s.getVof();
769 double sum = 0;
770 for (double q : c)
771 sum += q;
772 exact = 2 * lam * std::sqrt(alpha * te) * N;
773 const double layer = N - sum / (ny * nz);
774 const auto d = s.phaseChangeDiagnostics();
775 std::printf(
776 "P1' Stefan N=%d, PLANE-ANCHORED Dirichlet + QUADRATIC fit: layer = %.5f cells, "
777 "exact %.5f, rel %+.4f %% ; C in [%.3e, %.17g]\n",
778 N, layer, exact, 100.0 * (layer - exact) / exact, d.minC, d.maxC);
779 CHECK(d.minC >= 0.0);
780 CHECK(d.maxC <= 1.0);
781 return layer;
782}
783
784void p1p23() {
785 double exact = 0;
786 const double x = stefanRunP23(64, exact);
787 CHECK(std::fabs(x - exact) / exact < 0.001); // measured -0.014 %; the P0/P1 form is +1.31 %
788}
789
790// ============================================================ the consistent rho c_p T transport
791// The decisive identity (the energy twin of WO-K's uniform-velocity gate): a UNIFORM temperature
792// must survive an arbitrary sharp colour field advected by a uniform velocity, at ANY heat capacity
793// ratio. It does iff the heat content is carried by the SAME geometric flux as the colour and
794// recovered with the capacity of the SAME updated colour. An inconsistent scheme is wrong at
795// O(d(rho c_p)).
796void energyIdentity(double rcpRatio) {
797 const int nx = 32, ny = 8, nz = 8;
798 const double T0 = 3.25, U = 0.17;
799 std::vector<double> C((std::size_t)nx * ny * nz, 0.0), T((std::size_t)nx * ny * nz, T0);
800 for (int z = 0; z < nz; ++z)
801 for (int y = 0; y < ny; ++y)
802 for (int x = 0; x < nx; ++x) // an arbitrary sharp, tilted colour field
803 C[idx(x, y, z, nx, ny)] =
804 std::fmin(1.0, std::fmax(0.0, 0.37 * x + 0.21 * y - 0.13 * z - 3.4));
805 peclet::flow::IbmSolver s(nx, ny, nz);
806 s.setRho(1.0);
807 s.setMu(0.0);
808 s.setDt(1.0);
809 s.setPressureGeometry(std::vector<double>((std::size_t)nx * ny * nz, 1.0));
810 s.enableVof();
811 s.setVof(C);
812 s.addScalar("T", 0.0, 1, 1);
813 s.setField("T", T);
814 s.setVelocity(0, std::vector<double>((std::size_t)nx * ny * nz, U));
815 s.enablePhaseChange(1.0, 1.0, 1.0);
817 applyP3fEnv(s);
818 s.setPhaseChangeThermal("T", T0, 1.0, 1.0, 0.0); // T_sat = T0: a uniform field has zero mdot
819 s.setPhaseChangeEnergy(1.0, rcpRatio);
820 double worst = 0.0;
821 for (int k = 0; k < 20; ++k) {
822 s.advectVofKinematic(1.0);
823 const auto t = s.getField("T");
824 for (double q : t)
825 worst = std::fmax(worst, std::fabs(q - T0));
826 }
827 std::printf(
828 "ENERGY uniform-T identity at rho c_p ratio %g: max |T - T0| = %.3e over 20 "
829 "kinematic steps\n",
830 rcpRatio, worst);
831 CHECK(worst == 0.0); // the deviation form makes this BITWISE, not "small"
832}
833
834// ============================================================ P2: the sucking interface
835
836double suckingB(double ja, double rr) {
837 double b = ja / std::sqrt(M_PI);
838 for (int i = 0; i < 300; ++i) {
839 const double g = b * rr;
840 b = ja * std::exp(-g * g) / (std::sqrt(M_PI) * std::erfc(g));
841 }
842 return b;
843}
844
845// Welch & Wilson (JCP 160:662, 2000): saturated vapour against a wall, SUPERHEATED liquid beyond,
846// the interface moving into the liquid and the liquid pushed out through an outlet. All of mdot
847// comes from the LIQUID side, so this is the gate on the liquid half of the fit, the per-phase
848// closures and the consistent rho c_p T transport at once. Full ladder in
849// tests/study/vof_sucking.py.
850void p2(int N) {
851 const double ratio = 10.0, ja = 1.0, alpha_l = 1.0, x0p = 0.10, xep = 0.25, Fo = 0.5, cfl = 0.2;
852 const double rr = 1.0 / ratio, b = suckingB(ja, rr);
853 const double t0 = (x0p / (2 * b)) * (x0p / (2 * b)) / alpha_l;
854 const double te = (xep / (2 * b)) * (xep / (2 * b)) / alpha_l;
855 const int ny = 4, nz = 4;
856 const double al = alpha_l * N * N, rho_l = 1.0, rho_v = rr, cpl = 1.0;
857 const double k_l = al * rho_l * cpl, k_v = k_l / ratio, dT = 1.0;
858 const double h_lv = rho_l * cpl * dT / (rho_v * ja);
859 peclet::flow::IbmSolver s(N, ny, nz);
860 s.setRho(rho_l); // the OUTLET phase: makes the boundary coefficient the varRho one exactly
861 s.setMu(1e-3);
862 s.setDomainBc(0, 1, 0, 0, 0);
863 s.setDomainBc(1, 3, 0, 0, 0);
864 s.setPressureGeometry(std::vector<double>((std::size_t)N * ny * nz, 1.0));
865 const double X0 = x0p * N;
866 std::vector<double> C((std::size_t)N * ny * nz, 0.0), T((std::size_t)N * ny * nz, 0.0);
867 for (int z = 0; z < nz; ++z)
868 for (int y = 0; y < ny; ++y)
869 for (int x = 0; x < N; ++x) {
870 C[idx(x, y, z, N, ny)] = std::fmin(1.0, std::fmax(0.0, (x + 1) - X0));
871 const double xp = (x + 0.5) / N;
872 if (xp > x0p) {
873 const double sv = xp / (2 * std::sqrt(alpha_l * t0)) - b * (1.0 - rr);
874 T[idx(x, y, z, N, ny)] = dT - dT * std::erfc(sv) / std::erfc(b * rr);
875 }
876 }
877 s.enableVof();
878 s.setVof(C);
879 s.setPropertyModel("rho", peclet::flow::ClosureKind::LinearMix, "C", "", {rho_v, rho_l - rho_v});
880 s.setPressureFcg(true, 4000, 1e-10);
881 s.addScalar("T", al, 1, 60);
882 s.setScalarBc("T", 0, 2, 0.0);
883 auto farT = [&](double tt) {
884 const double sv = 1.0 / (2 * std::sqrt(alpha_l * tt)) - b * (1.0 - rr);
885 return dT - dT * std::erfc(sv) / std::erfc(b * rr);
886 };
887 s.setScalarBc("T", 1, 2, farT(t0));
888 s.setField("T", T);
889 s.enablePhaseChange(rho_v, rho_l, h_lv);
891 applyP3fEnv(s);
892 s.setPhaseChangeThermal("T", 0.0, k_v, k_l, 0.0);
893 s.setPhaseChangeEnergy(rho_v * cpl, rho_l * cpl);
894 double tcur = t0;
895 int ns = 0, capped = 0;
896 long itmax = 0;
897 while (tcur < te) {
898 const double Xd = b * std::sqrt(alpha_l / tcur) * N;
899 double dt = std::fmin(cfl / std::fmax(Xd * (1.0 - rr), 1e-30), Fo / al);
900 dt = std::fmin(dt, te - tcur);
901 s.setDt(dt);
902 s.setScalarBc("T", 1, 2, farT(tcur + dt));
903 s.step();
904 tcur += dt;
905 ++ns;
906 itmax = std::max<long>(itmax, s.lastPressureIterations());
907 capped += s.lastPressureIterations() >= 4000 ? 1 : 0;
908 }
909 const auto c = s.getVof();
910 double sum = 0;
911 for (double q : c)
912 sum += q;
913 const double layer = N - sum / (ny * nz);
914 const double exact = 2 * b * std::sqrt(alpha_l * te) * N;
915 const auto d = s.phaseChangeDiagnostics();
916 std::printf(
917 "P2 sucking interface N=%d (%d steps, ratio %g, Ja %g, b = %.6f): layer = %.5f "
918 "cells, exact %.5f, rel %+.4f %% ; pressure iters max %d/4000 (capped %d), "
919 "band_div %.3e, C in [%.3e, %.17g]\n",
920 N, ns, ratio, ja, b, layer, exact, 100.0 * (layer - exact) / exact, (int)itmax, capped,
921 d.bandDiv, d.minC, d.maxC);
922 CHECK(capped == 0); // rule 3b
923 CHECK(std::fabs(layer - exact) / exact < 0.01);
924}
925
926// ============================================================ inertness
927void inert() {
928 const int nx = 32, ny = 4, nz = 4;
929 std::vector<double> C((std::size_t)nx * ny * nz, 0.0);
930 for (int z = 0; z < nz; ++z)
931 for (int y = 0; y < ny; ++y)
932 for (int x = 0; x < nx; ++x)
933 C[idx(x, y, z, nx, ny)] = std::fmin(1.0, std::fmax(0.0, 16.4 - x));
934 peclet::flow::IbmSolver a(nx, ny, nz), b(nx, ny, nz);
935 a.setDt(1.0);
936 b.setDt(1.0);
937 a.enableVof();
938 a.setVof(C);
939 b.enableVof();
940 b.setVof(C);
941 b.enablePhaseChange(1.0, 1.0, 1.0);
943 b.setMassFluxUniform(0.0);
944 for (int k = 0; k < 10; ++k)
945 b.applyPhaseChange(1.0);
946 const auto ca = a.getVof(), cb = b.getVof();
947 double diff = 0;
948 for (std::size_t i = 0; i < ca.size(); ++i)
949 diff = std::fmax(diff, std::fabs(ca[i] - cb[i]));
950 std::printf("INERT mdot == 0: max |C_pc - C_ref| = %.3e (bitwise expected)\n", diff);
951 CHECK(diff == 0.0);
952}
953
954// ============================================== PHASE 3 GATES K4 / K5: anisotropic cells
955//
956// `flow/doc/anisotropic_vof.md` §7 (decision V5) and §11 K4/K5.
957//
958// K4 — the interfacial AREA is a physical area: the unit-cube polygon area times `det(H) s(m)`
959// (the cofactor transformation of the area vector), and the marching-tetrahedra sheet is CUT
960// in index space and MEASURED in physical space.
961// K5 — the one-sided gradient fit must be fed PHYSICAL sample offsets. This is the discriminating
962// gate of the whole phase-change half: with INDEX offsets the fitted `dT/dn` is wrong by the
963// shape factor `s(m)`, which reaches a factor ~2 at the metrics below — so a fit that passes
964// here cannot be reading index distances.
965void anisoAreaAndGradient() {
966 std::printf("\nK4/K5 (Phase 3): anisotropic area and one-sided gradient\n");
967 const double hs[3][3] = {{1.0, 1.0, 1.0}, {1.0, 1.0, 2.0}, {2.0, 1.5, 4.0}};
968 const double ns[4][3] = {
969 {1.0, 0.0, 0.0}, {0.6, -0.8, 0.0}, {0.4243, -0.7071, 0.5657}, {0.5774, 0.5774, 0.5774}};
970
971 // ---- K4a: plicAreaMetric against the analytic area of the plane's cross-section --------------
972 double worstA = 0.0;
973 long bitA = 0, totA = 0;
974 for (int q = 0; q < 3; ++q) {
975 const VofMetric g{{hs[q][0], hs[q][1], hs[q][2]}};
976 for (int p = 0; p < 4; ++p) {
977 const double* n = ns[p];
978 // the plane n.x = d through the cell centre: index normal m = H n
979 double m[3];
980 vofIndexNormal(n, g, m);
981 const double alpha = 0.5 * (m[0] + m[1] + m[2]);
982 const double got = plicAreaMetric(m[0], m[1], m[2], alpha, g);
983 // ORACLE: the area of the cross-section of the BOX [0,h0]x[0,h1]x[0,h2] by a plane through
984 // its centre with unit normal n, by direct 2-D quadrature of the projected footprint.
985 // A = (footprint area on the plane most normal to n) / |n . e_d|
986 int d = 0;
987 for (int k = 1; k < 3; ++k)
988 if (std::fabs(n[k]) > std::fabs(n[d]))
989 d = k;
990 const int t1 = (d + 1) % 3, t2 = (d + 2) % 3;
991 const int sub = 2000;
992 const double w1 = g.h[t1] / sub, w2 = g.h[t2] / sub;
993 long inside = 0;
994 for (int b = 0; b < sub; ++b)
995 for (int a = 0; a < sub; ++a) {
996 double x[3];
997 x[t1] = (a + 0.5) * w1;
998 x[t2] = (b + 0.5) * w2;
999 // solve n.x = d_plane for x[d]
1000 const double dPlane = 0.5 * (n[0] * g.h[0] + n[1] * g.h[1] + n[2] * g.h[2]);
1001 x[d] = (dPlane - n[t1] * x[t1] - n[t2] * x[t2]) / n[d];
1002 if (x[d] >= 0.0 && x[d] <= g.h[d])
1003 ++inside;
1004 }
1005 const double proj = (double)inside * w1 * w2;
1006 const double ref = proj / std::fabs(n[d]);
1007 const double rel = std::fabs(got - ref) / std::fmax(ref, 1e-12);
1008 worstA = std::fmax(worstA, rel);
1009 // RULE B on the isotropic metric: exactly plicArea
1010 if (q == 0) {
1011 ++totA;
1012 if (got == plicArea(m[0], m[1], m[2], alpha))
1013 ++bitA;
1014 }
1015 }
1016 }
1017 std::printf(
1018 " K4a plicAreaMetric vs a 2000^2 footprint quadrature: max rel %.3e (gate 2e-3,\n"
1019 " the quadrature's own 1/sub floor); isotropic == plicArea bitwise %ld/%ld\n",
1020 worstA, bitA, totA);
1021 CHECK(worstA < 2e-3);
1022 CHECK(bitA == totA);
1023
1024 // ---- K4b: the JOINED marching-tetrahedra sheet on a stretched dual cube -----------------------
1025 double worstMc = 0.0;
1026 long bitMc = 0, totMc = 0;
1027 for (int q = 0; q < 3; ++q) {
1028 const VofMetric g{{hs[q][0], hs[q][1], hs[q][2]}};
1029 for (int p = 0; p < 4; ++p) {
1030 const double* n = ns[p];
1031 double m[3];
1032 vofIndexNormal(n, g, m);
1033 const double qn = std::sqrt(m[0] * m[0] + m[1] * m[1] + m[2] * m[2]);
1034 const double mu[3] = {m[0] / qn, m[1] / qn, m[2] / qn}; // L2-unit INDEX normal
1035 for (double off = -0.3; off <= 0.31; off += 0.15) {
1036 McVertex v[8];
1037 bool crossed = false;
1038 for (int k = 0; k < 8; ++k) {
1039 double pc[3];
1041 // an exact index-space signed distance to the plane through the cube centre + off
1042 // The index-space distance to the plane, on the L2-unit index normal (so `psi` is a
1043 // true distance and `kMcSrcPlic` interpolates it exactly, as the isotropic gate does).
1044 const double phi =
1045 mu[0] * (pc[0] - 0.5) + mu[1] * (pc[1] - 0.5) + mu[2] * (pc[2] - 0.5) - off;
1046 v[k].psi = phi;
1047 v[k].d = phi;
1048 v[k].n[0] = mu[0];
1049 v[k].n[1] = mu[1];
1050 v[k].n[2] = mu[2];
1051 v[k].has = true;
1052 if (k && ((v[k].psi < 0.0) != (v[0].psi < 0.0)))
1053 crossed = true;
1054 }
1055 if (!crossed)
1056 continue;
1057 // exact: the unit-cube polygon area of the same plane, mapped by det(H) s(m)
1058 const double alpha = 0.5 * (mu[0] + mu[1] + mu[2]) + off;
1059 const double exact = plicAreaMetric(mu[0], mu[1], mu[2], alpha, g);
1060 if (!(exact > 1e-6))
1061 continue;
1062 double sum = 0.0, sumIso = 0.0;
1063 for (int lc = 0; lc < 8; ++lc) {
1064 sum += mcCubeCornerArea(v, lc, kMcSrcPlic, kMcDepositCentroid, g);
1065 sumIso += mcCubeCornerArea(v, lc, kMcSrcPlic, kMcDepositCentroid);
1066 }
1067 worstMc = std::fmax(worstMc, std::fabs(sum - exact) / exact);
1068 if (q == 0) {
1069 ++totMc;
1070 if (sum == sumIso)
1071 ++bitMc;
1072 }
1073 }
1074 }
1075 }
1076 std::printf(
1077 " K4b joined sheet on a stretched plane: max rel |sum - exact| %.3e (gate 1e-13);"
1078 "\n isotropic == the unit-metric overload bitwise %ld/%ld\n",
1079 worstMc, bitMc, totMc);
1080 CHECK(worstMc < 1e-13);
1081 CHECK(bitMc == totMc);
1082
1083 // ---- K5: the one-sided fit on PHYSICAL offsets ------------------------------------------------
1084 //
1085 // Manufactured: a plane interface through the centre cell, `T = T_G + G phi_phys` in the gas.
1086 // The linear fit through the interface value is EXACT for a linear profile — but only if the
1087 // sample distances are the physical ones. The `index` column is the same computation with the
1088 // raw integer offsets: its error is the shape factor and is what this gate excludes.
1089 double worstPhys = 0.0, worstIdx = 0.0;
1090 for (int q = 1; q < 3; ++q) { // q = 0 is isotropic, where the two agree by construction
1091 const VofMetric g{{hs[q][0], hs[q][1], hs[q][2]}};
1092 for (int p = 0; p < 4; ++p) {
1093 const double* n = ns[p];
1094 double m[3];
1095 vofIndexNormal(n, g, m);
1096 double np[3];
1097 const double sMet = vofPhysNormal(m, g, np);
1098 CHECK(sMet > 0.0);
1099 const double alpha = 0.5 * (m[0] + m[1] + m[2]); // plane through the centre cell's centre
1100 const double phic = pcCentreDistance(m[0], m[1], m[2], alpha) / sMet;
1101 const double Gtrue = 3.7, Tg = 1.5;
1102 PcGradFit fp, fi;
1103 for (int dz = -2; dz <= 2; ++dz)
1104 for (int dy = -2; dy <= 2; ++dy)
1105 for (int dx = -2; dx <= 2; ++dx) {
1106 if (dx == 0 && dy == 0 && dz == 0)
1107 continue;
1108 const double di[3] = {(double)dx, (double)dy, (double)dz};
1109 double dp[3];
1110 g.toPhys(di, dp);
1111 const double phiP = pcOffsetDistance(phic, np, dp[0], dp[1], dp[2]);
1112 if (!(phiP > 0.0))
1113 continue; // gas side only
1114 const double T = Tg + Gtrue * phiP; // the manufactured PHYSICAL profile
1115 const double wP = pcGradWeight(dp[0], dp[1], dp[2], np);
1116 if (wP > 0.0)
1117 pcGradAdd(fp, wP, phiP, T, Tg);
1118 // the same samples read with INDEX distances (the pre-Phase-3 arithmetic)
1119 const double phiI = pcOffsetDistance(pcCentreDistance(m[0], m[1], m[2], alpha), np,
1120 (double)dx, (double)dy, (double)dz);
1121 const double wI = pcGradWeight(dx, dy, dz, np);
1122 if (wI > 0.0)
1123 pcGradAdd(fi, wI, phiI, T, Tg);
1124 }
1125 if (fp.n >= 3) {
1126 worstPhys = std::fmax(worstPhys, std::fabs(pcGradSolve(fp) - Gtrue) / Gtrue);
1127 worstIdx = std::fmax(worstIdx, std::fabs(pcGradSolve(fi) - Gtrue) / Gtrue);
1128 }
1129 }
1130 }
1131 std::printf(
1132 " K5 one-sided dT/dn on a linear profile: PHYSICAL offsets rel err %.3e"
1133 " (gate 1e-12)\n the same fit on INDEX offsets: %.3e <- what the metric"
1134 " removes\n",
1135 worstPhys, worstIdx);
1136 CHECK(worstPhys < 1e-12);
1137 CHECK(worstIdx > 1e-2); // the gate is only discriminating while the two genuinely differ
1138}
1139
1140} // namespace
1141
1142int main(int argc, char** argv) {
1143 Kokkos::initialize(argc, argv);
1144 {
1145 areaGate();
1146 areaModeGate();
1147 mcSheetGate();
1148 gradientGate();
1149 quadraticGradientGate();
1150 gfmThetaGate();
1151 gfmSecondOrderGate();
1152 anisoAreaAndGradient();
1153 p0a();
1154 p0b();
1155 p1();
1156 p1p23();
1157 energyIdentity(1e4);
1158 p2(64);
1159 inert();
1160 }
1161 Kokkos::finalize();
1162 if (failures) {
1163 std::fprintf(stderr, "%d CHECK(s) failed\n", failures);
1164 return 1;
1165 }
1166 std::printf("all phase-change gates passed\n");
1167 return 0;
1168}
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
exact(Ds, K, hc, gs, nmax=1)
Definition conj.py:29
double pcGradSolve2(const PcGradFit &f)
The QUADRATIC one-sided fit (WO-P23): T - T_G = G phi + Q phi^2, returning G.
void pcGradAdd(PcGradFit &f, double w, double phi, double T, double Tg)
Add one pure-phase sample at normal distance phi with temperature T.
double pcCentreDistance(double mx, double my, double mz, double alpha)
Signed distance (in cells) from the CELL CENTRE to the PLIC plane, measured along n and POSITIVE ON T...
double pcGfmTheta(double phiC, double nd, double s, double thetaMin, double thetaMax)
thetaMin keeps the coefficient finite when the plane grazes the cell centre (the standard GFM clamp);...
@ kMcDepositSplit
clipped to each cell's cube, each cell gets its own piece
@ kMcDepositCentroid
the whole triangle to the cell holding its centroid
PcGfmRow pcGfmRow(double theta, bool behind, int order)
double hfAreaElement(const double h[9])
The height function's area element sqrt(1 + h_x^2 + h_y^2), with the same central differences hfPatch...
double pcOffsetDistance(double phic, const double n[3], double dx, double dy, double dz)
Signed distance from the plane of the neighbour at INTEGER cell offset d, given the centre distance p...
double plicArea(double mx, double my, double mz, double alpha)
Area of the PLIC polygon {x in [0,1]^3 : m.x = alpha}, as a multiple of h^2.
double pcGfmThetaK(double phiC, double nd, double s, double kappa, double thetaMin, double thetaMax)
WO-P3g item 3 — the curvature-consistent GFM distance.
@ kAreaNormal
variant B: plicArea rebuilt on the cascade's normal.
@ kAreaMetric
variant A: the PLIC footprint x the cascade's slope. DEFAULT.
@ kAreaPlic
rung P0/P1: plicArea on the MYC normal. The old numbers.
@ kMcSrcColour
the C = 1/2 level set (the WO's design)
@ kMcSrcPlic
the zero of the PLIC-reconstructed signed distance (the smooth variant)
double plicAreaMetric(double mx, double my, double mz, double alpha, const VofMetric &g)
The PHYSICAL area of the PLIC polygon of an ANISOTROPIC cell, in hRef^2 (flow/doc/anisotropic_vof....
void pvSurfaceNormal(const double a[6], const double t1[3], const double t2[3], const double nn[3], double m[3])
The unit normal of the fitted paraboloid z = a0 + a1 x + a2 y + ... AT THE ORIGIN of its own frame (t...
double interfaceAreaFromNormal(int mode, double mx, double my, double mz, double alpha, double c, const double ns[3], const VofMetric &g)
The projection axis of kAreaMetric is the one ns is most aligned with — the column direction on tiers...
void mcCornerPos(int k, double p[3])
Local coordinates of dual-cube corner k (bit 0 = x, bit 1 = y, bit 2 = z).
void hfSurfaceNormal(const double h[9], int d, double m[3])
The unit surface normal implied by the SAME 3x3 patch of heights, in world components.
double pcGradSolve(const PcGradFit &f)
The fitted normal derivative dT/dn (0 if the side carried no usable sample).
double pcUnitNormal(double mx, double my, double mz, double n[3])
Unit (L2) interface normal from a PLIC normal of any scale.
double mcCubeCornerArea(const McVertex v[8], int lc, int src, int deposit, const VofMetric &g)
The interfacial area inside one dual cube that belongs to its local corner lc.
double pcGradWeight(double dx, double dy, double dz, const double n[3])
Malan's collinearity weight for a sample at cell offset d: w = xi / |d| with xi = (d....
fit(a, t, t0=40.0, t1=160.0)
int D
Definition tracers.py:128
One dual-cube corner = one cell centre.
Accumulator for dT/dn fitted through the interface value: T - T_G = G * phi, so G = sum_j w_j phi_j (...
static constexpr int N
static double X0()
static constexpr double U
#define CHECK(cond)
void applyP3fEnv(S &s)
static double stefanLambda(double St)
void applyAreaModeEnv(S &s)