Files
CelerisLab/tests/unit/test_moving_bodies.py
T
ClaudeandClaude Opus 5.5 0f8a428f07 lbm: fixed-centre moving-circle cut links (S2a)
MovingCircleLinks computes the cut links of each translating circle from an
FP64 {cx, cy, r} table over its swept band (same q/hit/clamp/fallback order
as build_cut_links, explicit _rn operations) and applies the unchanged
apply_bouzidi_link with spin wall velocity. obs keeps its contract; a
separate per-body diagnostics buffer records CME, the Wen term u_w*dm,
link_dm and the link count. Moving tables are built at initialize and
attached to the stepper, which then launches the kernel before the static
curved kernel; until then direct and run steps stay blocked. Displacement
is disabled: set_body offset/vx/vy on a translating circle raises before
mutation. Contract section 10a documents S2a and the S3 pending rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 07:35:02 +00:00

394 lines
19 KiB
Python

"""Translating-circle host contract, S1 (docs/moving_bodies.md 4a, 5, 7).
Host doubles only: no kernels exist yet, so every positive-step run with a
moving body must be rejected before any device write.
"""
from types import SimpleNamespace
import pytest
from CelerisLab.body.motion import (MAX_STEP, R_MIN, MovingCircle, Translation,
enumerate_donor_coverage)
BAD = (float("nan"), float("inf"))
def circle(box=(-2., 2., -3., 3.), speed=.1):
return MovingCircle((50., 40.), 10., Translation(box, speed))
def test_translation_and_radius_validation():
for box in ((0., 1., -1., 1.)[:3], (.5, 1., -1., 1.), (-1., 1., BAD[0], 1.)):
with pytest.raises(ValueError):
Translation(box, .1)
for speed in (0., MAX_STEP + 1e-9) + BAD:
with pytest.raises(ValueError):
Translation((0., 0., -1., 1.), speed)
with pytest.raises(ValueError):
MovingCircle((0., 0.), R_MIN - .1, Translation((0., 0., -1., 1.), .1))
def test_targets_validate_against_applied_and_last_wins():
c = circle()
c.set_target((0., .3), (0., .05))
c.set_target((0., .5), (0., .05)) # second setter replaces, checked vs applied (0, 0)
assert c.pending == ((0., .5), (0., .05))
for offset, velocity in (((0., .51), (0., 0.)), # Euclidean bound
((.36, .36), (0., 0.)), # |dc|_2 = .509
((0., 3.1), (0., 0.)), # outside box
((0., .1), (0., .11)), # speed bound
((0., 0.), (0., .01)), # hold with sliding velocity
((BAD[0], 0.), (0., 0.)), ((0., 0.), (0., BAD[1]))):
with pytest.raises(ValueError):
c.set_target(offset, velocity)
assert c.pending == ((0., .5), (0., .05)) # invalid replacement keeps the old target
c.set_target((.35, .35), (0., 0.)) # |dc|_2 = .495 inside the bound
assert c.applied == (0., 0.)
def test_record_commit_and_hold():
c = circle()
assert c.record() == ((50., 40.), (50., 40.), (0., 0.)) # hold: no translation velocity
c.set_target((0., .25), (0., .05))
assert c.record() == ((50., 40.), (50., 40.25), (0., .05))
uploads = []
c.advance(uploads.append, lambda: None, lambda: None)
assert uploads == [((50., 40.), (50., 40.25), (0., .05))]
assert c.applied == (0., .25) and c.pending is None # offset-only commit
c.advance(uploads.append, lambda: None, lambda: None) # no new target: hold step
assert uploads[-1] == ((50., 40.25), (50., 40.25), (0., 0.)) and c.applied == (0., .25)
c.set_target((0., 0.), (0., -.05)) # return to zero offset is a real target
assert c.pending == ((0., 0.), (0., -.05))
c.advance(uploads.append, lambda: None, lambda: None)
assert c.applied == (0., 0.) and c.pending is None
@pytest.mark.parametrize("stage", ("upload", "step", "synchronize"))
def test_failure_after_upload_attempt_is_fail_closed(stage):
c = circle()
c.set_target((0., .25), (0., .05))
def call(name):
def run(*_):
if name == stage:
raise RuntimeError(f"{name} failed")
return run
with pytest.raises(RuntimeError, match=f"{stage} failed"):
c.advance(call("upload"), call("step"), call("synchronize"))
assert c.failed and c.applied == (0., 0.)
for later in (c.record, lambda: c.set_target((0., .1), (0., 0.)),
lambda: c.advance(*(lambda *_: None,) * 3)):
with pytest.raises(RuntimeError, match="unknown"):
later()
def test_band_covers_offset_box_with_margin():
assert circle().band() == (50 - 2 - 10 - 2, 50 + 2 + 10 + 2, 40 - 3 - 10 - 2, 40 + 3 + 10 + 2)
@pytest.mark.parametrize("radius", (R_MIN, 4.3, 7.5, 12.0))
def test_refill_donor_sets_nonempty_within_bounds(radius):
# Evidence for R >= R_MIN and |dc|_2 <= MAX_STEP on a fixed sample, not a proof.
assert enumerate_donor_coverage(radius) == 0
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module # noqa: E402
from tests.unit.test_simulation_lifecycle import Owner, make_simulation # noqa: E402
class ClosableOwner(Owner):
"""Owner double that refuses activation after close, like CudaContext.activate."""
def activate(self):
if self.is_closed:
raise RuntimeError("CUDA context is closed")
return super().activate()
def moving_simulation(monkeypatch, *, moving=True):
sim = make_simulation(monkeypatch)
obj = SimpleNamespace(motion=circle() if moving else None, obj_id=0,
center=(50., 40.), radius=10.)
writes = []
sim._failed, sim._initialized = None, True
sim.bodies = SimpleNamespace(objects=[obj], count=1, jets=[], action_gpu=None, obs_gpu=None,
has_pending_edit=lambda: False,
_upload_action_async=lambda s: writes.append("action"),
zero_obs_async=lambda s: writes.append("obs"),
increment_obs_steps=lambda n: writes.append("count"),
download_obs_full_async=lambda s: writes.append("download"))
sim.stepper = SimpleNamespace(step=lambda n, **k: writes.append(("step", n)), blocked=None,
moving=None)
sim._assert_runtime_contracts = lambda: None
return sim, writes
STREAM = SimpleNamespace(synchronize=lambda: None)
def test_positive_steps_rejected_before_device_writes(monkeypatch):
sim, writes = moving_simulation(monkeypatch)
sim.bodies.objects[0].motion.set_target((0., .1), (0., .01))
sim.bodies.objects[0].motion.applied = (0., 0.)
for call in (lambda: sim.run(1, stream=STREAM), lambda: sim.run(5, stream=STREAM),
lambda: sim.step(), lambda: sim.run(3, stream=STREAM, checkpoint_interval=1)):
with pytest.raises(RuntimeError, match="moving kernels"):
call()
with pytest.raises(RuntimeError, match="moving kernels"): # nominal at rest as well
sim.bodies.objects[0].motion.pending = None
sim.run(1)
sim.bodies.objects[0].state = SimpleNamespace(omega=.02) # spin does not lift the guard
with pytest.raises(RuntimeError, match="moving kernels"):
sim.run(2, stream=STREAM)
assert writes == [] and sim.bodies.objects[0].motion.applied == (0., 0.)
def test_run_zero_keeps_pending_and_static_runs_proceed(monkeypatch):
sim, writes = moving_simulation(monkeypatch)
motion = sim.bodies.objects[0].motion
motion.set_target((0., .1), (0., .01))
sim.run(0, stream=STREAM) # existing action/obs work only; no motion upload or commit
assert motion.pending == ((0., .1), (0., .01)) and motion.applied == (0., 0.)
assert ("step", 0) in writes
static, writes = moving_simulation(monkeypatch, moving=False)
static.save_checkpoint = lambda *a: writes.append("checkpoint")
static.run(3, stream=STREAM)
static.run(4, stream=STREAM, checkpoint_interval=2)
assert writes.count(("step", 3)) == 1 and writes.count(("step", 2)) == 2
def test_direct_stepper_entry_is_guarded():
from CelerisLab.lbm.stepper import LBMStepper
launches = []
module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append(name))
cfg = SimpleNamespace(streaming="double_buffer", threads_per_block=128, is_d2q9=True,
nx=64, ny=32, nz=1)
field = SimpleNamespace(ddf_gpu="ddf", temp_gpu="temp", n_curved=0)
stepper = LBMStepper(field, module, cfg)
assert stepper.blocked is None
stepper.blocked = "translating circles need the moving kernels"
for n in (1, 3):
with pytest.raises(RuntimeError, match="moving kernels"):
stepper.step(n, action_gpu=None, obs_gpu=None)
assert launches == [] and (field.ddf_gpu, field.temp_gpu) == ("ddf", "temp")
assert stepper.step_count == 0
stepper.step(0, action_gpu=None, obs_gpu=None) # zero steps stay a no-op
assert launches == [] and stepper.step_count == 0
def test_moving_body_rejects_topology_and_checkpoint(monkeypatch):
sim, _ = moving_simulation(monkeypatch)
for call in (sim.sync_bodies, sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
with pytest.raises(ValueError, match="translating circles"):
call()
# -- Real-object routing, initialize rejections, packing, lifecycle -------------
import numpy as np # noqa: E402
from CelerisLab.body.geometry.circle import CircleGeometry # noqa: E402
from CelerisLab.body.manager import ObjectManager # noqa: E402
from CelerisLab.body.objects import SimObject # noqa: E402
from CelerisLab.lbm.descriptors import FLUID, SOLID # noqa: E402
class Cfg:
dim, is_d3q19, is_d2q9 = 2, False, True
streaming, ddf_shifting, store_precision, y_wall_bc = "double_buffer", False, "FP32", "bounce_back"
def body(x, y, r, translation=None, **kw):
obj = SimObject(obj_id=-1, geometry=CircleGeometry(x, y, r), center=(x, y), radius=r, **kw)
if translation is not None:
obj.motion = MovingCircle((x, y), r, translation)
return obj
def routed_simulation(monkeypatch):
sim = make_simulation(monkeypatch)
sim._failed, sim._initialized, sim.lbm_cfg = None, False, Cfg()
sim.stepper = SimpleNamespace(blocked=None)
sim.bodies = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
return sim
def test_add_body_and_set_body_route_targets(monkeypatch):
sim = routed_simulation(monkeypatch)
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((-2., 2., -3., 3.), .1))
still = sim.add_body("circle", (100., 40.), 5.)
motion = sim.bodies.get(bid).motion
assert sim.stepper.blocked is not None # declaring a moving circle blocks direct steps
for initialized in (False, True): # S2a: displacement disabled before and after initialize
sim._initialized = initialized
for kwargs in (dict(offset=(0., .3), vy=.05), dict(offset=(0., 0.)), dict(vy=.05),
dict(vx=.05), dict(offset=(0., .1), omega=.01)):
with pytest.raises(ValueError, match="disabled until S3"):
sim.set_body(bid, **kwargs)
assert motion.pending is None and motion.applied == (0., 0.)
sim._initialized = False
obj = sim.bodies.get(bid)
assert obj.state.omega == 0.
sim.set_body(bid, omega=.01) # spin on hold stays allowed
assert obj.state.omega == .01 and (obj.state.vx, obj.state.vy) == (0., 0.)
assert motion.pending is None
with pytest.raises(ValueError, match="not declared"):
sim.set_body(still, offset=(0., .1))
sim.set_body(still, vx=.01) # stationary API unchanged
assert sim.bodies.get(still).state.vx == .01 and sim.bodies.get(still).motion is None
for kwargs in (dict(type="sensor"), dict(surface_actuation=dict(angle=0., half_width=.2,
normal_velocity=.01))):
kind = kwargs.pop("type", "circle")
with pytest.raises(ValueError, match="translation"):
sim.add_body(kind, (20., 20.), 5., translation=Translation((0., 0., 0., 0.), .1),
**kwargs)
def test_moving_tables_layout_and_attachment(monkeypatch):
from CelerisLab.body.motion import MovingTables
a = MovingCircle((50., 40.), 10., Translation((-1., 1., -3., 3.), .1))
b = MovingCircle((90.25, 30.5), 5., Translation((0., 0., 0., 0.), .1))
band, geom = MovingTables.layout([(a, 2), (b, 5)])
wa, ha = 63 - 37 + 1, 55 - 25 + 1
assert band == [37, 25, wa, ha, 0, 2, 83, 23, 16, 16, wa * ha * 8, 5]
assert geom == [50., 40., 10., 90.25, 30.5, 5.]
sim = routed_simulation(monkeypatch)
sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., 0., 0.), .1))
sim._moving_tables = "tables"
sim._attach_moving()
assert sim.stepper.moving == "tables" and sim.stepper.blocked is None
sim._moving_tables = None
sim._attach_moving()
assert sim.stepper.moving is None and sim.stepper.blocked is not None
def test_stepper_launches_moving_links_before_static_curved():
from CelerisLab.lbm.stepper import LBMStepper
launches = []
module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append((name, a, k)))
cfg = SimpleNamespace(streaming="double_buffer", threads_per_block=128, is_d2q9=True,
nx=64, ny=32, nz=1)
field = SimpleNamespace(ddf_gpu="ddf", temp_gpu="temp", flag_gpu="flag", n_curved=0,
invalidate_host_ddf_cache=lambda: None)
stepper = LBMStepper(field, module, cfg)
stepper._launch_force_region = stepper._launch_sensor = lambda *a, **k: None
stepper.moving = SimpleNamespace(band_gpu="band", geom_gpu="geom", diag_gpu="diag",
slot_debug_gpu=None, n_bodies=1, n_slots=300)
stepper.step(2, action_gpu="act", obs_gpu="obs")
names = [name for name, _, _ in launches]
assert names == ["MovingCircleLinks", "OneStep"] * 2 and stepper.step_count == 2
args, kw = launches[0][1], launches[0][2]
assert args[:4] == ("ddf", "flag", "band", "geom") and int(args[5]) == 300
assert kw["grid"] == (3, 1, 1) and kw["block"] == (128, 1, 1)
def test_recompile_installs_a_guarded_stepper(monkeypatch):
from CelerisLab.lbm.stepper import LBMStepper
sim = routed_simulation(monkeypatch)
sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1))
launches = []
kernel_module = SimpleNamespace(get_function=lambda name: lambda *a, **k: launches.append(name))
monkeypatch.setattr(module.compiler, "build_module", lambda *a, **k: kernel_module)
sim.lbm_cfg = SimpleNamespace(validate_runtime=lambda: None, viscosity=.01, omega=1.,
streaming="double_buffer", threads_per_block=128,
is_d2q9=True, nx=120, ny=80, nz=1)
sim._compiled_viscosity = .01
sim._assert_allocation_contract = lambda: None
sim._resolve_compile_arch = lambda: "sm_70"
sim._assert_object_count_contract = lambda **k: None
sim.field = SimpleNamespace(module=None, invalidate_module_constant_cache=lambda: None,
_upload_params=lambda: None, _runtime_params={},
ddf_gpu="ddf", temp_gpu="temp", n_curved=0)
sim.stepper = SimpleNamespace(_step_count=7, blocked=None)
sim._recompile()
assert isinstance(sim.stepper, LBMStepper) and sim.stepper.step_count == 7
with pytest.raises(RuntimeError, match="moving kernels"):
sim.stepper.step(1, action_gpu=None, obs_gpu=None)
assert launches == [] and (sim.field.ddf_gpu, sim.field.temp_gpu) == ("ddf", "temp")
assert sim.stepper.step_count == 7
def validate(monkeypatch, objects, flags=None, **cfg):
sim = routed_simulation(monkeypatch)
for obj in objects:
sim.bodies.add(obj)
for name, value in cfg.items():
setattr(sim.lbm_cfg, name, value)
base = np.full(120 * 80, FLUID, np.uint16) if flags is None else flags
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: base.copy())
sim._validate_moving_bodies()
def test_initialize_rejects_band_and_modes(monkeypatch):
t = Translation((-1., 1., -3., 3.), .1)
validate(monkeypatch, [body(50., 40., 10., t), body(100., 40., 5.)]) # accepted
walls = np.full(120 * 80, FLUID, np.uint16)
walls[60 + 30 * 120] = SOLID
cases = [([body(10., 40., 10., t)], None, {}, "domain edge"),
([body(50., 40., 10., t)], walls, {}, "non-fluid"),
([body(50., 40., 10., t), body(68., 40., 4.)], None, {}, "overlaps"),
([body(50., 40., 10., t), body(50., 60., 3., is_sensor=True)], None, {}, "overlaps"),
([body(50., 40., 10., t), body(64., 40., 3., is_force_region=True)], None, {}, "overlaps"),
([body(50., 40., 10., t)], None, {"streaming": "esopull"}, "double_buffer"),
([body(50., 40., 10., t)], None, {"ddf_shifting": True}, "unshifted"),
([body(50., 40., 10., t)], None, {"is_d2q9": False}, "D2Q9")]
for objects, flags, cfg, match in cases:
with pytest.raises(ValueError, match=match):
validate(monkeypatch, objects, flags, **cfg)
def test_initialize_validates_before_mutation(monkeypatch):
sim = routed_simulation(monkeypatch)
sim.bodies.add(body(10., 40., 10., Translation((0., 0., 0., 0.), .1)))
uploads = []
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: np.full(9600, FLUID, np.uint16),
upload_flags=lambda: uploads.append("flags"))
sim._assert_allocation_contract = lambda: None
with pytest.raises(ValueError, match="domain edge"):
sim.initialize()
assert uploads == [] and not sim._initialized
def test_moving_circle_excluded_from_static_links():
def manager(*objects):
mgr = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
ids = [mgr.add(obj) for obj in objects]
return mgr, ids
reference_mgr, _ = manager(body(100., 40., 5.), body(20., 40., 6.))
mixed_mgr, ids = manager(body(100., 40., 5.), body(20., 40., 6.),
body(55., 40., 10., Translation((0., 0., -1., 1.), .1)))
reference = reference_mgr.build_compact_lists_for(reference_mgr.objects)
mixed = mixed_mgr.build_compact_lists_for(mixed_mgr.objects)
for a, b in zip(reference[:8], mixed[:8]): # static link columns byte-identical
assert np.asarray(a).tobytes() == np.asarray(b).tobytes()
assert len(reference[0]) > 0 and ids[2] not in set(np.asarray(mixed[3]).tolist())
def test_failed_moving_step_marks_simulation_failed(monkeypatch):
sim = routed_simulation(monkeypatch)
sim.ctx = ClosableOwner()
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1))
motion = sim.bodies.get(bid).motion
motion.set_target((0., .2), (0., .05))
def fail(*_):
raise RuntimeError("sync failed")
with pytest.raises(RuntimeError, match="sync failed"):
motion.advance(lambda *_: None, lambda: None, fail)
assert sim._failed is not None and motion.applied == (0., 0.)
for call in (lambda: sim.run(0), lambda: sim.run(1), sim.step, sim.sync_bodies,
sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
with pytest.raises(RuntimeError, match="Simulation failed"):
call()
with pytest.raises(RuntimeError, match="unknown"): # no retry on the failed circle
motion.advance(lambda *_: None, lambda: None, lambda: None)
with sim.ctx.activate(): # a refused close keeps the failed state and resources
with pytest.raises(RuntimeError, match="active CUDA"):
sim.close()
assert sim._failed is not None and sim.bodies is not None and not sim._closed
sim.close() # retry outside the activation succeeds
assert sim._closed and sim._failed is not None
with pytest.raises(RuntimeError, match="CUDA context is closed"): # no re-entry after close
sim.run(1)
with pytest.raises(RuntimeError, match="unknown"): # the failed circle stays refused
motion.record()