From 4f17c3c586f38976032b2cbb5f26e89e4aa9b03a Mon Sep 17 00:00:00 2001 From: Frank14f <1515444314@qq.com> Date: Sat, 31 Oct 2026 11:24:09 +0800 Subject: [PATCH] motion: exercise real step ownership and move host test helpers out of src --- src/CelerisLab/body/motion.py | 69 +--------------- tests/unit/test_moving_bodies.py | 133 +++++++++++++++++++++++-------- 2 files changed, 103 insertions(+), 99 deletions(-) diff --git a/src/CelerisLab/body/motion.py b/src/CelerisLab/body/motion.py index 9e49ad8..c82e25b 100644 --- a/src/CelerisLab/body/motion.py +++ b/src/CelerisLab/body/motion.py @@ -1,10 +1,10 @@ # CelerisLab/body/motion.py -"""Host state of a translating circle (dense double buffer, D2Q9 only). +"""Translation targets and device tables (dense double buffer, D2Q9 only). Contract: docs/moving_bodies.md, sections 4a, 5 and 7. The applied geometry (last successfully committed offset) and an optional pending target are two -separate facts; centres are always ``base + offset``. Nothing here touches -the device: the caller supplies upload/step/synchronize callables. +separate facts; centres are always ``base + offset``. MovingCircle owns host +targets, MovingTables owns device records, and Simulation commits completed steps. """ from __future__ import annotations @@ -18,10 +18,6 @@ R_MIN = 4.0 # smallest radius covered by the donor enumeration Vec = Tuple[float, float] -# D2Q9 non-rest directions, paired order of lbm/descriptors. -_CX = (1, -1, 0, 0, 1, -1, 1, -1) -_CY = (0, 0, 1, -1, 1, -1, -1, 1) - def _vec(value, name: str) -> Vec: x, y = (float(v) for v in value) @@ -98,28 +94,6 @@ class MovingCircle: offset, velocity = self.applied, (0.0, 0.0) return self.centre(self.applied), self.centre(offset), velocity - def advance(self, upload: Callable, step: Callable, synchronize: Callable) -> None: - """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 - 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 as exc: - self.fail(exc) - raise - self.commit() - def commit(self) -> None: """Applied := pending offset (caller has synchronized a successful step).""" if self.pending is not None: @@ -143,43 +117,6 @@ class MovingCircle: raise RuntimeError("moving circle state is unknown after a failed step") -def _inside(x: int, y: int, c: Vec, r: float) -> bool: - return (x - c[0]) ** 2 + (y - c[1]) ** 2 < r * r - - -def empty_donor_cells(c_old: Vec, c_new: Vec, radius: float): - """Fresh cells (inside c_old, outside c_new) whose donor set V(x) is empty.""" - r = radius - xs = range(math.floor(min(c_old[0], c_new[0]) - r) - 1, math.ceil(max(c_old[0], c_new[0]) + r) + 2) - ys = range(math.floor(min(c_old[1], c_new[1]) - r) - 1, math.ceil(max(c_old[1], c_new[1]) + r) + 2) - empty = [] - for x in xs: - for y in ys: - if not _inside(x, y, c_old, r) or _inside(x, y, c_new, r): - continue - if not any(not _inside(x + cx, y + cy, c_old, r) and not _inside(x + cx, y + cy, c_new, r) - for cx, cy in zip(_CX, _CY)): - empty.append((x, y)) - return empty - - -def enumerate_donor_coverage(radius: float, positions: int = 8, directions: int = 16) -> int: - """Count empty-donor fresh cells over sub-cell centres and moves of MAX_STEP. - - Evidence for the declared bounds (radius >= R_MIN, |dc|_2 <= MAX_STEP), - not a proof; the device keeps the ``no_donor`` counter. - """ - count = 0 - for i in range(positions): - for j in range(positions): - c_old = (i / positions, j / positions) - for k in range(directions): - a = 2.0 * math.pi * k / directions - c_new = (c_old[0] + MAX_STEP * math.cos(a), c_old[1] + MAX_STEP * math.sin(a)) - count += len(empty_donor_cells(c_old, c_new, radius)) - return count - - class MovingTables: """Device tables of the translating circles for ``MovingCircleCells``/``Links``. diff --git a/tests/unit/test_moving_bodies.py b/tests/unit/test_moving_bodies.py index 1c4f418..ff65502 100644 --- a/tests/unit/test_moving_bodies.py +++ b/tests/unit/test_moving_bodies.py @@ -1,14 +1,13 @@ -"""Translating-circle host contract, S1 (docs/moving_bodies.md 4a, 5, 7). +"""Translating-circle host contract (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. +Real Simulation entrypoints with device doubles; no CUDA context or GPU work. """ +import math from types import SimpleNamespace import pytest -from CelerisLab.body.motion import (MAX_STEP, R_MIN, MovingCircle, Translation, - enumerate_donor_coverage) +from CelerisLab.body.motion import MAX_STEP, R_MIN, MovingCircle, Translation BAD = (float("nan"), float("inf")) @@ -46,46 +45,96 @@ def test_targets_validate_against_applied_and_last_wins(): 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)) +def test_record_commit_and_hold(monkeypatch): + sim, writes, c = routed_advancing_simulation(monkeypatch) + assert c.record() == ((50., 40.), (50., 40.), (0., 0.)) + sim.set_body(0, offset=(0., .25), vy=.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))] + seen_at_sync = [] + sim.run(1, stream=SimpleNamespace(synchronize=lambda: seen_at_sync.append(c.applied))) + assert seen_at_sync == [(0., 0.), (0., .25)] # commit between step sync and hold sync + uploads = [w[1][0] for w in writes if isinstance(w, tuple) and w[0] == "upload"] + assert uploads == [((50., 40.), (50., 40.25), (0., .05)), + ((50., 40.25), (50., 40.25), (0., 0.))] 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 + sim.run(1, stream=STREAM) # no new target: hold step + uploads = [w[1][0] for w in writes if isinstance(w, tuple) and w[0] == "upload"] 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 + sim.set_body(0, offset=(0., 0.), vy=-.05) # zero offset is a real target assert c.pending == ((0., 0.), (0., -.05)) - c.advance(uploads.append, lambda: None, lambda: None) + sim.run(1, stream=STREAM) 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 test_failure_after_upload_attempt_is_fail_closed(monkeypatch, stage): + sim, writes, c = routed_advancing_simulation(monkeypatch, fail="upload" if stage == "upload" else None) + sim.set_body(0, offset=(0., .25), vy=.05) - def call(name): - def run(*_): - if name == stage: - raise RuntimeError(f"{name} failed") - return run + def fail(*a, **k): + raise RuntimeError(f"{stage} failed") + if stage == "step": + sim.stepper._step = fail + stream = SimpleNamespace(synchronize=fail) if stage == "synchronize" else STREAM 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)): + sim.run(1, stream=stream) + assert c.failed and c.applied == (0., 0.) and c.pending == ((0., .25), (0., .05)) + assert sim._failed is not None and sim.stepper.blocked == sim._failed + for later in (c.record, lambda: sim.set_body(0, offset=(0., .1), vy=0.)): with pytest.raises(RuntimeError, match="unknown"): later() + with pytest.raises(RuntimeError, match="Simulation failed"): + sim.run(1, stream=STREAM) + with pytest.raises(RuntimeError, match="moving circle step failed"): + sim.stepper.step(1, action_gpu=None, obs_gpu=None) 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) +# D2Q9 non-rest directions, paired order of lbm/descriptors. +_CX = (1, -1, 0, 0, 1, -1, 1, -1) +_CY = (0, 0, 1, -1, 1, -1, -1, 1) + + +def _inside(x: int, y: int, c, r: float) -> bool: + return (x - c[0]) ** 2 + (y - c[1]) ** 2 < r * r + + +def empty_donor_cells(c_old, c_new, radius: float): + """Fresh cells (inside c_old, outside c_new) whose donor set V(x) is empty.""" + r = radius + xs = range(math.floor(min(c_old[0], c_new[0]) - r) - 1, math.ceil(max(c_old[0], c_new[0]) + r) + 2) + ys = range(math.floor(min(c_old[1], c_new[1]) - r) - 1, math.ceil(max(c_old[1], c_new[1]) + r) + 2) + empty = [] + for x in xs: + for y in ys: + if not _inside(x, y, c_old, r) or _inside(x, y, c_new, r): + continue + if not any(not _inside(x + cx, y + cy, c_old, r) and not _inside(x + cx, y + cy, c_new, r) + for cx, cy in zip(_CX, _CY)): + empty.append((x, y)) + return empty + + +def enumerate_donor_coverage(radius: float, positions: int = 8, directions: int = 16) -> int: + """Count empty-donor fresh cells over sub-cell centres and moves of MAX_STEP. + + Evidence for the declared bounds (radius >= R_MIN, |dc|_2 <= MAX_STEP), + not a proof; the device keeps the ``no_donor`` counter. + """ + count = 0 + for i in range(positions): + for j in range(positions): + c_old = (i / positions, j / positions) + for k in range(directions): + a = 2.0 * math.pi * k / directions + c_new = (c_old[0] + MAX_STEP * math.cos(a), c_old[1] + MAX_STEP * math.sin(a)) + count += len(empty_donor_cells(c_old, c_new, radius)) + return count + + @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. @@ -278,6 +327,26 @@ def advancing_simulation(monkeypatch, **tables): return sim, writes, sim.bodies.objects[0].motion +def routed_advancing_simulation(monkeypatch, **tables): + """Real body registration, target routing, run and owner hook; double device I/O.""" + sim = routed_simulation(monkeypatch) + bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((-2., 2., -3., 3.), .1)) + sim._initialized = True + sim._assert_runtime_contracts = lambda: None + writes = [] + monkeypatch.setattr(sim.bodies, "_upload_action_async", lambda s: writes.append("action")) + monkeypatch.setattr(sim.bodies, "zero_obs_async", lambda s: writes.append("obs")) + monkeypatch.setattr(sim.bodies, "download_obs_full_async", lambda s: writes.append("download")) + sim.stepper = SimpleNamespace( + moving=Tables(writes, **tables), blocked=None, + _step=lambda n, advance=False, **k: writes.append(("step", n, advance))) + from CelerisLab.lbm.stepper import LBMStepper + sim.stepper.step = LBMStepper.step.__get__(sim.stepper) + sim._moving_tables = sim.stepper.moving + sim._attach_moving() + return sim, writes, sim.bodies.get(bid).motion + + def test_run_one_advances_and_commits_after_sync(monkeypatch): sim, writes, motion = advancing_simulation(monkeypatch) motion.set_target((0., .25), (0., .05)) @@ -545,23 +614,21 @@ def test_moving_circle_excluded_from_static_links(): def test_failed_moving_step_marks_simulation_failed(monkeypatch): - sim = routed_simulation(monkeypatch) + sim, writes, motion = routed_advancing_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)) + sim.set_body(0, offset=(0., .2), vy=.05) def fail(*_): raise RuntimeError("sync failed") with pytest.raises(RuntimeError, match="sync failed"): - motion.advance(lambda *_: None, lambda: None, fail) + sim.run(1, stream=SimpleNamespace(synchronize=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) + sim.set_body(0, offset=(0., .1), vy=.05) with sim.ctx.activate(): # a refused close keeps the failed state and resources with pytest.raises(RuntimeError, match="active CUDA"): sim.close()