Files
CelerisLab/tests/validation/multigrid/test_exchange_window.py
T
ClaudeandClaude Opus 5.5 d930323479 feat: optional observation-window raw exchange ledger
FixedPatchStepper(observe_window_exchange=True), which requires
observe_raw_exchange, allocates a second per-link FP64 ledger per body
level. While a window is open, the unchanged BlockAccumulateExchange
kernel also accumulates into it after each fine hop. Small API:
start_exchange_window() zeroes and opens at the current completed step;
read_exchange_window(body_id, level=) is non-destructive and reports
impulse with rates over the integer window duration. initialize/
_initialize_raw and step failure invalidate; remap builds a new instance
with no open window; Jet amplitude updates may stay in-window. Default
disabled: no allocation and no extra launch; last_substep/last_base_step
unchanged. summarize_exchange gains window='window' with duration.
Host contract tests (mocked driver) and a GPU gate against summed
per-base reads with a reordering-bound FP64 tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:54:44 +00:00

151 lines
7.8 KiB
Python

"""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()