Generalize synchronized remap to multiple pinned body rectangles

This commit is contained in:
2026-10-16 11:32:42 -07:00
parent 864a220dbe
commit 60ad6b8b0d
6 changed files with 375 additions and 32 deletions
+21 -11
View File
@@ -113,22 +113,24 @@ 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
also accepts stationary Circles in separated L2 rectangles. Every body’s
**complete containing rectangle must remain identical**. Several bodies may
share one pinned rectangle; there need not be any fluid-only rectangles.
Separated fluid-only rectangles may be added, removed or moved. 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.
Supply all original root edge-frame definitions in their construction order.
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,
compares exact ordered circle centers/radii, 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.
rectangle fail without device reads or allocations; reordered bodies fail
too. 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
@@ -139,12 +141,20 @@ boundary and velocities must match exactly.
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.
old fluid children. The helper checks the old/new body pins and boundary too.
Its immutable `pinned_rectangles` is a canonical, deduplicated tuple, empty for body-free layouts; rectangle input
order does not affect it. Scalar-parent window offsets remain part of the
coordinate conversion.
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
Multiple-body CPU coverage: `tests/unit/test_multiple_pinned_remap.py`.
`tests/validation/multigrid/test_multiple_pinned_remap_gpu.py` adds two pins,
two independently updated Jets, variable fluid-region counts and add/move/remove
immediate comparisons with CPU transfer. All nine solid-excluded area-weighted
population integrals, exact overlap and nonleaf NaNs are checked.
These new 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.
+5 -5
View File
@@ -499,9 +499,9 @@ class FixedPatchStepper:
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)
stationary circles pinned in unchanged complete L2 rectangles.
Separated fluid-only rectangles may be added, moved or removed.
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.
@@ -538,10 +538,10 @@ class FixedPatchStepper:
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
if (old.pinned_rectangles != new.pinned_rectangles
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")
raise ValueError("remap requires the identical pinned body rectangles 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)
+20 -15
View File
@@ -1,7 +1,7 @@
"""CPU-only whole-volume G transfer, not a runtime adaptive solver.
Periodic three-level grids with fixed L0/L1 topology are supported, including
one stationary circle pinned inside an unchanged L2 rectangle. The caller
stationary circles pinned inside unchanged L2 rectangles. The caller
certifies that current owned leaves are quiescent,
unshifted D2Q9 postcollision G at the supplied completed base interval.
No device buffers, device boundary state, ghosts, collision or time advance
@@ -25,12 +25,14 @@ class RemapTopology:
Child rectangles are in the parent's allocated L1 coordinates, including
its window offset/halo when the parent is a scalar patch. Dense masks are
rebuilt from geometry, never inferred from finite values in input arrays.
``pinned_rectangles`` canonically lists each body-containing rectangle once;
fluid-only layouts have no pins.
"""
parent: object
child: FixedPatchSet2D
leaf_masks: tuple
boundary: FineBoundary2D | None = None
pinned_rectangle: tuple | None = None
pinned_rectangles: tuple = ()
surface_velocity: np.ndarray | None = None
@classmethod
@@ -43,24 +45,24 @@ class RemapTopology:
parent = type(parent).from_config(cfg, parent.bounds)
child, middle_kind = nest_patch_set(cfg, parent, child_rectangles)
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
pinned, velocity = (), 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])
containing = set()
for obj in child_objects:
cx, cy = (np.asarray(obj.geometry.center) - parent.fine_origin) * 2
radius = obj.geometry._radius * 2
containing.add(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]))
pinned = tuple(sorted(containing))
velocity = boundary_velocity(cfg, child, boundary, child_jets)
if velocity is not None:
velocity.setflags(write=False)
@@ -88,8 +90,11 @@ def same_physical_boundary(a, b):
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)):
# Exact ordered geometry also catches changes hidden by cut-table rounding.
def signatures(boundary):
return tuple((o.geometry.center, o.geometry._radius, o.surface_actuation)
for o in boundary.objects)
if signatures(a) != signatures(b):
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),
@@ -162,10 +167,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
if (old.pinned_rectangles != new.pinned_rectangles
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")
raise ValueError("remap requires the identical pinned body rectangles 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)
+153
View File
@@ -0,0 +1,153 @@
"""Multiple body pins and variable fluid sets; mocked runtime is host-only."""
import unittest
from unittest.mock import patch
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.body.jets import Jet, Tube, Parallel
from CelerisLab.body.objects import SurfaceActuation
from CelerisLab.lbm.refinement import FixedPatch2D
from CelerisLab.lbm.synchronized_remap import RemapTopology
from tests.unit.test_pinned_body_remap import body, mesh
from tests.unit.test_refinement_three_level_multipatch import mock_backend
from tests.unit.test_synchronized_remap import fields, integrals, remap
PINS = ((16, 16, 32, 24), (56, 16, 72, 24))
FLUID = ((16, 32, 32, 40), (56, 32, 72, 40))
ADDED = ((16, 44, 32, 48),)
MOVED = ((20, 32, 36, 40), (60, 32, 76, 40))
def objects():
return (body(center=(12., 10.)), body(center=(32., 10.)))
def ready():
bodies = objects()
jets = tuple(Jet(Tube((x-1.5, 10.), 0., 3., .3), Parallel(0.),
'parabolic', .003) for x in (12., 32.))
with patch('tests.unit.test_refinement_three_level_multipatch.bodies_and_jets',
return_value=(bodies, jets)):
run, module, allocations = mock_backend(children=PINS+FLUID, 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(PINS+FLUID, bodies, jets), np.float32))
run._step_count = 7
return run, module, allocations, bodies
class MultiplePinnedRemap(unittest.TestCase):
def check_transfer(self, old, new, arrays, tolerance):
out = remap(old, new, arrays)
before = integrals(old, arrays)
np.testing.assert_allclose(integrals(new, out.g), before, rtol=0,
atol=tolerance*np.abs(before[:9]).sum())
for a, b, om, nm in zip(arrays, out.g, old.leaf_masks, new.leaf_masks):
self.assertEqual(a[:, om & nm].tobytes(), b[:, om & nm].tobytes())
self.assertTrue(np.isnan(b[:, ~nm]).all())
return out.g
def test_add_move_remove_and_body_only_all_populations(self):
for dtype, tolerance in ((np.float64, 2e-13), (np.float32, 2e-7)):
old = mesh(PINS+FLUID, objects())
arrays = fields(old, dtype)
for rects in (PINS+FLUID+ADDED, PINS+MOVED+ADDED, PINS, PINS+FLUID):
new = mesh(rects, objects())
self.assertEqual(new.pinned_rectangles, PINS)
arrays = self.check_transfer(old, new, arrays, tolerance)
old = new
def test_shared_pin_order_invariance_and_body_free(self):
bodies = (body(center=(11., 14.)), body(center=(14., 14.)))
rects = ((16, 16, 32, 40), (56, 16, 72, 40))
old, new = mesh(rects, bodies), mesh(rects[::-1], bodies)
self.assertEqual(old.pinned_rectangles, (rects[0],))
self.assertEqual(new.pinned_rectangles, old.pinned_rectangles)
self.check_transfer(old, new, fields(old), 2e-13)
only = mesh((rects[0],), bodies)
self.check_transfer(old, only, fields(old), 2e-13)
self.assertEqual(mesh(rects, ()).pinned_rectangles, ())
def test_shifted_scalar_parent_coordinates(self):
cfg = LBMConfig(nx=48, ny=32, viscosity=.1)
parent = FixedPatch2D.from_config(cfg, (4, 4, 44, 28))
self.assertEqual(parent.fine_origin, (2., 2.))
bodies = (body(center=(14., 12.)), body(center=(34., 12.)))
def topology(rects):
return RemapTopology.from_config(cfg, parent, rects, child_objects=bodies)
old, new = topology(PINS+FLUID), topology(PINS+MOVED)
self.assertEqual(old.pinned_rectangles, PINS)
self.check_transfer(old, new, fields(old), 2e-13)
def test_direct_helper_rejects_each_changed_pin_and_body_order(self):
old = mesh(PINS+FLUID, objects())
candidates = [mesh(PINS+FLUID, objects()[::-1])]
for index in range(2):
changed = list(PINS+FLUID)
x0, y0, x1, y1 = changed[index]
changed[index] = (x0-4, y0, x1, y1)
candidates.append(mesh(changed, objects()))
for new in candidates:
# Invalid donors ensure boundary validation precedes array reads.
with self.assertRaisesRegex(ValueError, 'pinned'):
remap(old, new, None)
def test_live_independent_jets_and_source_ownership(self):
run, module, allocations, bodies = ready()
run.set_jet_amplitude(0, .017, level=2)
run.set_jet_amplitude(1, -.011, level=2)
before = run.download()
clone = run.remap_child((PINS+MOVED+ADDED)[::-1], child_objects=bodies)
try:
self.assertEqual(tuple(j.amplitude for j in clone._boundaries[2].jets), (.017, -.011))
np.testing.assert_array_equal(clone._boundaries[2].tables['surface_velocity'],
run._boundaries[2].tables['surface_velocity'])
clone.set_jet_amplitude(1, .005, level=2)
self.assertEqual(run._boundaries[2].jets[1].amplitude, -.011)
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)
finally:
clone.close()
run.close()
def test_sub_ulp_cut_table_geometry_change_rejected(self):
run, module, allocations, bodies = ready()
changed = objects()
changed[1].geometry._radius = np.nextafter(.8, np.inf)
start = len(allocations)
module.cuda.memcpy_dtoh.reset_mock()
with self.assertRaisesRegex(ValueError, 'boundary'):
run.remap_child(PINS+FLUID, child_objects=changed)
self.assertEqual(len(allocations), start)
module.cuda.memcpy_dtoh.assert_not_called()
with self.assertRaisesRegex(ValueError, 'pinned'):
remap(mesh(PINS+FLUID, bodies), mesh(PINS+FLUID, changed), None)
run.close()
def test_each_pin_and_body_identity_rejected_before_device_access(self):
run, module, allocations, bodies = ready()
cases = [(PINS+FLUID, bodies[:1]), (PINS+FLUID, bodies[::-1]),
(PINS+FLUID, (bodies[0], body(center=(32.1, 10.)))),
(PINS+FLUID, (bodies[0], body(SurfaceActuation(0., .7, 0.), (32., 10.)))),
(PINS+FLUID, run.child_boundary.objects)]
for index in range(2):
changed = list(PINS+FLUID)
x0, y0, x1, y1 = changed[index]
changed[index] = (x0-4, y0, x1, y1)
cases.append((changed, bodies))
cases.append((tuple(r for i, r in enumerate(PINS+FLUID) if i != index), bodies))
start = len(allocations)
module.cuda.memcpy_dtoh.reset_mock()
run._stream.synchronize.reset_mock()
for rects, supplied in cases:
with self.subTest(rects=rects), self.assertRaises(ValueError):
run.remap_child(rects, child_objects=supplied)
self.assertEqual(len(allocations), start)
module.cuda.memcpy_dtoh.assert_not_called()
run._stream.synchronize.assert_not_called()
self.assertEqual((run._status, run.step_count), ('ready', 7))
run.close()
+1 -1
View File
@@ -55,7 +55,7 @@ class PinnedBodyRemap(unittest.TestCase):
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])
self.assertEqual(old.pinned_rectangles,(A[0],))
def test_current_jets_and_source_independence(self):
run,module,allocations,storage,objects=ready()
@@ -0,0 +1,175 @@
"""Manual GPU gates for two pinned bodies and several fluid-only L2 regions.
Run explicitly on an approved idle GPU. The CPU helper checks immediate
whole-population transfer at the same physical time, not independent evolution
accuracy. Continuation checks cover finite leaves and no-op bitwise identity;
they do not establish an independent BFL replay or a numerical error bound.
"""
from contextlib import ExitStack
import unittest
import numpy as np
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.jets import Jet, Parallel, Tube
from CelerisLab.body.objects import SimObject
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from CelerisLab.lbm.synchronized_remap import (
RemapTopology, remap_synchronized, same_physical_boundary,
)
from test_refinement_blocked_base import exact
from test_refinement_three_level_multipatch_gpu import CFG, PARENTS
from test_runtime_child_remap_gpu import RuntimeChildRemapGPU, initial
# All bounds use the allocated L1 frame, with support aligned to 4x4 blocks.
PINS = ((16, 16, 32, 24), (56, 16, 72, 24))
FLUID = ((16, 32, 32, 40), (56, 32, 72, 40))
EXTRA = ((16, 44, 32, 48),)
MOVED = ((20, 32, 36, 40), (60, 32, 76, 40))
START = PINS + FLUID
CHANGES = (
('add', START + EXTRA, (.017, -.011)),
('move', PINS + MOVED + EXTRA, (-.009, .005)),
('remove', PINS + MOVED, (.013, -.007)),
)
def make(context):
centers = ((12., 10.), (32., 10.))
objects = tuple(
SimObject(71 + index, geometry=CircleGeometry(*center, .8),
center=center, radius=.8)
for index, center in enumerate(centers)
)
jets = tuple(
Jet(Tube((x - 1.5, y), 0., 3., .3), Parallel(0.), 'parabolic', amplitude)
for (x, y), amplitude in zip(centers, (.003, -.002))
)
run = FixedPatchStepper(
CFG, PARENTS, context=context, base_block=(4, 4), fine_block=(4, 4),
child_block=(4, 4), child_rectangles_in_parent=START,
child_objects=objects, child_jets=jets,
)
return run, objects
def topology(run, rectangles, objects):
return RemapTopology.from_config(
CFG, run.patch, rectangles, child_objects=objects,
child_jets=run._boundaries[2].jets,
)
def population_integrals(mesh, arrays):
"""Nine separate area integrals in the root frame, accumulated in FP64."""
return sum(array[:, leaf].sum(axis=1, dtype=np.float64) / 4**level
for level, (array, leaf) in enumerate(zip(arrays, mesh.leaf_masks)))
class MultiplePinnedRemapGPU(unittest.TestCase):
check_public = RuntimeChildRemapGPU.check_public
check_reset = RuntimeChildRemapGPU.check_reset
def change_amplitudes(self, run, amplitudes):
# Each jet selects a different body. Updating one must preserve the
# other body's current per-link velocities, including its driven links.
boundary = run._boundaries[2]
body_ids = run.child_boundary.curved_host[3]
for index, amplitude in enumerate(amplitudes):
before = boundary.tables['surface_velocity'].copy()
run.set_jet_amplitude(index, amplitude, level=2)
after = boundary.tables['surface_velocity']
exact(self, after[body_ids != index], before[body_ids != index],
'other body velocity unchanged')
self.assertTrue(np.any(after[body_ids == index] != before[body_ids == index]))
self.assertEqual(tuple(jet.amplitude for jet in boundary.jets), amplitudes)
def check_boundary(self, old, new, mesh, amplitudes):
self.assertEqual(mesh.pinned_rectangles, PINS)
self.assertTrue(same_physical_boundary(old.child_boundary, new.child_boundary))
self.assertTrue(same_physical_boundary(new.child_boundary, mesh.boundary))
# Compare physical cut-link rows, never layout-dependent blocked addresses.
for run in (old, new):
boundary = run._boundaries[2]
self.assertEqual(tuple(jet.amplitude for jet in boundary.jets), amplitudes)
exact(self, boundary.tables['surface_velocity'], mesh.surface_velocity,
'latest physical per-link velocities')
def test_noop_next_step_bitwise(self):
with CudaContext(0) as context, ExitStack() as owned:
old, objects = make(context)
owned.enter_context(old)
old.initialize(*initial(old, False))
old.step(2)
amplitudes = (.017, -.011)
self.change_amplitudes(old, amplitudes)
before, count = old.download(), old.step_count
mesh = topology(old, START, objects)
# Input order is irrelevant even with several pins and fluid regions.
new = owned.enter_context(old.remap_child(START[::-1], child_objects=objects))
self.assertIsNot(old, new)
self.assertEqual((old.step_count, new.step_count), (count, count))
self.check_boundary(old, new, mesh, amplitudes)
self.check_reset(new, before)
for got, want in zip(old.download(), before):
exact(self, got, want, 'no-op source unchanged')
old.step()
new.step()
self.assertEqual((old.step_count, new.step_count), (count + 1, count + 1))
for got, want in zip(new.download(), old.download()):
exact(self, got, want, 'no-op next step bitwise')
self.check_public(old)
self.check_public(new)
def test_add_move_remove_immediate_transfer_and_source_independence(self):
with CudaContext(0) as context, ExitStack() as owned:
current, objects = make(context)
owned.enter_context(current)
current.initialize(*initial(current, False))
current.step(2)
for action, rectangles, amplitudes in CHANGES:
with self.subTest(action=action):
previous = current
self.change_amplitudes(previous, amplitudes)
before, count = previous.download(), previous.step_count
old_mesh = topology(previous, previous.child_patch.bounds, objects)
new_mesh = topology(previous, rectangles, objects)
expected = remap_synchronized(
old_mesh, new_mesh, before, base_count=count,
level_steps=(count, 2 * count, 4 * count),
phase='postcollision_g',
).g
current = owned.enter_context(
previous.remap_child(rectangles, child_objects=objects))
self.assertIsNot(previous, current)
self.assertEqual((previous.step_count, current.step_count), (count, count))
self.check_boundary(previous, current, new_mesh, amplitudes)
self.check_reset(current, expected)
actual = current.download()
for got, want, source, old_leaf, new_leaf in zip(
actual, expected, before, old_mesh.leaf_masks, new_mesh.leaf_masks):
exact(self, got, want, 'immediate CPU transfer parity')
exact(self, got[:, old_leaf & new_leaf], source[:, old_leaf & new_leaf],
'same-level physical fluid overlap')
self.assertTrue(np.isfinite(got[:, new_leaf]).all())
self.assertTrue(np.isnan(got[:, ~new_leaf]).all())
# Conservation is asserted only across remap, not collision.
np.testing.assert_allclose(
population_integrals(new_mesh, actual),
population_integrals(old_mesh, before),
rtol=2 * np.finfo(np.float32).eps, atol=0,
err_msg='all nine area-weighted population integrals',
)
self.check_public(current)
current.step()
self.assertEqual((previous.step_count, current.step_count), (count, count + 1))
for got, want in zip(previous.download(), before):
exact(self, got, want, 'source unchanged after candidate advances')
self.check_boundary(previous, current, new_mesh, amplitudes)
self.check_public(current)
if __name__ == '__main__':
unittest.main()