From 985db14249fda589bca17f22ffd73f0d4204faad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 22:36:05 +0000 Subject: [PATCH] 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 --- src/CelerisLab/lbm/refinement_stepper.py | 9 ++- tests/unit/test_single_level.py | 67 +++++++++++++++++++ .../multigrid/test_block_stepper.py | 12 ++-- 3 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_single_level.py diff --git a/src/CelerisLab/lbm/refinement_stepper.py b/src/CelerisLab/lbm/refinement_stepper.py index 8c9c3c9..bdf1947 100644 --- a/src/CelerisLab/lbm/refinement_stepper.py +++ b/src/CelerisLab/lbm/refinement_stepper.py @@ -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 diff --git a/tests/unit/test_single_level.py b/tests/unit/test_single_level.py new file mode 100644 index 0000000..2a5427f --- /dev/null +++ b/tests/unit/test_single_level.py @@ -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() diff --git a/tests/validation/multigrid/test_block_stepper.py b/tests/validation/multigrid/test_block_stepper.py index 648e796..2206cc9 100644 --- a/tests/validation/multigrid/test_block_stepper.py +++ b/tests/validation/multigrid/test_block_stepper.py @@ -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)