291 lines
14 KiB
Python
291 lines
14 KiB
Python
"""CPU preparation for one fixed D2Q9 2:1 patch and optional interior circles.
|
|
|
|
Cell indices use y*nx+x; population slots use q*(nx*ny)+cell, matching
|
|
core/layout.cuh. Bounds are half-open (x0, y0, x1, y1) in coarse cells.
|
|
The four fine halo cells implement the pinned CPUREF volumetric geometry.
|
|
Base topology is independent of boundaries; prepare_fine_boundary checks them.
|
|
"""
|
|
from dataclasses import dataclass, replace
|
|
from numbers import Integral
|
|
|
|
import numpy as np
|
|
|
|
from ..config import LBMConfig
|
|
from .descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
|
|
|
|
ACTIVE, INNER_GHOST, OUTER_GHOST = 0, 1, 2
|
|
HALO = 4
|
|
|
|
|
|
def _readonly(values, dtype):
|
|
array = np.ascontiguousarray(values, dtype=dtype)
|
|
array.setflags(write=False)
|
|
return array
|
|
|
|
|
|
@dataclass(frozen=True, eq=False)
|
|
class FixedPatch2D:
|
|
"""Layer metadata and compact exchange tables, constructed by from_config.
|
|
|
|
Shapes are (ny, nx), including the fine halo. ``fine_origin`` is its lower
|
|
cell edge in coarse physical units, with coarse dx=dt=1 and fine dx=dt=.5.
|
|
``ghost_fine`` and ``ghost_coarse`` pair fine cells with coarse donors.
|
|
``coalesce_coarse`` contains incoming population slots; each corresponding
|
|
row of ``coalesce_fine`` contains four fine slots in (dy, dx) row order.
|
|
Weights are all 1/4. Closed corner links are included even when the coarse
|
|
donor is uncovered. These tables contain no DDF data and allocate no GPU.
|
|
|
|
This is preparation, not permission to advance these levels with OneStep:
|
|
its channel boundaries and fused collision do not implement patch coupling.
|
|
"""
|
|
coarse_shape: tuple[int, int]
|
|
bounds: tuple[int, int, int, int]
|
|
tau_coarse: float
|
|
tau_fine: float
|
|
fine_kind: np.ndarray
|
|
ghost_fine: np.ndarray
|
|
ghost_coarse: np.ndarray
|
|
coalesce_coarse: np.ndarray
|
|
coalesce_fine: np.ndarray
|
|
|
|
@classmethod
|
|
def from_config(cls, cfg: LBMConfig, bounds) -> "FixedPatch2D":
|
|
"""Prepare maps without changing cfg or allocating coarse-sized tables.
|
|
|
|
The initial backend contract is D2Q9/SRT/FP32/unshifted/double_buffer,
|
|
without LES. Two coarse cells of margin keep all halo donors in bounds;
|
|
this does NOT certify that those donors are fluid or periodic.
|
|
"""
|
|
checked = replace(cfg)
|
|
checked.validate()
|
|
if (not checked.is_d2q9 or checked.collision != "SRT"
|
|
or checked.streaming != "double_buffer"
|
|
or checked.store_precision != "FP32"
|
|
or checked.ddf_shifting or checked.les_enabled):
|
|
raise ValueError("FixedPatch2D requires D2Q9/SRT/FP32/unshifted/double_buffer without LES")
|
|
bounds = tuple(bounds)
|
|
if len(bounds) != 4 or any(isinstance(v, (bool, np.bool_))
|
|
or not isinstance(v, Integral) for v in bounds):
|
|
raise ValueError("bounds must be four integers: (x0, y0, x1, y1)")
|
|
x0, y0, x1, y1 = bounds = tuple(map(int, bounds))
|
|
nx, ny = checked.nx, checked.ny
|
|
if not (2 <= x0 < x1 <= nx - 2 and 2 <= y0 < y1 <= ny - 2):
|
|
raise ValueError("patch must be nonempty with a two-coarse-cell margin")
|
|
fx, fy = 2 * (x1 - x0) + 2 * HALO, 2 * (y1 - y0) + 2 * HALO
|
|
if max(nx * ny, fx * fy) > np.iinfo(np.uint32).max:
|
|
raise ValueError("layer cell counts exceed uint32 indexing")
|
|
tau_c = 3.0 * checked.viscosity + 0.5
|
|
tau_f = 6.0 * checked.viscosity + 0.5
|
|
if not checked.omega_min <= 1.0 / tau_f <= checked.omega_max:
|
|
raise ValueError("fine relaxation rate lies outside configured omega guards")
|
|
|
|
kind = np.full((fy, fx), OUTER_GHOST, dtype=np.uint8)
|
|
kind[2:-2, 2:-2] = INNER_GHOST
|
|
kind[HALO:-HALO, HALO:-HALO] = ACTIVE
|
|
gy, gx = np.nonzero(kind != ACTIVE)
|
|
ghost_fine = gy * fx + gx
|
|
ghost_coarse = (y0 + (gy - HALO) // 2) * nx + x0 + (gx - HALO) // 2
|
|
|
|
targets, sources = [], []
|
|
# Only the outside ring can receive fine-to-coarse populations.
|
|
# Integer doubled midpoints include all eight diagonal corner passes.
|
|
for y in range(y0 - 1, y1 + 1):
|
|
xs = range(x0 - 1, x1 + 1) if y in (y0 - 1, y1) else (x0 - 1, x1)
|
|
for x in xs:
|
|
for q in range(1, 9):
|
|
mx, my = 2 * x + 1 - D2Q9_EX[q], 2 * y + 1 - D2Q9_EY[q]
|
|
if 2 * x0 <= mx <= 2 * x1 and 2 * y0 <= my <= 2 * y1:
|
|
targets.append(q * nx * ny + y * nx + x)
|
|
a, b = HALO + 2 * (x - x0), HALO + 2 * (y - y0)
|
|
sources.append([q * fx * fy + (b + dy) * fx + a + dx
|
|
for dy in (0, 1) for dx in (0, 1)])
|
|
return cls((ny, nx), bounds, tau_c, tau_f,
|
|
_readonly(kind, np.uint8),
|
|
_readonly(ghost_fine, np.uint32),
|
|
_readonly(ghost_coarse, np.uint32),
|
|
_readonly(targets, np.uint64),
|
|
_readonly(sources, np.uint64).reshape(-1, 4))
|
|
|
|
@property
|
|
def fine_shape(self) -> tuple[int, int]:
|
|
return self.fine_kind.shape
|
|
|
|
@property
|
|
def fine_origin(self) -> tuple[float, float]:
|
|
return self.bounds[0] - HALO / 2, self.bounds[1] - HALO / 2
|
|
|
|
@property
|
|
def table_bytes(self) -> int:
|
|
return sum(a.nbytes for a in (self.fine_kind, self.ghost_fine,
|
|
self.ghost_coarse, self.coalesce_coarse,
|
|
self.coalesce_fine))
|
|
|
|
def check_synchronization(self, coarse_steps: int, fine_steps: int, *, phase: str) -> None:
|
|
"""Check a proposed backend handoff; does not convert or collide data.
|
|
|
|
Production storage is g=C(f), while CPUREF returns arrival_f. A future
|
|
g-to-g coarse step must collide coarse cells once and fine cells twice;
|
|
fine halo ghosts never collide. Coalescence must precede coarse collision.
|
|
Converting reference f to g for comparison is an observation operation,
|
|
not an additional production collision. Counts here are completed steps.
|
|
"""
|
|
if phase != "postcollision_g":
|
|
raise ValueError("patch backend handoff requires postcollision_g")
|
|
for value in (coarse_steps, fine_steps):
|
|
if isinstance(value, (bool, np.bool_)) or not isinstance(value, Integral) or value < 0:
|
|
raise ValueError("step counts must be nonnegative integers")
|
|
if fine_steps != 2 * coarse_steps:
|
|
raise ValueError("levels are not synchronized: fine_steps must equal 2*coarse_steps")
|
|
|
|
|
|
@dataclass(frozen=True, eq=False)
|
|
class FineBoundary2D:
|
|
"""Fixed local projection plus auditable, finite transfer dependency sets.
|
|
|
|
transfer_reads rows are (hop, direction, cell), traced from coalescence at
|
|
hop 2 back to exploded G at hop 0. No previous coarse interval is traversed.
|
|
Cell footprints also include the whole explosion write/donor tables.
|
|
"""
|
|
objects: tuple
|
|
flags: np.ndarray
|
|
curved_host: tuple
|
|
boundary_cells: np.ndarray
|
|
transfer_fine_cells: np.ndarray
|
|
transfer_coarse_cells: np.ndarray
|
|
transfer_reads: np.ndarray
|
|
|
|
|
|
def _transfer_footprint(patch):
|
|
ny, nx = patch.fine_shape
|
|
cells = nx * ny
|
|
pending = [(2, *divmod(int(slot), cells)) for slot in patch.coalesce_fine.ravel()]
|
|
reads = set()
|
|
while pending:
|
|
hop, q, k = pending.pop()
|
|
if (hop, q, k) in reads:
|
|
continue
|
|
reads.add((hop, q, k))
|
|
if hop == 0:
|
|
continue
|
|
y, x = divmod(k, nx)
|
|
kind = patch.fine_kind[y, x]
|
|
if kind == OUTER_GHOST:
|
|
raise ValueError("Transfer reads an invalid post-hop outer ghost")
|
|
# A collided output depends on all nine arrivals; ghosts only stream.
|
|
for direction in (range(9) if kind == ACTIVE else (q,)):
|
|
sx, sy = x - D2Q9_EX[direction], y - D2Q9_EY[direction]
|
|
if not (0 <= sx < nx and 0 <= sy < ny):
|
|
raise ValueError("Transfer dependency leaves the fine allocation")
|
|
pending.append((hop - 1, direction, sy * nx + sx))
|
|
fine = set(map(int, patch.ghost_fine)) | {k for _, _, k in reads}
|
|
coarse = set(map(int, patch.ghost_coarse)) | set(map(
|
|
int, patch.coalesce_coarse % np.prod(patch.coarse_shape)))
|
|
return fine, coarse, _readonly(sorted(reads), np.int64)
|
|
|
|
|
|
def prepare_fine_boundary(cfg, patch, objects, *, wall_aware=False):
|
|
"""Project fixed circles and reject boundary/transfer overlap before CUDA.
|
|
|
|
Input centers/radii are in coarse physical coordinates (dx=dt=1).
|
|
SurfaceActuation retains its lattice-velocity meaning: input coarse units
|
|
are converted into independent fine-local objects with dx=dt=.5.
|
|
Original objects are not retained or mutated. No runtime geometry updates.
|
|
"""
|
|
if not objects:
|
|
return None
|
|
checked = replace(cfg)
|
|
checked.validate()
|
|
if (patch.coarse_shape != (cfg.ny, cfg.nx) or not checked.is_d2q9
|
|
or checked.collision != "SRT" or checked.streaming != "double_buffer"
|
|
or checked.store_precision != "FP32" or checked.ddf_shifting or checked.les_enabled
|
|
or patch.tau_coarse != 3*checked.viscosity+.5):
|
|
raise ValueError("Fine boundary requires the matching fixed-patch configuration")
|
|
from ..body.geometry.circle import CircleGeometry
|
|
from ..body.objects import SimObject
|
|
from ..body.coupling.soa_packer import pack_cut_links_to_soa
|
|
|
|
ny, nx = patch.fine_shape
|
|
fine_cfg = replace(cfg, nx=nx, ny=ny, viscosity=2 * cfg.viscosity)
|
|
flags = np.full(nx * ny, FLUID, np.uint16)
|
|
active = patch.fine_kind.ravel() == ACTIVE
|
|
local, links = [], []
|
|
x0, y0, x1, y1 = patch.bounds
|
|
dx = dt = .5
|
|
for bid, obj in enumerate(objects):
|
|
if (type(obj.geometry) is not CircleGeometry or obj.is_sensor or obj.is_force_region
|
|
or any(getattr(obj.state, name) != 0 for name in ("vx", "vy", "omega"))
|
|
or any(vars(obj.control).values())):
|
|
raise ValueError("Fine boundaries require stationary circles without other roles")
|
|
cx, cy = obj.geometry.center
|
|
radius = obj.geometry._radius
|
|
if (not np.isfinite([cx, cy, radius]).all() or radius <= 0
|
|
or cx-radius < x0 or cx+radius > x1 or cy-radius < y0 or cy+radius > y1):
|
|
raise ValueError("Entire fine circle must lie inside the physical patch")
|
|
center = tuple((v-o)/dx-.5 for v, o in zip((cx, cy), patch.fine_origin))
|
|
spec = obj.surface_actuation
|
|
if spec is not None:
|
|
spec = replace(spec, normal_velocity=spec.normal_velocity * dt / dx)
|
|
item = SimObject(bid, geometry=CircleGeometry(*center, radius/dx),
|
|
center=center, radius=radius/dx, surface_actuation=spec)
|
|
item.validate_surface_actuation(fine_cfg)
|
|
mask = item.get_flag_mask(nx, ny)
|
|
solid = (mask & SOLID) != 0
|
|
if not solid.any() or np.any(solid & ~active) or np.any(flags[solid] & SOLID):
|
|
raise ValueError("Fine solids must be resolved, disjoint and entirely active")
|
|
flags[solid] = mask[solid]
|
|
local.append(item)
|
|
for item in local:
|
|
cut = item.geometry.build_cut_links(nx, ny, domain_flags=flags)
|
|
if not cut:
|
|
raise ValueError("Fine circle has no resolved cut links")
|
|
if item.surface_actuation is not None:
|
|
_, aperture = item.surface_actuation.project(
|
|
np.array([link.rx for link in cut]), np.array([link.ry for link in cut]))
|
|
if not aperture.any():
|
|
raise ValueError("Surface aperture hits no resolved cut links")
|
|
for link in cut:
|
|
link.body_id = item.obj_id
|
|
links.extend(cut)
|
|
|
|
boundary, writers, receivers = set(), set(), set()
|
|
for link in links:
|
|
r, q = link.fluid_idx, link.dir
|
|
y, x = divmod(r, nx)
|
|
sx, sy = x + D2Q9_EX[q], y + D2Q9_EY[q]
|
|
s = sy * nx + sx
|
|
if not (active[r] and flags[r] & FLUID and active[s] and flags[s] & SOLID):
|
|
raise ValueError("Cut-link receiver/source must be active fluid/solid")
|
|
writer = (s, q+1 if q % 2 else q-1)
|
|
if writer in writers:
|
|
raise ValueError("Duplicate fine boundary writer")
|
|
writers.add(writer)
|
|
receivers.add((r, q))
|
|
boundary.update((r, s))
|
|
if link.q < .5:
|
|
dx_, dy_ = x - D2Q9_EX[q], y - D2Q9_EY[q]
|
|
if not (0 <= dx_ < nx and 0 <= dy_ < ny and active[dy_*nx+dx_]):
|
|
raise ValueError("q<.5 donor crosses the fine active interface; fallback cannot hide it")
|
|
boundary.add(dy_*nx+dx_)
|
|
# Reverse coverage: every fluid pull across a solid neighbor needs a writer.
|
|
required = set()
|
|
for r in np.flatnonzero(active & ((flags & FLUID) != 0)):
|
|
for q in range(1, 9):
|
|
if flags[r + D2Q9_EX[q] + nx*D2Q9_EY[q]] & SOLID:
|
|
required.add((int(r), q))
|
|
if required != receivers:
|
|
raise ValueError("Fine solid pull coverage does not match the cut links")
|
|
fine, coarse, reads = _transfer_footprint(patch)
|
|
if fine & (boundary | set(map(int, np.flatnonzero(flags & SOLID)))):
|
|
raise ValueError("Boundary stencil overlaps the two-hop transfer footprint")
|
|
for k in coarse:
|
|
y, x = divmod(k, cfg.nx)
|
|
if x0 <= x < x1 and y0 <= y < y1:
|
|
raise ValueError("Transfer coarse donor/target is not an uncovered bulk cell")
|
|
if wall_aware and (y <= 2 or y >= cfg.ny-3):
|
|
raise ValueError("Transfer footprint touches the coarse wall reconstruction stencil")
|
|
return FineBoundary2D(tuple(local), _readonly(flags, np.uint16),
|
|
tuple(_readonly(a, a.dtype) for a in pack_cut_links_to_soa(links)),
|
|
_readonly(sorted(boundary), np.uint32),
|
|
_readonly(sorted(fine), np.uint32),
|
|
_readonly(sorted(coarse), np.uint32), reads)
|