"""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. GPU tolerance: window rows are summed per link over time, the per-step reads over links then over time. Reordering a sum of m FP64 terms changes it by at most 2*(m-1)*eps*sum|terms| (each order is within (m-1)*eps*sum|terms| of the exact sum), so the bound is scaled by the absolute sum of the per-step row integrals and is robust to cancellation. """ from contextlib import ExitStack import unittest from unittest.mock import patch import numpy as np import pycuda.driver as cuda from CelerisLab.lbm.refinement_stepper import FixedPatchStepper from test_refinement_blocked_base import mocked_backend from test_refinement_blocked_fine import BOUNDS, circle, config EPS = np.finfo(np.float64).eps def build(m, window=True, raw=True): return FixedPatchStepper(config("periodic"), BOUNDS, context=m["context"], base_block=(3, 4), fine_block=(16, 20), fine_objects=[circle()], observe_raw_exchange=raw, observe_window_exchange=window) def accumulations(m): return [c for c in m["calls"] if c[0] == "BlockAccumulateExchange"] class ExchangeWindowCPU(unittest.TestCase): def test_constructor_validation(self): with mocked_backend() as m: with self.assertRaisesRegex(ValueError, "requires observe_raw_exchange"): build(m, window=True, raw=False) with self.assertRaisesRegex(ValueError, "must be bool"): build(m, window=1) def test_state_machine_and_launches(self): with mocked_backend() as m, ExitStack() as stack: stack.enter_context(patch.object(cuda, "memset_d8")) stack.enter_context(patch.object(cuda, "memset_d8_async")) stack.enter_context(patch.object(cuda, "memcpy_dtoh", side_effect=lambda out, src: out.fill(0))) backend = build(m) b = backend._level_boundaries[1] self.assertIsNotNone(b.window_integral) with self.assertRaises(RuntimeError): backend.start_exchange_window() # not initialized arrays = [np.full(level.shape, 1 / 9, np.float32) for level in backend._levels] backend.initialize(*arrays) with self.assertRaisesRegex(RuntimeError, "no valid exchange window"): backend.read_exchange_window(0) backend.step(1) self.assertEqual(len(accumulations(m)), 2) # closed window: interval ledger only backend.start_exchange_window() with self.assertRaisesRegex(RuntimeError, "at least one completed"): backend.read_exchange_window(0) m["calls"].clear() backend.step(2) calls = accumulations(m) self.assertEqual(len(calls), 8) # two fine hops x two ledgers x two base steps self.assertEqual({id(c[1][1]) for c in calls}, {id(b.interval_integral), id(b.window_integral)}) out = backend.read_exchange_window(0) self.assertEqual((out["time_start"], out["time_end"], out["sample_count"]), (1., 3, 4)) self.assertEqual(backend.read_exchange_window(0)["time_end"], 3) # non-destructive backend.initialize(*arrays) # invalidates with self.assertRaisesRegex(RuntimeError, "no valid exchange window"): backend.read_exchange_window(0) backend.start_exchange_window() with patch.object(backend, "_hop", side_effect=RuntimeError("injected")): with self.assertRaises(RuntimeError): backend.step(1) self.assertFalse(backend._window_valid) with self.assertRaises(RuntimeError): backend.read_exchange_window(0) # failed status def test_disabled_default_has_no_window_cost(self): with mocked_backend() as m, ExitStack() as stack: stack.enter_context(patch.object(cuda, "memset_d8_async")) backend = build(m, window=False) self.assertIsNone(backend._level_boundaries[1].window_integral) backend.initialize(*[np.full(l.shape, 1 / 9, np.float32) for l in backend._levels]) backend.step(1) self.assertEqual(len(accumulations(m)), 2) with self.assertRaisesRegex(RuntimeError, "disabled"): backend.start_exchange_window() class ExchangeWindowGPU(unittest.TestCase): def test_window_matches_summed_base_reads_and_populations_unchanged(self): from test_raw_exchange_gpu import BOUNDS as RB, CFG, circle as rc, jet, state from CelerisLab.cuda.context import CudaContext with CudaContext(0) as ctx: kw = dict(context=ctx, fine_block=(16, 20), fine_objects=[rc()], jets=[jet()], observe_raw_exchange=True) with FixedPatchStepper(CFG, RB, observe_window_exchange=True, **kw) as windowed, \ FixedPatchStepper(CFG, RB, **kw) as plain: arrays = [state(level.shape) for level in windowed._levels] windowed.initialize(*arrays) plain.initialize(*arrays) windowed.step(2) plain.step(2) windowed.start_exchange_window() b = windowed._level_boundaries[1] count = len(b.tables["cell"]) keys = ("impulse", "angular_impulse", "mass_into_fluid") summed = {part: [0., 0., 0., 0.] for part in ("total", "wall", "aperture")} scale = np.zeros(4) steps = 0 for speed in (0., .002, .002, -.001, 0., 0.): # amplitude updates stay in-window windowed.set_jet_amplitude(0, speed) plain.set_jet_amplitude(0, speed) windowed.step(1) plain.step(1) steps += 1 obs = windowed.read_raw_exchange(0) for part in summed: o = obs[part] summed[part] = [summed[part][0] + o["impulse"][0], summed[part][1] + o["impulse"][1], summed[part][2] + o["angular_impulse"], summed[part][3] + o["mass_into_fluid"]] rows = np.empty((count, 4), np.float64) cuda.memcpy_dtoh(rows, b.interval_integral) scale += np.abs(rows).sum(axis=0) for a, e in zip(windowed._download_raw().values, plain._download_raw().values): np.testing.assert_array_equal(np.isnan(a), np.isnan(e)) np.testing.assert_array_equal(a[np.isfinite(a)], e[np.isfinite(e)]) out = windowed.read_exchange_window(0) self.assertEqual((out["time_end"] - out["time_start"], out["sample_count"]), (steps, 2 * steps)) bound = 2 * (count * steps) * EPS * scale + np.finfo(np.float64).tiny for part in summed: o = out[part] got = np.array([o["impulse"][0], o["impulse"][1], o["angular_impulse"], o["mass_into_fluid"]]) diff = np.abs(got - np.array(summed[part])) self.assertTrue((diff <= bound).all(), f"{part}: {diff} > {bound}") np.testing.assert_array_equal(o["force"], o["impulse"] / steps) again = windowed.read_exchange_window(0) np.testing.assert_array_equal(again["total"]["impulse"], out["total"]["impulse"]) windowed.initialize(*arrays) with self.assertRaises(RuntimeError): windowed.read_exchange_window(0) if __name__ == "__main__": unittest.main()