Add bounded pinned-circle manual L2 remapping

This commit is contained in:
2026-10-16 10:41:56 -07:00
parent 766658185f
commit 6a7e9226db
6 changed files with 361 additions and 21 deletions
+39
View File
@@ -109,3 +109,42 @@ finite leaves, exact masks and absolute counts. They were not run on the CPU
host. The separate independent FP64 capture/replay gate is max population
error **3e-6**, with exact masks; remap mass/momentum allow FP32 roundoff.
Per-population conservation is not an advancement target under collision.
### Bounded pinned-body extension
`replacement = current.remap_child(rectangles, child_objects=original_objects)`
now also accepts exactly one stationary Circle in one of exactly two L2
rectangles. Its **complete containing rectangle must remain identical**;
only the separated fluid rectangle may change. Existing nesting, four-cell
gap, full-circle containment, reverse cut-link coverage and two-hop BFL
transfer-footprint checks still apply. This is manual remapping, not adaptive
body tracking or runtime body editing.
Supply the original root edge-frame definition used at construction. Do not
pass `current.child_boundary.objects`: those are projected fine-local copies.
There is no retained mutable original-object registry. Before reading device
state, remapping reprojects the supplied definition onto the old topology and
compares flags, physical cut links, body IDs, q, lever arms, hits, BFL support,
legacy SurfaceActuation specification and current per-link velocity against
the existing boundary. Omission, changed geometry/actuation and changed body
rectangle fail without device reads or allocations. Root object IDs are still
assigned by list position, as in the constructor.
Jets are taken from the live L2 boundary, including the most recent successful
`set_jet_amplitude(..., level=2)`. There is no replacement-Jets argument or
constructor-time Jet snapshot. New blocked addresses may differ; the physical
boundary and velocities must match exactly.
`RemapTopology.from_config(..., child_objects=..., child_jets=...)` prepares a
typed `FineBoundary2D`; it derives L2 fluid leaves as ACTIVE minus SOLID. No
caller mask is accepted. Solids, ghosts and holes are never donors and remain
NaN, physical-fluid overlap is exact, and exposed L1 still restricts all four
old fluid children. The helper checks the old/new body pin and boundary too.
Focused CPU mocks: `tests/unit/test_pinned_body_remap.py`. GPU declarations:
`tests/validation/multigrid/test_pinned_body_remap_gpu.py`, covering no-op
next-step bitwise identity, moved-fluid A→B→A, current-amplitude preservation,
physical boundary/velocity equality, source independence and absolute counts.
These GPU cases have **not been run here**. They do not establish evolution
accuracy. A 3e-6 FP32-vs-FP64 evolution gate requires a separate independent
BFL reference; the body-free replay gate above does not establish that claim.
+37 -11
View File
@@ -3,7 +3,7 @@
The caller owns the CudaContext and must close this backend before that context.
Only synchronized postcollision_g leaf populations are public. No checkpoint,
forcing or runtime parameter mutation is offered. Manual body-free periodic
forcing or runtime parameter mutation is offered. Bounded manual periodic
L2 set replacement is available at completed base intervals via remap_child.
"""
from dataclasses import dataclass, replace
@@ -494,29 +494,54 @@ class FixedPatchStepper:
self._status = "failed"
raise
def remap_child(self, rectangles):
def remap_child(self, rectangles, *, child_objects=()):
"""Return a NEW ready stepper with an L2 set replaced at this base time.
Only body-free periodic three-level set children are supported; L0/L1
and block sizes stay fixed. The lock and stream synchronization certify
Periodic three-level set children only; L0/L1 and block sizes stay fixed.
A body run requires its original root-edge-frame ``child_objects``:
one stationary circle pinned in one of exactly two L2 rectangles.
The complete body rectangle stays identical; only the other fluid
rectangle may move. Current L2 Jets (including updated amplitudes)
are preserved. Local projected boundary objects are not valid inputs.
The lock and stream synchronization certify
current postcollision G and the completed 1:2:4 counts internally.
Both objects remain independently owned: the caller must close each,
and chooses when to stop using the unchanged old instance. Candidate
construction/upload errors never invalidate the old instance; failure
to synchronize/read its device state does, as with step/download.
"""
from .synchronized_remap import RemapTopology, remap_synchronized
from .synchronized_remap import (RemapTopology, remap_synchronized,
same_physical_boundary, boundary_velocity)
with self._lock:
self._require("ready")
if (self._wall_aware or len(self._levels) != 3
or not isinstance(self.child_patch, FixedPatchSet2D)
or self.fine_boundary is not None or self.child_boundary is not None
or any(level.boundary is not None for level in self._levels)):
raise ValueError("remap_child requires body-free periodic three-level L2 sets")
or self.fine_boundary is not None
or any(level.boundary is not None for level in self._levels[:2])):
raise ValueError("remap_child requires periodic three-level L2 sets with no L0/L1 bodies")
cfg = self._remap_cfg
old = RemapTopology.from_config(cfg, self.patch, self.child_patch.bounds)
new = RemapTopology.from_config(cfg, self.patch, rectangles)
child_objects = tuple(child_objects)
actual = self._boundaries[2]
jets = () if actual is None else actual.jets
old = RemapTopology.from_config(cfg, self.patch, self.child_patch.bounds,
child_objects=child_objects, child_jets=jets)
if (not same_physical_boundary(old.boundary, self.child_boundary)
or (self.child_boundary is not None and actual is None)):
raise ValueError("child_objects must reproduce the original root-edge-frame boundary")
if actual is not None:
velocity = actual.tables.get("surface_velocity")
if velocity is None:
velocity = boundary_velocity(cfg, self.child_patch, actual.host, jets)
if (not same_physical_boundary(old.boundary, actual.host)
or not np.array_equal(old.surface_velocity, velocity)):
raise ValueError("child_objects changed boundary ownership or surface velocity")
new = RemapTopology.from_config(cfg, self.patch, rectangles,
child_objects=child_objects, child_jets=jets)
if (old.pinned_rectangle != new.pinned_rectangle
or not same_physical_boundary(old.boundary, new.boundary)
or not np.array_equal(old.surface_velocity, new.surface_velocity)):
raise ValueError("remap requires the identical pinned body rectangle and boundary")
base_block, fine_block, child_block = self._remap_blocks
# Check sparse block alignment/launch size before any device read/allocation.
layout = new.child.fine_layout(child_block)
@@ -536,7 +561,8 @@ class FixedPatchStepper:
candidate = type(self)(
cfg, self.patch.bounds, context=self.ctx, base_block=base_block,
fine_block=fine_block, child_block=child_block,
child_rectangles_in_parent=new.child.bounds)
child_rectangles_in_parent=new.child.bounds,
child_objects=child_objects, child_jets=jets)
try:
candidate.initialize(*transferred.g)
candidate._step_count = count
+70 -9
View File
@@ -1,16 +1,21 @@
"""CPU-only whole-volume G transfer, not a runtime adaptive solver.
Only body-free periodic three-level grids with a fixed L0/L1 topology are
supported. The caller certifies that current owned leaves are quiescent,
Periodic three-level grids with fixed L0/L1 topology are supported, including
one stationary circle pinned inside an unchanged L2 rectangle. The caller
certifies that current owned leaves are quiescent,
unshifted D2Q9 postcollision G at the supplied completed base interval.
No device buffers, boundary state, ghosts, collision or time advance occur.
No device buffers, device boundary state, ghosts, collision or time advance
occur; boundary preparation is host-only.
"""
from dataclasses import dataclass
from dataclasses import dataclass, replace
from numbers import Integral
import numpy as np
from .refinement import ACTIVE, FixedPatch2D, FixedPatchSet2D, nest_patch_set
from .refinement import (ACTIVE, FixedPatch2D, FixedPatchSet2D, FineBoundary2D,
nest_patch_set, prepare_fine_boundary)
from .descriptors import SOLID
from ..body.jets import surface_link_velocity
@dataclass(frozen=True, eq=False)
@@ -24,19 +29,71 @@ class RemapTopology:
parent: object
child: FixedPatchSet2D
leaf_masks: tuple
boundary: FineBoundary2D | None = None
pinned_rectangle: tuple | None = None
surface_velocity: np.ndarray | None = None
@classmethod
def from_config(cls, cfg, parent, child_rectangles, *, coarse_boundary="periodic", objects=()):
def from_config(cls, cfg, parent, child_rectangles, *, coarse_boundary="periodic", objects=(),
child_objects=(), child_jets=()):
if coarse_boundary != "periodic" or tuple(objects) or cfg.nz != 1:
raise ValueError("remap supports only body-free periodic 2D state")
raise ValueError("remap requires periodic 2D state; use child_objects for a pinned L2 body")
if type(parent) not in (FixedPatch2D, FixedPatchSet2D):
raise ValueError("parent must be a fixed patch or fixed patch set")
parent = type(parent).from_config(cfg, parent.bounds)
child, middle_kind = nest_patch_set(cfg, parent, child_rectangles)
masks = (~parent.covered, middle_kind == ACTIVE, child.fine_kind == ACTIVE)
child_objects, child_jets = tuple(child_objects), tuple(child_jets)
if child_objects and (len(child_objects) != 1 or len(child.bounds) != 2):
raise ValueError("body remap requires one stationary circle and two L2 rectangles")
fine_cfg = replace(cfg, nx=parent.fine_shape[1], ny=parent.fine_shape[0],
viscosity=2 * cfg.viscosity)
boundary = prepare_fine_boundary(
fine_cfg, child, child_objects, jets=child_jets,
parent_origin=parent.fine_origin, parent_spacing=.5)
pinned, velocity = None, None
child_leaf = child.fine_kind == ACTIVE
if boundary is not None:
child_leaf &= (boundary.flags.reshape(child.fine_shape) & SOLID) == 0
# Whole circle containment, not merely the resolved solid cells.
obj = child_objects[0]
cx, cy = (np.asarray(obj.geometry.center) - parent.fine_origin) * 2
radius = obj.geometry._radius * 2
pinned = next(r for r in child.bounds if
r[0] <= cx-radius and cx+radius <= r[2] and
r[1] <= cy-radius and cy+radius <= r[3])
velocity = boundary_velocity(cfg, child, boundary, child_jets)
if velocity is not None:
velocity.setflags(write=False)
masks = (~parent.covered, middle_kind == ACTIVE, child_leaf)
for mask in masks:
mask.setflags(write=False)
return cls(parent, child, masks)
return cls(parent, child, masks, boundary, pinned, velocity)
def boundary_velocity(cfg, child, boundary, jets=()):
"""Physical cut-link order; never blocked addresses or stale Jet snapshots."""
local_cfg = replace(cfg, nx=child.fine_shape[1], ny=child.fine_shape[0],
viscosity=4 * cfg.viscosity)
local_cfg.validate()
fluid, dirs, _, ids, rx, ry, _, _ = boundary.curved_host
velocity, _ = surface_link_velocity(
boundary.objects, local_cfg, boundary.flags, fluid, dirs, ids, rx, ry,
jets=jets, hits=boundary.curved_hits)
return velocity
def same_physical_boundary(a, b):
"""Compare geometry, ownership, hits and the entire BFL support stencil."""
if a is None or b is None:
return a is b
if not isinstance(a, FineBoundary2D) or not isinstance(b, FineBoundary2D):
return False
if (tuple(o.surface_actuation for o in a.objects)
!= tuple(o.surface_actuation for o in b.objects)):
return False
return all(np.array_equal(x, y) for x, y in zip(
(a.flags, a.boundary_cells, a.curved_hits, *a.curved_host),
(b.flags, b.boundary_cells, b.curved_hits, *b.curved_host)))
@dataclass(frozen=True, eq=False)
@@ -105,6 +162,10 @@ def remap_synchronized(old, new, old_g, *, base_count, level_steps, phase,
or not _same_parent(old.parent, new.parent)
or old.child.tau_fine != new.child.tau_fine):
raise ValueError("remap requires unchanged L0/L1 geometry and relaxation")
if (old.pinned_rectangle != new.pinned_rectangle
or not same_physical_boundary(old.boundary, new.boundary)
or not np.array_equal(old.surface_velocity, new.surface_velocity)):
raise ValueError("remap requires the identical pinned body rectangle and physical boundary")
if not isinstance(level_steps, (tuple, list)):
raise ValueError("level_steps must be three completed integer counts")
counts = (base_count, *level_steps)
+130
View File
@@ -0,0 +1,130 @@
"""Bounded pinned-body transfer and host lifecycle; no CUDA numerical claim."""
import unittest
from unittest.mock import patch
import numpy as np
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject, SurfaceActuation
from CelerisLab.body.jets import Jet, Tube, Parallel
from CelerisLab.lbm.synchronized_remap import RemapTopology, same_physical_boundary
from tests.unit.test_synchronized_remap import CFG, PARENTS, fields, remap
from tests.unit.test_refinement_three_level_multipatch import mock_backend
from CelerisLab.lbm.refinement import FixedPatchSet2D
A = ((16,16,32,40), (56,16,72,40))
B = ((16,16,32,40), (60,16,76,40))
def body(spec=None, center=(12.,14.)):
return SimObject(71, geometry=CircleGeometry(*center,.8), center=center,
radius=.8, surface_actuation=spec)
def jet():
return Jet(Tube((10.5,14.),0.,3.,.3), Parallel(0.), 'parabolic', .003)
def mesh(rects, objects, jets=()):
return RemapTopology.from_config(CFG, FixedPatchSet2D.from_config(CFG,PARENTS),
rects, child_objects=objects, child_jets=jets)
def ready(objects=None, jets=None):
objects = (body(),) if objects is None else objects
jets = (jet(),) if jets is None else jets
with patch('tests.unit.test_refinement_three_level_multipatch.bodies_and_jets',
return_value=(objects,jets)):
run, module, allocations = mock_backend(children=A,bodies=True)
storage = {}
module.cuda.memcpy_htod.side_effect = lambda p,a: storage.__setitem__(id(p),np.array(a,copy=True))
module.cuda.memcpy_dtoh.side_effect = lambda a,p: np.copyto(a,storage[id(p)])
run.initialize(*fields(mesh(A,objects,jets),np.float32))
run._step_count = 7
return run,module,allocations,storage,objects
class PinnedBodyRemap(unittest.TestCase):
def test_fluid_only_transfer_poison_and_round_trip(self):
old,new = mesh(A,(body(),)),mesh(B,(body(),))
arrays=fields(old,np.float32)
for a,leaf in zip(arrays,old.leaf_masks): a[:,~leaf]=12345
out=remap(old,new,arrays)
for a,b,before,after in zip(arrays,out.g,old.leaf_masks,new.leaf_masks):
np.testing.assert_array_equal(a[:,before & after],b[:,before & after])
self.assertTrue(np.isnan(b[:,~after]).all())
self.assertTrue(same_physical_boundary(old.boundary,new.boundary))
back=remap(new,old,out.g)
self.assertTrue(np.isnan(back.g[2][:,~old.leaf_masks[2]]).all())
self.assertEqual(old.pinned_rectangle,A[0])
def test_current_jets_and_source_independence(self):
run,module,allocations,storage,objects=ready()
run.set_jet_amplitude(0,.017,level=2)
before=run.download()
clone=run.remap_child(B,child_objects=objects)
self.assertEqual(clone._boundaries[2].jets[0].amplitude,.017)
np.testing.assert_array_equal(clone._boundaries[2].tables['surface_velocity'],
run._boundaries[2].tables['surface_velocity'])
self.assertTrue(same_physical_boundary(run.child_boundary,clone.child_boundary))
clone.step()
self.assertEqual((run.step_count,clone.step_count),(7,8))
for got,want in zip(run.download(),before): np.testing.assert_array_equal(got,want)
clone.close();run.close()
def test_static_body_without_actuation(self):
run,module,allocations,storage,objects=ready(jets=())
clone=run.remap_child(A,child_objects=objects)
self.assertFalse(clone._boundaries[2].actuated)
self.assertNotIn('surface_velocity',clone._boundaries[2].tables)
for a,b in zip(run.download(),clone.download()): np.testing.assert_array_equal(a,b)
clone.close();run.close()
def test_reject_before_device_reads_or_allocation(self):
run,module,allocations,storage,objects=ready()
start=len(allocations)
cases=[(B,()),(B,run.child_boundary.objects),(B,(body(center=(12.1,14.)),)),
(((20,16,36,40),B[1]),objects),(((16,20,32,40),B[1]),objects),
((B[1],),objects)]
module.cuda.memcpy_dtoh.reset_mock()
for rects,objs in cases:
with self.subTest(rects=rects),self.assertRaises(ValueError):
run.remap_child(rects,child_objects=objs)
self.assertEqual(len(allocations),start)
module.cuda.memcpy_dtoh.assert_not_called()
self.assertEqual((run._status,run.step_count),('ready',7))
run.close()
def test_legacy_velocity_and_zero_velocity_ownership_rejected(self):
for speed in (.002,0.):
run,module,allocations,storage,objects=ready(
(body(SurfaceActuation(0.,.7,speed)),),())
for spec in (SurfaceActuation(0.,.7,.003),SurfaceActuation(2.,.7,speed)):
module.cuda.memcpy_dtoh.reset_mock()
with self.assertRaises(ValueError): run.remap_child(B,child_objects=(body(spec),))
module.cuda.memcpy_dtoh.assert_not_called()
clone=run.remap_child(B,child_objects=objects)
clone.close();run.close()
def test_allocation_failure_keeps_old_ready_and_count(self):
run,module,allocations,storage,objects=ready()
before=run.download(); saved=list(allocations)
allocate=module.cuda.mem_alloc.side_effect
calls=0
def fail(size):
nonlocal calls
calls+=1
if calls == 3: raise RuntimeError('candidate allocation')
return allocate(size)
with patch.object(module.cuda,'mem_alloc',side_effect=fail):
with self.assertRaisesRegex(RuntimeError,'allocation'):
run.remap_child(B,child_objects=objects)
self.assertEqual((run._status,run.step_count),('ready',7))
for got,want in zip(run.download(),before): np.testing.assert_array_equal(got,want)
for a in saved: a.free.assert_not_called()
for a in allocations[len(saved):]: a.free.assert_called_once()
run.close()
def test_direct_helper_rejects_changed_pinned_rectangle(self):
old=mesh(A,(body(),))
new=mesh(((12,16,32,40),A[1]),(body(),))
with self.assertRaisesRegex(ValueError,'pinned'): remap(old,new,fields(old))
+1 -1
View File
@@ -185,7 +185,7 @@ class SynchronizedRemapTests(unittest.TestCase):
with self.assertRaises(ValueError):
RemapTopology.from_config(replace(CFG, **changes), parent, A)
for kwargs in (dict(coarse_boundary='slot_reservoir'), dict(objects=(object(),))):
with self.assertRaisesRegex(ValueError, 'body-free periodic'):
with self.assertRaisesRegex(ValueError, 'periodic 2D'):
RemapTopology.from_config(CFG, parent, A, **kwargs)
different = topology(A, cfg=replace(CFG, viscosity=.12))
with self.assertRaisesRegex(ValueError, 'unchanged'):
@@ -0,0 +1,84 @@
"""Pinned-body GPU lifecycle gates. NOT executed by CPU test collection.
No evolution accuracy tolerance is asserted here: an independent BFL replay
reference is required before a 3e-6 FP32-vs-FP64 gate can be claimed. These
checks require exact physical boundary/velocity, remap overlap and no-op next
step identity; they never compare blocked boundary addresses across layouts.
"""
from contextlib import ExitStack
import unittest
import numpy as np
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.lbm.synchronized_remap import RemapTopology, remap_synchronized
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.body.jets import Jet, Tube, Parallel
from test_runtime_child_remap_gpu import RuntimeChildRemapGPU, initial
from test_refinement_three_level_multipatch_gpu import CFG, PARENTS
from test_refinement_blocked_base import exact
A = ((16,16,32,40), (56,16,72,40))
B = ((16,16,32,40), (60,16,76,40))
def make(ctx):
body = SimObject(71, geometry=CircleGeometry(12.,14.,.8),center=(12.,14.),radius=.8)
jet = Jet(Tube((10.5,14.),0.,3.,.3),Parallel(0.),'parabolic',.003)
run = FixedPatchStepper(CFG,PARENTS,context=ctx,base_block=(4,4),fine_block=(4,4),
child_block=(4,4),child_rectangles_in_parent=A,
child_objects=(body,),child_jets=(jet,))
return run,(body,)
class PinnedBodyRemapGPU(unittest.TestCase):
check_public = RuntimeChildRemapGPU.check_public
check_reset = RuntimeChildRemapGPU.check_reset
def check_boundary(self, before, after):
for name in ('flags','boundary_cells','curved_hits'):
exact(self,getattr(after.child_boundary,name),getattr(before.child_boundary,name),name)
for a,b in zip(after.child_boundary.curved_host,before.child_boundary.curved_host):
exact(self,a,b,'physical cut links')
exact(self,after._boundaries[2].tables['surface_velocity'],
before._boundaries[2].tables['surface_velocity'],'current per-link velocities')
self.assertEqual(after._boundaries[2].jets[0].amplitude,.017)
def test_noop_next_step_bitwise(self):
with CudaContext(0) as ctx, ExitStack() as owned:
old,objects=make(ctx);owned.enter_context(old)
old.initialize(*initial(old,False));old.step(2)
old.set_jet_amplitude(0,.017,level=2)
before=old.download()
new=owned.enter_context(old.remap_child(A,child_objects=objects))
self.check_boundary(old,new);self.check_reset(new,before)
old.step();new.step()
for a,b in zip(new.download(),old.download()): exact(self,a,b,'no-op next step bitwise')
self.assertEqual((old.step_count,new.step_count),(3,3))
self.check_public(new)
def test_moved_fluid_return_and_source_independence(self):
with CudaContext(0) as ctx, ExitStack() as owned:
current,objects=make(ctx);owned.enter_context(current)
current.initialize(*initial(current,False));current.step(2)
current.set_jet_amplitude(0,.017,level=2)
for rects in (B,A):
previous=current;before=previous.download();count=previous.step_count
jets=previous._boundaries[2].jets
old=RemapTopology.from_config(CFG,previous.patch,previous.child_patch.bounds,
child_objects=objects,child_jets=jets)
new=RemapTopology.from_config(CFG,previous.patch,rects,
child_objects=objects,child_jets=jets)
expected=remap_synchronized(old,new,before,base_count=count,
level_steps=(count,2*count,4*count),phase='postcollision_g').g
current=owned.enter_context(previous.remap_child(rects,child_objects=objects))
self.check_boundary(previous,current);self.check_reset(current,expected)
for a,b,om,nm in zip(current.download(),before,old.leaf_masks,new.leaf_masks):
exact(self,a[:,om & nm],b[:,om & nm],'physical-fluid overlap')
current.step(2)
self.assertEqual((previous.step_count,current.step_count),(count,count+2))
for a,b in zip(previous.download(),before): exact(self,a,b,'source independent')
self.check_public(current)
if __name__ == '__main__': unittest.main()