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 + "
111 "Set physical parameters (rho/mu/dt) and any domain BCs before the geometry / first "
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 "
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 ⇒ "
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).")
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.")
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.")
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.")
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.")
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.")
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().")
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);
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 "
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): "
230 "in [0,1] scales the dissipative outflow term that prevents backflow divergence when "
232 "reverses at the outlet (e.g. a separated wake / BFS recirculation). Default 0.2; 0 = "
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.")
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).")
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");
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.")
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'");
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"),
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().")
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();
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).")
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_,
350 nb::arg(
"face"), nb::arg(
"profile"),
351 "Prescribe a per-position inlet velocity profile (Nb,Nc,3) over a face (sets it to "
354 "set_pressure_geometry",
355 [](S& s, nb::ndarray<double, nb::f_contig> sdf) { s.setPressureGeometry(
grid_in(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, "
360 "set_solid(sdf, cutcell_pressure=True) instead -- do NOT also call this (a second "
362 "setter overwrites the SDF and wipes the solid).")
365 [](S& s, nb::ndarray<double, nb::f_contig> sdf,
bool cutcell_pressure,
366 const std::string& ) {
367 s.setSolid(
grid_in(sdf), cutcell_pressure);
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 "
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.")
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) {
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).")
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 "
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 "
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 "
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 "
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).")
405 "get_oy", [](S& s) {
return field_out(s, s.getOpenness(1)); },
406 "TEMP: -y face openness per inner cell, (nx,ny,nz).")
408 "get_oz", [](S& s) {
return field_out(s, s.getOpenness(2)); },
409 "TEMP: -z face openness per inner cell, (nx,ny,nz).")
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).")
416 "get_oy_proj", [](S& s) {
return field_out(s, s.getOpennessProj(1)); },
417 "-y face openness the projection conserves (see get_ox_proj).")
419 "get_oz_proj", [](S& s) {
return field_out(s, s.getOpennessProj(2)); },
420 "-z face openness the projection conserves (see get_ox_proj).")
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: "
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: "
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: "
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.")
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.")
442 "field_names", [](S& s) {
return s.fieldNames(); },
443 "Names of all registered fields (velocity u/v/w, p, sdf, plus any added), sorted.")
445 "get_field", [](S& s,
const std::string& name) {
return field_out(s, s.getField(name)); },
447 "Return a registered field's inner region as a Fortran-order (nx,ny,nz) float64 array.")
450 [](S& s,
const std::string& name, nb::ndarray<double, nb::f_contig> a) {
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).")
458 [](S& s,
const std::string& name) {
return field3d_out(s, s.fieldView(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).")
463 "exchange_field", [](S& s,
const std::string& name) { s.exchangeField(name); },
465 "Fill a registered field's ghost cells (cross-rank + periodic under MPI; periodic "
468 "exchange_field_add", [](S& s,
const std::string& name) { s.exchangeFieldAdd(name); },
470 "Add-reduce halo: fold ghost-layer deposits back onto their owner (cross-rank + "
472 "The particle->grid deposition primitive for MPI CFD-DEM; single-rank non-periodic "
477 [](S& s,
const std::string& name,
double diffusivity,
int scheme,
int iters) {
478 s.addScalar(name, diffusivity, scheme, iters);
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 "
485 "is a registered field (get_field/set_field/field_view). Requires geometry "
486 "(set_solid/set_pressure_geometry) for the openness-weighted operators.")
489 [](S& s,
const std::string& name,
int face,
int type,
double value) {
490 s.setScalarBc(name, face, type, value);
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.")
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.")
499 "advance_scalars", [](S& s) { s.advanceScalars(); },
500 "Advance all registered scalars one dt with the current velocity (also done by step()).")
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")
514 throw std::runtime_error(
"set_property_model: unknown kind '" + kind +
"'");
515 s.setPropertyModel(target, k, in0, in1, params);
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().")
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).")
534 "update_properties", [](S& s) { s.updateProperties(); },
535 "Apply all registered property/force closures now (also done at the top of step()).")
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 "
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.")
543 "enable_drag", [](S& s) { s.enableDrag(); },
544 "Enable implicit (semi-implicit) linear drag for CFD-DEM: allocate the per-cell "
546 "field (added to the momentum diagonal so a -beta*(u-u_p) source is treated implicitly "
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.")
552 [](S& s,
const std::string& mode,
bool harmonic) {
553 s.setPropertyMode(mode ==
"variable", harmonic);
555 nb::arg(
"mode") =
"variable", nb::arg(
"harmonic") =
false,
556 "Enable variable-coefficient momentum (variable viscosity): mode 'variable' binds the "
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.")
563 "set_variable_rotational",
564 [](S& s,
const std::string& mode,
double chi) {
568 else if (mode ==
"full")
570 else if (mode ==
"off")
573 throw std::runtime_error(
"set_variable_rotational: mode must be min/full/off");
574 s.setVariableRotational(m, chi);
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).")
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]).")
596 "ghost_width", [](S& s) {
return s.ghostWidth(); },
597 "Ghost-layer width g of the velocity block (field_view returns an (n+2g) buffer).")
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.")
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 "
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.")
618 "set_pressure_underrelax", [](S& s,
double w) { s.setPressureUnderRelax(w); },
620 "Pressure under-relaxation factor omega_p in (0,1] for the incremental accumulation "
622 "§10.1); 1.0 = off (default). <1 damps the incremental predictor overshoot on stiff "
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 "
628 "to enforce div(eps u)=0 when the per-cell eps deposit's time-derivative is too jagged "
630 "destabilizes the eps-weighted pressure solve.")
632 "set_porous_conservative", [](S& s,
bool on) { s.setPorousConservative(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 "
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).")
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).")
652 auto g = s.globalResolution();
653 return std::vector<int>{g[0], g[1], g[2]};
655 "Return the GLOBAL grid resolution [gnx, gny, gnz] (== local single-rank). For the "
656 "CFD-DEM co-decomposition weight field.")
660 auto o = s.blockOrigin();
661 return std::vector<int>{o[0], o[1], o[2]};
663 "This rank's inner-block origin in GLOBAL cells ([0,0,0] single-rank). Shift the "
665 "deposit origin by this so particles in global coordinates land in the local block.")
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
676 [](S& s,
int gnx,
int gny,
int gnz) {
678 s.initMpi(gnx, gny, gnz, MPI_COMM_WORLD);
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 "
683 "constructed with this rank's LOCAL block dims (from mpi_block). MPI_Init is called if "
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 "
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).")
698 MPI_Comm_rank(MPI_COMM_WORLD, &r);
701 "This rank's index in MPI_COMM_WORLD.")
707 MPI_Comm_size(MPI_COMM_WORLD, &n);
710 "The number of ranks in MPI_COMM_WORLD.")
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).")
717 "size", [](S&) {
return 1; },
"MPI size (1 in the single-rank Python module).")
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 "