refinement: single level matches PeriodicBlockStepper lifecycle; host contract tests

Review follow-ups for S1 (f764d68):
- a single level starts its spare buffer NaN, as PeriodicBlockStepper does,
  so stale halos cannot hide (multilevel initialization unchanged);
- projected explosion is rejected for a single level;
- new tests/unit/test_single_level.py: NaN spare after initialize, per-step
  launch order halo -> BFL (circles only) -> BlockStep with a null kind and
  one wait per step(n), circle solids excluded from the state mask;
- the GPU bitwise comparison also covers shifted storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-26 22:36:05 +00:00
co-authored by Claude Opus 5.5
parent f764d68c81
commit 985db14249
3 changed files with 80 additions and 8 deletions
+6 -3
View File
@@ -204,9 +204,10 @@ class FixedPatchStepper:
if self._ddf_shifting and any(b is None for b in level_blocks):
raise ValueError("shifted populations require all-blocked levels; dense/mixed is unsupported")
if depth == 1 and (coarse_boundary != "periodic" or cfg.collision != "SRT" or cfg.les_enabled
or explosion != "constant"
or observe_raw_exchange or observe_window_exchange):
raise ValueError("a single level requires a periodic SRT root without LES or exchange "
"observation")
raise ValueError("a single level requires a periodic SRT root without LES, projection "
"or exchange observation")
self._channel = coarse_boundary == "channel"
if self._channel:
validate_channel_config(cfg, level_blocks)
@@ -533,7 +534,9 @@ class FixedPatchStepper:
try:
for level, array in zip(self._levels, arrays):
cuda.memcpy_htod(level.current, array)
cuda.memcpy_htod(level.spare, array)
# A single level starts its spare NaN so stale halos cannot hide.
cuda.memcpy_htod(level.spare, np.full_like(array, np.nan)
if len(self._levels) == 1 else array)
if self._fine_curved is not None and self._fine_curved.link_exchange_gpu is not None:
cuda.memset_d8(self._fine_curved.link_exchange_gpu, 0, self._fine_curved.count * 12)
self._step_count = 0
+67
View File
@@ -0,0 +1,67 @@
"""Host contract of FixedPatchStepper(refinements=[]): one blocked level (mocked driver).
Per step: halo refresh -> static BFL (circles only) -> BlockStep with a null
kind, one stream wait per step(n) call, NaN spare after initialize.
"""
from contextlib import nullcontext
from unittest.mock import Mock
import unittest
import numpy as np
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from tests.unit.test_refinement_three_level_multipatch import host_module
def circle(center=(15.5, 11.5), radius=4.3):
return SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
def backend(objects=()):
host, log = host_module(), []
module = Mock()
module.get_global.return_value = (Mock(), 20)
module.get_function.side_effect = lambda name: Mock(
side_effect=lambda *a, **k: log.append((name, a)))
host.compiler.build_module.return_value = module
host.cuda.mem_alloc.side_effect = lambda size: Mock(size=size)
host.cuda.memcpy_htod.side_effect = lambda pointer, array: log.append(("upload", (pointer, array)))
run = host.FixedPatchStepper(LBMConfig(nx=32, ny=24, viscosity=.1),
context=Mock(activate=nullcontext, sm_arch="sm_70"),
refinements=[], level_blocks=[(12, 16)], level_objects=[objects])
run._stream.synchronize.side_effect = lambda: log.append(("sync", ()))
log.clear()
run.initialize(np.full(run._levels[0].shape, 1 / 9, np.float32))
return run, log
class SingleLevel(unittest.TestCase):
def test_spare_starts_nan_and_current_holds_state(self):
run, log = backend()
level = run._levels[0]
uploads = {id(a[0]): a[1] for name, a in log if name == "upload"}
self.assertTrue(np.isnan(uploads[id(level.spare)]).all())
self.assertTrue(np.isfinite(level.layout.to_dense(uploads[id(level.current)])).all())
def test_launch_order_per_step(self):
for objects, order in (((), ["BlockHaloCopy", "BlockStep"]),
((circle(),), ["BlockHaloCopy", "BlockCurvedBoundary", "BlockStep"])):
with self.subTest(bodies=len(objects)):
run, log = backend(objects)
log.clear()
run.step(2)
self.assertEqual([name for name, _ in log], order * 2 + ["sync"])
step = [a for name, a in log if name == "BlockStep"][0]
self.assertEqual(int(step[4]), 0) # null kind: every core cell ACTIVE
def test_circle_solids_are_not_state(self):
run, _ = backend((circle(),))
solid = run._level_boundaries[0].tables["solid"]
self.assertTrue(solid.any())
np.testing.assert_array_equal(run._state[0], ~solid)
if __name__ == "__main__":
unittest.main()
@@ -130,6 +130,7 @@ class CpuContracts(unittest.TestCase):
(config(collision="MRT"), {}),
(None, dict(observe_raw_exchange=True)),
(None, dict(level_jets=[(object(),)])),
(None, dict(explosion="projected_linear")),
(None, dict(level_objects=[(circle((15.5, 4.0), 4.3),)]))): # touches edge
with self.subTest(kw=kw), self.assertRaises(ValueError):
single(cfg, **kw)
@@ -354,16 +355,17 @@ class PeriodicBlockGPU(unittest.TestCase):
from CelerisLab.cuda.context import CudaContext
g0 = initial_state()
with CudaContext(0) as context:
for (by, bx), objects in [(split, objects) for split in SPLITS
for objects in ((), (circle(),))]:
with self.subTest(split=(by, bx), bodies=len(objects)):
for (by, bx), objects, shifted in [(split, objects, shifted) for split in SPLITS
for objects in ((), (circle(),))
for shifted in (False, True)]:
with self.subTest(split=(by, bx), bodies=len(objects), shifted=shifted):
layout = BlockLayout2D.build(NY, NX, by, bx)
with PeriodicBlockStepper(config(), layout, context=context,
with PeriodicBlockStepper(config(ddf_shifting=shifted), layout, context=context,
objects=objects) as backend:
backend.initialize(g0)
backend.step(STEPS)
expected = backend.download()
with FixedPatchStepper(config(), context=context, refinements=[],
with FixedPatchStepper(config(ddf_shifting=shifted), context=context, refinements=[],
level_blocks=[(by, bx)],
level_objects=[objects]) as backend:
backend.initialize(g0)