Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 d5b13d7f31 motion: mark MovingCircle.advance as a test helper, not the production path
Docstring only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:24:35 +00:00
ClaudeandClaude Opus 5.5 353a7b03c4 stepper: bind moving-circle kernels only for D2Q9 double buffer
Both kernels are compiled under DIM == 2, but LBMStepper requested them for
every double-buffer module, so stationary D3Q19 double-buffer construction
would fail at get_function. Moving circles already reject D3Q19 and EsoPull
at initialize. Stale latch comment updated. Parametrized CPU test with a
module double that lacks the symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:23:54 +00:00
ClaudeandClaude Opus 5.5 0d6df6e6b8 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 <noreply@anthropic.com>
2026-10-31 01:23:04 +00:00
4 changed files with 56 additions and 7 deletions
+6 -1
View File
@@ -99,7 +99,12 @@ class MovingCircle:
return self.centre(self.applied), self.centre(offset), velocity return self.centre(self.applied), self.centre(offset), velocity
def advance(self, upload: Callable, step: Callable, synchronize: Callable) -> None: def advance(self, upload: Callable, step: Callable, synchronize: Callable) -> None:
"""Upload the record, run one step and commit only after a successful sync. """Single-circle transaction helper (unit tests): upload, step, sync, commit.
Not the production path: ``Simulation._run_moving`` batches all circles,
adds the ``no_donor`` gate and uses ``commit``/``fail`` directly.
Upload the record, run one step and commit only after a successful sync.
Any exception after the upload is attempted leaves device geometry Any exception after the upload is attempted leaves device geometry
unknown: the circle is marked failed, never reports applied state again, unknown: the circle is marked failed, never reports applied state again,
+5 -3
View File
@@ -21,7 +21,7 @@ class LBMStepper:
self._esopull = (cfg.streaming == "esopull") self._esopull = (cfg.streaming == "esopull")
# Set by the owner when a step would be wrong (translating circles # Set by the owner when a step would be wrong (translating circles
# excluded from the packed links before the moving kernels exist). # without attached moving tables, a pending target, or a failed step).
self.blocked: str | None = None self.blocked: str | None = None
self.moving = None # MovingTables attached by the owner (translating circles) self.moving = None # MovingTables attached by the owner (translating circles)
@@ -39,8 +39,10 @@ class LBMStepper:
self.apply_esopull_curved_fn = module.get_function("ApplyEsoPullCurvedKernel") self.apply_esopull_curved_fn = module.get_function("ApplyEsoPullCurvedKernel")
self.sensor_fn = module.get_function("SensorKernel") self.sensor_fn = module.get_function("SensorKernel")
self.force_region_fn = module.get_function("ForceRegionKernel") self.force_region_fn = module.get_function("ForceRegionKernel")
self.moving_fn = None if self._esopull else module.get_function("MovingCircleLinks") # Moving-circle kernels exist only in D2Q9 double-buffer modules.
self.moving_cells_fn = None if self._esopull else module.get_function("MovingCircleCells") moving = not self._esopull and cfg.is_d2q9
self.moving_fn = module.get_function("MovingCircleLinks") if moving else None
self.moving_cells_fn = module.get_function("MovingCircleCells") if moving else None
tpb = cfg.threads_per_block tpb = cfg.threads_per_block
self.block = (tpb, 1, 1) self.block = (tpb, 1, 1)
+7 -3
View File
@@ -242,7 +242,8 @@ class Simulation:
self._require_usable() self._require_usable()
tables = self.stepper.moving tables = self.stepper.moving
if tables is None: 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() self.stream.synchronize()
return tables.download_diagnostics() return tables.download_diagnostics()
@@ -785,6 +786,7 @@ class Simulation:
(docs/moving_bodies.md 4a, 5). Any failure leaves the Simulation unusable.""" (docs/moving_bodies.md 4a, 5). Any failure leaves the Simulation unusable."""
tables = self.stepper.moving tables = self.stepper.moving
circles = [(obj.motion, obj.obj_id) for obj in self._moving_bodies()] circles = [(obj.motion, obj.obj_id) for obj in self._moving_bodies()]
pending = any(motion.pending is not None for motion, _ in circles)
try: try:
advancing = tables.upload_records(circles, stream) advancing = tables.upload_records(circles, stream)
self.stepper._step(steps, action_gpu=self.bodies.action_gpu, 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")] no_donor = tables.download_diagnostics()[:, tables.DIAG.index("no_donor")]
if np.any(no_donor > 0): if np.any(no_donor > 0):
raise RuntimeError("translating circle refill found no donor cell") 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: for motion, _ in circles:
motion.commit() motion.commit()
tables.upload_records(circles, stream) # first hold: old = new = applied, u = 0 if advancing:
stream.synchronize() tables.upload_records(circles, stream) # first hold: old = new = applied, u = 0
stream.synchronize()
self._attach_moving() self._attach_moving()
except BaseException as exc: except BaseException as exc:
for motion, _ in circles: # device geometry/state unknown after any failure for motion, _ in circles: # device geometry/state unknown after any failure
+38
View File
@@ -297,6 +297,44 @@ def test_run_one_advances_and_commits_after_sync(monkeypatch):
assert ("step", 3, False) in writes and motion.applied == (0., .25) 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
@pytest.mark.parametrize("streaming,d2q9,bound", [("double_buffer", True, True),
("double_buffer", False, False),
("esopull", True, False)])
def test_moving_kernels_bound_only_for_d2q9_double_buffer(monkeypatch, streaming, d2q9, bound):
from CelerisLab.lbm import stepper as stepper_module
monkeypatch.setattr(stepper_module.cuda, "mem_alloc", lambda n: object())
moving = {"MovingCircleLinks", "MovingCircleCells"}
def get_function(name):
if name in moving and not (d2q9 and streaming == "double_buffer"):
raise KeyError(name) # absent from D3Q19 / EsoPull modules
return lambda *a, **k: None
cfg = SimpleNamespace(streaming=streaming, threads_per_block=128, is_d2q9=d2q9,
nx=16, ny=8, nz=4, nq=9 if d2q9 else 19)
stepper = stepper_module.LBMStepper(SimpleNamespace(), SimpleNamespace(get_function=get_function), cfg)
assert (stepper.moving_fn is not None) == bound and (stepper.moving_cells_fn is not None) == bound
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", @pytest.mark.parametrize("failure", ("upload", "first_sync", "no_donor", "hold_upload",
"hold_sync")) "hold_sync"))
def test_failed_advance_fails_simulation(monkeypatch, failure): def test_failed_advance_fails_simulation(monkeypatch, failure):