diff --git a/docs/moving_bodies.md b/docs/moving_bodies.md index efecc50..aa64a63 100644 --- a/docs/moving_bodies.md +++ b/docs/moving_bodies.md @@ -1,6 +1,11 @@ # Moving circles on the uniform dense grid — S0 DRAFT contract -Status: **DRAFT for review, not frozen.** No implementation exists. Base: main +Status: **DRAFT, not frozen** for the force RHS, kernels and physical +acceptance. Host-only S1 (state, validation, guards; no kernels) is on branch +`claude/moving-bodies-s1`; positive-step runs with a moving body are rejected +until S2. When S2 lifts that guard, `run(n > 1)` with a pending target must +raise `ValueError` before mutation (4a), and `run(0)` must not upload a motion +record or consume the pending target. Base: main `b2034f87e03656c5f67ab88562a70d6c338b9918`. Scope approved by Frank (via TRON, thread C0C78UFKGRM/1793335557.460509): uniform-grid prescribed translation and single-cylinder free transverse VIV. Out of scope: refinement/blocked path, diff --git a/src/CelerisLab/body/motion.py b/src/CelerisLab/body/motion.py index 32ae02d..9746207 100644 --- a/src/CelerisLab/body/motion.py +++ b/src/CelerisLab/body/motion.py @@ -52,7 +52,8 @@ class Translation: class MovingCircle: """Applied offset, pending target and the fail-closed commit of one circle.""" - def __init__(self, base, radius: float, translation: Translation): + def __init__(self, base, radius: float, translation: Translation, + on_failure: Optional[Callable[[BaseException], None]] = None): self.base = _vec(base, "base") self.radius = float(radius) if not (math.isfinite(self.radius) and self.radius >= R_MIN): @@ -63,6 +64,7 @@ class MovingCircle: self.applied: Vec = (0.0, 0.0) self.pending: Optional[Tuple[Vec, Vec]] = None self.failed = False + self._on_failure = on_failure # owner lifecycle hook (Simulation marks itself failed) def centre(self, offset: Vec) -> Vec: return self.base[0] + offset[0], self.base[1] + offset[1] @@ -100,15 +102,18 @@ class MovingCircle: """Upload the record, run one step and commit only after a successful sync. Any exception after the upload is attempted leaves device geometry - unknown: the circle is marked failed and never reports applied state again. + unknown: the circle is marked failed, never reports applied state again, + and the owner's ``on_failure`` hook is called. """ record = self.record() try: upload(record) step() synchronize() - except BaseException: + except BaseException as exc: self.failed = True + if self._on_failure is not None: + self._on_failure(exc) raise if self.pending is not None: self.applied = self.pending[0] diff --git a/src/CelerisLab/simulation.py b/src/CelerisLab/simulation.py index 7c74be2..bd4ed22 100644 --- a/src/CelerisLab/simulation.py +++ b/src/CelerisLab/simulation.py @@ -142,7 +142,8 @@ class Simulation: is_sensor=False, surface_actuation=surface_actuation) if translation is not None: from .body.motion import MovingCircle - obj.motion = MovingCircle((cx, cy), radius, translation) + obj.motion = MovingCircle((cx, cy), radius, translation, + on_failure=self._fail_moving) elif type_lower == "sensor": from .body.geometry.circle import CircleGeometry cx, cy = float(center[0]), float(center[1]) @@ -202,6 +203,10 @@ class Simulation: raise self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude + def _fail_moving(self, exc: BaseException) -> None: + # Device geometry is unknown after a failed moving step: same lifecycle as Jets. + self._failed = f"moving circle step failed: {exc!r}" + def _moving_bodies(self): return [obj for obj in self.bodies.objects if obj.motion is not None] diff --git a/tests/unit/test_moving_bodies.py b/tests/unit/test_moving_bodies.py index e5261ca..7059b42 100644 --- a/tests/unit/test_moving_bodies.py +++ b/tests/unit/test_moving_bodies.py @@ -129,3 +129,128 @@ def test_moving_body_rejects_topology_and_checkpoint(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.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 + sim.set_body(bid, offset=(0., .3), vy=.05) + sim.set_body(bid, offset=(0., .4), vy=.05) # last wins + for kwargs in (dict(offset=(0., .6)), dict(vy=.05), dict(vx=.05), + dict(offset=(0., .1), vy=.2), dict(offset=(0., 0.), vy=.01)): + with pytest.raises(ValueError): + sim.set_body(bid, **kwargs) + assert motion.pending == ((0., .4), (0., .05)) # invalid input keeps the target + sim.set_body(bid, omega=.01) # omega-only keeps the pending target + obj = sim.bodies.get(bid) + assert obj.state.omega == .01 and (obj.state.vx, obj.state.vy) == (0., 0.) + assert motion.pending == ((0., .4), (0., .05)) and motion.applied == (0., 0.) + 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 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(): + still = [body(100., 40., 5.)] + mgr = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg()) + reference = mgr.build_compact_lists_for(still) + with_moving = mgr.build_compact_lists_for( + still + [body(50., 40., 10., Translation((0., 0., -1., 1.), .1))]) + for a, b in zip(reference[:8], with_moving[:8]): + assert np.asarray(a).tobytes() == np.asarray(b).tobytes() + assert len(reference[0]) > 0 + + +def test_failed_moving_step_marks_simulation_failed(monkeypatch): + sim = routed_simulation(monkeypatch) + 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): + with pytest.raises(RuntimeError, match="Simulation failed"): + call() + sim.close() # close still works; no re-entry afterwards