peclet-amr 0.2.0
Block-local-Morton AMR octree + collocated cut-cell Navier–Stokes solver (header-only C++20, Kokkos + MPI)
Loading...
Searching...
No Matches
amr_bindings.cpp
Go to the documentation of this file.
1// amr — Python surface of peclet::amr (the `peclet.amr` package).
2//
3// A nanobind module exposing the adaptive-mesh-refinement package: the per-block BlockOctree
4// (serial) and the MPI DistributedOctree (ORB over root cells), so an mpi4py driver can build a
5// graded octree, refine it to a signed-distance surface, read leaf geometry + a per-leaf field as
6// numpy, load-rebalance the distributed octree, gather face-neighbour values, and export VTU; the
7// geometric-multigrid Poisson solver; and the device (Kokkos) collocated Navier–Stokes `Flow`,
8// whose developer tier is `Flow.diagnostics` (FlowDiagnostics, suite QUALITY_PLAN D2).
9//
10// Conventions (see ../docs/CONVENTIONS.md):
11// * 3D only here (Bits=21, codes in a 64-bit int). x-fastest where a linear index appears.
12// * Leaf arrays are in the octree's Z-order slot order; every per-leaf numpy array (centers,
13// sizes, levels, codes, fields) is indexed by that same slot, length num_leaves.
14// * Geometry: a leaf's world centre = origin + spacing * fine_centre (per axis); a leaf at
15// refinement `level` is spacing * 2**level wide. Root cells sit at level=lmax; level decreases
16// toward 0 (finest). `cells` is always the FINEST grid (root cells * 2**lmax), as in flow.
17//
18// Per-leaf arrays are returned via the shared peclet::core::python::vector_to_ndarray
19// (capsule-backed, no extra copy); the Flow path returns host fields the same way. The module links
20// MPI but never calls MPI_Init/Finalize: a distributed driver imports mpi4py.MPI first, and the
21// distributed classes run on MPI_COMM_WORLD.
22//
23// Build: see CMakeLists.txt (the `amr` target -> peclet.amr._amr, re-exported by
24// packaging/amr_init.py).
25#include <mpi.h>
26#include <nanobind/nanobind.h>
27#include <nanobind/ndarray.h>
28#include <nanobind/stl/array.h>
29#include <nanobind/stl/function.h>
30#include <nanobind/stl/optional.h>
31#include <nanobind/stl/string.h>
32#include <nanobind/stl/tuple.h>
33#include <nanobind/stl/variant.h>
34#include <nanobind/stl/vector.h>
35
36#include <array>
37#include <cstdint>
38#include <functional>
39#include <Kokkos_Core.hpp>
40#include <optional>
41#include <set>
42#include <string>
43#include <vector>
44
45#include "peclet/amr/adapt.hpp"
49#include "peclet/amr/flow.hpp" // the canonical (device) AmrFlow exposed to Python
50#include "peclet/amr/flow_oracle.hpp" // oracle::AmrFlow (dev-only reference; not exposed)
53#include "peclet/amr/mg_predict.hpp" // predictPressureLadder (docs/amr_mg_depth.md §6.7)
55#include "peclet/amr/refine.hpp"
56#include "peclet/amr/vtu_io.hpp"
57#include "peclet/core/common/types.hpp"
58#include "peclet/core/geom/scene_query.hpp" // SphereBedQuery (Layer 2-for-core)
59#include "peclet/core/geom/sdf.hpp"
60#include "peclet/core/python/kokkos_teardown.hpp"
61#include "peclet/core/python/ndarray_interop.hpp"
62
63namespace nb = nanobind;
64using namespace peclet::core;
65
66// The wrapper classes live in a NAMED namespace on purpose: they have virtual methods (Releasable),
67// and with an anonymous namespace hipcc's host/device split leaves their vtables unemitted, so the
68// HIP link fails with `ld.lld: undefined hidden symbol: vtable for (anonymous namespace)::Octree`
69// (and Poisson / Flow / DistributedOctree). OpenMP/CUDA builds are indifferent.
71
72using BO = amr::BlockOctree<3>; // 3D, Bits=21 (default) — codes fit a uint64
74using Code = BO::Code;
76using DArray = nb::ndarray<double, nb::c_contig>;
77
78// ---- per-leaf array helpers (capsule-backed; no extra copy; Z-order slot order) ----------------
79
80template <class T>
81nb::ndarray<nb::numpy, T> vec1(std::vector<T> v) {
82 const std::size_t n = v.size();
83 return peclet::core::python::vector_to_ndarray<T>(std::move(v), {n}, {1});
84}
85template <class T>
86nb::ndarray<nb::numpy, T> vec2(std::vector<T> v, std::size_t cols) {
87 const std::size_t n = v.size() / cols;
88 return peclet::core::python::vector_to_ndarray<T>(std::move(v), {n, cols},
89 {static_cast<std::int64_t>(cols), 1});
90}
91
92// World centre of every leaf -> (N,3) float64.
93nb::ndarray<nb::numpy, double> leafCenters(const BO& t, const amr::AmrGeometry<3>& geo) {
94 const Index n = t.numLeaves();
95 std::vector<double> d(static_cast<std::size_t>(n) * 3);
96 for (Index i = 0; i < n; ++i) {
97 Vec<3> c = geo.center(t.bounds(i));
98 d[i * 3 + 0] = c[0];
99 d[i * 3 + 1] = c[1];
100 d[i * 3 + 2] = c[2];
101 }
102 return vec2<double>(std::move(d), 3);
103}
104
105// World width of every leaf along `axis` (spacing[axis] * 2**level) -> (N,) float64.
106// Phase 3: a leaf is a BOX, so a single width exists only on a cubic octree; `axis` selects one.
107nb::ndarray<nb::numpy, double> leafSizes(const BO& t, const amr::AmrGeometry<3>& geo, int axis) {
108 if (axis < 0 || axis > 2)
109 throw std::runtime_error("sizes(axis): axis must be 0, 1 or 2");
110 const Index n = t.numLeaves();
111 std::vector<double> d(static_cast<std::size_t>(n));
112 for (Index i = 0; i < n; ++i)
113 d[i] = geo.leafSize(t.level(i), axis);
114 return vec1<double>(std::move(d));
115}
116
117// Refinement level of every leaf -> (N,) int32 (lmax at the root, 0 finest).
118nb::ndarray<nb::numpy, std::int32_t> leafLevels(const BO& t) {
119 const Index n = t.numLeaves();
120 std::vector<std::int32_t> d(static_cast<std::size_t>(n));
121 for (Index i = 0; i < n; ++i)
122 d[i] = static_cast<std::int32_t>(t.level(i));
123 return vec1<std::int32_t>(std::move(d));
124}
125
126// Block-local Morton origin code of every leaf -> (N,) uint64.
127nb::ndarray<nb::numpy, std::uint64_t> leafCodes(const BO& t) {
128 const Index n = t.numLeaves();
129 std::vector<std::uint64_t> d(static_cast<std::size_t>(n));
130 for (Index i = 0; i < n; ++i)
131 d[i] = static_cast<std::uint64_t>(t.code(i));
132 return vec1<std::uint64_t>(std::move(d));
133}
134
135// A contiguous vector<double> as a (N,) float64 numpy array.
136nb::ndarray<nb::numpy, double> vecToArray(std::vector<double> v) {
137 return vec1<double>(std::move(v));
138}
139
140// Validate a per-leaf field numpy array and view it as a contiguous vector<double>.
141std::vector<double> asField(const BO& t, const DArray& field, const char* who) {
142 if (static_cast<Index>(field.shape(0)) != t.numLeaves())
143 throw std::runtime_error(std::string(who) + ": field length != num_leaves");
144 const double* f = field.data();
145 return std::vector<double>(f, f + static_cast<std::size_t>(t.numLeaves()));
146}
147
148// ---- teardown registry -------------------------------------------------------------------------
149// The suite-wide pattern (peclet/core/python/kokkos_teardown.hpp): Kokkos::finalize() MUST run from
150// the atexit hook (else cudaErrorCudartUnloading on CUDA), but any wrapper that still holds Views
151// at that point -- an Octree/Flow at script or notebook scope, the normal case -- is destroyed
152// after finalize and Kokkos::abort()s the process. Every wrapper below is a Releasable (registered
153// on construction); the hook drops their Views (release()) BEFORE finalize.
154using peclet::core::python::Releasable;
155
156// ---- serial single-block octree ----------------------------------------------------------------
157
158// Derive the finest cell width h0 from a PHYSICAL extent and the finest cell counts
159// (suite/docs/PHYSICAL_UNITS_PLAN.md, work order U6): the caller states the box, the octree
160// derives its own spacing, and nobody writes h. Phase 1 is isotropic — the octree's leaves are
161// cubes by construction (a level-l leaf is h0*2**l wide on every axis), so an extent that does not
162// give one spacing is refused with the numbers rather than silently taking the x one.
163// PHASE 3 (suite/docs/PHYSICAL_UNITS_PLAN.md, core/docs/amr_anisotropic.md): the octree's cells
164// are BOXES. `extent/cells` is taken per axis and NOTHING is refused — the octree refines by 2 on
165// every axis, so a level-l leaf is `h0[d]*2**l` wide on axis `d` and every level inherits the root
166// aspect ratio.
167inline std::array<double, 3> spacingsFromExtent(const std::array<double, 3>& extent,
168 const std::array<long, 3>& cells) {
169 std::array<double, 3> h{};
170 for (int d = 0; d < 3; ++d) {
171 if (!(extent[d] > 0.0))
172 throw std::runtime_error("extent: every component must be > 0");
173 if (cells[d] <= 0)
174 throw std::runtime_error("extent: every finest-cell count must be > 0");
175 h[d] = extent[d] / (double)cells[d];
176 }
177 return h;
178}
179
180// The SCALAR helper keeps its cubic contract: it returns one number, so an extent that does not
181// give one spacing is still refused with the three values. `spacings_from_extent` is the per-axis
182// form, and the constructors take the anisotropic extent directly.
183inline double spacingFromExtent(const std::array<double, 3>& extent,
184 const std::array<long, 3>& cells) {
185 const std::array<double, 3> h = spacingsFromExtent(extent, cells);
186 for (int d = 1; d < 3; ++d)
187 if (std::fabs(h[d] - h[0]) > 1e-12 * std::fabs(h[0])) {
188 char msg[320];
189 std::snprintf(msg, sizeof msg,
190 "spacing_from_extent returns ONE number, but extent/cells gives "
191 "dx=%.17g dy=%.17g dz=%.17g — the octree's cells are CUBES here. Use "
192 "spacings_from_extent (or pass `extent` to the constructor) for a box mesh.",
193 h[0], h[1], h[2]);
194 throw std::runtime_error(msg);
195 }
196 return h[0];
197}
198
199// `spacing=` accepts one number (cubic cells) or three (a box mesh).
200using SpacingArg = std::variant<double, std::array<double, 3>>;
201inline std::array<double, 3> spacingOf(const SpacingArg& sp) {
202 if (const double* d = std::get_if<double>(&sp))
203 return {*d, *d, *d};
204 return std::get<std::array<double, 3>>(sp);
205}
206
207// The octree is stated on its FINEST grid (`cells`, the same quantity as flow's `cells`); the
208// root brick it is built from is cells / 2**lmax, which must divide exactly.
209inline std::array<long, 3> rootCellsOf(const std::array<long, 3>& cells, unsigned lmax,
210 const char* who) {
211 const long f = (long)1 << lmax;
212 std::array<long, 3> root{};
213 for (int d = 0; d < 3; ++d) {
214 if (cells[d] <= 0 || cells[d] % f != 0) {
215 char msg[256];
216 std::snprintf(msg, sizeof msg,
217 "%s: cells=(%ld, %ld, %ld) must be positive multiples of 2**lmax = %ld "
218 "(`cells` is the FINEST grid; the root brick is cells / 2**lmax)",
219 who, cells[0], cells[1], cells[2], f);
220 throw std::runtime_error(msg);
221 }
222 root[d] = cells[d] / f;
223 }
224 return root;
225}
226
227// Resolve the constructor's `spacing=` / `extent=` pair into the per-axis finest spacing.
228inline std::array<double, 3> resolveSpacing(const std::optional<SpacingArg>& spacing,
229 const std::optional<std::array<double, 3>>& extent,
230 const std::array<long, 3>& cells, const char* who) {
231 if (spacing && extent)
232 throw std::runtime_error(std::string(who) +
233 ": pass EITHER spacing= (the finest cell size) OR extent= (the box "
234 "side lengths; spacing = extent / cells), not both");
235 if (extent)
236 return spacingsFromExtent(*extent, cells);
237 if (spacing)
238 return spacingOf(*spacing);
239 return {1.0, 1.0, 1.0};
240}
241
242// A per-block adaptive octree with its world placement (origin + per-axis finest spacing).
243// Wraps peclet::amr::BlockOctree<3> + AmrGeometry<3>: build a uniform brick, refine toward a
244// surface, query leaves, and read leaf geometry / fields as numpy. The serial / single-rank form;
245// for the distributed (MPI) octree use DistributedOctree below.
246class Octree : public Releasable {
247 public:
248 Octree(std::array<long, 3> root, unsigned lmax, std::array<double, 3> origin,
249 std::array<double, 3> spacing) {
250 t_.init(IVec<3>{root[0], root[1], root[2]}, lmax);
251 geo_.origin = {origin[0], origin[1], origin[2]};
252 geo_.h0 = {spacing[0], spacing[1], spacing[2]};
253 }
254 void release() noexcept override { t_ = BO{}; }
255
256 // ---- introspection ----
257 Index num_leaves() const { return t_.numLeaves(); }
258 unsigned lmax() const { return t_.lmax(); }
259 std::array<double, 3> origin() const { return {geo_.origin[0], geo_.origin[1], geo_.origin[2]}; }
261 std::array<long, 3> cells() const {
262 const auto b = t_.brick();
263 const long f = (long)1 << t_.lmax();
264 return {(long)b[0] * f, (long)b[1] * f, (long)b[2] * f};
265 }
267 std::array<double, 3> extent() const {
268 const auto c = cells();
269 return {(double)c[0] * geo_.h0[0], (double)c[1] * geo_.h0[1], (double)c[2] * geo_.h0[2]};
270 }
271 std::array<double, 3> spacing() const { return {geo_.h0[0], geo_.h0[1], geo_.h0[2]}; }
272 bool is_balanced() const { return t_.isBalanced(); }
273
274 nb::ndarray<nb::numpy, double> centers() const { return leafCenters(t_, geo_); }
275 nb::ndarray<nb::numpy, double> sizes(int axis) const { return leafSizes(t_, geo_, axis); }
276 nb::ndarray<nb::numpy, std::int32_t> levels() const { return leafLevels(t_); }
277 nb::ndarray<nb::numpy, std::uint64_t> codes() const { return leafCodes(t_); }
278
279 // ---- queries ----
280 // Index of the leaf containing world point (x,y,z), or -1 if outside the block.
281 Index find(std::array<double, 3> x) const {
282 std::array<Coord, 3> fine{};
283 for (int d = 0; d < 3; ++d) {
284 double f = (x[d] - geo_.origin[d]) / geo_.h0[d];
285 if (f < 0)
286 return -1;
287 fine[d] = static_cast<Coord>(f);
288 }
289 return t_.find(fine);
290 }
291
292 // ---- refinement ----
293 // Refine leaves the sphere's surface passes through (plus a band) down to target_level.
294 Index refine_to_sphere(std::array<double, 3> center, double radius, unsigned target_level,
295 double band, bool balance) {
296 geom::Sphere s{{center[0], center[1], center[2]}, radius};
297 return amr::refineToSdf(
298 t_, geo_, [&](const Vec<3>& p) { return s.eval(p); }, target_level, band, balance);
299 }
300
301 // Refine toward an arbitrary signed-distance field given as a Python callable f(x,y,z)->distance
302 // (suite SDF sign: <0 inside solid). Convenient for scripting; the callback runs under the GIL.
303 Index refine_to_sdf(std::function<double(double, double, double)> sdf, unsigned target_level,
304 double band, bool balance) {
305 return amr::refineToSdf(
306 t_, geo_, [&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); }, target_level, band,
307 balance);
308 }
309
310 // Graded surface refinement: a per-point TARGET LEVEL callable instead of one global target
311 // (the mixed-level cut band, plan §7). Pair with Flow.set_ghost_sampled(True).
312 Index refine_to_sdf_graded(std::function<double(double, double, double)> sdf,
313 std::function<unsigned(double, double, double)> target_level,
314 double band, bool balance) {
316 t_, geo_, [&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); },
317 [&](const Vec<3>& p) { return target_level(p[0], p[1], p[2]); }, band, balance);
318 }
319
320 // The plan's gap-width floor (§7 criterion 1) driving refine_to_sdf_graded in one call:
321 // target level = coarsest L with n*h_L <= gap(x), clamped to [0, coarsest_level].
322 Index refine_to_gap_floor(std::function<double(double, double, double)> sdf,
323 std::function<double(double, double, double)> gap,
324 unsigned coarsest_level, double n, double band, bool balance) {
325 auto tgt = amr::gapFloorTarget<3>([&](const Vec<3>& p) { return gap(p[0], p[1], p[2]); },
326 geo_.hMax(), coarsest_level, n);
328 t_, geo_, [&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); }, tgt, band, balance);
329 }
330
331 // Split a single leaf by index; returns True if it was split (level>0).
332 bool refine_leaf(Index i) { return t_.refineLeaf(i); }
333
334 // Enforce 2:1 (graded) balance; returns the number of refinements performed.
335 Index balance() { return t_.balance2to1(); }
336
337 // Internal (not bound to Python): give the Poisson solver the underlying octree + geometry.
338 const BO& octreeRef() const { return t_; }
339 const amr::AmrGeometry<3>& geoRef() const { return geo_; }
340
341 // ---- solution-adaptive refinement ----
342 // Löhner normalized-second-difference indicator E in [0,1] per leaf from a scalar field (N,);
343 // large E marks steep features to refine, small E marks smooth regions to coarsen.
344 nb::ndarray<nb::numpy, double> lohner_indicator(DArray field, double eps) {
345 return vecToArray(amr::lohnerIndicator(t_, asField(t_, field, "lohner_indicator"), eps));
346 }
347
348 // One solution-adaptive step driven by the Löhner indicator: refine leaves where the indicator
349 // exceeds refine_thresh (down to finest_level), coarsen sibling groups all below coarsen_thresh,
350 // restore 2:1 balance, and conservatively remap `field` (N,) onto the new mesh. MUTATES the
351 // octree in place (num_leaves/centers/... then reflect the new mesh) and returns the remapped
352 // field (M,).
353 nb::ndarray<nb::numpy, double> adapt(DArray field, double refine_thresh, double coarsen_thresh,
354 unsigned finest_level, double eps, bool linear) {
355 auto fv = asField(t_, field, "adapt");
357 t_ = std::move(r.octree);
358 return vecToArray(std::move(r.field));
359 }
360
361 // ---- output ----
362 // Write the octree + a per-leaf scalar field (N,) as a VTK UnstructuredGrid (.vtu, ASCII).
363 void write_vtu(const std::string& path, const std::string& name, DArray field) {
364 amr::writeVtu(path, t_, geo_, name, asField(t_, field, "write_vtu"));
365 }
366
367 private:
368 BO t_;
370};
371
372// ---- geometric-multigrid Poisson solver --------------------------------------------------------
373
374// Cell-centered finite-volume Poisson solver (Lu = rhs) on an Octree, via a geometric multigrid
375// V-cycle (peclet::amr::AmrMultigrid). The operator L is the conservative two-point FV
376// Laplacian (negative-definite, suite sign convention); on a graded octree it is consistent and
377// second-order in the bulk (first-order at coarse/fine faces). `periodic=True` solves the singular
378// periodic problem (the constant null space is removed each cycle); the manufactured RHS b =
379// apply(u_exact) is exactly mean-zero, so the residual drives to round-off. The hierarchy snapshots
380// the octree at construction. (Cut-cell openness is handled by the flow solver, which sets it
381// consistently.)
382class Poisson : public Releasable {
383 public:
384 Poisson(const Octree& oct, bool periodic) : n_(oct.octreeRef().numLeaves()) {
385 mg_.build(oct.octreeRef(), oct.geoRef().h0);
386 mg_.setPeriodic(periodic);
387 }
388 void release() noexcept override { mg_ = amr::AmrMultigrid<3>{}; }
389
390 Index num_leaves() const { return n_; }
391 std::size_t num_levels() const { return mg_.numLevels(); }
392
393 // L applied to u (the FV Laplacian); use it to manufacture a RHS b = apply(u_exact). (N,)->(N,).
394 nb::ndarray<nb::numpy, double> apply(DArray u) {
395 std::vector<double> out;
396 mg_.op(0).applyLaplacian(toVec(u, "apply"), out);
397 return vec1<double>(std::move(out));
398 }
399
400 // Volume-weighted L2 residual norm sqrt(sum V*(rhs - L u)^2).
402 std::vector<double> res;
403 return mg_.op(0).residual(toVec(u, "residual"), toVec(rhs, "residual"), res);
404 }
405
406 // Solve L u = rhs with up to `cycles` V-cycles (pre/post smoothing sweeps each), starting from
407 // x0 (or 0). Stops early once the residual <= tol (tol<=0 disables). Returns
408 // (u (N,), final_residual, cycles_done).
409 nb::tuple solve(DArray rhs, std::optional<DArray> x0, int cycles, int pre, int post, double tol) {
410 std::vector<double> rv = toVec(rhs, "solve");
411 std::vector<double> u(static_cast<std::size_t>(n_), 0.0);
412 if (x0)
413 u = toVec(*x0, "solve");
414 std::vector<double> res;
415 double r = mg_.op(0).residual(u, rv, res);
416 int done = 0;
417 for (int k = 0; k < cycles; ++k) {
418 mg_.vcycle(0, u, rv, pre, post);
419 r = mg_.op(0).residual(u, rv, res);
420 ++done;
421 if (tol > 0.0 && r <= tol)
422 break;
423 }
424 return nb::make_tuple(vec1<double>(std::move(u)), r, done);
425 }
426
427 private:
428 std::vector<double> toVec(const DArray& a, const char* who) const {
429 if (static_cast<Index>(a.shape(0)) != n_)
430 throw std::runtime_error(std::string(who) + ": length != num_leaves");
431 const double* p = a.data();
432 return std::vector<double>(p, p + static_cast<std::size_t>(n_));
433 }
434
436 Index n_;
437};
438
439// ---- collocated incompressible flow ------------------------------------------------------------
440
441// Collocated (cell-centered) incompressible Stokes / Navier-Stokes step on an Octree with a
442// cut-cell immersed boundary (peclet::amr::AmrFlow). Each step() is: an implicit
443// backward-Euler viscous momentum predictor with no-slip (u=0) Dirichlet cut-cell IBM on the SDF
444// solid, then the Almgren-Bell-Colella approximate projection in incremental-rotational form
445// (openness-weighted pressure Poisson). Stokes by default; set_advection(True) adds explicit
446// high-order (SOU / Koren-TVD) momentum advection. Driven by a body force; iterate step() to steady
447// state. Velocities/pressure are per-leaf (num_leaves,) in Z-order slots. The octree is borrowed by
448// reference (kept alive for the Flow's lifetime).
449//
450// The classic cut band is uniformly finest (the ghost closures reach ±2 same-level cells);
451// set_ghost_sampled(True) is what admits cut cells at several levels. Away from the wall the
452// advection and the C/F schemes handle 2:1 faces themselves (docs/amr_cf_convective.md).
453class DistributedOctree; // the MPI wrapper, defined below (Flow's distributed constructor)
454
455class Flow : public Releasable {
456 public:
457 Flow(const Octree& oct, double rho, double mu, double dt) : n_(oct.octreeRef().numLeaves()) {
458 flow_.init(oct.octreeRef(), oct.geoRef().h0, oct.geoRef().origin);
459 flow_.setDensity(rho);
460 flow_.setViscosity(mu);
461 flow_.setDt(dt);
462 const auto& t = oct.octreeRef();
463 for (int d = 0; d < 3; ++d) {
464 extent_[d] = static_cast<double>(t.brick()[d]) * static_cast<double>(Index(1) << t.lmax()) *
465 oct.geoRef().h0[d];
466 origin_[d] = oct.geoRef().origin[d];
467 }
468 }
469 // Distributed (mpi4py): run this solver on one ORB block of a DistributedOctree — the whole
470 // step then executes multi-rank through the ±2 LeafHalo (docs/amr_distributed_flow.md).
471 // Defined after DistributedOctree below; keep_alive on the binding pins the octree.
472 Flow(DistributedOctree& d, double rho, double mu, double dt);
473 // Distributed load rebalance: weighted-ORB re-decomposition migrating u/p with the leaves +
474 // full rebuild (collective; distributed constructor only).
475 void rebalance_mpi(std::function<double(double, double, double)> sdf) {
476 nb::gil_scoped_release nogil; // see set_solid
477 flow_.rebalanceMpi([&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); });
478 n_ = flow_.numLeaves();
479 }
480 void release() noexcept override { flow_ = amr::AmrFlow<>{}; }
481
482 Index num_leaves() const { return n_; }
483 void set_body_force(double fx, double fy, double fz) { flow_.setBodyForce(fx, fy, fz); }
484 void set_advection(bool on) { flow_.setAdvection(on); }
487 }
488 void set_ghost_sampled(bool on, double rho, long max_samples) {
489 flow_.setGhostSampled(on, rho, max_samples);
490 }
492
493 // Adaptivity during a run: snapshot -> (externally mutate the Octree: adapt / refine_to_* /
494 // balance) -> rebuild + conservative field transfer. num_leaves is refreshed.
495 void begin_adapt() { flow_.beginAdapt(); }
496 void set_pressure(nb::ndarray<double, nb::c_contig> h) {
497 flow_.setPressure(std::vector<double>(h.data(), h.data() + h.shape(0)));
498 }
499 void set_dt(double dt) { flow_.setDt(dt); }
500 void finish_adapt(std::function<double(double, double, double)> sdf) {
501 nb::gil_scoped_release nogil; // see set_solid
502 flow_.finishAdapt([&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); });
503 n_ = flow_.numLeaves();
504 }
506 void set_implicit_advection(bool on) { flow_.setImplicitAdvection(on); }
507
508 // Build the cut-cell operators from a signed-distance callable f(x,y,z) (>0 in fluid, <0 in
509 // solid), and zero the velocity / pressure fields. Call before stepping; re-call to change the
510 // geometry.
511 void set_solid(std::function<double(double, double, double)> sdf) {
512 // The host operator builders sample the SDF from Kokkos host parallel regions. nanobind's
513 // std::function wrapper takes the GIL for every call, so with the calling thread still
514 // holding it while it waits at the parallel region's barrier, the worker threads block
515 // forever (measured: a hang under the OpenMP host backend at OMP_NUM_THREADS > 1). Release
516 // it here; the callback re-acquires it per sample, which serialises the sampling but
517 // cannot deadlock. The same holds for finish_adapt / rebalance_mpi, which rebuild through
518 // setSolid.
519 nb::gil_scoped_release nogil;
520 flow_.setSolid([&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); });
521 }
522
523 // Native union-of-spheres SDF — the porous-media geometry evaluated in C++ instead of through
524 // a Python callback. set_solid samples the SDF tens of times per leaf (operator build, overlay
525 // classification at virtual positions, the openness probe), so at bed scale the callback, not
526 // the solve, dominates: measured >1h43m of pure numpy on an 11.35M-leaf 180-sphere bed before
527 // the GPU was touched once. `centers` is (M,3) and `radii` is (M,) or a single scalar-valued
528 // (1,) array; `periodic` applies the minimum-image convention over the octree's own extent.
529 void set_solid_spheres(nb::ndarray<const double, nb::c_contig> centers,
530 nb::ndarray<const double, nb::c_contig> radii, bool periodic) {
531 if (centers.ndim() != 2 || centers.shape(1) != 3)
532 throw std::runtime_error("set_solid_spheres: centers must be (M, 3)");
533 const std::size_t m = centers.shape(0);
534 if (radii.ndim() != 1 || (radii.shape(0) != m && radii.shape(0) != 1))
535 throw std::runtime_error("set_solid_spheres: radii must be (M,) or (1,)");
536 std::vector<double> cx(m), cy(m), cz(m), rr(m);
537 const double* cp = centers.data();
538 const double* rp = radii.data();
539 const bool oneR = radii.shape(0) == 1;
540 for (std::size_t i = 0; i < m; ++i) {
541 cx[i] = cp[3 * i];
542 cy[i] = cp[3 * i + 1];
543 cz[i] = cp[3 * i + 2];
544 rr[i] = oneR ? rp[0] : rp[i];
545 }
546 // Layer 2-for-core (suite/docs/archive/AMR_GEOMETRY_SETUP_REQUIREMENTS.md): the per-query
547 // brute-force scan over all M spheres — measured 1.18 us/eval at M=180, 94% of the whole
548 // setSolid — is retired for the candidate-grid + equal-radius query. BIT-IDENTICAL to the old
549 // callback (value, not just sign) by construction and by the geom_scene_query ctest; the
550 // templated setSolid inlines the query, so there is no std::function indirection left either.
551 geom::SphereBedQuery q(std::move(cx), std::move(cy), std::move(cz), std::move(rr),
552 Vec<3>{origin_[0], origin_[1], origin_[2]},
553 Vec<3>{extent_[0], extent_[1], extent_[2]}, periodic);
554 flow_.setSolid(q);
555 }
556
557 void set_outer_iterations(int n, double tol) { flow_.setOuterIterations(n, tol); }
559 // The pressure DRIVER of the aperture projection: MG-PCG (true, the default) or the bounded
560 // stationary V-cycle (false) -- flow's `set_pressure_pcg` spelling and tier (ROADMAP A'2).
561 void set_pressure_pcg(bool on) { flow_.setPressurePCG(on); }
563 // §6.6/§11.4: what solves the coarsest pressure level, and where the ladder hands over to it --
564 // flow's spellings and tier (NAMING.md §1.8). The MODE takes effect at once (the bottom is
565 // re-decided on the hierarchy already built) and is kept for later set_solid calls; the extent
566 // only at the next set_solid, where the ladder is built.
567 void set_pressure_bottom(const std::string& mode) { flow_.setPressureBottom(mode); }
568 void set_pressure_bottom_extent(long cells) {
569 flow_.setPressureBottomExtent(static_cast<Index>(cells));
570 }
571 long pressure_bottom_extent() const { return static_cast<long>(flow_.pressureBottomExtent()); }
572
573 // Advance one collocated projection step (Stokes, or NS if advection is on).
574 void step(int mom_iters, int pres_iters) { flow_.step(mom_iters, pres_iters); }
575
576 // Per-leaf velocity component c (0=x,1=y,2=z) -> (num_leaves,) float64.
577 nb::ndarray<nb::numpy, double> velocity(int c) const {
578 if (c < 0 || c > 2)
579 throw std::runtime_error("velocity: component must be 0..2");
580 const auto& v = flow_.velocity(c);
581 return vec1<double>(std::vector<double>(v.begin(), v.end()));
582 }
583
584 // All three velocity components -> (num_leaves, 3) float64. Packed on-device into one interleaved
585 // buffer, so the device->host boundary is crossed once (G6) instead of three times.
586 nb::ndarray<nb::numpy, double> velocities() const { return vec2<double>(flow_.velocities(), 3); }
587
588 // Per-leaf fluid mask (False inside the solid) -> (num_leaves,) bool.
589 nb::ndarray<nb::numpy, bool> is_fluid() const {
590 auto* buf = new std::vector<std::uint8_t>(static_cast<std::size_t>(n_));
591 for (Index i = 0; i < n_; ++i)
592 (*buf)[static_cast<std::size_t>(i)] = flow_.isFluid(i) ? 1 : 0;
593 nb::capsule owner(buf,
594 [](void* p) noexcept { delete static_cast<std::vector<std::uint8_t>*>(p); });
595 return nb::ndarray<nb::numpy, bool>(reinterpret_cast<bool*>(buf->data()),
596 {static_cast<std::size_t>(n_)}, owner, {1});
597 }
598
599 // Volume-weighted L2 norm of the residual cell divergence (a projection-quality diagnostic).
600 double divergence_norm() { return flow_.divNormL2(); }
601
602 // Per-leaf pressure (incremental-rotational p) -> (num_leaves,) float64.
603 nb::ndarray<nb::numpy, double> pressure() const { return vec1<double>(flow_.pressure()); }
604 // ABC divergence-free FACE velocity, one value per CSR (sub)face -> (num_faces,) float64.
605 nb::ndarray<nb::numpy, double> face_field() const { return vec1<double>(flow_.faceField()); }
606 // Write velocity component c (0=x,1=y,2=z) from a (num_leaves,) array — ICs / restart /
607 // warm-start.
608 void set_velocity(int c, nb::ndarray<double, nb::c_contig> h) {
609 flow_.setVelocity(c, std::vector<double>(h.data(), h.data() + h.shape(0)));
610 }
611 // Pressure projection only (no momentum) — project an externally-set velocity to divergence-free.
612 void project(int pres_iters) { flow_.project(pres_iters); }
614 amr::AmrFlow<>& engine() { return flow_; }
615
616 private:
617 amr::AmrFlow<> flow_; // the canonical device (Kokkos) AMR flow
618 Index n_;
619 std::array<double, 3> extent_{0.0, 0.0, 0.0}; // world box size (min-image for set_solid_spheres)
620 std::array<double, 3> origin_{0.0, 0.0, 0.0}; // world box origin (candidate-grid coverage)
621};
622
623// ---- distributed (MPI) octree ------------------------------------------------------------------
624
625// The MPI octree: an ORB block decomposition of a global root grid, one BlockOctree per rank.
626// Build it collectively (every rank constructs the same global geometry; ORB hands each rank a
627// block), refine the local octree toward a global surface, restore cross-block 2:1 balance,
628// load-rebalance leaves+fields onto a weighted ORB, gather face-neighbour field values across the
629// owner-based halo, and read this rank's local leaf geometry / fields as numpy. Uses
630// MPI_COMM_WORLD.
631class DistributedOctree : public Releasable {
632 public:
633 DistributedOctree(std::array<long, 3> global_root, unsigned lmax, std::array<double, 3> origin,
634 std::array<double, 3> spacing, std::array<bool, 3> periodic) {
636 g.origin = {origin[0], origin[1], origin[2]};
637 g.h0 = {spacing[0], spacing[1], spacing[2]};
639 {periodic[0], periodic[1], periodic[2]}, MPI_COMM_WORLD);
640 }
641 void release() noexcept override { d_ = DO{}; }
642
643 // ---- introspection ----
644 int rank() const { return d_.rank(); }
645 int size() const { return d_.size(); }
646 Index num_leaves() const { return d_.local().numLeaves(); }
647 unsigned lmax() const { return d_.lmax(); }
648 std::array<double, 3> spacing() const {
649 const auto& hv = d_.h0();
650 return {hv[0], hv[1], hv[2]};
651 }
653 std::array<long, 3> cells() const {
654 const auto& c = d_.globalFineSize();
655 return {(long)c[0], (long)c[1], (long)c[2]};
656 }
658 std::array<double, 3> extent() const {
659 const auto c = cells();
660 const auto& hv = d_.h0();
661 return {(double)c[0] * hv[0], (double)c[1] * hv[1], (double)c[2] * hv[2]};
662 }
664 std::array<double, 3> origin() const {
665 const auto& o = d_.globalGeometry().origin;
666 return {o[0], o[1], o[2]};
667 }
668 std::array<long, 3> block_origin_root() const {
669 const auto& b = d_.blockOriginRoot();
670 return {b[0], b[1], b[2]};
671 }
672 std::array<long, 3> block_brick() const {
673 const auto& b = d_.blockBrick();
674 return {b[0], b[1], b[2]};
675 }
676 std::array<long, 3> global_root_size() const {
677 const auto& b = d_.globalRootSize();
678 return {b[0], b[1], b[2]};
679 }
680
681 // World leaf geometry of THIS rank's block (centres in global world coordinates).
682 nb::ndarray<nb::numpy, double> centers() const {
683 return leafCenters(d_.local(), d_.localGeometry());
684 }
685 nb::ndarray<nb::numpy, double> sizes(int axis) const {
686 return leafSizes(d_.local(), d_.localGeometry(), axis);
687 }
688 nb::ndarray<nb::numpy, std::int32_t> levels() const { return leafLevels(d_.local()); }
689 nb::ndarray<nb::numpy, std::uint64_t> codes() const { return leafCodes(d_.local()); }
690
691 // ---- refinement ----
692 // Refine the local octree toward a GLOBAL sphere surface, then (if balance) restore cross-block
693 // 2:1 balance collectively. Returns the local refinement count. Collective when balance=True.
694 Index refine_to_sphere(std::array<double, 3> center, double radius, unsigned target_level,
695 double band, bool balance) {
696 geom::Sphere s{{center[0], center[1], center[2]}, radius};
697 auto lgeo = d_.localGeometry();
699 d_.local(), lgeo, [&](const Vec<3>& p) { return s.eval(p); }, target_level, band,
700 /*balance=*/false);
701 if (balance)
702 d_.balance();
703 return n;
704 }
705
706 // Refine the local octree toward an arbitrary GLOBAL surface given as a Python SDF callable
707 // f(x,y,z) (suite sign: <0 inside solid), then (if balance) restore cross-block 2:1 balance
708 // collectively. The distributed analogue of the serial Octree.refine_to_sdf. Collective when
709 // balance.
710 Index refine_to_sdf(std::function<double(double, double, double)> sdf, unsigned target_level,
711 double band, bool balance) {
712 auto lgeo = d_.localGeometry();
714 d_.local(), lgeo, [&](const Vec<3>& p) { return sdf(p[0], p[1], p[2]); }, target_level,
715 band, /*balance=*/false);
716 if (balance)
717 d_.balance();
718 return n;
719 }
720
721 // Cross-block 2:1 balance (collective). Returns the local number of refinements performed.
722 Index balance() { return d_.balance(); }
723
724 // ---- load balancing ----
725 // Re-decompose by leaf COUNT (weighted ORB) so each rank holds a near-equal share, migrating
726 // leaves AND their fields. `fields` is (N,K) float64 (K per-leaf columns); returns this rank's
727 // (M,K) columns after migration. Pure redistribution; the partition is updated in place.
728 // Collective.
729 nb::ndarray<nb::numpy, double> rebalance(DArray fields) {
730 const Index n = d_.local().numLeaves();
731 if (static_cast<Index>(fields.shape(0)) != n)
732 throw std::runtime_error("rebalance: fields.shape[0] != num_leaves");
733 const int K = static_cast<int>(fields.shape(1));
734 const double* in = fields.data();
735 std::vector<std::vector<double>> cols(static_cast<std::size_t>(K),
736 std::vector<double>(static_cast<std::size_t>(n)));
737 for (Index i = 0; i < n; ++i)
738 for (int c = 0; c < K; ++c)
739 cols[static_cast<std::size_t>(c)][static_cast<std::size_t>(i)] = in[i * K + c];
740
741 d_.rebalance(cols); // mutates the octree in place and swaps in the new columns
742
743 const Index m = d_.local().numLeaves();
744 std::vector<double> out(static_cast<std::size_t>(m) * static_cast<std::size_t>(K));
745 for (Index i = 0; i < m; ++i)
746 for (int c = 0; c < K; ++c)
747 out[i * K + c] = cols[static_cast<std::size_t>(c)][static_cast<std::size_t>(i)];
748 return vec2<double>(std::move(out), static_cast<std::size_t>(K));
749 }
750
751 // ---- halo ----
752 // For each local leaf and each of the 6 faces, the neighbouring leaf's field value, gathered
753 // across the owner-based halo. Returns (N,6) float64 laid out as [+x,-x,+y,-y,+z,-z]; domain
754 // boundaries (no neighbour) carry `sentinel`. Collective. `field` is this rank's per-leaf (N,).
755 nb::ndarray<nb::numpy, double> face_neighbor_gather(DArray field, double sentinel) {
756 std::vector<double> f = asField(d_.local(), field, "face_neighbor_gather");
757 std::vector<double> g = d_.faceNeighborGather(f, sentinel);
758 return vec2<double>(std::move(g), 6); // 2*Dim faces
759 }
760
761 // ---- solution-adaptive refinement ----
762 // Löhner indicator per local leaf, evaluated across the owner-based halo so cross-block
763 // neighbours contribute exactly as in a whole-domain solve. `field` is this rank's (num_leaves,).
764 // Collective.
765 nb::ndarray<nb::numpy, double> lohner_indicator(DArray field, double eps) {
766 auto fv = asField(d_.local(), field, "lohner_indicator");
768 }
769
770 // One distributed solution-adaptive step: refine/coarsen each block's local octree from the
771 // Löhner indicator, restore cross-block 2:1 balance, and conservatively remap `field`
772 // (num_leaves,) onto the new local mesh. MUTATES the octree in place (keeping ORB ownership);
773 // returns the remapped local field (M,). Bit-identical across rank counts. Collective.
774 nb::ndarray<nb::numpy, double> adapt(DArray field, double refine_thresh, double coarsen_thresh,
775 unsigned finest_level, double eps, bool linear) {
776 auto fv = asField(d_.local(), field, "adapt");
777 return vecToArray(
779 }
780
781 // ---- output ----
782 // Write THIS rank's local octree + a per-leaf scalar (N,) as a .vtu (one file per rank).
783 void write_vtu(const std::string& path, const std::string& name, DArray field) {
785 asField(d_.local(), field, "write_vtu"));
786 }
787
789 DO& ref() { return d_; }
790
791 private:
792 DO d_;
793};
794
795// Distributed Flow constructor (out of line: DistributedOctree is declared after Flow).
796inline Flow::Flow(DistributedOctree& d, double rho, double mu, double dt)
797 : n_(d.ref().local().numLeaves()) {
798 flow_.initMpi(d.ref());
799 flow_.setDensity(rho);
800 flow_.setViscosity(mu);
801 flow_.setDt(dt);
802}
803
804// ---- the diagnostics tier of Flow ------------------------------------------------------------
805// suite/docs/QUALITY_PLAN.md D2: the PUBLIC surface of Flow is what a user needs to set up, run and
806// read out a simulation; everything a developer uses to inspect, ablate or profile lives here, as
807// `flow.diagnostics.<name>` — a view holding a reference to the Flow (no copies of state; the
808// binding pins the Flow for the view's lifetime).
810 public:
811 explicit FlowDiagnostics(Flow& f) : f_(f) {}
812 // Solver iteration counts from the last step (convergence / perf instruments).
813 int last_mom_iters() const { return f_.engine().lastMomIters(); }
814 int last_pres_iters() const { return f_.engine().lastPresIters(); }
815 int last_outer_iters() const { return f_.engine().lastOuterIters(); }
816 // L2 norm of the divergence of the ABC divergence-free FACE field (≈ the pressure-solve residual,
817 // far below divergence_norm — including across 2:1 interfaces).
818 double divergence_norm_face() { return f_.engine().divNormFace(); }
819 // The C/F census (docs/amr_cf_flux_gate.md §6.5): a count, so a property (NAMING.md §1.2/§1.3).
820 peclet::core::Index num_cf_cut_faces() const { return f_.engine().numCfCutFaces(); }
821 // The face CSR topology `Flow.face_field()` is indexed by -> a dict of host-copied arrays (the
822 // CSR itself, the per-face geometry, and the seam-reconstruction tables); the keys are listed in
823 // the docstring below. A 2:1 sub-face is a slot whose two incident leaves have different
824 // `Octree.levels()`; its centroid is the FINER leaf's face centre. Read-only and host-copied on
825 // every call — a diagnostic, not a step-loop read-out.
826 nb::dict face_topology() const {
827 const auto t = f_.engine().faceTopology();
828 nb::dict d;
829 d["start"] = vec1<std::int64_t>(std::vector<std::int64_t>(t.start.begin(), t.start.end()));
830 d["nbr"] = vec1<std::int64_t>(std::vector<std::int64_t>(t.nbr.begin(), t.nbr.end()));
831 d["axis"] = vec1<std::int32_t>(std::vector<std::int32_t>(t.axis.begin(), t.axis.end()));
832 d["dir"] = vec1<std::int32_t>(std::vector<std::int32_t>(t.dir.begin(), t.dir.end()));
833 d["upup_i"] = vec1<std::int64_t>(std::vector<std::int64_t>(t.upupI.begin(), t.upupI.end()));
834 d["upup_j"] = vec1<std::int64_t>(std::vector<std::int64_t>(t.upupJ.begin(), t.upupJ.end()));
835 d["raw_area"] = vec1<double>(t.rawArea);
836 d["dist"] = vec1<double>(t.dist);
837 d["alpha"] = vec1<double>(t.alpha);
838 // Seam reconstruction (docs/amr_cf_convective.md §5.2). Empty arrays when no tables exist.
839 auto i64 = [](const std::vector<peclet::core::Index>& v) {
840 return vec1<std::int64_t>(std::vector<std::int64_t>(v.begin(), v.end()));
841 };
842 d["seam"] = i64(t.seam);
843 d["samp_i"] = i64(t.sampI);
844 d["samp_j"] = i64(t.sampJ);
845 d["uu_rec_i"] = i64(t.uuRecI);
846 d["uu_rec_j"] = i64(t.uuRecJ);
847 d["d1_i"] = vec1<double>(t.d1I);
848 d["d1_j"] = vec1<double>(t.d1J);
849 d["rec_start"] = i64(t.recStart);
850 d["rec_dist"] = vec1<double>(t.recDist);
851 d["rec_cell"] = i64(t.recCell);
852 d["rec_w"] = vec1<double>(t.recW);
853 d["cell_center"] = vec2<double>(std::vector<double>(t.center), 3);
854 return d;
855 }
856 // The pressure multigrid's ladder AS BUILT on this rank: per-level leaf counts, level 0 first
857 // (docs/amr_mg_depth.md §6.7). Levels below the root brick are lifted levels. Read it against
858 // `peclet.amr.predict_hierarchy`, never against a literal.
859 std::vector<peclet::core::Index> pressure_mg_levels() const {
860 return f_.engine().pressureMgLevels();
861 }
862 // What actually solves the coarsest pressure level: "jacobi" | "amg", with "+tail" when the
863 // replicated stage moved it (docs/amr_mg_depth.md §6.6/§6.7).
864 std::string pressure_mg_bottom() const { return f_.engine().pressureMgBottom(); }
865 // The seam-reconstruction census (docs/amr_cf_convective.md §5.2), this rank's.
866 peclet::core::Index num_seam_sample_records() const { return f_.engine().numSeamSampleRecords(); }
867 peclet::core::Index num_seam_layer_records() const { return f_.engine().numSeamLayerRecords(); }
868 // Solver-internals and ablation switches (the production path needs none of them).
869 void set_momentum_mg(bool on) { f_.engine().setMomentumMG(on); }
870 void set_momentum_gs(bool on) { f_.engine().setMomentumGS(on); }
873 void set_ghost_gradient(bool on) { f_.engine().setGhostGradient(on); }
875 void set_uf_advection(bool on) { f_.engine().setUfAdvection(on); }
877
878 private:
879 Flow& f_;
880};
881
882// ---- the members Octree and DistributedOctree share, bound ONCE (QUALITY_PLAN G.5) --------------
883// Both wrappers expose the same leaf read-outs, refinement / balance / adapt entry points and VTU
884// writer with identical signatures (the distributed one acts on THIS rank's block and is
885// collective where the docstring says so); the 14+ verbatim `.def` lines they used to carry each
886// live here.
887template <class T>
888void bindOctreeCommon(nb::class_<T>& c) {
889 c.def_prop_ro("cells", &T::cells,
890 "Finest-level cell counts per axis (root*2**lmax; the GLOBAL grid on a "
891 "DistributedOctree).")
892 .def_prop_ro("extent", &T::extent, "Box side lengths in world units (cells*spacing).")
893 .def_prop_ro("spacing", &T::spacing,
894 "Finest cell size (dx, dy, dz), per axis. Equal on a cubic octree.")
895 .def_prop_ro("num_leaves", &T::num_leaves,
896 "Number of leaves (Z-order slots; this rank's on a DistributedOctree).")
897 .def_prop_ro("lmax", &T::lmax, "Root-cell level (max refinement depth).")
898 .def_prop_ro("origin", &T::origin, "Lower corner in world coordinates.")
899 .def("centers", &T::centers,
900 "Leaf world centres, (num_leaves, 3) float64 (global coordinates).")
901 .def("sizes", &T::sizes, nb::arg("axis") = 0,
902 "Leaf world widths along `axis`: spacing[axis]*2**level, (num_leaves,) float64. A leaf "
903 "is a BOX, so `axis` selects which of the three widths (0 by default, which is THE "
904 "width on a cubic octree).")
905 .def("levels", &T::levels, "Leaf refinement levels, (num_leaves,) int32 (0 = finest).")
906 .def("codes", &T::codes, "Leaf block-local Morton origin codes, (num_leaves,) uint64.")
907 .def("refine_to_sphere", &T::refine_to_sphere, nb::arg("center"), nb::arg("radius"),
908 nb::arg("target_level") = 0u, nb::arg("band") = 1.0, nb::arg("balance") = true,
909 "Refine leaves the sphere surface passes through (plus `band` cells) down to "
910 "target_level; optionally restore 2:1 balance (cross-block, collective, on a "
911 "DistributedOctree). Returns the (local) number of refinements performed.")
912 .def(
913 "refine_to_sdf", &T::refine_to_sdf, nb::arg("sdf"), nb::arg("target_level") = 0u,
914 nb::arg("band") = 1.0, nb::arg("balance") = true,
915 "Refine toward an arbitrary signed-distance field given as a callable f(x,y,z)->distance "
916 "(suite sign: <0 inside solid), down to target_level — rings / packed beds / any "
917 "non-sphere geometry. Collective when balance=True on a DistributedOctree. Returns "
918 "refinements performed.")
919 .def("balance", &T::balance,
920 "Enforce 2:1 graded balance to a fixpoint (cross-block and collective on a "
921 "DistributedOctree); returns (this rank's) refinements performed.")
922 .def(
923 "lohner_indicator", &T::lohner_indicator, nb::arg("field"), nb::arg("eps") = 0.01,
924 "Löhner normalized-second-difference feature indicator E in [0,1] per leaf from a scalar "
925 "field (num_leaves,); large E = steep feature (refine), small = smooth (coarsen). On a "
926 "DistributedOctree it is evaluated across the owner-based halo (collective).")
927 .def("adapt", &T::adapt, nb::arg("field"), nb::arg("refine_thresh"),
928 nb::arg("coarsen_thresh"), nb::arg("finest_level") = 0u, nb::arg("eps") = 0.01,
929 nb::arg("linear") = true,
930 "Solution-adaptive step (Löhner-driven): refine where the indicator > refine_thresh (to "
931 "finest_level), coarsen sibling groups all < coarsen_thresh, 2:1-balance, and "
932 "conservatively remap `field`. MUTATES the octree in place; returns the remapped field "
933 "(M,). `linear` uses minmod-limited prolongation (else piecewise-constant). On a "
934 "DistributedOctree: per block, cross-block balance, ORB ownership kept, bit-identical "
935 "across rank counts (collective).")
936 .def("write_vtu", &T::write_vtu, nb::arg("path"), nb::arg("name"), nb::arg("field"),
937 "Write the octree (this rank's block on a DistributedOctree — one file per rank, "
938 "combine in ParaView) + a per-leaf scalar field (num_leaves,) as a VTK UnstructuredGrid "
939 "(.vtu, ASCII, one cell per leaf).");
940}
941
942} // namespace peclet::amr::pybind
943
945 using namespace peclet::amr::pybind;
946 // The Flow path runs Kokkos kernels — initialise the device runtime on import (the backend/arch
947 // is fixed by the prefix the module was built against); the release-then-finalize atexit hook,
948 // finalize() and execution_space are the suite-wide pattern (teardown registry above). Per-leaf
949 // arrays are host-vector-backed (no Views).
950 peclet::core::python::install(m);
951 m.attr("build_toolchain") = PECLET_AMR_BUILD_TOOLCHAIN; // the byte gate compares like with like
952 m.attr("__doc__") =
953 "peclet.amr — adaptive mesh refinement: per-block Octree (serial) and DistributedOctree "
954 "(MPI ORB) for the mesh, the geometric-multigrid Poisson solver, and the device (Kokkos) "
955 "Flow cut-cell Stokes/Navier-Stokes solver. Build a graded octree, refine to an SDF "
956 "surface, read leaf geometry + per-leaf fields as numpy, load-rebalance, gather face "
957 "neighbours, export VTU, and run the flow step on device.";
958
959 m.def(
960 "predict_hierarchy",
961 [](std::array<long, 3> cells, unsigned lmax, int num_ranks, long bottom_extent) {
962 const auto root = rootCellsOf(cells, lmax, "predict_hierarchy");
963 if (num_ranks < 1)
964 throw std::runtime_error("predict_hierarchy: num_ranks must be >= 1");
965 if (bottom_extent < 1)
966 throw std::runtime_error("predict_hierarchy: bottom_extent must be >= 1");
967 const peclet::core::IVec<3> G{root[0], root[1], root[2]};
969 nb::list cellsL, kindL, extentL;
970 for (const auto& lv : p.levels) {
971 cellsL.append(lv.cells);
972 kindL.append(peclet::amr::toString(lv.kind));
973 extentL.append(nb::make_tuple(lv.extent[0], lv.extent[1], lv.extent[2]));
974 }
975 nb::dict d;
976 d["cells"] = cellsL;
977 d["extent"] = extentL;
978 d["kind"] = kindL;
979 d["num_levels"] = (long)p.levels.size();
980 d["num_in_place"] = (long)p.numInPlace();
981 d["tail"] = p.tail;
982 nb::list stagesL;
983 for (auto k : p.stages)
985 d["stages"] = stagesL;
986 d["bottom"] = p.bottomName();
987 return d;
988 },
989 nb::arg("cells"), nb::arg("lmax") = 0u, nb::arg("num_ranks") = 1,
990 nb::arg("bottom_extent") = 4,
991 "The pressure-multigrid ladder a Flow builds on `cells` FINEST cells per axis at tree depth "
992 "`lmax` over `num_ranks` ranks (docs/amr_mg_depth.md §6.2, §6.7) — a pre-flight tool. A "
993 "pure function: it builds no mesh and needs no MPI; it re-runs the ladder rule on the ORB "
994 "a freshly constructed DistributedOctree would produce (not a partition after "
995 "`DistributedOctree.rebalance` / `Flow.rebalance_mpi`). Below the root brick the ladder "
996 "halves the grid while every rank's block stays even; where it stops above "
997 "`bottom_extent` cells per axis on more than one rank the level MOVES (a stage): onto fewer "
998 "ranks by a sibling merge or a repartition when the Flow's stage policy is on "
999 "(docs/amr_mg_depth.md WO4b), where the ladder lifts again, else gathered onto every rank "
1000 "(the replicated tail) and continued there; the coarsest level is then solved "
1001 "by damped-Jacobi sweeps if it has at most `bottom_extent` cells per axis, else by the "
1002 "agglomerated GraphAMG-PCG bottom. The prediction is that of the default "
1003 "`Flow.set_pressure_bottom('auto')`.\n\n"
1004 "Returns a dict, levels finest first: `extent` the level's global grid in CELLS per axis "
1005 "(a count, not a length), `cells` its total cell count as if the mesh were uniform (so it "
1006 "equals `Flow.diagnostics.pressure_mg_levels` leaf for leaf only for a uniform mesh at "
1007 "num_ranks=1), `kind` ('octree' at or above the root brick, 'lifted' below it in place, "
1008 "'tail' / 'sibling' / 'repartition' on a stage's continued ladder -- a stage's first level "
1009 "is the SAME grid as the level above it, moved rather than coarsened, exactly as "
1010 "`pressure_mg_levels` lists it), `num_levels`, `num_in_place` (levels above the first "
1011 "stage), `stages` (the stage kinds, outermost first), `tail` (bool: a replicated tail "
1012 "engages) and `bottom` ('jacobi' or 'amg', suffixed '+tail' / '+sibling' / '+repartition' "
1013 "per stage, innermost first) — the spelling `Flow.diagnostics.pressure_mg_bottom` "
1014 "reports.\n\n"
1015 "`lmax` is the number of octree coarsenings THE MESH supports, i.e. the tree's lmax for a "
1016 "mesh refined to level 0 somewhere. An UNREFINED Octree(cells, lmax=k>0) is the same mesh "
1017 "as Octree(cells/2**k, lmax=0) — all its leaves are root cells — and must be predicted that "
1018 "way. In general pass depth = tree.lmax - levels().min() and cells = root * 2**depth. "
1019 "`bottom_extent` (default 4, the solver's default) is the "
1020 "`Flow.pressure_bottom_extent` the run will use; pass the same value.");
1021
1022 m.def(
1023 "spacing_from_extent",
1024 [](std::array<double, 3> extent, std::array<long, 3> root_cells, unsigned lmax) {
1025 const long f = (long)1 << lmax;
1026 return spacingFromExtent(extent, {root_cells[0] * f, root_cells[1] * f, root_cells[2] * f});
1027 },
1028 nb::arg("extent"), nb::arg("root_cells"), nb::arg("lmax"),
1029 "The finest cell width h0 = extent / (root_cells * 2**lmax) of a PHYSICAL domain — the "
1030 "one place an AMR spacing is computed, so no caller writes one (see "
1031 "suite/docs/PHYSICAL_UNITS_PLAN.md). Returns ONE number, so it raises when the extent does "
1032 "not give cubic cells — use `spacings_from_extent` for a box mesh.");
1033
1034 m.def(
1035 "spacings_from_extent",
1036 [](std::array<double, 3> extent, std::array<long, 3> root_cells, unsigned lmax) {
1037 const long f = (long)1 << lmax;
1038 return spacingsFromExtent(extent,
1039 {root_cells[0] * f, root_cells[1] * f, root_cells[2] * f});
1040 },
1041 nb::arg("extent"), nb::arg("root_cells"), nb::arg("lmax"),
1042 "The finest cell size (dx, dy, dz) = extent / (root_cells * 2**lmax), PER AXIS. The "
1043 "anisotropic form of `spacing_from_extent`: it accepts any positive extent, because the "
1044 "octree's cells are boxes (core/docs/amr_anisotropic.md).");
1045
1046 nb::class_<Octree> octree(
1047 m, "Octree",
1048 "Serial single-block adaptive octree with a world placement (origin + finest "
1049 "spacing per axis). Leaves are addressed in Z-order slot order; every per-leaf "
1050 "array is indexed by that slot.");
1051 octree
1052 .def(
1053 "__init__",
1054 [](Octree* self, std::array<long, 3> cells, unsigned lmax, std::array<double, 3> origin,
1055 std::optional<SpacingArg> spacing, std::optional<std::array<double, 3>> extent) {
1056 const auto root = rootCellsOf(cells, lmax, "Octree");
1057 new (self) Octree(root, lmax, origin, resolveSpacing(spacing, extent, cells, "Octree"));
1058 },
1059 nb::arg("cells"), nb::kw_only(), nb::arg("lmax") = 0u,
1060 nb::arg("origin") = std::array<double, 3>{0, 0, 0}, nb::arg("spacing") = nb::none(),
1061 nb::arg("extent") = nb::none(),
1062 "Build a uniform octree resolving `cells` FINEST cells per axis, refinable `lmax` levels "
1063 "(the root brick is cells/2**lmax root cells, which must divide exactly; lmax=0 is a "
1064 "uniform grid). `origin` is the block's lower corner. Every argument after `cells` is "
1065 "keyword-only.\n\n"
1066 "State the domain PHYSICALLY with `extent` = the box side lengths: the cell size is "
1067 "then extent/cells PER AXIS and the caller never writes a spacing. Alternatively give "
1068 "`spacing` (one number for cubic cells, or (dx, dy, dz)); default 1. Passing both "
1069 "raises.\n\n"
1070 "The cells are BOXES (core/docs/amr_anisotropic.md): any positive extent is accepted, "
1071 "the octree refines by 2 on every axis, and every level inherits the root aspect "
1072 "ratio. Read the three numbers back from `.spacing`.")
1073 .def("is_balanced", &Octree::is_balanced,
1074 "True iff every face-adjacent leaf pair differs by at most one level (2:1).")
1075 .def("find", &Octree::find, nb::arg("x"),
1076 "Index of the leaf containing world point x=(x,y,z), or -1 if outside the block.")
1077 .def("refine_to_sdf_graded", &Octree::refine_to_sdf_graded, nb::arg("sdf"),
1078 nb::arg("target_level"), nb::arg("band") = 2.0, nb::arg("balance") = true,
1079 "GRADED surface refinement (the mixed-level cut band, "
1080 "docs/amr_mixed_level_cut_band_plan.md §7): `target_level` is a callable "
1081 "f(x,y,z)->level giving the COARSEST acceptable level at a world point (0 = finest), "
1082 "so cut cells end up at SEVERAL levels — fine in throats/contacts, coarse on smooth "
1083 "caps. The band margin is measured in cells of the level being created (not in the "
1084 "finest spacing as in refine_to_sdf). Requires Flow.set_ghost_sampled(True) — the "
1085 "classic overlay contracts a uniform finest band and raises on these level jumps. "
1086 "Returns refinements performed.")
1087 .def("refine_to_gap_floor", &Octree::refine_to_gap_floor, nb::arg("sdf"), nb::arg("gap"),
1088 nb::arg("coarsest_level"), nb::arg("n") = 4.0, nb::arg("band") = 2.0,
1089 nb::arg("balance") = true,
1090 "refine_to_sdf_graded driven by the plan's GAP-WIDTH FLOOR (§7 criterion 1): the "
1091 "target level at a point is the coarsest L with n*h_L <= gap(x), clamped to "
1092 "[0, coarsest_level]. `gap` is the local fluid-gap proxy f(x,y,z)->width — for a "
1093 "sphere packing the two-closest-surfaces sum d1+d2; a medial-axis or peclet.pnm "
1094 "throat-radius field substitutes verbatim. n=4 per the M1/M2 measurements. This is "
1095 "the AMReX multi-valued-cell rule inverted: coarsening never merges or disconnects "
1096 "fluid.")
1097 .def("refine_leaf", &Octree::refine_leaf, nb::arg("i"),
1098 "Split leaf `i` into its 8 children; returns True if it was split (level>0).");
1099 bindOctreeCommon(octree);
1100
1101 nb::class_<Poisson>(
1102 m, "Poisson",
1103 "Cell-centered finite-volume Poisson solver (L u = rhs) on an Octree, by a "
1104 "geometric-multigrid V-cycle. L is the conservative two-point FV Laplacian (suite sign). "
1105 "The hierarchy snapshots the octree at construction; per-leaf arrays are (num_leaves,) "
1106 "float64 in Z-order slots.")
1107 .def(
1108 nb::init<const Octree&, bool>(), nb::arg("octree"), nb::arg("periodic") = true,
1109 "Build the multigrid hierarchy from `octree`. periodic=True solves the singular periodic "
1110 "problem (constant null space removed each cycle).")
1111 .def_prop_ro("num_leaves", &Poisson::num_leaves, "Leaves on the finest level.")
1112 .def_prop_ro(
1113 "num_levels", &Poisson::num_levels,
1114 "Number of multigrid levels: the octree's own coarsenings down to the root brick, then "
1115 "levels BELOW it (the root lifted, docs/amr_mg_depth.md §6.1-§6.2) for as long as some "
1116 "axis has more than 4 cells and every axis stays even with at least 2 cells after "
1117 "halving. So a uniform lmax=0 mesh has a real hierarchy (64^3: 5 levels).")
1118 .def("apply", &Poisson::apply, nb::arg("u"),
1119 "L applied to u (the FV Laplacian); use b = apply(u_exact) to manufacture a RHS. "
1120 "(num_leaves,) -> (num_leaves,).")
1121 .def("residual", &Poisson::residual, nb::arg("u"), nb::arg("rhs"),
1122 "Volume-weighted L2 residual norm sqrt(sum V*(rhs - L u)^2).")
1123 .def(
1124 "solve", &Poisson::solve, nb::arg("rhs"), nb::arg("x0") = std::nullopt,
1125 nb::arg("cycles") = 20, nb::arg("pre") = 2, nb::arg("post") = 2, nb::arg("tol") = 0.0,
1126 "Solve L u = rhs with up to `cycles` V-cycles (pre/post Gauss-Seidel sweeps), from x0 or "
1127 "0, stopping once residual <= tol (tol<=0 disables). Returns (u (num_leaves,), "
1128 "final_residual, cycles_done).");
1129
1130 nb::class_<Flow>(
1131 m, "Flow",
1132 "Collocated incompressible Stokes/Navier-Stokes step on an Octree with a cut-cell immersed "
1133 "boundary (no-slip on an SDF solid). step() = implicit viscous momentum predictor + "
1134 "Almgren-Bell-Colella rotational projection, whose pressure Poisson equation is solved by "
1135 "multigrid-preconditioned CG (the hierarchy continues below the octree's root brick; "
1136 "`diagnostics.pressure_mg_levels`). The box is TRIPLY PERIODIC and the only driving is a "
1137 "uniform body force (set_body_force); a wall is an immersed solid. Any consistent unit "
1138 "system: density [mass/length^3], dynamic viscosity [mass/(length*time)], dt [time], "
1139 "lengths those of the Octree's extent. Iterate step() to a steady state or march in time; "
1140 "velocities and pressure are per-leaf (num_leaves,) arrays in Z-order slots.")
1141 .def(nb::init<const Octree&, double, double, double>(), nb::arg("octree"),
1142 nb::arg("density") = 1.0, nb::arg("viscosity") = 1.0, nb::arg("dt") = 1e6,
1143 nb::keep_alive<1,
1144 2>(), // keep the octree alive for the Flow's lifetime (borrowed by ref)
1145 "Create a flow on `octree` with the given density, dynamic viscosity and time step "
1146 "(defaults 1, 1 and 1e6). A large dt drives straight to the steady (Stokes) solution. "
1147 "The octree is borrowed by reference and must outlive the Flow's use of it.")
1148 .def(nb::init<DistributedOctree&, double, double, double>(), nb::arg("octree"),
1149 nb::arg("density") = 1.0, nb::arg("viscosity") = 1.0, nb::arg("dt") = 1e6,
1150 nb::keep_alive<1, 2>(),
1151 "DISTRIBUTED (mpi4py, collective): run this solver on one ORB block of a "
1152 "DistributedOctree — the whole step (momentum, pressure, overlays, adaptivity) then "
1153 "executes multi-rank through the ±2 leaf ghost registry. Per-leaf arrays are this "
1154 "rank's LOCAL leaves. setSolid/step/project/begin_adapt/finish_adapt/rebalance_mpi "
1155 "are collective. np=1 is bit-identical to the single-rank constructor.")
1156 .def("rebalance_mpi", &Flow::rebalance_mpi, nb::arg("sdf"),
1157 "DISTRIBUTED: weighted-ORB load rebalance — migrates the leaves WITH the state (u, p) "
1158 "to the new owners and rebuilds every solver structure (collective). num_leaves is "
1159 "refreshed.")
1160 .def_prop_ro("num_leaves", &Flow::num_leaves, "Number of leaves.")
1161 .def("set_solid", &Flow::set_solid, nb::arg("sdf"),
1162 "Build the cut-cell operators from a signed-distance callable f(x,y,z) (>0 fluid, <0 "
1163 "solid) and zero the fields. Call before stepping; re-call to change the geometry.")
1164 .def("set_solid_spheres", &Flow::set_solid_spheres, nb::arg("centers"), nb::arg("radii"),
1165 nb::arg("periodic") = true,
1166 "set_solid for a UNION OF SPHERES, evaluated natively instead of through a Python "
1167 "callback — the porous-media geometry. `centers` is (M,3), `radii` is (M,) or (1,); "
1168 "`periodic` uses the minimum-image convention over the octree's own box extent. "
1169 "Prefer this to set_solid at bed scale: set_solid samples the SDF tens of times per "
1170 "leaf (operator build, overlay classification at virtual positions, the openness "
1171 "probe), so a Python callback dominates everything else — measured >1h43m of pure "
1172 "numpy on an 11.35M-leaf 180-sphere bed before the GPU ran a single kernel.")
1173 .def("set_body_force", &Flow::set_body_force, nb::arg("fx"), nb::arg("fy"), nb::arg("fz"),
1174 "Set the body force per unit volume [force/length^3] driving the flow, e.g. a mean "
1175 "pressure gradient -dp/dx along x. It is the only driving the periodic box has. "
1176 "Default (0, 0, 0).")
1177 .def("set_advection", &Flow::set_advection, nb::arg("on"),
1178 "Enable explicit momentum advection (Navier-Stokes); off = Stokes.")
1179 .def("set_ghost_projection", &Flow::set_ghost_projection, nb::arg("on"),
1180 nb::arg("matrix_order") = 2, nb::arg("rhs_order") = 2,
1181 "DEFAULT since 2026-08-25 (AUTO: ghost, with an aperture fallback + stderr notice when "
1182 "the finest band is too thin): the fluid-only constraint scheme — family-free, "
1183 "unconditionally stable, protocol-independent (flow's attractor-campaign verdicts; == "
1184 "flow's set_collocated_scheme('ghost')). FULL directional ghost-cell projection (the "
1185 "AMR port): binary-openness pressure operator + wall-anchored closure overlay on the "
1186 "finest-band rows, MG-preconditioned BiCGStab, ghost-closed divergence constraint; "
1187 "implies set_ghost_gradient. (matrix_order, rhs_order) closure orders: (2, 2) default "
1188 "and the only pair cleared for production — the (1, 2) mixed form is march-UNSTABLE "
1189 "above ~2000 spheres (flow hardening Phase A), kept callable for parity records only. "
1190 "Raises if the finest band is too thin (a closure would cross a 2:1 boundary). Call "
1191 "before set_solid.")
1192 .def("set_ghost_sampled", &Flow::set_ghost_sampled, nb::arg("on"), nb::arg("rho") = 2.2,
1193 nb::arg("max_samples") = 0L,
1194 "MIXED-LEVEL CUT BAND (docs/amr_mixed_level_cut_band_plan.md): allow cut cells at "
1195 "MULTIPLE octree levels — the finest-band contract is dropped. Chain entries that "
1196 "cross a 2:1 boundary become degree-2 LS virtual samples at the uniform closure "
1197 "positions (identity weights at same level, so a uniform finest band is BIT-IDENTICAL "
1198 "to set_ghost_sampled(False)); face classification uses the level-aware canonical "
1199 "openness; the momentum xi-row seam correction and the wall-aware C/F tangential "
1200 "fallback ride along. Implies the ghost projection (engages when the resolved scheme "
1201 "is ghost — the AUTO default or an explicit set_ghost_projection(True)). Distributed "
1202 "since 2026-08-30 (the clouds are a deterministic probe set through the leaf halo). "
1203 "`rho` is the least-squares cloud radius factor (rho = factor * max(h, H); 2.2 = the "
1204 "shipped behaviour) and `max_samples` the nearest-N candidate cap (0 = uncapped) — the "
1205 "M2a cloud-economy knobs, inert at their defaults; do not change them in production "
1206 "without the M2a table. Call before set_solid.")
1207 .def("set_pressure", &Flow::set_pressure, nb::arg("values"),
1208 "Write the accumulated rotational pressure from a (num_leaves,) array — restart, or "
1209 "re-accumulation policies after finish_adapt (at steady-state dt the transferred p is "
1210 "the load-bearing state; zeroing it after a coarsening adapt lets it re-accumulate "
1211 "cleanly).")
1212 .def("set_dt", &Flow::set_dt, nb::arg("dt"),
1213 "Change the time step. dt is BAKED INTO the momentum operator at build time "
1214 "(idiag = rho/dt), so a set_dt must be followed by set_solid or the operator stays "
1215 "stale. set_solid reallocates and zeroes u and p, so a dt SWITCH mid-march is: read "
1216 "velocity()/pressure() -> set_dt -> set_solid -> set_velocity()/set_pressure(). That "
1217 "sequence is the dt-cycling protocol of the attractor-family batteries.")
1218 .def("begin_adapt", &Flow::begin_adapt,
1219 "Snapshot the octree topology + (u, p) ahead of an external mesh mutation (adapt / "
1220 "refine_to_sphere / refine_to_sdf / balance on the SAME Octree object). Pair with "
1221 "finish_adapt.")
1222 .def("finish_adapt", &Flow::finish_adapt, nb::arg("sdf"),
1223 "Rebuild the solver on the mutated octree and conservatively transfer the snapshotted "
1224 "u and p onto it (minmod-limited linear remap). `sdf` is re-sampled on the new leaves "
1225 "(pass the same geometry callable as set_solid); keep the cut band at the finest "
1226 "level on the new mesh (re-run refine_to_sdf on the geometry band after a "
1227 "solution-driven adapt). The advecting face field restarts from the cell average for "
1228 "one step.")
1229 .def("set_cf_scheme", &Flow::set_cf_scheme, nb::arg("scheme"),
1230 "Coarse/fine (2:1) interface scheme. 1 = Martin-Cartwright tangential quadratic, THE "
1231 "DEFAULT since 2026-09-21: second order, applied to the momentum diffusion, the "
1232 "divergence constraint and the pressure gradients (the pressure matrix/MG stays "
1233 "standard, which does not move the steady solution). 0 = the standard two-point flux, "
1234 "first-order at level boundaries, kept only to reproduce pre-2026-09-21 graded results. "
1235 "The default is INERT BY GEOMETRY on a uniform or finest-band mesh -- no C/F faces, no "
1236 "delta -- and matters where a 2:1 interface sees tangential variation, which on a "
1237 "self-similar graded ladder is the difference between order 1.60 and 0.41. Works with "
1238 "both the aperture and the ghost projection. The seam reconstruction of the advected "
1239 "value "
1240 "(diagnostics.set_seam_reconstruction) rides on the quadratic scheme: with 0 no seam "
1241 "tables are built. Call before set_solid.")
1242 .def("set_advection_scheme", &Flow::set_advection_scheme, nb::arg("scheme"),
1243 "High-order advection flux: 0 = second-order upwind (default), 1 = Koren TVD.")
1244 .def(
1245 "set_implicit_advection", &Flow::set_implicit_advection, nb::arg("on"),
1246 "Implicit first-order-upwind deferred-correction advection (default on): unconditionally "
1247 "stable. Off = fully explicit high-order advection.")
1248 .def("set_outer_iterations", &Flow::set_outer_iterations, nb::arg("n"), nb::arg("tol") = 1e-6,
1249 "Picard outer iterations over the lagged advection per step (default 1).")
1250 .def("set_pressure_tolerance", &Flow::set_pressure_tolerance, nb::arg("rtol"),
1251 "Relative tolerance of the PRESSURE solve, for both drivers -- the MG-PCG default and "
1252 "the ghost projection's BiCGStab (flow shares its set_pressure_pcg tolerance with its "
1253 "ghost BiCGStab the same way). Default 1e-10, the value that was hard-coded until "
1254 "2026-09-21, so leaving it alone reproduces every earlier result. The iteration CAP is "
1255 "step()'s `pres_iters`; this is the accuracy it works to, and loosening it is the "
1256 "cheapest cost knob in the step.")
1257 .def("set_pressure_pcg", &Flow::set_pressure_pcg, nb::arg("on"),
1258 "The pressure-solve DRIVER of the aperture projection. on=True (THE DEFAULT) is "
1259 "MG-preconditioned conjugate gradients: one V-cycle of the pressure multigrid "
1260 "preconditions CG, the fluid-mean component of the residual is removed every iteration "
1261 "(an aperture cut-cell right-hand side is incompatible by exactly that component), and "
1262 "the solve stops at set_pressure_tolerance or at step()'s / project()'s `pres_iters`. "
1263 "on=False is the bounded stationary V-cycle: exactly `pres_iters` V-cycles, with no "
1264 "tolerance test and no mean removal, so on cut-cell geometry its residual STALLS at "
1265 "the incompatible component instead of converging. Both solve the same pressure "
1266 "equation, so where both converge they give the same projected velocity to the solve "
1267 "tolerance; the choice moves cost and robustness, not the answer. Leave it on: "
1268 "on=False exists to A/B the driver on a compatible problem. It has NO effect under the "
1269 "ghost projection -- the default scheme -- whose nonsymmetric operator is always "
1270 "solved by MG-preconditioned BiCGStab; it acts with set_ghost_projection(False) or "
1271 "where the default fell back to the aperture projection. flow's "
1272 "`Solver.set_pressure_pcg` is the same selector, but flow has no stationary driver "
1273 "(on=False raises there) and takes the cap and tolerance as its max_iter / rtol "
1274 "arguments. Takes effect at the next step()/project(); on a DISTRIBUTED Flow pass "
1275 "the same value on every rank.")
1276 .def("set_momentum_tolerance", &Flow::set_momentum_tolerance, nb::arg("rtol"),
1277 "Relative tolerance of the per-component MOMENTUM solve (BiCGStab, MG-preconditioned "
1278 "by default). Default 1e-8. At the large dt used for steady drag the momentum operator "
1279 "degrades toward a bare elliptic Laplacian and the solve gets expensive; this bounds "
1280 "the over-solve. The cap is step()'s `mom_iters`. (flow spells the same concept "
1281 "set_velocity_residual_tolerance -- the momentum/velocity divergence between the two "
1282 "codes is a ../docs/NAMING.md item, not settled here.)")
1283 .def("set_pressure_bottom", &Flow::set_pressure_bottom, nb::arg("mode"),
1284 "What solves the coarsest level of the pressure multigrid (docs/amr_mg_depth.md "
1285 "§6.6). A V-cycle converges at a mesh-independent rate only if its coarsest level is "
1286 "effectively solved, and 60 damped-Jacobi sweeps solve a level only up to ~4 cells per "
1287 "axis (8e-9 at 4, 8e-3 at 8, 0.6 at 25). 'auto' (THE DEFAULT) engages the agglomerated "
1288 "GraphAMG-PCG bottom iff the coarsest level still has more than "
1289 "`pressure_bottom_extent` cells on some axis -- i.e. where the ladder ran out on an odd "
1290 "or badly factored grid -- and keeps the sweeps otherwise; 'smoother' always sweeps; "
1291 "'agglomerated' always solves exactly. The choice changes the preconditioner, not the "
1292 "converged pressure (to the solve tolerance), and at the default extent it does not "
1293 "change the iteration count either (§11.4(a): identical counts in all nine measured "
1294 "'smoother'/'agglomerated' pairs); leave it at 'auto' unless you are comparing bottom "
1295 "solves. The same three strings and default as flow's `Solver.set_pressure_bottom`. "
1296 "Takes effect at once on the hierarchy already built and is kept for later set_solid "
1297 "calls. On a DISTRIBUTED Flow call it on every rank: the exact bottom lives in the "
1298 "replicated stage's continued ladder, which the call may build. Raises on any other "
1299 "string.")
1300 .def("set_pressure_bottom_extent", &Flow::set_pressure_bottom_extent, nb::arg("cells"),
1301 "Where the pressure ladder stops coarsening below the root brick and hands over to the "
1302 "bottom solve, in CELLS PER AXIS of the coarsest level -- a count, not a length: the "
1303 "limit is the bottom smoother's, which solves a level only up to ~4 cells per axis "
1304 "whatever the physical domain (../docs/NAMING.md §1.8; docs/amr_mg_depth.md "
1305 "§6.2/§11.4). The ladder stops once no axis has more than `cells` cells, and "
1306 "set_pressure_bottom('auto') engages the exact bottom where it stopped above it. "
1307 "Default 4, the value at which the 60 bottom sweeps are exact; it is measured rather "
1308 "than preferred (§11.4: 8 moves the iteration count by ~1 on one case in three; "
1309 "tests/study/amr_pressure_depth.py --sweep), so there is rarely a reason to change it. "
1310 "Changes the preconditioner, not the converged pressure (to the solve tolerance). "
1311 "Takes effect at the NEXT set_solid, where the ladder is built. Raises if cells < 1.")
1312 .def_prop_ro("pressure_bottom_extent", &Flow::pressure_bottom_extent,
1313 "The bottom extent, in cells per axis (a count, not a length), that "
1314 "set_pressure_bottom_extent last stored (default 4) -- the one the next "
1315 "set_solid builds the ladder with (docs/amr_mg_depth.md §6.8).")
1316 .def("step", &Flow::step, nb::arg("mom_iters") = 100, nb::arg("pres_iters") = 60,
1317 "Advance one collocated projection step of length dt on device. `mom_iters` caps the "
1318 "momentum solve (BiCGStab, MG-preconditioned) and `pres_iters` the pressure solve "
1319 "(MG-PCG, or BiCGStab under the ghost projection); each stops earlier at its "
1320 "set_momentum_tolerance / set_pressure_tolerance. Collective on a distributed Flow.")
1321 .def("velocity", &Flow::velocity, nb::arg("component"),
1322 "Per-leaf velocity component (0=x,1=y,2=z), (num_leaves,) float64.")
1323 .def("velocities", &Flow::velocities,
1324 "All three velocity components, (num_leaves, 3) float64.")
1325 .def("is_fluid", &Flow::is_fluid,
1326 "Per-leaf fluid mask (False in the solid), (num_leaves,) bool.")
1327 .def("divergence_norm", &Flow::divergence_norm,
1328 "Volume-weighted L2 norm of the residual cell divergence (projection-quality "
1329 "diagnostic).")
1330 .def("pressure", &Flow::pressure,
1331 "Per-leaf pressure (incremental-rotational p), (num_leaves,) float64.")
1332 .def("face_field", &Flow::face_field,
1333 "ABC divergence-free FACE velocity, one value per CSR (sub)face (conservative flux / "
1334 "streamline post-processing).")
1335 .def("set_velocity", &Flow::set_velocity, nb::arg("component"), nb::arg("values"),
1336 "Write velocity component c (0=x,1=y,2=z) from a (num_leaves,) array — initial "
1337 "conditions, restart, or warm-start. Call before step()/project().")
1338 .def("project", &Flow::project, nb::arg("pres_iters") = 60,
1339 "Pressure projection only (no momentum solve) — project an externally-set velocity "
1340 "field to divergence-free. Returns nothing; read the result via "
1341 "velocity()/velocities().")
1342 .def_prop_ro(
1343 "diagnostics", [](Flow& f) { return FlowDiagnostics(f); }, nb::keep_alive<0, 1>(),
1344 "The developer tier (FlowDiagnostics, a view onto this Flow): iteration counts of the "
1345 "last step, the face-field divergence and topology, the C/F and seam-reconstruction "
1346 "censuses, the pressure-multigrid ladder and its bottom solver, and the "
1347 "solver-internals / ablation switches. Nothing here is needed to set up, run or read "
1348 "out a simulation; everything here has a production default.");
1349
1350 nb::class_<FlowDiagnostics>(
1351 m, "FlowDiagnostics",
1352 "Developer instruments and ablation switches of a Flow, reached as `flow.diagnostics` "
1353 "(suite/docs/QUALITY_PLAN.md D2: the public Flow surface is what a user needs to set up, "
1354 "run and read out a simulation; this is what a developer uses to inspect or ablate it).")
1355 .def("last_mom_iters", &FlowDiagnostics::last_mom_iters,
1356 "Total momentum BiCGStab iterations (summed over the 3 components) of the last step.")
1357 .def("last_pres_iters", &FlowDiagnostics::last_pres_iters,
1358 "Pressure PCG iterations of the last step.")
1359 .def("last_outer_iters", &FlowDiagnostics::last_outer_iters,
1360 "Picard outer iterations actually run in the last step (1 unless "
1361 "set_outer_iterations(>1)).")
1362 .def("divergence_norm_face", &FlowDiagnostics::divergence_norm_face,
1363 "L2 norm of the APERTURE-weighted divergence of the ABC face field. MEANINGFUL ON THE "
1364 "APERTURE PATH ONLY (set_ghost_projection(False)), where it is the pressure-solve "
1365 "residual, far below divergence_norm — including across 2:1 interfaces. Under the "
1366 "GHOST projection (the DEFAULT) the solved constraint is this divergence PLUS an "
1367 "overlay delta that is a functional of the CELL velocities, not of the face field, so "
1368 "this norm omits it and reads O(1) on a perfectly healthy solve (34 at N=32, 184 at "
1369 "N=64 on the Z&H sphere, with the velocities matching peclet.flow to 1e-6). There, "
1370 "divergence_norm() is the residual you want. Unnormalized either way: it grows with "
1371 "resolution and velocity magnitude, so read it as a trend, never as an absolute.")
1372 .def_prop_ro("num_cf_cut_faces", &FlowDiagnostics::num_cf_cut_faces,
1373 "How many 2:1 C/F sub-face slots of THIS RANK carry the standard two-point "
1374 "face value because the quadratic one is withheld: both incident cells must be "
1375 "REGULAR fluid (fluid and not cut) for the quadratic C/F scheme "
1376 "(set_cf_scheme(1), the default) to apply there, so this counts the sub-faces "
1377 "where a level boundary meets the wall (docs/amr_cf_flux_gate.md §6.5). 0 "
1378 "on every uniform or finest-band mesh; on a graded mesh it measures the size "
1379 "of the set that runs at the standard scheme's local order. Each sub-face "
1380 "contributes two slots (one per incident cell) and the sum over ranks equals "
1381 "the single-rank count.")
1382 .def("face_topology", &FlowDiagnostics::face_topology,
1383 "The face CSR topology face_field() is indexed by, as a dict of arrays: 'start' "
1384 "(num_leaves+1 row offsets, int64), 'nbr' (neighbour leaf per (sub)face, int64), "
1385 "'axis' (0/1/2, int32), 'dir' (+1/-1 from the owning cell toward the neighbour, "
1386 "int32), 'raw_area' (world area the ADVECTIVE flux uses -- the FINE area at a 2:1 "
1387 "sub-face, so a coarse face's four sub-faces sum to the coarse area), 'dist' (world "
1388 "centre distance, 1.5*h_fine at a 2:1 sub-face), 'alpha' (face openness in [0, 1]) and "
1389 "'upup_i' / 'upup_j' (the second upwind probes the SOU/Koren reconstruction samples, "
1390 "-1 where none). A 2:1 sub-face is a slot whose two incident leaves have different "
1391 "Octree.levels(); its centroid is the FINER leaf's face centre. Host-copied on every "
1392 "call -- a diagnostic, not a step-loop read-out. Under MPI a neighbour index >= "
1393 "num_leaves is a ghost slot of this rank's registry, whose world centre (like every "
1394 "slot's) is row `slot` of 'cell_center', an (num_leaves + num_ghost_cells, 3) array. "
1395 "The SEAM RECONSTRUCTION tables of docs/amr_cf_convective.md come with it: 'seam' (one "
1396 "descriptor id per face slot, -1 = a plain slot that takes the ordinary SOU/Koren "
1397 "line), the per-descriptor 'samp_i' / 'samp_j' (the tangential-sample record when i / "
1398 "j is the COARSE cell of a 2:1 sub-face, else -1), 'uu_rec_i' / 'uu_rec_j' (the "
1399 "upstream probe's record when the second upwind cell of i / j crosses a level, else "
1400 "-1) and 'd1_i' / 'd1_j' (world half width along the face axis), and the record CSR "
1401 "'rec_start', 'rec_cell', 'rec_w', 'rec_dist' (the world probe distance, used only "
1402 "where a record is an UPSTREAM probe). All empty with set_cf_scheme(0 = standard), "
1403 "which builds no tables. Rank-local under MPI.")
1404 .def_prop_ro("pressure_mg_levels", &FlowDiagnostics::pressure_mg_levels,
1405 "Leaf count of every pressure-multigrid level, finest first "
1406 "(docs/amr_mg_depth.md §6.7), as a list of int; empty before set_solid, which "
1407 "is where the hierarchy is built. Levels below the root brick are LIFTED levels "
1408 "— the same octree with its root halved — so a uniform mesh has a real "
1409 "hierarchy rather than a single level. Under MPI the in-place levels are THIS "
1410 "RANK's counts, the levels of a replicated tail (appended last) are GLOBAL "
1411 "counts, identical on every rank, and the levels of a sibling-merge or "
1412 "repartition stage are this rank's counts on its target block (0 on a rank "
1413 "that owns none). Check it against "
1414 "`peclet.amr.predict_hierarchy` (level count, and leaf for leaf on a "
1415 "uniform mesh at np=1), never against a literal.")
1416 .def_prop_ro("pressure_mg_bottom", &FlowDiagnostics::pressure_mg_bottom,
1417 "What solves the coarsest pressure level of the hierarchy AS BUILT: 'jacobi' "
1418 "(60 damped-Jacobi sweeps, effectively exact at <= 4 cells per axis: the "
1419 "slowest mode falls by 8e-9) or 'amg' (the agglomerated GraphAMG-PCG solve), "
1420 "with the suffix '+tail' when the coarsest level was gathered onto every rank "
1421 "by the replicated stage, '+sibling' / '+repartition' when a level moved onto "
1422 "fewer ranks (one suffix per stage, innermost first; docs/amr_mg_depth.md "
1423 "§6.5-§6.7, WO4b). Which one runs "
1424 "follows `Flow.set_pressure_bottom` (default 'auto': the exact bottom engages "
1425 "only where the ladder ran out above `Flow.pressure_bottom_extent` cells per "
1426 "axis). 'jacobi' before set_solid.")
1427 .def_prop_ro("num_seam_sample_records", &FlowDiagnostics::num_seam_sample_records,
1428 "How many tangential-sample records the seam reconstruction built on THIS RANK "
1429 "-- one per 2:1 sub-face pair that passes the C/F face gate (both cells regular "
1430 "fluid). 0 on a uniform mesh and with the standard C/F scheme.")
1431 .def_prop_ro("num_seam_layer_records", &FlowDiagnostics::num_seam_layer_records,
1432 "How many face-layer records the seam reconstruction built on THIS RANK -- one "
1433 "per (coarse cell, face) whose far side is refined and whose four fine cells "
1434 "are fluid (the case-3 upstream probe).")
1435 .def("set_momentum_mg", &FlowDiagnostics::set_momentum_mg, nb::arg("on"),
1436 "Use the Galerkin velocity multigrid as the momentum solve preconditioner (default on; "
1437 "makes the momentum solve scale with resolution). Call before set_solid.")
1438 .def("set_momentum_gs", &FlowDiagnostics::set_momentum_gs, nb::arg("on"),
1439 "Use the symmetric multicolour Gauss-Seidel smoother in the momentum multigrid (default "
1440 "off = weighted Jacobi). Call before set_solid.")
1441 .def("set_velocity_mg_staircase", &FlowDiagnostics::set_velocity_mg_staircase, nb::arg("on"),
1442 "Use the rediscretised staircase velocity-MG instead of Galerkin (default off).")
1443 .def("set_momentum_mg_solver", &FlowDiagnostics::set_momentum_mg_solver, nb::arg("on"),
1444 "Solve the momentum predictor with the velocity-MG as the solver (no Krylov), mirroring "
1445 "flow's velocity solve (default off = BiCgStab with the MG as preconditioner).")
1446 .def("set_ghost_gradient", &FlowDiagnostics::set_ghost_gradient, nb::arg("on"),
1447 "Directional ghost cell-gradient on cut cells for the pressure predictor and the "
1448 "projection's cell correction (2nd-order one-sided, never reads decoupled solid "
1449 "pressure — removes the gauge-dependent O(1/h) cut-cell gradient error of the plain "
1450 "ABC gradient). The ghost projection (the default) implies it; this switch matters for "
1451 "the aperture fallback only. Call before set_solid.")
1452 .def("set_aperture_order", &FlowDiagnostics::set_aperture_order, nb::arg("order"),
1453 "Aperture estimator for the (fallback) aperture projection: 2 = analytic "
1454 "marching-squares (DEFAULT since 2026-08-26), 1 = legacy one-sample model. "
1455 "Call before set_solid.")
1456 .def("set_uf_advection", &FlowDiagnostics::set_uf_advection, nb::arg("on"),
1457 "ABLATION. The advecting velocity of the momentum advection: the projected, "
1458 "divergence-free face field uf (ON, the shipped Almgren-Bell-Colella scheme) or, with "
1459 "on=False, the un-projected 1/2(u_i+u_j) cell->face average that the first step uses "
1460 "before any projection has run. It is the ONE discretization difference between this "
1461 "solver and peclet.flow's SolverColocated on a uniform grid; turning it off makes the "
1462 "two agree to solver tolerance (docs/amr_flow_uniform_parity.md).")
1463 .def("set_seam_reconstruction", &FlowDiagnostics::set_seam_reconstruction, nb::arg("on"),
1464 "ABLATION. Reconstruct the ADVECTED value with level-aware probes at every face whose "
1465 "upwind-side stencil crosses a 2:1 coarse/fine seam (default ON, "
1466 "docs/amr_cf_convective.md): the coarse upwind cell is tangentially sampled at the "
1467 "sub-face's own column, and an upstream probe at another octree level is taken at its "
1468 "TRUE distance (a coarser one as the same tangential sample, a finer one as the mean of "
1469 "the four face-layer children). Without it the sub-face value carries the coarse "
1470 "column's tangential offset -- an O(h) face error, so an O(1) local truncation on the "
1471 "fine side of the seam. on=False restores that, bit for bit, and may be flipped between "
1472 "steps; the tables are built in set_solid either way. Inert with advection off, with "
1473 "set_cf_scheme(0 = standard), and on any mesh with no 2:1 face. Leave it on in "
1474 "production: the switch exists for the A/B of docs/amr_cf_convective.md §12, where it "
1475 "cuts the one-step seam truncation 7.9e-3 -> 2.0e-3 at no measurable cost.");
1476
1477 nb::class_<DistributedOctree> distributed(
1478 m, "DistributedOctree",
1479 "MPI octree: an ORB block decomposition of a global root grid (one BlockOctree per rank, "
1480 "over MPI_COMM_WORLD). Construct it collectively; "
1481 "refine/balance/rebalance/face_neighbor_gather are collective. Per-leaf arrays describe "
1482 "THIS rank's local block in global world coordinates.");
1484 .def(
1485 "__init__",
1486 [](DistributedOctree* self, std::array<long, 3> cells, unsigned lmax,
1487 std::array<double, 3> origin, std::optional<SpacingArg> spacing,
1488 std::array<bool, 3> periodic, std::optional<std::array<double, 3>> extent) {
1489 const auto root = rootCellsOf(cells, lmax, "DistributedOctree");
1490 new (self) DistributedOctree(
1491 root, lmax, origin, resolveSpacing(spacing, extent, cells, "DistributedOctree"),
1492 periodic);
1493 },
1494 nb::arg("cells"), nb::kw_only(), nb::arg("lmax") = 0u,
1495 nb::arg("origin") = std::array<double, 3>{0, 0, 0}, nb::arg("spacing") = nb::none(),
1496 nb::arg("periodic") = std::array<bool, 3>{true, true, true},
1497 nb::arg("extent") = nb::none(),
1498 "Decompose a global grid of `cells` FINEST cells per axis (root cells = cells/2**lmax, "
1499 "each `lmax` levels deep) across the ranks of MPI_COMM_WORLD via ORB. `origin` places "
1500 "the global grid; `periodic` per axis. Every argument after `cells` is keyword-only.\n\n"
1501 "State the domain PHYSICALLY with `extent` = the global box side lengths; the cell size "
1502 "is then extent/cells PER AXIS. Alternatively give `spacing` (one number or (dx, dy, "
1503 "dz)); default 1. Passing both raises. Read the three numbers back from `.spacing`.")
1504 .def_prop_ro("rank", &DistributedOctree::rank, "This process's MPI rank.")
1505 .def_prop_ro("size", &DistributedOctree::size, "Number of ranks (blocks).")
1506 .def_prop_ro("block_origin_root", &DistributedOctree::block_origin_root,
1507 "This rank's block lower corner, in global root-cell coordinates.")
1508 .def_prop_ro("block_brick", &DistributedOctree::block_brick,
1509 "This rank's block size in root cells per axis.")
1510 .def_prop_ro("global_root_size", &DistributedOctree::global_root_size,
1511 "Global grid size in root cells per axis.")
1512 .def("rebalance", &DistributedOctree::rebalance, nb::arg("fields"),
1513 "Re-decompose by leaf count (weighted ORB) and migrate leaves + their fields. `fields` "
1514 "is (num_leaves, K) float64; returns this rank's (M, K) columns after migration. Pure "
1515 "redistribution; the partition is updated in place (collective).")
1516 .def("face_neighbor_gather", &DistributedOctree::face_neighbor_gather, nb::arg("field"),
1517 nb::arg("sentinel") = 0.0,
1518 "For each local leaf, the field value across each of its 6 faces, gathered over the "
1519 "owner-based halo. `field` is (num_leaves,); returns (num_leaves, 6) laid out "
1520 "[+x,-x,+y,-y,+z,-z]; domain boundaries carry `sentinel` (collective).");
1521 bindOctreeCommon(distributed);
1522}
NB_MODULE(_amr, m)
void setImplicitAdvection(bool on)
Implicit-FOU deferred correction (default ON).
Definition flow.hpp:641
Index numSeamSampleRecords() const
Seam-reconstruction census (docs/amr_cf_convective.md §5.2): how many sample records (one per 2:1 sub...
Definition flow.hpp:1973
void setApertureOrder(int order)
FULL directional ghost-cell projection (the AMR port of flow's collocated set_ghost_projection): the ...
Definition flow.hpp:596
void setMomentumMG(bool on)
Use the Galerkin velocity multigrid (MomentumMG) as the momentum BiCGStab preconditioner.
Definition flow.hpp:686
std::vector< double > velocity(int c) const
Copy a velocity component back to host (single D2H, no host loop — S2a).
Definition flow.hpp:1762
void finishAdapt(SdfFn &&sdfFn)
Rebuild on the mutated octree and transfer the snapshotted fields onto it.
Definition flow.hpp:1631
void setGhostGradient(bool on)
Directional ghost cell-gradient for the −∇pⁿ predictor and the projection's cell correction (the AMR ...
Definition flow.hpp:566
void step(int momIters=100, int presIters=60)
Definition flow.hpp:1282
void initMpi(DistributedOctree< 3, Bits > &d)
Distributed mode (docs/amr_distributed_flow.md, rung 4): run this solver on one ORB block of a Distri...
Definition flow.hpp:533
void setBodyForce(double fx, double fy, double fz)
Definition flow.hpp:549
void setPressurePCG(bool on)
Use MG-preconditioned CG for the pressure solve (default) vs plain V-cycles.
Definition flow.hpp:551
std::vector< double > velocities() const
All three velocity components interleaved as a flat (n,3) row-major host buffer (out[i*3+c]) with a s...
Definition flow.hpp:1781
std::vector< double > pressure() const
Copy the pressure field back to host (single D2H), (num_leaves,) — the incremental-rotational p.
Definition flow.hpp:1765
void setPressureTol(double tol)
Relative tolerance of the PRESSURE solve, for both drivers: the MG-PCG (the default) and the ghost pr...
Definition flow.hpp:660
void setVelocity(int c, const std::vector< double > &h)
Set a velocity component from host (testing / initial conditions).
Definition flow.hpp:1754
void setDt(double dt)
Definition flow.hpp:548
bool isFluid(Index i) const
Per-leaf fluid mask (false inside the solid) — for host-side post-processing / bindings.
Definition flow.hpp:1992
double divNormL2()
L2 norm of the (openness-weighted) divergence of the current velocity — the ghost-closed divergence w...
Definition flow.hpp:1795
void setAdvectionScheme(int s)
High-order advection scheme: 0 = second-order upwind (SOU, default), 1 = Koren TVD.
Definition flow.hpp:643
void setViscosity(double mu)
Definition flow.hpp:547
void setGhostProjection(bool on, int matrixOrder=2, int rhsOrder=2)
Definition flow.hpp:597
Index pressureBottomExtent() const
The bottom extent the next setSolid builds the ladder with (default 4).
Definition flow.hpp:1900
Index numSeamLayerRecords() const
The face-layer half of the census: one record per (regular coarse cell, face) whose far side is refin...
Definition flow.hpp:1976
int lastOuterIters() const
Picard outer iterations actually run in the last step (1 unless setOuterIterations(>1)).
Definition flow.hpp:1998
void setDensity(double rho)
Definition flow.hpp:546
Index numCfCutFaces() const
The C/F census (docs/amr_cf_flux_gate.md §6.5): owned forEachFaceFull slots on a 2:1 sub-face between...
Definition flow.hpp:1990
double divNormFace()
L2 norm of the APERTURE-weighted divergence of the ABC face field uf_ (built each project()).
Definition flow.hpp:1824
void setPressureBottom(const std::string &kind)
What solves the coarsest pressure level (docs/amr_mg_depth.md §6.6/§11.6): "auto" (the default — the ...
Definition flow.hpp:1868
std::string pressureMgBottom() const
What actually solves the coarsest pressure level (docs/amr_mg_depth.md §6.6/§6.7): "jacobi" (60 dampe...
Definition flow.hpp:1852
void project(int presIters=60)
Pressure projection of the current velocity in place.
Definition flow.hpp:1406
std::vector< double > faceField() const
Copy the divergence-free face field to host (one value per CSR (sub)face, forEachFaceFull order).
Definition flow.hpp:1904
void setMomentumTol(double tol)
Relative tolerance for the per-step momentum BiCGStab solve (default 1e-8).
Definition flow.hpp:653
void setSeamReconstruction(bool on)
ABLATION (docs/amr_cf_convective.md §5.5, Q-C).
Definition flow.hpp:678
void setPressureBottomExtent(Index e)
Where the pressure ladder stops lifting the root and hands over to the bottom (docs/amr_mg_depth....
Definition flow.hpp:1894
Index numLeaves() const
Definition flow.hpp:1977
void setUfAdvection(bool on)
ABLATION (developer tier).
Definition flow.hpp:669
void setPressure(const std::vector< double > &h)
Write the accumulated rotational pressure from host (restart / finishAdapt).
Definition flow.hpp:1679
void setVelocityMGStaircase(bool on)
Choose the momentum-MG coarse-operator strategy: false (default) = Galerkin (MomentumMG,...
Definition flow.hpp:692
int lastPresIters() const
Pressure PCG iterations of the last step.
Definition flow.hpp:1996
void rebalanceMpi(SdfFn &&sdfFn)
Distributed load rebalance (docs/amr_distributed_flow.md, rung 6): re-decompose the octree by leaf co...
Definition flow.hpp:1661
std::vector< Index > pressureMgLevels() const
The pressure multigrid's ladder as BUILT on this rank: per-level leaf counts, level 0 first (docs/amr...
Definition flow.hpp:1834
int lastMomIters() const
Total momentum BiCGStab iterations (summed over the 3 components) of the last step.
Definition flow.hpp:1994
void beginAdapt()
Snapshot the octree topology + (u, p) ahead of an external mesh mutation.
Definition flow.hpp:1611
void setGhostSampled(bool on, double rho=2.2, long maxSamples=0)
SAMPLED ghost projection: the mixed-level cut-band mode (docs/amr_mixed_level_cut_band_plan....
Definition flow.hpp:613
void setMomentumGS(bool on)
Opt-in: use the multicolour Gauss–Seidel smoother in the momentum MG (Galerkin or staircase) instead ...
Definition flow.hpp:701
void setMomentumMGSolver(bool on)
Opt-in (P4): solve the momentum predictor with the velocity multigrid used as the solver — MG-precond...
Definition flow.hpp:715
void setAdvection(bool on)
Enable momentum advection ∇·(u u) (default OFF ⇒ Stokes).
Definition flow.hpp:638
void setCfScheme(int scheme)
Coarse/fine (2:1) interface scheme (cf_scheme.hpp): 1 = Martin–Cartwright tangential quadratic,...
Definition flow.hpp:631
void setSolid(SdfFn &&sdfFn)
Build the cut-cell operators (host) + upload all device structures.
Definition flow.hpp:730
void setOuterIterations(int n, double tol=1e-6)
Optional Picard outer loop over the lagged advection (mirror of flow's outerIters_): each outer itera...
Definition flow.hpp:722
FaceTopologyHost faceTopology() const
Definition flow.hpp:1930
void init(const Octree &t, Real h0, Vec< 3 > origin=Vec< 3 >{})
Cubic-cell overload — the pre-Phase-3 spelling (docs/amr_anisotropic.md §2).
Definition flow.hpp:515
Geometric multigrid for AmrPoisson over a uniformly-coarsened octree hierarchy.
Definition poisson.hpp:634
void setPeriodic(bool p)
Apply a boundary condition to every level (periodic default, or non-periodic homogeneous Dirichlet).
Definition poisson.hpp:725
void vcycle(std::size_t L, std::vector< double > &u, const std::vector< double > &rhs, int pre=2, int post=2)
One V-cycle on level L solving L u = rhs (correction scheme).
Definition poisson.hpp:739
const AmrPoisson< Dim, Bits > & op(std::size_t L=0) const
Definition poisson.hpp:721
std::size_t numLevels() const
Definition poisson.hpp:720
void build(const Octree &finest, Real h0, bool liftRoot=true, Index bottomExtent=4)
Build the hierarchy from a finest octree by uniform coarsening until a single leaf remains (or no ful...
Definition poisson.hpp:667
Index balance2to1()
Enforce 2:1 (graded) balance within this block: no two face-adjacent leaves differ by more than one l...
bool isBalanced() const
True iff the leaves form a valid 2:1-balanced partition: every face neighbour differs by at most one ...
bool refineLeaf(Index i)
Refine a single leaf by index. Returns true if it was split.
const IVec< Dim > & brick() const
Index find(Code p) const
Leaf containing Morton code p, or -1. Host wrapper over amrLocate.
void init(IVec< Dim > brick, unsigned lmax, IVec< Dim > globalOrigin=IVec< Dim >{})
typename M::coord_type Coord
typename M::code_type Code
std::vector< double > faceNeighborGather(const std::vector< double > &field, double sentinel=kNoNeighbor) const
For each local leaf and each of the 2*Dim faces, the neighbouring leaf's field value.
const IVec< Dim > & globalRootSize() const
Index balance()
Bring the whole distributed octree to a 2:1-balanced state.
Index rebalance(std::vector< std::vector< double > > &fields)
Re-decompose by per-root-cell octree-leaf count and migrate leaves (with their field columns) to the ...
const Vec< Dim > & h0() const
Phase 3: the FINEST spacing per axis (docs/amr_anisotropic.md §2).
const AmrGeometry< Dim > & globalGeometry() const
AmrGeometry< Dim > localGeometry() const
World geometry of this rank's block (the global geometry shifted to its origin).
void init(IVec< Dim > globalRootSize, unsigned lmax, AmrGeometry< Dim > globalGeo, std::array< bool, Dim > periodic, MPI_Comm comm=MPI_COMM_WORLD)
Decompose a globalRootSize grid of root cells over the communicator and build this rank's local block...
const IVec< Dim > & globalFineSize() const
const IVec< Dim > & blockBrick() const
const IVec< Dim > & blockOriginRoot() const
std::array< long, 3 > global_root_size() const
std::array< long, 3 > block_origin_root() const
std::array< long, 3 > block_brick() const
DO & ref()
The wrapped core octree (the distributed Flow constructor hooks into it).
void write_vtu(const std::string &path, const std::string &name, DArray field)
nb::ndarray< nb::numpy, std::int32_t > levels() const
std::array< double, 3 > spacing() const
nb::ndarray< nb::numpy, double > rebalance(DArray fields)
nb::ndarray< nb::numpy, double > lohner_indicator(DArray field, double eps)
DistributedOctree(std::array< long, 3 > global_root, unsigned lmax, std::array< double, 3 > origin, std::array< double, 3 > spacing, std::array< bool, 3 > periodic)
Index refine_to_sphere(std::array< double, 3 > center, double radius, unsigned target_level, double band, bool balance)
std::array< long, 3 > cells() const
The GLOBAL finest grid: global root cells * 2**lmax per axis (what the constructor took).
std::array< double, 3 > origin() const
The global lower corner.
Index refine_to_sdf(std::function< double(double, double, double)> sdf, unsigned target_level, double band, bool balance)
nb::ndarray< nb::numpy, double > centers() const
nb::ndarray< nb::numpy, double > face_neighbor_gather(DArray field, double sentinel)
nb::ndarray< nb::numpy, double > sizes(int axis) const
nb::ndarray< nb::numpy, double > adapt(DArray field, double refine_thresh, double coarsen_thresh, unsigned finest_level, double eps, bool linear)
nb::ndarray< nb::numpy, std::uint64_t > codes() const
std::array< double, 3 > extent() const
The global box side lengths, cells * spacing.
peclet::core::Index num_seam_sample_records() const
std::vector< peclet::core::Index > pressure_mg_levels() const
peclet::core::Index num_cf_cut_faces() const
peclet::core::Index num_seam_layer_records() const
void set_pressure_tolerance(double rtol)
void set_solid(std::function< double(double, double, double)> sdf)
void set_implicit_advection(bool on)
void set_pressure_bottom(const std::string &mode)
void set_ghost_sampled(bool on, double rho, long max_samples)
nb::ndarray< nb::numpy, double > pressure() const
void set_solid_spheres(nb::ndarray< const double, nb::c_contig > centers, nb::ndarray< const double, nb::c_contig > radii, bool periodic)
nb::ndarray< nb::numpy, double > face_field() const
void set_body_force(double fx, double fy, double fz)
nb::ndarray< nb::numpy, bool > is_fluid() const
void set_velocity(int c, nb::ndarray< double, nb::c_contig > h)
void set_momentum_tolerance(double rtol)
void project(int pres_iters)
long pressure_bottom_extent() const
nb::ndarray< nb::numpy, double > velocity(int c) const
nb::ndarray< nb::numpy, double > velocities() const
void set_outer_iterations(int n, double tol)
void rebalance_mpi(std::function< double(double, double, double)> sdf)
void release() noexcept override
void step(int mom_iters, int pres_iters)
void set_pressure_bottom_extent(long cells)
Flow(const Octree &oct, double rho, double mu, double dt)
void set_ghost_projection(bool on, int matrix_order, int rhs_order)
void set_cf_scheme(int scheme)
amr::AmrFlow & engine()
The developer tier (QUALITY_PLAN D2): FlowDiagnostics reaches the solver through this.
void finish_adapt(std::function< double(double, double, double)> sdf)
void set_pressure(nb::ndarray< double, nb::c_contig > h)
std::array< double, 3 > spacing() const
Index refine_to_sphere(std::array< double, 3 > center, double radius, unsigned target_level, double band, bool balance)
Index refine_to_gap_floor(std::function< double(double, double, double)> sdf, std::function< double(double, double, double)> gap, unsigned coarsest_level, double n, double band, bool balance)
nb::ndarray< nb::numpy, std::uint64_t > codes() const
nb::ndarray< nb::numpy, std::int32_t > levels() const
Index find(std::array< double, 3 > x) const
nb::ndarray< nb::numpy, double > centers() const
const BO & octreeRef() const
Index refine_to_sdf_graded(std::function< double(double, double, double)> sdf, std::function< unsigned(double, double, double)> target_level, double band, bool balance)
nb::ndarray< nb::numpy, double > lohner_indicator(DArray field, double eps)
void release() noexcept override
void write_vtu(const std::string &path, const std::string &name, DArray field)
std::array< long, 3 > cells() const
The FINEST-level cell counts the block resolves: root cells * 2**lmax per axis.
std::array< double, 3 > origin() const
Octree(std::array< long, 3 > root, unsigned lmax, std::array< double, 3 > origin, std::array< double, 3 > spacing)
Index refine_to_sdf(std::function< double(double, double, double)> sdf, unsigned target_level, double band, bool balance)
std::array< double, 3 > extent() const
The block's physical side lengths, cells * spacing.
nb::ndarray< nb::numpy, double > sizes(int axis) const
const amr::AmrGeometry< 3 > & geoRef() const
nb::ndarray< nb::numpy, double > adapt(DArray field, double refine_thresh, double coarsen_thresh, unsigned finest_level, double eps, bool linear)
nb::tuple solve(DArray rhs, std::optional< DArray > x0, int cycles, int pre, int post, double tol)
void release() noexcept override
std::size_t num_levels() const
Poisson(const Octree &oct, bool periodic)
double residual(DArray u, DArray rhs)
nb::ndarray< nb::numpy, double > apply(DArray u)
nb::ndarray< nb::numpy, double > leafCenters(const BO &t, const amr::AmrGeometry< 3 > &geo)
nb::ndarray< nb::numpy, std::uint64_t > leafCodes(const BO &t)
std::array< double, 3 > spacingOf(const SpacingArg &sp)
nb::ndarray< nb::numpy, double > vecToArray(std::vector< double > v)
double spacingFromExtent(const std::array< double, 3 > &extent, const std::array< long, 3 > &cells)
std::array< double, 3 > resolveSpacing(const std::optional< SpacingArg > &spacing, const std::optional< std::array< double, 3 > > &extent, const std::array< long, 3 > &cells, const char *who)
std::array< double, 3 > spacingsFromExtent(const std::array< double, 3 > &extent, const std::array< long, 3 > &cells)
std::array< long, 3 > rootCellsOf(const std::array< long, 3 > &cells, unsigned lmax, const char *who)
void bindOctreeCommon(nb::class_< T > &c)
nb::ndarray< double, nb::c_contig > DArray
nb::ndarray< nb::numpy, T > vec2(std::vector< T > v, std::size_t cols)
nb::ndarray< nb::numpy, double > leafSizes(const BO &t, const amr::AmrGeometry< 3 > &geo, int axis)
std::variant< double, std::array< double, 3 > > SpacingArg
nb::ndarray< nb::numpy, std::int32_t > leafLevels(const BO &t)
nb::ndarray< nb::numpy, T > vec1(std::vector< T > v)
std::vector< double > asField(const BO &t, const DArray &field, const char *who)
const char * toString(MgLevelKind k)
Index refineToSdf(BlockOctree< Dim, Bits > &t, const AmrGeometry< Dim > &geo, SdfFn &&sdf, unsigned targetLevel, Real band=1.0, bool balance=true)
Refine leaves near an SDF interface down to targetLevel.
Definition refine.hpp:34
std::vector< double > lohnerIndicator(const BlockOctree< Dim, Bits > &t, const std::vector< double > &u, double eps=0.01)
Löhner normalized second-difference indicator E_i ∈ [0,1], per leaf, for scalar u (indexed by leaf sl...
std::vector< double > distributedAdapt(DistributedOctree< Dim, Bits > &d, const std::vector< double > &f, double refineThresh, double coarsenThresh, unsigned finestLevel=0, double eps=0.01, bool linear=true)
One distributed solution-adaptive step.
@ Octree
a coarsening of the octree itself (at or above the root brick)
Index refineToSdfGraded(BlockOctree< Dim, Bits > &t, const AmrGeometry< Dim > &geo, SdfFn &&sdf, TargetFn &&targetFn, Real band=2.0, bool balance=true)
Graded variant of refineToSdf: a per-point TARGET LEVEL function instead of one global targetLevel — ...
Definition refine.hpp:85
AdaptResult< Dim, Bits > adapt(const BlockOctree< Dim, Bits > &t, const std::vector< double > &f, double refineThresh, double coarsenThresh, unsigned finestLevel=0, double eps=0.01, bool linear=true)
All-in-one solution-adaptive step: Löhner indicator → flags → adaptField.
Definition adapt.hpp:257
void writeVtu(const std::string &path, const BlockOctree< Dim, Bits > &t, const AmrGeometry< Dim > &geo, const std::string &name, const std::vector< double > &cellData)
Write a BlockOctree (+ world geometry + a per-leaf scalar) as a VTK UnstructuredGrid (....
Definition vtu_io.hpp:47
std::vector< std::array< double, Dim > > transferFieldGradients(const BlockOctree< Dim, Bits > &oldT, const std::vector< double > &oldF)
Per-old-leaf minmod prolongation gradients (per fine-coordinate unit) — transferField's stencil,...
Definition adapt.hpp:51
std::vector< double > lohnerIndicatorDistributed(const DistributedOctree< Dim, Bits > &d, const std::vector< double > &u, double eps=0.01)
Löhner indicator per local leaf, using the owner-based face-neighbour halo so cross-block neighbours ...
World-space placement of a block-local octree: fine coordinate (0,..,0) sits at origin,...