diff --git a/tests/validation/multigrid/test_refinement_multipatch.py b/tests/validation/multigrid/test_refinement_multipatch.py new file mode 100644 index 0000000..8468aa9 --- /dev/null +++ b/tests/validation/multigrid/test_refinement_multipatch.py @@ -0,0 +1,131 @@ +"""Opt-in multipatch FixedPatchStepper (FixedPatchSet2D): CPU guards plus GPU gates. + +Run CPU and GPU classes in separate processes. GPU gates (frozen before any run): +- one rectangle through the multipatch interface is bit-identical to the old + single-patch blocked path (both boundary modes, raw owner masks included); +- S1 translation copy: two patches on 16x32 equal the 16x16 one-patch run tiled + twice, bit for bit, every step; +- reversed rectangle order gives bit-identical results; +- face gap-4 pair (16x24): ACTIVE leaves finite, covered/hole masks exact. +Weighted leaf mass is printed as a diagnostic only. +""" +from dataclasses import replace +import unittest + +import numpy as np +import pycuda.driver as cuda + +from CelerisLab.config import LBMConfig +from CelerisLab.lbm.refinement import ACTIVE, FixedPatch2D, FixedPatchSet2D +from CelerisLab.lbm.refinement_stepper import FixedPatchStepper +from test_refinement_blocked_fine import BOUNDS, NX, NY, config, state, circle +from test_refinement_blocked_base import exact, raw + +FACE = ((4, 4, 8, 12), (12, 4, 16, 12)) +S1 = ((4, 4, 12, 12), (20, 4, 28, 12)) + + +def level_fine(patch_set, windows): + """Level-wide fine array with each patch window filled from `windows`.""" + out = np.full((9, *patch_set.fine_shape), np.nan, np.float32) + for p, w in zip(patch_set.patches, windows): + y0, x0 = p.fine_level_origin + out[:, y0:y0 + p.fine_shape[0], x0:x0 + p.fine_shape[1]] = w + return out + + +def window(patch_set, index, fine): + p = patch_set.patches[index] + y0, x0 = p.fine_level_origin + return fine[:, y0:y0 + p.fine_shape[0], x0:x0 + p.fine_shape[1]] + + +class MultipatchCPU(unittest.TestCase): + def test_rejections_before_cuda(self): + cfg = config("periodic") + context = object() # never activated: every case must fail first + for kw in (dict(), dict(fine_block=(8, 4)), dict(base_block=(4, 4)), + dict(fine_block=(8, 4), base_block=(4, 4), fine_objects=[circle()]), + dict(fine_block=(5, 4), base_block=(4, 4))): + with self.subTest(kw=kw), self.assertRaises(ValueError): + FixedPatchStepper(cfg, ((4, 4, 8, 12), (14, 4, 18, 12)), context=context, **kw) + with self.assertRaisesRegex(ValueError, "closer"): + FixedPatchStepper(cfg, ((4, 4, 8, 12), (10, 4, 14, 12)), context=context, + fine_block=(8, 4), base_block=(4, 4)) + + +class MultipatchGPU(unittest.TestCase): + STEPS = (0, 1, 2, 8) + + def run_steps(self, context, cfg, bounds, coarse, fine, **kw): + out = [] + with FixedPatchStepper(cfg, bounds, context=context, **kw) as backend: + backend.initialize(coarse, fine) + for step in self.STEPS: + backend.step(step - backend.step_count) + out.append(backend.download() + raw(backend)) + return out + + def test_one_rectangle_matches_single_patch_path(self): + from CelerisLab.cuda.context import CudaContext + with CudaContext(0) as context: + for mode in ("periodic", "slot_reservoir"): + cfg = config(mode) + p = FixedPatch2D.from_config(cfg, BOUNDS) + s = FixedPatchSet2D.from_config(cfg, (BOUNDS,)) + coarse, fine = state(NY, NX, 1), state(*p.fine_shape, 2) + kw = dict(coarse_boundary=mode, fine_block=(8, 4), base_block=(3, 4)) + old = self.run_steps(context, cfg, BOUNDS, coarse, fine, **kw) + new = self.run_steps(context, cfg, (BOUNDS,), coarse, level_fine(s, [fine]), **kw) + for step, a, b in zip(self.STEPS, old, new): + label = f"{mode} step {step}" + exact(self, b[0], a[0], label + " coarse") + exact(self, window(s, 0, b[1]), a[1], label + " fine") + exact(self, b[2], a[2], label + " raw coarse") + exact(self, window(s, 0, b[3]), a[3], label + " raw fine") + + def test_s1_translation_copy_and_order(self): + from CelerisLab.cuda.context import CudaContext + cfg16 = LBMConfig(nx=16, ny=16, viscosity=.1) + cfg32 = replace(cfg16, nx=32) + one = FixedPatchSet2D.from_config(cfg16, (S1[0],)) + two = FixedPatchSet2D.from_config(cfg32, S1) + coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4) + kw = dict(fine_block=(4, 4), base_block=(4, 4)) + with CudaContext(0) as context: + ref = self.run_steps(context, cfg16, (S1[0],), coarse, level_fine(one, [fine]), **kw) + tiled = np.concatenate([coarse, coarse], axis=2) + for bounds in (S1, S1[::-1]): + got = self.run_steps(context, cfg32, bounds, tiled, + level_fine(two, [fine, fine]), **kw) + for step, a, b in zip(self.STEPS, ref, got): + label = f"order {bounds[0]} step {step}" + exact(self, b[0], np.concatenate([a[0], a[0]], axis=2), label + " coarse") + for i in (0, 1): + exact(self, window(two, i, b[1]), window(one, 0, a[1]), f"{label} fine {i}") + + def test_face_gap4_finite_masks_and_order(self): + from CelerisLab.cuda.context import CudaContext + cfg = LBMConfig(nx=24, ny=16, viscosity=.1) + s = FixedPatchSet2D.from_config(cfg, FACE) + coarse = state(16, 24, 5) + fine = level_fine(s, [state(*p.fine_shape, 6 + i) for i, p in enumerate(s.patches)]) + kw = dict(fine_block=(4, 4), base_block=(4, 4)) + leaf = s.fine_kind == ACTIVE + with CudaContext(0) as context: + runs = [self.run_steps(context, cfg, bounds, coarse, fine, **kw) + for bounds in (FACE, FACE[::-1])] + for step, a, b in zip(self.STEPS, *runs): + for x, y in zip(a, b): + exact(self, y, x, f"reversed order step {step}") + c, f = a[0], a[1] + self.assertTrue(np.isfinite(c[:, ~s.covered]).all()) + self.assertTrue(np.isnan(c[:, s.covered]).all()) + self.assertTrue(np.isfinite(f[:, leaf]).all()) + self.assertTrue(np.isnan(f[:, ~leaf]).all()) + mass = float(np.nansum(c, dtype=np.float64) + .25 * np.nansum(f, dtype=np.float64)) + print(f"face gap4 step {step}: weighted leaf mass {mass:.9g} (diagnostic)", flush=True) + + +if __name__ == "__main__": + unittest.main()