Add research measured-strain fluid refinement example
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# One measured-strain refinement example
|
||||
|
||||
Research only: one completed base-time observation, one possible growth-only
|
||||
fluid L2 enlargement, then two more base steps. L0/L1 and the Circle-containing
|
||||
L2 rectangle stay fixed. No scheduler, coarsening, packing, CUDA changes, or
|
||||
claim of accuracy, stable long-time adaptation, or optimal mesh selection.
|
||||
|
||||
The periodic initial shear wave is evolved for two base steps. The downloaded
|
||||
postcollision G moments supply actual L1 velocity, at dx=.5 and the parent's
|
||||
physical origin. The tagger measures |S|=sqrt(2 S:S), not vorticity. We use
|
||||
s*=|S|/0.05, cutoff .001, no wall band/history/buffer. This deliberately small
|
||||
cutoff exercises the mechanism; it is not an error estimator or calibrated
|
||||
physical criterion. A high cutoff yields no change with the same observation.
|
||||
|
||||
The eligible corridor is L1 indices [72,76) x [20,36), immediately right of the
|
||||
fluid child. Only L1 fluid leaves are valid gradient donors and owned voters;
|
||||
covered cells, holes and ghosts cannot supply gradients. L1 has no physical
|
||||
solid cells: the Circle is entirely in the fixed L2 patch. The cropped sparse
|
||||
arrays are never treated as a full periodic domain. One-sided derivatives may
|
||||
be used at missing-neighbor edges. Unknown gradients, unavailable corridor
|
||||
cells, and tags outside the corridor are reported separately. Ignored tags do
|
||||
not certify adequate resolution elsewhere. Old coverage is always retained.
|
||||
|
||||
`grow_rectangle` knows only a rectangle, boolean requests, alignment and area
|
||||
cap. It returns an unchanged/enlarged tuple or raises ValueError before any
|
||||
solver mutation. The caller validates the entire proposed mesh with the
|
||||
existing RemapTopology and sparse-layout guards; remap_child repeats its full
|
||||
runtime checks. This example targets exactly the original mesh once, not a
|
||||
reusable adaptation loop. A rejection propagates explicitly; no partial
|
||||
proposal is installed. All solver instances are independently closed.
|
||||
|
||||
## Run
|
||||
|
||||
From repository root, with dependencies already available:
|
||||
|
||||
PYTHONPATH=src:. python -m unittest discover -s tests/unit -p test_shear_refinement_example.py -v
|
||||
|
||||
Only on an approved idle CUDA device:
|
||||
|
||||
PYTHONPATH=src:. python -m research.shear_refinement.example
|
||||
PYTHONPATH=src:.:tests/validation/multigrid python -m unittest discover -s tests/validation/multigrid -p test_shear_refinement_example_gpu.py -v
|
||||
|
||||
The GPU gate compares automatic selection to an explicitly identical manual
|
||||
rectangle after short continuation, requires finite leaves, exact NaN masks,
|
||||
and an unchanged source. It does not replace independent numerical replay.
|
||||
GPU execution has not been performed in the implementation cloud environment.
|
||||
|
||||
## Tagger provenance and license
|
||||
|
||||
`boundary_shear_tagging.py` is the unchanged accepted Grok standalone v0.2
|
||||
preparation (2026-10-16), SHA256
|
||||
`12bc70ab989953501a752a2dc23e3712b6e69bfd47e67664c1771ff2dfa4da0b`.
|
||||
The delivered `README_boundary_shear_tagging_v0.2.md` identifies it as original
|
||||
code under MIT, the same license as CelerisLab (see repository LICENSE), with
|
||||
nothing copied from upstream examples. Its source ledger/review is in the
|
||||
preparation/grok_amr_review/v0.2 handoff. Vendoring keeps this example runnable
|
||||
without external preparation paths; it is not installed as production API.
|
||||
The separate reviewed level-request policy v0.2.1 is intentionally not copied:
|
||||
this example observes only L1 and can request only the existing L2 level.
|
||||
|
||||
## Local implementation checks (2026-10-16)
|
||||
|
||||
Seven focused example tests passed. The combined CPU suite below passed all
|
||||
60 tests, including existing topology, pinned-body and synchronized remap gates:
|
||||
|
||||
PYTHONPATH=src:. python -m unittest tests.unit.test_refinement tests.unit.test_refinement_three_level_multipatch tests.unit.test_pinned_body_remap tests.unit.test_runtime_child_remap tests.unit.test_synchronized_remap tests.unit.test_shear_refinement_example -q
|
||||
|
||||
Broad unit discovery was also attempted; 14 legacy module imports failed
|
||||
because PyCUDA is absent. That broad run is not a pass. No installation or GPU
|
||||
execution was attempted. The example uses a passive Circle, with no Jet drive.
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Boundary-proximity + physically scaled shear tagging with hysteresis (CPU prototype), v0.2.
|
||||
|
||||
Status: standalone preparation for CelerisLab AMR (baseline dev 93d1cdd). Original code, not
|
||||
copied from any project. It only *proposes* cells for refinement: it returns masks and
|
||||
diagnostics and never builds patches, regrids, applies a max_level, or touches solver state.
|
||||
Not a block/AMR integration: callers must still legalise/align proposals and run the existing
|
||||
BlockLayout2D / FixedPatch2D / prepare_fine_boundary guards.
|
||||
|
||||
v0.2 changes (2026-10-16 review): explicit periodic axes for distance AND dilation (minimum
|
||||
image); three separate masks (physical solid / valid stencil samples / owned cells); core tags
|
||||
only on owned-eligible fluid; hold previous core where the strain is unevaluable.
|
||||
|
||||
Masks (all bool, shape (ny, nx)):
|
||||
* solid - physical solid geometry. The ONLY source of walls. Never fill holes with it.
|
||||
* valid - cells holding a usable, finite macroscopic velocity sample (may include ghosts).
|
||||
Derivative stencils read only valid cells. Default: ~solid. Must not overlap solid.
|
||||
Cells that are neither solid nor valid are "absent" (sparse holes): never walls,
|
||||
never derivative donors, never tagged; their velocity is never read.
|
||||
* owned - cells this caller owns and may propose (e.g. ACTIVE fine cells, owned blocks).
|
||||
Default: all True. eligible = owned & ~solid, and must be a subset of valid.
|
||||
Core tags exist only on eligible cells; the buffer is clipped to owned cells.
|
||||
|
||||
Units (match CelerisLab refinement.py: coarse dx = dt = 1, level l has dx = dt = 2**-l):
|
||||
* Lengths (origin, wall distance, L_ref, periods) are in COARSE cells.
|
||||
* Velocities are lattice velocities; under acoustic scaling these equal coarse units.
|
||||
* Strain rate |S| = sqrt(2 S_ij S_ij) in 1/coarse-time-step; u = (g*y, 0) gives |S| = |g|.
|
||||
* Shear indicator s* = |S| * L_ref / U_ref: dimensionless; the physical definition is
|
||||
level-independent (its finite-difference estimate is exactly level-invariant only for
|
||||
affine fields; otherwise it changes by truncation error).
|
||||
* cell_strain = |S| * dx_level / U_ref: an analogous dx-scaled STRAIN diagnostic, returned
|
||||
but not used to tag. It is not the quantity waLBerla/AMReX threshold: those use
|
||||
vorticity-related indicators (rigid rotation: zero strain, nonzero vorticity). We borrow
|
||||
only their level-scaling pattern. A dx-scaled indicator is resolution-sensitive; it is not
|
||||
an accuracy certificate and not a substitute for a hard max_level in the policy layer.
|
||||
* Velocity must be MACROSCOPIC (moments of one consistent population phase).
|
||||
|
||||
Tag rule per call, on eligible cells:
|
||||
wall: d <= band_on or (prev and d <= band_off) band_off >= band_on (units L_ref)
|
||||
shear: where evaluable: s* >= shear_on or (prev and s* >= shear_off)
|
||||
where unevaluable (no valid stencil): HOLD prev (never switches a tag off)
|
||||
core = wall | shear; tagged = core dilated n_buffer cells (Chebyshev, periodic-aware) & owned.
|
||||
Feed ``core`` (never ``tagged``) back as ``prev_core``. prev_core outside eligible cells is
|
||||
rejected; buffered history on an all-fluid grid cannot be detected, so the contract stays
|
||||
core-only. History is only meaningful on unchanged topology: reset or remap after a regrid.
|
||||
An untagged unevaluable cell (reported in ``unevaluable``) is NOT evidence that it may coarsen.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from numbers import Integral, Real
|
||||
|
||||
import numpy as np
|
||||
|
||||
WALL, SHEAR, HELD = 1, 2, 4 # bits in TagResult.reason (HELD: shear state held, unevaluable)
|
||||
_CHUNK = 4096
|
||||
|
||||
|
||||
def _ro(a):
|
||||
a = np.ascontiguousarray(a)
|
||||
a.setflags(write=False)
|
||||
return a
|
||||
|
||||
|
||||
def _num(name, v, *, positive=False, nonneg=False):
|
||||
if isinstance(v, (bool, np.bool_)) or not isinstance(v, Real) or not np.isfinite(v):
|
||||
raise ValueError(f"{name} must be a finite real number")
|
||||
v = float(v)
|
||||
if positive and v <= 0:
|
||||
raise ValueError(f"{name} must be > 0")
|
||||
if nonneg and v < 0:
|
||||
raise ValueError(f"{name} must be >= 0")
|
||||
return v
|
||||
|
||||
|
||||
def _periodic(periodic):
|
||||
if (not isinstance(periodic, (tuple, list)) or len(periodic) != 2
|
||||
or not all(isinstance(p, (bool, np.bool_)) for p in periodic)):
|
||||
raise ValueError("periodic must be two bools (x, y)")
|
||||
return bool(periodic[0]), bool(periodic[1])
|
||||
|
||||
|
||||
def _mask(name, m, shape, default):
|
||||
if m is None:
|
||||
return default
|
||||
m = np.asarray(m)
|
||||
if m.dtype != np.bool_ or m.shape != shape:
|
||||
raise ValueError(f"{name} must be a bool array of the grid shape")
|
||||
return m
|
||||
|
||||
|
||||
def _neighbour_or(m, periodic):
|
||||
"""OR over the 3x3 Chebyshev neighbourhood; wraps on periodic axes, False-pads others."""
|
||||
px, py = periodic
|
||||
p = np.pad(m, ((0 if py else 1,) * 2, (0 if px else 1,) * 2), constant_values=False)
|
||||
out = np.zeros(m.shape, bool)
|
||||
ny, nx = m.shape
|
||||
for dy in (-1, 0, 1):
|
||||
for dx in (-1, 0, 1):
|
||||
if px or py:
|
||||
s = np.roll(p, (-dy if py else 0, -dx if px else 0), axis=(0, 1))
|
||||
else:
|
||||
s = p
|
||||
oy = 0 if py else 1 + dy
|
||||
ox = 0 if px else 1 + dx
|
||||
out |= s[oy:oy + ny, ox:ox + nx]
|
||||
return out
|
||||
|
||||
|
||||
def dilate(mask, n, periodic=(False, False)):
|
||||
"""Chebyshev dilation by n cells; wraps across periodic axes only."""
|
||||
periodic = _periodic(periodic)
|
||||
m = np.asarray(mask, bool).copy()
|
||||
for _ in range(int(n)):
|
||||
m = _neighbour_or(m, periodic)
|
||||
return m
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class TagResult:
|
||||
core: np.ndarray # bool: hysteresis state on eligible cells; feed back as prev_core
|
||||
tagged: np.ndarray # bool: core + buffer, clipped to owned; proposal for a regridder
|
||||
reason: np.ndarray # uint8 bits WALL|SHEAR|HELD on core cells
|
||||
strain_star: np.ndarray # float64 s*; NaN where unevaluable or not valid
|
||||
cell_strain: np.ndarray # float64 |S| dx_level / U_ref (diagnostic only)
|
||||
wall_distance: np.ndarray # float64 coarse cells from non-solid centres; inf if no wall / solid
|
||||
unevaluable: np.ndarray # bool: eligible cells with no usable gradient stencil
|
||||
eligible: np.ndarray # bool: owned & ~solid (the only cells that can hold core tags)
|
||||
n_unevaluable_fluid: int # count of `unevaluable`
|
||||
|
||||
|
||||
def strain_rate(ux, uy, fluid, dx_level, periodic=(False, False)):
|
||||
"""|S| = sqrt(2 S:S) in 1/coarse-time-step on `fluid` (= valid sample) cells; NaN elsewhere.
|
||||
|
||||
Each derivative uses a central difference when both neighbours are valid (inside the
|
||||
domain or across a periodic seam), else a one-sided difference (first order) to one valid
|
||||
neighbour, else NaN. Values in non-valid cells are never read.
|
||||
"""
|
||||
fluid = np.asarray(fluid, bool)
|
||||
px, py = _periodic(periodic)
|
||||
|
||||
def ddir(f, axis, per):
|
||||
n = f.shape[axis]
|
||||
idx = np.arange(n)
|
||||
ip, im = idx + 1, idx - 1
|
||||
okp, okm = ip < n, im >= 0
|
||||
if per:
|
||||
ip, im = ip % n, im % n
|
||||
okp = okm = np.ones(n, bool)
|
||||
ip, im = np.clip(ip, 0, n - 1), np.clip(im, 0, n - 1)
|
||||
take = lambda a, i: np.take(a, i, axis=axis)
|
||||
shape = [1, 1]; shape[axis] = n
|
||||
fp = take(fluid, ip) & okp.reshape(shape)
|
||||
fm = take(fluid, im) & okm.reshape(shape)
|
||||
fz = np.where(fluid, f, 0.0)
|
||||
vp, vm = take(fz, ip), take(fz, im)
|
||||
out = np.full(f.shape, np.nan)
|
||||
c = fluid & fp & fm
|
||||
out[c] = 0.5 * (vp - vm)[c]
|
||||
o = fluid & fp & ~fm
|
||||
out[o] = (vp - fz)[o]
|
||||
o = fluid & ~fp & fm
|
||||
out[o] = (fz - vm)[o]
|
||||
return out / dx_level
|
||||
|
||||
dudx, dudy = ddir(ux, 1, px), ddir(ux, 0, py)
|
||||
dvdx, dvdy = ddir(uy, 1, px), ddir(uy, 0, py)
|
||||
sxy = 0.5 * (dudy + dvdx)
|
||||
return np.sqrt(2.0 * (dudx**2 + dvdy**2 + 2.0 * sxy**2))
|
||||
|
||||
|
||||
def wall_distance(fluid, dx_level, origin=(0.0, 0.0), distance_fn=None,
|
||||
periodic=(False, False), solid=None):
|
||||
"""Distance (coarse cells) from each non-solid cell centre to the wall; inf if no wall.
|
||||
|
||||
`solid` (default ~fluid) is the physical geometry; only solid cells with a non-solid
|
||||
Chebyshev neighbour (seam-aware) are wall cells, so absent cells are never walls.
|
||||
distance_fn(x, y) -> signed distance (coarse units, negative inside bodies) is preferred
|
||||
when geometry is known; it is evaluated at cell centres as given, so for periodic axes it
|
||||
must itself return the minimum-image distance. Otherwise a raster HEURISTIC is used:
|
||||
distance to the nearest wall-cell centre (minimum image on periodic axes) minus dx/2.
|
||||
It is exact only for a planar wall lying on cell faces; it has no general error bound
|
||||
(a physical wall off the cell face, curved, sub-cell or off-patch bodies differ).
|
||||
"""
|
||||
fluid = np.asarray(fluid, bool)
|
||||
solid = ~fluid if solid is None else np.asarray(solid, bool)
|
||||
px, py = _periodic(periodic)
|
||||
ny, nx = fluid.shape
|
||||
x = origin[0] + (np.arange(nx) + 0.5) * dx_level
|
||||
y = origin[1] + (np.arange(ny) + 0.5) * dx_level
|
||||
X, Y = np.meshgrid(x, y)
|
||||
target = ~solid
|
||||
d = np.full(fluid.shape, np.inf)
|
||||
if distance_fn is not None:
|
||||
dv = np.asarray(distance_fn(X, Y), dtype=np.float64)
|
||||
if dv.shape != fluid.shape or not np.all(np.isfinite(dv[target])):
|
||||
raise ValueError("distance_fn must return finite values of the grid shape")
|
||||
d[target] = np.maximum(dv[target], 0.0)
|
||||
return d
|
||||
wall = solid & _neighbour_or(~solid, (px, py))
|
||||
if not wall.any() or not target.any():
|
||||
return d
|
||||
Lx, Ly = nx * dx_level, ny * dx_level
|
||||
wx, wy = X[wall], Y[wall]
|
||||
fx, fy = X[target], Y[target]
|
||||
best = np.empty(fx.size)
|
||||
for s in range(0, fx.size, _CHUNK):
|
||||
ex, ey = np.abs(fx[s:s + _CHUNK, None] - wx), np.abs(fy[s:s + _CHUNK, None] - wy)
|
||||
if px:
|
||||
ex = np.minimum(ex, Lx - ex)
|
||||
if py:
|
||||
ey = np.minimum(ey, Ly - ey)
|
||||
best[s:s + _CHUNK] = np.sqrt((ex * ex + ey * ey).min(axis=1))
|
||||
d[target] = np.maximum(best - 0.5 * dx_level, 0.0)
|
||||
return d
|
||||
|
||||
|
||||
def tag_cells(ux, uy, solid, *, dx_level, U_ref, L_ref, shear_on, shear_off,
|
||||
band_on, band_off, n_buffer=2, prev_core=None, origin=(0.0, 0.0),
|
||||
periodic=(False, False), distance_fn=None, valid=None, owned=None):
|
||||
"""Propose refinement tags for one level. Pure function: inputs are not modified.
|
||||
|
||||
band_on/band_off are in units of L_ref. See module docstring for the three masks.
|
||||
"""
|
||||
solid = np.asarray(solid)
|
||||
if solid.dtype != np.bool_ or solid.ndim != 2 or min(solid.shape) < 3:
|
||||
raise ValueError("solid must be a 2-D bool array with both sides >= 3")
|
||||
shape = solid.shape
|
||||
ux = np.asarray(ux, dtype=np.float64)
|
||||
uy = np.asarray(uy, dtype=np.float64)
|
||||
if ux.shape != shape or uy.shape != shape:
|
||||
raise ValueError("ux, uy and solid must have the same (ny, nx) shape")
|
||||
valid = _mask("valid", valid, shape, ~solid)
|
||||
owned = _mask("owned", owned, shape, np.ones(shape, bool))
|
||||
if np.any(valid & solid):
|
||||
raise ValueError("valid must not overlap solid (solid cells are not velocity samples)")
|
||||
eligible = owned & ~solid
|
||||
if np.any(eligible & ~valid):
|
||||
raise ValueError("owned non-solid cells must be valid (have a velocity sample)")
|
||||
if not (np.all(np.isfinite(ux[valid])) and np.all(np.isfinite(uy[valid]))):
|
||||
raise ValueError("velocity must be finite on every valid cell")
|
||||
dx_level = _num("dx_level", dx_level, positive=True)
|
||||
lvl = -np.log2(dx_level)
|
||||
if abs(lvl - round(lvl)) > 1e-12 or round(lvl) < 0:
|
||||
raise ValueError("dx_level must be 2**-level coarse cells (1, 0.5, 0.25, ...)")
|
||||
U_ref = _num("U_ref", U_ref, positive=True)
|
||||
L_ref = _num("L_ref", L_ref, positive=True)
|
||||
shear_on = _num("shear_on", shear_on, positive=True)
|
||||
shear_off = _num("shear_off", shear_off, nonneg=True)
|
||||
if not shear_off < shear_on:
|
||||
raise ValueError("hysteresis needs shear_off < shear_on")
|
||||
band_on = _num("band_on", band_on, nonneg=True)
|
||||
band_off = _num("band_off", band_off, nonneg=True)
|
||||
if not band_off >= band_on:
|
||||
raise ValueError("hysteresis needs band_off >= band_on")
|
||||
if isinstance(n_buffer, (bool, np.bool_)) or not isinstance(n_buffer, Integral) or n_buffer < 0:
|
||||
raise ValueError("n_buffer must be a non-negative integer")
|
||||
if len(origin) != 2:
|
||||
raise ValueError("origin must be (x0, y0) in coarse units")
|
||||
origin = (_num("origin[0]", origin[0]), _num("origin[1]", origin[1]))
|
||||
periodic = _periodic(periodic)
|
||||
if prev_core is None:
|
||||
prev = np.zeros(shape, bool)
|
||||
else:
|
||||
prev = np.asarray(prev_core)
|
||||
if prev.dtype != np.bool_ or prev.shape != shape:
|
||||
raise ValueError("prev_core must be a bool array of the grid shape")
|
||||
if np.any(prev & ~eligible):
|
||||
raise ValueError("prev_core marks solid/unowned cells: pass TagResult.core, not tagged")
|
||||
|
||||
S = strain_rate(ux, uy, valid, dx_level, periodic)
|
||||
s_star = S * L_ref / U_ref
|
||||
cell_strain = S * dx_level / U_ref
|
||||
d = wall_distance(~solid, dx_level, origin, distance_fn, periodic, solid=solid)
|
||||
dn = d / L_ref
|
||||
|
||||
ok = np.isfinite(s_star)
|
||||
sv = np.where(ok, s_star, -np.inf)
|
||||
unevaluable = eligible & ~ok
|
||||
held = unevaluable & prev
|
||||
shear = eligible & (((sv >= shear_on) | (prev & (sv >= shear_off))) | held)
|
||||
wall = eligible & ((dn <= band_on) | (prev & (dn <= band_off)))
|
||||
core = shear | wall
|
||||
reason = (wall * WALL + shear * SHEAR + held * HELD).astype(np.uint8)
|
||||
tagged = dilate(core, n_buffer, periodic) & owned
|
||||
return TagResult(_ro(core), _ro(tagged), _ro(reason), _ro(s_star), _ro(cell_strain),
|
||||
_ro(d), _ro(unevaluable), _ro(eligible),
|
||||
int(np.count_nonzero(unevaluable)))
|
||||
@@ -0,0 +1,140 @@
|
||||
"""One measured L1 strain observation, one growth-only L2 remap, no scheduler.
|
||||
|
||||
Run from the repository root: PYTHONPATH=src:. python -m research.shear_refinement.example
|
||||
Requires CUDA; CPU helpers and tests never import CUDA.
|
||||
"""
|
||||
from contextlib import ExitStack
|
||||
from numbers import Integral
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab.config import LBMConfig
|
||||
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||
from CelerisLab.body.objects import SimObject
|
||||
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
|
||||
from CelerisLab.lbm.refinement import FixedPatchSet2D
|
||||
from CelerisLab.lbm.synchronized_remap import RemapTopology
|
||||
from .boundary_shear_tagging import tag_cells
|
||||
|
||||
CFG = LBMConfig(nx=48, ny=32, viscosity=.1)
|
||||
PARENTS = ((4, 4, 20, 28), (24, 4, 44, 28))
|
||||
CHILDREN = ((16, 16, 32, 40), (56, 16, 72, 40))
|
||||
CORRIDOR = (72, 20, 76, 36) # L1 allocated indices; bounded to right of fluid L2
|
||||
|
||||
|
||||
def grow_rectangle(current, request, *, alignment=4, area_cap=480):
|
||||
"""Bounding-box growth only, in request-array cell-edge coordinates.
|
||||
|
||||
No body or solver knowledge. Reject invalid/budget-exceeding proposals with
|
||||
ValueError; never clip a request, shrink old coverage or mutate an input.
|
||||
Alignment is edge divisibility, not a substitute for full topology guards.
|
||||
"""
|
||||
request = np.asarray(request)
|
||||
if request.dtype != bool or request.ndim != 2:
|
||||
raise ValueError('request must be a 2D boolean mask')
|
||||
values = (*current, alignment, area_cap)
|
||||
if len(current) != 4 or any(isinstance(v, (bool, np.bool_)) or not isinstance(v, Integral) for v in values):
|
||||
raise ValueError('rectangle, alignment and area cap must be integers')
|
||||
x0, y0, x1, y1 = current
|
||||
ny, nx = request.shape
|
||||
if alignment <= 0 or area_cap <= 0 or not (0 <= x0 < x1 <= nx and 0 <= y0 < y1 <= ny):
|
||||
raise ValueError('invalid rectangle, alignment or area cap')
|
||||
if any(v % alignment for v in current):
|
||||
raise ValueError('current rectangle must be aligned')
|
||||
y, x = np.nonzero(request)
|
||||
if x.size:
|
||||
x0, y0 = min(x0, int(x.min())), min(y0, int(y.min()))
|
||||
x1, y1 = max(x1, int(x.max())+1), max(y1, int(y.max())+1)
|
||||
a = alignment
|
||||
result = (x0//a*a, y0//a*a, (x1+a-1)//a*a, (y1+a-1)//a*a)
|
||||
if result[2] > nx or result[3] > ny:
|
||||
raise ValueError('aligned growth exceeds observation frame')
|
||||
if (result[2]-result[0])*(result[3]-result[1]) > area_cap:
|
||||
raise ValueError('growth exceeds area cap')
|
||||
return result
|
||||
|
||||
|
||||
def objects():
|
||||
return (SimObject(71, geometry=CircleGeometry(12., 14., .8),
|
||||
center=(12., 14.), radius=.8),)
|
||||
|
||||
|
||||
def topology(rectangles=CHILDREN):
|
||||
return RemapTopology.from_config(CFG, FixedPatchSet2D.from_config(CFG, PARENTS),
|
||||
rectangles, child_objects=objects())
|
||||
|
||||
|
||||
def observe(g, mesh, *, shear_on=.001):
|
||||
"""Moments of completed-time postcollision G; only real L1 fluid leaves.
|
||||
|
||||
No reconstructed covered values, ghosts or holes are derivative donors.
|
||||
solid is independent geometry (empty on L1: the body is pinned at L2).
|
||||
The array is cropped/sparse, so neither derivative axis wraps.
|
||||
"""
|
||||
valid = mesh.leaf_masks[1]
|
||||
f = g[:, valid].astype(np.float64)
|
||||
rho = f.sum(axis=0)
|
||||
if not np.isfinite(f).all() or np.any(rho <= 0):
|
||||
raise ValueError('invalid completed-time L1 fluid populations')
|
||||
ux, uy = (np.full(valid.shape, np.nan) for _ in range(2))
|
||||
ux[valid] = (np.asarray(D2Q9_EX)[:, None]*f).sum(axis=0)/rho
|
||||
uy[valid] = (np.asarray(D2Q9_EY)[:, None]*f).sum(axis=0)/rho
|
||||
tags = tag_cells(ux, uy, np.zeros(valid.shape, bool), valid=valid, owned=valid,
|
||||
dx_level=.5, origin=mesh.parent.fine_origin, periodic=(False, False),
|
||||
U_ref=.05, L_ref=1., shear_on=shear_on, shear_off=0.,
|
||||
band_on=0., band_off=0., n_buffer=0)
|
||||
corridor = np.zeros(valid.shape, bool)
|
||||
x0, y0, x1, y1 = CORRIDOR
|
||||
corridor[y0:y1, x0:x1] = True
|
||||
request = tags.tagged & corridor
|
||||
proposed = (CHILDREN[0], grow_rectangle(CHILDREN[1], request))
|
||||
# Host guards cover nesting, donor footprints, complete body and separation.
|
||||
candidate = topology(proposed)
|
||||
candidate.child.fine_layout((4, 4))
|
||||
report = dict(requested=int(request.sum()), unknown=int(tags.unevaluable.sum()),
|
||||
corridor_unknown=int((tags.unevaluable & corridor).sum()),
|
||||
corridor_unavailable=int((corridor & ~valid).sum()),
|
||||
out_of_scope=int((tags.tagged & ~corridor).sum()), rectangles=proposed)
|
||||
return proposed, report
|
||||
|
||||
|
||||
def initial_fields(mesh):
|
||||
"""Smooth periodic shear wave, sampled in each level's physical frame."""
|
||||
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
|
||||
frames = ((np.zeros(2), 1.), (np.asarray(mesh.parent.fine_origin), .5),
|
||||
(np.asarray(mesh.parent.fine_origin)+.5*np.asarray(mesh.child.fine_origin), .25))
|
||||
arrays = []
|
||||
for leaf, (origin, dx) in zip(mesh.leaf_masks, frames):
|
||||
y = origin[1]+dx*(np.arange(leaf.shape[0])+.5)
|
||||
ux = .01*np.sin(2*np.pi*y/CFG.ny)
|
||||
rows = np.stack([compute_feq_d2q9(1., u, 0.) for u in ux], axis=1)
|
||||
arrays.append(np.broadcast_to(rows[:, :, None], (9, *leaf.shape)).copy())
|
||||
return tuple(arrays)
|
||||
|
||||
|
||||
def initialize(run):
|
||||
run.initialize(*initial_fields(topology()))
|
||||
|
||||
|
||||
def make(context):
|
||||
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
|
||||
return FixedPatchStepper(CFG, PARENTS, context=context, base_block=(4, 4),
|
||||
fine_block=(4, 4), child_block=(4, 4),
|
||||
child_rectangles_in_parent=CHILDREN, child_objects=objects())
|
||||
|
||||
|
||||
def main():
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
with CudaContext(0) as ctx, ExitStack() as owned:
|
||||
run = owned.enter_context(make(ctx))
|
||||
initialize(run)
|
||||
run.step(2) # Returns only at completed synchronized base time.
|
||||
proposed, report = observe(run.download()[1], topology())
|
||||
print(report)
|
||||
if proposed != CHILDREN:
|
||||
run = owned.enter_context(run.remap_child(proposed, child_objects=objects()))
|
||||
run.step(2)
|
||||
print(f'completed base step {run.step_count}; body rectangle {run.child_patch.bounds[0]}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""CPU-only research-example contracts; no solver/GPU import required."""
|
||||
import unittest
|
||||
import numpy as np
|
||||
from research.shear_refinement.boundary_shear_tagging import tag_cells, strain_rate
|
||||
from research.shear_refinement.example import (CHILDREN, grow_rectangle, topology,
|
||||
observe, initial_fields)
|
||||
|
||||
|
||||
class ShearRefinementExample(unittest.TestCase):
|
||||
def test_affine_shear_physical_spacing_and_rotation(self):
|
||||
y, x = np.indices((12, 20), dtype=float)
|
||||
x, y = 7.+.5*(x+.5), 3.+.5*(y+.5)
|
||||
valid = np.ones(x.shape, bool)
|
||||
np.testing.assert_allclose(strain_rate(.003*y, 0*x, valid, .5), .003, atol=1e-17)
|
||||
np.testing.assert_allclose(strain_rate(-.003*y, .003*x, valid, .5), 0., atol=1e-17)
|
||||
|
||||
def test_holes_are_unknown_not_walls_or_zero(self):
|
||||
valid = np.zeros((7, 7), bool); valid[3, 3] = True
|
||||
u = np.full(valid.shape, np.nan); u[3, 3] = .1
|
||||
result = tag_cells(u, u, np.zeros_like(valid), valid=valid, owned=valid,
|
||||
dx_level=.5, origin=(7., 3.), U_ref=.05, L_ref=1.,
|
||||
shear_on=.01, shear_off=0., band_on=0., band_off=0., n_buffer=0)
|
||||
self.assertTrue(result.unevaluable[3, 3])
|
||||
self.assertTrue(np.isnan(result.strain_star[3, 3]))
|
||||
self.assertFalse(result.tagged.any())
|
||||
self.assertTrue(np.isinf(result.wall_distance).all())
|
||||
|
||||
def test_masked_hole_never_supplies_a_gradient(self):
|
||||
y, x = np.indices((9, 9), dtype=float)
|
||||
valid = np.ones(x.shape, bool); valid[:, 4] = False
|
||||
ux = .003*(5.+.5*y); uy = np.zeros_like(ux)
|
||||
ux[~valid] = np.inf; uy[~valid] = np.nan
|
||||
strain = strain_rate(ux, uy, valid, .5)
|
||||
np.testing.assert_allclose(strain[valid], .003, atol=1e-17)
|
||||
self.assertTrue(np.isnan(strain[~valid]).all())
|
||||
|
||||
def test_predictable_growth_and_input_unchanged(self):
|
||||
mask = np.zeros((64, 96), bool); mask[22, 74] = True
|
||||
before = mask.copy()
|
||||
self.assertEqual(grow_rectangle(CHILDREN[1], mask), (56, 16, 76, 40))
|
||||
np.testing.assert_array_equal(mask, before)
|
||||
mask[:] = False
|
||||
self.assertEqual(grow_rectangle(CHILDREN[1], mask), CHILDREN[1])
|
||||
|
||||
def test_budget_and_geometry_rejection(self):
|
||||
mask = np.zeros((64, 96), bool); mask[22, 74] = True
|
||||
with self.assertRaisesRegex(ValueError, 'area cap'):
|
||||
grow_rectangle(CHILDREN[1], mask, area_cap=479)
|
||||
with self.assertRaises(ValueError):
|
||||
grow_rectangle((57, 16, 72, 40), mask)
|
||||
mask = np.zeros((9, 9), bool); mask[8, 8] = True
|
||||
with self.assertRaisesRegex(ValueError, 'frame'):
|
||||
grow_rectangle((0, 0, 4, 4), mask)
|
||||
with self.assertRaises(ValueError):
|
||||
topology((CHILDREN[0], (56, 16, 88, 40)))
|
||||
|
||||
def test_uniform_velocity_observation_does_not_grow(self):
|
||||
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
|
||||
mesh = topology()
|
||||
g = np.broadcast_to(compute_feq_d2q9(1., .01, 0.)[:, None, None],
|
||||
(9, *mesh.leaf_masks[1].shape)).copy()
|
||||
g[:, ~mesh.leaf_masks[1]] = np.nan
|
||||
rectangles, report = observe(g, mesh)
|
||||
self.assertEqual(rectangles, CHILDREN)
|
||||
self.assertEqual(report['requested'], 0)
|
||||
|
||||
def test_observation_growth_and_low_strain_no_change(self):
|
||||
mesh = topology()
|
||||
g = initial_fields(mesh)[1]
|
||||
g[:, ~mesh.leaf_masks[1]] = np.nan
|
||||
before = g.copy()
|
||||
rectangles, report = observe(g, mesh)
|
||||
self.assertEqual(rectangles, (CHILDREN[0], (56, 16, 76, 40)))
|
||||
self.assertEqual(report['requested'], 64)
|
||||
self.assertEqual(report['corridor_unknown'], 0)
|
||||
self.assertEqual(report['corridor_unavailable'], 0)
|
||||
self.assertGreater(report['out_of_scope'], 0)
|
||||
np.testing.assert_array_equal(g, before)
|
||||
rectangles, report = observe(g, mesh, shear_on=1.)
|
||||
self.assertEqual(rectangles, CHILDREN)
|
||||
self.assertEqual(report['requested'], 0)
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Run explicitly on an idle GPU; automatic/manual identical-layout equivalence.
|
||||
|
||||
This is not independent flow-accuracy validation. CPU collection never runs it.
|
||||
PYTHONPATH=src:.:tests/validation/multigrid python -m unittest discover \
|
||||
-s tests/validation/multigrid -p test_shear_refinement_example_gpu.py -v
|
||||
"""
|
||||
from contextlib import ExitStack
|
||||
import unittest
|
||||
import numpy as np
|
||||
from CelerisLab.cuda.context import CudaContext
|
||||
from research.shear_refinement.example import (CHILDREN, make, initialize, observe,
|
||||
topology, objects)
|
||||
|
||||
|
||||
class ShearRefinementGPU(unittest.TestCase):
|
||||
def test_measured_growth_matches_explicit_manual_continuation(self):
|
||||
with CudaContext(0) as ctx, ExitStack() as owned:
|
||||
old = owned.enter_context(make(ctx))
|
||||
initialize(old)
|
||||
old.step(2)
|
||||
before = old.download()
|
||||
chosen, report = observe(before[1], topology())
|
||||
manual = (CHILDREN[0], (56, 16, 76, 40))
|
||||
self.assertEqual(chosen, manual)
|
||||
self.assertGreater(report['requested'], 0)
|
||||
self.assertEqual(report['corridor_unknown'], 0)
|
||||
self.assertEqual(report['corridor_unavailable'], 0)
|
||||
auto = owned.enter_context(old.remap_child(chosen, child_objects=objects()))
|
||||
explicit = owned.enter_context(old.remap_child(manual, child_objects=objects()))
|
||||
for run in (auto, explicit):
|
||||
self.assertEqual(run.child_patch.bounds[0], CHILDREN[0])
|
||||
self.assertEqual(run.step_count, 2)
|
||||
run.step(2)
|
||||
for a, b, leaf in zip(auto.download(), explicit.download(), topology(manual).leaf_masks):
|
||||
np.testing.assert_array_equal(a, b)
|
||||
self.assertTrue(np.isfinite(a[:, leaf]).all())
|
||||
self.assertTrue(np.isnan(a[:, ~leaf]).all())
|
||||
for a, b in zip(old.download(), before):
|
||||
np.testing.assert_array_equal(a, b)
|
||||
self.assertEqual((old.step_count, auto.step_count, explicit.step_count), (2, 4, 4))
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
Reference in New Issue
Block a user