"""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, also on raw owner cores per patch (INNER partly invalid by contract, not asserted). 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, OUTER_GHOST, 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 levels(bounds, blocks=(None, None), objects=(), jets=(), **rest): """Two-level constructor keywords: one L1 rectangle or set, per-level blocks, L1 bodies/Jets.""" return dict(refinements=[bounds], level_blocks=list(blocks), level_objects=[(), tuple(objects)], level_jets=[(), tuple(jets)], **rest) 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_offset 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_offset 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(blocks=(None, (8, 4))), dict(blocks=((4, 4), None)), dict(blocks=((4, 4), (8, 4)), objects=[circle()]), dict(blocks=((4, 4), (5, 4)))): with self.subTest(kw=kw), self.assertRaises(ValueError): FixedPatchStepper(cfg, context=context, **levels(((4, 4, 8, 12), (14, 4, 18, 12)), **kw)) with self.assertRaisesRegex(ValueError, "margin"): FixedPatchStepper(cfg, context=context, **levels(((1, 4, 8, 12), (10, 4, 14, 12)), blocks=((4, 4), (8, 4)))) from CelerisLab.body.jets import Jet, Parallel, Tube cfg32 = LBMConfig(nx=32, ny=16, viscosity=.1) span = Jet(Tube((5., 8.), 0., 22., .6), Parallel(0.), amplitude=0.) with self.assertRaisesRegex(ValueError, "one jet"): # same obj_id cannot bypass it FixedPatchStepper(cfg32, context=context, **levels(((2, 2, 14, 14), (18, 2, 30, 14)), blocks=((4, 4), (4, 4)), objects=[body(8., 8.), body(24., 8.)], jets=[span])) class MultipatchGPU(unittest.TestCase): STEPS = (0, 1, 2, 8) def run_steps(self, context, cfg, bounds, coarse, fine, **kw): out = [] with FixedPatchStepper(cfg, context=context, **levels(bounds, **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, blocks=((3, 4), (8, 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(blocks=((4, 4), (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(blocks=((4, 4), (4, 4))) leaf = s.fine_kind == ACTIVE support = s.fine_layout((4, 4)).owned outer = support & (s.fine_kind == OUTER_GHOST) holes = ~support 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()) # Raw owner cores, independent of the download mask (every patch). # INNER may be partly NaN after hop 2 by contract: not asserted. rc, rf = a[2], a[3] label = f"raw step {step}" self.assertTrue(np.isfinite(rc[:, ~s.covered]).all(), label + " coarse leaves") if step: # covered parent cores are poisoned by the parent step self.assertTrue(np.isnan(rc[:, s.covered]).all(), label + " covered") self.assertTrue(np.isfinite(rf[:, leaf]).all(), label + " fine ACTIVE") self.assertTrue(np.isnan(rf[:, outer]).all(), label + " fine OUTER") self.assertTrue(np.isnan(rf[:, holes]).all(), label + " fine holes") for i, p in enumerate(s.patches): active = window(s, i, rf)[:, p.fine_kind == ACTIVE] self.assertTrue(np.isfinite(active).all(), f"{label} patch {i} ACTIVE") 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) def body(x, y, obj_id=0): from CelerisLab.body.geometry.circle import CircleGeometry from CelerisLab.body.objects import SimObject return SimObject(obj_id, geometry=CircleGeometry(x, y, 2.15), center=(x, y), radius=2.15) class MultipatchBodiesGPU(unittest.TestCase): """fine_objects/jets on several patches (frozen gates, bitwise).""" PATCHES = ((2, 2, 14, 14), (18, 2, 30, 14)) STEPS = (0, 1, 2, 8) def run_steps(self, context, cfg, bounds, coarse, fine, switch=None, **kw): out = [] with FixedPatchStepper(cfg, context=context, **levels(bounds, **kw)) as backend: backend.initialize(coarse, fine) for step in self.STEPS: if switch is not None and step == 8: for index, amplitude in switch: backend.set_jet_amplitude(index, amplitude, level=1) backend.step(step - backend.step_count) out.append(backend.download() + raw(backend)) return out def test_one_body_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, blocks=((3, 4), (8, 4)), objects=[circle()]) 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, window(s, 0, b[3]), a[3], label + " raw fine") def test_two_bodies_and_jets_equal_tiled_single(self): """Translation copy across a block seam, with jets and an amplitude switch.""" from CelerisLab.body.jets import Jet, Parallel, Tube from CelerisLab.cuda.context import CudaContext cfg16 = LBMConfig(nx=16, ny=16, viscosity=.1) cfg32 = replace(cfg16, nx=32) one = FixedPatchSet2D.from_config(cfg16, self.PATCHES[:1]) two = FixedPatchSet2D.from_config(cfg32, self.PATCHES) coarse, fine = state(16, 16, 3), state(*one.patches[0].fine_shape, 4) jet = lambda x: Jet(Tube((x, 8.), 0., 6., .6), Parallel(0.), amplitude=.002) kw = dict(blocks=((4, 4), (4, 4))) with CudaContext(0) as context: for jets in ((), (jet(5.),)): ref = self.run_steps(context, cfg16, self.PATCHES[:1], coarse, level_fine(one, [fine]), switch=[(0, -.001)] if jets else None, objects=[body(8., 8.)], jets=jets, **kw) both = jets and (jet(5.), jet(21.)) got = self.run_steps(context, cfg32, self.PATCHES, np.concatenate([coarse, coarse], 2), level_fine(two, [fine, fine]), switch=[(0, -.001), (1, -.001)] if jets else None, objects=[body(8., 8., 5), body(24., 8., 5)], jets=both or (), **kw) for step, a, b in zip(self.STEPS, ref, got): label = f"jets={len(jets)} 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}") exact(self, window(two, i, b[3]), window(one, 0, a[3]), f"{label} raw fine {i}") if __name__ == "__main__": unittest.main()