motion: exercise real step ownership and move host test helpers out of src
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
# CelerisLab/body/motion.py
|
# 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
|
Contract: docs/moving_bodies.md, sections 4a, 5 and 7. The applied geometry
|
||||||
(last successfully committed offset) and an optional pending target are two
|
(last successfully committed offset) and an optional pending target are two
|
||||||
separate facts; centres are always ``base + offset``. Nothing here touches
|
separate facts; centres are always ``base + offset``. MovingCircle owns host
|
||||||
the device: the caller supplies upload/step/synchronize callables.
|
targets, MovingTables owns device records, and Simulation commits completed steps.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,10 +18,6 @@ R_MIN = 4.0 # smallest radius covered by the donor enumeration
|
|||||||
|
|
||||||
Vec = Tuple[float, float]
|
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:
|
def _vec(value, name: str) -> Vec:
|
||||||
x, y = (float(v) for v in value)
|
x, y = (float(v) for v in value)
|
||||||
@@ -98,28 +94,6 @@ class MovingCircle:
|
|||||||
offset, velocity = self.applied, (0.0, 0.0)
|
offset, velocity = self.applied, (0.0, 0.0)
|
||||||
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:
|
|
||||||
"""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:
|
def commit(self) -> None:
|
||||||
"""Applied := pending offset (caller has synchronized a successful step)."""
|
"""Applied := pending offset (caller has synchronized a successful step)."""
|
||||||
if self.pending is not None:
|
if self.pending is not None:
|
||||||
@@ -143,43 +117,6 @@ class MovingCircle:
|
|||||||
raise RuntimeError("moving circle state is unknown after a failed step")
|
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:
|
class MovingTables:
|
||||||
"""Device tables of the translating circles for ``MovingCircleCells``/``Links``.
|
"""Device tables of the translating circles for ``MovingCircleCells``/``Links``.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Real Simulation entrypoints with device doubles; no CUDA context or GPU work.
|
||||||
moving body must be rejected before any device write.
|
|
||||||
"""
|
"""
|
||||||
|
import math
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from CelerisLab.body.motion import (MAX_STEP, R_MIN, MovingCircle, Translation,
|
from CelerisLab.body.motion import MAX_STEP, R_MIN, MovingCircle, Translation
|
||||||
enumerate_donor_coverage)
|
|
||||||
|
|
||||||
BAD = (float("nan"), float("inf"))
|
BAD = (float("nan"), float("inf"))
|
||||||
|
|
||||||
@@ -46,46 +45,96 @@ def test_targets_validate_against_applied_and_last_wins():
|
|||||||
assert c.applied == (0., 0.)
|
assert c.applied == (0., 0.)
|
||||||
|
|
||||||
|
|
||||||
def test_record_commit_and_hold():
|
def test_record_commit_and_hold(monkeypatch):
|
||||||
c = circle()
|
sim, writes, c = routed_advancing_simulation(monkeypatch)
|
||||||
assert c.record() == ((50., 40.), (50., 40.), (0., 0.)) # hold: no translation velocity
|
assert c.record() == ((50., 40.), (50., 40.), (0., 0.))
|
||||||
c.set_target((0., .25), (0., .05))
|
sim.set_body(0, offset=(0., .25), vy=.05)
|
||||||
assert c.record() == ((50., 40.), (50., 40.25), (0., .05))
|
assert c.record() == ((50., 40.), (50., 40.25), (0., .05))
|
||||||
uploads = []
|
seen_at_sync = []
|
||||||
c.advance(uploads.append, lambda: None, lambda: None)
|
sim.run(1, stream=SimpleNamespace(synchronize=lambda: seen_at_sync.append(c.applied)))
|
||||||
assert uploads == [((50., 40.), (50., 40.25), (0., .05))]
|
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
|
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)
|
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))
|
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
|
assert c.applied == (0., 0.) and c.pending is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("stage", ("upload", "step", "synchronize"))
|
@pytest.mark.parametrize("stage", ("upload", "step", "synchronize"))
|
||||||
def test_failure_after_upload_attempt_is_fail_closed(stage):
|
def test_failure_after_upload_attempt_is_fail_closed(monkeypatch, stage):
|
||||||
c = circle()
|
sim, writes, c = routed_advancing_simulation(monkeypatch, fail="upload" if stage == "upload" else None)
|
||||||
c.set_target((0., .25), (0., .05))
|
sim.set_body(0, offset=(0., .25), vy=.05)
|
||||||
|
|
||||||
def call(name):
|
def fail(*a, **k):
|
||||||
def run(*_):
|
raise RuntimeError(f"{stage} failed")
|
||||||
if name == stage:
|
if stage == "step":
|
||||||
raise RuntimeError(f"{name} failed")
|
sim.stepper._step = fail
|
||||||
return run
|
stream = SimpleNamespace(synchronize=fail) if stage == "synchronize" else STREAM
|
||||||
with pytest.raises(RuntimeError, match=f"{stage} failed"):
|
with pytest.raises(RuntimeError, match=f"{stage} failed"):
|
||||||
c.advance(call("upload"), call("step"), call("synchronize"))
|
sim.run(1, stream=stream)
|
||||||
assert c.failed and c.applied == (0., 0.)
|
assert c.failed and c.applied == (0., 0.) and c.pending == ((0., .25), (0., .05))
|
||||||
for later in (c.record, lambda: c.set_target((0., .1), (0., 0.)),
|
assert sim._failed is not None and sim.stepper.blocked == sim._failed
|
||||||
lambda: c.advance(*(lambda *_: None,) * 3)):
|
for later in (c.record, lambda: sim.set_body(0, offset=(0., .1), vy=0.)):
|
||||||
with pytest.raises(RuntimeError, match="unknown"):
|
with pytest.raises(RuntimeError, match="unknown"):
|
||||||
later()
|
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():
|
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)
|
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))
|
@pytest.mark.parametrize("radius", (R_MIN, 4.3, 7.5, 12.0))
|
||||||
def test_refill_donor_sets_nonempty_within_bounds(radius):
|
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.
|
# 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
|
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):
|
def test_run_one_advances_and_commits_after_sync(monkeypatch):
|
||||||
sim, writes, motion = advancing_simulation(monkeypatch)
|
sim, writes, motion = advancing_simulation(monkeypatch)
|
||||||
motion.set_target((0., .25), (0., .05))
|
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):
|
def test_failed_moving_step_marks_simulation_failed(monkeypatch):
|
||||||
sim = routed_simulation(monkeypatch)
|
sim, writes, motion = routed_advancing_simulation(monkeypatch)
|
||||||
sim.ctx = ClosableOwner()
|
sim.ctx = ClosableOwner()
|
||||||
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((0., 0., -1., 1.), .1))
|
sim.set_body(0, offset=(0., .2), vy=.05)
|
||||||
motion = sim.bodies.get(bid).motion
|
|
||||||
motion.set_target((0., .2), (0., .05))
|
|
||||||
|
|
||||||
def fail(*_):
|
def fail(*_):
|
||||||
raise RuntimeError("sync failed")
|
raise RuntimeError("sync failed")
|
||||||
with pytest.raises(RuntimeError, match="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.)
|
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,
|
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")):
|
sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
|
||||||
with pytest.raises(RuntimeError, match="Simulation failed"):
|
with pytest.raises(RuntimeError, match="Simulation failed"):
|
||||||
call()
|
call()
|
||||||
with pytest.raises(RuntimeError, match="unknown"): # no retry on the failed circle
|
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 sim.ctx.activate(): # a refused close keeps the failed state and resources
|
||||||
with pytest.raises(RuntimeError, match="active CUDA"):
|
with pytest.raises(RuntimeError, match="active CUDA"):
|
||||||
sim.close()
|
sim.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user