"""Host admission and independent lattice-stencil checks; no CUDA context.""" from types import SimpleNamespace import numpy as np import pytest from CelerisLab.body.motion import MovingCircle, MovingTables, Translation, validate_shared_translations from CelerisLab.lbm.descriptors import FLUID from tests.unit.test_moving_bodies import STREAM, body, moving_simulation, routed_simulation def scene(monkeypatch, envelope=None, gap=10.): sim = routed_simulation(monkeypatch) envelope = envelope or Translation((0., 0., -6., 6.), .5, shared=True) for y in (40. - (20. + gap) / 2., 40. + (20. + gap) / 2.): sim.bodies.add(body(60., y, 10., envelope)) sim.field = SimpleNamespace(nx=120, ny=80, build_channel_flags=lambda: np.full((80, 120), FLUID)) return sim def test_shared_bands_are_admitted_but_other_groups_are_not(monkeypatch): sim = scene(monkeypatch) sim._validate_moving_bodies() a, b = [o.motion for o in sim.bodies.objects] assert a.band()[3] >= b.band()[2] # whole envelopes genuinely overlap band, geom = MovingTables.layout([(a, 0), (b, 1)]) assert band[10] == band[2] * band[3] * 8 assert len(geom) == 14 b.translation = Translation((0., 0., -6., 6.), .5, shared=True) with pytest.raises(ValueError, match='overlaps body'): sim._validate_moving_bodies() # equal values do not link independent motion b.translation = a.translation sim.bodies.add(body(60., 40., 2.)) with pytest.raises(ValueError, match='overlaps body'): sim._validate_moving_bodies() @pytest.mark.parametrize('gap', (.3, 4.)) def test_small_surface_gaps_rejected(monkeypatch, gap): with pytest.raises(ValueError, match='clearance'): scene(monkeypatch, gap=gap)._validate_moving_bodies() def test_shared_envelope_is_transverse_only(): with pytest.raises(ValueError, match='transverse'): Translation((-.1, .1, -1., 1.), .5, shared=True) envelope = Translation((0., 0., -1., 1.), .5, shared=True) with pytest.raises(ValueError, match="zero x velocity"): MovingCircle((0., 0.), 10., envelope).check_target((0., .1), (.1, .1)) def test_group_rejection_is_before_writes_and_can_be_corrected(monkeypatch): sim, writes = moving_simulation(monkeypatch) env = Translation((0., 0., -2., 2.), .5, shared=True) sim.bodies.objects = [body(40., y, 10., env) for y in (20., 50.)] a, b = [o.motion for o in sim.bodies.objects] sim.stepper.moving = object() a.set_target((0., .2), (0., .2)) with pytest.raises(ValueError, match='identical'): sim.run(1, stream=STREAM) assert writes == [] and a.applied == b.applied == (0., 0.) b.set_target((0., .2), (0., .1)) with pytest.raises(ValueError, match='identical'): sim.run(1, stream=STREAM) assert writes == [] b.set_target((0., .2), (0., .2)) validate_shared_translations([a, b]) b.applied = (0., -.1) with pytest.raises(ValueError, match='identical'): validate_shared_translations([a, b]) C = np.array([(x, y) for x in (-1, 0, 1) for y in (-1, 0, 1) if (x, y) != (0, 0)]) def footprint_conflicts(centres, radius, dy): """Enumerate geometric write/read supports, not refill/BFL formulas. Tests every candidate donor, conservatively including M2's second link. Body-indexed slot numbers are separate from physical (cell,direction) writes. """ centres = np.asarray(centres, float) lo = np.floor(centres.min(0) - radius - 4).astype(int) hi = np.ceil(centres.max(0) + radius + 4).astype(int) yy, xx = np.mgrid[lo[1]:hi[1]+1, lo[0]:hi[0]+1] points = np.column_stack((xx.ravel(), yy.ravel())) def inside(p, c): return ((p - c)**2).sum(axis=-1) < radius**2 old = np.array([inside(points, c) for c in centres]) new_centres = centres + (0., dy) new = np.array([inside(points, c) for c in new_centres]) changed = old ^ new conflicts = int(np.count_nonzero(changed.sum(0) > 1)) writes = set() for i, (oc, nc) in enumerate(zip(centres, new_centres)): others = [j for j in range(len(centres)) if j != i] fresh = points[old[i] & ~new[i]] for e in C: donor = fresh + e valid = ~inside(donor, oc) & ~inside(donor, nc) for j in others: conflicts += int(np.count_nonzero(valid & (inside(donor, centres[j]) | inside(donor, new_centres[j])))) link = ~new[i] & inside(points + e, nc) fluid = points[link] solid = fluid + e for p in solid: slot = (int(p[0]), int(p[1]), int(e[0]), int(e[1])) conflicts += int(slot in writes) writes.add(slot) for read in (fluid, fluid - e): for j in others: conflicts += int(np.count_nonzero(inside(read, centres[j]) | inside(read, new_centres[j]))) return conflicts def test_counterexample_and_pinball_local_ownership(): # Close but disjoint circles are insufficient: parent/Grok independent example. assert footprint_conflicts(((.25, .25), (.25, 8.55)), 4., .5) > 0 for phase in (0., .25, .5, .75): for dy in (-.5, -.1, .1, .5): centres = np.array(((0., 0.), (26., 15.), (26., -15.))) + phase assert footprint_conflicts(centres, 10., dy) == 0