flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
flow_bindings.cpp
Go to the documentation of this file.
1
17#include <nanobind/nanobind.h>
18#include <nanobind/ndarray.h>
19#include <nanobind/stl/pair.h>
20#include <nanobind/stl/string.h>
21#include <nanobind/stl/vector.h>
22
23#include <array>
24#include <cstdint>
25#include <Kokkos_Core.hpp>
26#include <string>
27#include <vector>
28
29#ifdef PECLET_FLOW_MPI
30#include <mpi.h>
31
32#include "peclet/core/common/types.hpp"
33#include "peclet/core/decomp/block_decomposer.hpp"
34#endif
35
36#include "flow_ibm.hpp"
37#include "peclet/core/python/ndarray_interop.hpp"
38
39#ifdef PECLET_FLOW_MPI
40// Ensure MPI_Init has been called (mirrors the dem init_mpi idiom); safe to call repeatedly.
41static void ensure_mpi_init() {
42 int inited = 0;
43 MPI_Initialized(&inited);
44 if (!inited) {
45 int argc = 0;
46 char** argv = nullptr;
47 MPI_Init(&argc, &argv);
48 }
49}
50#endif
51
52namespace nb = nanobind;
53
54// A solver field (flat x-fastest, ghost-stripped) -> Fortran-order (nx,ny,nz) float64 NumPy array
55// for [x,y,z] indexing. The vector is moved into the array's backing store (no extra copy vs the
56// old to_xyz).
57template <class S>
58static nb::ndarray<nb::numpy, double> field_out(S& s, std::vector<double>&& v) {
59 const auto nx = static_cast<std::size_t>(s.nx());
60 const auto ny = static_cast<std::size_t>(s.ny());
61 const auto nz = static_cast<std::size_t>(s.nz());
62 return peclet::core::python::vector_to_ndarray(
63 std::move(v), {nx, ny, nz},
64 {1, static_cast<std::int64_t>(nx), static_cast<std::int64_t>(nx * ny)});
65}
66
67// A Fortran-order (nx,ny,nz) float64 array -> flat x-fastest host vector (F-contiguous data() is
68// already x-fastest). nanobind casts/copies the input to f_contig double if needed.
69static std::vector<double> grid_in(nb::ndarray<double, nb::f_contig> a) {
70 return peclet::core::python::ndarray_to_vector<double>(nb::ndarray<>(a));
71}
72
73// Zero-copy export of a registered field's padded device buffer as a Fortran-order 3-D array of the
74// full block shape (ex,ey,ez) = (nx+2G, ny+2G, nz+2G), x-fastest strides {1,ex,ex*ey}. Includes the
75// ghost band (the flat buffer is contiguous; a ghost-stripped view would not be). The capsule owns
76// a copy of the managed CCField, so the allocation outlives the array — host → NumPy referencing
77// the buffer, device → DLPack for CuPy/torch. Mirrors peclet::core::python::view_to_ndarray but
78// with an explicit 3-D reshape of the flat 1-D field.
79template <class S>
80static auto field3d_out(S& s, peclet::flow::CCField f) {
81 namespace pcp = peclet::core::python;
83 const auto bs = s.blockShape();
84 std::array<std::size_t, 3> shape{static_cast<std::size_t>(bs[0]), static_cast<std::size_t>(bs[1]),
85 static_cast<std::size_t>(bs[2])};
86 std::array<std::int64_t, 3> strides{1, static_cast<std::int64_t>(bs[0]),
87 static_cast<std::int64_t>(bs[0]) * bs[1]};
88 auto* held = new peclet::flow::CCField(f);
89 nb::capsule owner(held, [](void* p) noexcept { delete static_cast<peclet::flow::CCField*>(p); });
90 double* data = f.data();
91 if constexpr (pcp::is_host_space_v<Mem>) {
92 return nb::ndarray<nb::numpy, double>(data, 3, shape.data(), owner, strides.data(),
93 nb::dtype<double>(), nb::device::cpu::value, 0);
94 } else {
95 auto dev = pcp::dlpack_device<Mem>();
96 return nb::ndarray<double>(data, 3, shape.data(), owner, strides.data(), nb::dtype<double>(),
97 dev.first, dev.second);
98 }
99}
100
101// Register a solver class for the given GridLayout policy (Staggered -> "Solver", Colocated ->
102// "SolverColocated"). The Python API is identical across grids; only the velocity-unknown placement
103// and the advection control volume differ inside Solver<Grid>.
104template <class Grid>
105static void bind_solver(nb::module_& m, const char* name) {
107 nb::class_<S>(m, name)
108 .def(nb::init<int, int, int>(), nb::arg("nx"), nb::arg("ny"), nb::arg("nz"),
109 "Create a solver on an nx x ny x nz unit-spacing grid (x-fastest, I = x + y*nx + "
110 "z*nx*ny). "
111 "Set physical parameters (rho/mu/dt) and any domain BCs before the geometry / first "
112 "step.")
113 .def("set_rho", &S::setRho, nb::arg("rho"),
114 "Set fluid density rho (physical units). Set before geometry/first step.")
115 .def("set_mu", &S::setMu, nb::arg("mu"), "Set dynamic viscosity mu (physical units).")
116 .def("set_dt", &S::setDt, nb::arg("dt"),
117 "Set the time step dt; the momentum solve is scaled by 1/dt (well-conditioned at large "
118 "dt).")
119 .def("set_body_force", &S::setBodyForce, nb::arg("fx"), nb::arg("fy"), nb::arg("fz"),
120 "Set the body force per unit volume (fx, fy, fz) — e.g. a mean pressure gradient.")
121 .def("set_advection", &S::setAdvection, nb::arg("on"),
122 "Enable/disable explicit high-order momentum advection (default scheme SOU). Off ⇒ "
123 "Stokes.")
124 .def("set_advection_scheme", &S::setAdvectionScheme, nb::arg("scheme"),
125 "High-order advection scheme: 0 = second-order upwind (SOU, default), 1 = Koren TVD.")
126 .def("set_incremental_pressure", &S::setIncrementalPressure, nb::arg("on"),
127 "Toggle the rotational incremental-pressure projection.")
128 .def("set_pressure_warmstart", &S::setPressureWarmstart, nb::arg("on"),
129 "Seed each pressure solve from the previous step's phi (default off).")
130 .def(
131 "set_face_interp", &S::setFaceInterp, nb::arg("mode"),
132 "DEPRECATED integer form of set_collocated_scheme (0 = plain, 9 = gauge-exact, the "
133 "default). Modes 1/2/5/6/7/10 were RETIRED 2026-08-18 (ablations; 10 measured divergent) "
134 "and now raise. Modes 3/4 survive as FV-constraint ablations (4 pairs with "
135 "set_fv_relax). No effect on the staggered solver.")
136 .def(
137 "set_collocated_scheme", &S::setCollocatedScheme, nb::arg("name"),
138 "Collocated cut-cell projection scheme (no effect on the staggered solver). DEFAULT "
139 "since 2026-08-25: AUTO = 'ghost' where supported, falling back to 'gauge-exact' with "
140 "a stderr notice on porous/variable-rho/domain-BC/Chebyshev configurations (ghost v1 "
141 "limits); any explicit selection here disables AUTO.\n"
142 " 'gauge-exact' (default 2026-08-18..25; the AUTO fallback) — the aperture constraint with the "
143 "directional gauge-exact pressure gradient. Cheapest scheme measured (symmetric MG-PCG, "
144 "no fragmentation guard). CAVEATS from the 2026-08 campaign "
145 "(doc/collocated_invisible_subspace.md): possesses an attractor FAMILY of steady states "
146 "(support-inconsistent gradient/constraint pair) selected by march protocol, and a "
147 "rotational-update instability whose wall-blend stopgap "
148 "(set_rotational_wall_weight) has a resolution-dependent margin — UNSTABLE at "
149 "(R>=16 cells/radius, dt>=600); use fixed dt<=60 protocols at high resolution. "
150 "Clean-protocol bias vs the staggered reference: -2.5% (R=8) -> ~+0.2% asymptote.\n"
151 " 'ghost' — the fluid-only constraint scheme (binary-openness divergence + directional "
152 "closures + the same gauge-exact gradient). Family-free, unconditionally stable "
153 "(dt 60..1e20, no stabilizer), protocol-independent (C2), both-bed clean ladder "
154 "-1.4% (R=8) -> +0.22% asymptote, Z&H anchor -0.018% at N=128. Costs: nonsymmetric "
155 "BiCGStab pressure solve (~2.3-2.7x), ~1.6 KB/cell overlay (single-GPU size cap), "
156 "fragmentation guard. MPI-capable (np=1,2,4 ctests); at-scale np>=16 hardening in "
157 "progress. Equivalent to set_ghost_projection(True, 2, 2); the (1, 2) mixed mode "
158 "stays quarantined.\n"
159 " 'plain' — the legacy path: plain 1/2-1/2 face average + central-difference grad(P). "
160 "FIRST order at curved cut cells (the cell gradient reads the decoupled p=0 of "
161 "solid-centred neighbours, an O(1/h) gauge error), and on a dense bed it also fails to "
162 "reach steady state within 800 steps at coarse resolution. Kept for reproducing "
163 "published results.")
164 .def(
165 "set_rotational_pressure", &S::setRotationalPressure, nb::arg("on"),
166 "PM I ablation (Guy-Fogelson 2005): False drops the rotational -mu*div(u*) term from "
167 "the incremental pressure accumulation (constant-mu path only). Default True = shipped "
168 "rotational (Timmermans) update. Also: set_collocated_scheme accepts \"gauge-2a\" -- "
169 "the experimental gradient-2a one-sided branch of the gauge-exact gradient.")
170 .def(
171 "set_rotational_filter", &S::setRotationalFilter, nb::arg("on"), nb::arg("eps") = 0.05,
172 "Experimental filtered rotational update: smooth div(u*) (mask-aware axis-wise 1-2-1, "
173 "one-sided into the fluid at walls) before accumulating -mu*div into P. Keeps the O(1) "
174 "pressure-relaxation gain, removes the checkerboard feedback channel.")
175 .def(
176 "set_rotational_weight", &S::setRotationalWeight, nb::arg("w"),
177 "Under-relax the rotational term: P += ct*phi - w*mu*div(u*). 1 = shipped, 0 = PM I; "
178 "small w raises the boundary-mode stability threshold ~1/w at ~1/w slower smooth-mode "
179 "pressure relaxation. phi=0 stays the unique fixed point for any w>0 at every dt.")
180 .def(
181 "set_rotational_wall_weight", &S::setRotationalWallWeight, nb::arg("w0"),
182 "Wall-banded rotational blend: at fluid cells with a solid axis-neighbour use "
183 "P += (rho/dt + w0*mu/dx^2)*phi - (1-w0)*mu*div(u*); bulk keeps the full rotational "
184 "update. Stabilizes the boundary rows without slowing bulk pressure relaxation, and "
185 "keeps them relaxing at dt->infinity. 0 (default) = off.")
186 .def("set_aperture_order", &S::setApertureOrder, nb::arg("order"),
187 "Face-aperture estimator: 1 = one-sample linear model (default, byte-identical), 2 = "
188 "marching-squares (5 samples/face, O(h^2); removes the convexity bias measured at "
189 "+0.59/+0.27% bed permeability at R=8/12 -- see doc/collocated_paper_plan.md row 51). "
190 "For analytic geometry, exact apertures via set_openness_override are better still. "
191 "Call before set_solid.")
192 .def("set_fluid_only_constraint", &S::setFluidOnlyConstraint, nb::arg("mode"),
193 "Fluid-only pressure constraint (route 2b, call before set_solid; collocated "
194 "experiment). 1 = Design A (openness filter), 2 = Design B (SPD Kron star "
195 "elimination), 0 = off.")
196 .def("set_fv_relax", &S::setFvRelax, nb::arg("w"),
197 "Mode-4 FV wall-flux defect-correction under-relaxation (1=full; <1 damps the stiff "
198 "explicit-lagged wall term). Steady state is independent of w.")
199 .def("set_velocity_streams", &S::setVelocityStreams, nb::arg("on"),
200 "Toggle overlapped per-component velocity solves.")
201 .def("set_implicit_advection", &S::setImplicitAdvection, nb::arg("on"),
202 "Use implicit-FOU advection with deferred-correction TVD.")
203 .def("set_outer_iterations", &S::setOuterIterations, nb::arg("n"),
204 "Set the number of Picard/outer iterations per step.")
205 .def("set_outer_tolerance", &S::setOuterTolerance, nb::arg("tol"),
206 "Set the outer (Picard) convergence tolerance.")
207 .def("last_outer_iterations", &S::lastOuterIterations,
208 "Return the outer-iteration count from the last step().")
209 .def(
210 "set_velocity_solver_params",
211 [](S& s, int iters, double rtol, int min_iters) {
212 s.setVelocityIterations(iters);
213 s.setVelocityTolerance(rtol, min_iters);
214 },
215 nb::arg("iters"), nb::arg("rtol") = 0.0, nb::arg("min_iters") = 2,
216 "Momentum smoother control: `iters` RB-GS sweeps per component, or with rtol > 0 a "
217 "TOLERANCE STOP — end the loop once the sweep's max velocity increment has contracted "
218 "to rtol of the first sweep's (iters becomes the cap, min_iters the floor). Easy "
219 "regimes (small nu*dt/dx^2) exit after ~3-5 sweeps; stiff regimes run to the cap "
220 "unchanged. rtol = 0 (default) is the legacy fixed count, byte-identical.")
221 .def("set_deferred_correction", &S::setDeferredCorrection, nb::arg("on"),
222 "Deferred-correction advection: True (default) = 2nd order (implicit FOU + explicit "
223 "high-order correction, the high-order scheme being SOU by default or Koren TVD via "
224 "set_advection_scheme); False = pure implicit FOU (1st-order upwind, more dissipative "
225 "but "
226 "unconditionally stable at sharp shear layers).")
227 .def("set_backflow_stabilization", &S::setBackflowStab, nb::arg("beta"),
228 "Outflow backflow-stabilization coefficient (Bazilevs 2009 / Esmaily-Moghadam 2011): "
229 "beta "
230 "in [0,1] scales the dissipative outflow term that prevents backflow divergence when "
231 "flow "
232 "reverses at the outlet (e.g. a separated wake / BFS recirculation). Default 0.2; 0 = "
233 "off. "
234 "Inert where the outlet is purely outgoing.")
235 .def("set_pressure_solver_params", &S::setPressureIterations, nb::arg("iters"),
236 "Set the pressure smoother iteration count.")
237 .def(
238 "set_pressure_multigrid", [](S& s, bool, int levels) { s.setPressureLevels(levels); },
239 nb::arg("on"), nb::arg("levels") = 4,
240 "Set the pressure multigrid depth (levels=1 => pure RB-GS, no coarse grid).")
241 .def("set_pressure_chebyshev", &S::setPressureChebyshev, nb::arg("on"),
242 nb::arg("max_iter") = 120, nb::arg("rtol") = 1e-9,
243 "Use the communication-light Chebyshev pressure accelerator (exclusive with PCG).")
244 .def(
245 "set_pressure_mean_removal",
246 [](S& s, const std::string& scope) {
247 if (scope != "all" && scope != "fine")
248 throw std::runtime_error("set_pressure_mean_removal: scope must be 'all' or 'fine'");
249 s.setPressureMeanRemoval(scope == "all");
250 },
251 nb::arg("scope"),
252 "Nullspace (mean) removal scope in the pressure solve: 'fine' (DEFAULT — only the "
253 "projections the Krylov iteration needs: rhs/residual, the fine-level V-cycle exit, the "
254 "final iterate; ~3x fewer global-reduction latency hits per iteration, the measured "
255 "winner of the multi-GPU ablation) or 'all' (legacy — every V-cycle level + after every "
256 "matvec). Iteration counts are identical (A preserves mean-freeness); results equal "
257 "within solver tolerance, not bit-identical.")
258 .def(
259 "set_pressure_bottom",
260 [](S& s, const std::string& m) {
261 if (m == "auto") s.setPressureBottomMode(-1);
262 else if (m == "smoother") s.setPressureBottomMode(0);
263 else if (m == "agglomerated") s.setPressureBottomMode(1);
264 else throw std::runtime_error("set_pressure_bottom: 'auto' | 'smoother' | 'agglomerated'");
265 },
266 nb::arg("mode"),
267 "Coarse-level (bottom) solve of the pressure multigrid. A V-cycle converges at a "
268 "domain-independent rate only if its COARSEST level is effectively solved, and a geometric "
269 "hierarchy cannot always get small enough: an axis stops coarsening once it turns odd, and "
270 "under MPI once ANY rank's block turns odd -- so at fixed cells/rank the coarsest GLOBAL "
271 "grid grows with the rank count and the bottom is progressively under-solved. "
272 "'auto' (DEFAULT) agglomerates the coarsest level onto a global operator and solves it "
273 "exactly whenever it exceeds PECLET_FLOW_AGGLOM_EXTENT (4) cells on any axis, and uses "
274 "the cheap smoothed bottom otherwise (byte-identical to 'smoother' then). 'smoother' = "
275 "never agglomerate (legacy). 'agglomerated' = always. Measured on one GPU (2048x64x64 "
276 "channel): a smoothed bottom needs 13.5 pressure iterations/step at 4 levels and 6.0 at "
277 "6, against 4.4 at full geometric depth; agglomerated it is 4.0 at BOTH depths, and "
278 "faster in wall-clock than the deep hierarchy. Works on the cut-cell IBM and "
279 "ghost-projection paths (per-fluid-component null-space projection, 2026-08-13).")
280 .def("set_pressure_graph_amg", &S::setPressureGraphAmg, nb::arg("on"),
281 "Solve the pressure MG's coarsest level with an agglomerated mesh-agnostic algebraic "
282 "multigrid (core GraphAMG), decomposition-agnostic: with levels=1 this gives a "
283 "mesh-independent pressure solve that works under a WEIGHTED ORB (where the geometric "
284 "coarse levels can't cleanly coarsen). Applied at the next set_solid.")
285 .def("set_pressure_pcg", &S::setPressurePcg, nb::arg("on"), nb::arg("max_iter") = 200,
286 nb::arg("rtol") = 1e-8,
287 "Use the MG-PCG pressure accelerator (single-GPU default; exclusive with Chebyshev).")
288 .def("set_exact_crossings", &S::setExactCrossings, nb::arg("t"),
289 "Analytic-SDF capability: exact wall-crossing fractions overriding the "
290 "linear-interpolated theta in the momentum cut-cell overlay AND the ghost-projection "
291 "closures. Flat array of 9*nx*ny*nz values, blocks [(c*3+k)]: component c's staggered "
292 "point at inner cell i toward its +k neighbour; NaN = no crossing. Call BEFORE "
293 "set_solid; empty list clears. Single-rank, staggered momentum placement.")
294 .def("set_openness_override", &S::setOpennessOverride, nb::arg("ox"), nb::arg("oy"),
295 nb::arg("oz"),
296 "Analytic-SDF capability: exact face-aperture (openness) fields for the cut-cell "
297 "projection, overriding the sampled-SDF openness. Inner nx*ny*nz arrays, x-fastest; "
298 "ox[i] = fluid fraction of the -x face of cell i. Call BEFORE set_solid; empty ox "
299 "clears. Single-rank.")
300 .def("set_ghost_projection", &S::setGhostProjection, nb::arg("on"),
301 nb::arg("matrix_order") = 2, nb::arg("rhs_order") = 2,
302 "QUARANTINED 2026-08-18 (verification only, unsupported): superseded by the gauge-exact collocated scheme, which matches its accuracy at 5-6x lower cost. Kept as the independent second discretization behind the cross-IBM physics gate. Enabling it on the collocated grid silently selects the plain face map, since it owns the operators the gauge-exact scheme replaces. EXPERIMENTAL directional ghost-cell projection (second staggered IBM): point-based FD "
303 "divergence with wall-anchored directional closures instead of the openness-weighted "
304 "cut-cell projection; solved by MG-preconditioned BiCGStab. Call BEFORE set_solid. "
305 "Closure orders (1=linear, 2=quadratic): (matrix_order, rhs_order) = (2,2) full "
306 "quadratic 13-point matrix; (1,1) linear 7-point; (1,2) mixed/deferred — 2nd-order "
307 "steady constraint on a 7-point matrix. Collocated: the same closures/matrix on the "
308 "face-averaged field, plus a directional (one-sided 2nd-order) cell gradient for the "
309 "-grad(P) predictor and cell correction; requires face_interp 0. v1: single-rank, "
310 "periodic + IBM, stationary walls; incompatible with "
311 "porous/variable-rho/domain-BC/Chebyshev.")
312 .def("set_velocity_multigrid", &S::setVelocityMultigrid, nb::arg("on"), nb::arg("levels") = 4,
313 nb::arg("vcycles") = 8,
314 "Enable velocity (momentum) multigrid for the implicit diffusion solve.")
315 .def("last_pressure_iterations", &S::lastPressureIterations,
316 "Return the pressure-solver iteration count from the last step().")
317 .def(
318 "last_step_timers",
319 [](S& s) {
320 nb::dict d;
321 d["step"] = s.lastStepSeconds();
322 d["predictor"] = s.lastPredictorSeconds();
323 d["momentum"] = s.lastMomentumSeconds();
324 d["projection"] = s.lastProjectionSeconds();
325 d["pressure_allreduce"] = s.lastPressureAllreduceSeconds();
326 d["pressure_allreduce_count"] = s.lastPressureAllreduceCount();
327 d["momentum_sweeps"] = s.lastMomentumSweeps();
328 return d;
329 },
330 "Per-phase wall times (seconds, THIS rank, device-fenced) of the last step(): 'predictor' "
331 "(ghost fills + RHS/advection/stencil builds), 'momentum' (implicit-diffusion solves), "
332 "'projection' (cut-cell pressure projection), 'step' (whole step). "
333 "'pressure_allreduce'/'pressure_allreduce_count' = time in / number of global reductions "
334 "(MPI_Allreduce) inside the pressure solve — the latency-bound term of the distributed "
335 "solve (0 on a single rank).")
336 .def("set_domain_bc", &S::setDomainBc, nb::arg("face"), nb::arg("type"), nb::arg("vx") = 0.0,
337 nb::arg("vy") = 0.0, nb::arg("vz") = 0.0,
338 "Set a per-face domain BC (face 0..5 = -x,+x,-y,+y,-z,+z; type 0 periodic/1 wall/2 "
339 "inflow/3 outflow).")
340 .def(
341 "set_domain_bc_profile",
342 [](S& s, int face, nb::ndarray<double, nb::c_contig> prof) {
343 if (prof.ndim() != 3 || prof.shape(2) != 3)
344 throw std::runtime_error("profile must be (Nb,Nc,3)");
345 const int nb_ = (int)prof.shape(0), nc = (int)prof.shape(1);
346 s.setDomainBcProfile(
347 face, peclet::core::python::ndarray_to_vector<double>(nb::ndarray<>(prof)), nb_,
348 nc);
349 },
350 nb::arg("face"), nb::arg("profile"),
351 "Prescribe a per-position inlet velocity profile (Nb,Nc,3) over a face (sets it to "
352 "inflow).")
353 .def(
354 "set_pressure_geometry",
355 [](S& s, nb::ndarray<double, nb::f_contig> sdf) { s.setPressureGeometry(grid_in(sdf)); },
356 nb::arg("sdf"),
357 "Set an all-fluid SDF for the cut-cell pressure operator without an immersed solid (the "
358 "channel/BFS domain-BC path). For a no-slip immersed BODY in an inflow/outflow domain, "
359 "call "
360 "set_solid(sdf, cutcell_pressure=True) instead -- do NOT also call this (a second "
361 "geometry "
362 "setter overwrites the SDF and wipes the solid).")
363 .def(
364 "set_solid",
365 [](S& s, nb::ndarray<double, nb::f_contig> sdf, bool cutcell_pressure,
366 const std::string& /*pressure_coarse*/) {
367 s.setSolid(grid_in(sdf), cutcell_pressure);
368 },
369 nb::arg("sdf"), nb::arg("cutcell_pressure") = false, nb::arg("pressure_coarse") = "const",
370 "Set the solid SDF as a Fortran-order (nx,ny,nz) float64 array (negative inside the "
371 "solid, positive in fluid). cutcell_pressure=True enables the open-face-weighted "
372 "cut-cell "
373 "pressure operator (proper no-slip); it composes with domain BCs, so this is the single "
374 "call for a no-slip immersed body in an inflow/outflow domain.")
375 .def(
376 "set_state",
377 [](S& s, nb::ndarray<double, nb::f_contig> u, nb::ndarray<double, nb::f_contig> v,
378 nb::ndarray<double, nb::f_contig> w) {
379 s.uploadVelocity(grid_in(u), grid_in(v), grid_in(w));
380 },
381 nb::arg("u"), nb::arg("v"), nb::arg("w"),
382 "Upload an initial velocity field (u,v,w each a Fortran-order (nx,ny,nz) float64 array).")
383 .def("step", &S::step,
384 "Advance the solver one time step (semi-implicit: diffusion + projection).")
385 .def(
386 "get_u", [](S& s) { return field_out(s, s.getVelocity(0)); },
387 "Return the x-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
388 "[x,y,z]).")
389 .def(
390 "get_v", [](S& s) { return field_out(s, s.getVelocity(1)); },
391 "Return the y-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
392 "[x,y,z]).")
393 .def(
394 "get_w", [](S& s) { return field_out(s, s.getVelocity(2)); },
395 "Return the z-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
396 "[x,y,z]).")
397 .def(
398 "get_p", [](S& s) { return field_out(s, s.getPressure()); },
399 "Return the physical pressure as a Fortran-order (nx,ny,nz) float64 array (index "
400 "[x,y,z]).")
401 .def(
402 "get_ox", [](S& s) { return field_out(s, s.getOpenness(0)); },
403 "TEMP: -x face openness (fluid area fraction) per inner cell, (nx,ny,nz).")
404 .def(
405 "get_oy", [](S& s) { return field_out(s, s.getOpenness(1)); },
406 "TEMP: -y face openness per inner cell, (nx,ny,nz).")
407 .def(
408 "get_oz", [](S& s) { return field_out(s, s.getOpenness(2)); },
409 "TEMP: -z face openness per inner cell, (nx,ny,nz).")
410 .def(
411 "get_ox_proj", [](S& s) { return field_out(s, s.getOpennessProj(0)); },
412 "-x face openness whose fluxes the projection CONSERVES (binary/COUPLED under "
413 "set_ghost_projection, geometric cut-cell otherwise). Use for flux bookkeeping "
414 "(peclet.pnm extract_network_flow).")
415 .def(
416 "get_oy_proj", [](S& s) { return field_out(s, s.getOpennessProj(1)); },
417 "-y face openness the projection conserves (see get_ox_proj).")
418 .def(
419 "get_oz_proj", [](S& s) { return field_out(s, s.getOpennessProj(2)); },
420 "-z face openness the projection conserves (see get_ox_proj).")
421 .def(
422 "get_uf", [](S& s) { return field_out(s, s.getFaceVelocity(0)); },
423 "Return the divergence-free FACE x-velocity (collocated: projected MAC field; staggered: "
424 "== get_u).")
425 .def(
426 "get_vf", [](S& s) { return field_out(s, s.getFaceVelocity(1)); },
427 "Return the divergence-free FACE y-velocity (collocated: projected MAC field; staggered: "
428 "== get_v).")
429 .def(
430 "get_wf", [](S& s) { return field_out(s, s.getFaceVelocity(2)); },
431 "Return the divergence-free FACE z-velocity (collocated: projected MAC field; staggered: "
432 "== get_w).")
433 // --- Named field registry (multiphysics field container) ---------------------------------
434 .def(
435 "add_field", [](S& s, const std::string& name) { s.addField(name); }, nb::arg("name"),
436 "Register a new zero-initialised cell-centred field on the grid (for transported scalars "
437 "or material properties). Idempotent.")
438 .def(
439 "has_field", [](S& s, const std::string& name) { return s.hasField(name); },
440 nb::arg("name"), "Whether a field of this name is registered.")
441 .def(
442 "field_names", [](S& s) { return s.fieldNames(); },
443 "Names of all registered fields (velocity u/v/w, p, sdf, plus any added), sorted.")
444 .def(
445 "get_field", [](S& s, const std::string& name) { return field_out(s, s.getField(name)); },
446 nb::arg("name"),
447 "Return a registered field's inner region as a Fortran-order (nx,ny,nz) float64 array.")
448 .def(
449 "set_field",
450 [](S& s, const std::string& name, nb::ndarray<double, nb::f_contig> a) {
451 s.setField(name, grid_in(a));
452 },
453 nb::arg("name"), nb::arg("array"),
454 "Write a Fortran-order (nx,ny,nz) float64 array into a registered field's inner region "
455 "(ghosts refilled on the next exchange_field/step).")
456 .def(
457 "field_view",
458 [](S& s, const std::string& name) { return field3d_out(s, s.fieldView(name)); },
459 nb::arg("name"),
460 "Zero-copy view of a registered field's full padded buffer as a Fortran-order "
461 "(nx+2g, ny+2g, nz+2g) array (g = ghost_width); host → NumPy, device → DLPack (CuPy).")
462 .def(
463 "exchange_field", [](S& s, const std::string& name) { s.exchangeField(name); },
464 nb::arg("name"),
465 "Fill a registered field's ghost cells (cross-rank + periodic under MPI; periodic "
466 "single-rank).")
467 .def(
468 "exchange_field_add", [](S& s, const std::string& name) { s.exchangeFieldAdd(name); },
469 nb::arg("name"),
470 "Add-reduce halo: fold ghost-layer deposits back onto their owner (cross-rank + "
471 "periodic). "
472 "The particle->grid deposition primitive for MPI CFD-DEM; single-rank non-periodic "
473 "no-op.")
474 // --- Scalar transport (advection-diffusion) ----------------------------------------------
475 .def(
476 "add_scalar",
477 [](S& s, const std::string& name, double diffusivity, int scheme, int iters) {
478 s.addScalar(name, diffusivity, scheme, iters);
479 },
480 nb::arg("name"), nb::arg("diffusivity") = 0.0, nb::arg("scheme") = 1,
481 nb::arg("iters") = 50,
482 "Register a transported scalar (temperature/concentration/…): constant diffusivity (grid "
483 "units), advection scheme 0=FOU/1=Koren TVD/2=SOU, and RB-GS diffusion sweeps. The "
484 "scalar "
485 "is a registered field (get_field/set_field/field_view). Requires geometry "
486 "(set_solid/set_pressure_geometry) for the openness-weighted operators.")
487 .def(
488 "set_scalar_bc",
489 [](S& s, const std::string& name, int face, int type, double value) {
490 s.setScalarBc(name, face, type, value);
491 },
492 nb::arg("name"), nb::arg("face"), nb::arg("type"), nb::arg("value") = 0.0,
493 "Scalar boundary condition on a domain face (0..5 = -x,+x,-y,+y,-z,+z): type 0 periodic, "
494 "1 Neumann zero-flux (adiabatic), 2 Dirichlet value. Single-rank.")
495 .def(
496 "has_scalar", [](S& s, const std::string& name) { return s.hasScalar(name); },
497 nb::arg("name"), "Whether a transported scalar of this name is registered.")
498 .def(
499 "advance_scalars", [](S& s) { s.advanceScalars(); },
500 "Advance all registered scalars one dt with the current velocity (also done by step()).")
501 // --- Property closures + Boussinesq body force -------------------------------------------
502 .def(
503 "set_property_model",
504 [](S& s, const std::string& target, const std::string& kind, const std::string& in0,
505 const std::vector<double>& params, const std::string& in1) {
507 if (kind == "linear")
509 else if (kind == "boussinesq")
511 else if (kind == "arrhenius")
513 else
514 throw std::runtime_error("set_property_model: unknown kind '" + kind + "'");
515 s.setPropertyModel(target, k, in0, in1, params);
516 },
517 nb::arg("target"), nb::arg("kind"), nb::arg("field"),
518 nb::arg("params") = std::vector<double>{}, nb::arg("field2") = std::string{},
519 "Register a device closure writing a property/body-force field from input field(s). "
520 "target: a registered field (a property 'mu'/'rho'/… or a body-force component "
521 "'force_x'/'force_y'/'force_z'). kind: 'linear' (params [p0,p1,p2]: "
522 "p0+p1*field+p2*field2), "
523 "'boussinesq' (params [rho0,g,beta,T0]: rho0*g*beta*(field-T0) buoyancy), 'arrhenius' "
524 "(params [mu_ref,B,Tref]: mu_ref*exp(B*(1/field-1/Tref))). Applied at the top of step().")
525 .def(
526 "set_property_table",
527 [](S& s, const std::string& target, const std::string& field,
528 const std::vector<double>& x,
529 const std::vector<double>& y) { s.setPropertyTable(target, field, x, y); },
530 nb::arg("target"), nb::arg("field"), nb::arg("x"), nb::arg("y"),
531 "Register a tabulated property: target = piecewise-linear interpolation of (x, y) at the "
532 "input field value (x ascending, clamped at the ends).")
533 .def(
534 "update_properties", [](S& s) { s.updateProperties(); },
535 "Apply all registered property/force closures now (also done at the top of step()).")
536 .def(
537 "enable_cell_force", [](S& s) { s.enableCellForce(); },
538 "Allocate + register the per-cell body-force fields force_x/force_y/force_z and route "
539 "them "
540 "into the momentum RHS, for an external writer (e.g. CFD-DEM drag feedback) to fill "
541 "directly via field_view('force_z'). They persist across steps until overwritten.")
542 .def(
543 "enable_drag", [](S& s) { s.enableDrag(); },
544 "Enable implicit (semi-implicit) linear drag for CFD-DEM: allocate the per-cell "
545 "'drag_beta' "
546 "field (added to the momentum diagonal so a -beta*(u-u_p) source is treated implicitly "
547 "-> "
548 "unconditionally stable for the stiff beta of a dense bed) plus force_x/y/z (which carry "
549 "beta*u_p, the RHS target). Fill 'drag_beta' and 'force_*' via field_view each step.")
550 .def(
551 "set_property_mode",
552 [](S& s, const std::string& mode, bool harmonic) {
553 s.setPropertyMode(mode == "variable", harmonic);
554 },
555 nb::arg("mode") = "variable", nb::arg("harmonic") = false,
556 "Enable variable-coefficient momentum (variable viscosity): mode 'variable' binds the "
557 "'mu' "
558 "field (get/set_field('mu')) into the diffusion operator; 'constant' reverts. harmonic = "
559 "harmonic face-viscosity mean (continuous shear stress across a jump) vs arithmetic. A "
560 "closure targeting 'mu' enables this automatically. The incremental-rotational pressure "
561 "scheme (large-dt / steady-Stokes) stays active — see set_variable_rotational.")
562 .def(
563 "set_variable_rotational",
564 [](S& s, const std::string& mode, double chi) {
565 int m = 0;
566 if (mode == "min")
567 m = 0;
568 else if (mode == "full")
569 m = 1;
570 else if (mode == "off")
571 m = 2;
572 else
573 throw std::runtime_error("set_variable_rotational: mode must be min/full/off");
574 s.setVariableRotational(m, chi);
575 },
576 nb::arg("mode") = "min", nb::arg("chi") = 1.0,
577 "Rotational-pressure term under variable viscosity (the constant-mu Timmermans term "
578 "-mu*div(u*) is only valid for homogeneous viscosity — Deteix & Yakoubi 2018). 'min' "
579 "(default): constant coefficient chi*mu_min — provably stable at any contrast, exact "
580 "fallback to the constant-mu scheme for uniform mu. 'full': pointwise chi*mu(i) — better "
581 "pressure consistency at MILD contrast only. 'off': plain incremental (no rotational "
582 "term). All modes keep the incremental predictor (large-dt / steady-Stokes capability).")
583 .def(
584 "set_density_mode",
585 [](S& s, const std::string& mode) { s.setDensityMode(mode == "variable"); },
586 nb::arg("mode") = "variable",
587 "Enable variable density (staggered solver only): binds the 'rho' field "
588 "(get/set_field('rho'), created seeded with set_rho's value if absent) into the momentum "
589 "time term, the advection weight, the per-cell body force (face-interpolated), and the "
590 "pressure projection (face coefficient openness*rho0/rho_f with the matching 1/rho_f "
591 "velocity correction; rho0 = set_rho's value, so a uniform field reduces exactly to the "
592 "constant solver). A closure targeting 'rho' (e.g. a linear mixture of a transported "
593 "phase fraction) enables this automatically. For gravity, register a closure "
594 "force_z = linear(rho, params=[0, -g]).")
595 .def(
596 "ghost_width", [](S& s) { return s.ghostWidth(); },
597 "Ghost-layer width g of the velocity block (field_view returns an (n+2g) buffer).")
598 .def(
599 "has_cutcell_pressure", [](S& s) { return s.hasCutcellPressure(); },
600 "True once the cut-cell pressure operator exists (set_solid or set_pressure_geometry was "
601 "called). The porous continuity requires it; the coupling driver auto-sets an all-fluid "
602 "geometry when absent.")
603 .def(
604 "set_porous_continuity", [](S& s, bool on) { s.setPorousContinuity(on); },
605 nb::arg("on") = true,
606 "Enable the volume-averaged (porous) continuity for unresolved CFD-DEM (staggered only): "
607 "the projection enforces d(eps)/dt + div(eps u) = 0 instead of div(u)=0, so the fluid "
608 "velocity is NOT solenoidal where the void fraction changes (bubbling/expansion). Binds "
609 "the 'eps' field (void fraction from the particle deposition, created seeded to 1 if "
610 "absent; the coupling writes it each step BEFORE step()). eps=1 everywhere reduces "
611 "exactly "
612 "to div(u)=0. Pair with max_porous_residual() for the meaningful convergence check.")
613 .def("max_open_divergence", &S::maxOpenDivergence,
614 "Return the max cut-cell velocity-flux divergence max|div(open*u)|. With porous "
615 "continuity this is NOT ~0 -- it equals -d(eps)/dt (the bed expanding). Use "
616 "max_porous_residual() for the continuity residual.")
617 .def(
618 "set_pressure_underrelax", [](S& s, double w) { s.setPressureUnderRelax(w); },
619 nb::arg("omega"),
620 "Pressure under-relaxation factor omega_p in (0,1] for the incremental accumulation "
621 "(MFIX "
622 "§10.1); 1.0 = off (default). <1 damps the incremental predictor overshoot on stiff "
623 "porous+drag.")
624 .def(
625 "set_porous_deps_dt", [](S& s, bool on) { s.setPorousDepsDt(on); }, nb::arg("on"),
626 "Include (default True) or drop the d(eps)/dt source in the porous projection RHS. Drop "
627 "it "
628 "to enforce div(eps u)=0 when the per-cell eps deposit's time-derivative is too jagged "
629 "and "
630 "destabilizes the eps-weighted pressure solve.")
631 .def(
632 "set_porous_conservative", [](S& s, bool on) { s.setPorousConservative(on); },
633 nb::arg("on"),
634 "eps-conservative porous momentum + projection pair (default True): time term "
635 "(eps_f rho/dt) u, eps rho-weighted advective form, projection coefficients "
636 "open*(eps rho idt)/(eps rho idt+beta) with matching correction. False = the legacy "
637 "plain-u pair (A/B only; it lets the projection drag gas with the moving porosity at "
638 "zero inertia cost — a spurious late-time energy source in clustering flows).")
639 .def("sync_porous_prev", &S::syncPorousPrev,
640 "Reseed eps^n = eps^{n+1} (d(eps)/dt=0 this step) — call once after the first "
641 "void-fraction "
642 "deposition so step 0 has no spurious source.")
643 .def("max_porous_residual", &S::maxPorousResidual,
644 "Residual of the volume-averaged continuity max|div(open*eps*u) + d(eps)/dt| -- the "
645 "quantity the porous projection drives to zero. 0 unless set_porous_continuity(True).")
646 .def(
647 "get_resolution", [](S& s) { return std::vector<int>{s.nx(), s.ny(), s.nz()}; },
648 "Return the LOCAL grid resolution [nx, ny, nz] (this rank's block under MPI).")
649 .def(
650 "global_resolution",
651 [](S& s) {
652 auto g = s.globalResolution();
653 return std::vector<int>{g[0], g[1], g[2]};
654 },
655 "Return the GLOBAL grid resolution [gnx, gny, gnz] (== local single-rank). For the "
656 "CFD-DEM co-decomposition weight field.")
657 .def(
658 "block_origin",
659 [](S& s) {
660 auto o = s.blockOrigin();
661 return std::vector<int>{o[0], o[1], o[2]};
662 },
663 "This rank's inner-block origin in GLOBAL cells ([0,0,0] single-rank). Shift the "
664 "coupling "
665 "deposit origin by this so particles in global coordinates land in the local block.")
666 .def(
667 "get_spacing", [](S&) { return std::vector<double>{1.0, 1.0, 1.0}; },
668 "Return the grid spacing [dx, dy, dz] (always unit on this grid).")
669#ifdef PECLET_FLOW_MPI
670 // Distributed path (built with -DPECLET_FLOW_MPI): construct the Solver with this rank's
671 // LOCAL block dims (see the module-level mpi_block()), then init_mpi with the GLOBAL grid
672 // dims. step() then does the g=2 velocity-block halo exchange + the distributed cut-cell
673 // pressure MG. Bit-exact to single-rank.
674 .def(
675 "init_mpi",
676 [](S& s, int gnx, int gny, int gnz) {
677 ensure_mpi_init();
678 s.initMpi(gnx, gny, gnz, MPI_COMM_WORLD);
679 },
680 nb::arg("gnx"), nb::arg("gny"), nb::arg("gnz"),
681 "Wire the multi-rank step: pass the GLOBAL grid dims (gnx,gny,gnz). The Solver must have "
682 "been "
683 "constructed with this rank's LOCAL block dims (from mpi_block). MPI_Init is called if "
684 "needed.")
685 .def(
686 "rebalance_by_weights",
687 [](S& s, const std::vector<double>& w) { s.rebalanceByWeights(w); }, nb::arg("weights"),
688 "Dynamic load balancing: redistribute the solver's state onto the weighted ORB of "
689 "per-cell "
690 "weights (global x-fastest, gnx*gny*gnz). Pass fluid work + gamma*particle_count and the "
691 "coupled dem migrates onto the SAME partition from the same array. State-preserving "
692 "(bit-exact at np=1, reduction floor at np>1).")
693 .def(
694 "rank",
695 [](S&) {
696 ensure_mpi_init();
697 int r = 0;
698 MPI_Comm_rank(MPI_COMM_WORLD, &r);
699 return r;
700 },
701 "This rank's index in MPI_COMM_WORLD.")
702 .def(
703 "size",
704 [](S&) {
705 ensure_mpi_init();
706 int n = 1;
707 MPI_Comm_size(MPI_COMM_WORLD, &n);
708 return n;
709 },
710 "The number of ranks in MPI_COMM_WORLD.")
711#else
712 .def(
713 "rank", [](S&) { return 0; },
714 "MPI rank (always 0 in the single-rank Python module; the multi-rank path is the "
715 "tests/kokkos_mpi suite).")
716 .def(
717 "size", [](S&) { return 1; }, "MPI size (1 in the single-rank Python module).")
718#endif
719 .def(
720 "bcast_from_root", [](S&, nb::object v) { return v; }, nb::arg("value"),
721 "Broadcast a value from rank 0 (identity in the single-rank module; mirrors the MPI "
722 "API).");
723}
724
725NB_MODULE(_flow, m) {
726 m.attr("__doc__") =
727 "flow — Kokkos cut-cell IBM incompressible Navier-Stokes solver for porous media.\n\n"
728 "Two solver classes share an identical API (only the velocity-unknown placement differs):\n"
729 " Solver — staggered MAC grid (THE flow solver; permeability/drag accuracy "
730 "default)\n"
731 " SolverColocated — collocated / cell-centered velocities (ABC approximate projection)\n\n"
732 "Conventions: physical units throughout (density rho, viscosity mu, physical pressure p); "
733 "SDFs\n"
734 "are negative inside the solid; fields are Fortran-order (nx,ny,nz) float64 (x-fastest). "
735 "This is\n"
736 "the single-rank module — the multi-rank MPI path is exercised by the tests/kokkos_mpi "
737 "suite.\n\n"
738 "Kokkos is initialized at import and finalized via a Python atexit hook. Release every "
739 "Solver "
740 "before interpreter exit (it goes out of scope, or `del s; gc.collect()`) so no Kokkos View "
741 "outlives finalize.";
742 if (!Kokkos::is_initialized())
743 Kokkos::initialize();
744 // Register Kokkos::finalize via Python atexit. This is REQUIRED on CUDA: without it, Kokkos's
745 // internal device state is torn down by static destructors AFTER the CUDA runtime unloads,
746 // aborting with cudaErrorCudartUnloading at every exit. atexit runs the hook while the driver is
747 // still up. (Returned fields are backed by host std::vectors, not device Views, so they never
748 // block finalize; a live Solver still holding Views at exit must be released first — hence the
749 // docstring note.)
750 nb::module_::import_("atexit").attr("register")(nb::cpp_function([]() {
751 if (Kokkos::is_initialized() && !Kokkos::is_finalized())
752 Kokkos::finalize();
753 }));
754 // The active Kokkos backend ("OpenMP", "Cuda", "HIP"), chosen by the build's install prefix.
755 m.attr("execution_space") = nb::str(Kokkos::DefaultExecutionSpace::name());
756
757 // Staggered MAC grid (THE flow solver) + the collocated/cell-centered variant. Same Python API.
758 bind_solver<peclet::flow::Staggered>(m, "Solver");
759 bind_solver<peclet::flow::Colocated>(m, "SolverColocated");
760
761#ifdef PECLET_FLOW_MPI
762 // Module-level: this rank's ORB block of the global (gnx,gny,gnz) grid, matching the
763 // deterministic BlockDecomposer the Solver's initMpi re-derives internally (and the C++
764 // tests/kokkos_mpi template). Returns (origin=[ox,oy,oz], size=[lnx,lny,lnz]); slice the global
765 // SDF with these to build the local block, then Solver(*size) + init_mpi(gnx,gny,gnz). MPI_Init
766 // is called if needed.
767 m.def(
768 "mpi_block",
769 [](int gnx, int gny, int gnz) {
770 ensure_mpi_init();
771 int rank = 0, size = 1;
772 MPI_Comm_rank(MPI_COMM_WORLD, &rank);
773 MPI_Comm_size(MPI_COMM_WORLD, &size);
774 // Derive it through the SAME factory Solver::initMpi uses, so this local block size matches
775 // the solver's dec_ under either decomposition mode (see set_decomposition_levels).
776 auto dec = peclet::flow::CutcellMG::decomposition(static_cast<std::size_t>(size), gnx, gny,
777 gnz);
778 auto blk = dec.block(static_cast<std::size_t>(rank));
779 std::vector<int> origin{(int)blk.origin[0], (int)blk.origin[1], (int)blk.origin[2]};
780 std::vector<int> bsize{(int)blk.size[0], (int)blk.size[1], (int)blk.size[2]};
781 return std::make_pair(origin, bsize);
782 },
783 nb::arg("gnx"), nb::arg("gny"), nb::arg("gnz"),
784 "Return this MPI rank's ORB block of the global (gnx,gny,gnz) grid as (origin, size), each a "
785 "length-3 list [x,y,z]. Use it to slice the global SDF into this rank's local block for a "
786 "distributed Solver (see Solver.init_mpi). MPI_Init is called if needed.");
787
788 m.def(
789 "set_decomposition_levels",
790 [](int levels) { peclet::flow::CutcellMG::setDecompositionLevels(levels); },
791 nb::arg("levels"),
792 "Choose how the shared MPI decomposition is built. 0 (default) = the aligned ORB: split "
793 "positions on the FINE grid are snapped to a power of two (capped at 16, i.e. 5 nested "
794 "multigrid levels). levels >= 2 = COARSE-FIRST: build the ORB on the grid coarsened "
795 "levels-1 times and refine the partition upward, so every block is a multiple of the "
796 "coarsening factor BY CONSTRUCTION and the hierarchy nests for the full requested depth. "
797 "Coarse-first also balances better: the aligned ORB picks a split and then rounds it (a "
798 "balanced 96|96 can round to 128|64), whereas on the coarse grid one cell IS the quantum, so "
799 "the rank count no longer has to be a power of two -- what matters is that the COARSE grid "
800 "divides among the ranks. Backs off automatically if the coarse grid would have fewer cells "
801 "than ranks. CALL BEFORE mpi_block() AND Solver.init_mpi(): both derive the same partition "
802 "from this setting and must agree. Env override: PECLET_FLOW_DECOMP_LEVELS.");
803 m.def("decomposition_levels", [] { return peclet::flow::CutcellMG::decompositionLevels(); },
804 "Current decomposition mode (0 = aligned ORB, >= 2 = coarse-first with that depth).");
805
806 m.attr("has_mpi") = true;
807#else
808 m.attr("has_mpi") = false;
809#endif
810}
NB_MODULE(_flow, m)
static nb::ndarray< nb::numpy, double > field_out(S &s, std::vector< double > &&v)
static std::vector< double > grid_in(nb::ndarray< double, nb::f_contig > a)
static void bind_solver(nb::module_ &m, const char *name)
static auto field3d_out(S &s, peclet::flow::CCField f)
flow — host-facing Kokkos IBM Navier-Stokes solver (drop-in flow-style API).
CCExec::memory_space CCMem
Kokkos::View< double *, CCMem > CCField
Kokkos::DefaultExecutionSpace::memory_space Mem