From 0d6df6e6b8f28d24eecd25698c6a048cc849875c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 01:23:04 +0000 Subject: [PATCH] simulation: consume a zero-motion translation target; empty diagnostics shape A pending target equal to the applied offset uploaded the same record, so _run_moving saw no advance and never committed it: pending stayed latched, blocking run(n > 1) and direct steps. Commit whenever a target was pending (no_donor check and hold-record re-upload still only on advancing steps). moving_diagnostics() without moving circles now returns (0, DIAG_FLOATS) instead of the stale (0, 6). Tests for both. Co-Authored-By: Claude Opus 5.5 --- src/CelerisLab/simulation.py | 10 +++++++--- tests/unit/test_moving_bodies.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/CelerisLab/simulation.py b/src/CelerisLab/simulation.py index c37ef3d..a7e1195 100644 --- a/src/CelerisLab/simulation.py +++ b/src/CelerisLab/simulation.py @@ -242,7 +242,8 @@ class Simulation: self._require_usable() tables = self.stepper.moving if tables is None: - return np.zeros((0, 6), np.float32) + from .body.motion import MovingTables + return np.zeros((0, MovingTables.DIAG_FLOATS), np.float32) self.stream.synchronize() return tables.download_diagnostics() @@ -785,6 +786,7 @@ class Simulation: (docs/moving_bodies.md 4a, 5). Any failure leaves the Simulation unusable.""" tables = self.stepper.moving circles = [(obj.motion, obj.obj_id) for obj in self._moving_bodies()] + pending = any(motion.pending is not None for motion, _ in circles) try: advancing = tables.upload_records(circles, stream) self.stepper._step(steps, action_gpu=self.bodies.action_gpu, @@ -797,10 +799,12 @@ class Simulation: no_donor = tables.download_diagnostics()[:, tables.DIAG.index("no_donor")] if np.any(no_donor > 0): raise RuntimeError("translating circle refill found no donor cell") + if pending: # a target equal to the applied offset is consumed as a hold step for motion, _ in circles: motion.commit() - tables.upload_records(circles, stream) # first hold: old = new = applied, u = 0 - stream.synchronize() + if advancing: + tables.upload_records(circles, stream) # first hold: old = new = applied, u = 0 + stream.synchronize() self._attach_moving() except BaseException as exc: for motion, _ in circles: # device geometry/state unknown after any failure diff --git a/tests/unit/test_moving_bodies.py b/tests/unit/test_moving_bodies.py index 158ded2..89d6ff3 100644 --- a/tests/unit/test_moving_bodies.py +++ b/tests/unit/test_moving_bodies.py @@ -297,6 +297,26 @@ def test_run_one_advances_and_commits_after_sync(monkeypatch): assert ("step", 3, False) in writes and motion.applied == (0., .25) +def test_zero_motion_target_is_consumed_by_run_one(monkeypatch): + sim, writes, motion = advancing_simulation(monkeypatch) + motion.set_target((0., 0.), (0., 0.)) # equal to applied: a hold target + sim._attach_moving() + assert sim.stepper.blocked == module.PENDING_BLOCKED + sim.run(1, stream=STREAM) + assert motion.pending is None and motion.applied == (0., 0.) + assert sim.stepper.blocked is None and ("step", 1, False) in writes + writes.clear() + sim.run(3, stream=STREAM) # no stale latch + assert ("step", 3, False) in writes + + +def test_moving_diagnostics_empty_shape_matches_layout(monkeypatch): + from CelerisLab.body.motion import MovingTables + sim = make_simulation(monkeypatch) + sim._failed, sim.stepper = None, SimpleNamespace(moving=None) + assert sim.moving_diagnostics().shape == (0, MovingTables.DIAG_FLOATS) + + @pytest.mark.parametrize("failure", ("upload", "first_sync", "no_donor", "hold_upload", "hold_sync")) def test_failed_advance_fails_simulation(monkeypatch, failure):