From f851aecf73fbb08ce81eb4d13175ebc3ffcd20bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 13:03:18 +0000 Subject: [PATCH] test: shifted-MRT no-Jet window gate with step(0), reset and split-step checks Separate GPU class for a shifted MRT, all-blocked, stationary-body case without Jets: window totals vs summed per-base reads (substep-scaled tolerance), bitwise populations vs a raw-only stepper, a single step(N) stepper with bitwise-equal window rows and populations, step(0) non-change, and reset after a window. SRT/Jet test stays separately scoped. Test-only. Co-Authored-By: Claude Opus 5.5 --- .../multigrid/test_exchange_window.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/validation/multigrid/test_exchange_window.py b/tests/validation/multigrid/test_exchange_window.py index 84d19a4..be8b11c 100644 --- a/tests/validation/multigrid/test_exchange_window.py +++ b/tests/validation/multigrid/test_exchange_window.py @@ -1,6 +1,8 @@ """Optional observation-window exchange ledger: host contract (mocked driver) and GPU gate. Run CPU and GPU classes in separate processes; GPU callers select the device. +ExchangeWindowGPU uses the SRT/unshifted Jet fixture; ExchangeWindowShiftedMRTGPU covers a +shifted MRT, all-blocked, stationary-body case without Jets (no shifted-MRT Jet claim). GPU tolerance (conservative engineering tolerance, not a universal proof): the window ledger sums h*dt*raw per link over fine substeps, while the reference sums per-base interval ledgers over links and then over base steps. Both are FP64 reorderings of the same FP32-exact substep @@ -169,4 +171,90 @@ class ExchangeWindowGPU(unittest.TestCase): windowed.read_exchange_window(0) +def audit_substeps(stepper, b, count, record): + """Wrap the level-1 step kernel: after BFL and accumulation, add |substep terms| to record.""" + factors = np.array([.25, .25, .125, .25]) + kernel = stepper._levels[1].kernel + + def audited(src, dst, *args, **kw): + stepper._stream.synchronize() + raw = np.empty((count, 4), np.float32) + cuda.memcpy_dtoh(raw, b.last_raw) + record["scale"] = record["scale"] + (np.abs(raw.astype(np.float64)) * factors).sum(axis=0) + record["substeps"] += 1 + kernel(src, dst, *args, **kw) + stepper._levels[1].kernel = audited + + +def components(obs): + o = obs["total"] + return np.array([o["impulse"][0], o["impulse"][1], o["angular_impulse"], o["mass_into_fluid"]]) + + +class ExchangeWindowShiftedMRTGPU(unittest.TestCase): + """Shifted MRT, all-blocked, stationary body, NO Jet (scoped separately from SRT/Jet).""" + + def test_window_step0_reset_and_split_step(self): + from test_raw_exchange_gpu import BOUNDS as RB, circle as rc, state + from CelerisLab.config import LBMConfig + from CelerisLab.cuda.context import CudaContext + cfg = LBMConfig(nx=32, ny=24, viscosity=.1, collision="MRT", ddf_shifting=True) + with CudaContext(0) as ctx: + kw = dict(context=ctx, base_block=(12, 8), fine_block=(16, 20), fine_objects=[rc()], + observe_raw_exchange=True) + with FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as windowed, \ + FixedPatchStepper(cfg, RB, observe_window_exchange=True, **kw) as split, \ + FixedPatchStepper(cfg, RB, **kw) as plain: + arrays = [state(level.shape) for level in windowed._levels] + for run in (windowed, split, plain): + run.initialize(*arrays) + run.step(2) + if run is not plain: + run.start_exchange_window() + b = windowed._level_boundaries[1] + count = len(b.tables["cell"]) + record = dict(scale=np.zeros(4), substeps=0) + audit_substeps(windowed, b, count, record) + steps, summed = 5, np.zeros(4) + for _ in range(steps): + windowed.step(1) + plain.step(1) + summed += components(windowed.read_raw_exchange(0)) + for a, e in zip(windowed._download_raw().values, plain._download_raw().values): + same_bits(self, a, e) + split.step(steps) # one call: same ledger arithmetic as five step(1) calls + for a, e in zip(windowed._download_raw().values, split._download_raw().values): + same_bits(self, a, e) + rows = [np.empty((count, 4), np.float64) for _ in range(2)] + cuda.memcpy_dtoh(rows[0], b.window_integral) + cuda.memcpy_dtoh(rows[1], split._level_boundaries[1].window_integral) + np.testing.assert_array_equal(rows[0].view(np.uint64), rows[1].view(np.uint64)) + + out = windowed.read_exchange_window(0) + self.assertEqual(record["substeps"], 2 * steps) + self.assertEqual((out["time_end"] - out["time_start"], out["sample_count"]), + (steps, 2 * steps)) + bound = 4 * (count * record["substeps"]) * EPS * record["scale"] + 1e-300 + diff = np.abs(components(out) - summed) + self.assertTrue((diff <= bound).all(), f"{diff} > {bound}") + np.testing.assert_array_equal(split.read_exchange_window(0)["total"]["impulse"], + out["total"]["impulse"]) + + windowed.step(0) # no interval: window and its duration unchanged + again = windowed.read_exchange_window(0) + self.assertEqual(again["time_end"], out["time_end"]) + np.testing.assert_array_equal(again["total"]["impulse"], out["total"]["impulse"]) + + windowed.start_exchange_window() # reset after a window + with self.assertRaisesRegex(RuntimeError, "at least one completed"): + windowed.read_exchange_window(0) + record.update(scale=np.zeros(4), substeps=0) + windowed.step(1) + single = components(windowed.read_raw_exchange(0)) + reset = windowed.read_exchange_window(0) + self.assertEqual(reset["time_end"] - reset["time_start"], 1) + bound = 4 * (count * record["substeps"]) * EPS * record["scale"] + 1e-300 + self.assertTrue((np.abs(components(reset) - single) <= bound).all()) + + if __name__ == "__main__": unittest.main()