flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
mac_approx_projection.hpp
Go to the documentation of this file.
1
12#ifndef PECLET_FLOW_MAC_APPROX_PROJECTION_HPP
13#define PECLET_FLOW_MAC_APPROX_PROJECTION_HPP
14
15#include <Kokkos_Core.hpp>
16
17#include "mac_cutcell.hpp" // peclet::flow::C3, CCField, CCConst, CCExec, ccSampleExt
18
19namespace peclet::flow {
20
21// const view of the (float) IBM stencil coefficients (same alias mac_ibm.hpp defines; this header
22// is included first, so declare it here for the mode-4 stencilMatvec).
23using MConst = Kokkos::View<const float*, CCMem>;
24
25// Average cell-centered velocities onto the staggered face layout: uf(i) is the velocity at the low
26// (-x) face of cell i (located at i-1/2) = ½(U(i-1)+U(i)); likewise vf/wf along y/z. This
27// reproduces the exact layout the staggered solver stores directly, so divergOpen / projectCorrect
28// (mac_pressure.hpp) act on uf/vf/wf unchanged. Computed over the block where the axis neighbour
29// exists ([1,e) per axis); the cell velocity ghosts must be filled first.
31 int g) {
32 (void)g;
34 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
35 Kokkos::parallel_for(
36 "peclet::flow::center_to_face", MD(space, {1, 1, 1}, {e.x, e.y, e.z}),
37 KOKKOS_LAMBDA(int x, int y, int z) {
38 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
39 const long i = (long)x + (long)y * sy + (long)z * sz;
40 uf(i) = 0.5 * (U(i) + U(i - sx));
41 vf(i) = 0.5 * (V(i) + V(i - sy));
42 wf(i) = 0.5 * (W(i) + W(i - sz));
43 });
44}
45
46// Wall-aware variant of centerToFace (opt-in: Solver::setFaceInterp(1); collocated only). At a face
47// whose adjacent cell centers straddle the immersed boundary (one SDF negative), the plain average
48// ½(U_fluid + U_solid=0) implicitly places the wall AT THE SOLID CELL CENTER (theta = 1) regardless
49// of the true axis intercept theta = sdf_c/(sdf_c - sdf_n) — an O(1) relative flux error at every
50// cut face that makes the collocated drag first-order (doc/collocated_first_order_analysis.md).
51// Here the face value is reconstructed from the fluid side by a WALL-ANCHORED quadratic in
52// (theta - s) — exactly zero at the wall — least-squares-fitted to the fluid-side samples
53// {U_c, U_far, U_far2} with the near-wall cell SHED smoothly as theta -> 0 (weight
54// theta²/(theta²+0.15²)): a sliver cell's value is slaved to the wall by the momentum IBM and
55// carries no independent information, so downweighting it bounds the noise amplification without
56// dropping the order — the theta->0 limit is the (still 2nd-order) quadratic through
57// {wall, far, far2}. A small Tikhonov term on the curvature keeps the 2x2 solve well-posed when
58// far2 is unavailable; a fluid cell sandwiched along the axis falls back to the wall-anchored
59// linear through U_c (bounded: xe/theta <= 1/2). Faces whose center lies behind the wall
60// (theta <= 1/2) get 0, continuously (the parabola vanishes at the wall); their open area is a
61// corner sliver. Interior fluid faces reproduce the plain average bit-exactly. Validated a priori
62// against analytic Stokes flow past a sphere: face-value error O(h²) (the plain average is O(h)).
63// Per-face stencil of the wall-aware map: up to 3 (cell index, weight) pairs such that
64// uf(face) = sum_k w[k]*U(idx[k]); returns the entry count (0 = closed face or corner sliver:
65// uf = 0). Shared by the forward map (centerToFaceWallAware) and its TRANSPOSE
66// (transposeGradWallAware) so the two stay exact adjoints — the accuracy of the constrained steady
67// state hinges on that pairing, not on the forward truncation alone.
69 double xcen, long idx[3], double w[3]) {
70 const double sm = sdf(i - sa), sp = sdf(i); // low cell (coord cFace-1) / high cell (cFace)
71 const bool fm = sm >= 0.0, fp = sp >= 0.0;
72 if (fm && fp) { // both fluid: the plain 1/2-1/2 average, unchanged
73 idx[0] = i;
74 w[0] = 0.5;
75 idx[1] = i - sa;
76 w[1] = 0.5;
77 return 2;
78 }
79 if (!fm && !fp)
80 return 0; // fully solid face (openness 0; value irrelevant)
81 const long ic = fp ? i : i - sa; // the fluid cell
82 const double sc = fp ? sp : sm, ss = fp ? sm : sp;
83 double th = sc / (sc - ss); // axis intercept from the fluid center (ibmFillEntry convention)
84 th = th < 1e-4 ? 1e-4 : (th > 1.0 ? 1.0 : th);
85 // Evaluation abscissa in wall coordinates (theta - s): the face-CENTER distance theta - 1/2 by
86 // default, or (xcen >= 0, mode 3) the OPEN-FACE-CENTROID wall distance from the static geometry
87 // build. The projection's flux model is o_f * uf, so the correct uf is the open-area MEAN of the
88 // velocity — the midpoint-at-centroid quadrature — not the face-center point value: the open area
89 // lies on the fluid side, farther from the wall, so the (accurate) face-center value UNDERCOUNTS
90 // the flux at O(h). Measured a priori (Stokes-past-sphere): centroid evaluation drops the
91 // systematic flux bias by ~2 orders of magnitude at N=128.
92 const double xe = (xcen >= 0.0) ? xcen : th - 0.5;
93 if (xe <= 0.0)
94 return 0; // face (or its open centroid) behind the wall
95 const int cc = fp ? cFace : cFace - 1; // fluid cell's axis coordinate
96 const int up = fp ? 1 : -1; // coordinate step AWAY from the solid
97 const long dir = fp ? -sa : sa; // index step TOWARD the solid
98 const bool ok1 = (cc + up >= 0) && (cc + up < eAxis) && sdf(ic - dir) >= 0.0;
99 if (!ok1) { // sandwiched along this axis: wall-anchored linear through U_c
100 idx[0] = ic;
101 w[0] = Kokkos::fmin(xe / th, 2.0); // capped (xe can exceed theta at centroid evaluation)
102 return 1;
103 }
104 const bool ok2 = (cc + 2 * up >= 0) && (cc + 2 * up < eAxis) && sdf(ic - 2 * dir) >= 0.0;
105 const double wc = th * th / (th * th + 0.0225); // shed the sliver cell (theta* = 0.15)
106 const double w2 = ok2 ? 0.25 : 0.0;
107 const double xc = th, xf = th + 1.0, xg = th + 2.0;
108 const double S11 = wc * xc * xc + xf * xf + w2 * xg * xg;
109 const double S12 = wc * xc * xc * xc + xf * xf * xf + w2 * xg * xg * xg;
110 const double S22 = wc * xc * xc * xc * xc + xf * xf * xf * xf + w2 * xg * xg * xg * xg + 0.01;
111 const double det = S11 * S22 - S12 * S12;
112 // coefficient of datum k (abscissa x_k, weight w_k) in T = a*xe + b*xe^2:
113 // c_k = w_k * [ x_k*(S22*xe - S12*xe^2) + x_k^2*(S11*xe^2 - S12*xe) ] / det
114 const double A = (S22 * xe - S12 * xe * xe) / det, B = (S11 * xe * xe - S12 * xe) / det;
115 idx[0] = ic;
116 w[0] = wc * (xc * A + xc * xc * B);
117 idx[1] = ic - dir;
118 w[1] = xf * A + xf * xf * B;
119 if (!ok2)
120 return 2;
121 idx[2] = ic - 2 * dir;
122 w[2] = w2 * (xg * A + xg * xg * B);
123 return 3;
124}
125
127 int eAxis, double xcen) {
128 long idx[3];
129 double w[3];
130 const int n = wallAwareFaceStencil(sdf, i, sa, cFace, eAxis, xcen, idx, w);
131 double v = 0.0;
132 for (int k = 0; k < n; ++k)
133 v += w[k] * U(idx[k]);
134 return v;
135}
136
137// Static per-face geometry for the mode-3 centroid quadrature: for each face whose adjacent cell
138// centers straddle the boundary, subsample the face plane (4x4 trilinear SDF), locate the OPEN-area
139// centroid, and store its axis wall-distance (in cells, measured toward the solid side; two-point
140// line intercept from the trilinear SDF, clamped to [0,2]). 0 => no open samples (no flux). Faces
141// with both centers on one side store -1 (sentinel: stencil falls back to theta-1/2, which the
142// both-fluid branch never reads anyway). One-time cost at setSolid; the geometry is static.
145 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
146 Kokkos::parallel_for(
147 "peclet::flow::face_centroid_dist", MD(space, {1, 1, 1}, {e.x, e.y, e.z}),
148 KOKKOS_LAMBDA(int x, int y, int z) {
149 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
150 const long i = (long)x + (long)y * sy + (long)z * sz;
151 // face center in the sampling frame where CELL CENTERS sit at integer coordinates
152 // (buildOpenness convention): -x face of cell (x,y,z) is at (x-1/2, y, z), etc.
153 const double fc[3][3] = {{x - 0.5, (double)y, (double)z},
154 {(double)x, y - 0.5, (double)z},
155 {(double)x, (double)y, z - 0.5}};
156 const long st[3] = {sx, sy, sz};
157 CCField out[3] = {xcx, xcy, xcz};
158 for (int a = 0; a < 3; ++a) {
159 const double sm = sdf(i - st[a]), sp = sdf(i);
160 if ((sm >= 0.0) == (sp >= 0.0)) {
161 out[a](i) = -1.0; // not a solid-bordering face: stencil never reads it
162 continue;
163 }
164 const double dir = (sm < 0.0) ? -1.0 : 1.0; // axis step TOWARD the solid side
165 const int t1 = (a + 1) % 3, t2 = (a + 2) % 3;
166 double c1 = 0.0, c2 = 0.0;
167 int nOpen = 0;
168 for (int j1 = 0; j1 < 4; ++j1)
169 for (int j2 = 0; j2 < 4; ++j2) {
170 const double o1 = -0.375 + 0.25 * j1, o2 = -0.375 + 0.25 * j2;
171 double p[3] = {fc[a][0], fc[a][1], fc[a][2]};
172 p[t1] += o1;
173 p[t2] += o2;
174 if (ccSampleExt(sdf, e, p[0], p[1], p[2]) > 0.0) {
175 c1 += o1;
176 c2 += o2;
177 ++nOpen;
178 }
179 }
180 if (nOpen == 0) {
181 out[a](i) = 0.0; // fully covered face: zero flux
182 continue;
183 }
184 double p0[3] = {fc[a][0], fc[a][1], fc[a][2]};
185 p0[t1] += c1 / nOpen;
186 p0[t2] += c2 / nOpen; // open-area centroid on the face
187 double p1[3] = {p0[0], p0[1], p0[2]};
188 p1[a] += dir; // one cell toward the solid
189 const double s0 = ccSampleExt(sdf, e, p0[0], p0[1], p0[2]);
190 const double s1 = ccSampleExt(sdf, e, p1[0], p1[1], p1[2]);
191 double d = (s0 > 0.0 && s0 - s1 > 1e-12) ? s0 / (s0 - s1) : 0.0;
192 out[a](i) = d < 0.0 ? 0.0 : (d > 2.0 ? 2.0 : d); // cap at 2 cells: never leaves the
193 // fitted data range [theta, theta+2], so the stencil weights stay interpolation-bounded
194 }
195 });
196}
197
198// centerToFace with the wall-aware reconstruction at solid-bordering faces (see above). The SDF is
199// the cell-centered field (ghosts filled); U/V/W ghosts must be filled first, as for centerToFace.
200// useCen (mode 3): evaluate at the stored open-centroid wall distance xcx/xcy/xcz instead of the
201// face center (the flux-consistent quadrature).
204 bool useCen, C3 e, int g) {
205 (void)g;
207 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
208 Kokkos::parallel_for(
209 "peclet::flow::center_to_face_wall", MD(space, {1, 1, 1}, {e.x, e.y, e.z}),
210 KOKKOS_LAMBDA(int x, int y, int z) {
211 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
212 const long i = (long)x + (long)y * sy + (long)z * sz;
213 uf(i) = wallAwareFaceValue(U, sdf, i, sx, x, e.x, useCen ? xcx(i) : -1.0);
214 vf(i) = wallAwareFaceValue(V, sdf, i, sy, y, e.y, useCen ? xcy(i) : -1.0);
215 wf(i) = wallAwareFaceValue(W, sdf, i, sz, z, e.z, useCen ? xcz(i) : -1.0);
216 });
217}
218
219// The O-WEIGHTED EXACT ADJOINT of the wall-aware constraint, applied to a cell field p:
220// out(i) = sum over the (up to 6) axis faces f whose stencil references cell i of
221// c_i(f)·o(f)·(p(f) - p(f-sa)) — i.e. Cᵀp for the constraint C = D·diag(o)·T. For interior fluid
222// cells (o = 1, ½/½ weights) this is exactly the central difference ½(p(i+sa) - p(i-sa)).
223//
224// WHY the o-weighting is essential (learned the hard way): the wall-anchored T deliberately has
225// row sums != 1 at cut faces (the no-slip anchor pulls them down), so the UNWEIGHTED transpose
226// force Tᵀ·G·P no longer telescopes to zero over a periodic box — the fluid feels a net spurious
227// pressure force proportional to the near-wall gradients, which feeds back through the flow and
228// DIVERGES (the mode-3 runaway; capping the abscissa does not help because the weights were never
229// the problem). With the exact adjoint the pressure does NO work on the constraint manifold
230// ((u, Cᵀp) = (Cu, p) = 0), cutting the feedback unconditionally, and o·T·Tᵀ stays
231// interpolation-bounded so the per-step projection remains contractive. Used (setFaceInterp(2/3))
232// for BOTH the incremental predictor's -grad(P) and the projection's cell correction: at steady
233// state the predictor gradient IS the pressure force, so it must be the adjoint of the constraint —
234// upgrading T alone (mode 1) measurably WORSENS the drag.
236 bool useCen, int axis, C3 e, int g) {
238 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
239 Kokkos::parallel_for(
240 "peclet::flow::transpose_grad_wall", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
241 KOKKOS_LAMBDA(int x, int y, int z) {
242 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
243 const long i = (long)x + (long)y * sy + (long)z * sz;
244 const long sa = (axis == 0) ? sx : (axis == 1) ? sy : sz;
245 const int eA = (axis == 0) ? e.x : (axis == 1) ? e.y : e.z;
246 const int ca = (axis == 0) ? x : (axis == 1) ? y : z;
247 double acc = 0.0;
248 long idx[3];
249 double w[3];
250 for (int m = -2; m <= 3; ++m) { // all faces whose stencil can reference this cell
251 const int cf = ca + m;
252 if (cf < 1 || cf >= eA)
253 continue;
254 const long f = i + (long)m * sa;
255 const int n = wallAwareFaceStencil(sdf, f, sa, cf, eA, useCen ? xc(f) : -1.0, idx, w);
256 for (int k = 0; k < n; ++k)
257 if (idx[k] == i)
258 acc += w[k] * o(f) * (p(f) - p(f - sa));
259 }
260 out(i) = acc;
261 });
262}
263
264// Cell fluid volume fraction cs (mode 4): 1 in clear fluid, 0 in solid, subsampled (4x4x4 trilinear
265// SDF) at cut cells. Static, one-time at setSolid. Used to weight the FV momentum time term and the
266// body/pressure forcing so a cut cell is driven over its FLUID volume only (omitting cs is an O(h)
267// surface error — part of what keeps modes 0-3 first-order).
268inline void buildCellFraction(CCField cs, CCConst sdf, C3 e, int g) {
270 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
271 Kokkos::parallel_for(
272 "peclet::flow::cell_fraction", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
273 KOKKOS_LAMBDA(int x, int y, int z) {
274 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
275 const double sc = sdf(i);
276 // fully clear one cell away on all axes -> not a cut cell; cheap exact classification
277 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
278 const bool f0 = sc >= 0.0;
279 const bool cut = (sdf(i + sx) >= 0.0) != f0 || (sdf(i - sx) >= 0.0) != f0 ||
280 (sdf(i + sy) >= 0.0) != f0 || (sdf(i - sy) >= 0.0) != f0 ||
281 (sdf(i + sz) >= 0.0) != f0 || (sdf(i - sz) >= 0.0) != f0;
282 if (!cut) {
283 cs(i) = sc >= 0.0 ? 1.0 : 0.0;
284 return;
285 }
286 int nf = 0;
287 for (int j0 = 0; j0 < 4; ++j0)
288 for (int j1 = 0; j1 < 4; ++j1)
289 for (int j2 = 0; j2 < 4; ++j2) {
290 const double o0 = -0.375 + 0.25 * j0, o1 = -0.375 + 0.25 * j1,
291 o2 = -0.375 + 0.25 * j2;
292 if (ccSampleExt(sdf, e, x + o0, y + o1, z + o2) > 0.0)
293 ++nf;
294 }
295 cs(i) = nf / 64.0;
296 });
297}
298
299// FINITE-VOLUME MOMENTUM OPERATOR (mode 4): applies L_FV(U) over the fluid control volume of each
300// cell (the centroid wall-gradient is validated a priori in tests/study/fv_wallflux_apriori.py):
301// L_FV(U)_i = idt·cs_i·U_i + mu·[ Σ_f o_f·(U_i − U_nbr) + Σ_a W_a·g_a^centroid(U) ]
302// The o_f-weighted two-point face fluxes + the fragment-centroid wall drag = the same finite-volume
303// boundary geometry the projection uses. In the interior (cs=1, o_f=1, W=0) this is exactly the
304// backward-Euler diffusion operator idt·U − mu·Lap(U) — identical to the IBM matrix M there, so the
305// defect correction (M·u − L_FV·u) vanishes and interior cells stay byte-identical to mode 0.
307 CCConst oz, double mu, double idt, C3 e, int g) {
309 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
310 Kokkos::parallel_for(
311 "peclet::flow::fv_viscous_apply", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
312 KOKKOS_LAMBDA(int x, int y, int z) {
313 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
314 const long st[3] = {sx, sy, sz};
315 const long i = (long)x + (long)y * sy + (long)z * sz;
316 CCConst oa[3] = {ox, oy, oz};
317 double W[3], of[3]; // fragment normal per axis; low/high face openness
318 double diag = 0.0, offs = 0.0, aw = 0.0;
319 for (int a = 0; a < 3; ++a) {
320 const double om = oa[a](i), op = oa[a](i + st[a]);
321 W[a] = om - op;
322 of[a] = om + op; // low + high face openness
323 diag += om + op;
324 offs += om * U(i - st[a]) + op * U(i + st[a]);
325 aw += (W[a] < 0.0 ? -W[a] : W[a]);
326 }
327 double wall = 0.0;
328 if (aw > 1e-12) { // centroid-anchored wall drag mu·Σ_a W_a g_a (a-priori-validated:
329 // fv_wallflux_apriori.py)
330 double nx = 0.5 * (sdf(i + sx) - sdf(i - sx));
331 double ny = 0.5 * (sdf(i + sy) - sdf(i - sy));
332 double nz = 0.5 * (sdf(i + sz) - sdf(i - sz));
333 double nn = Kokkos::sqrt(nx * nx + ny * ny + nz * nz);
334 if (nn > 1e-12) {
335 nx /= nn;
336 ny /= nn;
337 nz /= nn;
338 const double nv[3] = {nx, ny, nz};
339 const double sdi = sdf(i);
340 // foot point p* = x − sdi·n̂, clamped into the sampleable block [1, e−2] so the two
341 // off-cell trilinear taps (p*+2σ) never index outside the padded field (guards a NaN/
342 // large sdi too — ccSampleExt only clamps integer indices, not a NaN coordinate).
343 auto cl = [](double v, double hi) { return v < 1.0 ? 1.0 : (v > hi ? hi : v); };
344 const double px = cl(x - sdi * nx, e.x - 2.0);
345 const double py = cl(y - sdi * ny, e.y - 2.0);
346 const double pz = cl(z - sdi * nz, e.z - 2.0);
347 for (int a = 0; a < 3; ++a) {
348 const double sg = nv[a] >= 0.0 ? 1.0 : -1.0;
349 const double u1 = ccSampleExt(U, e, px + (a == 0 ? sg : 0.0),
350 py + (a == 1 ? sg : 0.0), pz + (a == 2 ? sg : 0.0));
351 const double u2 =
352 ccSampleExt(U, e, px + (a == 0 ? 2.0 * sg : 0.0), py + (a == 1 ? 2.0 * sg : 0.0),
353 pz + (a == 2 ? 2.0 * sg : 0.0));
354 wall += W[a] * sg * (2.0 * u1 - 0.5 * u2);
355 }
356 }
357 }
358 // FV viscous operator = idt·cs·U + μ·[Σo_f(U_i−U_nbr) − Σ_a W_a g_a]. The wall term sign
359 // is −μ Σ W_a g_a: from ∫_CV −μ∇²u = −μ[flux_out − flux_in], the wall flux g_a enters with
360 // a minus, so a resistive wall (∂u/∂n<0) ADDS to the operator (dissipative), matching the
361 // interior −μLap. (+ would be anti-dissipative and blows the solve up.)
362 Lu(i) = idt * cs(i) * U(i) + mu * (diag * U(i) - offs - wall);
363 (void)of;
364 });
365}
366
367// Lagrange quadratic through (-1,a1),(0,a2),(+1,a3) evaluated at xx (Basilisk embed.h `quadratic`).
368KOKKOS_INLINE_FUNCTION double eQuad(double xx, double a1, double a2, double a3) {
369 return (a1 * (xx - 1.0) + a3 * (xx + 1.0)) * xx * 0.5 - a2 * (xx - 1.0) * (xx + 1.0);
370}
371
372// TRUE-NORMAL wall gradient d(U)/dn at the embedded boundary of a cut cell — the Basilisk embed.h
373// `dirichlet_gradient` (no-slip U_wall = 0), in cell units (h = 1). n̂ = unit inward-to-FLUID normal
374// (∇sdf), p = boundary-point offset from the cell centre (cell units). Along the dominant-|n̂| axis
375// it places two image points 1 and 2 cells into the fluid, interpolates U there by TRANSVERSE
376// bi-quadratic interpolation of the cell-centred values (`eQuad`×`eQuad`), and fits the quadratic
377// {0 at wall, v0 at d0, v1 at d1} for a 2nd-order derivative. Fallbacks (Basilisk): if the near
378// image point's 3×3 transverse stencil straddles the wall, a biased-linear `embed_interpolate`
379// anchored at the fluid home cell (uses only fluid cells); if even the home cell is solid, the
380// degenerate 1-point estimate through the cell centre. A-priori-validated O(h²) with 0% degenerate
381// on the Stokes sphere (tests/study/fv_wallflux_apriori.py, variant C). Interior/transverse reads
382// of U and sdf must be halo-filled (the velocity block carries G=2 ghosts).
384 int z, double nx, double ny, double nz,
385 double px, double py, double pz) {
386 const double nv[3] = {nx, ny, nz}, pv[3] = {px, py, pz};
387 const int ci[3] = {x, y, z}, ext[3] = {e.x, e.y, e.z};
388 const long st[3] = {1, (long)e.x, (long)e.x * e.y};
389 int da = 0;
390 double best = Kokkos::fabs(nv[0]);
391 if (Kokkos::fabs(nv[1]) > best) {
392 best = Kokkos::fabs(nv[1]);
393 da = 1;
394 }
395 if (Kokkos::fabs(nv[2]) > best)
396 da = 2;
397 const int t1 = (da + 1) % 3, t2 = (da + 2) % 3;
398 const double sgn = nv[da] >= 0.0 ? 1.0 : -1.0;
399 auto clampi = [](int v, int n) { return v < 0 ? 0 : (v >= n ? n - 1 : v); };
400 auto rd = [](double v) { // round to nearest integer, clamped to {-1,0,1} (Basilisk j/k)
401 double r = v >= 0.0 ? Kokkos::floor(v + 0.5) : Kokkos::ceil(v - 0.5);
402 return r > 1.0 ? 1.0 : (r < -1.0 ? -1.0 : r);
403 };
404 double vL[2], dL[2];
405 bool defd[2];
406 for (int l = 0; l < 2; ++l) {
407 const int io = (l + 1) * (int)sgn; // ±1, ±2 cells into the fluid along the dominant axis
408 const double dl = ((double)io - pv[da]) / nv[da]; // distance (cells) from wall to image plane
409 const double y1 = pv[t1] + dl * nv[t1], z1 = pv[t2] + dl * nv[t2];
410 const int jj = (int)rd(y1), kk = (int)rd(z1);
411 const double ly = y1 - jj, lz = z1 - kk;
412 double vv[3][3];
413 bool sf[3][3];
414 for (int dk = -1; dk <= 1; ++dk)
415 for (int dj = -1; dj <= 1; ++dj) {
416 int cc[3];
417 cc[da] = ci[da] + io;
418 cc[t1] = ci[t1] + jj + dj;
419 cc[t2] = ci[t2] + kk + dk;
420 const long idx = (long)clampi(cc[0], ext[0]) * st[0] + (long)clampi(cc[1], ext[1]) * st[1] +
421 (long)clampi(cc[2], ext[2]) * st[2];
422 vv[dj + 1][dk + 1] = U(idx);
423 sf[dj + 1][dk + 1] = sdf(idx) >= 0.0;
424 }
425 bool full = true;
426 for (int a2 = 0; a2 < 3; ++a2)
427 for (int b2 = 0; b2 < 3; ++b2)
428 full = full && sf[a2][b2];
429 const bool home = sf[1][1];
430 const double vbq =
431 eQuad(lz, eQuad(ly, vv[0][0], vv[1][0], vv[2][0]), eQuad(ly, vv[0][1], vv[1][1], vv[2][1]),
432 eQuad(ly, vv[0][2], vv[1][2], vv[2][2]));
433 double vbl = vv[1][1]; // biased-linear embed_interpolate (fluid-side only), anchored at home
434 {
435 const int fp = ly >= 0.0 ? 2 : 0, fm = ly >= 0.0 ? 0 : 2;
436 if (sf[fp][1])
437 vbl += Kokkos::fabs(ly) * (vv[fp][1] - vv[1][1]);
438 else if (sf[fm][1])
439 vbl += Kokkos::fabs(ly) * (vv[1][1] - vv[fm][1]);
440 }
441 {
442 const int fp = lz >= 0.0 ? 2 : 0, fm = lz >= 0.0 ? 0 : 2;
443 if (sf[1][fp])
444 vbl += Kokkos::fabs(lz) * (vv[1][fp] - vv[1][1]);
445 else if (sf[1][fm])
446 vbl += Kokkos::fabs(lz) * (vv[1][1] - vv[1][fm]);
447 }
448 vL[l] = full ? vbq : vbl;
449 dL[l] = dl;
450 defd[l] = full || home;
451 }
452 if (defd[0] && defd[1]) { // 2-point quadratic fit (3rd-order value -> 2nd-order gradient)
453 const double d0 = dL[0], d1 = dL[1], v0 = vL[0], v1 = vL[1];
454 return (v0 * d1 / d0 - v1 * d0 / d1) / (d1 - d0);
455 }
456 if (defd[0]) // near image point only -> 1-point linear
457 return vL[0] / dL[0];
458 double d0 = Kokkos::fabs(pv[da] / nv[da]); // degenerate sliver: 1-point through the cell centre
459 // Floor d0 at the resolution scale. The degenerate estimate U/d0 enters the momentum march as an
460 // EXPLICIT lagged wall flux (defect correction), whose per-step gain ~ mu*area/d0 against the row
461 // diagonal mu*sum(o) + rho/dt must stay < 1: with area <= sqrt(3), d0 >= 0.5 bounds it by ~0.6 for
462 // any (mu, rho, dt). The previous 1e-3 floor let a cell centre grazing the surface (|sdf|~1e-3,
463 // guaranteed on multi-sphere beds) drive gains of O(10^2) -- the dt-independent x~100/step blowup
464 // behind the mode-6/7 "CutcellMG preconditioner non-finite z" abort. (Basilisk's identical
465 // dirichlet_gradient stencil is safe there because its home coefficient goes into the IMPLICIT
466 // diagonal via *coef; this port applies it explicitly, so the floor carries the stability.)
467 if (d0 < 0.5)
468 d0 = 0.5;
469 const long ii = (long)ci[0] * st[0] + (long)ci[1] * st[1] + (long)ci[2] * st[2];
470 return U(ii) / d0;
471}
472
473// EMBED viscous operator (setFaceInterp(5)): identical to fvViscousApply except the wall drag is
474// the TRUE-NORMAL Basilisk gradient (embedDirichletGradient) rather than the axis-by-axis
475// fragment-normal estimate. Per cut cell the fragment area is |W| = |o_{a−}−o_{a+}|
476// (divergence-theorem normal) and the wall flux is +μ·area·d(U)/dn — algebraically the −μ·(W·∇U) of
477// fvViscousApply but with the O(h²) true-normal derivative in place of the O(h) axis
478// reconstruction. Interior cells (area→0) reduce to idt·cs·U + μ(diag·U − offs) exactly, so the
479// mode-0 defect vanishes there.
481 CCConst oy, CCConst oz, double mu, double idt, C3 e, int g) {
483 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
484 Kokkos::parallel_for(
485 "peclet::flow::embed_viscous_apply", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
486 KOKKOS_LAMBDA(int x, int y, int z) {
487 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
488 const long st[3] = {sx, sy, sz};
489 const long i = (long)x + (long)y * sy + (long)z * sz;
490 CCConst oa[3] = {ox, oy, oz};
491 double Wv[3];
492 double diag = 0.0, offs = 0.0;
493 for (int a = 0; a < 3; ++a) {
494 const double om = oa[a](i), op = oa[a](i + st[a]);
495 Wv[a] = om - op;
496 diag += om + op;
497 offs += om * U(i - st[a]) + op * U(i + st[a]);
498 }
499 const double area = Kokkos::sqrt(Wv[0] * Wv[0] + Wv[1] * Wv[1] + Wv[2] * Wv[2]);
500 double wall = 0.0;
501 if (area > 1e-12) {
502 double nx = 0.5 * (sdf(i + sx) - sdf(i - sx));
503 double ny = 0.5 * (sdf(i + sy) - sdf(i - sy));
504 double nz = 0.5 * (sdf(i + sz) - sdf(i - sz));
505 const double nn = Kokkos::sqrt(nx * nx + ny * ny + nz * nz);
506 if (nn > 1e-12) {
507 nx /= nn;
508 ny /= nn;
509 nz /= nn;
510 const double sdi = sdf(i); // foot-point (boundary centroid proxy) offset, cell units
511 const double dudn = embedDirichletGradient(U, sdf, e, x, y, z, nx, ny, nz, -sdi * nx,
512 -sdi * ny, -sdi * nz);
513 wall = area * dudn; // +μ·area·dudn == −μ·(W·∇U) with the true-normal derivative
514 }
515 }
516 Lu(i) = idt * cs(i) * U(i) + mu * (diag * U(i) - offs + wall);
517 });
518}
519
520// 7-point stencil matvec y = M·u using the stored (rs-scaled) IBM operator coefficients. Used to
521// form the mode-4 defect-correction RHS b = M·u^k − rs·L_FV(u^k) + rs·b_FV, whose fixed point
522// satisfies L_FV·u* = b_FV exactly, with M only the (stable, small-cell-safe) preconditioner.
523inline void stencilMatvec(CCField y, CCConst u, MConst AC, MConst AW, MConst AE, MConst AS,
524 MConst AN, MConst AB, MConst AT, C3 e, int g) {
526 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
527 Kokkos::parallel_for(
528 "peclet::flow::stencil_matvec", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
529 KOKKOS_LAMBDA(int x, int y2, int z) {
530 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
531 const long i = (long)x + (long)y2 * sy + (long)z * sz;
532 y(i) = (double)AC(i) * u(i) + (double)AW(i) * u(i - sx) + (double)AE(i) * u(i + sx) +
533 (double)AS(i) * u(i - sy) + (double)AN(i) * u(i + sy) + (double)AB(i) * u(i - sz) +
534 (double)AT(i) * u(i + sz);
535 });
536}
537
538// u -= d over the inner cells (the mode-2 correction applies the transposeGradWallAware field).
539inline void subtractField(CCField u, CCConst d, C3 e, int g) {
541 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
542 Kokkos::parallel_for(
543 "peclet::flow::subtract_field", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
544 KOKKOS_LAMBDA(int x, int y, int z) {
545 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
546 u(i) -= d(i);
547 });
548}
549
550// OPENNESS-WEIGHTED cell pressure gradient along one axis (Basilisk centered_gradient / embed cell
551// correction): out(i) = (o(i)·(p(i)−p(i−sa)) + o(i+sa)·(p(i+sa)−p(i))) / (o(i)+o(i+sa)). In the
552// interior (o=1) this is the central difference ½(p(i+sa)−p(i−sa)); at a CUT cell with one closed
553// face (o=0) it returns the OPEN face gradient at FULL weight (not the plain projectCorrectCenter's
554// ½), which is the flux-consistent pressure force the approximate projection needs at the embedded
555// boundary — the O(h) under-correction the plain map makes there (analysis defect (b)). Used for
556// both the incremental −grad(P^n) predictor and the projection correction of the EMBED path (mode
557// 6), so the two stay the openness-adjoint of the fs-weighted divergence constraint.
558inline void centerGradOpen(CCField out, CCConst p, CCConst o, int axis, C3 e, int g) {
560 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
561 Kokkos::parallel_for(
562 "peclet::flow::center_grad_open", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
563 KOKKOS_LAMBDA(int x, int y, int z) {
564 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
565 const long i = (long)x + (long)y * sy + (long)z * sz;
566 const long sa = (axis == 0) ? sx : (axis == 1) ? sy : sz;
567 const double om = o(i), op = o(i + sa);
568 out(i) = (om * (p(i) - p(i - sa)) + op * (p(i + sa) - p(i))) / (om + op + 1e-12);
569 });
570}
571
572// ADJOINT-APERTURE cell pressure gradient along one axis (setFaceInterp(11)):
573// out(i) = 1/2·(o(i)·(p(i)−p(i−sa)) + o(i+sa)·(p(i+sa)−p(i))) — centerGradOpen WITHOUT the
574// normalization, which makes it the exact TRANSPOSE of the aperture divergence of the 1/2-1/2
575// face average: G = −(D_α Π)^T. Two structural consequences (collocated_invisible_subspace.md):
576// (i) support-consistent — it reads a solid-centred φ wherever the constraint couples it
577// (α_f > 0), so the invisible pressure subspace of the gauge-exact/plain gradients collapses;
578// (ii) adjoint — the dt→∞ Uzawa pressure map has an SPSD Schur complement, the stabilizable
579// case (the normalized embed pair (modes 6/7) is non-adjoint and measured unconditionally
580// unstable on beds, dt-free doubling ~75 steps). The price is the 1/2·α under-weighting of the
581// pressure force at nearly-closed cut cells (accuracy measured on the ladder, not assumed).
582inline void centerGradAperture(CCField out, CCConst p, CCConst o, int axis, C3 e, int g) {
584 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
585 Kokkos::parallel_for(
586 "peclet::flow::center_grad_aperture", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
587 KOKKOS_LAMBDA(int x, int y, int z) {
588 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
589 const long i = (long)x + (long)y * sy + (long)z * sz;
590 const long sa = (axis == 0) ? sx : (axis == 1) ? sy : sz;
591 out(i) = 0.5 * (o(i) * (p(i) - p(i - sa)) + o(i + sa) * (p(i + sa) - p(i)));
592 });
593}
594
595// PER-CELL-RESCALED adjoint-aperture gradient (setFaceInterp(12)): centerGradAperture times the
596// scalar S(i) = 6 / max(sum_a(o_a(i) + o_a(i+sa)), 0.5), one weight per CELL (not per axis).
597// S == 1 in the bulk (sum = 6), so interior cells reproduce mode 11 / mode 0 exactly; at cut
598// cells it restores (on average) the full-weight pressure force the 1/2*alpha under-weighting of
599// the pure adjoint removes -- the mode-11 accuracy price -- while keeping the corrector a
600// positive-DIAGONAL rescaling S*G of the adjoint pair (support unchanged; contrast mode 6's
601// per-axis normalization, which is NOT such a rescaling and is measured unstable). The cap
602// sum >= 0.5 bounds S <= 12 at nearly-closed cells.
604 int axis, C3 e, int g) {
606 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
607 Kokkos::parallel_for(
608 "peclet::flow::center_grad_aperture_scaled", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
609 KOKKOS_LAMBDA(int x, int y, int z) {
610 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
611 const long i = (long)x + (long)y * sy + (long)z * sz;
612 const long sa = (axis == 0) ? sx : (axis == 1) ? sy : sz;
613 CCConst o = (axis == 0) ? ox : (axis == 1) ? oy : oz;
614 double osum = ox(i) + ox(i + sx) + oy(i) + oy(i + sy) + oz(i) + oz(i + sz);
615 if (osum < 0.5)
616 osum = 0.5;
617 out(i) = (6.0 / osum) * 0.5 *
618 (o(i) * (p(i) - p(i - sa)) + o(i + sa) * (p(i + sa) - p(i)));
619 });
620}
621
622// CAPPED per-axis normalized aperture gradient (setFaceInterp(13)): the mode-6 embed form
623// (om*d- + op*d+)/(om+op) with the denominator FLOORED at omin -- equivalently the adjoint
624// gradient centerGradAperture times the per-cell-per-COMPONENT diagonal S = 2/max(om+op, omin).
625// Rationale: mode 6's floor is 1e-12, an unbounded diagonal gain (up to ~1e12) at nearly-closed
626// axes, and the mode-6 pressure loop is measured unconditionally unstable on beds; mode 11
627// (S = 1) is unconditionally clean but under-weights the cut-cell pressure force by ~(om+op)/2
628// (k gap -11% at R=8). This kernel keeps the full-weight embed gradient wherever om+op >= omin
629// (every ordinary cut cell) and only caps the gain where an axis is nearly closed.
630inline void centerGradOpenCapped(CCField out, CCConst p, CCConst o, int axis, double omin, C3 e,
631 int g) {
633 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
634 Kokkos::parallel_for(
635 "peclet::flow::center_grad_open_capped", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
636 KOKKOS_LAMBDA(int x, int y, int z) {
637 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
638 const long i = (long)x + (long)y * sy + (long)z * sz;
639 const long sa = (axis == 0) ? sx : (axis == 1) ? sy : sz;
640 const double om = o(i), op = o(i + sa);
641 double den = om + op;
642 if (den < omin)
643 den = omin;
644 out(i) = (om * (p(i) - p(i - sa)) + op * (p(i + sa) - p(i))) / den;
645 });
646}
647
648// u,v,w -= openness-weighted cell pressure gradient of phi (centerGradOpen per axis). The embed
649// analogue of projectCorrectCenter: the cut-cell cell velocity gets the full open-face pressure
650// force.
652 CCConst oy, CCConst oz, C3 e, int g) {
654 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
655 Kokkos::parallel_for(
656 "peclet::flow::correct_center_open", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
657 KOKKOS_LAMBDA(int x, int y, int z) {
658 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
659 const long i = (long)x + (long)y * sy + (long)z * sz;
660 const double omx = ox(i), opx = ox(i + sx);
661 const double omy = oy(i), opy = oy(i + sy);
662 const double omz = oz(i), opz = oz(i + sz);
663 u(i) -= (omx * (phi(i) - phi(i - sx)) + opx * (phi(i + sx) - phi(i))) / (omx + opx + 1e-12);
664 v(i) -= (omy * (phi(i) - phi(i - sy)) + opy * (phi(i + sy) - phi(i))) / (omy + opy + 1e-12);
665 w(i) -= (omz * (phi(i) - phi(i - sz)) + opz * (phi(i + sz) - phi(i))) / (omz + opz + 1e-12);
666 });
667}
668
669// Cell-centered velocity correction u_c -= grad_c(phi), grad_c per axis = ½·(g⁻ + g⁺) of the two
670// adjacent FACE pressure-gradients, but a face that is fully CLOSED (openness 0 — a solid
671// neighbour) contributes a ZERO gradient instead of reading that neighbour's φ. Rationale:
672// * interior fluid cell (both faces open): ½(φᵢ₊₁-φᵢ₋₁) — the central difference, bulk unchanged;
673// * immersed cut cell (solid neighbour): the closed face's φ is DECOUPLED (≈0, AC≈0 in the
674// operator), so
675// reading it corrupts the gradient — zeroing that face uses only the fluid-side (open)
676// gradient;
677// * domain-BC wall (Neumann, φ-ghost = interior): the closed wall face truly has ∂φ/∂n≈0, and
678// zeroing it
679// gives ½·g_open — identical to the previous central difference with the Neumann ghost (no
680// change there).
681// Cut faces (0<o<1) keep their real gradient (the neighbour is fluid). Only the cell field is
682// touched; the projection's face divergence-free guarantee is unaffected. phi ghosts + face
683// openness must be filled first.
685 CCConst oy, CCConst oz, C3 e, int g) {
687 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
688 Kokkos::parallel_for(
689 "peclet::flow::correct_center", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
690 KOKKOS_LAMBDA(int x, int y, int z) {
691 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
692 const long i = (long)x + (long)y * sy + (long)z * sz;
693 const double gm_x = (ox(i) > 1e-12) ? (phi(i) - phi(i - sx)) : 0.0;
694 const double gp_x = (ox(i + sx) > 1e-12) ? (phi(i + sx) - phi(i)) : 0.0;
695 const double gm_y = (oy(i) > 1e-12) ? (phi(i) - phi(i - sy)) : 0.0;
696 const double gp_y = (oy(i + sy) > 1e-12) ? (phi(i + sy) - phi(i)) : 0.0;
697 const double gm_z = (oz(i) > 1e-12) ? (phi(i) - phi(i - sz)) : 0.0;
698 const double gp_z = (oz(i + sz) > 1e-12) ? (phi(i + sz) - phi(i)) : 0.0;
699 u(i) -= 0.5 * (gm_x + gp_x);
700 v(i) -= 0.5 * (gm_y + gp_y);
701 w(i) -= 0.5 * (gm_z + gp_z);
702 });
703}
704
705} // namespace peclet::flow
706
707#endif // PECLET_FLOW_MAC_APPROX_PROJECTION_HPP
flow — portable (Kokkos) cut-cell pressure-operator face openness from an SDF.
void transposeGradWallAware(CCField out, CCConst p, CCConst sdf, CCConst o, CCConst xc, bool useCen, int axis, C3 e, int g)
void projectCorrectCenterOpen(CCField u, CCField v, CCField w, CCConst phi, CCConst ox, CCConst oy, CCConst oz, C3 e, int g)
double eQuad(double xx, double a1, double a2, double a3)
void buildFaceCentroidDist(CCField xcx, CCField xcy, CCField xcz, CCConst sdf, C3 e)
void subtractField(CCField u, CCConst d, C3 e, int g)
void stencilMatvec(CCField y, CCConst u, MConst AC, MConst AW, MConst AE, MConst AS, MConst AN, MConst AB, MConst AT, C3 e, int g)
void buildCellFraction(CCField cs, CCConst sdf, C3 e, int g)
void centerGradApertureScaled(CCField out, CCConst p, CCConst ox, CCConst oy, CCConst oz, int axis, C3 e, int g)
double embedDirichletGradient(CCConst U, CCConst sdf, C3 e, int x, int y, int z, double nx, double ny, double nz, double px, double py, double pz)
void centerToFaceWallAware(CCField uf, CCField vf, CCField wf, CCConst U, CCConst V, CCConst W, CCConst sdf, CCConst xcx, CCConst xcy, CCConst xcz, bool useCen, C3 e, int g)
void fvViscousApply(CCField Lu, CCConst U, CCConst sdf, CCConst cs, CCConst ox, CCConst oy, CCConst oz, double mu, double idt, C3 e, int g)
void centerGradOpen(CCField out, CCConst p, CCConst o, int axis, C3 e, int g)
void centerGradAperture(CCField out, CCConst p, CCConst o, int axis, C3 e, int g)
void embedViscousApply(CCField Lu, CCConst U, CCConst sdf, CCConst cs, CCConst ox, CCConst oy, CCConst oz, double mu, double idt, C3 e, int g)
void centerToFace(CCField uf, CCField vf, CCField wf, CCConst U, CCConst V, CCConst W, C3 e, int g)
void ibmFillEntry(const OV &o, int list_idx, int c_idx, float sdf_c, const float sdf_n[6], int bc_type, const float *thEx)
int wallAwareFaceStencil(CCConst sdf, long i, long sa, int cFace, int eAxis, double xcen, long idx[3], double w[3])
Kokkos::View< double *, CCMem > CCField
Kokkos::DefaultExecutionSpace CCExec
void projectCorrectCenter(CCField u, CCField v, CCField w, CCConst phi, CCConst ox, CCConst oy, CCConst oz, C3 e, int g)
Kokkos::View< const float *, CCMem > MConst
double ccSampleExt(CCConst sdf, C3 ext, double x, double y, double z)
double wallAwareFaceValue(CCConst U, CCConst sdf, long i, long sa, int cFace, int eAxis, double xcen)
void centerGradOpenCapped(CCField out, CCConst p, CCConst o, int axis, double omin, C3 e, int g)
Kokkos::View< const double *, CCMem > CCConst
static constexpr double AC