From e682d16c3a021704bc741bbe2803d89eac425f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 06:53:12 +0000 Subject: [PATCH] simulation: derive the stepper latch from moving bodies on add/remove One helper sets stepper.blocked from _moving_bodies() after add_body, pre-init remove_body, initialize and stepper recreation, so the latch never disagrees with motion ownership. Tests: latch follows add/remove; the after-close assertion checks the actual refusal. Co-Authored-By: Claude Opus 5.5 --- src/CelerisLab/simulation.py | 14 ++++++++++---- tests/unit/test_moving_bodies.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/CelerisLab/simulation.py b/src/CelerisLab/simulation.py index fc8f7d1..b1e0327 100644 --- a/src/CelerisLab/simulation.py +++ b/src/CelerisLab/simulation.py @@ -148,7 +148,6 @@ class Simulation: from .body.motion import MovingCircle obj.motion = MovingCircle((cx, cy), radius, translation, on_failure=self._fail_moving) - self.stepper.blocked = MOVING_BLOCKED elif type_lower == "sensor": from .body.geometry.circle import CircleGeometry cx, cy = float(center[0]), float(center[1]) @@ -172,7 +171,9 @@ class Simulation: if self._initialized: self.bodies.stage_add(obj) return -1 - return self.bodies.add(obj) + body_id = self.bodies.add(obj) + self._refresh_stepper_block() + return body_id def add_jet(self, jet) -> int: """Register a body-independent Jet before ``initialize()``; returns its index. @@ -212,6 +213,10 @@ class Simulation: # Device geometry is unknown after a failed moving step: same lifecycle as Jets. self._failed = f"moving circle step failed: {exc!r}" + def _refresh_stepper_block(self) -> None: + # Derived latch: direct stepper calls must not step after static-link exclusion. + self.stepper.blocked = MOVING_BLOCKED if self._moving_bodies() else None + def _moving_bodies(self): return [obj for obj in self.bodies.objects if obj.motion is not None] @@ -269,6 +274,7 @@ class Simulation: self.bodies.stage_remove(id) else: self.bodies.remove(id) + self._refresh_stepper_block() # -- Legacy convenience (keep for backward compat) ----------------------- def add_cylinder(self, center: Tuple[float, ...], @@ -516,7 +522,7 @@ class Simulation: self.field, self._module, self.lbm_cfg, ) self.stepper._step_count = _prev_step_count - self.stepper.blocked = MOVING_BLOCKED if self._moving_bodies() else None + self._refresh_stepper_block() self._assert_object_count_contract(expected_count=expected_n_objects) @@ -543,7 +549,7 @@ class Simulation: # change. Compact-list construction publishes no outputs on failure. next_flags = self.bodies.build_flags(self.field.build_channel_flags()) self._validate_moving_bodies() - self.stepper.blocked = MOVING_BLOCKED if self._moving_bodies() else None + self._refresh_stepper_block() if self.bodies.jets and self.bodies.count == 0: raise ValueError("Jets need bodies with cut links") self.bodies.build_compact_lists_for(self.bodies.objects, next_flags) diff --git a/tests/unit/test_moving_bodies.py b/tests/unit/test_moving_bodies.py index 3f26682..c5b5631 100644 --- a/tests/unit/test_moving_bodies.py +++ b/tests/unit/test_moving_bodies.py @@ -232,6 +232,16 @@ def test_add_body_and_set_body_route_targets(monkeypatch): **kwargs) +def test_stepper_latch_follows_moving_bodies(monkeypatch): + sim = routed_simulation(monkeypatch) + still = sim.add_body("circle", (100., 40.), 5.) + assert sim.stepper.blocked is None + moving = sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1)) + assert sim.stepper.blocked is not None + sim.remove_body(moving) # removing the last translating circle clears the latch + assert sim.stepper.blocked is None and sim.bodies.get(still).motion is None + + def validate(monkeypatch, objects, flags=None, **cfg): sim = routed_simulation(monkeypatch) for obj in objects: @@ -306,5 +316,5 @@ def test_failed_moving_step_marks_simulation_failed(monkeypatch): with pytest.raises(RuntimeError, match="unknown"): # no retry on the failed circle motion.advance(lambda *_: None, lambda: None, lambda: None) sim.close() # close still works - with pytest.raises(Exception): # no re-entry after close + with pytest.raises(RuntimeError, match="Simulation failed"): # no re-entry after close sim.run(1)