body: moving-step failure fails the Simulation; S1 acceptance tests
A failed MovingCircle.advance now calls the owner hook, so the Simulation is marked failed (Jet lifecycle) and refuses run/step/sync until close(). Adds host tests for set_body routing on real objects, initialize band and mode rejections before mutation, exclusion from static link packing, and the Simulation failure lifecycle. Contract status line updated for S1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
305ab2409b
commit
8a8e8a03ff
4 files changed
+145
-5
No files matched your search
@@ -1,6 +1,11 @@
|
|||||||
# Moving circles on the uniform dense grid — S0 DRAFT contract
|
# Moving circles on the uniform dense grid — S0 DRAFT contract
|
||||||
|
|
||||||
Status: **DRAFT for review, not frozen.** No implementation exists. Base: main
|
Status: **DRAFT, not frozen** for the force RHS, kernels and physical
|
||||||
|
acceptance. Host-only S1 (state, validation, guards; no kernels) is on branch
|
||||||
|
`claude/moving-bodies-s1`; positive-step runs with a moving body are rejected
|
||||||
|
until S2. When S2 lifts that guard, `run(n > 1)` with a pending target must
|
||||||
|
raise `ValueError` before mutation (4a), and `run(0)` must not upload a motion
|
||||||
|
record or consume the pending target. Base: main
|
||||||
`b2034f87e03656c5f67ab88562a70d6c338b9918`. Scope approved by Frank (via TRON,
|
`b2034f87e03656c5f67ab88562a70d6c338b9918`. Scope approved by Frank (via TRON,
|
||||||
thread C0C78UFKGRM/1793335557.460509): uniform-grid prescribed translation and
|
thread C0C78UFKGRM/1793335557.460509): uniform-grid prescribed translation and
|
||||||
single-cylinder free transverse VIV. Out of scope: refinement/blocked path,
|
single-cylinder free transverse VIV. Out of scope: refinement/blocked path,
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ class Translation:
|
|||||||
class MovingCircle:
|
class MovingCircle:
|
||||||
"""Applied offset, pending target and the fail-closed commit of one circle."""
|
"""Applied offset, pending target and the fail-closed commit of one circle."""
|
||||||
|
|
||||||
def __init__(self, base, radius: float, translation: Translation):
|
def __init__(self, base, radius: float, translation: Translation,
|
||||||
|
on_failure: Optional[Callable[[BaseException], None]] = None):
|
||||||
self.base = _vec(base, "base")
|
self.base = _vec(base, "base")
|
||||||
self.radius = float(radius)
|
self.radius = float(radius)
|
||||||
if not (math.isfinite(self.radius) and self.radius >= R_MIN):
|
if not (math.isfinite(self.radius) and self.radius >= R_MIN):
|
||||||
@@ -63,6 +64,7 @@ class MovingCircle:
|
|||||||
self.applied: Vec = (0.0, 0.0)
|
self.applied: Vec = (0.0, 0.0)
|
||||||
self.pending: Optional[Tuple[Vec, Vec]] = None
|
self.pending: Optional[Tuple[Vec, Vec]] = None
|
||||||
self.failed = False
|
self.failed = False
|
||||||
|
self._on_failure = on_failure # owner lifecycle hook (Simulation marks itself failed)
|
||||||
|
|
||||||
def centre(self, offset: Vec) -> Vec:
|
def centre(self, offset: Vec) -> Vec:
|
||||||
return self.base[0] + offset[0], self.base[1] + offset[1]
|
return self.base[0] + offset[0], self.base[1] + offset[1]
|
||||||
@@ -100,15 +102,18 @@ class MovingCircle:
|
|||||||
"""Upload the record, run one step and commit only after a successful sync.
|
"""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 and never reports applied state again.
|
unknown: the circle is marked failed, never reports applied state again,
|
||||||
|
and the owner's ``on_failure`` hook is called.
|
||||||
"""
|
"""
|
||||||
record = self.record()
|
record = self.record()
|
||||||
try:
|
try:
|
||||||
upload(record)
|
upload(record)
|
||||||
step()
|
step()
|
||||||
synchronize()
|
synchronize()
|
||||||
except BaseException:
|
except BaseException as exc:
|
||||||
self.failed = True
|
self.failed = True
|
||||||
|
if self._on_failure is not None:
|
||||||
|
self._on_failure(exc)
|
||||||
raise
|
raise
|
||||||
if self.pending is not None:
|
if self.pending is not None:
|
||||||
self.applied = self.pending[0]
|
self.applied = self.pending[0]
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ class Simulation:
|
|||||||
is_sensor=False, surface_actuation=surface_actuation)
|
is_sensor=False, surface_actuation=surface_actuation)
|
||||||
if translation is not None:
|
if translation is not None:
|
||||||
from .body.motion import MovingCircle
|
from .body.motion import MovingCircle
|
||||||
obj.motion = MovingCircle((cx, cy), radius, translation)
|
obj.motion = MovingCircle((cx, cy), radius, translation,
|
||||||
|
on_failure=self._fail_moving)
|
||||||
elif type_lower == "sensor":
|
elif type_lower == "sensor":
|
||||||
from .body.geometry.circle import CircleGeometry
|
from .body.geometry.circle import CircleGeometry
|
||||||
cx, cy = float(center[0]), float(center[1])
|
cx, cy = float(center[0]), float(center[1])
|
||||||
@@ -202,6 +203,10 @@ class Simulation:
|
|||||||
raise
|
raise
|
||||||
self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude
|
self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude
|
||||||
|
|
||||||
|
def _fail_moving(self, exc: BaseException) -> None:
|
||||||
|
# Device geometry is unknown after a failed moving step: same lifecycle as Jets.
|
||||||
|
self._failed = f"moving circle step failed: {exc!r}"
|
||||||
|
|
||||||
def _moving_bodies(self):
|
def _moving_bodies(self):
|
||||||
return [obj for obj in self.bodies.objects if obj.motion is not None]
|
return [obj for obj in self.bodies.objects if obj.motion is not None]
|
||||||
|
|
||||||
|
|||||||
@@ -129,3 +129,128 @@ def test_moving_body_rejects_topology_and_checkpoint(monkeypatch):
|
|||||||
for call in (sim.sync_bodies, sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
|
for call in (sim.sync_bodies, sim.save_checkpoint, lambda: sim.load_checkpoint("x.h5")):
|
||||||
with pytest.raises(ValueError, match="translating circles"):
|
with pytest.raises(ValueError, match="translating circles"):
|
||||||
call()
|
call()
|
||||||
|
|
||||||
|
|
||||||
|
# -- Real-object routing, initialize rejections, packing, lifecycle -------------
|
||||||
|
import numpy as np # noqa: E402
|
||||||
|
|
||||||
|
from CelerisLab.body.geometry.circle import CircleGeometry # noqa: E402
|
||||||
|
from CelerisLab.body.manager import ObjectManager # noqa: E402
|
||||||
|
from CelerisLab.body.objects import SimObject # noqa: E402
|
||||||
|
from CelerisLab.lbm.descriptors import FLUID, SOLID # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Cfg:
|
||||||
|
dim, is_d3q19, is_d2q9 = 2, False, True
|
||||||
|
streaming, ddf_shifting, store_precision, y_wall_bc = "double_buffer", False, "FP32", "bounce_back"
|
||||||
|
|
||||||
|
|
||||||
|
def body(x, y, r, translation=None, **kw):
|
||||||
|
obj = SimObject(obj_id=-1, geometry=CircleGeometry(x, y, r), center=(x, y), radius=r, **kw)
|
||||||
|
if translation is not None:
|
||||||
|
obj.motion = MovingCircle((x, y), r, translation)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def routed_simulation(monkeypatch):
|
||||||
|
sim = make_simulation(monkeypatch)
|
||||||
|
sim._failed, sim._initialized, sim.lbm_cfg = None, False, Cfg()
|
||||||
|
sim.bodies = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
|
||||||
|
return sim
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_body_and_set_body_route_targets(monkeypatch):
|
||||||
|
sim = routed_simulation(monkeypatch)
|
||||||
|
bid = sim.add_body("circle", (50., 40.), 10., translation=Translation((-2., 2., -3., 3.), .1))
|
||||||
|
still = sim.add_body("circle", (100., 40.), 5.)
|
||||||
|
motion = sim.bodies.get(bid).motion
|
||||||
|
sim.set_body(bid, offset=(0., .3), vy=.05)
|
||||||
|
sim.set_body(bid, offset=(0., .4), vy=.05) # last wins
|
||||||
|
for kwargs in (dict(offset=(0., .6)), dict(vy=.05), dict(vx=.05),
|
||||||
|
dict(offset=(0., .1), vy=.2), dict(offset=(0., 0.), vy=.01)):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sim.set_body(bid, **kwargs)
|
||||||
|
assert motion.pending == ((0., .4), (0., .05)) # invalid input keeps the target
|
||||||
|
sim.set_body(bid, omega=.01) # omega-only keeps the pending target
|
||||||
|
obj = sim.bodies.get(bid)
|
||||||
|
assert obj.state.omega == .01 and (obj.state.vx, obj.state.vy) == (0., 0.)
|
||||||
|
assert motion.pending == ((0., .4), (0., .05)) and motion.applied == (0., 0.)
|
||||||
|
with pytest.raises(ValueError, match="not declared"):
|
||||||
|
sim.set_body(still, offset=(0., .1))
|
||||||
|
sim.set_body(still, vx=.01) # stationary API unchanged
|
||||||
|
assert sim.bodies.get(still).state.vx == .01 and sim.bodies.get(still).motion is None
|
||||||
|
for kwargs in (dict(type="sensor"), dict(surface_actuation=dict(angle=0., half_width=.2,
|
||||||
|
normal_velocity=.01))):
|
||||||
|
kind = kwargs.pop("type", "circle")
|
||||||
|
with pytest.raises(ValueError, match="translation"):
|
||||||
|
sim.add_body(kind, (20., 20.), 5., translation=Translation((0., 0., 0., 0.), .1),
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(monkeypatch, objects, flags=None, **cfg):
|
||||||
|
sim = routed_simulation(monkeypatch)
|
||||||
|
for obj in objects:
|
||||||
|
sim.bodies.add(obj)
|
||||||
|
for name, value in cfg.items():
|
||||||
|
setattr(sim.lbm_cfg, name, value)
|
||||||
|
base = np.full(120 * 80, FLUID, np.uint16) if flags is None else flags
|
||||||
|
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: base.copy())
|
||||||
|
sim._validate_moving_bodies()
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_rejects_band_and_modes(monkeypatch):
|
||||||
|
t = Translation((-1., 1., -3., 3.), .1)
|
||||||
|
validate(monkeypatch, [body(50., 40., 10., t), body(100., 40., 5.)]) # accepted
|
||||||
|
walls = np.full(120 * 80, FLUID, np.uint16)
|
||||||
|
walls[60 + 30 * 120] = SOLID
|
||||||
|
cases = [([body(10., 40., 10., t)], None, {}, "domain edge"),
|
||||||
|
([body(50., 40., 10., t)], walls, {}, "non-fluid"),
|
||||||
|
([body(50., 40., 10., t), body(68., 40., 4.)], None, {}, "overlaps"),
|
||||||
|
([body(50., 40., 10., t), body(50., 60., 3., is_sensor=True)], None, {}, "overlaps"),
|
||||||
|
([body(50., 40., 10., t), body(64., 40., 3., is_force_region=True)], None, {}, "overlaps"),
|
||||||
|
([body(50., 40., 10., t)], None, {"streaming": "esopull"}, "double_buffer"),
|
||||||
|
([body(50., 40., 10., t)], None, {"ddf_shifting": True}, "unshifted"),
|
||||||
|
([body(50., 40., 10., t)], None, {"is_d2q9": False}, "D2Q9")]
|
||||||
|
for objects, flags, cfg, match in cases:
|
||||||
|
with pytest.raises(ValueError, match=match):
|
||||||
|
validate(monkeypatch, objects, flags, **cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initialize_validates_before_mutation(monkeypatch):
|
||||||
|
sim = routed_simulation(monkeypatch)
|
||||||
|
sim.bodies.add(body(10., 40., 10., Translation((0., 0., 0., 0.), .1)))
|
||||||
|
uploads = []
|
||||||
|
sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: np.full(9600, FLUID, np.uint16),
|
||||||
|
upload_flags=lambda: uploads.append("flags"))
|
||||||
|
sim._assert_allocation_contract = lambda: None
|
||||||
|
with pytest.raises(ValueError, match="domain edge"):
|
||||||
|
sim.initialize()
|
||||||
|
assert uploads == [] and not sim._initialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_moving_circle_excluded_from_static_links():
|
||||||
|
still = [body(100., 40., 5.)]
|
||||||
|
mgr = ObjectManager(nx=120, ny=80, nz=1, nq=9, cfg=Cfg())
|
||||||
|
reference = mgr.build_compact_lists_for(still)
|
||||||
|
with_moving = mgr.build_compact_lists_for(
|
||||||
|
still + [body(50., 40., 10., Translation((0., 0., -1., 1.), .1))])
|
||||||
|
for a, b in zip(reference[:8], with_moving[:8]):
|
||||||
|
assert np.asarray(a).tobytes() == np.asarray(b).tobytes()
|
||||||
|
assert len(reference[0]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_moving_step_marks_simulation_failed(monkeypatch):
|
||||||
|
sim = routed_simulation(monkeypatch)
|
||||||
|
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))
|
||||||
|
|
||||||
|
def fail(*_):
|
||||||
|
raise RuntimeError("sync failed")
|
||||||
|
with pytest.raises(RuntimeError, match="sync failed"):
|
||||||
|
motion.advance(lambda *_: None, lambda: None, 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):
|
||||||
|
with pytest.raises(RuntimeError, match="Simulation failed"):
|
||||||
|
call()
|
||||||
|
sim.close() # close still works; no re-entry afterwards
|
||||||
Reference in new issue
Block a user