157 "pnm — pore-network extraction from SDF geometry (Kokkos).\n\n"
158 "Arrays are (Nz,Ny,Nx) C-order and every describing triple is z-y-x with a `_zyx` suffix. "
159 "Precision: the SDF is float32 and the geometry kernels compute in float32; origin_zyx / "
160 "spacing_zyx are doubles narrowed to float32 (pore centres and radii are float32 in the "
162 "unit system); the network-flow MAC fields (u, v, w, p, ox, oy, oz) are float64 and their "
163 "sums are double. SDF sign: negative inside the solid, positive in the pore space.";
168 peclet::core::python::install(m);
171 nb::class_<SDFReader>(m,
"SDFReader",
172 "VTI (VTK ImageData) reader for a signed-distance-field volume. Stateless: "
173 "the class exists only to hold read_vti as a static method, not to be "
174 "instantiated — call SDFReader.read_vti(filename) directly.")
177 [](
const std::string& filename) {
179 std::size_t shape[3]{(std::size_t)data->resolution[2], (std::size_t)data->resolution[1],
180 (std::size_t)data->resolution[0]};
181 std::vector<double> org{data->origin[2], data->origin[1], data->origin[0]};
182 std::vector<double> spc{data->spacing[2], data->spacing[1], data->spacing[0]};
185 nb::capsule owner(data, [](
void* p)
noexcept {
delete static_cast<SDFData*
>(p); });
186 nb::ndarray<nb::numpy, float> sdf_3d(data->sdf_values.data(), 3, shape, owner);
187 return nb::make_tuple(sdf_3d, org, spc);
189 "Reads a VTI (VTK ImageData) SDF volume. Returns the tuple (sdf, origin_zyx, "
190 "spacing_zyx), in that order: sdf a float32 (Nz,Ny,Nx) C-order array over the "
191 "reader's buffer (no copy), origin_zyx and spacing_zyx the z-y-x lists of the grid "
192 "origin and cell size (float64).");
194 nb::class_<Pore>(m,
"Pore",
195 "One detected pore: a strict local maximum of the SDF over its 26-neighbourhood "
196 "(periodic in all three directions). The centre is the peak voxel's position "
197 "plus a squared-SDF-weighted sub-voxel offset over the 3x3x3 stencil, in the "
198 "unit system of origin_zyx / spacing_zyx; the radius is the SDF value at the "
199 "peak, i.e. the physical inscribed-sphere radius. All fields are float32. "
200 "The centre is exposed as three self-named scalars x, y, z (not a triple), so "
201 "no axis order is implied and no _zyx suffix applies — the documented "
202 "exception to NAMING.md 1.7.")
206 [](
Pore* p,
float x,
float y,
float z,
float radius) {
new (p)
Pore{x, y, z, radius}; },
207 nb::arg(
"x"), nb::arg(
"y"), nb::arg(
"z"), nb::arg(
"radius"))
208 .def_rw(
"x", &
Pore::x,
"Centre x coordinate (physical units of origin_zyx / spacing_zyx).")
209 .def_rw(
"y", &
Pore::y,
"Centre y coordinate.")
210 .def_rw(
"z", &
Pore::z,
"Centre z coordinate.")
212 "Inscribed-sphere radius = the SDF value at the peak voxel (physical units).")
213 .def(
"__repr__", [](
const Pore& p) {
215 std::snprintf(buf,
sizeof buf,
"Pore(x=%g, y=%g, z=%g, radius=%g)", (
double)p.x,
216 (
double)p.y, (
double)p.z, (
double)p.radius);
217 return std::string(buf);
222 [](nb::ndarray<float, nb::c_contig> sdf, std::vector<double> origin_zyx,
223 std::vector<double> spacing_zyx) {
224 std::array<int, 3> res;
225 auto v =
to_sdf(sdf, res);
226 std::array<float, 3> org{(float)origin_zyx[2], (
float)origin_zyx[1], (float)origin_zyx[0]};
227 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
228 (float)spacing_zyx[0]};
231 nb::arg(
"sdf"), nb::arg(
"origin_zyx"), nb::arg(
"spacing_zyx"),
232 "Pore detection: every voxel with sdf > 0 that is a strict local maximum of the SDF over its "
233 "26 neighbours (periodic wrap in x, y, z; ties broken towards the higher flat index) becomes "
234 "a Pore. sdf is a float32 (Nz,Ny,Nx) C-order array; origin_zyx / spacing_zyx are the grid's "
235 "z-y-x origin and cell size (narrowed to float32). Returns the list of Pore(x, y, z, radius) "
236 "in the input unit system, in device-completion order (NOT sorted; sort by (z, y, x) for a "
237 "reproducible order). Capped at 1e6 pores.");
241 [](nb::ndarray<float, nb::c_contig> sdf, std::vector<double> spacing_zyx) {
242 std::array<int, 3> res;
243 auto v =
to_sdf(sdf, res);
244 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
245 (float)spacing_zyx[0]};
248 nb::arg(
"sdf"), nb::arg(
"spacing_zyx"),
249 "Marker-controlled watershed segmentation of the SDF grid. Returns an int32 (Nz,Ny,Nx) "
250 "C-order label array (sdf.shape; the same memory as the kernels' flat x-fastest label "
251 "vector — no copy, no reshape needed): pore voxels (sdf > 0) carry the "
252 "id of the pore basin they belong to, 1, 2, ... in first-encounter (flat-index) order of the "
253 "basin peaks — the same peaks extract_pores finds — assigned by a gradient-ascent path to "
254 "the local SDF maximum; solid voxels (sdf <= 0) carry the NEGATIVE id -1, -2, ... of their "
255 "connected solid grain (26-connected components of the deep solid, sdf < -1.5 * min "
256 "spacing, flooded outwards by a Jacobi min-label sweep), and 0 marks solid debris no grain "
257 "reached. Periodic in all three directions. spacing_zyx only sets the deep-solid marker "
262 [](nb::ndarray<const int, nb::c_contig> segmentation,
263 std::optional<std::vector<int>> shape_zyx) {
264 std::array<int, 3> res;
265 if (segmentation.ndim() == 3) {
266 res = {(int)segmentation.shape(2), (int)segmentation.shape(1),
267 (int)segmentation.shape(0)};
268 if (shape_zyx && (shape_zyx->size() != 3 || (*shape_zyx)[0] != res[2] ||
269 (*shape_zyx)[1] != res[1] || (*shape_zyx)[2] != res[0]))
270 throw std::runtime_error(
"shape_zyx does not match the (Nz,Ny,Nx) segmentation");
271 }
else if (segmentation.ndim() == 1) {
273 throw std::runtime_error(
"a flat segmentation needs shape_zyx = (Nz, Ny, Nx)");
274 if (shape_zyx->size() != 3)
275 throw std::runtime_error(
"shape_zyx must be (Nz, Ny, Nx)");
276 res = {(*shape_zyx)[2], (*shape_zyx)[1], (*shape_zyx)[0]};
277 if (segmentation.size() != std::size_t(res[0]) * res[1] * res[2])
278 throw std::runtime_error(
"segmentation length does not match shape_zyx");
280 throw std::runtime_error(
"segmentation must be (Nz,Ny,Nx) or flat");
285 nb::arg(
"segmentation"), nb::arg(
"shape_zyx") = nb::none(),
286 "Label adjacency of a segment_volume result: the sorted, unique (a, b) pairs with a < b of "
287 "labels that share a voxel face (+x, +y, +z, periodic wrap), as an (M,2) int32 array. "
288 "segmentation is the int32 (Nz,Ny,Nx) array segment_volume returned (read in place, no "
289 "copy); a flat x-fastest label vector is accepted too, then shape_zyx = (Nz, Ny, Nx) of "
290 "the grid it was made on (= sdf.shape) is required. Rows with a label <= 0 are pore-solid "
291 "(or grain-grain / debris) contacts; the pore-to-pore throats are the rows with both "
292 "labels > 0. One row per label pair (a per-PATCH throat list, which can repeat a pair, is "
293 "what extract_network_flow returns).");
297 "extract_pore_network",
298 [](nb::ndarray<float, nb::c_contig> sdf, std::vector<double> origin_zyx,
299 std::vector<double> spacing_zyx) {
300 std::array<int, 3> res;
301 auto v =
to_sdf(sdf, res);
302 std::array<float, 3> org{(float)origin_zyx[2], (
float)origin_zyx[1], (float)origin_zyx[0]};
303 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
304 (float)spacing_zyx[0]};
309 nb::arg(
"sdf"), nb::arg(
"origin_zyx"), nb::arg(
"spacing_zyx"),
310 "Fused extraction (SDF uploaded once, segmentation device-resident across stages): returns "
311 "the tuple (pores, segmentation, connections), in that order, each element exactly what "
312 "extract_pores, segment_volume and extract_topology return (list[Pore]; int32 (Nz,Ny,Nx) "
313 "array; (M,2) int32 array).\n\n"
314 "UNITS: everything is in the system `origin_zyx` / `spacing_zyx` and the SDF are stated in "
315 "(the VTI's own, straight from SDFReader.read_vti) — pore centres are physical coordinates "
316 "and a pore radius is the physical inscribed radius, not a voxel count. Pass "
317 "spacing_zyx = [1,1,1] and origin_zyx = [0,0,0] to work in voxels. Measured on "
318 "flow/data/packing_ring.vti: doubling the spacing, the origin and the SDF together leaves "
319 "the pore count (7199) and the throat-connection count (53020) unchanged and doubles every "
320 "radius BITWISE. One sub-voxel caveat: the centre's intra-cell offset is guarded by an "
321 "absolute `sw > 1e-6` on a squared-distance weight sum, so a near-degenerate peak's centre "
322 "can shift by a fraction of a cell (measured up to 0.37 cells) under a change of unit "
323 "system. Radii, counts and topology are unaffected.");
328 static const std::string kNetworkFlowDoc =
330 "Pore-network FLOW data from a MAC field on the same grid as the SDF: segments the SDF, "
331 "then returns per-pore-label centers/pressures (trilinear p at the basin peak, periodic) "
332 "and per-throat flow rates (sum of openness-weighted MAC face fluxes o*u*A over the "
333 "label-interface faces; positive from the lower to the higher label). All arrays are "
334 "(Nz,Ny,Nx) C-order on the SDF grid: pass flow's fields as get_uf().T, get_p().T, "
335 "get_ox().T, ... u(i,j,k) is the -x face velocity of cell (i,j,k) (flow's MAC layout); "
336 "omit ox/oy/oz for a fully open grid. grad_p_zyx adds the macroscopic gradient along "
337 "the min-image pore-to-pore vector to throat_dp (= P_i - P_j, drives flow i->j when "
338 "positive). pore_residual is the signed flux sum over each pore's whole boundary — "
339 "~solver tolerance when u is flow's projected divergence-free field. Throats are "
340 "PER-PATCH (a connected patch of interface faces): two disjoint interfaces between the "
341 "same two pores are separate parallel throats, so the throat list can repeat a label "
345 "extract_network_flow",
346 [](nb::ndarray<float, nb::c_contig> sdf, std::vector<double> origin_zyx,
347 std::vector<double> spacing_zyx, nb::ndarray<double, nb::c_contig> u,
348 nb::ndarray<double, nb::c_contig> v, nb::ndarray<double, nb::c_contig> w,
349 nb::ndarray<double, nb::c_contig> p, std::optional<nb::ndarray<double, nb::c_contig>> ox,
350 std::optional<nb::ndarray<double, nb::c_contig>> oy,
351 std::optional<nb::ndarray<double, nb::c_contig>> oz, std::vector<double> grad_p_zyx) {
352 std::array<int, 3> res;
353 auto sv =
to_sdf(sdf, res);
354 if ((
bool)ox != (
bool)oy || (
bool)ox != (
bool)oz)
355 throw std::runtime_error(
"pass all three openness arrays (ox,oy,oz) or none");
356 std::array<float, 3> org{(float)origin_zyx[2], (
float)origin_zyx[1], (float)origin_zyx[0]};
357 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
358 (float)spacing_zyx[0]};
359 std::array<double, 3> gp{grad_p_zyx[2], grad_p_zyx[1], grad_p_zyx[0]};
361 sv, res, org, spc,
to_field(u, res,
"u"),
to_field(v, res,
"v"),
to_field(w, res,
"w"),
362 to_field(p, res,
"p"), ox ?
to_field(*ox, res,
"ox") : std::vector<double>{},
363 oy ?
to_field(*oy, res,
"oy") : std::vector<double>{},
364 oz ?
to_field(*oz, res,
"oz") : std::vector<double>{}, gp);
367 nb::arg(
"sdf"), nb::arg(
"origin_zyx"), nb::arg(
"spacing_zyx"), nb::arg(
"u"), nb::arg(
"v"),
368 nb::arg(
"w"), nb::arg(
"p"), nb::arg(
"ox") = nb::none(), nb::arg(
"oy") = nb::none(),
369 nb::arg(
"oz") = nb::none(), nb::arg(
"grad_p_zyx") = std::vector<double>{0.0, 0.0, 0.0},
370 kNetworkFlowDoc.c_str());
379 [](std::vector<int> global_shape_zyx) {
381 if (global_shape_zyx.size() != 3)
382 throw std::runtime_error(
"global_shape_zyx must be (Nz, Ny, Nx)");
383 std::array<int, 3> gd{global_shape_zyx[2], global_shape_zyx[1], global_shape_zyx[0]};
384 std::array<int, 3> o{}, s{};
386 return nb::make_tuple(nb::make_tuple(o[2], o[1], o[0]), nb::make_tuple(s[2], s[1], s[0]));
388 nb::arg(
"global_shape_zyx"),
389 "This rank's ORB block of the global (Nz,Ny,Nx) grid (collective; MPI_Init is called if "
390 "needed): returns the tuple (offset_zyx, shape_zyx), in that order — two int 3-tuples, the "
391 "block's first VOXEL index per axis and its voxel count per axis. offset_zyx is an integer "
392 "grid offset, NOT the physical origin_zyx the extraction functions take (that stays the "
393 "global grid's). Slice the global SDF as sdf[oz:oz+sz, oy:oy+sy, ox:ox+sx] and pass the "
394 "block to extract_pore_network_mpi / extract_network_flow_mpi.");
396 "extract_pore_network_mpi",
397 [](nb::ndarray<float, nb::c_contig> sdf_local, std::vector<int> global_shape_zyx,
398 std::vector<double> origin_zyx, std::vector<double> spacing_zyx) {
400 std::array<int, 3> res;
401 auto v =
to_sdf(sdf_local, res);
402 std::array<int, 3> gd{global_shape_zyx[2], global_shape_zyx[1], global_shape_zyx[0]};
403 std::array<int, 3> bo{}, bs{};
406 throw std::runtime_error(
"sdf_local shape does not match this rank's mpi_block");
407 std::array<float, 3> org{(float)origin_zyx[2], (
float)origin_zyx[1], (float)origin_zyx[0]};
408 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
409 (float)spacing_zyx[0]};
414 nb::arg(
"sdf_local"), nb::arg(
"global_shape_zyx"), nb::arg(
"origin_zyx"),
415 nb::arg(
"spacing_zyx"),
416 "Distributed fused extraction (collective over MPI_COMM_WORLD). sdf_local is this rank's "
417 "ORB block (Nz,Ny,Nx C-order, from mpi_block); origin_zyx / spacing_zyx are the GLOBAL "
418 "grid's. Returns the tuple (pores, segmentation, connections), in that order: the pores "
419 "whose peak this rank owns (list[Pore]), this rank's block of the segmentation (int32 "
420 "array of sdf_local.shape, global label ids), and the GLOBAL connection list ((M,2) int32, "
421 "identical on every rank). Bit-exact to the single-rank extract_pore_network on the "
423 static const std::string kNetworkFlowMpiDoc =
425 "Distributed network-flow extraction (collective over MPI_COMM_WORLD): all arrays are "
426 "this rank's ORB block (Nz,Ny,Nx C-order, from mpi_block; MAC fields from a distributed "
427 "peclet.flow run on the SAME BlockDecomposer). Returns the same dict as "
428 "extract_network_flow, GLOBAL and identical on every rank.") +
431 "extract_network_flow_mpi",
432 [](nb::ndarray<float, nb::c_contig> sdf_local, std::vector<int> global_shape_zyx,
433 std::vector<double> origin_zyx, std::vector<double> spacing_zyx,
434 nb::ndarray<double, nb::c_contig> u, nb::ndarray<double, nb::c_contig> v,
435 nb::ndarray<double, nb::c_contig> w, nb::ndarray<double, nb::c_contig> p,
436 std::optional<nb::ndarray<double, nb::c_contig>> ox,
437 std::optional<nb::ndarray<double, nb::c_contig>> oy,
438 std::optional<nb::ndarray<double, nb::c_contig>> oz, std::vector<double> grad_p_zyx) {
440 std::array<int, 3> res;
441 auto sv =
to_sdf(sdf_local, res);
442 std::array<int, 3> gd{global_shape_zyx[2], global_shape_zyx[1], global_shape_zyx[0]};
443 std::array<int, 3> bo{}, bs{};
446 throw std::runtime_error(
"sdf_local shape does not match this rank's mpi_block");
447 if ((
bool)ox != (
bool)oy || (
bool)ox != (
bool)oz)
448 throw std::runtime_error(
"pass all three openness arrays (ox,oy,oz) or none");
449 std::array<float, 3> org{(float)origin_zyx[2], (
float)origin_zyx[1], (float)origin_zyx[0]};
450 std::array<float, 3> spc{(float)spacing_zyx[2], (
float)spacing_zyx[1],
451 (float)spacing_zyx[0]};
452 std::array<double, 3> gp{grad_p_zyx[2], grad_p_zyx[1], grad_p_zyx[0]};
454 sv, gd, org, spc,
to_field(u, res,
"u"),
to_field(v, res,
"v"),
to_field(w, res,
"w"),
455 to_field(p, res,
"p"), ox ?
to_field(*ox, res,
"ox") : std::vector<double>{},
456 oy ?
to_field(*oy, res,
"oy") : std::vector<double>{},
457 oz ?
to_field(*oz, res,
"oz") : std::vector<double>{}, gp, MPI_COMM_WORLD);
460 nb::arg(
"sdf_local"), nb::arg(
"global_shape_zyx"), nb::arg(
"origin_zyx"),
461 nb::arg(
"spacing_zyx"), nb::arg(
"u"), nb::arg(
"v"), nb::arg(
"w"), nb::arg(
"p"),
462 nb::arg(
"ox") = nb::none(), nb::arg(
"oy") = nb::none(), nb::arg(
"oz") = nb::none(),
463 nb::arg(
"grad_p_zyx") = std::vector<double>{0.0, 0.0, 0.0}, kNetworkFlowMpiDoc.c_str());