Files
CelerisLab/src/CelerisLab/lbm/blocks.py
T
ClaudeandClaude Opus 5.5 8e1feffcad lbm: delete PeriodicBlockStepper; a single blocked level is FixedPatchStepper(refinements=[]) (S2)
The second owner of one blocked level goes. Its capability is the
zero-refinement FixedPatchStepper added in f764d68/985db14 (periodic SRT
level, stationary passive circles, shifted storage, NaN spare, single wait),
which matched it array for array on GPU at 1318fc2 (3 splits x +-circle x
shifted/unshifted) and passes the host lifecycle and fault contracts.

API change for callers:
  PeriodicBlockStepper(cfg, BlockLayout2D.build(ny, nx, by, bx), context=c, objects=o)
  -> FixedPatchStepper(cfg, context=c, refinements=[], level_blocks=[(by, bx)],
                       level_objects=[tuple(o)])
  download() returns a 1-tuple; static-circle tables are
  _level_boundaries[0].tables (was .boundary); no free-memory preflight.

- lbm/block_stepper.py deleted; MAX_GRID_DIM lives in refinement_stepper.
- tests/validation/multigrid/test_block_stepper.py: same frozen GPU gates
  (split identity, FP64 oracle, circle across seams) and pre-CUDA rejection
  cases on the single level; the old-vs-new bitwise test is retired with the
  old class (its result is the 1318fc2 record).
- tests/unit/test_block_step_sync.py folded into test_single_level.py
  (step(n) vs n x step(1) roles and one wait, step(0), mid-batch launch
  failure), which also gains bad-state rejection before upload, download
  failure and activation-failure retry on close.
- test_shifted_storage and test_raw_exchange_gpu use the single level; the
  raw-exchange reference feeds the AMR Jet column and raw ledger through the
  root level boundary instead of patching a private kernel field.
- Pinned historical runners (taylor_green/run_case.py, run_jet_circle_cv.py)
  are unchanged: they only run on trees that still ship PeriodicBlockStepper.
  No alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:02:08 +00:00

457 lines
21 KiB
Python

# CelerisLab/lbm/blocks.py
"""CPU layout for fixed-size 2D blocks: core ownership, halo pairs and packing.
Dense arrays are Q-major (q, y, x) with cell y*nx+x, as in core/layout.cuh.
Blocked storage is block-contiguous and Q-major inside each block:
(nblocks, q, by+2*halo, bx+2*halo). A full layout stores every grid block,
block b = jy*nbx + jx with core origin (jy*by, jx*bx), so every dense cell,
fluid or solid, has exactly one core owner. A sparse layout stores only the
selected grid-aligned blocks (row-major order, b = position in that list);
cells of absent blocks are HOLES: owner = layout.hole (one past the last
block cell, so any unguarded use fails instead of wrapping like -1), no halo
pair reads them, packing ignores them and unpacking returns NaN there. This
is physical-block storage, not arbitrary cell refinement.
A "block cell" id is b*L + c with L=(by+2h)*(bx+2h) and c the local row-major
cell; its population q lives at flat slot b*NQ*L + q*L + c. Halo pairs copy
all NQ populations from an owner core cell into another block's halo cell.
Periodicity is explicit per axis. Across a non-periodic exterior, halo cells
have no pair and stay invalid (NaN after packing): no wrap, no implied BC.
Preparation only: no CUDA, no boundary conditions, no refinement levels.
The one-cell same-level halo here is unrelated to the four-cell fine halo of
the fixed refinement patch (refinement.py), whose semantics are unchanged.
Static cut links (map_cut_links) use RECEIVER-LOCAL staging: each physical
link belongs once to the block whose core holds its fluid receiver. Order per
step: gather halos -> BFL writes the reflected source population into that
block's own core/halo copy of the solid cell -> pull/collide core cells, with
NO halo copy between BFL and pull. Reflected populations are temporary,
receiver-local boundary values: the solid's owner-core copy of that slot may
be stale, which is distinct from the unique core ownership of stored state.
No moving geometry is implied.
"""
from dataclasses import dataclass, replace
from numbers import Integral
from types import SimpleNamespace
import numpy as np
from .descriptors import D2Q9_EX, D2Q9_EY
NQ = 9
FACE, CORNER = 0, 1
def _readonly(values, dtype):
array = np.ascontiguousarray(values, dtype=dtype)
array.setflags(write=False)
return array
def _index(value, upper, name, lower=0):
"""Integer index or index array in [lower, upper); rejects bools and floats."""
array = np.asarray(value)
if array.dtype.kind not in "iu" or (array.size and (array.min() < lower or array.max() >= upper)):
raise ValueError(f"{name} must be integer index(es) in [{lower}, {upper})")
return array
def _positive_int(value, name):
if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral) or value <= 0:
raise ValueError(f"{name} must be a positive integer")
return int(value)
def _selected_blocks(origins, ny, nx, by, bx):
"""Row-major grid block indices of explicit (y0, x0) origins, validated."""
array = np.asarray(origins)
if array.dtype.kind not in "iu" or array.ndim != 2 or array.shape[1] != 2 or not len(array):
raise ValueError("origins must be a non-empty integer (n, 2) array of (y0, x0)")
y0, x0 = array[:, 0].astype(np.int64), array[:, 1].astype(np.int64)
if (y0 % by).any() or (x0 % bx).any():
raise ValueError("origins must be multiples of the block size")
if (y0 < 0).any() or (y0 >= ny).any() or (x0 < 0).any() or (x0 >= nx).any():
raise ValueError("origins must lie inside the grid")
index = (y0 // by) * (nx // bx) + x0 // bx
if (np.diff(index) == 0).any():
raise ValueError("origins must be unique")
if (np.diff(index) < 0).any():
raise ValueError("origins must be in row-major order")
return index
@dataclass(frozen=True, eq=False)
class BlockCutLinks:
"""One row per input link, in input order (so force is counted once).
``block`` owns the receiver core cell; ``receiver`` and ``donor`` are block
cell ids in that block (donor = NO_DONOR unless q<.5 with a legal donor);
``write`` is the flat blocked slot of the reflected population.
"""
block: np.ndarray
receiver: np.ndarray
donor: np.ndarray
write: np.ndarray
NO_DONOR = np.iinfo(np.uint64).max
@dataclass(frozen=True, eq=False)
class BlockLayout2D:
"""Fixed-size block partition of an (ny, nx) grid; construct with build().
``origins`` rows are (y0, x0) core origins. ``owner`` maps each dense cell
to its owning block cell id, or ``hole`` if its block is absent.
``halo_dst``/``halo_src`` are block cell ids (destination halo cell,
source owner core cell) sorted by destination;
``halo_kind`` marks FACE or CORNER pairs. Sources are never halo cells, so
one gather in any order fills every valid halo cell.
"""
shape: tuple[int, int]
block: tuple[int, int]
halo: int
periodic: tuple[bool, bool]
origins: np.ndarray
owner: np.ndarray
halo_dst: np.ndarray
halo_src: np.ndarray
halo_kind: np.ndarray
@classmethod
def build(cls, ny, nx, by, bx, *, halo=1, periodic=(True, True), origins=None) -> "BlockLayout2D":
"""``periodic`` is (periodic_x, periodic_y); block sizes must divide the grid.
``origins`` (optional) selects stored blocks by (y0, x0) core origin:
integer multiples of (by, bx) inside the grid, at least one, unique
and strictly increasing in row-major order (no silent reordering).
None stores all blocks; passing every origin gives the same layout.
"""
ny, nx, by, bx, halo = (_positive_int(v, n) for v, n in
((ny, "ny"), (nx, "nx"), (by, "by"), (bx, "bx"), (halo, "halo")))
if ny % by or nx % bx:
raise ValueError("block sizes must divide the grid")
if halo > min(by, bx):
raise ValueError("halo may not exceed the block size")
if len(periodic) != 2 or not all(isinstance(p, (bool, np.bool_)) for p in periodic):
raise ValueError("periodic must be two booleans (x, y)")
px, py = map(bool, periodic)
nby, nbx = ny // by, nx // bx
ly, lx = by + 2 * halo, bx + 2 * halo
cells = ly * lx
if origins is not None:
selected = _selected_blocks(origins, ny, nx, by, bx)
origins = np.array(origins, dtype=np.int64).reshape(-1, 2) # own copy, never caller storage
# Capacity from arithmetic before any grid-sized allocation; hole =
# nblocks * cells is below this bound too.
if (nby * nbx if origins is None else len(origins)) * NQ * cells > np.iinfo(np.int64).max // 2:
raise ValueError("blocked storage exceeds 64-bit indexing")
if origins is None:
selected = np.arange(nby * nbx)
jy, jx = np.divmod(selected, nbx)
origins = np.stack([jy * by, jx * bx], axis=1)
# Grid block -> stored block, or len(origins) for an absent block, so
# absent cells land exactly on hole = nblocks * cells.
stored = np.full(nby * nbx, len(origins), dtype=np.int64)
stored[selected] = np.arange(len(origins))
gy, gx = np.mgrid[0:ny, 0:nx]
gb = stored[(gy // by) * nbx + gx // bx]
hole = len(origins) * cells
owner = np.where(gb < len(origins),
gb * cells + (gy % by + halo) * lx + gx % bx + halo, hole)
# Halo cells of one block in local row-major order, then all blocks.
hy, hx = np.mgrid[0:ly, 0:lx]
out_y = (hy < halo) | (hy >= by + halo)
out_x = (hx < halo) | (hx >= bx + halo)
ring = out_y | out_x
hy, hx, kind = hy[ring], hx[ring], np.where(out_y[ring] & out_x[ring], CORNER, FACE)
dst, src, kinds = [], [], []
for b, (y0, x0) in enumerate(origins):
sy, sx = y0 + hy - halo, x0 + hx - halo
if py:
sy %= ny
if px:
sx %= nx
keep = (sy >= 0) & (sy < ny) & (sx >= 0) & (sx < nx)
keep[keep] = owner[sy[keep], sx[keep]] != hole
dst.append(b * cells + hy[keep] * lx + hx[keep])
src.append(owner[sy[keep], sx[keep]])
kinds.append(kind[keep])
return cls((ny, nx), (by, bx), halo, (px, py),
_readonly(origins, np.int64), _readonly(owner, np.uint64),
_readonly(np.concatenate(dst), np.uint64),
_readonly(np.concatenate(src), np.uint64),
_readonly(np.concatenate(kinds), np.uint8))
@property
def nblocks(self) -> int:
return len(self.origins)
@property
def hole(self) -> int:
"""Owner value of cells in absent blocks: one past the last block cell."""
return self.nblocks * int(np.prod(self.local_shape))
@property
def owned(self) -> np.ndarray:
"""Dense (ny, nx) mask of cells stored in some block core."""
return self.owner != self.hole
@property
def local_shape(self) -> tuple[int, int]:
return self.block[0] + 2 * self.halo, self.block[1] + 2 * self.halo
@property
def blocked_shape(self) -> tuple[int, int, int, int]:
return (self.nblocks, NQ, *self.local_shape)
def slot(self, block_cell, q):
"""Flat blocked population slot of block cell id(s) for direction q."""
cells = int(np.prod(self.local_shape))
block_cell = _index(block_cell, self.nblocks * cells, "block_cell").astype(np.uint64)
q = _index(q, NQ, "q").astype(np.uint64)
b, c = np.divmod(block_cell, np.uint64(cells))
return (b * np.uint64(NQ) + q) * np.uint64(cells) + c
def block_cell(self, block, y, x):
"""Address of in-grid cell (y, x) in ``block``'s own storage, core or halo.
Periodic axes resolve to the image inside the block's halo window;
if two images fit, the core-side one is returned (use local offsets,
not this lookup, when a stencil must follow a specific image).
Raises if the cell is not represented there or lies across an exterior.
In a sparse layout a represented halo cell whose source is a hole has
no pair and stays invalid (NaN after packing).
"""
by, bx = self.block
ly, lx = self.local_shape
ny, nx = self.shape
block = int(_index(block, self.nblocks, "block"))
y, x = int(_index(y, ny, "y")), int(_index(x, nx, "x"))
y0, x0 = self.origins[block]
ry, rx = y - int(y0) + self.halo, x - int(x0) + self.halo
if self.periodic[1] and not 0 <= ry < ly:
ry = next((r for r in (ry - ny, ry + ny) if 0 <= r < ly), ry)
if self.periodic[0] and not 0 <= rx < lx:
rx = next((r for r in (rx - nx, rx + nx) if 0 <= r < lx), rx)
if not (0 <= ry < ly and 0 <= rx < lx):
raise ValueError("cell is not represented in this block's storage")
return block * ly * lx + ry * lx + rx
def _neighbor(self, y, x, dy, dx):
ny, nx = self.shape
y, x = y + dy, x + dx
if self.periodic[1]:
y %= ny
if self.periodic[0]:
x %= nx
if not (0 <= y < ny and 0 <= x < nx):
raise ValueError("cut-link stencil crosses a non-periodic exterior")
if self.owner[y, x] == self.hole:
raise ValueError("cut-link stencil touches an absent block")
return y, x
def map_cut_links(self, links) -> BlockCutLinks:
"""Map dense static cut links (fluid_idx, dir, q, fallback) to blocks.
Rejects repeated physical links and repeated local writers. Addresses
are geometry-free: q, fallback and BFL math stay as built on the dense
grid. Halo must be at least one cell (always true here). In a sparse
layout the receiver, its solid neighbor and any q<.5 donor must all lie
in stored blocks.
"""
ny, nx = self.shape
lx = self.local_shape[1]
cells = int(np.prod(self.local_shape))
rows, seen, writers = [], set(), set()
for link in links:
r = int(_index(link.fluid_idx, ny * nx, "fluid_idx"))
q = int(_index(link.dir, NQ, "dir", lower=1))
if (r, q) in seen:
raise ValueError("cut link is repeated")
seen.add((r, q))
y, x = divmod(r, nx)
receiver = int(self.owner[y, x])
if receiver == self.hole:
raise ValueError("cut-link receiver lies in an absent block")
block = receiver // cells
# Local stencil offsets from the core receiver: the window image the
# next local pull reads, even where a periodic seam wraps physically.
# Physical neighbors are formed only to reject non-periodic exteriors.
step = D2Q9_EY[q] * lx + D2Q9_EX[q]
self._neighbor(y, x, D2Q9_EY[q], D2Q9_EX[q])
opp = q + 1 if q % 2 else q - 1
write = int(self.slot(receiver + step, opp))
if write in writers:
raise ValueError("duplicate local boundary writer")
writers.add(write)
donor = NO_DONOR
if float(link.q) < .5 and int(link.fallback) == 0:
self._neighbor(y, x, -D2Q9_EY[q], -D2Q9_EX[q])
donor = receiver - step
rows.append((block, receiver, donor, write))
table = np.array(rows, dtype=np.uint64).reshape(-1, 4)
return BlockCutLinks(_readonly(table[:, 0], np.uint32),
*(_readonly(table[:, i], np.uint64) for i in (1, 2, 3)))
def exchange(self, blocked) -> None:
"""Fill every valid halo cell in place from its owner's core."""
blocked = self._check_blocked(blocked)
flat = blocked.reshape(self.nblocks, NQ, -1)
cells = flat.shape[2]
db, dc = np.divmod(self.halo_dst.astype(np.intp), cells)
sb, sc = np.divmod(self.halo_src.astype(np.intp), cells)
flat[db, :, dc] = flat[sb, :, sc]
def to_blocked(self, dense):
"""Pack (NQ, ny, nx) floats; halo cells without a pair stay NaN.
Values at holes (absent blocks) are ignored.
"""
dense = np.asarray(dense)
if dense.shape != (NQ, *self.shape) or not np.issubdtype(dense.dtype, np.floating):
raise ValueError(f"expected floating (NQ, ny, nx) = {(NQ, *self.shape)}")
blocked = np.full(self.blocked_shape, np.nan, dtype=dense.dtype)
flat = blocked.reshape(self.nblocks, NQ, -1)
owned = self.owned.ravel()
ob, oc = np.divmod(self.owner.ravel()[owned].astype(np.intp), flat.shape[2])
flat[ob, :, oc] = dense.reshape(NQ, -1)[:, owned].T
self.exchange(blocked)
return blocked
def to_dense(self, blocked):
"""Unpack owner cores only; halo contents are ignored, holes are NaN.
Full layouts accept any dtype; layouts with holes require floats.
"""
blocked = self._check_blocked(blocked)
flat = blocked.reshape(self.nblocks, NQ, -1)
owned = self.owned.ravel()
if not owned.all() and not np.issubdtype(blocked.dtype, np.floating):
raise ValueError("a layout with holes unpacks floating arrays only (holes are NaN)")
ob, oc = np.divmod(self.owner.ravel()[owned].astype(np.intp), flat.shape[2])
dense = np.empty((NQ, owned.size), dtype=blocked.dtype)
if not owned.all(): # even an empty NaN assignment fails for integer dtypes
dense[:, ~owned] = np.nan
dense[:, owned] = flat[ob, :, oc].T
return dense.reshape(NQ, *self.shape)
def _check_blocked(self, blocked):
if not isinstance(blocked, np.ndarray) or blocked.shape != self.blocked_shape \
or not blocked.flags.c_contiguous:
raise ValueError(f"expected C-contiguous blocked array {self.blocked_shape}")
return blocked
def blocked_link_tables(layout, flags, fluid_idx, dir, q, fallback):
"""Storage adapter: dense static cut-link SoA + dense flags -> blocked tables.
Legality (geometry, coverage, transfer isolation) stays with the caller's
own builder; this only maps storage via map_cut_links (unique receiver
owner and local writer, input row order kept). Returns (tables, mapped):
tables holds device-ready arrays, with flags sized nblocks*L (unused halo
entries 0) and cell = the receiver's local core cell. Flags at holes
(absent blocks) are not stored.
"""
flags = np.asarray(flags)
if flags.shape != (int(np.prod(layout.shape)),) or flags.dtype != np.uint16:
raise ValueError("flags must be dense uint16 with one entry per grid cell")
columns = [np.asarray(fluid_idx), np.asarray(dir), np.asarray(q), np.asarray(fallback)]
if len({len(c) for c in columns}) != 1:
raise ValueError("cut-link columns must have equal length")
mapped = layout.map_cut_links(SimpleNamespace(fluid_idx=r, dir=d, q=v, fallback=f)
for r, d, v, f in zip(*columns))
cells = int(np.prod(layout.local_shape))
blocked_flags = np.zeros(layout.nblocks * cells, np.uint16)
owned = layout.owned.ravel()
blocked_flags[layout.owner.ravel()[owned].astype(np.intp)] = flags[owned]
tables = dict(flags=blocked_flags, block=mapped.block,
cell=(mapped.receiver % cells).astype(np.uint32),
dir=columns[1].astype(np.uint8), q=columns[2].astype(np.float32),
fallback=columns[3].astype(np.uint8))
return tables, mapped
# One blocked level as FixedPatchStepper builds and launches it (any depth). Raw argument orders of the blocked.cu kernels live only here.
def level_config(cfg, layout, tau):
"""Kernel config of one blocked level: NX/NY = block window incl. halo.
Physical wall/slot/inlet fields are neutralized; a blocked root gets them
from its module globals (d_block_wall/d_block_channel) instead.
"""
ly, lx = layout.local_shape
local = replace(cfg, nx=lx, ny=ly, y_wall_bc="bounce_back", top_wall_bc="velocity",
wall_slot_width=0, wall_normal_velocity=0.0, velocity=0.0)
local.validate()
if not np.isclose(local.omega, 1 / tau, rtol=1e-14, atol=0):
raise RuntimeError("block level omega/tau mismatch")
return local
def launch_halo_copy(kernel, buffer, dst, src, count, *, block, stream):
"""BlockHaloCopy over ``count`` uploaded (dst, src) block-cell pairs."""
kernel(buffer, dst, src, np.uint32(count),
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def launch_curved_boundary(kernel, buffer, tables, count, *, block, stream,
velocity=None, rx=None, ry=None, raw=None):
"""BlockCurvedBoundary on uploaded link tables; None = no actuation/observation."""
none = np.uintp(0)
kernel(buffer, tables["block"], tables["cell"], tables["dir"], tables["q"], tables["fallback"],
np.uint32(count), none if velocity is None else velocity,
none if rx is None else rx, none if ry is None else ry, none if raw is None else raw,
grid=((count + block[0] - 1) // block[0], 1), block=block, stream=stream)
def static_circle_tables(layout, objects):
"""Host tables for stationary circles; rejects before any CUDA work.
Every periodic neighbor of every fluid core is inspected, including domain
edges that CircleGeometry does not enumerate, so clipped or incomplete
geometry cannot pass. Each physical link has one receiver-owning block.
"""
from ..body.geometry.circle import CircleGeometry
from .descriptors import FLUID, SOLID
ny, nx = layout.shape
flags = np.full(ny * nx, FLUID, np.uint16)
for obj in objects:
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
or obj.surface_actuation is not None
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
or any(vars(obj.control).values())):
raise ValueError("Block bodies must be stationary passive circles")
cx, cy = obj.geometry.center
radius = obj.geometry._radius
if not np.isfinite([cx, cy, radius]).all() or radius <= 0:
raise ValueError("Block circle center/radius must be finite with radius > 0")
mask = obj.geometry.build_flag_mask(nx, ny)
solid = (mask & SOLID) != 0
if not solid.any() or np.any(flags[solid] & SOLID):
raise ValueError("Block circles must be resolved and disjoint")
flags[solid] = mask[solid]
solid = ((flags & SOLID) != 0).reshape(ny, nx)
if solid[[0, -1]].any() or solid[:, [0, -1]].any():
raise ValueError("Block circles must not touch the domain edge")
links = [link for obj in objects
for link in obj.geometry.build_cut_links(nx, ny, domain_flags=flags)]
def source(r, q):
y, x = divmod(r, nx)
return (y + D2Q9_EY[q]) % ny, (x + D2Q9_EX[q]) % nx
receivers = {(int(link.fluid_idx), int(link.dir)) for link in links}
required = {(int(r), q) for r in np.flatnonzero(~solid.ravel())
for q in range(1, NQ) if solid[source(r, q)]}
if len(receivers) != len(links) or receivers != required:
raise ValueError("Cut links must cover every fluid pull from a solid exactly once")
tables, _ = blocked_link_tables(
layout, flags, np.array([link.fluid_idx for link in links], np.uint32),
np.array([link.dir for link in links], np.uint8),
np.array([link.q for link in links], np.float32),
np.array([link.fallback for link in links], np.uint8))
return dict(solid=solid, **tables)