263 "Developer tier of a Solver, reached as `s.diagnostics`: instruments (censuses, timers, "
264 "budgets, ledgers), solver TUNING beyond the public driver selection, ablation switches "
265 "and the raw zero-copy / MPI internals. Nothing here is needed to set up, run or read out "
266 "a simulation. It holds a reference to the solver and no state of its own; the setters "
267 "that CHANGE RESULTS say so in their docstring.")
270 [](D& diag,
int mode) {
271 if (mode != 5 && mode != 6)
272 throw std::invalid_argument(
273 "diagnostics.set_face_interp: mode must be 5 or 6 (the two intermediate rungs "
274 "of the embed port); the complete schemes are Solver.set_collocated_scheme("
275 "'plain' | 'gauge-exact' | 'embed' | 'ghost')");
276 diag.s->setFaceInterp(mode);
279 "The two INTERMEDIATE rungs of the Basilisk embed.h port on the collocated grid "
280 "(doc/history/collocated_embed_port_plan.md), reachable for the ladder that measures "
281 "what each piece of the complete port ('embed' = mode 7) is worth: 5 = the FV momentum "
282 "operator with the true-normal dirichlet_gradient wall drag as a defect correction, on "
283 "the wall-aware face map with the transpose-gradient pressure force; 6 = plus the "
284 "openness-weighted -grad(P) predictor and cell correction, on the plain face map "
285 "(7 adds the wall-aware face map and the solid-cut-cell sliver mask). CHANGES RESULTS; "
286 "no effect on the staggered solver. Modes 1-4 and 10-13 (ablations) were deleted with "
287 "their kernels at 1.0.0.")
288 .def(
"set_incremental_pressure", [](D& diag,
bool on) {
return diag.s->setIncrementalPressure(on); }, nb::arg(
"on"),
289 "Toggle the rotational incremental-pressure projection.")
290 .def(
"set_pressure_warmstart", [](D& diag,
bool on) {
return diag.s->setPressureWarmstart(on); }, nb::arg(
"on"),
291 "Seed each pressure solve from the previous step's phi (default off).")
293 "set_rotational_pressure", [](D& diag,
bool on) {
return diag.s->setRotationalPressure(on); }, nb::arg(
"on"),
294 "PM I ablation (Guy-Fogelson 2005): False drops the rotational -mu*div(u*) term from "
295 "the incremental pressure accumulation (constant-mu path only). Default True = shipped "
296 "rotational (Timmermans) update.")
298 "set_rotational_filter", [](D& diag,
bool on,
double eps) {
return diag.s->setRotationalFilter(on, eps); }, nb::arg(
"on"), nb::arg(
"eps") = 0.05,
299 "Experimental filtered rotational update: smooth div(u*) (mask-aware axis-wise 1-2-1, "
300 "one-sided into the fluid at walls) before accumulating -mu*div into P. Keeps the O(1) "
301 "pressure-relaxation gain, removes the checkerboard feedback channel.")
303 "set_rotational_weight", [](D& diag,
double w) {
return diag.s->setRotationalWeight(w); }, nb::arg(
"w"),
304 "Under-relax the rotational term: P += ct*phi - w*mu*div(u*). 1 = shipped, 0 = PM I; "
305 "small w raises the boundary-mode stability threshold ~1/w at ~1/w slower smooth-mode "
306 "pressure relaxation. phi=0 stays the unique fixed point for any w>0 at every dt.")
308 "set_rotational_wall_weight", [](D& diag,
double w0) {
return diag.s->setRotationalWallWeight(w0); }, nb::arg(
"w0"),
309 "Wall-banded rotational blend: at fluid cells with a solid axis-neighbour use "
310 "P += (rho/dt + w0*mu/dx^2)*phi - (1-w0)*mu*div(u*); bulk keeps the full rotational "
311 "update. Stabilizes the boundary rows without slowing bulk pressure relaxation, and "
312 "keeps them relaxing at dt->infinity. 0 (default) = off.")
313 .def(
"set_aperture_order", [](D& diag,
int order) {
return diag.s->setApertureOrder(order); }, nb::arg(
"order"),
314 "Face-aperture estimator (DEFAULT 2 since 2026-08-26): 1 = legacy one-sample linear model, 2 = "
315 "marching-squares (5 samples/face, O(h^2); removes the convexity bias measured at "
316 "+0.59/+0.27% bed permeability at R=8/12 -- see doc/collocated_paper_plan.md row 51). "
317 "For analytic geometry, exact apertures via set_openness_override are better still. "
318 "Call before set_solid (a later call raises).")
319 .def(
"set_advection_wall_velocity", [](D& diag,
bool on) {
return diag.s->setAdvectionWallVelocity(on); }, nb::arg(
"on"),
320 "A0: fill the advection inputs' masked (solid) rows with the instantaneous WALL "
321 "velocity instead of zeros, so a MOVING body's advective term sees the body's own "
322 "motion. DEFAULT True; False is the pre-A0 ablation (measurement only). Inert on a "
323 "static scene and on the collocated grid.")
324 .def_prop_ro(
"advection_wall_velocity", [](D& diag) {
return diag.s->advectionWallVelocity(); },
325 "Whether the wall-velocity advection inputs are in force.")
326 .def(
"set_uf_advection", [](D& diag,
bool on) {
return diag.s->setUfAdvection(on); }, nb::arg(
"on"),
327 "Advect the COLLOCATED momentum with the projected, divergence-free MAC face field "
328 "uf/vf/wf of the last projection instead of the un-projected cell->face average "
329 "0.5*(u_i+u_j). DEFAULT True -- the shipped scheme: doc/flow_colocated_plan.md "
330 "step 3, the Almgren-Bell-Colella prescription (the field the projection just made "
331 "solenoidal IS the conservative advective flux), and what the FOU operator's "
332 "conservative row-sum identity needs. False is the ablation that restores the phase-2 "
333 "average (measurement only; see doc/uf_advection.md). Inert on the staggered grid (the "
334 "stored velocity already IS the projected face velocity) and before the first "
335 "projection or set_velocity has built a face field.")
336 .def_prop_ro(
"uf_advection", [](D& diag) {
return diag.s->ufAdvection(); },
337 "Whether the projected face field is the collocated advecting velocity.")
340 [](D& diag,
const std::string& mode) {
344 else if (mode ==
"off")
346 else if (mode ==
"momentum")
348 else if (mode ==
"pressure")
351 throw std::runtime_error(
352 "set_comm_avoiding: 'both' | 'off' | 'momentum' | 'pressure'");
353 diag.s->setCommAvoiding(mask);
356 "Communication-avoiding red-black smoothing (multi-rank): exchange a 2-deep ghost layer "
357 "once per red-black PAIR instead of a 1-deep layer before every colour, redundantly "
358 "re-smoothing the first colour's ghost ring so the second colour reads exactly what a "
359 "per-colour exchange would have delivered. BIT-IDENTICAL, at half the halo events "
360 "(measured: np=32 weak efficiency 35% -> 62%). 'both' (DEFAULT) | 'off' | 'momentum' "
361 "(the velocity RB-GS only) | 'pressure' (the pressure-multigrid coarse levels only) -- "
362 "the split exists to ATTRIBUTE a measured regression to one subsystem. Set it BEFORE "
363 "init_mpi (a later call raises: the momentum half is latched with the halo topology); "
364 "either half is inert on blocks smaller than 4 cells on any axis.")
368 const int m = diag.s->commAvoiding();
374 "The communication-avoiding smoothing mode in force (see set_comm_avoiding).")
375 .def(
"set_multigrid_aspect_threshold", [](D& diag,
double theta) {
return diag.s->setMultigridAspectThreshold(theta); }, nb::arg(
"theta"),
376 "Anisotropic-coarsening aspect threshold theta shared by the pressure and velocity "
377 "multigrids (doc/anisotropic_metric.md §5.1): an axis is coarsened only while its cell "
378 "size stays within theta of the smallest one, so a stretched grid semi-coarsens instead "
379 "of building levels whose operator is dominated by one direction. DEFAULT 2.0; must be "
380 "> 1; 1e9 restores full coarsening on every axis (the §8.5 item (c) ablation). Only "
381 "ever consulted on an ANISOTROPIC metric, so it is inert on cubic cells.")
382 .def_prop_ro(
"multigrid_aspect_threshold", [](D& diag) {
return diag.s->multigridAspectThreshold(); },
383 "The anisotropic-coarsening threshold in force (see "
384 "set_multigrid_aspect_threshold).")
385 .def(
"set_pressure_strict", [](D& diag,
bool on) {
return diag.s->setPressureStrict(on); }, nb::arg(
"on"),
386 "Raise instead of report when the pressure preconditioner returns a non-finite "
387 "correction. DEFAULT False: the shipped behaviour caps the solve, prints one line, and "
388 "raises the pressure_solve_failed() flag, so a check can catch it without changing "
389 "control flow. True turns the same event into an exception.")
390 .def_prop_ro(
"pressure_strict", [](D& diag) {
return diag.s->pressureStrict(); },
391 "Whether a non-finite pressure preconditioner output raises (see "
392 "set_pressure_strict).")
393 .def(
"set_pressure_coarse_ghost", [](D& diag,
bool on) {
return diag.s->setPressureCoarseGhost(on); }, nb::arg(
"on"),
394 "Zero-gradient (Neumann) coarse ghost on wall/inflow faces before the pressure "
395 "multigrid's prolongation -- the WO-H symmetry repair. DEFAULT True. False restores "
396 "the pre-2026-08-30 periodic-wrap ghost, which reinstates the asymmetry that stalls "
397 "MG-PCG on every 3-D wall-bounded grid: a MEASUREMENT ABLATION, never a production "
398 "setting. Inert on periodic / IBM problems, where the two are the same.")
399 .def_prop_ro(
"pressure_coarse_ghost", [](D& diag) {
return diag.s->pressureCoarseGhost(); },
400 "Whether the Neumann coarse ghost is in force (see set_pressure_coarse_ghost).")
402 "set_fluid_only_constraint",
403 [](D& diag,
const std::string& mode) {
404 diag.s->setFluidOnlyConstraint(
408 "Fluid-only pressure constraint (route 2b, a collocated mechanism instrument; call before "
409 "set_solid): 'filter' = Design A (openness filter), 'star' = Design B (SPD Kron star "
410 "elimination), 'off'. Any setting other than 'off' disables the AUTO scheme.")
411 .def(
"set_outer_iterations", [](D& diag,
int iters) {
return diag.s->setOuterIterations(iters); }, nb::arg(
"n"),
412 "Set the number of Picard/outer iterations per step.")
413 .def(
"set_outer_tolerance", [](D& diag,
double tol) {
return diag.s->setOuterTolerance(tol); }, nb::arg(
"tol"),
414 "Set the outer (Picard) convergence tolerance.")
415 .def(
"last_outer_iterations", [](D& diag) {
return diag.s->lastOuterIterations(); },
416 "Return the outer-iteration count from the last step().")
418 "set_velocity_solver",
419 [](D& diag,
const std::string& name) {
421 diag.s->setVelocitySolverAuto();
422 else if (name ==
"gauss_seidel") {
423 diag.s->setVelocityChebyshev(
false);
424 diag.s->setVelocityMultigrid(
false, 3, 40);
425 }
else if (name ==
"multigrid") {
426 diag.s->setVelocityChebyshev(
false);
427 diag.s->setVelocityMultigrid(
true, 3, 40);
428 }
else if (name ==
"chebyshev") {
429 diag.s->setVelocityMultigrid(
false, 3, 40);
430 diag.s->setVelocityChebyshev(
true);
433 throw std::invalid_argument(
434 "set_velocity_solver: expected 'auto', 'gauss_seidel', 'multigrid' or "
435 "'chebyshev', got '" + name +
"'");
438 "Which solver runs the implicit momentum (diffusion) equation. 'auto' (DEFAULT) picks by "
439 "the operator's condition number kappa = 1 + 4 dt mu (sum w)/rho -- the multigrid at "
440 "kappa >= 13, the smoother below -- and names no geometry, so an immersed body and a "
441 "domain-BC box at the same dt get the same solver. 'gauss_seidel' is the red-black "
442 "smoother; 'multigrid' the 3-level velocity V-cycle; 'chebyshev' Chebyshev "
443 "semi-iteration over a Gershgorin interval. ALL FOUR SOLVE THE SAME EQUATION TO THE SAME "
444 "TOLERANCE, so this changes cost and not the answer -- except that 'gauss_seidel' runs "
445 "out of sweeps above kappa ~ 50 and then returns a solution that never met it. "
446 "MEASURED (384^3 cut-cell bed, Snellius): multigrid beats gauss_seidel 1.66x-2.23x "
447 "across the CPU ladder and 2.19x on one H100; chebyshev beats gauss_seidel by the "
448 "sqrt(kappa) factor theory predicts but loses to multigrid except at the smallest "
449 "per-rank blocks, where the evidence is suggestive and not conclusive.")
451 "set_velocity_mg_smoother",
452 [](D& diag,
const std::string& name,
int degree,
double eig_ratio) {
453 if (name ==
"gauss_seidel")
454 diag.s->setVelocityMgChebyshev(
false);
455 else if (name ==
"chebyshev")
456 diag.s->setVelocityMgChebyshev(
true, degree, eig_ratio);
458 throw std::invalid_argument(
459 "set_velocity_mg_smoother: expected 'gauss_seidel' or 'chebyshev', got '" +
462 nb::arg(
"name"), nb::arg(
"degree") = 0, nb::arg(
"eig_ratio") = 10.0,
463 "Which smoother the velocity multigrid uses on every level -- orthogonal to "
464 "set_velocity_solver, and read only when that selects 'multigrid'. 'gauss_seidel' "
465 "(DEFAULT) or 'chebyshev'. ABLATION, AND CHEBYSHEV IS MEASURED SLOWER: at degree 4 / "
466 "eig_ratio 6 it IS the stronger smoother per cycle (8.0 V-cycles against red-black's "
467 "9.3) and still loses 1.21x on wall time, because matching red-black's smoothing "
468 "strength costs the same four halo exchanges two sweeps cost, while Chebyshev needs a "
469 "separate residual and update pass where a Gauss-Seidel sweep fuses the two. Kept "
470 "because it is the apparatus for a question that gets asked repeatedly. degree=0 "
471 "follows the V-cycle's own pre/post/bottom counts.")
472 .def(
"velocity_solver", [](D& diag) {
473 return diag.s->velocityChebyshevActive() ? std::string(
"chebyshev")
474 : diag.s->velocityMultigridActive() ? std::string(
"multigrid")
475 : std::string(
"gauss_seidel");
477 "The momentum solver actually in force, resolving 'auto'. Only meaningful after the "
478 "first step(), which is where the choice is made (dt and mu are final only then).")
479 .def(
"velocity_mg_smoother", [](D& diag) {
480 return diag.s->velocityMgChebyshev() ? std::string(
"chebyshev")
481 : std::string(
"gauss_seidel");
483 "The velocity multigrid's smoother.")
485 "set_velocity_solver_params",
486 [](D& diag,
int iters,
double rtol,
int min_iters) {
487 diag.s->setVelocityIterations(iters);
488 diag.s->setVelocityTolerance(rtol, min_iters);
490 nb::arg(
"iters"), nb::arg(
"rtol") = 0.0, nb::arg(
"min_iters") = 2,
491 "Momentum smoother control: `iters` RB-GS sweeps per component, or with rtol > 0 a "
492 "TOLERANCE STOP — end the loop once the sweep's max velocity increment has contracted "
493 "to rtol of the first sweep's (iters becomes the cap, min_iters the floor). Easy "
494 "regimes (small nu*dt/dx^2) exit after ~3-5 sweeps; stiff regimes run to the cap "
495 "unchanged. rtol = 0 (default) is the legacy fixed count, byte-identical.")
496 .def(
"set_velocity_multigrid_auto", [](D& diag,
long cellsPerRank,
long minGlobalCells,
int minBlockExtent) {
return diag.s->setVelocityMultigridAuto(cellsPerRank, minGlobalCells, minBlockExtent); }, nb::arg(
"cells_per_rank"),
497 nb::arg(
"min_global_cells") = -1, nb::arg(
"min_block_extent") = -1,
498 "Narrow the rule that selects the momentum solver. Since 2026-09-15 the DEFAULT is the "
499 "velocity V-cycle on every validated operator mode and at every rank count: measured on "
500 "the 1.0.0 scaling benchmark it is faster at every rung of both the CPU and the GPU "
501 "ladder (2.23x at 147k cells/rank, 2.19x on one H100), and the red-black smoother it "
502 "replaced was running at its sweep cap -- not converging -- on every rung of both. "
503 "cells_per_rank sets an upper bound in cells per rank (0 = never use the V-cycle); "
504 "set_velocity_multigrid_auto(65536, 1 << 23) restores the 1.0.0 behaviour exactly. "
505 "min_block_extent (default 16) is the shortest per-rank inner extent worth building a "
506 "hierarchy on. An explicit set_velocity_multigrid() overrides all of it.")
507 .def(
"velocity_multigrid_active", [](D& diag) {
return diag.s->velocityMultigridActive(); },
508 "Whether the velocity (momentum) solve uses the multigrid V-cycle rather than plain "
509 "RB-GS. Reads back the OUTCOME, not the setting: an explicit set_velocity_multigrid("
510 "on=...) call always wins, but absent one the AUTO rule (set_velocity_multigrid_auto) "
511 "decides it when the geometry is built (set_solid / set_pressure_geometry / "
512 "set_solid_from_scene) -- on for a distributed run (np > 1) in an eligible operator "
513 "mode with a global problem of at least min_global_cells (default 8M) and fewer than "
514 "cells_per_rank (default 65536) cells per rank, off otherwise. So the value can differ "
515 "from what the caller last set explicitly, and is only meaningful after geometry has "
516 "been installed (False on a fresh solver).")
519 [](D& diag,
const std::string& name) {
520 diag.s->cutcellScalar(name,
"scalar_geometry");
522 d[
"kappa"] =
field_out(*diag.s, diag.s->scalarGeometryField(0));
523 d[
"aperture_x"] =
field_out(*diag.s, diag.s->scalarGeometryField(1));
524 d[
"aperture_y"] =
field_out(*diag.s, diag.s->scalarGeometryField(2));
525 d[
"aperture_z"] =
field_out(*diag.s, diag.s->scalarGeometryField(3));
526 d[
"unknown"] =
field_out(*diag.s, diag.s->scalarGeometryField(4));
527 if (diag.s->cutcellScalar(name,
"scalar_geometry").cut->solid.extent(0) > 0) {
528 d[
"kappa_solid"] =
field_out(*diag.s, diag.s->scalarSolidGeometryField(name, 0));
529 d[
"solid_unknown"] =
field_out(*diag.s, diag.s->scalarSolidGeometryField(name, 1));
534 "The cut-cell scalar geometry record (doc/scalar_ibm_design.md §2) as F-order (nx, ny, "
535 "nz) arrays of this rank's block: 'kappa' (fluid fraction of the fan-tetrahedron PL "
536 "model), 'aperture_x/y/z' (the SNAPPED, ungated scalar aperture of the -x/-y/-z face of "
537 "each cell -- the scalar's own, never the pressure openness), 'unknown' (1 where the "
538 "cell is a fluid unknown: kappa > 1e-14 with an open face). Built on first use after "
539 "the geometry; shared by every cut-cell scalar. A scalar with a solid field "
540 "(set_scalar_solid) also gets 'kappa_solid' (1 - kappa) and 'solid_unknown' (1 where the "
541 "cell is a solid unknown: kappa_solid > 0 and the cell's material conjugate, §2.7).")
544 [](D& diag,
const std::string& name) {
545 const auto& st = *diag.s->cutcellScalar(name,
"scalar_census").cut;
546 const auto c = diag.s->scalarCutCensus();
548 d[
"num_unknowns"] = c.numUnknowns;
549 d[
"num_cut_cells"] = c.numCutCells;
550 d[
"num_facets"] = c.numFacets;
551 d[
"num_two_sided"] = c.numTwoSided;
552 d[
"num_thin_solid"] = c.numThinSolid;
553 d[
"num_sealed"] = c.numSealed;
554 d[
"sealed_volume"] = c.sealedVolume;
556 rungs[
"fluid"] = nb::make_tuple(c.rungs[0], c.rungs[1], c.rungs[2], c.rungs[3]);
557 d[
"probe_rungs"] = rungs;
558 d[
"num_solid_unknowns"] = st.conj ? st.numSolidUnknowns : 0L;
559 d[
"solid_volume"] = st.conj ? st.solidVolume : 0.0;
560 d[
"sealed_solid_volume"] = st.conj ? st.sealedSolidVolume : 0.0;
562 rungs[
"solid"] = nb::make_tuple(st.solidRungs[0], st.solidRungs[1], st.solidRungs[2],
564 d[
"krylov_iterations"] = st.iterations;
565 d[
"krylov_residual"] = st.residual;
566 d[
"krylov_converged"] = st.converged;
567 d[
"mg_levels"] = st.mgLevels;
568 d[
"steady_incompatibility"] = st.incompatibility;
569 d[
"num_small_cells"] = st.numSmall;
570 d[
"num_implicit_faces"] = st.numImplicitFaces;
571 d[
"num_flux_faces"] = st.numFluxFaces;
572 d[
"num_guarded_flux_faces"] = st.numGuardedFaces;
573 d[
"num_backflow_faces"] = st.numBackflowFaces;
574 d[
"bulk_courant"] = st.bulkCourant;
575 d[
"max_cell_peclet"] = st.maxCellPeclet;
579 "Census of the cut-cell scalar (collective under MPI, summed over ranks). The solve "
580 "part, of the last advance or steady solve: 'krylov_iterations', 'krylov_residual' "
581 "(the true max-norm residual over the reference max(max|b|, max|A c0|)), "
582 "'krylov_converged', 'mg_levels' (the ScalarMG levels the V-cycle used; 1: level 0 alone), "
583 "'steady_incompatibility' (|sum b| / sum|b| of a singular steady problem before its "
584 "projection, else 0), 'num_solid_unknowns' (0: single phase; the solid unknowns of a "
585 "conjugate scalar, whose 'probe_rungs' then carries a 'solid' tuple too), 'solid_volume' / "
586 "'sealed_solid_volume' (physical: the conjugate solid kappa_s V, and its part in cells "
587 "that are not solid unknowns -- kappa_s > 0 with no open solid face and no conjugate "
588 "facet, ruling D-WO7-1). The advection part "
589 "(design §6.3, the last advance or steady solve): 'num_small_cells' (cut cells whose "
590 "explicit outflow would exceed the bulk's), 'num_implicit_faces' / 'num_flux_faces' "
591 "(faces carrying flux that took implicit upwind / all faces carrying flux; steady: all "
592 "implicit), 'bulk_courant' (C_bulk: max over full cells of dt Out/V; 0 steady), "
593 "'num_guarded_flux_faces' (faces with projection flux toward a cell that is not a fluid "
594 "unknown, whose flux the operator drops), 'num_backflow_faces' (rows of an 'outflow' "
595 "domain face whose flux enters the domain: their inflow takes the zero-gradient value; "
596 "a steady solve warns once), 'max_cell_peclet' (the largest "
597 "aperture-weighted face Peclet number |u_a| a h_a / D over the interior faces; the "
598 "steady envelope of the advective preconditioner is <= 10). The geometry "
599 "part: 'num_unknowns' (fluid unknowns), 'num_cut_cells' (cells carrying a facet), "
600 "'num_facets', 'num_two_sided' (cells with two facets), 'num_thin_solid' (two-facet "
601 "cells with solid between two fluids -- a resolution warning), 'num_sealed' / "
602 "'sealed_volume' (kappa > 0 behind closed faces; physical volume), 'probe_rungs' "
603 "({'fluid': (R0, R1a, R1b, R2)} facet counts of the probe ladder).")
606 [](D& diag,
const std::string& name) {
607 const auto b = diag.s->scalarBudget(name);
610 d[
"mass_solid"] = b.massSolid;
611 d[
"d_mass"] = b.dMass;
612 d[
"wall_in"] = b.wallIn;
613 d[
"boundary_in"] = b.boundaryIn;
614 d[
"source_in"] = b.sourceIn;
615 d[
"defect"] = b.defect;
616 d[
"identity_error"] = b.identityError;
620 "Budget of a cut-cell scalar over the last advance (collective under MPI), physical: "
621 "'mass' = sum kappa V c, 'mass_solid' (= sum C_s kappa_s V c_s of a conjugate scalar; 0 "
622 "single phase), 'd_mass' = the change of mass + mass_solid over the advance, 'wall_in' / 'boundary_in' / 'source_in' = the rates INTO the fluid from the "
623 "immersed walls (the conjugate interfaces are internal: not in it), the domain faces (the Dirichlet faces' diffusive flux plus the advective "
624 "flux through inflow/outflow faces) and the source, 'defect' = dt sum V r (r the "
625 "linear-solver residual), and 'identity_error' = d_mass - dt (wall_in + boundary_in + "
626 "source_in) + defect, which is round-off for a correct discretization. After a steady "
627 "solve it is the rate balance: d_mass = 0, defect = sum V r and identity_error = "
628 "defect - (wall_in + boundary_in + source_in). Computed on request from the stored "
632 [](D& diag,
const std::string& name) {
633 auto f = diag.s->scalarFacets(name);
634 const std::size_t n = f.area.size();
636 d[
"centroid"] = peclet::core::python::vector_to_ndarray(std::move(f.centroid), {n, 3},
638 d[
"normal"] = peclet::core::python::vector_to_ndarray(std::move(f.normal), {n, 3},
640 d[
"area"] = peclet::core::python::vector_to_ndarray(std::move(f.area), {n}, {1});
641 d[
"instance"] = peclet::core::python::vector_to_ndarray(std::move(f.instance), {n}, {1});
643 peclet::core::python::vector_to_ndarray(std::move(f.wallValue), {n}, {1});
644 d[
"flux"] = peclet::core::python::vector_to_ndarray(std::move(f.flux), {n}, {1});
645 d[
"probe_rung"] = peclet::core::python::vector_to_ndarray(std::move(f.rung), {n}, {1});
649 "Per-facet arrays of a cut-cell scalar on this rank (facet order = x-fastest cell "
650 "order): 'centroid' (n, 3) physical coordinates, 'normal' (n, 3) into the fluid, 'area' "
651 "(physical), 'instance' (scene body; 0 for raw-SDF geometry), 'wall_value' (c at the "
652 "wall from the elimination: robin (D u_p + k s g)/(D + k s), dirichlet g, neumann "
653 "u_p + q s/D; NaN on a facet whose cell is not an unknown), 'flux' (into the fluid per "
654 "area, c L/T physical, after the last advance or steady solve; local Nu/Sh maps, first "
655 "order in the max norm) and 'probe_rung' (0..3 = R0, R1a, R1b, R2).")
657 "set_scalar_max_iterations",
658 [](D& diag,
const std::string& name,
int maxit) {
659 diag.s->setScalarMaxIterations(name, maxit);
661 nb::arg(
"name"), nb::arg(
"maxit"),
662 "Cut-cell scalars: the BiCGStab iteration cap (default 200). On non-convergence the "
663 "iterate is kept, diagnostics.scalar_census reports it and a one-time warning is "
668 const auto ob = diag.s->outflowBackflow();
670 d[
"max_reverse"] = ob.maxReverse;
671 d[
"fraction"] = ob.fraction;
672 d[
"energy_influx"] = ob.energyInflux;
673 d[
"reversed_faces"] = ob.reversed;
674 d[
"outlet_faces"] = ob.total;
677 "Outflow REVERSAL census over the outflow faces (collective under MPI): 'max_reverse' = "
678 "the largest reversed normal velocity max(0, -u.n), 'fraction' = reversed / all outlet "
679 "faces, 'energy_influx' = sum over the reversed faces of rho |u.n| |u|^2 / 2 -- the "
680 "kinetic-energy production the zero-gradient (do-nothing) outflow admits where the flow "
681 "re-enters, which is the mechanism that diverges an inflow/outflow run whose "
682 "recirculation reaches the outlet (a BFS bubble at x_r ~ L, a shed vortex). The "
683 "backflow stabilization (set_backflow_stabilization, default beta 0.2) removes beta rho "
684 "|u.n| |u_n|^2 of it; beta >= 0.5 is the unconditional energy bound. All zeros without "
685 "an outflow face or without reversal. step() warns once, on stderr, when reversal "
686 "appears while the stabilization is switched off.")
687 .def(
"last_momentum_residual", [](D& diag) {
return diag.s->lastMomentumResidual(); },
688 "max over components of max|r|/max|b| at exit of the last step's momentum solves "
689 "(residual mode only; -1 otherwise).")
690 .def(
"set_deferred_correction", [](D& diag,
bool on) {
return diag.s->setDeferredCorrection(on); }, nb::arg(
"on"),
691 "Deferred-correction advection: True (default) = 2nd order (implicit FOU + explicit "
692 "high-order correction, the high-order scheme being SOU by default or Koren TVD via "
693 "set_advection_scheme); False = pure implicit FOU (1st-order upwind, more dissipative "
695 "unconditionally stable at sharp shear layers).")
697 "set_pressure_mean_removal",
698 [](D& diag,
const std::string& scope) {
699 if (scope !=
"all" && scope !=
"fine")
700 throw std::runtime_error(
"set_pressure_mean_removal: scope must be 'all' or 'fine'");
701 diag.s->setPressureMeanRemoval(scope ==
"all");
704 "Nullspace (mean) removal scope in the pressure solve: 'fine' (DEFAULT — only the "
705 "projections the Krylov iteration needs: rhs/residual, the fine-level V-cycle exit, the "
706 "final iterate; ~3x fewer global-reduction latency hits per iteration, the measured "
707 "winner of the multi-GPU ablation) or 'all' (legacy — every V-cycle level + after every "
708 "matvec). Iteration counts are identical (A preserves mean-freeness); results equal "
709 "within solver tolerance, not bit-identical.")
711 "set_pressure_bottom_solver",
712 [](D& diag,
const std::string& engine) {
713 if (engine ==
"auto")
714 diag.s->setPressureBottomSolver(0);
715 else if (engine ==
"direct")
716 diag.s->setPressureBottomSolver(1);
717 else if (engine ==
"algebraic")
718 diag.s->setPressureBottomSolver(2);
720 throw std::runtime_error(
"set_pressure_bottom_solver: 'auto' | 'direct' | 'algebraic'");
723 "Engine of the AGGLOMERATED pressure bottom (set_pressure_bottom 'auto' / "
724 "'agglomerated'). 'auto' (DEFAULT): single rank, the singular (periodic / wall) "
725 "operator, a bottom of at most 8192 cells with at most 64 fluid components (solids "
726 "allowed) and an axis whose planes hold at most 192 cells, 'direct': one team launch "
727 "runs flexible CG in FP64 (inner tolerance 1e-5, cap 100) preconditioned by a "
728 "block-tridiagonal FP32 direct factor of the bottom (planes along one axis, explicit "
729 "Schur-complement inverses), refactored once per operator change -- no host transfer "
730 "on a GPU; on a host backend the team has min(8, team_size_max) threads and the FCG's "
731 "reductions run on one lane, so the result does not depend on the thread count. "
732 "Everywhere else the GraphAMG solve. 'direct' forces that engine and raises at the "
733 "bottom solve, naming the failed condition, where it is not eligible; 'algebraic' "
734 "forces GraphAMG (the A/B instrument). doc/vof_step_performance_design.md §13, §14 "
736 .def(
"set_pressure_graph_amg", [](D& diag,
bool on) {
return diag.s->setPressureGraphAmg(on); }, nb::arg(
"on"),
737 "Solve the pressure MG's coarsest level with an agglomerated mesh-agnostic algebraic "
738 "multigrid (core GraphAMG), decomposition-agnostic: with levels=1 this gives a "
739 "mesh-independent pressure solve that works under a WEIGHTED ORB (where the geometric "
740 "coarse levels can't cleanly coarsen). Applied at the next set_solid.")
741 .def(
"set_exact_crossings", [](D& diag,
const std::vector<double>& t) {
return diag.s->setExactCrossings(t); }, nb::arg(
"t"),
742 "Analytic-SDF capability: exact wall-crossing fractions overriding the "
743 "linear-interpolated theta in the momentum cut-cell overlay AND the ghost-projection "
744 "closures. Flat array of 9*nx*ny*nz values, blocks [(c*3+k)]: component c's staggered "
745 "point at inner cell i toward its +k neighbour; NaN = no crossing. Call BEFORE "
746 "set_solid (a later call raises); empty list clears. Single-rank, staggered momentum "
748 .def(
"set_openness_override", [](D& diag,
const std::vector<double>& ox,
const std::vector<double>& oy,
const std::vector<double>& oz) {
return diag.s->setOpennessOverride(ox, oy, oz); }, nb::arg(
"ox"), nb::arg(
"oy"),
750 "Analytic-SDF capability: exact face-aperture (openness) fields for the cut-cell "
751 "projection, overriding the sampled-SDF openness. Inner nx*ny*nz arrays, x-fastest; "
752 "ox[i] = fluid fraction of the -x face of cell i. Call BEFORE set_solid (a later call "
753 "raises); empty ox clears. Single-rank.")
754 .def(
"set_ghost_projection", [](D& diag,
bool on,
int matrixOrder,
int rhsOrder) {
return diag.s->setGhostProjection(on, matrixOrder, rhsOrder); }, nb::arg(
"on"),
755 nb::arg(
"matrix_order") = 2, nb::arg(
"rhs_order") = 2,
756 "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 "
757 "divergence with wall-anchored directional closures instead of the openness-weighted "
758 "cut-cell projection; solved by MG-preconditioned BiCGStab. Call BEFORE set_solid (a "
759 "later call raises). "
760 "Closure orders (1=linear, 2=quadratic): (matrix_order, rhs_order) = (2,2) full "
761 "quadratic 13-point matrix; (1,1) linear 7-point; (1,2) mixed/deferred — 2nd-order "
762 "steady constraint on a 7-point matrix. Collocated: the same closures/matrix on the "
763 "face-averaged field, plus a directional (one-sided 2nd-order) cell gradient for the "
764 "-grad(P) predictor and cell correction; requires face_interp 0. v1: single-rank, "
765 "periodic + IBM, stationary walls; incompatible with "
766 "porous/variable-rho/domain-BC/Chebyshev.")
767 .def(
"pressure_mg_level_ratios", [](D& diag) {
return diag.s->pressureMgLevelRatios(); },
768 "The pressure multigrid's per-level coarsening ratio, one (rx, ry, rz) per level "
770 "On an isotropic domain every coarsenable axis halves, as it always has. On an "
771 "ANISOTROPIC one (extent giving different spacings per axis) the hierarchy coarsens "
772 "the FINEST coarsenable axis and defers one that is already at least "
773 "set_multigrid_aspect_threshold (default 2) times coarser, so the coarse operators stay close "
774 "to isotropic and the point smoother keeps its rate -- e.g. cells (N, 2N, N/2) on a "
775 "cube gives (1,2,1), (2,2,1), (2,2,2), ... Empty until set_solid/"
776 "set_pressure_geometry has built the operator. See flow/doc/anisotropic_metric.md "
778 .def(
"last_superficial_velocity_shift",
779 [](D& diag) {
return diag.s->lastSuperficialVelocityShift(); },
780 "The uniform velocity shift set_superficial_velocity applied at the end of the last "
781 "step() (physical velocity units; 0 when it is off). Reading it synchronises with the "
783 .def(
"last_balanced_force_iterations",
784 [](D& diag) {
return diag.s->lastBalancedForceIterations(); },
785 "Pressure-driver iterations of the last step's balanced-force solve "
786 "(set_balanced_force_projection); 0 when it is off, the force divergence was zero, or "
787 "the previous split already met the stop.")
788 .def(
"last_balanced_force_failed",
789 [](D& diag) {
return diag.s->lastBalancedForceFailed(); },
790 "Whether the last step's balanced-force solve FAILED (driver breakdown or a non-finite "
791 "result). A failed solve keeps P and p_balanced unchanged for that step; the first one "
793 .def(
"balanced_force_failures", [](D& diag) {
return diag.s->balancedForceFailures(); },
794 "How many balanced-force solves have failed over the solver's lifetime (see "
795 "last_balanced_force_failed).")
797 "anderson_accelerator",
798 [](D& diag,
int window,
double mixing) {
801 nb::arg(
"window") = 5, nb::arg(
"mixing") = 1.0, nb::rv_policy::take_ownership,
802 nb::keep_alive<0, 1>(),
803 "A new Anderson accelerator of this solver's steady march "
804 "(doc/steady_acceleration.md; the public driver is peclet.flow.march_to_steady). "
805 "window in [1, 8] (default 5), mixing in (0, 1] (default 1.0). Raises on an "
806 "unsupported configuration (a collocated scheme other than 'ghost', VoF, phase change, "
807 "scalars, porous, variable properties, drag, cell forces, moving scenes, superficial "
808 "velocity, pressure warm start, the balanced-force projection) and when the history, "
809 "(2m+3) * n_fields * 8 * n_padded bytes, does not fit. Collective under MPI.")
810 .def(
"last_pressure_iterations", [](D& diag) {
return diag.s->lastPressureIterations(); },
811 "Return the pressure-solver iteration count from the last step().\n\n"
812 "A solve that BROKE DOWN (non-finite preconditioner output) reports the iteration "
813 "CAP, so the usual rule-3b 'a capped pressure solve invalidates the run' check sees "
814 "it; pressure_solve_failed() distinguishes the two.")
815 .def_prop_ro(
"num_pressure_chebyshev_restarts",
816 [](D& diag) {
return diag.s->numPressureChebyshevRestarts(); },
817 "How many Chebyshev pressure solves were redone on cold spectral bounds since "
818 "construction. Under variable density the bounds are re-estimated every step "
819 "from the previous estimate's power iterates (5 + 5 iterations instead of "
820 "15 + 15 from the right-hand side); a solve on such bounds that reaches the "
821 "cap, or whose residual after 3 iterations exceeds the initial one, is redone "
822 "on a cold estimate and counted here; the abandoned attempt's V-cycles count in "
823 "last_pressure_iterations().")
824 .def(
"pressure_solve_failed", [](D& diag) {
return diag.s->pressureSolveFailed(); },
825 "Did the last pressure solve break down on a non-finite recurrence scalar (a "
826 "preconditioner or operator that produced NaN/Inf)?\n\n"
827 "The driver then zeroes the correction and continues -- so the projection was handed "
828 "NOTHING for that step and the run is invalid. Until 2026-09-04 the only trace was one "
829 "line on stdout ('preconditioner produced non-finite z') while "
830 "last_pressure_iterations() reported 0, i.e. a perfectly healthy-looking solve "
831 "(measured: examples/pore-scale-imbibition, a 3.1-cell throat). Set "
832 "set_pressure_strict(True) to raise instead.")
837 d[
"step"] = diag.s->lastStepSeconds();
838 d[
"predictor"] = diag.s->lastPredictorSeconds();
839 d[
"momentum"] = diag.s->lastMomentumSeconds();
840 d[
"projection"] = diag.s->lastProjectionSeconds();
841 d[
"pressure_allreduce"] = diag.s->lastPressureAllreduceSeconds();
842 d[
"pressure_allreduce_count"] = diag.s->lastPressureAllreduceCount();
843 d[
"momentum_sweeps"] = diag.s->lastMomentumSweeps();
846 "Per-phase wall times (seconds, THIS rank, device-fenced) of the last step(): 'predictor' "
847 "(ghost fills + RHS/advection/stencil builds), 'momentum' (implicit-diffusion solves), "
848 "'projection' (cut-cell pressure projection), 'step' (whole step). "
849 "'pressure_allreduce'/'pressure_allreduce_count' = time in / number of global reductions "
850 "(MPI_Allreduce) inside the pressure solve — the latency-bound term of the distributed "
851 "solve (0 on a single rank).")
852 .def(
"set_exact_crossings_from_scene", [](D& diag) {
return diag.s->setExactCrossingsFromScene(); },
853 "Compute EXACT wall crossings from the installed scene, on device, on every rank -- the "
854 "in-solver replacement for set_exact_crossings + scripts/exact_apertures_spheres.py. "
855 "Bisection, not Newton: only SIGN correctness is guaranteed for bound-only leaves.")
856 .def(
"set_wall_flux_divergence", [](D& diag,
bool on) {
return diag.s->setWallFluxDivergence(on); }, nb::arg(
"on"),
857 "Rung 3 on/off (default on = correct physics). Off leaves the moving wall's no-slip "
858 "datum in the momentum operator but drops the wall's own volume flux from the cut-cell "
859 "projection -- the configuration the Galilean gate uses to exhibit the failure the term "
861 .def(
"set_fresh_cell_seed", [](D& diag,
bool on) {
return diag.s->setFreshCellSeed(on); }, nb::arg(
"on"),
862 "Fresh-cell policy for MOVING geometry. A point a body has just uncovered inherits "
863 "whatever the solid held there -- zero, or a stale masked value -- which is one of the "
864 "two textbook mechanisms behind spurious force oscillations in a moving-boundary IBM. "
865 "True seeds it with the LOCAL WALL VELOCITY instead, so it starts moving with the "
866 "surface that released it. Bounded (no extrapolation), no new field, and exactly the "
867 "old behaviour when nothing moves. DEFAULT TRUE since 2026-08-30: measured on an "
868 "oscillating sphere translating through the grid, it removes a resolution-INDEPENDENT "
869 "+2.6..2.9% drag bias, cuts the spurious force oscillation 20-50x to within 17% of the "
870 "non-moving floor, and improves the resolved CFD-DEM loop's total-momentum "
871 "conservation 95x. Pass False for the pre-2026-08-30 behaviour.")
873 "fluid_momentum_cells",
875 const auto n = diag.s->fluidMomentumCells();
876 return std::array<long, 3>{n[0], n[1], n[2]};
878 "Unmasked (fluid) staggered momentum cells per component -- the exact discrete datum of "
879 "the reaction identity: at steady state, sum over bodies of F_c = f_c * N_c.")
881 "reaction_budget_terms",
883 std::vector<double> v = diag.s->reactionBudgetTerms();
884 return peclet::core::python::vector_to_ndarray(std::move(v), {2, std::size_t(3)},
887 "Diagnostic decomposition of the reaction identity, shape (2, 3): [0] the unsteady sum "
888 "sum_i (rho/dt)(u_i - u^n_i) and [1] the advective sum sum_i A_i, both over the FLUID "
889 "momentum cells of each component. The full discrete identity is sum_bodies F_c = "
890 "f_c*N_c + sum fb + [1]_c - [0]_c; the Stokes form drops both because they vanish at "
891 "steady state with advection off. sum_i A_i is the advection operator's net momentum "
892 "flux through the cut walls -- an O(h) property of that operator, not of the budget.")
894 "moving_instance_cut_cells",
896 std::vector<long> v = diag.s->movingInstanceCutCells();
897 std::vector<double> d(v.begin(), v.end());
898 return peclet::core::python::vector_to_ndarray(std::move(d), {v.size()}, {1});
900 "Per instance: inner cells on this rank it owns that touch a fractional face aperture, "
901 "recounted whenever any instance moves. ZERO for a moving instance means its surface "
902 "sits on grid planes (or is sub-cell) and its wall velocity is silently inert "
903 "(set_solid_from_scene warns). Empty when no instance moves.")
905 "moving_instance_degenerate_points",
907 std::vector<long> v = diag.s->movingInstanceDegeneratePoints();
908 std::vector<double> d(v.begin(), v.end());
909 return peclet::core::python::vector_to_ndarray(std::move(d), {v.size()}, {1});
911 "Per instance: staggered velocity points where the sampled SDF is EXACTLY zero -- a face "
912 "on a lattice plane. Those points are fluid to the mask and not ghosts to the cut-cell "
913 "fold, so a moving body's datum never enters there (set_solid_from_scene warns). Empty "
914 "when no instance moves.")
915 .def(
"instance_center_pinned", [](D& diag,
int i) {
return diag.s->instanceCenterPinned(i); }, nb::arg(
"i"),
916 "True if instance i's centre of rotation is PINNED (an explicit finite centre in the "
917 "encoding, or set_instance_motion(center=...)); False if it follows the body's "
918 "translation (NaN in the encoding, the builder's default; legacy all-zero raw arrays).")
919 .def(
"periodic_image_overlap_cells", [](D& diag) {
return diag.s->periodicImageOverlapCells(); },
920 "Cells on this rank whose solid/fluid sign was decided by a periodic IMAGE of an "
921 "instance wider than the box (set_solid_from_scene warns when nonzero): the scene "
922 "evaluates the UNION of images, so a slab wider than the box refills any cavity carved "
923 "from it. 0 when no instance is that wide or the images agree.")
925 "hydro_force_torque_traction",
926 [](D& diag) {
return traction_force_torque_array<Grid>(*diag.s); },
927 "DIAGNOSTIC -- not the force on the bodies; that is "
928 "Solver.hydro_force_torque_reaction(). The reconstructed surface-TRACTION loads, shape "
929 "(4, n_instances, 3): [0] force, [1] torque about the instance centre, [2] the pressure "
930 "part, [3] the viscous part ([0] == [2] + [3]). The cut-cell surface integral of "
931 "(-p I + mu(grad u + grad u^T)) against the exact aperture wall-area vector, with a "
932 "central-difference velocity gradient that spans the wall. It UNDER-READS THE VISCOUS "
933 "PART: traction / reaction drag = 0.685-0.730 over phi 0.008-0.45 and N 24-128 "
934 "(measured 2026-09-30), i.e. ~30 % low at ANY resolution -- refinement does not fix "
935 "it. The torque is low by the same mechanism: a steadily spinning sphere reads "
936 "0.59-0.72 of 8 pi mu a^3 Omega / (1 - phi) at R/h 6-12 (the reaction: 1.019-1.027). "
937 "What it offers that the reaction does not: the pressure/viscous split, and it runs "
938 "where the reaction refuses (the collocated solver, porous, variable properties, domain "
939 "BCs, implicit advection). A consistent traction (from the momentum operator's "
940 "small-cell-robust wall reconstruction) is planned. Was Solver.hydro_force_torque() "
941 "(deprecated). Atomics: tolerance-reproducible, not bitwise.")
942 .def(
"wall_area_probe", [](D& diag) {
return diag.s->wallAreaProbe(); },
943 "Diagnostic: [sum_c x_c*A_wall_x, sum_c y_c*A_wall_y, sum_c z_c*A_wall_z] over all cut "
944 "cells. Must equal -V_solid componentwise if the aperture wall-area vectors are right, "
945 "so it separates a force-integral error in the GEOMETRY from one in the traction.")
946 .def(
"wall_flux_imbalance", [](D& diag) {
return diag.s->wallFluxImbalance(); },
947 "Sum over cells of u_wall . A_wall -- the compatibility datum of the singular pressure "
948 "problem. Exactly zero for a translating body in a periodic box; small but nonzero for "
949 "rotation. Reported, not corrected.")
953 std::vector<int> v = diag.s->getCutOwner();
954 const auto nx =
static_cast<std::size_t
>(diag.s->nx());
955 const auto ny =
static_cast<std::size_t
>(diag.s->ny());
956 const auto nz =
static_cast<std::size_t
>(diag.s->nz());
957 return peclet::core::python::vector_to_ndarray(
958 std::move(v), {nx, ny, nz},
959 {1,
static_cast<std::int64_t
>(nx),
static_cast<std::int64_t
>(nx * ny)});
961 "Per-inner-cell owning scene instance (the argmin behind the sampled SDF), (nx,ny,nz) "
962 "int32; -1 before set_solid_from_scene. Moving geometry reads its wall velocity off the "
963 "owner and resolved CFD-DEM posts the hydrodynamic force back to it.")
966 [](D& diag,
const std::string& name) {
return field3d_out(*diag.s, diag.s->fieldView(name)); },
968 "Zero-copy view of a registered field's full padded buffer as a Fortran-order "
969 "(nx+2g, ny+2g, nz+2g) array (g = ghost_width); host → NumPy, device → DLPack (CuPy).")
971 "exchange_field", [](D& diag,
const std::string& name) { diag.s->exchangeField(name); },
973 "Fill a registered field's ghost cells (cross-rank + periodic under MPI; periodic "
976 "exchange_field_add", [](D& diag,
const std::string& name) { diag.s->exchangeFieldAdd(name); },
978 "Add-reduce halo: fold ghost-layer deposits back onto their owner (cross-rank + "
980 "The particle->grid deposition primitive for MPI CFD-DEM; single-rank non-periodic "
983 "vof_last_courant", [](D& diag) {
return diag.s->vofLastCourant(); },
984 "The interface-local Courant number of the step just taken (0 before the first step).")
986 "set_vof_step_parity", [](D& diag,
long n) { diag.s->setVofStepParity(n); }, nb::arg(
"n"),
987 "Set the sweep-permutation counter of the NEXT colour advection: the Weymouth-Yue sweep "
988 "order is kWySweepPerm[n % 6], cycled so no axis is systematically favoured. Exposed so "
989 "a benchmark can hold the permutation fixed (n constant) or resume a run across a "
990 "restart. Default: it increments once per advection from 0.")
992 "vof_step_parity", [](D& diag) {
return diag.s->vofStepParity(); },
993 "The sweep-permutation counter of the next colour advection.")
995 "set_vof_wisp_eps", [](D& diag,
double eps) { diag.s->setVofWispEps(eps); }, nb::arg(
"eps"),
996 "Wisp tolerance on the Weymouth-Yue mixed-cell predicate: a cell counts as carrying an "
997 "interface only while eps < C < 1 - eps, and one outside that band is fluxed "
998 "ALGEBRAICALLY as C_donor * a — its ACTUAL colour, so the exact telescoping conservation "
999 "is untouched. The same threshold gates the interface-local Courant band "
1000 "(|C_i - C_j| > eps instead of an exact !=).\n\n"
1001 "DEFAULT 1e-8 (the same value the V3 curvature predicate uses under surface tension); "
1002 "0 restores the V1 predicate bit for bit. Two measured reasons it is not 0 (WO-R2 item "
1003 "4): (i) a domain that DRAINS through an open boundary leaves nothing but round-off "
1004 "residue, ~1e-18, which `0 < C < 1` still calls mixed — the MYC normal of that stencil "
1005 "is degenerate and plicAlpha divides by it (sum C -> -inf -> NaN within three steps on "
1006 "one backend); (ii) the round-off wake behind a passing interface (min C ~ -3.8e-17) "
1007 "kept the whole wake inside the Courant band, so vof_last_courant() on Zalesak read "
1008 "0.3110 by step 1000 on a case whose interface never exceeds 0.255.")
1010 "vof_wisp_eps", [](D& diag) {
return diag.s->vofWispEps(); },
1011 "The wisp tolerance currently in force (see set_vof_wisp_eps).")
1013 "set_pressure_exact_residual",
1014 [](D& diag,
bool on) { diag.s->setPressureExactResidual(on); }, nb::arg(
"on") =
true,
1015 "Apply the level-0 pressure operator EXACTLY (matrix-free, double, flux form) in the "
1016 "residual and the Krylov matvec instead of reading the float band storage. P1 of the "
1017 "suite defect-correction campaign (suite/docs/archive/DEFECT_CORRECTION_PLAN.md). Per "
1018 "solver, OFF at construction.\n\n"
1019 "enable_vof() turns it ON, because a two-phase coefficient contrast is exactly what "
1020 "amplifies the float operator's broken row-sum identity A*1 = 0. Measured on Hysing "
1021 "case 2 (64x128x4, adaptive dt, nvidia-cuda): max|div(open u)| 1.85e-03 -> 5.15e-11, "
1022 "with 116/600 pressure iterations, 1123 steps, the dt-limit census and both published "
1023 "functionals (v_rise max 0.2574 at t = 0.671, y_c(3) 1.1082) identical to every printed "
1024 "digit. Everything below level 0 stays float on purpose: it is a preconditioner and its "
1025 "errors change the convergence RATE, never the fixed point. A flag read at every solve, "
1026 "so it may be changed at any time; the ablation is False AFTER enable_vof.")
1028 "pressure_exact_residual", [](D& diag) {
return diag.s->pressureExactResidual(); },
1029 "Whether the exact level-0 operator apply is in force (see set_pressure_exact_residual).")
1031 "set_vof_cutcell_flux_clamp", [](D& diag,
bool on) { diag.s->setVofCutFluxClamp(on); },
1033 "Ablation: Weymouth's admissible-interval clamp on the openness-weighted cut-cell flux "
1034 "(ON by default). The clamp bounds |F| by what the DONOR actually holds — at most "
1035 "eps*C liquid and at most eps*(1-C) gas of the fluid volume o*|a| swept through the face "
1036 "— applied to the one value both neighbours share, so conservation still telescopes "
1037 "bit-exactly. It is what makes the whole-cell-PLIC-times-open-area flux approximation "
1038 "BOUNDED. Measured with it off on a 24^3 packing at CFL 0.2: the [0,1] clip fires at "
1039 "3.2e-5 liquid volume per step and the conserved functional drifts 1.3e-8 in 30 steps; "
1040 "with it on the clip stops firing.")
1042 "vof_filled_color", [](D& diag) {
return field_out(*diag.s, diag.s->getVofFilledColour()); },
1043 "The colour field INCLUDING the neutral solid-band fill — what the MYC and "
1044 "height-function stencils actually read — as a Fortran-order (nx,ny,nz) array. "
1045 "get_vof()/'C' is the canonical field and carries EXACTLY 0 in solid cells; the fill is "
1046 "a stencil device regenerated at every ghost exchange (three passes with a shrinking "
1048 "budget, src/vof/cutcell.hpp), and it is what makes the wall look 90-degree neutral "
1049 "instead of perfectly non-wetting.")
1051 "vof_geometry", [](D& diag,
int which) {
return field_out(*diag.s, diag.s->getVofGeometry(which)); },
1053 "The cut-cell geometry the colour block runs on, as a Fortran-order (nx,ny,nz) array: "
1054 "0 = the cell fluid fraction eps (4^3-subsampled, a multiple of 1/64), 1/2/3 = the "
1055 "openness of the +x/+y/+z face of each cell (the ADVECTOR's high-face convention, one "
1056 "cell shifted from the solver's ox/oy/oz), 4 = the classification (1 = SOLID, i.e. "
1057 "eps == 0 AND all six faces closed).\n\n"
1058 "On an ALL-FLUID solver (set_pressure_geometry, or no cut-cell pressure operator) there "
1059 "is no geometry block and this returns the TRIVIAL geometry the V1 transport kernels "
1060 "actually execute: eps = 1, openness = 1, classification = 0. It used to raise, which "
1061 "meant one diagnostic could not serve a packed scene and its all-fluid control.")
1063 "set_contact_angle_pivot",
1064 [](D& diag,
const std::string& mode) {
1065 diag.s->setContactAnglePivot(
enum_index(mode,
kPivots,
"set_contact_angle_pivot",
"mode"));
1068 "ABLATION -- how the theta-plane is anchored in the fluid cell. 'volume' (DEFAULT) match "
1069 "the anchor cell's liquid volume with plicAlpha; 'centroid' pass through the PLIC "
1070 "centroid p_f (the Afkhami-Bussmann / Basilisk contact.h rule); 'projected-centroid' the "
1071 "work order's c = p_f - sdf(p_f) n_w; 'contact-line' the contact line on the wall. "
1072 "'volume', 'centroid' and 'contact-line' are exactly idempotent (1e-15 on gate G0a); "
1073 "'projected-centroid' is NOT -- projecting the centroid along n_w shifts the plane by "
1074 "-sdf(p_f) cos(theta), measured 0.26 in cell fraction at theta = 60, with the wrong sign "
1075 "(it removes liquid from the band for a wetting angle).")
1077 "contact_angle_diagnostics",
1079 const auto d = diag.s->contactAngleDiagnostics();
1081 r[
"contact_cells"] = d.contactCells;
1082 r[
"neighbour_cells"] = d.neighbourCells;
1083 r[
"pure_cells"] = d.pureCells;
1084 r[
"parallel_cells"] = d.parallelCells;
1085 r[
"neutral_cells"] = d.neutralCells;
1086 r[
"unfilled_cells"] = d.unfilledCells;
1087 r[
"mean_apparent_angle"] = d.meanApparentAngle;
1088 r[
"set_angle"] = d.setAngle;
1089 r[
"dynamic_cells"] = d.dynamicCells;
1090 r[
"pinned_cells"] = d.pinnedCells;
1091 r[
"advancing_cells"] = d.advancingCells;
1092 r[
"receding_cells"] = d.recedingCells;
1093 r[
"mean_imposed_theta"] = d.meanImposedTheta;
1094 r[
"mean_apparent_theta"] = d.meanApparentTheta;
1095 r[
"max_Ca_cl"] = d.maxCaCl;
1096 r[
"max_contact_speed"] = d.maxContactSpeed;
1099 "The solid-band census of the CURRENT colour field on this rank: how many band cells each "
1100 "branch of pass 1 wrote ('contact_cells' the theta plane of the cell's own anchor, "
1101 "'neighbour_cells' the mean of the anchor's MIXED neighbours' planes where the anchor "
1102 "itself is pure phase, 'pure_cells' the pure-phase "
1103 "continuation C_s = C_f, 'parallel_cells' an interface parallel to the wall where no "
1104 "rotation is defined, 'neutral_cells' the WO-Q neutral-mean fallback, 'unfilled_cells' "
1105 "left untouched), plus 'mean_apparent_angle' — the mean angle the fluid-only normal "
1106 "reported at the contact cells BEFORE the rotation, in degrees. That last number is the "
1107 "direct read-out of how far the fluid-side interface still is from the prescribed angle, "
1108 "measured on the fill's own data rather than on a post-processed shape.\n\n"
1109 "Rung V6 (WO-V6) adds, all zero unless set_contact_angle_dynamic / "
1110 "set_contact_angle_hysteresis is configured: 'dynamic_cells' (band cells the V6 pass "
1111 "produced an angle for), 'pinned_cells' / 'advancing_cells' / 'receding_cells' (the "
1112 "hysteresis branch census), 'mean_imposed_theta' and 'mean_apparent_theta' in degrees, "
1113 "'max_Ca_cl' = max |mu_l U_cl / sigma| and 'max_contact_speed' = max |U_cl| in solver "
1116 "wall_slip_sandwich_cells",
1118 auto a = diag.s->wallSlipSandwichCells();
1119 return nb::make_tuple(a[0], a[1], a[2]);
1121 "Per velocity component, the number of cut-cell AXES at which a one-cell fluid gap made "
1122 "the Navier closure inapplicable and the no-slip one was kept. Nonzero means part of the "
1123 "wall is silently no-slip; report it.")
1125 "set_contact_angle_smoothing", [](D& diag,
bool on) { diag.s->setContactAngleSmoothing(on); },
1127 "ABLATION - the 3-point in-wall mean of U_cl (default ON). A MAC velocity next to a wall "
1128 "is noisy cell to cell and the cube root of the Cox-Voinov relation puts that noise "
1129 "straight into the imposed angle; off measures what the smoothing is worth.")
1131 "set_contact_angle_clamp",
1132 [](D& diag,
double lo,
double hi) { diag.s->setContactAngleClamp(lo, hi); }, nb::arg(
"lo"),
1134 "The clamp on the Cox-Voinov cube, in degrees (default 1 / 179). The cubic has no "
1135 "solution beyond the maximum receding capillary number (film entrainment) and the fill's "
1136 "plane construction degenerates at 0/180.")
1138 "vof_dynamic_field", [](D& diag,
int w) {
return field_out(*diag.s, diag.s->getVofDynamicField(w)); },
1140 "The V6 per-cell dynamic-wetting state on the inner region, as (nx,ny,nz): 0 the IMPOSED "
1141 "angle in degrees, 1 the measured APPARENT angle in degrees, 2 the smoothed U_cl "
1142 "(positive = the liquid ADVANCES), 3 Ca_cl, 4 the state (0 not a contact cell, 1 "
1143 "Cox-Voinov on the static base, 2 PINNED, 3 advancing, 4 receding). Regenerates the fill, "
1144 "so it is also the direct gate on the pass's decomposition independence.")
1146 "set_vof_timing", [](D& diag,
bool on) { diag.s->setVofTiming(on); }, nb::arg(
"on"),
1147 "WO-V9: arm (or disarm) the VoF pipeline's per-stage timers, and reset them. OFF by "
1148 "default. When armed, every stage boundary calls Kokkos::fence() before reading the "
1149 "clock -- the same rule the step's three coarse phase timers already follow, because on "
1150 "a device backend queued work would otherwise be billed to whichever stage next reads "
1151 "the clock. A fence changes WHEN work happens, never WHAT it computes: a run with the "
1152 "timers armed is bit-identical to the same run without them (gated in "
1153 "tests/kokkos/test_vof_timing.cpp). When disarmed the cost is one branch per stage and "
1156 "reset_vof_timing", [](D& diag) { diag.s->resetVofTiming(); },
1157 "Zero the VoF stage timers and the step counter without disarming them.")
1161 const auto& v = diag.s->vofTimingReport();
1162 const auto& k = diag.s->vofKernelTiming();
1164 r[
"steps"] = v.steps;
1166 r[
"step"] = diag.s->vofTimingStepSeconds();
1167 r[
"predictor"] = diag.s->vofTimingPredictorSeconds();
1168 r[
"momentum_solve"] = diag.s->vofTimingMomentumSeconds();
1169 r[
"projection"] = diag.s->vofTimingProjectionSeconds();
1171 r[
"vof_advect"] = v.advect;
1172 r[
"vof_bridge"] = v.bridge;
1173 r[
"vof_momentum_advect"] = v.momAdvect;
1174 r[
"vof_momentum_bridge"] = v.momBridge;
1175 r[
"curvature"] = v.curvature;
1177 r[
"phase_change"] = v.phaseChange;
1179 r[
"k_freeze"] = k.freeze;
1180 r[
"k_reconstruct"] = k.reconstruct;
1181 r[
"k_fluxes"] = k.fluxes;
1182 r[
"k_sweep"] = k.sweep;
1183 r[
"k_clip"] = k.clip;
1184 r[
"k_exchange"] = k.exchange;
1185 r[
"k_sweeps"] = k.sweeps;
1187 const auto& q = diag.s->vofCurvatureTiming();
1188 r[
"kc_calls"] = q.calls;
1189 r[
"kc_compact"] = q.compact;
1190 r[
"kc_planes"] = q.planes;
1191 r[
"kc_height"] = q.height;
1192 r[
"kc_fallback"] = q.fallback;
1193 r[
"kc_clip"] = q.clip;
1194 r[
"block_advect"] = v.blockAdvect;
1195 r[
"block_debris"] = v.blockDebris;
1196 r[
"kc_census"] = q.census;
1199 "WO-V9: cumulative VoF stage times in SECONDS since the last set_vof_timing/"
1200 "reset_vof_timing, on this rank. `steps` is the number of step() calls in the window, so "
1201 "every entry divides down to a per-step cost. `step`/`predictor`/`momentum_solve`/"
1202 "`projection` are the step's three coarse phases summed over the SAME window (the "
1203 "remainder of `step` is BC re-imposition, Picard bookkeeping and the scalars). "
1204 "`vof_advect` is the whole colour stage and `vof_bridge` the part of it spent in the "
1205 "G=2 <-> g=3 bridges and the C ghost policy; `vof_momentum_advect` is its V2b twin. "
1206 "`k_*` are the advector's own kernels, shared by the colour, momentum-consistent and "
1207 "consistent-energy drivers, so they are the per-KERNEL breakdown of whichever of the two "
1208 "stages is running: `k_reconstruct` the MYC + plicAlpha pass, `k_fluxes` the geometric "
1209 "face fluxes, `k_sweep` the update, `k_clip` the V5a cut-cell clip, `k_exchange` the "
1210 "g = 3 ghost exchange, `k_sweeps` the number of sweeps (3 per advect call). Returns "
1211 "zeros unless the timers are armed.")
1213 "set_vof_worklist", [](D& diag,
bool on) { diag.s->setVofWorklist(on); }, nb::arg(
"on"),
1214 "WyAdvector::useWorklist -- compact the PLIC reconstruction pass onto the mixed cells "
1215 "(a parallel_scan over the reconstruction region, then a dense parallel_for over the "
1216 "compacted list) instead of a guarded parallel_for over the whole region. Default ON. "
1217 "Pure optimization: the flux never reads a non-mixed cell's plane, so the two paths "
1218 "produce the same field BIT FOR BIT (gated in tests/kokkos/test_vof_advect.cpp and "
1219 "measured in WO-V9).")
1221 "vof_worklist", [](D& diag) {
return diag.s->vofWorklist(); },
1222 "Whether the reconstruction-pass compaction is on.")
1224 "set_vof_curvature_worklist", [](D& diag,
bool on) { diag.s->setVofCurvatureWorklist(on); },
1226 "WO-V9: run the V3 curvature cascade (the PLIC plane pass, the height functions and the "
1227 "PV fallback) over a COMPACTED list of the interfacial cells instead of over the whole "
1228 "inner region. Default ON. The cascade is the most divergent kernel in the pipeline -- "
1229 "on a resolved droplet the interfacial cells are well under 1 % of the block, so a dense "
1230 "parallel_for puts one or two active lanes in a warp and the other thirty run the guard "
1231 "and then idle for the whole height function. Compaction is a pure re-ordering (each "
1232 "cell reads the same neighbours and writes the same value), so the two paths are "
1233 "BIT-IDENTICAL; tests/kokkos/test_vof_timing.cpp gates that and WO-V9 records the gain.")
1235 "vof_curvature_worklist", [](D& diag) {
return diag.s->vofCurvatureWorklist(); },
1236 "Whether the curvature-cascade compaction is on.")
1240 const auto d = diag.s->vofDiagnostics();
1245 r[
"mixed"] = d.mixed;
1246 r[
"wisps"] = d.wisps;
1247 r[
"volume"] = d.volume;
1248 r[
"raw_volume"] = d.rawVolume;
1249 r[
"solid_sum"] = d.solidSumC;
1250 r[
"solid_fill_sum"] = d.solidFillSum;
1251 r[
"min_fluid"] = d.minCFluid;
1252 r[
"max_fluid"] = d.maxCFluid;
1253 r[
"clipped_volume"] = d.clippedVolume;
1254 r[
"clipped_signed"] = d.clippedSigned;
1255 r[
"cut_cells"] = d.cutCells;
1256 r[
"clamped_faces"] = d.clampedFaces;
1257 r[
"solid_cells"] = d.solidCells;
1259 const auto v = diag.s->vofBcVolumes();
1260 double in = 0.0, out = 0.0;
1261 for (
int f = 0; f < 6; ++f) {
1267 r[
"inflow_volume"] = in;
1268 r[
"outflow_volume"] = out;
1271 "Colour census over THIS RANK's inner cells: sum (cell-volume units), min, max, the "
1272 "number of mixed cells (0<C<1) and of wisps (C within 1e-8 of 0 or 1). No clipping is "
1273 "applied at this rung, so min/max may leave [0,1] if the CFL cap is raised.\n\n"
1274 "With an immersed solid (rung V5a) the dict also carries the cut-cell quantities, all "
1275 "zero without one: 'volume' = sum eps_eff*C over fluid cells, the functional the "
1276 "openness-weighted scheme conserves EXACTLY (eps_eff = max(eps, 1/64), the 4^3 "
1277 "subsampling resolution — see src/vof/cutcell.hpp rule 1); 'raw_volume' = sum eps*C, "
1278 "which differs only on eps==0 cells that still own an open face and is therefore NOT the "
1279 "conserved functional; 'solid_sum' = sum of C over solid cells (0 by construction — the "
1280 "neutral band fill lives on the working block, not on 'C'); 'min_fluid'/'max_fluid' over "
1281 "UNCUT fluid cells (eps==1), where Weymouth's boundedness applies verbatim; "
1282 "'clipped_volume' / 'clipped_signed' = the liquid volume the cut-cell clip moved during "
1283 "the last advection (a TRIPWIRE on the flux approximation, not a mechanism: if it is not "
1284 "negligible the fix is the solid-clipped flux polygon); 'cut_cells' / 'solid_cells'.\n\n"
1285 "'inflow_volume' / 'outflow_volume' are the liquid volume that ENTERED / LEFT through "
1286 "the domain faces during the LAST colour advection, in the same cell-volume units as "
1287 "'sum' (0 unless a VoF boundary colour is set — rung V-BC). They are the advector's OWN "
1288 "face fluxes, so sum(C) - sum(C_0) = integral(inflow - outflow) holds to round-off "
1289 "whatever the interface does; vof_bc_volumes() breaks them out per face.")
1294 const auto ov = diag.s->vofBlockOverlapCensus();
1295 for (
const auto& b : diag.s->vofBlockStats()) {
1298 r[
"master"] = b.master;
1299 r[
"lo"] = nb::make_tuple(b.lo[0], b.lo[1], b.lo[2]);
1300 r[
"hi"] = nb::make_tuple(b.hi[0], b.hi[1], b.hi[2]);
1301 r[
"cells"] = b.cells;
1302 r[
"volume"] = b.volume;
1303 r[
"centroid"] = nb::make_tuple(b.centroid[0], b.centroid[1], b.centroid[2]);
1304 r[
"velocity"] = nb::make_tuple(b.velocity[0], b.velocity[1], b.velocity[2]);
1305 r[
"moments"] = nb::make_tuple(b.moment[0], b.moment[1], b.moment[2], b.moment[3],
1306 b.moment[4], b.moment[5]);
1307 r[
"recentred"] = b.recentred;
1308 r[
"discarded"] = b.discarded;
1310 r[
"debris_cells"] = b.debrisCells;
1311 r[
"debris_volume"] = b.debrisVolume;
1312 r[
"debris_returned"] = b.debrisReturned;
1313 r[
"debris_lost"] = b.debrisLost;
1314 r[
"debris_unresolved"] = b.debrisUnresolved;
1315 r[
"full_axis"] = b.fullAxis;
1316 r[
"residue_returned"] = b.residueReturned;
1317 r[
"overlap_max_sum"] = ov.maxSum;
1318 r[
"overlap_excess"] = ov.excess;
1319 r[
"overlap_cells"] = ov.cells;
1320 r[
"overlap_bound_active"] = ov.active;
1325 "Per-bubble Lagrangian census, one dict per block, in block-id order. 'lo'/'hi' (the "
1326 "global index box) and 'master' are replicated on every rank; the MEASURED entries — "
1327 "'volume' (sum of C over the box, cell volumes), 'centroid', 'velocity' (d(centroid)/dt "
1328 "of the last advection), 'moments' (the central second moments xx, yy, zz, xy, xz, yz "
1329 "divided by the volume, i.e. the deformation) — are filled only on the block's MASTER "
1330 "and are zero elsewhere.\n\n"
1331 "'discarded' is the colour a re-centring dropped, cumulatively: Weymouth-Yue leaves "
1332 "round-off residue in every cell its sweeps touch and the box tracks the BUBBLE, not "
1333 "that wake, so the residue falling outside the new box is discarded. Never physical "
1334 "liquid (measured -9.5e-17 over a 20-cell translation, against a bubble volume of 524), "
1335 "but it is reported rather than hidden — a container that silently loses mass is not "
1337 "'debris_cells' / 'debris_volume' are this step's marker DEBRIS removal (block CSF on "
1338 "only; master only; see set_vof_block_debris): interfacial cells with every cell of the "
1339 "marker within two cells below C = 1/2, and their summed colour. 'debris_returned', "
1340 "'debris_lost' and 'debris_unresolved' are the cumulative ledger "
1341 "(doc/vof_overlap_design.md 5.3): volume + discarded + debris_lost is conserved. "
1342 "'full_axis' counts re-centrings whose box had to span a whole periodic axis (it is "
1343 "then [0, L) and copied with the wrap, losslessly; 12.3). 'residue_returned' is the "
1344 "cumulative SIGNED sub-wisp-threshold residue cleared after each advection and returned "
1345 "to the interface with the debris (13): volume + discarded + debris_lost is exact.\n\n"
1346 "'overlap_*' are the GLOBAL marker-overlap census of the last block advection "
1347 "(vof_overlap_design 5.6; block CSF or the overlap density on), the same in every dict: "
1348 "'overlap_max_sum' = max over cells of S = sum_k C_k, 'overlap_excess' = sum of "
1349 "(S - 1)^+ (cell volumes), 'overlap_cells' = cells with S > 1 + 1e-8, and "
1350 "'overlap_bound_active' = the phantom-aware capillary limit (2 rho_min in place of "
1351 "rho_l + rho_g) is in force for the next step (triggered by max S > 1 + 1e-2).")
1353 "set_vof_phantom_capillary_bound",
1354 [](D& diag,
bool enabled) { diag.s->setVofPhantomCapillaryBound(enabled); },
1355 nb::arg(
"enabled") =
true,
1356 "The phantom-aware capillary limit of the block container (doc/vof_overlap_design.md "
1357 "5.6, 11.3), DEFAULT True: while two block markers overlap (max S > 1 + 1e-2) the "
1358 "capillary limit of vof_step_limits() and step() uses 2 rho_min in place of "
1359 "rho_l + rho_g -- the part of one marker's surface inside another is an interface with "
1360 "gas on both sides. False restores the pre-5.6 bound (the ablation; a fixed-dt driver "
1361 "at a density ratio above ~31 otherwise throws at first contact).")
1363 "set_vof_block_debris", [](D& diag,
bool enabled) { diag.s->setVofBlockDebris(enabled); },
1365 "Per-step marker DEBRIS removal of the block container (doc/vof_overlap_design.md 5.3, "
1366 "11). Default: ON under enable_vof_block_csf, off otherwise. A debris cell is an "
1367 "interfacial cell of a marker (1e-8 < C < 1-1e-8) with every cell of that marker within "
1368 "two cells below C = 1/2: colour with no resolvable interface near it (what two "
1369 "overlapping markers tear off each other). Its colour is zeroed and the volume is "
1370 "returned to the marker's attached interfacial cells weighted C(1-C), in a fixed "
1371 "summation order, so the marker volume is exact and the state decomposition-"
1372 "independent. Ledger in vof_block_stats(): debris_cells / debris_volume (this step), "
1373 "debris_returned, debris_lost, debris_unresolved (cumulative, migrate with the block). "
1374 "enabled=False keeps the census (what WOULD be removed) and moves nothing.")
1376 "set_vof_block_overlap_density",
1377 [](D& diag,
bool enabled) { diag.s->setVofBlockOverlapDensity(enabled); },
1379 "OPT-IN MODEL, default False (the MAX union verbatim). With it the union colour that "
1380 "feeds rho(C) and mu(C) -- and the registered 'C' -- becomes C_eff = max_k C_k where "
1381 "S = sum_k C_k <= 1 + 1e-8 and max(0, 2 - S) where markers overlap: the overlap volume "
1382 "is treated as the sub-grid FILM liquid (the 'tent'), so the phantom interface of one "
1383 "marker inside another is an ordinary liquid-gas interface under the base capillary "
1384 "bound. Its cost: the lens holds liquid the real state does not (buoyancy and mixture "
1385 "weight off by 2 V_overlap during a contact), and it changes collision dynamics. See "
1386 "doc/vof_overlap_design.md 5.6.")
1388 "vof_block_imbalance", [](D& diag) {
return diag.s->vofBlockImbalance(); },
1389 "max/mean of the per-rank block-cell load under the CURRENT master assignment "
1390 "(round robin by block id at rung W0). 1.0 is perfect; the weighted-ORB assignment of "
1391 "rung W1 is what this number is here to grade.")
1395 std::vector<long> m, c;
1396 diag.s->vofBlockCensus(m, c);
1407 "Per-rank load census of the block container: 'masters'[r] = blocks rank r masters, "
1408 "'cells'[r] = the inner cells those blocks carry (the actual VoF work).")
1410 "vof_block_imbalance_of", [](D& diag,
int mode) {
return diag.s->vofBlockImbalanceOf(mode); },
1412 "The max/mean per-rank block-cell load the given assignment mode WOULD give on the "
1413 "current blocks, without applying it — so a study can put the three modes side by side "
1414 "on one swarm without perturbing the run.")
1416 "set_vof_block_device_staging",
1417 [](D& diag,
bool on) { diag.s->setVofBlockDeviceStaging(on); }, nb::arg(
"on"),
1418 "Pack/unpack the block gather and scatter in the block's own MEMORY SPACE (rung W1 item "
1419 "b, default True), with a host staging copy only per MPI MESSAGE — and none at all for "
1420 "the master's own cells, which are a device-to-device copy. False selects rung W0's "
1421 "host-staged path (a full mirror of the local patch per step), which is what the "
1422 "device-vs-host measurement compares against. Every step is a copy of a double, so the "
1423 "two paths are BITWISE identical; the ctest gates that rather than asserting it.")
1425 "set_vof_block_pool", [](D& diag,
bool on) { diag.s->setVofBlockPool(on); }, nb::arg(
"on"),
1426 "Recycle the advectors a re-centring retires, keyed by the exact box extent (rung W1 "
1427 "item c, default True). A translating bubble keeps its box SIZE and only moves its "
1428 "origin, so the hit rate is ~100 % and the ten Views of the new box are not allocated. "
1429 "A recycled advector is handed back in the state a freshly initialised one is in "
1430 "(colour and the three face-velocity fields zeroed), so the pool is bitwise inert.")
1432 "vof_block_pool_stats",
1434 const auto q = diag.s->vofBlockPoolStats();
1440 "Block-pool census: 'hits' = advectors recycled, 'misses' = advectors allocated.")
1444 "One marker's OWN curvature (internal units, 1/cell) over its block box, the layout of "
1445 "vof_block_color; empty unless this rank masters it and the block CSF is on. A diagnostic.")
1448 [](D& diag,
int c) {
return field_out(*diag.s, diag.s->getVofBlockForce(c)); },
1449 nb::arg(
"component"),
1450 "The block CSF face force of component c (0,1,2) as scattered (summed over markers) into "
1451 "the solver's LOW-face slot of each cell, internal units; the field the momentum RHS adds. "
1454 "vof_block_curvature_stats",
1456 const auto st = diag.s->vofBlockCurvatureStats();
1458 r[
"interfacial"] = st.interfacial;
1460 r[
"hf_mixed"] = st.hfMixed;
1461 r[
"hf_fit"] = st.hfFit;
1463 r[
"pv_reduced"] = st.pvReduced;
1464 r[
"no_estimate"] = st.noEstimate;
1465 r[
"clipped"] = st.clipped;
1468 "Branch census of the last block-CSF curvature pass, SUMMED over this rank's blocks "
1469 "(local to the rank). 'no_estimate' must be 0 on any gated case. 'clipped' = cells whose "
1470 "|kappa| the admissibility clip bounded (set_vof_block_kappa_clip; a NaN counts too); 0 "
1471 "on a resolved interface.")
1473 "set_outflow_rho_correction", [](D& diag,
bool on) { diag.s->setOutflowRhoCorrection(on); },
1474 nb::arg(
"on") =
true,
1475 "The 1/rho_f mobility factor on the HIGH-side outflow face correction. DEFAULT TRUE "
1476 "since WO-R2; pass False for the ablation.\n\n"
1477 "A projection correction cancels the discrete divergence only if it uses the SAME face "
1478 "coefficient the operator row used. Until WO-R2 the multigrid re-imposed the literal "
1479 "openness 1.0 at every Dirichlet domain face, overwriting buildRhoCoeff's "
1480 "open_f*rho0/rho_f, so the plain phi difference was the consistent partner and WO-R "
1481 "measured this factor making things seven orders WORSE. WO-R2 fixed the operator "
1482 "(CutcellMG::setOutflowCoefficient) and the verdict inverted. Stratified duct, ratio "
1483 "10, 5 steps, max|div(open u)| of the PROJECTED field:\n"
1484 " old operator fixed operator\n"
1485 " without the factor 8.76e-10 9.97e-05\n"
1486 " with the factor 9.24e-03 8.31e-10\n"
1487 "(tests/kokkos/test_vof_bc.cpp gate F2.) Bitwise inert at constant density (rho_f == "
1488 "rho0 makes the factor exactly 1) and gated on the variable-density path.")
1490 "outflow_rho_correction", [](D& diag) {
return diag.s->outflowRhoCorrection(); },
1491 "Whether the 1/rho_f factor is applied to the outflow face correction (WO-R item 4, "
1492 "reversed by WO-R2's operator fix; default True).")
1494 "set_vof_curvature_weight_width", [](D& diag,
double d) { diag.s->setVofCurvatureWeightWidth(d); },
1496 "Wendland support width of the PLIC-volumetric fallback fit, in CELL units. Default 2.5, "
1497 "which is Han et al.'s recommended pairing with the 5^3 stencil. Their translating "
1498 "droplet recovers first-order convergence of the spurious currents at 3.5 and loses "
1499 "convergence entirely at 4.5 (over-smoothing), so this is a real trade-off between the "
1500 "locality of the estimate and its robustness to transport error — not a free knob.")
1502 "set_vof_curvature_mixed_height_fit",
1503 [](D& diag,
bool on) { diag.s->setVofCurvatureMixedHeightFit(on); }, nb::arg(
"on") =
true,
1504 "Enable cascade branch 3, the mixed height-position fit (a paraboloid through the "
1505 "interface positions of whichever columns closed). DEFAULT OFF and it should stay off: "
1506 "measured on an exact-fraction sphere at 16/32/64 it takes over the 19.5-59.6% of cells "
1507 "the height function cannot serve and DESTROYS the convergence of the max curvature "
1508 "error (order 0.00 vs 1.86 with the PLIC-volumetric fallback instead), because its data "
1509 "set is the columns that closed - a slope-selected, asymmetric subset whose lever-arm "
1510 "bias is scale invariant. Shipped as a re-measurable instrument, not a configuration.")
1514 const auto d = diag.s->csfDiagnostics();
1516 r[
"max_force"] = nb::make_tuple(d.maxForce[0], d.maxForce[1], d.maxForce[2]);
1518 nb::make_tuple(d.orphanFaces[0], d.orphanFaces[1], d.orphanFaces[2]);
1520 nb::make_tuple(d.forcedFaces[0], d.forcedFaces[1], d.forcedFaces[2]);
1523 "Census of the CSF face force on THIS RANK: per component the max |F|, the number of "
1524 "faces that carried a force, and the number of ORPHAN faces — faces the colour jumps "
1525 "across but where neither cell has a curvature estimate, so the force was dropped. An "
1526 "orphan is a defect and must be 0; it is counted rather than hidden.")
1529 "set_phase_change_plane_dirichlet",
1530 [](D& diag,
bool on) { diag.s->setPhaseChangePlaneDirichlet(on); }, nb::arg(
"on"),
1531 "The PLANE-ANCHORED (ghost-fluid) interfacial Dirichlet condition. ON by default.\n\n"
1532 "Rungs P0/P1 pinned the whole interfacial CELL at T_G, so the numerical thermal boundary "
1533 "sat at the cell CENTRE while the mass-flux gradient is fitted from the PLIC PLANE — a "
1534 "mismatch of up to half a cell that CHANGES SIGN as the interface sweeps through a cell, "
1535 "and the first-order component of the P1 Stefan error (WO-P01 finding 6). With this on, "
1536 "the condition becomes PER FACE: for a pure cell i whose neighbour j is interfacial, that "
1537 "face's row carries k*open*(T_i - T_G)/theta with theta the distance in cells from i's "
1538 "centre to j's PLIC plane along the face's own axis, and the interfacial cell's own value "
1539 "is never read by any neighbour.\n\n"
1540 "A per-CELL value (the literal reading of the work order) does NOT work and the "
1541 "measurement is in the findings: giving the interfacial cell the value the one-sided "
1542 "profile takes at its centre is right for the side the fit came from and wrong for the "
1543 "other side — on the P1 Stefan ladder it heats the saturated liquid through the "
1544 "interfacial cell's liquid-side face, which gives the liquid a spurious gradient that "
1545 "feeds straight back into mdot: +6.20/+5.62/+5.42 % at N = 64/128/256, order 0.10, "
1546 "against +1.31/+0.59/+0.20 % and order 1.37 for the cell-centre pinning it was meant to "
1547 "improve on. The per-face form has no such asymmetry (the liquid-side face reads T_G at "
1548 "its own theta, i.e. exactly zero flux for a saturated liquid).\n\n"
1549 "False restores the rung P0/P1 behaviour bit-for-bit.")
1551 "set_phase_change_quadratic_fit",
1552 [](D& diag,
bool on) { diag.s->setPhaseChangeQuadraticFit(on); }, nb::arg(
"on"),
1553 "Fit the one-sided interfacial temperature gradients QUADRATICALLY through the interface "
1554 "value (T - T_G = G phi + Q phi^2) instead of linearly. This is VOF_PLAN section 9 item "
1555 "1's Aslam quadratic extrapolation in least-squares form — the same 5^3 pure-cell "
1556 "samples, the same Malan collinearity weights, one more basis function, no PDE sweeps. "
1557 "Once the plane-anchored Dirichlet has removed the cell-centre mismatch, the linear fit's "
1558 "O(T'' h) curvature bias is the leading error of the rung: its samples start about one "
1559 "cell from the plane and reach two and a half, so a curved profile tilts the straight "
1560 "line through the origin. ON by default (WO-P23): with the plane-anchored Dirichlet it takes "
1561 "the P1 Stefan interface position from +0.195 % to +0.003 % at N = 256, and the mdot "
1562 "kernel itself from order 1.1 to order 2.0. set(False) is the ablation.")
1564 "set_phase_change_area",
1565 [](D& diag,
const std::string& mode) {
1569 "WO-P3c: WHICH GEOMETRY the interfacial area A_Gamma comes from. A_Gamma sets the plane "
1570 "shift dV = mdot A dt / rho_l and the divergence source S = mdot A (1/rho_g - 1/rho_l), "
1571 "so a bubble grows as int mdot dA and a biased area is a biased growth rate.\n"
1572 " 'plic' (DEFAULT, rungs P0/P1): plicArea = |m|_2 dV/dalpha on the MYC normal.\n"
1573 " 'cascade-metric': the V3 curvature cascade's own geometry -- the height function's "
1574 "area element sqrt(1 + h_x^2 + h_y^2) from the SAME central differences the curvature "
1575 "differentiates once more (tiers 1/2), the PV paraboloid's gradient (tier 3) -- applied "
1576 "to the PLIC polygon's projected footprint, so the cells of a column still tile.\n"
1577 " 'cascade-normal': the same normals, but the plane is rebuilt on them, "
1578 "plicArea(n*, plicAlpha(n*, C)).\n"
1579 " 'cascade-footprint': the height function's OWN footprint times its own metric, the "
1580 "only per-cell variant whose pieces tile.\n"
1581 " 'sheet-color-centroid' / 'sheet-color-split' / 'sheet-plic-centroid' / "
1582 "'sheet-plic-split' = WO-P3d, the JOINED sheet: marching tetrahedra on the cell-centre "
1583 "lattice, one watertight surface whose triangles are booked to cells -- the C = 1/2 level "
1584 "set with whole triangles to the cell holding the centroid, the same sheet clipped to "
1585 "each cell's cube, and the same two deposits on the zero of the PLIC-reconstructed "
1586 "signed distance (exact on a TILTED plane, where interpolating C is not, because C(d) is "
1587 "the SZ piecewise cubic). The sheet modes are the only ones whose SUM converges on a "
1588 "curved interface: WO-P3c proved with two analytic controls that every PER-CELL area is "
1589 "first order in h/R because the pieces do not JOIN across cells.\n"
1590 "All of the per-cell modes are EXACT on a plane, so every planar gate (P0a/P0b/P1/P2) is "
1591 "unmoved. The default is 'plic' because the measurement says so: on a sphere whose "
1592 "colour field is resolved (16^3 sub-sampling), summed plicArea is within 0.5 % of "
1593 "4 pi R^2 and the cascade does not improve it. WO-P3b's 5.5-9.3 % 'PLIC area deficit' "
1594 "was its probe's own 4^3 sub-sampling, which quantizes C to 1/64 and drops a QUARTER of "
1595 "the interfacial cells (their volume is 1e-4 %, their area 6 %).")
1597 "phase_change_area", [](D& diag) {
return kAreaModes.at(diag.s->phaseChangeArea()); },
1598 "The set_phase_change_area mode in force, as its name.")
1600 "set_phase_change_energy_muscl",
1601 [](D& diag,
bool on) { diag.s->setPhaseChangeEnergyMuscl(on); }, nb::arg(
"on"),
1602 "MinMod-limited donor reconstruction of the face temperature in the CONSISTENT energy "
1603 "flux, instead of the default plain donor-cell (first-order upwind) value. OFF by "
1604 "default. The consistency is what the geometric flux buys; its first-order upwind "
1605 "numerical diffusion |u| h (1 - CFL)/2 thickens the thermal boundary layer and therefore "
1606 "LOWERS the interfacial gradient, which is exactly what mdot is — measured on the P3 "
1607 "Scriven bubble at Ja = 0.5: -2.24 % on R(t) with plain upwind against -1.46 % for the "
1608 "scalar module's Koren TVD (which is NOT consistent). This buys the accuracy back; it is "
1609 "a switch and not a default because it is the energy twin of set_vof_momentum_muscl.")
1611 "phase_change_diagnostics",
1613 const auto d = diag.s->phaseChangeDiagnostics();
1615 r[
"mdot_min"] = d.mdotMin;
1616 r[
"mdot_max"] = d.mdotMax;
1617 r[
"mdot_mean"] = d.mdotMean;
1618 r[
"interface_cells"] = d.interfaceCells;
1619 r[
"interface_area"] = d.area;
1620 r[
"area_hf_cells"] = d.areaHf;
1621 r[
"area_pv_cells"] = d.areaPv;
1622 r[
"area_no_cascade_cells"] = d.areaNone;
1623 r[
"area_orphan"] = d.areaOrphan;
1624 r[
"mdot_fit"] = d.mdotFit;
1625 r[
"q_operator"] = d.qOperator;
1626 r[
"q_orphan"] = d.qOrphan;
1627 r[
"removed_volume"] = d.removedVolume;
1628 r[
"redistributed"] = d.redistributed;
1629 r[
"deficit_cells"] = d.deficitCells;
1630 r[
"excess_cells"] = d.excessCells;
1631 r[
"source_sum"] = d.sourceSum;
1632 r[
"source_cells"] = d.sourceCells;
1633 r[
"fallback_cells"] = d.fallbackCells;
1634 r[
"unresolved"] = d.unresolved;
1635 r[
"min_C"] = d.minC;
1636 r[
"max_C"] = d.maxC;
1637 r[
"band_div"] = d.bandDiv;
1638 r[
"T_min"] = d.Tmin;
1639 r[
"T_max"] = d.Tmax;
1642 "Per-rank phase-change census of the LAST step: mdot extrema/mean and the interfacial "
1643 "cell count, the total PLIC interface area, the liquid volume the regression removed, "
1644 "the clip-and-redistribute ledger (|deficit| moved, and how many cells clipped at 0 / "
1645 "1), the deposited divergence source (its sum and how many cells received it), how many "
1646 "interfacial cells found NO pure gas cell within two cells along +n (the source then "
1647 "stays put — a fallback that must be 0 on a resolved interface), the colour extrema, "
1648 "'band_div' = max|div(open u)| over the INTERFACIAL cells (WO-P23: the direct read-out of "
1649 "whether the field Weymouth-Yue advects with is the liquid velocity there — the "
1650 "band-extended velocity of VOF_PLAN section 9 item 3 is needed iff this is not at the "
1651 "projection floor), and the energy-scalar extrema under the consistent transport.\n"
1652 "Units are the solver's physical ones: mdot_* a mass flux, interface_area/area_orphan "
1653 "an area, q_* a power, removed_volume/redistributed/unresolved a liquid VOLUME (colour "
1654 "x cell volume), source_sum and band_div 1/time; colours and temperatures as given.")
1656 "set_phase_change_fit_curvature",
1657 [](D& diag,
double k) { diag.s->setPhaseChangeFitCurvature(k); }, nb::arg(
"kappa"),
1658 "WO-P3f INSTRUMENT (default 0, bitwise inert): prescribe the interface curvature "
1659 "`kappa = div(n)` that the one-sided temperature fits use to measure a sample's distance "
1660 "to the CURVED interface instead of to the interfacial cell's tangent plane "
1661 "(`vof::pcCurvedDistance`). For a spherical GAS bubble of radius R, whose PLIC normal "
1662 "points inward, kappa = -2/R.\n\n"
1663 "Why it exists: the tangent-plane distance makes the fit FIRST ORDER in h/R on a curved "
1664 "interface, and every off-axis sample is hotter than the plane model expects, so the "
1665 "fitted dT/dn and with it mdot come out HIGH. Measured a priori on an exact sphere with "
1666 "an exactly linear profile: +19.2 / +12.1 / +8.8 / +6.2 % at R = 6 / 10 / 14 / 20, "
1667 "observed order 0.91-0.98 in h/R. This entry point takes a PRESCRIBED kappa so that bias "
1668 "can be measured against a known geometry; it is not a curvature estimator.")
1670 "set_phase_change_energy_order",
1671 [](D& diag,
int order) { diag.s->setPhaseChangeEnergyOrder(order); }, nb::arg(
"order"),
1672 "WO-P3g: the ORDER of the interfacial energy operator. 1 (the shipped WO-P23...P3f "
1674 "`order = 2` turns on, TOGETHER, the four pieces WO-P3f's instruments indicated:\n"
1675 " 1. `set_phase_change_mdot_operator(True)` -- mdot is the energy operator's OWN "
1676 "interfacial flux q/(h_lv A) instead of a separate least-squares fit, so the heat the "
1677 "energy equation loses and the mass the regression produces are one discrete quantity "
1678 "(and the interfacial AREA cancels out of the mass balance entirely);\n"
1679 " 2. `set_phase_change_gfm_order(2)` -- the Gibou-Fedkiw three-point ghost-fluid row "
1680 "(2/((1+theta) theta), 2/(1+theta)) instead of the two-point (1/theta, 1), which is "
1681 "exact on a quadratic profile at every theta;\n"
1682 " 3. `set_phase_change_curvature_distance(True)` -- the row's theta and the one-sided "
1683 "fits' sample distances are measured to the CURVED interface, with kappa taken per cell "
1684 "from the V3 curvature cascade;\n"
1685 " 4. `set_phase_change_carry_conserve(True)` -- WO-P3f's enthalpy-conserving Dirichlet "
1687 "Why together and not one at a time: WO-P3f measured the shipped scheme's 1 % Scriven "
1688 "error to be the residue of a CANCELLATION between the fit's +6 % curvature bias, the "
1689 "two-point row's -5 % flux deficit and the overwrite's -0.7...-4.3 % enthalpy "
1690 "destruction, so repairing any ONE alone makes the gate worse.")
1692 "set_phase_change_deposit_fallback",
1693 [](D& diag,
bool on) { diag.s->setPhaseChangeDepositFallback(on); }, nb::arg(
"on") =
true,
1694 "WO-P3f open item 6 / WO-P3g (OFF by default): give "
1695 "an interfacial cell whose two along-the-normal deposit candidates are BOTH still "
1696 "interfacial a target from the 5^3 box instead of leaving the divergence source in "
1697 "place. A cell that keeps its source carries div(open u) = S on its own faces, so "
1698 "Weymouth-Yue advects the colour with a field that is not the liquid velocity -- read it "
1699 "out with `phase_change_diagnostics()['band_div']` and the 'fallback_cells' count.")
1701 "set_phase_change_mdot_operator",
1702 [](D& diag,
bool on) { diag.s->setPhaseChangeMdotOperator(on); }, nb::arg(
"on") =
true,
1703 "WO-P3g item 1 (default OFF): take mdot from the energy operator's own interfacial flux "
1704 "-- the sum of the ghost-fluid rows' Dirichlet couplings evaluated with the converged T "
1705 "-- instead of the one-sided least-squares fit, which stays as "
1706 "`phase_change_diagnostics()['mdot_fit']`. Under a physical domain this needs "
1707 "set_phase_change_energy: the constant-diffusivity flux carries no heat capacity, so "
1708 "the step raises rather than assume one.")
1710 "set_phase_change_gfm_order", [](D& diag,
int o) { diag.s->setPhaseChangeGfmOrder(o); },
1712 "WO-P3g item 2 (default 1): the order of the one-sided (ghost-fluid) Dirichlet row. "
1713 "1 = the shipped two-point form k o (T_Gamma - T_i)/theta; 2 = Gibou-Fedkiw's "
1714 "three-point form through (T_behind, T_i, T_Gamma), which reproduces a quadratic "
1715 "temperature profile exactly at every theta.")
1717 "set_phase_change_curvature_distance",
1718 [](D& diag,
bool on) { diag.s->setPhaseChangeCurvatureDistance(on); }, nb::arg(
"on") =
true,
1719 "WO-P3g item 3 (default OFF): measure the GFM row's theta and the one-sided fits' sample "
1720 "distances to the CURVED interface, with the mean curvature taken PER CELL from the V3 "
1721 "curvature cascade (the same kappa surface tension uses; positive for a convex blob of "
1722 "liquid, i.e. -2/R for a gas bubble). Supersedes `set_phase_change_fit_curvature`, which "
1723 "prescribes one curvature for the whole field; where both are set the cascade wins.")
1725 "set_phase_change_carry_conserve",
1726 [](D& diag,
bool on) { diag.s->setPhaseChangeCarryConserve(on); }, nb::arg(
"on") =
true,
1727 "WO-P3f OPTION (default OFF): make the interfacial cells' per-cell Dirichlet OVERWRITE "
1728 "enthalpy-conserving.\n\n"
1729 "An interfacial cell's row is the identity `T = dval`, so whatever the geometric energy "
1730 "transport left there is discarded every step. Over a cell's interfacial lifetime that "
1731 "telescopes to rho c_p (T_entry - dval_exit): a liquid cell the interface sweeps enters "
1732 "with its superheat and leaves as vapour at T_sat, and the difference goes nowhere. "
1733 "Measured on Scriven 128^3 by `phase_change_budget()['d_overwrite']`: -0.7 % of "
1734 "mdot h_lv A_Gamma at Ja = 0.5 and -4.3 % at Ja = 2, one-signed.\n\n"
1735 "With this on, that enthalpy is handed to the interfacial cell's face neighbours that "
1736 "are still in the solve, weighted by n_d^2 (the clip-and-redistribute allocation), as a "
1737 "fixed-order gather so it is decomposition-independent. Which side receives is decided "
1738 "per axis by which pure neighbour deviates from T_Gamma in the same direction as the "
1739 "interfacial cell itself, i.e. the phase the enthalpy came from -- the superheated "
1740 "liquid on an evaporating bubble, the superheated vapour on the Stefan problem.")
1742 "phase_change_carry_ledger",
1745 r[
"deposited"] = diag.s->phaseChangeCarryDeposited();
1746 r[
"lost"] = diag.s->phaseChangeCarryLost();
1749 "WO-P3f: the last `set_phase_change_carry_conserve` pass — the enthalpy actually handed "
1750 "back ('deposited') and the enthalpy of the interfacial cells that had NO neighbour left "
1751 "in the solve ('lost', which stays destroyed). Both 0 when the option is off. Units as "
1752 "phase_change_budget's enthalpies.")
1754 "set_phase_change_budget", [](D& diag,
bool on) { diag.s->setPhaseChangeBudget(on); },
1755 nb::arg(
"on") =
true,
1756 "WO-P3f INSTRUMENT: turn on the energy budget of the phase-change energy solve. Costs "
1757 "one extra cell field and two reductions per energy solve, and is skipped entirely when "
1758 "off (the solve is then bit-identical). Read it with `phase_change_budget()`.\n\n"
1759 "What it exists for: interfacial cells are Dirichlet rows, i.e. they are OUTSIDE the "
1760 "energy solve, so the set over which enthalpy is conserved changes membership every step "
1761 "as the interface sweeps. A liquid cell that becomes interfacial LEAVES that set carrying "
1762 "its superheat rho c_p (T - T_sat) and an interfacial cell that becomes pure RE-ENTERS it "
1763 "carrying `pcCarriedValue`; neither transfer appears in the latent-heat book-keeping.")
1765 "phase_change_budget",
1767 const auto b = diag.s->phaseChangeBudgetValues();
1769 r[
"h_open"] = b.hOpen;
1770 r[
"h_open_new"] = b.hOpenNew;
1771 r[
"h_liquid"] = b.hLiquid;
1772 r[
"h_masked"] = b.hMasked;
1773 r[
"d_overwrite"] = b.dEoverwrite;
1774 r[
"d_overwrite_new"] = b.dEoverwriteNew;
1775 r[
"e_enter"] = b.eEnter;
1776 r[
"e_leave"] = b.eLeave;
1777 r[
"q_gfm"] = b.qGfm;
1778 r[
"q_behind"] = b.qBehind;
1779 r[
"n_enter_liquid"] = b.nEnterLiquid;
1780 r[
"n_enter_gas"] = b.nEnterGas;
1781 r[
"n_leave_liquid"] = b.nLeaveLiquid;
1782 r[
"n_leave_gas"] = b.nLeaveGas;
1783 r[
"n_masked"] = b.nMasked;
1784 r[
"calls"] = b.calls;
1787 "WO-P3f: the ENERGY BUDGET of the LAST energy solve, in the solver's physical units: "
1788 "energies for the enthalpies and powers for `q_gfm`/`q_behind` with "
1789 "set_phase_change_energy on; without it the constant-diffusivity scalar carries no heat "
1790 "capacity and they are temperature x volume (and per time).\n"
1791 " h_open / h_open_new sum rho c_p (T - T_sat) over the UNMASKED cells, before / after "
1793 " h_liquid, h_masked the same over the pure-liquid and the interfacial cells\n"
1794 " d_overwrite sum rho c_p (dval - T) over masked cells: what the Dirichlet "
1795 "rows inject when they overwrite the transported temperature (negative = destroyed)\n"
1796 " d_overwrite_new the part of it on cells that were NOT masked last step\n"
1797 " e_enter / e_leave the enthalpy carried OUT of / INTO the solved set by the cells "
1798 "that changed class this step\n"
1799 " q_gfm the heat the plane-anchored rows deliver INTO the unmasked set "
1800 "(negative while a bubble grows); the energy equation's own interfacial flux, to be "
1801 "compared with the regression's `mdot h_lv A_Gamma`\n"
1802 " n_enter_*/n_leave_* the class-change census\n"
1803 "The discrete balance the entries close over one solve is "
1804 "`sum rho c_p (T^{n+1} - T*)/dt = q_gfm + (domain boundary flux) + (solve residual)`.")
1807 [](D& diag,
const std::string& mode) {
1811 "ABLATION. 'face' (default, the only production mode) evaluates the surface-tension "
1812 "force as sigma*kappa_f*(C(i)-C(i-s))/h at the face -- the projection's own gradient "
1813 "operator. 'cell' evaluates a CELL-CENTRED sigma*kappa*grad(C) and face-interpolates it "
1814 "with the arithmetic mean, exactly as the per-cell body-force machinery carries a rho*g "
1815 "field: consistent, convergent, and wrong for surface tension, because the result is not "
1816 "in the range of the operator the projection inverts. Shipped so the difference is a "
1817 "measured number (see the vof_surface_tension ctest), not an argument.")
1819 "set_vof_interface_eps", [](D& diag,
double eps) { diag.s->setVofInterfaceEps(eps); },
1821 "Wisp threshold on the curvature cascade's interfacial predicate once surface tension is "
1822 "on: a cell carries an interface only while eps < C < 1-eps. Default 1e-8.\n\n"
1823 "This is NOT optional and it is not cosmetic. Weymouth-Yue leaves round-off colour "
1824 "residue (measured down to -3e-35) in every cell its sweeps touch; those cells satisfy "
1825 "0 < C < 1, so the cascade builds a PLIC polygon of area ~0 for them and returns "
1826 "|kappa| up to 1e8 where the physical value is 0.125. A face between such a cell and a "
1827 "REAL interfacial cell then carries a surface-tension force eight orders too large. "
1828 "Measured on a 32^3 static droplet with eps = 0: max|u| 4.5e-4 at step 1 -> 2.7e-1 by "
1829 "step 20, and at 96^3 the run trips the Weymouth-Yue CFL cap. Setting eps = 0 "
1830 "reproduces that, which is the ablation. compute_vof_curvature() called WITHOUT surface "
1831 "tension keeps the rung-V3 predicate (0 < C < 1) unchanged.")
1833 "vof_interface_eps", [](D& diag) {
return diag.s->vofInterfaceEps(); },
1834 "The wisp threshold set by set_vof_interface_eps.")
1836 "set_vof_block_kappa_clip",
1837 [](D& diag,
bool enabled, std::optional<double> kappaMax) {
1838 diag.s->setVofBlockKappaClip(enabled, kappaMax ? *kappaMax : -1.0);
1840 nb::arg(
"enabled"), nb::arg(
"kappa_max") = nb::none(),
1841 "Curvature admissibility clip of the BLOCK container (doc/vof_overlap_design.md 5.1, "
1842 "11): after each marker's cascade every interfacial cell gets |kappa| <= kappa_max. "
1843 "DEFAULT ON at kappa_max = 1/Delta_min. kappa_max is PHYSICAL (1/length); None selects "
1844 "the default. enabled=False restores the pre-clip block cascade verbatim (the "
1845 "ablation). The single-field cascade never clips: there a large |kappa| is legitimate "
1846 "(an under-resolved droplet, a contact-line cell).\n\n"
1847 "Why: a mixed cell with no body of its marker near it (debris left where two markers "
1848 "overlapped) has no closing height-function column and a PLIC-volumetric fit through "
1849 "polygons of area ~0, so its paraboloid is arbitrary (measured |kappa| 273 and 428 per "
1850 "cell against a true 0.4 on channel_18; one-step velocity blow-up). |kappa| <= 1/Delta "
1851 "is a sphere of R >= 2 Delta, the smallest the height function can see, and bounds every "
1852 "CSF face force by sigma |dC| / Delta^2. The value is written only where it changes, so "
1853 "a resolved interface is bit-identical; vof_block_curvature_stats() reports 'clipped'.")
1855 "set_vof_kappa_frozen", [](D& diag,
bool on) { diag.s->setVofKappaFrozen(on); },
1856 nb::arg(
"on") =
true,
1857 "INSTRUMENT: stop recomputing the curvature at the head of each step and use whatever is "
1858 "in the 'kappa'/'kappa_branch' fields. With set_vof_kappa_constant this isolates the "
1859 "balanced-force identity from the curvature estimator.")
1861 "set_vof_kappa_constant", [](D& diag,
double k) { diag.s->setVofKappaConstant(k); },
1863 "INSTRUMENT: set kappa to a constant everywhere (inner + ghosts), mark every cell as "
1864 "carrying a valid estimate, and freeze it. The CSF force is then EXACTLY the discrete "
1865 "gradient of sigma*kappa*C, so the projection must annihilate it to round-off from any "
1866 "colour field whatsoever — the exactness gate of rung V4, independent of curvature "
1867 "accuracy and of resolution.")
1869 "set_rho_face_harmonic", [](D& diag,
bool on) { diag.s->setRhoFaceHarmonic(on); },
1870 nb::arg(
"on") =
true,
1871 "Use the HARMONIC instead of the arithmetic face mean of rho in the pressure projection "
1872 "(both the operator coefficient and the velocity correction, so the projection stays "
1873 "exact). DEFAULT OFF, and it should stay off: the arithmetic mean of rho IS the harmonic "
1874 "mean of the mobility 1/rho — the series-correct choice for a normal flux — and it is "
1875 "what makes the discrete hydrostatic balance EXACT, because the momentum time term and "
1876 "the face body force interpolate rho arithmetically and are not switched by this flag. "
1877 "Shipped as a measured knob for the coefficient-coarsening question (VOF_PLAN S3), not "
1878 "as an alternative scheme.")
1880 "set_vof_rho_floor", [](D& diag,
double f) { diag.s->setVofRhoFloorFrac(f); }, nb::arg(
"fraction"),
1881 "Floor on rho^c in the recovery divide u = (rho^c u)/rho^c, as a FRACTION of "
1882 "min(rho_gas, rho_liquid). Default 1e-6. rho^c leaves [rho_gas, rho_liquid] only through "
1883 "a wisp in the half-shifted colour and reaching zero would need C^c ~ -1/(ratio-1), so "
1884 "this is a guard, not a model — vof_momentum_diagnostics()['floored'] reports how many "
1885 "control volumes it actually touched.")
1887 "vof_rho_floor", [](D& diag) {
return diag.s->vofRhoFloor(); },
1888 "The absolute rho^c floor used by the last recovery.")
1890 "set_vof_momentum_muscl", [](D& diag,
bool on) { diag.s->setVofMomentumMuscl(on); },
1891 nb::arg(
"on") =
true,
1892 "MinMod-limited linear reconstruction of the donor velocity in the momentum flux, "
1893 "instead of the DEFAULT plain donor-cell upwind. Both preserve the uniform-velocity "
1894 "identity exactly (a uniform field has a zero slope bit for bit), but on a control volume "
1895 "a sweep EMPTIES the slope's deviation from the volume's own velocity is amplified by "
1896 "drho*F/rho^c, which is unbounded in the density ratio. Measured at ratio 1e4, 50 steps: "
1897 "with the slope the uniform-velocity residual grows to 2.2e-10, without it it is flat at "
1898 "6.7e-16; at ratio 1e3 the slope is harmless. Turn it on deliberately and re-run the "
1899 "ratio sweep if you do.")
1901 "set_vof_momentum_cell_flag", [](D& diag,
bool on) { diag.s->setVofMomentumCellFlag(on); },
1902 nb::arg(
"on") =
true,
1903 "ABLATION: use the PRESSURE-cell frozen dilation flag H(C^n-1/2) on the shifted control "
1904 "volume instead of its structural analogue H(C^c,n-1/2). The flag that must be shared is "
1905 "the one of the pair that telescopes, and both members of that pair live on the shifted "
1906 "volume — this switch is the literal reading of the work order, kept as a measurement.")
1908 "set_vof_flux_clamp", [](D& diag,
bool on) { diag.s->setVofFluxClamp(on); }, nb::arg(
"on") =
true,
1909 "ABLATION: drop the Weymouth flux clamp max(0,|a|-(1-C^c_don)) <= |F| <= min(|a|,C^c_don) "
1910 "on the half-shifted control volume. The geometric flux is bounded by what the CURRENT "
1911 "cell planes see in the donor, not by the ADVECTED C^c; the gap is O(a^2) and at density "
1912 "ratio 1e4 a 2.6e-2 undershoot drives rho^c to -255, which the recovery would divide by. "
1913 "Default ON; off is how that statement stays a measured number.")
1915 "vof_momentum_diagnostics",
1917 const auto d = diag.s->vofMomentumDiagnostics();
1919 r[
"min_Cc"] = nb::make_tuple(d.minCc[0], d.minCc[1], d.minCc[2]);
1920 r[
"max_Cc"] = nb::make_tuple(d.maxCc[0], d.maxCc[1], d.maxCc[2]);
1921 r[
"sum_momentum"] = nb::make_tuple(d.sumM[0], d.sumM[1], d.sumM[2]);
1922 r[
"min_rho_c"] = d.minRhoC;
1923 r[
"floored"] = d.floored;
1924 r[
"clamped"] = d.clamped;
1927 "Census over THIS RANK's momentum control volumes: per-component min/max of the "
1928 "half-shifted colour C^c, the summed momentum rho^c u_c (the conservation census), the "
1929 "minimum rho^c before the floor, and the number of control volumes the floor touched.")
1931 "vof_advected_velocity", [](D& diag,
int c) {
return field_out(*diag.s, diag.s->getVofAdvectedVelocity(c)); },
1932 nb::arg(
"component"),
1933 "The recovered advected velocity (rho^c u_c)/rho^c of component c on the inner cells — "
1934 "the momentum RHS's time base. Exposed so the uniform-velocity consistency identity can "
1935 "be gated on the ADVECTION ALONE, with the projection and the momentum solve out of the "
1938 "set_property_mode",
1939 [](D& diag,
const std::string& mode,
bool harmonic) {
1940 diag.s->setPropertyMode(mode ==
"variable", harmonic);
1942 nb::arg(
"mode") =
"variable", nb::arg(
"harmonic") =
false,
1943 "Enable variable-coefficient momentum (variable viscosity): mode 'variable' binds the "
1945 "field (get/set_field('mu')) into the diffusion operator; 'constant' reverts. harmonic = "
1946 "harmonic face-viscosity mean (continuous shear stress across a jump) vs arithmetic. A "
1947 "closure targeting 'mu' enables this automatically. The incremental-rotational pressure "
1948 "scheme (large-dt / steady-Stokes) stays active — see set_variable_rotational.")
1950 "set_variable_rotational",
1951 [](D& diag,
const std::string& mode,
double chi) {
1955 else if (mode ==
"full")
1957 else if (mode ==
"off")
1960 throw std::runtime_error(
"set_variable_rotational: mode must be min/full/off");
1961 diag.s->setVariableRotational(m, chi);
1963 nb::arg(
"mode") =
"min", nb::arg(
"chi") = 1.0,
1964 "Rotational-pressure term under variable viscosity (the constant-mu Timmermans term "
1965 "-mu*div(u*) is only valid for homogeneous viscosity — Deteix & Yakoubi 2018). 'min' "
1966 "(default): constant coefficient chi*mu_min — provably stable at any contrast, exact "
1967 "fallback to the constant-mu scheme for uniform mu. 'full': pointwise chi*mu(i) — better "
1968 "pressure consistency at MILD contrast only. 'off': plain incremental (no rotational "
1969 "term). All modes keep the incremental predictor (large-dt / steady-Stokes capability).")
1972 [](D& diag,
const std::string& mode) { diag.s->setDensityMode(mode ==
"variable"); },
1973 nb::arg(
"mode") =
"variable",
1974 "Enable variable density: binds the 'rho' field "
1975 "(get/set_field('rho'), created seeded with set_rho's value if absent) into the momentum "
1976 "time term, the advection weight, the per-cell body force (face-interpolated), and the "
1977 "pressure projection (face coefficient openness*rho0/rho_f with the matching 1/rho_f "
1978 "velocity correction; rho0 = set_rho's value, so a uniform field reduces exactly to the "
1979 "constant solver). A closure targeting 'rho' (e.g. a linear mixture of a transported "
1980 "phase fraction) enables this automatically. For gravity, register a closure "
1981 "force_z = linear(rho, params=[0, -g]).\n\n"
1982 "COLLOCATED (SolverColocated, rung V8): supported since 2026-09-02, ALL-FLUID only "
1983 "(set_pressure_geometry; an immersed solid, the ghost projection and "
1984 "set_rho_face_harmonic throw). The face coefficient and the face correction are the same "
1985 "as on the staggered grid, applied to the ABC projection's MAC face field, which is the "
1986 "MOMENTUM-weighted map (rho_L u_L + rho_R u_R)/(rho_L + rho_R) of the cell velocities; the "
1987 "CELL correction is the average of the two face corrections of each axis (never a "
1988 "cell-centred grad(phi)/rho_c). The pressure, the CSF and set_body_force (a mean pressure "
1989 "gradient) enter the implicit momentum predictor as the matching face integral "
1990 "rho_c * avg_faces((f - grad_f P)/rho_f); a per-cell force enters at the cell value. The "
1991 "pair is adjoint, so the step is stable at every dt and density ratio and its steady "
1992 "state is dt-independent (doc/collocated_varrho_forces.md). Rated to density ratio ~10 "
1993 "for cases WITH MOTION (a translating drop exceeds the VoF CFL cap at ratio 100 and "
1994 "1000; momentum consistency needs Favre face states and is not in this rung); exact at "
1995 "rest at ratio 1000.")
1997 "set_pressure_underrelax", [](D& diag,
double w) { diag.s->setPressureUnderRelax(w); },
1999 "Pressure under-relaxation factor omega_p in (0,1] for the incremental accumulation "
2001 "§10.1); 1.0 = off (default). <1 damps the incremental predictor overshoot on stiff "
2004 "set_porous_deps_dt", [](D& diag,
bool on) { diag.s->setPorousDepsDt(on); }, nb::arg(
"on"),
2005 "Include (default True) or drop the d(eps)/dt source in the porous projection RHS. Drop "
2007 "to enforce div(eps u)=0 when the per-cell eps deposit's time-derivative is too jagged "
2009 "destabilizes the eps-weighted pressure solve.")
2011 "set_porous_conservative", [](D& diag,
bool on) { diag.s->setPorousConservative(on); },
2013 "eps-conservative porous momentum + projection pair (default True): time term "
2014 "(eps_f rho/dt) u, eps rho-weighted advective form, projection coefficients "
2015 "open*(eps rho idt)/(eps rho idt+beta) with matching correction. False = the legacy "
2016 "plain-u pair (A/B only; it lets the projection drag gas with the moving porosity at "
2017 "zero inertia cost — a spurious late-time energy source in clustering flows).")
2018#ifdef PECLET_FLOW_MPI
2024 "rebalance_by_weights",
2025 [](D& diag,
const std::vector<double>& w) {
return diag.s->rebalanceByWeights(w); },
2027 "Dynamic load balancing: redistribute the solver's state onto the weighted ORB of "
2028 "per-cell weights (global x-fastest, gnx*gny*gnz), its split planes aligned to 2^a "
2029 "cells with a the largest alignment whose weight imbalance stays within 1.05, so the "
2030 "pressure multigrid keeps coarsening in place for a levels. Returns that alignment "
2031 "(1 = the plain weighted ORB). Pass fluid work + gamma*particle_count; a coupled dem "
2032 "migrates onto the SAME partition with migrate_to_weights(weights, align=<the "
2033 "return value>). State-preserving (bit-exact at np=1, reduction floor at np>1).")
2037 "bcast_from_root", [](D&, nb::object v) {
return v; }, nb::arg(
"value"),
2038 "Broadcast a value from rank 0 (identity in the single-rank module; mirrors the MPI "
2047static void bind_solver(nb::module_& m,
const char* name,
const char* diag_name) {
2050 bind_diagnostics<Grid>(m, diag_name);
2053 "One incompressible Navier-Stokes solver on a block of the grid (staggered MAC, the "
2054 "reference) -- or, for SolverColocated, the same API with cell-centered velocities and "
2055 "the ABC approximate projection. Construct on a (physical) domain; set_rho / set_mu / "
2056 "set_dt and the domain BCs; give it a geometry (set_solid, set_pressure_geometry or a "
2057 "scene); step(); read out with the get_* arrays. Developer instruments and tuning live "
2058 "on `.diagnostics`.")
2060 "diagnostics", [](S& s) {
return D{&s}; }, nb::keep_alive<0, 1>(),
2061 "The developer tier of this solver (instruments, tuning, ablations, internals).")
2062 .def(nb::init<int, int, int>(), nb::arg(
"nx"), nb::arg(
"ny"), nb::arg(
"nz"),
2063 "Create a solver on an nx x ny x nz grid of UNIT cells (x-fastest, I = x + y*nx + "
2064 "z*nx*ny). Everything is then in cell units: the cell size is 1 and lengths, "
2065 "velocities and the SDF are measured in cells. Prefer the physical form "
2066 "Solver((nx,ny,nz), extent=(Lx,Ly,Lz)). Set the domain BCs before the geometry (a later "
2067 "set_domain_bc raises).")
2070 [](S* self, std::array<long, 3> cells, std::optional<std::array<double, 3>> extent,
2071 std::array<double, 3> origin, std::optional<std::array<long, 3>> global_cells) {
2072 for (
int a = 0; a < 3; ++a)
2074 throw std::runtime_error(
"Solver: every cell count must be positive");
2075 new (self) S((
int)cells[0], (int)cells[1], (
int)cells[2]);
2077 self->setPhysicalDomain(*extent, origin, global_cells ? *global_cells : cells);
2079 nb::arg(
"cells"), nb::arg(
"extent") = nb::none(),
2080 nb::arg(
"origin") = std::array<double, 3>{0.0, 0.0, 0.0},
2081 nb::arg(
"global_cells") = nb::none(),
2082 "Create a solver on a PHYSICAL domain: `cells` = (nx, ny, nz), `extent` = the box side "
2083 "lengths (Lx, Ly, Lz) and `origin` = its lower corner, all in the caller's own "
2084 "consistent units. The cell size is extent/cells and is never written by the user: "
2085 "rho, mu, dt, body forces, boundary velocities, surface tension, the SDF handed to "
2086 "set_solid and the coordinates of set_scene are all physical, and the velocity, "
2087 "pressure and curvature that come back are physical too.\n\n"
2088 "extent=None (the default) keeps the historical CELL-UNIT behaviour, bit for bit: the "
2089 "spacing is 1 and every quantity is in cells.\n\n"
2090 "ANISOTROPIC cells (extent/cells different per axis) are supported since Phase 2 on the "
2091 "staggered Solver AND (since commit C4) on SolverColocated: sampled or scene geometry, "
2092 "constant or variable properties, variable density, porous continuity with or without "
2093 "implicit drag, every domain-BC type, every pressure driver and bottom, the velocity "
2094 "multigrid, scalar transport, the hydrodynamic force and torque integrals, and MPI. "
2095 "moving geometry included. What still REFUSES one, with a message naming the three "
2096 "spacings and what lifts it: enable_vof (Phase 3). "
2097 "Three spacings that agree to 1e-12 relative are SNAPPED "
2098 "to one, so an isotropic domain stays bit-identical however its extent was written. "
2099 "Under MPI `cells` is this rank's block (from "
2100 "mpi_block) while `extent`/`origin` describe the GLOBAL domain, so pass the global "
2101 "grid as `global_cells` - the same numbers init_mpi gets.")
2105 return std::array<long, 3>{s.nx(), s.ny(), s.nz()};
2107 "This rank's inner block resolution (nx, ny, nz) -- the shape of every field array "
2108 "(get_u, get_p, the SDF for set_solid). Under MPI it is the LOCAL block from mpi_block().")
2110 "global_cells", [](S& s) {
return s.globalCells(); },
2111 "The GLOBAL grid resolution (gnx, gny, gnz); == `cells` single-rank, the numbers "
2112 "init_mpi gets under MPI (the CFD-DEM co-decomposition weight field is this shape).")
2114 "extent", [](S& s) {
return s.domainExtent(); },
2115 "The GLOBAL domain side lengths. Equal to the cell counts without a physical domain.")
2117 "origin", [](S& s) {
return s.domainOrigin(); },
2118 "The physical lower corner of the GLOBAL domain (0,0,0 by default).")
2120 "spacing", [](S& s) {
return s.spacing(); },
2121 "The cell size [dx, dy, dz] = extent/cells as three floats; [1.0, 1.0, 1.0] "
2122 "without a physical domain (the cell-unit default). Three spacings within 1e-12 "
2123 "relative are snapped to one value, so an isotropic box is exactly isotropic.")
2127 const auto& u = s.unitScales();
2129 d[
"identity"] = !u.physical;
2130 d[
"h_ref"] = u.hRef;
2131 d[
"rho_ref"] = u.rhoRef;
2132 d[
"t_ref"] = u.tRef;
2133 d[
"length_to_internal"] = u.lenToInt();
2134 d[
"velocity_to_internal"] = std::vector<double>{u.velToInt(0), u.velToInt(1),
2136 d[
"time_to_internal"] = u.timeToInt();
2137 d[
"density_to_internal"] = u.rhoToInt();
2138 d[
"viscosity_to_internal"] = u.muToInt();
2139 d[
"pressure_to_internal"] = u.pToInt();
2140 d[
"force_density_to_internal"] = std::vector<double>{
2141 u.forceToInt(0), u.forceToInt(1), u.forceToInt(2)};
2142 d[
"force_to_physical"] = u.forceTotalToPhys();
2143 d[
"torque_to_physical"] = u.torqueToPhys();
2146 d[
"diffusivity_to_internal"] = u.diffToInt();
2147 d[
"mass_flux_to_internal"] = u.mdotToInt();
2148 d[
"latent_heat_to_internal"] = u.latentToInt();
2149 d[
"conductivity_to_internal"] = u.condToInt();
2150 d[
"heat_capacity_to_internal"] = u.rcpToInt();
2151 d[
"interface_resistance_to_internal"] = u.rintToInt();
2152 d[
"divergence_to_internal"] = u.divToInt();
2155 "The factors between the caller's units and the ones the solver computes in.\n\n"
2156 "The solver keeps computing on the UNIT LATTICE and folds the metric into constants at "
2157 "its API boundary, so every array reached through the RAW field registry "
2158 "(diagnostics.field_view / get_field / set_field) is in those INTERNAL units, unlike get_u/get_p, "
2159 "which convert. `identity` is True when no extent was given, and then every factor is "
2160 "exactly 1.0 and internal == physical. Multiply a physical value by *_to_internal, and "
2161 "an internal one by force_to_physical (a total force) to come back. The registered "
2162 "'mdot' field is in mass_flux_to_internal units and 'pc_source' / 'div_source' in "
2163 "divergence_to_internal units; a transported scalar is never rescaled.")
2165 "physical_units", [](S& s) {
return s.hasPhysicalDomain(); },
2166 "True when the solver was given an extent, i.e. lengths are physical rather than cells.")
2170 return nb::make_tuple(s.cellCentres(0), s.cellCentres(1), s.cellCentres(2));
2172 "The physical cell-center coordinates of THIS rank's inner block as three 1-D float64 "
2173 "arrays (x, y, z), i.e. origin + (i + 1/2) * spacing per axis. "
2174 "np.meshgrid(*s.cell_centers(), indexing='ij') is the grid an SDF for set_solid is "
2175 "sampled on. Under MPI these are the LOCAL block's centers in GLOBAL coordinates. "
2176 "CELL-UNIT TRAP: without an extent this is i + 1/2, while set_scene puts cell i's centre "
2177 "at i -- the same body given as a scene and as an SDF sampled here lands half a cell "
2178 "apart. With an extent both use origin + (i + 1/2) * spacing. Unifying the cell-unit "
2179 "convention changes results; deferred to 2.0.0.")
2180 .def(
"set_rho", &S::setRho, nb::arg(
"rho"),
2181 "Set the fluid density rho (the caller's units). Under a physical domain the FIRST call "
2182 "pins the reference density the internal scales are built on; a later change rebuilds "
2183 "the momentum operator at the next step.")
2184 .def(
"set_mu", &S::setMu, nb::arg(
"mu"),
2185 "Set the dynamic viscosity mu (the caller's units); a change after the geometry "
2186 "rebuilds the momentum operator at the next step.")
2187 .def(
"set_dt", &S::setDt, nb::arg(
"dt"),
2188 "Set the time step dt (the caller's time unit); the momentum solve is scaled by 1/dt "
2189 "(well-conditioned at large dt). Under a physical domain the FIRST call pins the "
2190 "reference time the internal scales are built on; any later call (step_adaptive "
2191 "makes them) rebuilds the momentum operator at the next step.")
2194 [](S& s, std::array<double, 3> force) { s.setBodyForce(force[0], force[1], force[2]); },
2196 "Set the body force per unit volume (fx, fy, fz) as one 3-sequence — e.g. a mean "
2197 "pressure gradient.")
2199 "set_superficial_velocity",
2200 [](S& s,
bool enabled,
const std::string& axis,
double velocity) {
2201 s.setSuperficialVelocity(
2202 enabled,
enum_index(axis,
kAxes,
"set_superficial_velocity",
"axis"), velocity);
2204 nb::arg(
"enabled"), nb::arg(
"axis") =
"x", nb::arg(
"velocity") = 0.0,
2205 "Hold the SUPERFICIAL velocity along a periodic axis at `velocity` (physical units): "
2206 "the volume mean of that velocity component, i.e. the volume flux of the MIXTURE "
2207 "through any cross-section divided by the cross-section's full area. In multiphase "
2208 "flow 'velocity' alone is ambiguous between superficial and interstitial (per-phase); "
2209 "this is the superficial one. At the end of every step() one uniform shift is "
2210 "added to that component on every face; on an axis with no boundary a uniform shift "
2211 "of the normal velocity leaves every cell divergence unchanged, so the field stays "
2212 "discretely divergence-free. `velocity = 0` is a closed (batch) column: the flow can "
2213 "circulate but not pass through (TBFsolver's constant flow rate at 0). The mean is a "
2214 "device reduction, global under MPI; single-rank the step makes no host round trip for "
2215 "it. Staggered Solver, all-fluid domain (set_pressure_geometry), periodic `axis` only "
2216 "-- step() raises otherwise. set_superficial_velocity(False) turns it off. "
2217 "diagnostics.last_superficial_velocity_shift() is the shift the last step applied.")
2218 .def(
"set_balanced_force_projection", &S::setBalancedForceProjection, nb::arg(
"enabled"),
2219 "Balanced-force projection (flow doc/collocated_varrho_forces.md §4.6), on both grids. "
2220 "Once per step, before the momentum predictor, the gradient part of the forces "
2221 "(set_body_force, the per-cell force fields, the CSF) is moved into the pressure with "
2222 "one extra solve of the projection's own operator, so static balances -- a hydrostatic "
2223 "column, a constant-curvature drop -- are exact from the first step at every "
2224 "viscosity, dt, density ratio and immersed-solid aperture. The solve does not depend "
2225 "on the flow state, so stability and the converged steady state are identical on and "
2226 "off; off leaves a decaying transient balance residue. get_p() stays the total "
2227 "physical pressure; its balanced part is the registered field 'p_balanced', which is "
2228 "RESTART STATE: registered as soon as the option is active, restoring it with 'p' "
2229 "(set_field) keeps the split, and omitting it (or switching the option on after steps "
2230 "with it off) re-splits P -- never a second copy of the balanced pressure. "
2231 "Cost: one extra pressure solve per step while the forces change, "
2232 "solved for the increment of the previous split and skipped when that is already "
2233 "converged -- a static interface costs no iterations. Default: "
2234 "ON on SolverColocated with variable density or surface tension, OFF otherwise; with "
2235 "an inflow/outflow face that default resolves OFF (a one-time stderr notice says "
2236 "so); an explicit call always wins. Refused (named error) with porous continuity, the ghost projection, "
2237 "set_fluid_only_constraint(2), the block CSF, inflow/outflow faces, the "
2238 "non-incremental pressure, and on the constant-density SolverColocated path (nothing "
2239 "to balance there).")
2240 .def_prop_ro(
"balanced_force_projection", [](S& s) {
return s.balancedForceProjection(); },
2241 "Whether the balanced-force projection runs (see "
2242 "set_balanced_force_projection).")
2243 .def(
"set_advection", &S::setAdvection, nb::arg(
"on"),
2244 "Enable/disable explicit high-order momentum advection (default scheme SOU). Off ⇒ "
2247 "set_advection_scheme",
2248 [](S& s,
const std::string& scheme) {
2252 "High-order momentum advection scheme: 'sou' (second-order upwind, the default) or "
2253 "'koren' (Koren TVD).")
2255 "set_collocated_scheme", &S::setCollocatedScheme, nb::arg(
"name"),
2256 "Collocated cut-cell projection scheme (no effect on the staggered solver). DEFAULT "
2257 "since 2026-08-25: AUTO = 'ghost' where supported, falling back to 'gauge-exact' with "
2258 "a stderr notice on porous/variable-rho/domain-BC/Chebyshev configurations (ghost v1 "
2259 "limits); any explicit selection here disables AUTO.\n"
2260 " 'ghost' -- the fluid-only constraint scheme (binary-openness divergence + directional "
2261 "closures + the same gauge-exact gradient). Family-free, unconditionally stable "
2262 "(dt 60..1e20, no stabilizer), protocol-independent (C2), both-bed clean ladder "
2263 "-1.4% (R=8) -> +0.22% asymptote, Z&H anchor -0.018% at N=128. Costs: nonsymmetric "
2264 "BiCGStab pressure solve (~2.3-2.7x), ~1.6 KB/cell overlay (single-GPU size cap), "
2265 "fragmentation guard. MPI-capable (np=1,2,4 ctests); at-scale np>=16 hardening in "
2266 "progress. Equivalent to diagnostics.set_ghost_projection(True, 2, 2); the (1, 2) "
2267 "mixed mode stays quarantined there.\n"
2268 " 'gauge-exact' (the AUTO fallback) -- the aperture constraint with the directional "
2269 "gauge-exact pressure gradient. Cheapest scheme measured (symmetric MG-PCG, no "
2270 "fragmentation guard). CAVEATS from the 2026-08 campaign "
2271 "(doc/collocated_invisible_subspace.md): possesses an attractor FAMILY of steady states "
2272 "(support-inconsistent gradient/constraint pair) selected by march protocol, and a "
2273 "rotational-update instability whose wall-blend stopgap "
2274 "(diagnostics.set_rotational_wall_weight) has a resolution-dependent margin -- UNSTABLE "
2275 "at (R>=16 cells/radius, dt>=600); use fixed dt<=60 protocols at high resolution. "
2276 "Clean-protocol bias vs the staggered reference: -2.5% (R=8) -> ~+0.2% asymptote.\n"
2277 " 'embed' -- the complete Basilisk embed.h port (doc/history/"
2278 "collocated_embed_port_plan.md): the finite-volume momentum operator with the "
2279 "true-normal dirichlet_gradient wall drag applied as a defect correction on the IBM "
2280 "matrix, the openness-weighted -grad(P) predictor and cell correction, the wall-aware "
2281 "face map and the solid-cut-cell sliver mask. The live candidate for removing the "
2282 "collocated accuracy ceiling; its two intermediate rungs are "
2283 "diagnostics.set_face_interp(5 | 6).\n"
2284 " 'plain' -- the legacy path: plain 1/2-1/2 face average + central-difference grad(P). "
2285 "FIRST order at curved cut cells (the cell gradient reads the decoupled p=0 of "
2286 "solid-centred neighbours, an O(1/h) gauge error), and on a dense bed it also fails to "
2287 "reach steady state within 800 steps at coarse resolution. Kept for reproducing "
2288 "published results.")
2289 .def(
"set_decomposition", &S::setDecomposition, nb::arg(
"levels"),
2290 nb::arg(
"max_imbalance") = 1.05,
2291 "Choose how this solver's shared MPI decomposition is built. levels=0 (DEFAULT) = the "
2292 "aligned ORB: split positions on the FINE grid are snapped to a power of two (capped "
2293 "at 16, i.e. 5 nested multigrid levels). levels >= 2 = COARSE-FIRST: build the ORB on "
2294 "the grid coarsened levels-1 times and refine the partition upward, so every block is "
2295 "a multiple of the coarsening factor BY CONSTRUCTION and the hierarchy nests for the "
2296 "full requested depth. Each candidate depth is built and MEASURED, and the deepest one "
2297 "whose max-block/min-block ratio stays within max_imbalance (default 1.05) is taken, "
2298 "else the aligned ORB. Call BEFORE init_mpi() (a later call raises), and pass the SAME "
2299 "levels / max_imbalance to flow.mpi_block() -- both derive the same partition and must "
2301 .def_prop_ro(
"decomposition_levels", &S::decompositionLevels,
2302 "This solver's decomposition mode (0 = aligned ORB, >= 2 = coarse-first with "
2304 .def_prop_ro(
"decomposition_max_imbalance", &S::decompositionMaxImbalance,
2305 "The load-imbalance budget the coarse-first search stays within.")
2306 .def(
"set_pressure_bottom_extent", &S::setPressureBottomExtent, nb::arg(
"cells"),
2307 "The threshold set_pressure_bottom('auto') applies: agglomerate the coarsest level "
2308 "onto a global operator once the coarsest GLOBAL grid exceeds this many cells on any "
2309 "AXIS (not cell count -- what a few smoother sweeps cannot fix is a mode spanning many "
2310 "cells along one axis). DEFAULT 4.")
2311 .def_prop_ro(
"pressure_bottom_extent", &S::pressureBottomExtent,
2312 "The agglomerated-bottom extent threshold (see set_pressure_bottom_extent).")
2313 .def(
"set_implicit_advection", &S::setImplicitAdvection, nb::arg(
"on"),
2314 "Use implicit-FOU advection with deferred-correction TVD.")
2315 .def(
"set_velocity_residual_tolerance", &S::setVelocityResidualTolerance, nb::arg(
"rtol"),
2316 "Residual-based momentum stop: a component's implicit solve ends once max|b - A u| <= "
2317 "rtol * max(max|b|, max|A u|) over the solved unknowns (at least one sweep always runs; "
2318 "a round-off floor stops a solve that cannot improve). rtol < 0 (DEFAULT) FOLLOWS THE "
2319 "PRESSURE SOLVER'S rtol -- the projection consumes u* and resolves the divergence the "
2320 "momentum residual leaves to its own tolerance; rtol > 0 fixes it; rtol == 0 restores "
2321 "the legacy update criterion (relative to the FIRST sweep's update, which on a "
2322 "warm-started near-steady step is noise and cost the whole sweep cap). Every path.")
2323 .def(
"velocity_residual_tolerance", &S::velocityResidualTolerance,
2324 "The momentum residual tolerance in force (resolves the follow-the-pressure default).")
2325 .def(
"set_backflow_stabilization", &S::setBackflowStab, nb::arg(
"beta"),
2326 "Outflow backflow-stabilization coefficient (Bazilevs 2009 / Esmaily-Moghadam 2011): "
2328 "in [0,1] scales the dissipative outflow term that prevents backflow divergence when "
2330 "reverses at the outlet (e.g. a separated wake / BFS recirculation). Default 0.2; 0 = "
2332 "Inert where the outlet is purely outgoing.")
2333 .def(
"set_pressure_solver_params", &S::setPressureIterations, nb::arg(
"iters"),
2334 "Set the pressure smoother iteration count.")
2336 "set_pressure_multigrid", [](S& s,
bool,
int levels) { s.setPressureLevels(levels); },
2337 nb::arg(
"on"), nb::arg(
"levels") = 4,
2338 "Set the pressure multigrid depth (levels=1 => pure RB-GS, no coarse grid).")
2339 .def(
"set_pressure_chebyshev", &S::setPressureChebyshev, nb::arg(
"on"),
2340 nb::arg(
"max_iter") = 120, nb::arg(
"rtol") = 1e-9,
2341 "Use the communication-light Chebyshev pressure accelerator. Mutually exclusive with the "
2342 "two CG drivers (on=True clears an FCG selection); on=False deselects Chebyshev and "
2343 "falls back to FCG if that is selected, otherwise to MG-PCG.")
2344 .def(
"set_pressure_telescope", &S::setPressureTelescope, nb::arg(
"on"),
2345 "Coarse-level TELESCOPING of the pressure multigrid (multi-rank). The geometric "
2346 "hierarchy coarsens a level in place, which needs every rank's block even on that axis; "
2347 "once a block turns odd the hierarchy used to STOP, leaving a coarsest grid far too "
2348 "large to solve (384^3 on 1536 ranks: blocks die at 3x6x4 with the coarsest extent "
2349 "still 48, and the pressure iteration count grows 16.6 -> 38.7 across the ladder). With "
2350 "this on, a blocked level instead merges ORB siblings onto fewer ranks (the merged "
2351 "block's origin is its parent's split value, so parity is restored), moves the residual "
2352 "down / correction up within rank groups, and the roots keep coarsening on a "
2353 "sub-communicator to a tiny bottom. Idle ranks below the telescope point cost nothing "
2354 "that matters; the iteration count becomes a property of the problem rather than the "
2355 "rank count. ON by default since 2026-09-02 (the FoxBerry ladder); False restores the "
2356 "in-place-only hierarchy, byte-identical to before telescoping existed. See "
2357 "suite/docs/archive/MG_TELESCOPING_PLAN.md.")
2358 .def(
"pressure_telescope", &S::pressureTelescope,
2359 "Whether the pressure multigrid's coarse levels telescope (merge ORB siblings onto "
2360 "fewer ranks once a level's block turns odd) rather than stop coarsening in place. "
2361 "Reads back set_pressure_telescope's current setting; ON by default since 2026-09-02 "
2362 "(the FoxBerry ladder), so this is normally True even though it was never called "
2363 "explicitly -- check it rather than assuming the legacy in-place-only hierarchy.")
2365 "set_pressure_bottom",
2366 [](S& s,
const std::string& m) {
2367 if (m ==
"auto") s.setPressureBottomMode(-1);
2368 else if (m ==
"smoother") s.setPressureBottomMode(0);
2369 else if (m ==
"agglomerated") s.setPressureBottomMode(1);
2370 else throw std::runtime_error(
"set_pressure_bottom: 'auto' | 'smoother' | 'agglomerated'");
2373 "Coarse-level (bottom) solve of the pressure multigrid. A V-cycle converges at a "
2374 "domain-independent rate only if its COARSEST level is effectively solved, and a geometric "
2375 "hierarchy cannot always get small enough: an axis stops coarsening once it turns odd, and "
2376 "under MPI once ANY rank's block turns odd -- so at fixed cells/rank the coarsest GLOBAL "
2377 "grid grows with the rank count and the bottom is progressively under-solved. "
2378 "'auto' (DEFAULT) agglomerates the coarsest level onto a global operator and solves it "
2379 "exactly whenever it exceeds set_pressure_bottom_extent (4) cells on any axis, and uses "
2380 "the cheap smoothed bottom otherwise (byte-identical to 'smoother' then). 'smoother' = "
2381 "never agglomerate (legacy). 'agglomerated' = always. Measured on one GPU (2048x64x64 "
2382 "channel): a smoothed bottom needs 13.5 pressure iterations/step at 4 levels and 6.0 at "
2383 "6, against 4.4 at full geometric depth; agglomerated it is 4.0 at BOTH depths, and "
2384 "faster in wall-clock than the deep hierarchy. Works on the cut-cell IBM and "
2385 "ghost-projection paths (per-fluid-component null-space projection, 2026-08-13).")
2386 .def(
"set_pressure_pcg", &S::setPressurePcg, nb::arg(
"on"), nb::arg(
"max_iter") = 200,
2387 nb::arg(
"rtol") = 1e-8,
2388 "Use the MG-PCG pressure accelerator (single-GPU default) and set its iteration cap and "
2389 "relative tolerance. on=True GENUINELY selects: it clears both competing selections "
2390 "(Chebyshev and FCG), so it works after diagnostics.set_density_mode / set_porous -- last set wins. "
2391 "on=False RAISES: MG-PCG is the terminal fallback of the driver dispatch, so it cannot "
2392 "be deselected on its own -- select the driver you want instead "
2393 "(set_pressure_chebyshev(True, ...) or set_pressure_fcg(True, ...)). Under "
2394 "diagnostics.set_ghost_projection the nonsymmetric operator is solved by BiCGStab; this call still "
2395 "sets that solve's cap/tolerance (they are shared) and does not change its method.")
2396 .def(
"set_pressure_fcg", &S::setPressureFcg, nb::arg(
"on"), nb::arg(
"max_iter") = 200,
2397 nb::arg(
"rtol") = 1e-8,
2398 "Use the FLEXIBLE MG-CG pressure accelerator: the same V-cycle-preconditioned CG as "
2399 "set_pressure_pcg, the same stopping estimate/mean removal, and the same cap+tolerance "
2400 "(they are shared -- it is the same solve), differing only in the beta recurrence "
2401 "(Polak-Ribiere r^T(z_{k+1}-z_k)/r^T z_k instead of Fletcher-Reeves). Costs one extra "
2402 "level-0 vector and one extra global dot per iteration; buys tolerance of a "
2403 "preconditioner that is not symmetric with respect to the fine operator. On a symmetric "
2404 "preconditioner the two betas are algebraically identical, so FCG reproduces PCG's "
2405 "iteration count. Unlike set_pressure_pcg this flag GENUINELY selects: on=True clears "
2406 "the Chebyshev selection (so it works after diagnostics.set_density_mode/set_porous), on=False "
2407 "returns to MG-PCG, and a later set_pressure_chebyshev(True, ...) wins over it.")
2408 .def(
"set_velocity_multigrid", &S::setVelocityMultigrid, nb::arg(
"on"), nb::arg(
"levels") = 4,
2409 nb::arg(
"vcycles") = 8,
2410 "Enable velocity (momentum) multigrid for the implicit diffusion solve.")
2413 [](S& s,
const std::string& face,
const std::string& type,
2414 std::array<double, 3> velocity) {
2415 s.setDomainBc(
face_index(face,
"set_domain_bc"),
2417 velocity[1], velocity[2]);
2419 nb::arg(
"face"), nb::arg(
"type"),
2420 nb::arg(
"velocity") = std::array<double, 3>{0.0, 0.0, 0.0},
2421 "Set a per-face domain BC. `face` is one of '-x', '+x', '-y', '+y', '-z', '+z'; `type` "
2422 "is 'periodic' (the default on every face), 'wall' (no-slip), 'inflow' (Dirichlet "
2423 "`velocity` -- an inlet, or a lid with a tangential velocity), 'outflow' "
2424 "(zero-gradient velocity, p = 0) or 'slip' (free-slip / symmetry plane: zero normal "
2425 "velocity, zero normal derivative of the tangential components, pressure Neumann like a "
2426 "wall; `velocity` is ignored). The TYPE must be set BEFORE the geometry (set_solid / "
2427 "set_pressure_geometry / set_solid_from_scene): it is folded into the operators when "
2428 "the geometry is built, and a call that would CHANGE it raises afterwards. A VALUE "
2429 "update on a face whose type is unchanged (ramping an inflow jet, a lid's tangential "
2430 "speed) is allowed at any time and takes effect the next step. A half-domain closed by "
2431 "a 'slip' face reproduces the full symmetric domain pointwise "
2432 "(tests/kokkos/test_freeslip). Both grids (Solver and SolverColocated).")
2434 "set_domain_bc_profile",
2435 [](S& s,
const std::string& face, nb::ndarray<double, nb::c_contig>
prof) {
2436 if (
prof.ndim() != 3 ||
prof.shape(2) != 3)
2437 throw std::runtime_error(
"profile must be (Nb,Nc,3)");
2438 const int nb_ = (int)
prof.shape(0), nc = (int)
prof.shape(1);
2439 s.setDomainBcProfile(
2440 face_index(face,
"set_domain_bc_profile"), peclet::core::python::ndarray_to_vector<double>(nb::ndarray<>(
prof)), nb_,
2443 nb::arg(
"face"), nb::arg(
"profile"),
2444 "Prescribe a per-position inlet velocity profile (Nb,Nc,3) over a face (one of '-x', "
2445 "'+x', '-y', '+y', '-z', '+z'; sets it to 'inflow'). Like set_domain_bc, the TYPE (making "
2446 "a face inflow for the first time) must be set BEFORE the geometry; updating the "
2447 "profile on a face that is ALREADY inflow is allowed after the geometry.")
2449 "set_pressure_geometry",
2450 [](S& s, nb::ndarray<double, nb::f_contig> sdf) { s.setPressureGeometry(
grid_in(sdf)); },
2452 "Set an all-fluid SDF for the cut-cell pressure operator without an immersed solid (the "
2453 "channel/BFS domain-BC path). For a no-slip immersed BODY in an inflow/outflow domain, "
2455 "set_solid(sdf, cutcell_pressure=True) instead -- do NOT also call this (a second "
2457 "setter overwrites the SDF and wipes the solid).")
2460 [](S& s, nb::ndarray<int, nb::c_contig> ni, nb::ndarray<double, nb::c_contig> nr,
2461 nb::ndarray<int, nb::c_contig> ii, nb::ndarray<double, nb::c_contig> ir,
2463 s.setScene(std::vector<int>(ni.data(), ni.data() + ni.size()),
2464 std::vector<double>(nr.data(), nr.data() + nr.size()),
2465 std::vector<int>(ii.data(), ii.data() + ii.size()),
2466 std::vector<double>(ir.data(), ir.data() + ir.size()), periodic);
2468 nb::arg(
"node_ints"), nb::arg(
"node_reals"), nb::arg(
"instance_ints"),
2469 nb::arg(
"instance_reals"), nb::arg(
"periodic") =
false,
2470 "Install an analytic geometry scene from core's flat encoding (3 ints + 16 reals per "
2471 "node, 2 ints + 18 reals per instance -- reals[17] is the centre-pinned flag; legacy 17-real "
2472 "records are accepted, reading an all-zero centre as 'follows the body'). Coordinates are "
2473 "the caller's own PHYSICAL ones when the solver was given an extent -- so ONE scene "
2474 "serves flow, dem and voro unchanged -- and CELL UNITS on the global inner grid "
2475 "otherwise (cell (i,j,k)'s centre at (i,j,k) -- half a cell from cell_centers(), which "
2476 "returns i + 1/2 in cell units). The scene is replicated on every rank, so scene-derived geometry needs no "
2477 "communication and -- unlike diagnostics.set_exact_crossings -- is NOT single-rank only. "
2478 "periodic=True treats the scene as min-image periodic over the global grid (one "
2479 "instance per body, no images); periodic=False leaves images to the caller.")
2480 .def(
"set_solid_from_scene", &S::setSolidFromScene, nb::arg(
"cutcell_pressure") =
true,
2481 "Sample the installed scene onto this rank's inner grid and install it as the solid, "
2482 "entirely on device (no nx*ny*nz float64 host round trip).")
2483 .def(
"has_scene", &S::hasScene,
2484 "True once set_scene has installed an analytic CSG scene on this solver. False for "
2485 "geometry set any other way (set_solid with a sampled SDF, set_pressure_geometry), and "
2486 "false again if no scene has been given yet. set_solid_from_scene and the "
2487 "scene-derived per-body hydrodynamic force/motion bookkeeping all require this to be "
2490 "set_instance_motion",
2491 [](S& s,
int i, std::array<double, 3> lin, std::array<double, 3> ang,
2492 std::optional<std::array<double, 3>> center) {
2493 s.setInstanceMotion(i, lin, ang, center ? center->data() :
nullptr);
2495 nb::arg(
"instance"), nb::arg(
"lin_vel") = std::array<double, 3>{0.0, 0.0, 0.0},
2496 nb::arg(
"ang_vel") = std::array<double, 3>{0.0, 0.0, 0.0},
2497 nb::arg(
"center") = nb::none(),
2498 "Rigid-body motion of one scene instance, in the scene's own coordinates per time (the "
2499 "caller's physical length with an extent, CELLS without one; the angular velocity is "
2500 "1/time either way). Any nonzero component switches the solver onto the moving-geometry "
2501 "path: the momentum operator's no-slip datum becomes the local wall velocity and the "
2502 "cut-cell projection gains the wall's own volume flux. All-zero keeps the static path, "
2503 "bit for bit. Staggered grid only in v1 (no ghost projection / porous / variable rho).")
2504 .def(
"has_moving_instance", &S::hasMovingInstance,
2505 "True when at least one scene instance carries a nonzero velocity.")
2506 .def(
"num_scene_instances", &S::sceneInstanceCount,
2507 "Number of instances in the scene set by set_scene (0 before it). Instance indices "
2508 "run 0..num_scene_instances()-1 in set_instance_transform / set_instance_motion, "
2509 "scene_owner() and the hydrodynamic force/torque accessors.")
2511 "set_instance_transform",
2512 [](S& s,
int i, std::array<double, 3> t, std::array<double, 4> q) {
2513 s.setInstanceTransform(i, t, q);
2515 nb::arg(
"instance"), nb::arg(
"translation"),
2516 nb::arg(
"quat") = std::array<double, 4>{0.0, 0.0, 0.0, 1.0},
2517 "Move one scene instance (quat as (x,y,z,w)). Takes effect at the next "
2518 "rebuild_geometry(): the SDF, the cut-cell overlay, the apertures and the pressure "
2519 "operator are all derived from the transforms, so moving one without rebuilding would "
2520 "run the solver on stale geometry.")
2521 .def(
"refresh_wall_velocity", &S::refreshWallVelocity,
2522 "Re-derive ONLY the wall-velocity fields and the momentum operator that folds them in, "
2523 "for a driver that changes an instance's VELOCITY every step while its transform stays "
2524 "put -- the linearised oscillating body, a shear cell driven by counter-moving plates. "
2525 "set_instance_motion alone does not reach those fields (they are built in "
2526 "set_solid_from_scene), so the alternative is a full rebuild_geometry() per step to "
2527 "update a boundary condition. Refuses if a transform has changed since the last "
2528 "geometry build, because it does NOT re-sample the SDF, the apertures, the ownership "
2529 "field or the pressure operator. Velocity and pressure are untouched.")
2530 .def(
"rebuild_geometry", &S::rebuildGeometry,
2531 "Re-derive all geometry from the current instance transforms. Velocity and pressure are "
2532 "PRESERVED across the rebuild (set_solid zeroes them by design). Full rebuild: the "
2533 "measured cost is ~65% momentum/IBM stencils, ~35% pressure/MG, scene sampling in the "
2534 "noise. Cells uncovered by the motion inherit zero, not an extrapolated fluid value.")
2536 "hydro_force_torque_reaction",
2538 std::vector<double> v = s.hydroForceTorqueReaction();
2539 const std::size_t n = v.size() / 6;
2540 return peclet::core::python::vector_to_ndarray(
2541 std::move(v), {2, n, 3},
2542 {
static_cast<std::int64_t
>(3 * n), 3, 1});
2544 "THE hydrodynamic force and torque on each scene instance: the DISCRETE REACTION -- "
2545 "the momentum the fluid actually lost to each body, assembled from the composed step's "
2546 "budget (time term, body force, and the viscous fluxes at u*; the pressure telescopes "
2547 "inside each owner region and needs no field). Shape (2, n_instances, 3): [0] force, "
2548 "[1] torque about the instance centre. The force is exactly conservative (sum = f * "
2549 "V_fluid at steady state, to the solver residual) and as accurate as the flow solution "
2550 "it sustains: Zick & Homsy's drag within 0.7 % at N = 24 (tests/python/"
2551 "test_hydro_force_units.py). The torque carries the closed-form transposed-stress wall "
2552 "term; measured 2026-09-30 on a steadily spinning sphere it reads 1.019-1.027 of "
2553 "8 pi mu a^3 Omega / (1 - phi) at R/h 6-12 (over-reads ~2 %, falling with "
2554 "resolution), and on a translating sphere whose true torque is zero it is zero to "
2555 "1e-15 on a symmetric placement and |T| / (|F| R) = 5e-5 to 1e-3 off it. "
2556 "diagnostics.hydro_force_torque_traction() is the surface-traction estimate: ~30 % "
2557 "low, but it carries the pressure/viscous split. Staggered only; EXPLICIT advection "
2558 "is carried (the stashed advective RHS term is subtracted); implicit advection / "
2559 "porous / variable properties / domain BCs are refused loudly (v2); call after "
2560 "step(). Atomics: tolerance-reproducible, not bitwise.")
2562 "hydro_force_torque",
2567 if (PyErr_WarnEx(PyExc_DeprecationWarning,
2568 "hydro_force_torque() is deprecated: it returns the traction "
2569 "integral, which under-reads the drag by ~30 % at any resolution. "
2570 "For the force and torque on the bodies call "
2571 "hydro_force_torque_reaction(); for the traction and its "
2572 "pressure/viscous split, diagnostics.hydro_force_torque_traction().",
2574 throw nb::python_error();
2575 return traction_force_torque_array<Grid>(s);
2577 "DEPRECATED (warns) -- the canonical names are hydro_force_torque_reaction() for the "
2579 "torque on the bodies, and diagnostics.hydro_force_torque_traction() for what this "
2580 "returns. It is NOT the force on the bodies: it is the reconstructed surface-traction "
2581 "integral, which under-reads the drag by ~30 % at any resolution (the viscous part; "
2582 "traction / reaction 0.685-0.730 over phi 0.008-0.45, N 24-128). Returns exactly what "
2583 "diagnostics.hydro_force_torque_traction() returns (shape (4, n_instances, 3): force, "
2584 "torque, pressure part, viscous part); see that method for the details. Where the "
2585 "reaction refuses (SolverColocated, porous, variable properties, domain BCs, implicit "
2586 "advection) the traction is the only estimate there is -- call it by its diagnostic "
2587 "name and allow for the under-read. Kept working under the suite's rule for a shipped "
2588 "name (docs/NAMING.md section 0); removal needs a major release.")
2589 .def(
"instance_center", &S::instanceCenter, nb::arg(
"i"),
2590 "The resolved centre of rotation of instance i (world coordinates).")
2593 [](S& s, nb::ndarray<double, nb::f_contig> sdf,
bool cutcell_pressure) {
2594 s.setSolid(
grid_in(sdf), cutcell_pressure);
2596 nb::arg(
"sdf"), nb::arg(
"cutcell_pressure") =
false,
2597 "Set the solid SDF as a Fortran-order (nx,ny,nz) float64 array sampled at cell_centers() "
2598 "(negative inside the solid, positive in fluid, in the solver's length unit). "
2599 "cutcell_pressure=True enables the open-face-weighted cut-cell pressure operator "
2600 "(proper no-slip); it composes with domain BCs, so this is the single call for a "
2601 "no-slip immersed body in an inflow/outflow domain. The multigrid coarse operators are "
2602 "always the rediscretized per-level cut-cell operators (the former `pressure_coarse=` "
2603 "mode selector is gone).")
2606 [](S& s, nb::ndarray<double, nb::f_contig> u, nb::ndarray<double, nb::f_contig> v,
2607 nb::ndarray<double, nb::f_contig> w) {
2610 nb::arg(
"u"), nb::arg(
"v"), nb::arg(
"w"),
2611 "Upload an initial velocity field (u,v,w each a Fortran-order (nx,ny,nz) float64 array).")
2612 .def(
"step", &S::step,
2613 "Advance the solver one time step (semi-implicit: diffusion + projection).\n\n"
2614 "ATOMIC across the two explicit two-phase stability caps: the Weymouth-Yue "
2615 "boundedness cap and the Brackbill capillary limit are evaluated at the HEAD of the "
2616 "call, so a rejected dt leaves every field bitwise as it was and a retry at a smaller "
2617 "dt is exact. (Before 2026-09-04 the momentum half had already advanced by the "
2618 "rejected dt, so catch-and-halve desynchronised colour and momentum.) The WY "
2619 "pre-check reads the field the call starts from; where the projection itself "
2620 "accelerates the field inside the step, use step_adaptive().")
2623 [](S& s,
double cfl,
double capCfl,
double dtMax) {
2624 return s.stepAdaptive(cfl, capCfl, dtMax);
2626 nb::arg(
"cfl_target") = 0.4, nb::arg(
"capillary_cfl") = 0.4,
2627 nb::arg(
"dt_max") = std::numeric_limits<double>::infinity(),
2628 "One step at a dt re-picked from the CURRENT state; returns the dt used.\n\n"
2629 "dt = min(cfl_target * cfl_dt, capillary_cfl * capillary_dt, dt_max) with both limits "
2630 "taken from vof_step_limits() at the moment of the call, then set_dt(dt) + step(). "
2631 "Needs enable_vof().\n\n"
2632 "WHY: both limits are INSTANTANEOUS. Re-picking every ten steps is safe for a free "
2633 "bubble or a falling film and is NOT safe in a packing -- a throat jet can grow "
2634 "max|uf| by more than 50 %% inside ten steps (measured, examples/bubble-through-packing) "
2635 "and the WY cap is a hard throw; and capillary_dt itself reads the density field, so a "
2636 "value taken before the closures have run can be 7x too large (measured, "
2637 "examples/pore-scale-imbibition). This is bitwise the "
2638 "`dt = 0.4*min(L['cfl_dt'], L['capillary_dt']); s.set_dt(dt); s.step()` loop the "
2639 "gallery drivers write by hand (IEEE multiplication is monotone, so "
2640 "f*min(a,b) == min(f*a,f*b)). set_dt/step semantics are unchanged.")
2643 [](S& s,
int c, nb::ndarray<double, nb::f_contig> a) { s.setVelocity(c,
grid_in(a)); },
2644 nb::arg(
"c"), nb::arg(
"array"),
2645 "Write component c's inner velocity from a Fortran-order (nx,ny,nz) float64 array "
2646 "(solid rows re-masked). Initial-condition hook: a uniform stream around a fixed body "
2647 "is the Galilean twin of the same body towed through fluid at rest.")
2649 "get_u", [](S& s) {
return field_out(s, s.getVelocity(0)); },
2650 "Return the x-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
2653 "get_v", [](S& s) {
return field_out(s, s.getVelocity(1)); },
2654 "Return the y-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
2657 "get_w", [](S& s) {
return field_out(s, s.getVelocity(2)); },
2658 "Return the z-velocity component as a Fortran-order (nx,ny,nz) float64 array (index "
2661 "get_p", [](S& s) {
return field_out(s, s.getPressure()); },
2662 "Return the physical pressure as a Fortran-order (nx,ny,nz) float64 array (index "
2665 "get_ox", [](S& s) {
return field_out(s, s.getOpenness(0)); },
2666 "The -x face openness field of the cut-cell operator: the fluid area fraction (0..1, "
2667 "dimensionless) of the face between cells (i-1,j,k) and (i,j,k), as a Fortran-order "
2668 "(nx,ny,nz) float64 array over THIS rank's inner cells. Computed by set_solid from the "
2669 "SDF (1 everywhere without a solid); it is what the momentum operator and the geometric "
2670 "cut-cell projection see. get_ox_proj is the openness the PROJECTION actually conserves "
2671 "fluxes through, which differs under diagnostics.set_ghost_projection (binary/coupled faces).")
2673 "get_oy", [](S& s) {
return field_out(s, s.getOpenness(1)); },
2674 "The geometric -y face openness per inner cell, (nx,ny,nz); see get_ox.")
2676 "get_oz", [](S& s) {
return field_out(s, s.getOpenness(2)); },
2677 "The geometric -z face openness per inner cell, (nx,ny,nz); see get_ox.")
2679 "get_ox_proj", [](S& s) {
return field_out(s, s.getOpennessProj(0)); },
2680 "-x face openness whose fluxes the projection CONSERVES (binary/COUPLED under "
2681 "diagnostics.set_ghost_projection, geometric cut-cell otherwise). Use for flux bookkeeping "
2682 "(peclet.pnm extract_network_flow).")
2684 "get_oy_proj", [](S& s) {
return field_out(s, s.getOpennessProj(1)); },
2685 "-y face openness the projection conserves (see get_ox_proj).")
2687 "get_oz_proj", [](S& s) {
return field_out(s, s.getOpennessProj(2)); },
2688 "-z face openness the projection conserves (see get_ox_proj).")
2690 "get_uf", [](S& s) {
return field_out(s, s.getFaceVelocity(0)); },
2691 "Return the divergence-free FACE x-velocity (collocated: projected MAC field; staggered: "
2694 "get_vf", [](S& s) {
return field_out(s, s.getFaceVelocity(1)); },
2695 "Return the divergence-free FACE y-velocity (collocated: projected MAC field; staggered: "
2698 "get_wf", [](S& s) {
return field_out(s, s.getFaceVelocity(2)); },
2699 "Return the divergence-free FACE z-velocity (collocated: projected MAC field; staggered: "
2703 "add_field", [](S& s,
const std::string& name) { s.addField(name); }, nb::arg(
"name"),
2704 "Register a new zero-initialised cell-centred field on the grid (for transported scalars "
2705 "or material properties). Idempotent.")
2707 "has_field", [](S& s,
const std::string& name) {
return s.hasField(name); },
2708 nb::arg(
"name"),
"Whether a field of this name is registered.")
2710 "field_names", [](S& s) {
return s.fieldNames(); },
2711 "Names of all registered fields (velocity u/v/w, p, sdf, plus any added), sorted.")
2713 "get_field", [](S& s,
const std::string& name) {
return field_out(s, s.getField(name)); },
2715 "Return a registered field's inner region as a Fortran-order (nx,ny,nz) float64 array.")
2718 [](S& s,
const std::string& name, nb::ndarray<double, nb::f_contig> a) {
2721 nb::arg(
"name"), nb::arg(
"array"),
2722 "Write a Fortran-order (nx,ny,nz) float64 array into a registered field's inner region "
2723 "(ghosts refilled on the next diagnostics.exchange_field/step).")
2727 [](S& s,
const std::string& name,
double diffusivity,
const std::string& scheme,
2728 std::optional<int> iters,
bool cutcell) {
2729 if (cutcell && iters)
2730 throw std::invalid_argument(
2731 "add_scalar: iters must be None for a cut-cell scalar (it solves to a residual "
2732 "tolerance, set_scalar_tolerance)");
2733 s.addScalar(name, diffusivity,
2735 iters ? *iters : 50, cutcell);
2737 nb::arg(
"name"), nb::arg(
"diffusivity") = 0.0, nb::arg(
"scheme") =
"koren",
2738 nb::arg(
"iters") = nb::none(), nb::arg(
"cutcell") =
false,
2739 "Register a transported scalar (temperature/concentration/...): constant diffusivity "
2740 "(length^2/time, in the solver's physical units -- cells^2 per time unit without an "
2741 "extent), advection scheme 'fou' (first-order upwind), 'koren' (Koren TVD, the "
2742 "default) or 'sou' (second-order upwind), and RB-GS diffusion sweeps (iters, default "
2743 "50). The scalar is a registered field (get_field/set_field/diagnostics.field_view). "
2744 "Requires geometry (set_solid/set_pressure_geometry) for the openness-weighted "
2746 "cutcell=True selects the cut-cell discretization (doc/scalar_ibm_design.md): kappa "
2747 "(fluid fraction) storage, the scalar's own apertures, wall conditions per body "
2748 "(set_scalar_wall, default insulating), sources (set_scalar_source), BiCGStab to a "
2749 "residual tolerance (set_scalar_tolerance; iters must then be None), a steady solve "
2750 "(solve_scalar_steady) and per-body wall fluxes (scalar_wall_flux). Cells that are not "
2751 "fluid unknowns hold 0. Advection is not yet part of the cut-cell operator.")
2754 [](S& s,
const std::string& name,
const std::string& face,
const std::string& type,
2756 s.setScalarBc(name,
face_index(face,
"set_scalar_bc"),
2759 nb::arg(
"name"), nb::arg(
"face"), nb::arg(
"type"), nb::arg(
"value") = 0.0,
2760 "Scalar boundary condition on a domain face ('-x', '+x', '-y', '+y', '-z', '+z'): "
2761 "type 'periodic', 'neumann' (zero flux, adiabatic) or 'dirichlet' (the given value, in "
2762 "the scalar's own units: the solver never rescales a transported scalar). Single-rank "
2763 "for a legacy scalar; a cut-cell scalar takes it under MPI too, and its periodic / "
2764 "non-periodic faces must match set_domain_bc (checked at the first advance). For a "
2765 "cut-cell scalar `value` may also be a per-face profile (next overload).")
2768 [](S& s,
const std::string& name,
const std::string& face,
const std::string& type,
2769 nb::ndarray<
double, nb::ndim<2>, nb::device::cpu> value) {
2771 throw std::invalid_argument(
"set_scalar_bc: a profile is a 'dirichlet' value");
2772 const int n1 =
static_cast<int>(value.shape(0)), n2 =
static_cast<int>(value.shape(1));
2773 std::vector<double>
prof(
static_cast<std::size_t
>(n1) * n2);
2774 auto v = value.view();
2775 for (
int j2 = 0; j2 < n2; ++j2)
2776 for (
int j1 = 0; j1 < n1; ++j1)
2777 prof[
static_cast<std::size_t
>(j1) +
static_cast<std::size_t
>(j2) * n1] = v(j1, j2);
2778 s.setScalarBcProfile(name,
face_index(face,
"set_scalar_bc"),
prof, n1, n2);
2780 nb::arg(
"name"), nb::arg(
"face"), nb::arg(
"type"), nb::arg(
"value"),
2781 "Cut-cell scalars: a Dirichlet domain face with a per-face PROFILE, shape (N_t1, N_t2) "
2782 "over this rank's face cells in the face's tangential axes in x, y, z order (-x/+x: "
2783 "(ny, nz); -y/+y: (nx, nz); -z/+z: (nx, ny)), as set_domain_bc_profile. The value is "
2784 "the scalar at the boundary face centre.")
2787 [](S& s,
const std::string& name,
const std::string& type,
double value,
2788 double coefficient, std::optional<int> instance) {
2790 value, coefficient, instance ? *instance : -1);
2792 nb::arg(
"name"), nb::arg(
"type"), nb::arg(
"value") = 0.0, nb::arg(
"coefficient") = 0.0,
2793 nb::arg(
"instance") = nb::none(),
2794 "Cut-cell scalars: the condition on the immersed walls of one scene instance "
2795 "(instance=int, needs a scene) or of every body (instance=None, which also clears the "
2796 "per-instance settings). 'neumann': value = the flux INTO the fluid per area (c L/T; 0, "
2797 "the default for every body, is insulating). 'dirichlet': value = the wall "
2798 "concentration. 'robin': flux into the fluid = coefficient * (value - c_wall), "
2799 "coefficient = k (L/T, >= 0; a first-order surface reaction is k_r with value 0). "
2800 "Physical units under an extent; takes effect at the next advance or steady solve.")
2803 [](S& s,
const std::string& name,
double diffusivity,
double capacity,
double partition,
2804 double contact_resistance, std::optional<int> instance) {
2805 s.setScalarSolid(name, diffusivity, capacity, partition, contact_resistance,
2806 instance ? *instance : -1);
2808 nb::arg(
"name"), nb::arg(
"diffusivity"), nb::arg(
"capacity") = 1.0,
2809 nb::arg(
"partition") = 1.0, nb::arg(
"contact_resistance") = 0.0,
2810 nb::arg(
"instance") = nb::none(),
2811 "Cut-cell scalars, conjugate transport (doc/scalar_ibm_design.md §1.1): the solid "
2812 "interior of one scene instance (instance=int, needs a scene) or of every body "
2813 "(instance=None, which also clears the per-instance settings) carries the scalar too. "
2814 "diffusivity = D_s (L^2/T, the solid's own, >= 0), capacity = C_s (> 0; heat: "
2815 "(rho c_p)_s/(rho c_p)_f, a porous particle: its porosity), partition = K (> 0; c_s = K c "
2816 "at equilibrium), contact_resistance = R_c (T/L, >= 0; the interface flux is "
2817 "(c_s/K - c)/R_c). The solid field is the registered field '<name>_solid' (it holds "
2818 "c_s/K, internal; get_scalar_solid returns c_s). A later set_scalar_wall for the same "
2819 "instance makes it a wall again (last call wins). Takes effect at the next advance or "
2823 [](S& s,
const std::string& name) {
return field_out(s, s.getScalarSolid(name)); },
2825 "Cut-cell scalars: a copy of the solid concentration c_s = K psi_s of a conjugate scalar, "
2826 "(nx, ny, nz) F-order on this rank's block, NaN outside the solid unknowns (all NaN before "
2827 "the first advance or steady solve with a conjugate body).")
2829 "set_scalar_source",
2830 [](S& s,
const std::string& name,
double source) { s.setScalarSource(name,
source); },
2831 nb::arg(
"name"), nb::arg(
"source"),
2832 "Cut-cell scalars: a uniform volumetric source S (c/T, physical), weighted by the fluid "
2835 "set_scalar_source",
2836 [](S& s,
const std::string& name, nb::ndarray<double, nb::f_contig>
source) {
2839 nb::arg(
"name"), nb::arg(
"source"),
2840 "Cut-cell scalars: a per-cell volumetric source S (c/T, physical), an (nx, ny, nz) "
2841 "F-order array on this rank's block, weighted by kappa.")
2843 "set_scalar_tolerance",
2844 [](S& s,
const std::string& name,
double rtol) { s.setScalarTolerance(name, rtol); },
2845 nb::arg(
"name"), nb::arg(
"rtol"),
2846 "Cut-cell scalars: the max-norm relative residual tolerance of the solve, "
2847 "max|b - A c| <= rtol * max(max|b|, max|A c0|) (default 1e-10).")
2849 "set_scalar_mean_gradient",
2850 [](S& s,
const std::string& name,
const std::array<double, 3>& gradient) {
2851 s.setScalarMeanGradient(name, gradient);
2853 nb::arg(
"name"), nb::arg(
"gradient"),
2854 "Cut-cell scalars: the closure (mean-gradient) mode. The field then holds the periodic "
2855 "fluctuation theta of c = G.x + theta, written in the frame moving with the fluid's mean "
2856 "velocity; G = `gradient` (c/L, physical; x the cell_centers() coordinates). Every axis "
2857 "with a nonzero component must be periodic (checked at the next advance or solve). "
2858 "(0, 0, 0) turns it off. Read the result with scalar_mean_flux.")
2861 [](S& s,
const std::string& name) {
2862 const auto j = s.scalarMeanFlux(name);
2863 return peclet::core::python::vector_to_ndarray(std::vector<double>(j.begin(), j.end()),
2867 "Cut-cell scalars: the box-averaged total flux vector (3,), c L/T (physical), of the "
2868 "last advance or steady solve: the diffusive face sum of -D a A (c_j - c_i) with "
2869 "c = theta + G.x (the face's true displacement), plus the advective (1/V_box) sum_i "
2870 "(U_i - kappa_i V Ubar) theta_i in the moving frame, V_box the whole box (solid "
2871 "included). Exact as a plane average for steady fields without wall sources. Effective "
2872 "diffusivity k* = -J.G/(D|G|^2); dispersion D* = -J/(phi G), phi the discrete porosity. "
2873 "Collective under MPI.")
2875 "solve_scalar_steady",
2876 [](S& s,
const std::string& name) { s.solveScalarSteady(name); }, nb::arg(
"name"),
2877 "Cut-cell scalars: solve the steady problem now (no storage term) from the current "
2878 "field as the initial guess. Statistics in diagnostics.scalar_census.")
2881 [](S& s,
const std::string& name) {
2882 std::vector<double> v = s.scalarWallFlux(name);
2883 const std::size_t n = v.size();
2884 return peclet::core::python::vector_to_ndarray(std::move(v), {n}, {1});
2887 "Cut-cell scalars: the integrated flux INTO the fluid per body over the last advance or "
2888 "steady solve, shape (num_bodies,) (one body without a scene), in c L^3/T (physical). "
2889 "Collective under MPI.")
2891 "has_scalar", [](S& s,
const std::string& name) {
return s.hasScalar(name); },
2892 nb::arg(
"name"),
"Whether a transported scalar of this name is registered.")
2894 "advance_scalars", [](S& s) { s.advanceScalars(); },
2895 "Advance all registered scalars one dt with the current velocity (also done by step()).")
2898 "enable_vof", [](S& s) { s.enableVof(); },
2899 "Enable the geometric (PLIC + Weymouth-Yue) VoF colour field 'C'. Registers 'C' as an "
2900 "ordinary cell field (get_field/set_field/diagnostics.field_view/closure input) and allocates the "
2901 "colour field's OWN g=3 working block + halo; the solver's G=2 is untouched. C is "
2902 "advected at the end of every step() with the just-projected face velocities.\n\n"
2903 "RUNG V2a SCOPE — read before using: there is NO surface tension (rung V4) and NO "
2904 "momentum-consistent transport (rung V2b). Mass and momentum are advected by different "
2905 "fluxes, so a mixed cell carries a spurious interfacial momentum source of order "
2906 "delta-rho; the literature (Rudman 1998, Arrufat 2021) is unambiguous that this breaks "
2907 "down around density ratio 1000 unless the resolution is absurd. **V2a is valid only at "
2908 "MODEST density ratios** for cases with motion. A high-ratio case AT REST (the "
2909 "hydrostatic acid test) is exact, because there is no momentum to mis-advect. "
2910 "COLLOCATED (SolverColocated) is supported since rung V8 (2026-09-02), ALL-FLUID only: "
2911 "the colour is advected by the PROJECTED face field uf_/vf_/wf_ (the field the ABC "
2912 "approximate projection makes exactly divergence-free, which is what Weymouth-Yue's "
2913 "conservation proof needs), and the CSF enters the implicit momentum predictor as the "
2914 "mass-adjoint face integral (doc/collocated_varrho_forces.md). An immersed solid on that "
2916 "(the cut-cell colour transport of rung V5a is staggered-only), and so does "
2917 "enable_vof_momentum. On the collocated grid the SPURIOUS-CURRENT number to read is the "
2918 "FACE field (get_uf/get_vf/get_wf): the CELL field additionally carries the approximate "
2919 "projection's invisible odd-even mode, which centerToFace annihilates and the projection "
2920 "therefore cannot remove. An IMMERSED SOLID is supported since "
2921 "rung V5a: the geometric fluxes are openness-weighted and the update is done in "
2922 "fluid-volume units, so sum eps_eff*C is conserved exactly against the projection's own "
2923 "openness-weighted divergence; it needs set_solid(..., cutcell_pressure=True) (the "
2924 "staircase operator has no face openness to weight with) and it approximates the "
2925 "solid-clipped flux polygon by (whole-cell PLIC slab) x (open area) — see "
2926 "diagnostics.vof_diagnostics()['clipped_volume'].")
2929 [](S& s, nb::ndarray<double, nb::f_contig> a) { s.setVof(
grid_in(a)); }, nb::arg(
"array"),
2930 "Set the colour field from a Fortran-order (nx,ny,nz) float64 array: C = the LIQUID "
2931 "volume fraction of the cell, in [0,1]. Enables VoF if it is not on yet. Initialise it "
2932 "SHARP (0/1 with the exact fraction in the interface cells) — geometric VoF keeps a "
2933 "sharp interface and a diffuse initial profile is simply a different problem.")
2935 "get_vof", [](S& s) {
return field_out(s, s.getVof()); },
2936 "The colour field's inner region as a Fortran-order (nx,ny,nz) float64 array "
2937 "(== get_field('C')).")
2939 "vof_max_courant", [](S& s) {
return s.vofMaxCourant(); },
2940 "INTERFACE-LOCAL Courant number max|uf|*dt/h over the faces of mixed cells and their "
2941 "face neighbours, with the current velocity and dt (all-reduce max under MPI). This — "
2942 "not the global max — is the number Weymouth's boundedness bound applies to: a global "
2943 "max over-throttles badly (measured on Zalesak: 0.314 at a quiescent domain corner while "
2944 "the interface never exceeded 0.157). Pick dt as dt*cfl_target/vof_max_courant().")
2946 "set_vof_cfl_limit", [](S& s,
double v) { s.setVofCflLimit(v); }, nb::arg(
"value"),
2947 "Weymouth-Yue boundedness cap on the interface-local Courant number. Default 0.25 — the "
2948 "PROVEN 3D bound 1/(2(N-1)) from Weymouth's thesis eq. A.33; the widely-quoted 0.5 is "
2949 "the 2D value. step() throws when it is exceeded. Conservation is INDEPENDENT of "
2950 "boundedness (the telescoping is algebraic), so an over-CFL run loses 0<=C<=1, not "
2953 "vof_cfl_limit", [](S& s) {
return s.vofCflLimit(); },
2954 "The Weymouth-Yue boundedness cap currently in force.")
2957 "advect_vof", [](S& s,
double dt) { s.advectVofKinematic(dt); }, nb::arg(
"dt"),
2958 "KINEMATIC colour advection: advance C ONCE with the solver's CURRENT face velocity over "
2959 "`dt` (seconds), with NO Navier-Stokes step. This is the advection-benchmark entry point "
2960 "(Zalesak, LeVeque, and the cut-cell conservation gates): a frozen projected velocity "
2961 "advecting a colour field is a pure statement about the transport scheme, with the "
2962 "momentum and pressure solves out of the picture.\\n\\n"
2963 "It THROWS if the current velocity is not discretely divergence-free to 1e-10, measured "
2964 "with max_open_divergence_projected() (the NON-mutating diagnostic). That is not a "
2965 "nicety: Weymouth-Yue's exact conservation is CONDITIONAL on sum_f o_f u_f = 0 per cell, "
2966 "because the dilation term adds H(C-1/2) times that residual to every full cell's volume "
2967 "budget. Run step() to a steady state (or project()) and advect with the solver's own "
2968 "output; never with an analytic sample, which is solenoidal only to O(h^2).\n\n"
2969 "It ALSO throws when no cut-cell pressure operator exists (WO-R2): without one the "
2970 "divergence guard measures nothing at all — max_open_divergence() returns 0.0 — and a "
2971 "cell-centre-sampled LeVeque field (true max|div| 0.612) was silently accepted and lost "
2972 "4.93 % of the liquid in 50 steps. Call set_pressure_geometry(sdf) on an all-fluid box, "
2973 "or set_solid(sdf, cutcell_pressure=True).")
2975 "set_contact_angle", [](S& s,
double deg) { s.setContactAngle(deg); }, nb::arg(
"theta"),
2976 "Rung V5b (WO-S). Prescribe a STATIC contact angle, in DEGREES, measured THROUGH THE "
2977 "LIQUID, on every immersed SDF wall. Needs set_solid(..., cutcell_pressure=True) + "
2978 "enable_vof; with no call the wall stays at the neutral 90-degree fill of rung V5a and "
2979 "every V5a number is byte-identical.\n\n"
2980 "What it changes is PASS 1 of the solid-band fill and nothing else: the band cells "
2981 "receive the volume fractions of the plane that continues the fluid-side interface into "
2982 "the solid at angle theta (m . n_w = cos theta, with n_w = grad(sdf)/|grad(sdf)| the "
2983 "solid->fluid wall normal and m the PLIC normal pointing into the gas). The unmodified V3 "
2984 "height-function/MYC cascade then returns the curvature of an interface meeting the wall "
2985 "at theta and the V4 balanced force does the rest — NO force is added at the wall. "
2986 "theta = 0 fills the band with liquid (complete wetting), theta = 180 empties it.\n\n"
2987 "Construction (src/vof/wetting.hpp): the anchor fluid cell is found by walking along n_w; "
2988 "its fluid-only Youngs normal supplies the AZIMUTH of the contact line (its in-wall "
2989 "component, which a fluid-restricted stencil measures to ~1e-14 on a plane) while the "
2990 "angle to the wall is replaced by the prescribed one — the wall-normal component of a "
2991 "fluid-only estimator is unusable (measured 23 deg of error at theta = 30 against 2.3 "
2992 "for the full stencil, gate G0e). The plane is then anchored by matching the anchor "
2993 "cell's own liquid volume, which makes the fill EXACTLY idempotent: an interface that "
2994 "already meets the wall at theta is reproduced to 1e-15 (gate G0a), so theta is a fixed "
2995 "point of the scheme.")
2997 "set_contact_angle_field",
2998 [](S& s, nb::ndarray<double, nb::f_contig> a) { s.setContactAngleField(
grid_in(a)); },
3000 "Per-cell static contact angle in DEGREES, as a Fortran-order (nx,ny,nz) array. Only the "
3001 "value AT the solid band cell being filled is read, so cells away from a wall are "
3002 "irrelevant. theta is a field so the dynamic-angle rung (V6) changes only what fills it.")
3004 "contact_angle", [](S& s) {
return s.contactAngle(); },
3005 "The prescribed static contact angle in degrees (90 if none was set).")
3007 "set_contact_angle_dynamic",
3008 [](S& s,
bool enabled,
double th,
double slip,
double mu,
double sigma) {
3010 s.setContactAngleDynamicOff();
3013 if (!(slip > 0.0 && slip < 1.0) || !(mu > 0.0))
3014 throw std::invalid_argument(
3015 "set_contact_angle_dynamic(True, ...): slip_length_cells must lie in (0, 1) and "
3016 "mu_liquid must be positive");
3017 s.setContactAngleDynamic(th, slip, mu, sigma);
3019 nb::arg(
"enabled"), nb::arg(
"theta_e") = 90.0, nb::arg(
"slip_length_cells") = 0.0,
3020 nb::arg(
"mu_liquid") = 0.0, nb::arg(
"sigma") = 0.0,
3021 "Rung V6 (WO-V6). The DYNAMIC contact angle -- `enabled=False` turns it (and the "
3022 "hysteresis) off so the static angle stands again; `enabled=True` imposes at the grid "
3023 "scale the Cox-Voinov apparent angle of a contact line moving at speed U_cl, with the "
3024 "outer cut-off at the CELL SIZE and an EXPLICIT slip length lambda (Afkhami, Zaleski & "
3025 "Bussmann, JCP 228:5370, 2009):\n\n"
3026 " theta_Delta^3 = theta_e^3 + 9 Ca_cl ln(Delta/lambda), Ca_cl = mu_l U_cl / sigma\n\n"
3027 "angles in radians internally, arguments in DEGREES; Ca_cl > 0 advancing, < 0 receding; "
3028 "theta_Delta is clamped into [1, 179] degrees (diagnostics.set_contact_angle_clamp).\n\n"
3029 "`slip_length_cells` is lambda/Delta and must lie in (0, 1). It is not a tuning knob "
3030 "you may omit from a report: a VoF contact line's NUMERICAL slip is proportional to the "
3031 "cell size, so without an explicit lambda the imposed angle is silently grid-dependent "
3032 "(VOF_PLAN section 6). ALWAYS state lambda alongside a dynamic-wetting result.\n\n"
3033 "`mu_liquid` is the liquid dynamic viscosity entering Ca_cl (solver units); `sigma` "
3034 "defaults to whatever set_surface_tension holds. theta_e also becomes the static base "
3035 "angle, so this call replaces set_contact_angle. U_cl is measured as -u.t_hat at the "
3036 "anchor fluid cell of each band cell, with t_hat the IN-WALL direction of the fluid-side "
3037 "PLIC normal (m points into the gas, so the liquid advances along -t_hat), then smoothed "
3038 "with a 3-point mean along t_hat. Needs set_solid(..., cutcell_pressure=True) + "
3039 "enable_vof; with no call every V5b number is byte-identical.")
3041 "set_contact_angle_hysteresis",
3042 [](S& s,
double a,
double r) { s.setContactAngleHysteresis(a, r); },
3043 nb::arg(
"theta_a"), nb::arg(
"theta_r"),
3044 "Rung V6 (WO-V6). Advancing / receding contact-angle hysteresis, in DEGREES. While the "
3045 "measured apparent angle lies in [theta_r, theta_a] the contact line is PINNED and the "
3046 "fill imposes the APPARENT angle itself — which reproduces the current interface exactly, "
3047 "because the V5b fill is idempotent (WO-S finding 1), so no Young force appears and the "
3048 "line does not move. Above theta_a the advancing angle is imposed and below theta_r the "
3049 "receding one, each with the Cox-Voinov correction of set_contact_angle_dynamic when "
3050 "that is also configured. Sets the static base to (theta_a+theta_r)/2 if no angle was "
3053 "set_wall_slip_length", [](S& s,
double lam) { s.setWallSlipLength(lam); },
3054 nb::arg(
"lambda_cells"),
3055 "Rung V6b (WO-V6b) - the VELOCITY half of the dynamic contact line. In the Robust-Scaled "
3056 "cut-cell IBM closure the TANGENTIAL wall datum stops being no-slip and becomes the "
3057 "NAVIER condition\n\n"
3058 " u_t(wall) - u_body = lambda * d(u_t)/dn\n\n"
3059 "with `lambda_cells` the slip length in the caller's own units (CELLS without a physical "
3060 "domain, where the cell size is 1); the model needs lambda < one cell. "
3061 "The wall-NORMAL component is untouched: the wall stays impermeable, with the moving-body "
3062 "velocity as its datum. 0 (the default) restores the validated no-slip closure "
3063 "BIT-IDENTICALLY - the closure polynomials are the lambda = 0 members of the same "
3064 "family, and the lambda > 0 branch is never entered.\n\n"
3065 "It is ONE lambda with set_contact_angle_dynamic: either call overwrites the shared "
3066 "value (last call wins), so the Cox-Voinov inner cut-off and the momentum wall closure "
3067 "cannot disagree. Only this call switches the MOMENTUM half on, so every WO-V6 result "
3068 "taken with the angle half alone is unchanged.\n\n"
3069 "Why it exists, measured: with no-slip the contact line advances only at the scheme's "
3070 "own numerical slip, ~1/180 of Lucas-Washburn (WO-V6 finding 6), and WO-V7's pore-scale "
3071 "campaign found every IMBIBITION verdict inverted below the capillary number at which "
3072 "the imposed velocity crosses that numerical slip velocity. Typical values are 0.01-0.5 "
3073 "cells; state lambda with every dynamic-wetting result. Staggered grids only; needs "
3074 "set_solid(..., cutcell_pressure=True). Changing it rebuilds the three cut-cell overlays "
3075 "and the momentum operator in place (the velocity field is NOT reset).\n\n"
3076 "APPROXIMATIONS, both stated because they are invisible from the call: the tangential "
3077 "projector is taken DIAGONAL (the cross terms -n_c n_j u_j are dropped; they vanish "
3078 "exactly for an axis-aligned wall and are O(n_c n_j) otherwise), and an axis whose BOTH "
3079 "neighbours are solid (a one-cell fluid gap) keeps the no-slip closure - counted by "
3080 "diagnostics.wall_slip_sandwich_cells().")
3082 "wall_slip_length", [](S& s) {
return s.wallSlipLength(); },
3083 "The Navier slip length in force, in the caller's units (0 = no-slip).")
3085 "vof_has_geometry", [](S& s) {
return s.vofHasGeometry(); },
3086 "True when the colour advection is running the CUT-CELL (openness-weighted) kernels, "
3087 "i.e. an immersed solid is present and set_solid ran with cutcell_pressure=True. False "
3089 "the uncut rung-V1 kernels are running, byte-identically to a solid-free build.")
3092 "enable_vof_blocks",
3093 [](S& s, std::optional<std::vector<std::array<double, 4>>> seeds) {
3095 s.enableVofBlocks(*seeds);
3097 s.disableVofBlocks();
3099 nb::arg(
"seeds") = nb::none(),
3100 "Carry each bubble on its OWN VoF block (the TBFsolver vofBlock pattern, VOF_PLAN §10): "
3101 "one Weymouth-Yue advector per marker on a small moving global index box (bubble extent "
3102 "+ 3 cells, its own g = 3 halo on top) with a master rank of its own, and the registered "
3103 "'C' the closures see is the UNION C = max_blocks C_block.\n\n"
3104 "WHY: two bubbles that touch CANNOT coalesce numerically — colliding markers overlap in "
3105 "space but never merge, so coalescence becomes an explicit model decision (rung W4) "
3106 "instead of a numerical accident of a single global colour field. Measured on the gate: "
3107 "236 cells carry BOTH markers at closest approach (30.0 cells of shared liquid) while "
3108 "each marker's own volume is conserved to 2.6e-15, and the single-field control merges "
3109 "them irreversibly (neck colour 0.77 against the blocks' 0.00 after the reversal).\n\n"
3110 "`seeds=None` DROPS the container (the structured colour field 'C' and advect_vof are "
3111 "unaffected). Otherwise 'seeds' is a list of (cx, cy, cz, radius) spheres in CELL "
3112 "units, global indices -- these "
3113 "are GRID coordinates and a physical domain does not convert them. The "
3114 "colour is the same exact sphere fraction set_vof would take.\n\n"
3115 "SCOPE at W0: ALL-FLUID (an immersed solid raises — the cut-cell block is rung W12) and "
3116 "KINEMATIC (advect_vof_blocks(dt); NS coupling is W12). Masters are assigned round robin "
3117 "by block id, deliberately independent of where the bubble's cells live — the weighted-"
3118 "ORB assignment is rung W1; diagnostics.vof_block_imbalance() is the number to beat.")
3120 "vof_blocks_enabled", [](S& s) {
return s.vofBlocksEnabled(); },
3121 "True while the block container exists (enable_vof_blocks with seeds; None drops it).")
3123 "advect_vof_blocks", [](S& s,
double dt) { s.advectVofBlocks(dt); }, nb::arg(
"dt"),
3124 "Advance every block by dt with the CURRENT (projected) face velocity and union the "
3125 "result into 'C' — the block twin of advect_vof(dt), and it carries the same "
3126 "precondition: it RAISES unless the face field is discretely divergence-free to 1e-10, "
3127 "because Weymouth-Yue's exact conservation is conditional on it.\n\n"
3128 "Per step: gather the face velocity from the ranks that own each block's cells to its "
3129 "master (plain Isend/Irecv — the block table and the decomposition are both replicated, "
3130 "so every rank knows every message size and an NBX handshake would only rediscover it), "
3131 "run the three sweeps on the dense block, re-centre the box, replicate the table, and "
3132 "scatter the inner colour back with UNPACK_MAX.\n\n"
3133 "NOTE on the union: max() from an empty union CLIPS the negative Weymouth-Yue round-off "
3134 "residue to an exact 0 — measured up to 6.2e-17 on the LeVeque gate, and every measured "
3135 "union/global-field difference was exactly that and nothing else.")
3138 "enable_vof_blocks_from_field",
3139 [](S& s,
const std::vector<std::array<int, 6>>& boxes) {
3140 s.enableVofBlocksFromField(boxes);
3143 "Seed the block container from the CURRENT colour field: one marker per given global "
3144 "index box (lo_x, lo_y, lo_z, hi_x, hi_y, hi_z; half-open), its colour copied out of the "
3145 "field set_vof installed. The general seeding path — enable_vof_blocks(spheres) is a "
3146 "convenience over it — and the way a marker of any shape enters (a quasi-2-D Hysing "
3147 "cylinder, a scanned bubble, an arbitrary blob found with a host connected-component "
3149 "The boxes are the BUBBLE extents; the container grows each by its own 3-cell margin. "
3150 "They must not share a cell: the seeding gather is a copy, not a union, so a cell inside "
3151 "two boxes would be handed to both markers.")
3154 "vof_block_color", &vofBlockColourArray<S>, nb::arg(
"id"),
3155 "One marker's OWN inner color as a Fortran-order (nx,ny,nz) float64 array over its "
3156 "block box (diagnostics.vof_block_stats()['lo'/'hi']); empty on a rank that does not master it.\n\n"
3157 "This is the block's ONLY state, so {box, color} per marker is a COMPLETE checkpoint "
3158 "of the container -- and it is the only exact one: re-seeding from the union color "
3159 "field (enable_vof_blocks_from_field) hands each of two TOUCHING markers a slice of the "
3160 "other, because that gather is a copy of the union clipped to the seed extent. "
3161 "enable_vof_blocks_from_colors(boxes, colors) restarts the container from these.")
3163 "enable_vof_blocks_from_colors",
3164 [](S& s,
const std::vector<std::array<int, 6>>& boxes, nb::list colours) {
3165 std::vector<std::vector<double>> c;
3166 c.reserve(nb::len(colours));
3167 for (std::size_t i = 0; i < nb::len(colours); ++i) {
3168 auto a = nb::cast<nb::ndarray<double, nb::f_contig>>(colours[i]);
3169 c.push_back(peclet::core::python::ndarray_to_vector<double>(nb::ndarray<>(a)));
3171 s.enableVofBlocksFromColours(boxes, c);
3173 nb::arg(
"boxes"), nb::arg(
"colors"),
3174 "Restart the block container from a vof_block_color() checkpoint: one marker per "
3175 "(lo_x, lo_y, lo_z, hi_x, hi_y, hi_z) INNER box (exactly the 'lo'/'hi' of "
3176 "diagnostics.vof_block_stats(), NOT grown by the margin again) with its colour written directly. "
3177 "Exact whatever the markers are doing -- nothing is gathered out of the union field.")
3179 "set_vof_block_assign",
3180 [](S& s,
const std::string& mode,
long every) {
3184 nb::arg(
"mode"), nb::arg(
"every") = 0,
3185 "Master assignment of the block container (rung W1). 'round-robin' by block id (rung "
3186 "W0's, blind to block SIZE), 'lpt' = LONGEST-PROCESSING-TIME greedy on the block cell "
3187 "counts, 'orb' = weighted ORB over a 1-D block space (core's BlockDecomposer<1> with the "
3188 "cell counts as weights). 'every' > 0 re-runs the assignment every 'every' block steps "
3189 "and MIGRATES the colour of any block that changed master (nothing else in a block is "
3190 "state, so the migration is exact and the bitwise gates hold across it).\n\n"
3191 "All three are pure functions of the REPLICATED block table, so every rank computes the "
3192 "same assignment without an exchange -- which is what lets a re-assignment happen "
3193 "mid-run without breaking a bitwise gate. LPT is the measured winner (see the WO-W12 "
3194 "findings): the ORB's blocks must be CONTIGUOUS in block id, which LPT is free of.")
3196 "vof_block_assign", [](S& s) {
return kBlockAssign.at(s.vofBlockAssign()); },
3197 "The current master-assignment mode ('round-robin' | 'lpt' | 'orb').")
3199 "enable_vof_block_csf", [](S& s) { s.enableVofBlockCsf(); },
3200 "Form the surface-tension force PER BLOCK (rung W2): each marker runs its own curvature "
3201 "cascade on its own dense box and forms the V4 balanced-force face force there "
3202 "(sigma kappa_f (C(i) - C(i-s))/h with the same selective face-curvature rule "
3203 "addCsfRhs uses), and the three face fields are scattered into the global RHS with "
3204 "UNPACK_SUM — TBFsolver's VOF.f90::computeSurfaceTension structure on the suite's "
3206 "WHY THE FORCE AND NOT THE CURVATURE IS SCATTERED: kappa is not additive and the union "
3207 "colour is a max, so a face between two OVERLAPPING markers has no single (kappa, dC) "
3208 "pair to build a force from. The force is the additive quantity, and forming it where "
3209 "each marker's own colour still exists is the only place the balanced-force pairing (the "
3210 "same face difference the projection's gradient uses) is available per marker.\n\n"
3211 "Requires set_surface_tension(sigma) and the block container; STAGGERED only. Once on, "
3212 "step() drives the whole two-phase stage through the blocks: the union C feeds the "
3213 "closures exactly as before, and the colour is advected by the blocks in the "
3217 "set_vof_inflow", [](S& s,
const std::string& face,
double value) { s.setVofInflow(
face_index(face,
"set_vof_inflow"), value); },
3218 nb::arg(
"face"), nb::arg(
"value"),
3219 "Colour of the fluid ENTERING through inflow face 'face' ('-x', '+x', '-y', '+y', '-z', "
3220 "'+z'); 1 = liquid, 0 = gas. The face must already be an inflow (set_domain_bc(face, "
3222 "or this raises.\n\n"
3223 "The value may be FRACTIONAL, and it then means 'this fraction of the incoming flux is "
3224 "liquid' — a flux statement, not a sub-cell interface position. That is exactly how it "
3225 "is implemented: on a face whose donor is outside the domain the geometric flux is "
3226 "replaced by the algebraic C_donor*a (wyFaceFluxBc), because a uniform prescribed ghost "
3227 "band has no usable MYC normal and the inflow colour is boundary DATA.\n\n"
3228 "Setting this also makes the property ghosts of that face follow the inflow colour "
3229 "through the registered closures (rho_ghost = rho(C_inflow)) instead of copying the "
3230 "interior — without it the inlet FACE density is the interior's, wrong by up to the "
3231 "density ratio at a liquid inlet into a gas domain. Default (nothing set): the "
3232 "zero-gradient copy, i.e. today's behaviour, bit for bit.")
3234 "set_vof_inflow_profile",
3235 [](S& s,
const std::string& face, nb::ndarray<double, nb::c_contig>
prof) {
3236 if (
prof.ndim() != 2)
3237 throw std::runtime_error(
"vof inflow profile must be (Nb,Nc)");
3238 const int nb_ = (int)
prof.shape(0), nc = (int)
prof.shape(1);
3239 s.setVofInflowProfile(
3240 face_index(face,
"set_vof_inflow_profile"), peclet::core::python::ndarray_to_vector<double>(nb::ndarray<>(
prof)), nb_,
3243 nb::arg(
"face"), nb::arg(
"profile"),
3244 "Per-position inflow colour (Nb,Nc) on the INNER grid of the face's two perpendicular "
3245 "axes, resampled with the same clamp rule set_domain_bc_profile uses for the velocity. "
3246 "This is how a liquid distributor over part of an inlet is expressed: the colour "
3247 "profile says WHERE liquid enters, the velocity profile says how fast.")
3249 "set_vof_backflow", [](S& s,
const std::string& face,
double value) { s.setVofBackflow(
face_index(face,
"set_vof_backflow"), value); },
3250 nb::arg(
"face"), nb::arg(
"value") = 0.0,
3251 "inletOutlet colour on OUTFLOW face 'face' ('-x' ... '+z'; default 0 = gas): where the "
3253 "velocity points back INTO the domain, the colour ghost carries this value instead of "
3254 "the zero-gradient copy. This is OpenFOAM's inletOutletFvPatchField (Rusche 2002 thesis "
3255 "section 4), the standard VoF outlet — an outlet is a place where you know what leaves "
3256 "(whatever is inside) but must STATE what comes back. Zero-gradient on a reversing face "
3257 "re-injects whatever happens to be sitting at the outlet, which for a draining film "
3258 "feeds the film back into the domain. Where the fluid leaves, zero-gradient is kept.")
3260 "vof_bc_volumes", [](S& s) {
return s.vofBcVolumes(); },
3261 "Signed liquid volume that crossed each of the six domain faces during the LAST colour "
3262 "advection, in cell-volume units, POSITIVE for liquid ENTERING the domain. Local to this "
3263 "rank. Six entries in face order (-x,+x,-y,+y,-z,+z); only faces carrying a domain BC "
3266 "vof_bc_volumes_total", [](S& s) {
return s.vofBcVolumesTotal(); },
3267 "The same, accumulated since enable_vof() or the last reset_vof_bc_volumes().")
3269 "reset_vof_bc_volumes", [](S& s) { s.resetVofBcVolumes(); },
3270 "Zero the per-face boundary liquid ledger (both the per-step and the running totals).")
3272 "compute_vof_curvature",
3274 s.computeVofCurvature();
3275 const auto d = s.vofCurvatureStats();
3277 r[
"interfacial"] = d.interfacial;
3279 r[
"hf_mixed"] = d.hfMixed;
3280 r[
"hf_fit"] = d.hfFit;
3282 r[
"pv_reduced"] = d.pvReduced;
3283 r[
"no_estimate"] = d.noEstimate;
3284 r[
"clipped"] = d.clipped;
3287 "VoF rung V3: compute the interface curvature from the CURRENT colour field and store it "
3288 "in the registered cell fields 'kappa' and 'kappa_branch'. Returns THIS RANK's branch "
3289 "census as a dict.\n\n"
3290 "'kappa' is the RAW registered field, kappa = 2H in 1/h (cell units); vof_curvature() "
3291 "returns the same thing in 1/length. POSITIVE for a convex blob of liquid: a sphere of liquid of radius R cells "
3292 "reads +2/R, an infinite cylinder +1/R, a plane 0.\n\n"
3293 "The cascade is Popinet's (2009): a standard height function on 7-cell column sums of C "
3294 "over the 3x3 transverse patch in the direction of the largest |n| (branch 1), the same "
3295 "in the other two directions (branch 2), then the PLIC-VOLUMETRIC paraboloid fit of "
3296 "Jibben et al. on a 5^3 Wendland-weighted stencil (branches 4/5) — the best "
3297 "cost/accuracy fallback per Han, Evrard & Desjardins, IJMF 174:104769 (2024). Branch 3 "
3298 "(the mixed height-position fit) is off by default; branch 0 is 'not an interfacial "
3299 "cell' and branch 6 is 'NO estimate', which must never occur.\n\n"
3300 "ALWAYS read 'kappa_branch' alongside 'kappa': kappa is 0 both where there is no "
3301 "interface (branch 0) and where no estimate could be made (branch 6).\n\n"
3302 "Two things the literature settles and this is NOT a bug: the fallback ALWAYS fires "
3303 "somewhere below ~5 cells per diameter (measured here: 100% of interfacial cells at "
3304 "D/dx = 2.8-4.4, ~19% at D/dx = 38-48 on a sphere), and with advection-realistic volume "
3305 "fractions the curvature error STOPS CONVERGING below C*dx ~ 1e-2 for every known "
3306 "method. Curvature order is not the thing to chase; transport fidelity is.")
3308 "vof_curvature", [](S& s) {
return field_out(s, s.getVofCurvature()); },
3309 "The curvature field's inner region as a Fortran-order (nx,ny,nz) float64 array, in 1/h "
3310 "in 1/LENGTH. Call compute_vof_curvature() first; get_field('kappa') is the RAW "
3311 "solver-internal field, which is 1/h.")
3313 "vof_curvature_branch", [](S& s) {
return field_out(s, s.getVofCurvatureBranch()); },
3314 "Which cascade branch produced each cell's curvature, as a Fortran-order (nx,ny,nz) "
3315 "float64 array: 0 not interfacial, 1 height function, 2 height function in a "
3316 "non-preferred direction, 3 mixed height-position fit, 4 PLIC-volumetric paraboloid fit, "
3317 "5 the same with the rank-deficient 3-parameter model, 6 NO estimate. == "
3318 "get_field('kappa_branch').")
3321 "set_surface_tension", [](S& s,
double sigma) { s.setSurfaceTension(sigma); },
3323 "VoF rung V4: turn on the BALANCED-FORCE continuum surface force with coefficient sigma "
3324 "(0 = off, the default). Enables VoF and the curvature cascade, which then runs once per "
3325 "step at the head, from the same colour field the density closure sees.\n\n"
3326 "The force at the staggered velocity unknown u_c(i) is\n\n"
3327 " F = sigma * kappa_f * (C(i) - C(i - s_c)) / h\n\n"
3328 "i.e. the colour difference is the projection's OWN face difference — the identical "
3329 "discrete gradient operator the pressure uses (Francois et al. 2006; Popinet 2009). That "
3330 "is the whole content of 'balanced force': with a constant kappa the force is exactly a "
3331 "discrete gradient, so the projection annihilates it and a static drop stays at machine "
3332 "zero. Face-interpolating a cell-centred sigma*kappa*grad(C) instead — the obvious way "
3333 "to reuse the per-cell body force — is not a discrete gradient of anything and leaves "
3334 "spurious currents of order sigma*kappa/mu that no curvature accuracy removes.\n\n"
3335 "UNITS: sigma is a force per unit LENGTH in the caller's own units -- the system rho, mu "
3336 "and set_body_force are stated in (so per CELL without a physical domain, where the "
3337 "cell size is 1). SIGN: kappa is positive for a convex blob of liquid and C is the "
3338 "liquid fraction, so the equilibrium pressure is P = sigma*kappa*C + const — the "
3339 "Young-Laplace overpressure INSIDE the drop.\n\n"
3340 "Surface tension is EXPLICIT, so step() enforces the Brackbill capillary time step "
3341 "(see capillary_dt()).")
3343 "surface_tension", [](S& s) {
return s.surfaceTension(); },
3344 "The surface-tension coefficient (0 when the CSF is off).")
3346 "capillary_dt", [](S& s) {
return s.capillaryDt(); },
3347 "The Brackbill (1992) capillary time-step limit sqrt((rho_1+rho_2) h^3 / (4 pi sigma)), "
3348 "+inf when surface tension is off. Denner & van Wachem (2015) verified this IS the "
3349 "stability boundary of an explicit CSF — the prefactor 1/(4 pi), the h^{3/2} scaling and "
3350 "the SUM of the phase densities (both phases oscillate). The density sum is the declared "
3351 "phase pair under enable_vof_momentum, otherwise min(rho)+max(rho) over the field.")
3353 "set_capillary_cfl", [](S& s,
double f) { s.setCapillaryCfl(f); }, nb::arg(
"factor"),
3354 "Safety factor on the capillary limit: step() raises when dt > factor * capillary_dt(). "
3355 "Default 1.0 — the Brackbill formula was measured to be the boundary itself, so it "
3356 "carries no built-in margin. Set it huge to disable the check, the same escape hatch "
3357 "set_vof_cfl_limit is for the Weymouth-Yue cap.")
3361 const auto L = s.vofStepLimits();
3363 r[
"courant"] = L.courant;
3364 r[
"cfl_dt"] = L.cflDt;
3365 r[
"capillary_dt"] = L.capillaryDt;
3366 r[
"binding"] = L.binding;
3367 r[
"capillary_binds"] = L.capillaryBinds;
3370 "Both explicit two-phase step limits at the current state, and which one binds: the "
3371 "largest dt the interface-local Weymouth-Yue CFL cap admits ('cfl_dt'), the Brackbill "
3372 "capillary limit ('capillary_dt'), and min(cfl_dt, capillary_cfl*capillary_dt) with a "
3373 "'capillary_binds' flag. At pore-scale capillary numbers the capillary limit is expected "
3377 "enable_phase_change",
3378 [](S& s,
double rho_g,
double rho_l,
double h_lv) {
3379 s.enablePhaseChange(rho_g, rho_l, h_lv);
3381 nb::arg(
"rho_gas"), nb::arg(
"rho_liquid"), nb::arg(
"h_lv") = 1.0,
3382 "Enable VoF phase change (VOF_PLAN section 9, rungs P0/P1; the Boyd & Ling 2023 / Malan "
3383 "2021 pattern). rho_gas/rho_liquid are densities and h_lv the latent heat (energy per "
3384 "mass), all in the solver's physical units. Registers 'mdot' (the interfacial mass flux, "
3385 "POSITIVE = evaporation) and 'pc_source' (the deposited divergence source); both are "
3386 "raw-registry fields and hold INTERNAL values -- multiply by 1/unit_scales"
3387 "['mass_flux_to_internal'] and 1/unit_scales['divergence_to_internal'] (both 1 "
3388 "without an extent).\n"
3389 "\nWhat it does per step, at the HEAD of step(): (1) reconstruct the PLIC plane of every "
3390 "interfacial cell and take its POLYGON AREA A analytically (A = |m|_2 dV/dalpha, not a "
3391 "finite difference); (2) evaluate mdot (prescribed by set_mass_flux*, or computed by "
3392 "set_phase_change_thermal); (3) deposit S = mdot A (1/rho_g - 1/rho_l) into the nearest "
3393 "PURE GAS cell along +n, so the interfacial cell's own face velocity stays the LIQUID "
3394 "velocity and Weymouth-Yue keeps a field its conservation proof is entitled to; (4) "
3395 "regress the interface by a PLIC PLANE SHIFT, C -> C - mdot A dt/rho_l, with "
3396 "clip-and-redistribute along -n when a cell empties. There is NEVER a volume source in "
3397 "the C equation (the Hardt-Wondra smeared source leaves unresolvable residue and breaks "
3399 "\nThe densities are given explicitly rather than read off a closure (the same contract "
3400 "as enable_vof_momentum). SCOPE at this rung: staggered grid, NO immersed solid, not "
3401 "composable with enable_vof_momentum; all three throw with a message. A CLOSED domain "
3402 "needs the net vapour production to leave somewhere: use an outflow face, or "
3403 "set_divergence_source() with a balancing sink, or rho_g = rho_l (then S == 0).")
3405 "set_mass_flux_uniform", [](S& s,
double v) { s.setMassFluxUniform(v); },
3407 "P0: prescribe a UNIFORM interfacial mass flux (mass per area per time; positive = "
3408 "evaporation, liquid consumed). Turns the thermal mass flux OFF. Under an extent, call "
3409 "set_rho and set_dt first: the value is converted into the 'mdot' field once.")
3412 [](S& s, nb::ndarray<double, nb::f_contig> a) { s.setMassFlux(
grid_in(a)); },
3414 "P0: prescribe a per-cell interfacial mass flux from a Fortran-order (nx,ny,nz) float64 "
3415 "array, in the units of set_mass_flux_uniform (and with its call-order rule). Turns "
3416 "the thermal mass flux OFF.")
3418 "set_phase_change_thermal",
3419 [](S& s,
bool enabled,
const std::string& name,
double t_sat,
double k_gas,
3420 double k_liquid,
double r_int) {
3422 s.setPhaseChangeThermalOff();
3426 throw std::invalid_argument(
3427 "set_phase_change_thermal(True, ...): name the registered temperature scalar");
3428 s.setPhaseChangeThermal(name, t_sat, k_gas, k_liquid, r_int);
3430 nb::arg(
"enabled"), nb::arg(
"scalar") =
"", nb::arg(
"t_sat") = 0.0,
3431 nb::arg(
"k_gas") = 0.0, nb::arg(
"k_liquid") = 0.0, nb::arg(
"r_int") = 0.0,
3432 "P1: compute mdot each step from a registered scalar (the temperature) instead of "
3433 "prescribing it; `enabled=False` stops that (back to the prescribed mdot field):\n\n"
3434 " mdot = ( k_g dT_g/dn - k_l dT_l/dn ) / h_lv , n = m/|m|_2 (LIQUID -> GAS)\n\n"
3435 "Each one-sided derivative is a weighted least-squares fit THROUGH the interface value "
3436 "over the PURE-PHASE cells of a 5^3 stencil on that side, with Malan's collinearity "
3437 "weight w = (d.n)^2/|d|^3 and the exact normal distance from the PLIC plane. Interfacial "
3438 "cells are pinned at T_G = t_sat + mdot*r_int in the energy solve through a per-cell "
3439 "Dirichlet mask on the scalar (r_int = 0 is the hard Dirichlet; a nonzero r_int is the "
3440 "Schrage/IHTR Robin condition of Bures & Sato 2021).\n\n"
3441 "SIGN CONVENTION -- the interfacial energy balance is mdot*h_lv = (q_l - q_g).n with "
3442 "q = -k grad T and n pointing OUT OF THE LIQUID, which is the form above. Check it on "
3443 "the Stefan problem: superheated vapour behind the interface gives grad(T_g).n > 0 and "
3444 "mdot > 0, i.e. evaporation. The opposite pairing (k_l grad T_l - k_g grad T_g) with the "
3445 "SAME n would condense a superheated vapour.\n\n"
3446 "The energy scalar's diffusivity is whatever add_scalar was given (a CONSTANT); per-cell "
3447 "k(C) and the consistent rho c_p T geometric transport are set_phase_change_energy. That "
3448 "is not a limitation for the Stefan gate, where the liquid is saturated and pinned at "
3450 "Units: t_sat in the temperature scalar's own units (never rescaled); k_gas/k_liquid "
3451 "are thermal conductivities (power per length per kelvin) and r_int an interfacial "
3452 "resistance (kelvin per mass flux), all in the solver's physical units.")
3454 "vof_interface_area", [](S& s) {
return s.vofInterfaceArea(); },
3455 "Total interfacial area of the colour field (an area in the solver's units; cells "
3456 "squared without an extent), summed over "
3457 "the inner region and globally reduced under MPI. Uses the geometry "
3458 "diagnostics.set_phase_change_area selects, so the number a page quotes and the number the phase "
3459 "change integrates are the same one. Needs enable_vof; phase change need not be on. "
3460 "A sphere of radius R reads 4 pi R^2 to about 0.2-0.8 % once its colour field is "
3461 "resolved (WO-P3c).")
3463 "set_phase_change_energy",
3464 [](S& s,
bool enabled,
double rcp_gas,
double rcp_liquid) {
3466 s.setPhaseChangeEnergyOff();
3469 if (!(rcp_gas > 0.0) || !(rcp_liquid > 0.0))
3470 throw std::invalid_argument(
3471 "set_phase_change_energy(True, ...): both heat capacities must be positive");
3472 s.setPhaseChangeEnergy(rcp_gas, rcp_liquid);
3474 nb::arg(
"enabled"), nb::arg(
"rho_cp_gas") = 0.0, nb::arg(
"rho_cp_liquid") = 0.0,
3475 "CONSISTENT rho*c_p*T transport (VOF_PLAN section 9 item 6) for the scalar named by "
3476 "set_phase_change_thermal; `enabled=False` returns to the constant-diffusivity scalar "
3477 "operator and the Koren TVD advective term (the rung P0/P1 energy path). Two things "
3478 "change together:\n"
3479 " (1) TRANSPORT: H = (rho c_p) T is advected with the colour advection's OWN geometric "
3480 "fluxes, sweep order and frozen dilation flag (vof/energy_advect.hpp), and T is recovered "
3481 "as H / (rho c_p)(C^{n+1}) -- so a uniform temperature is preserved EXACTLY at any heat "
3482 "capacity ratio. With two different fluxes the heat carried into a mixed cell is divided "
3483 "by a capacity built from another flux: an error of order d(rho c_p), i.e. ~2000x at "
3484 "water/steam, which is the artificial interfacial heating this removes.\n"
3485 " (2) DIFFUSION: the implicit operator becomes A_C = rho c_p(C)/dt + sum_f k_f open_f "
3486 "with k_f the arithmetic mean of the cells' k(C) (the k_gas/k_liquid of "
3487 "set_phase_change_thermal) EXCEPT at a face touching a per-cell Dirichlet (interfacial) "
3488 "cell, where the pure neighbour's own k is used -- a Dirichlet row is an identity row, so "
3489 "that coefficient's only job is the conductance with which the pure cell reaches a "
3490 "boundary condition that already sits at the interface.\n"
3491 "Units: rho_cp_* are VOLUMETRIC heat capacities (energy per volume per kelvin) in the "
3492 "solver's physical units, like the k of set_phase_change_thermal. Requires "
3493 "set_phase_change_thermal.")
3495 "set_divergence_source",
3496 [](S& s, nb::ndarray<double, nb::f_contig> a) { s.setDivergenceSource(
grid_in(a)); },
3498 "A PRESCRIBED extra divergence source (1/time; under an extent call set_dt first) on "
3499 "the inner cells, added to the Poisson RHS "
3500 "beside the phase-change deposit, so the projection solves div(open u) = S. Registered "
3501 "as the field 'div_source'. Its use is to make a CLOSED (periodic) domain compatible "
3502 "with a net vapour production by carrying a balancing sink; with an outflow face the "
3503 "outflow does that job and this is unnecessary.")
3505 "clear_divergence_source", [](S& s) { s.clearDivergenceSource(); },
3506 "Drop the prescribed divergence source (the field stays registered).")
3508 "set_divergence_sink",
3509 [](S& s, nb::ndarray<double, nb::f_contig> a) { s.setDivergenceSink(
grid_in(a)); },
3511 "WO-P23: an AUTO-BALANCED sink region for the phase-change divergence source. Give a "
3512 "non-negative weight per cell (Fortran-order (nx,ny,nz)); after each deposit the solver "
3513 "subtracts (global sum of the phase-change source) * w / (global sum of w), so a CLOSED "
3514 "domain's Poisson RHS is exactly compatible every step with no user bookkeeping. Put the "
3515 "weights in the LIQUID far from the interface, where the exact solution simply has the "
3516 "liquid leaving. This is what makes a closed-box phase-change run possible without the "
3517 "variable-density outflow operator (whose density-ratio inconsistency is WO-R2's "
3518 "subject). Registered as the field 'div_sink'.")
3520 "clear_divergence_sink", [](S& s) { s.clearDivergenceSink(); },
3521 "Drop the auto-balanced sink region.")
3523 "apply_phase_change", [](S& s,
double dt) { s.applyPhaseChange(dt); }, nb::arg(
"dt"),
3524 "KINEMATIC phase-change step: build mdot / the PLIC areas / the normals, deposit the "
3525 "divergence source (census only — nothing is projected) and apply the interface "
3526 "regression over `dt`. No Navier-Stokes step, no advection. This is the P0a / P1 driver: "
3527 "an interface regressing under a prescribed or thermally-computed flux, with the "
3528 "momentum and pressure solves out of the picture.")
3531 "enable_vof_momentum",
3532 [](S& s,
double rg,
double rl) { s.enableVofMomentum(rg, rl); }, nb::arg(
"rho_gas"),
3533 nb::arg(
"rho_liquid"),
3534 "Advect rho*u on the HALF-SHIFTED momentum control volumes with the SAME geometric "
3535 "fluxes, the same sweep order and one frozen dilation flag as the colour field, then "
3536 "recover u = (rho^c u)/rho^c. This is what makes the scheme usable above density ratio "
3537 "~100 (Rudman 1998; Arrufat 2021: a raindrop accurate within 15% at 15 cells/diameter "
3538 "instead of ~200).\n\n"
3539 "The two phase densities are REQUIRED, and must be the ones the rho closure produces at "
3540 "C=0 and C=1 — the momentum flux is rho_gas*(a-F) + rho_liquid*F with F the geometric "
3541 "liquid flux, so the scheme has to know which density each phase carries.\n\n"
3542 "Turning this on MOVES the VoF advection from the end of step() to its head (the momentum "
3543 "advection must precede the predictor that consumes it), so the advecting field is u^n — "
3544 "the previous step's projected output. u at step 0 must therefore be discretely "
3545 "divergence-free (rest or a uniform field both are). Requires the variable-density path, "
3546 "staggered layout, explicit advection, no immersed solid and no porous continuity, and is "
3547 "refused together with the per-bubble block container (enable_vof_blocks*, in either "
3548 "order): the momentum rides the structured colour's fluxes, not the markers'.")
3550 "vof_momentum_enabled", [](S& s) {
return s.vofMomentumEnabled(); },
3551 "Whether momentum-consistent transport is on.")
3554 "set_property_model",
3555 [](S& s,
const std::string& target,
const std::string& kind,
const std::string& in0,
3556 const std::vector<double>& params,
const std::string& in1) {
3558 if (kind ==
"linear")
3560 else if (kind ==
"boussinesq")
3562 else if (kind ==
"arrhenius")
3565 throw std::runtime_error(
"set_property_model: unknown kind '" + kind +
"'");
3566 s.setPropertyModel(target, k, in0, in1, params);
3568 nb::arg(
"target"), nb::arg(
"kind"), nb::arg(
"field"),
3569 nb::arg(
"params") = std::vector<double>{}, nb::arg(
"field2") = std::string{},
3570 "Register a device closure writing a property/body-force field from input field(s). "
3571 "target: a registered field (a property 'mu'/'rho'/… or a body-force component "
3572 "'force_x'/'force_y'/'force_z'). kind: 'linear' (params [p0,p1,p2]: "
3573 "p0+p1*field+p2*field2), "
3574 "'boussinesq' (params [rho0,g,beta,T0]: rho0*g*beta*(field-T0) buoyancy), 'arrhenius' "
3575 "(params [mu_ref,B,Tref]: mu_ref*exp(B*(1/field-1/Tref))). Applied at the top of step().\n\n"
3576 "Units: the input fields are taken as UNSCALED quantities (a phase fraction, a "
3577 "temperature, a concentration -- the solver never rescales a transported scalar), so on "
3578 "a target 'rho', 'mu' or 'force_x/y/z' the parameters are in the solver's PHYSICAL "
3579 "units (a density, a viscosity, a force per volume: rho0*g*beta*(T-T0) as written); "
3580 "any other target takes its field's INTERNAL units (see unit_scales).")
3582 "set_property_table",
3583 [](S& s,
const std::string& target,
const std::string& field,
3584 const std::vector<double>& x,
3585 const std::vector<double>& y) { s.setPropertyTable(target, field, x, y); },
3586 nb::arg(
"target"), nb::arg(
"field"), nb::arg(
"x"), nb::arg(
"y"),
3587 "Register a tabulated property: target = piecewise-linear interpolation of (x, y) at the "
3588 "input field value (x ascending, clamped at the ends). Units as set_property_model: y in "
3589 "the target's physical units on 'rho'/'mu'/'force_*', x in the input field's.")
3591 "update_properties", [](S& s) { s.updateProperties(); },
3592 "Apply all registered property/force closures now (also done at the top of step()).")
3594 "enable_cell_force", [](S& s) { s.enableCellForce(); },
3595 "Allocate + register the per-cell body-force fields force_x/force_y/force_z and route "
3597 "into the momentum RHS, for an external writer (e.g. CFD-DEM drag feedback) to fill "
3598 "directly via diagnostics.field_view('force_z'). They persist across steps until overwritten.")
3600 "enable_drag", [](S& s) { s.enableDrag(); },
3601 "Enable implicit (semi-implicit) linear drag for CFD-DEM: allocate the per-cell "
3603 "field (added to the momentum diagonal so a -beta*(u-u_p) source is treated implicitly "
3605 "unconditionally stable for the stiff beta of a dense bed) plus force_x/y/z (which carry "
3606 "beta*u_p, the RHS target). Fill 'drag_beta' and 'force_*' via diagnostics.field_view each step.")
3608 "ghost_width", [](S& s) {
return s.ghostWidth(); },
3609 "Ghost-layer width g of the velocity block (diagnostics.field_view returns an (n+2g) buffer).")
3611 "has_cutcell_pressure", [](S& s) {
return s.hasCutcellPressure(); },
3612 "True once the cut-cell pressure operator exists (set_solid or set_pressure_geometry was "
3613 "called). The porous continuity requires it; the coupling driver auto-sets an all-fluid "
3614 "geometry when absent.")
3616 "set_porous_continuity", [](S& s,
bool on) { s.setPorousContinuity(on); },
3617 nb::arg(
"on") =
true,
3618 "Enable the volume-averaged (porous) continuity for unresolved CFD-DEM (staggered only): "
3619 "the projection enforces d(eps)/dt + div(eps u) = 0 instead of div(u)=0, so the fluid "
3620 "velocity is NOT solenoidal where the void fraction changes (bubbling/expansion). Binds "
3621 "the 'eps' field (void fraction from the particle deposition, created seeded to 1 if "
3622 "absent; the coupling writes it each step BEFORE step()). eps=1 everywhere reduces "
3624 "to div(u)=0. Pair with max_porous_residual() for the meaningful convergence check.")
3626 "max_open_divergence_projected", [](S& s) {
return s.maxOpenDivergenceProjected(); },
3627 "max|div(open*u)| of the field the projection ACTUALLY produced — the outflow-face "
3628 "correction included, and WITHOUT mutating the velocity.\n\n"
3629 "Use this instead of max_open_divergence() on any domain with an OUTFLOW face. "
3630 "max_open_divergence() re-imposes the zero-gradient outflow velocity before measuring "
3631 "(its own comment says so), which (a) DESTROYS bcCorrectOutflow's correction — the "
3632 "mechanism by which mass leaves — as a side effect, so calling it once per step inside a "
3633 "time loop changes the run, and (b) reports the divergence of a field the solver never "
3634 "used. Measured on a stratified ratio-1000 outflow box: 5e-3 from the mutating "
3635 "diagnostic, flat in the iteration count, flat in the density ratio and bit-identical in "
3636 "a -DPECLET_FLOW_OPERATOR_DOUBLE build (i.e. not a solver residual), against the projected "
3637 "field's own residual from this call. Identical to max_open_divergence() when there is "
3638 "no outflow face, and on the collocated grid (which already measures the face field).")
3639 .def(
"max_open_divergence", &S::maxOpenDivergence,
3640 "Return the max cut-cell velocity-flux divergence max|div(open*u)|. With porous "
3641 "continuity this is NOT ~0 -- it equals -d(eps)/dt (the bed expanding). Use "
3642 "max_porous_residual() for the continuity residual.")
3643 .def(
"sync_porous_prev", &S::syncPorousPrev,
3644 "Reseed eps^n = eps^{n+1} (d(eps)/dt = 0 for the next step). Call it once the first "
3645 "void-fraction field has been written into 'eps' and before the first step, so that "
3646 "step sees no spurious d(eps)/dt source; any later call simply restarts the "
3647 "d(eps)/dt bookkeeping from the current field.")
3648 .def(
"max_porous_residual", &S::maxPorousResidual,
3649 "Residual of the volume-averaged continuity max|div(open*eps*u) + d(eps)/dt| -- the "
3650 "quantity the porous projection drives to zero. 0 unless set_porous_continuity(True).")
3654 auto o = s.blockOrigin();
3655 return std::vector<int>{o[0], o[1], o[2]};
3657 "This rank's inner-block origin in GLOBAL cells ([0,0,0] single-rank). Shift the "
3659 "deposit origin by this so particles in global coordinates land in the local block.")
3660#ifdef PECLET_FLOW_MPI
3667 [](S& s,
int gnx,
int gny,
int gnz) {
3669 s.initMpi(gnx, gny, gnz, MPI_COMM_WORLD);
3671 nb::arg(
"gnx"), nb::arg(
"gny"), nb::arg(
"gnz"),
3672 "Wire the multi-rank step: pass the GLOBAL grid dims (gnx,gny,gnz). The Solver must have "
3674 "constructed with this rank's LOCAL block dims (from mpi_block). MPI_Init is called if "
3675 "needed. Call it BEFORE the geometry (set_solid / set_pressure_geometry / "
3676 "set_solid_from_scene): the geometry builds the pressure and velocity multigrids for "
3677 "the partition in force when it runs, so init_mpi raises if a geometry already exists "
3678 "(built first, every rank would solve its own block-periodic pressure problem).")
3684 MPI_Comm_rank(MPI_COMM_WORLD, &r);
3687 "This rank's index in MPI_COMM_WORLD.")
3693 MPI_Comm_size(MPI_COMM_WORLD, &n);
3696 "The number of ranks in MPI_COMM_WORLD.")
3699 "rank", [](S&) {
return 0; },
3700 "MPI rank (always 0: this module was built without PECLET_FLOW_MPI; a build with it "
3701 "exposes init_mpi / mpi_block and the real rank).")
3703 "size", [](S&) {
return 1; },
3704 "MPI size (always 1: this module was built without PECLET_FLOW_MPI).")