Files
CelerisLab/tests/validation/multigrid/test_refinement_three_level.py
T
ClaudeandClaude Opus 5.5 2b10b01c49 test: retire dense-route tests, require tiles everywhere (D2)
Retired with the dense refinement route (results recorded at 1573942 and
2469af7 or named as accepted losses):
- test_refinement_blocked_base: BlockedBaseGPU exact parity (3 tests),
  DenseBlockedRouteParity (6 tests incl. MRT and two bodies),
  DenseBlockedRouteCPU (capability matrix, ledger comparator);
- test_refinement_blocked_fine: BlockedFineGPU dense-vs-blocked (3 tests);
- test_refinement_mrt_gpu: test_dense_versus_blocked_mrt_two_level;
- test_raw_exchange_gpu: test_dense_fine_storage_reference (private
  link_exchange cut-link oracle, accepted loss);
- test_refinement_gpu: test_bounded_equivalence_and_ownership (dense
  CELERIS_REFINEMENT_AUDIT ownership counters, accepted loss); its physics
  stays in test_bounded_equivalence_blocked; eight_steps loses the audit
  branch and the misleading 'ownership/counts PASS' label;
- test_fine_surface_gpu: test_fine_surface_four_hops (dense per-hop flux
  ledger, CurvedLinkSoA reflection oracle, audit counts: accepted losses;
  raw exchange covers the boundary part only, not full flux closure) and the
  dense SoA fault tests (missing surface buffer, close frees SoA); blocked
  equivalents remain in test_refinement_blocked_fine/_base;
- test_raw_exchange.test_dense_observation_rejected_before_device_access.
New TileContractCPU: level_blocks omitted/None/wrong length/None entry/
wrong arity/non-integer/bool/non-positive/non-tuple/not dividing all raise
before CUDA; whole-level tiles reach CUDA. Rejection tests and fixtures pass
tiles; launch-order and kernel-text tests follow the blocked root;
examples/fixed_periodic_patch uses whole-level tiles (+ host tile test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:12:20 +00:00

276 lines
14 KiB
Python

"""Nested three-level FixedPatchStepper (child=..., single chain): CPU guards and GPU gates.
Fixture = reviewed CPU probe: L0 16x16 periodic, L1 (4,4,12,12) -> 24x24 window,
L2 (8,8,16,16) in L1 window cells -> 24x24 window; 4x4 blocks on every level.
Run CPU and GPU classes in separate processes. GPU gates (frozen before any run):
- uniform moving equilibrium stays on all three levels' ACTIVE leaves, |dG| <= 1e-6;
- seeded non-equilibrium: every level's ACTIVE leaves finite, raw owner cores NaN on
OUTER/covered (INNER partly invalid by contract, not asserted);
- each L1 hop feeds L2 from the L1 buffer it is about to step (current, then the new
G written by the first hop), never from L0: 2 child advances per coarse step.
Numerical comparison against the CPU probe (G vs C(f)) is done by the acceptance run.
"""
from contextlib import nullcontext
from dataclasses import replace
import unittest
from unittest.mock import Mock, patch
import numpy as np
import pycuda.driver as cuda
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
from test_refinement_blocked_fine import state, circle
from test_refinement_blocked_base import raw
CFG = LBMConfig(nx=16, ny=16, viscosity=.1)
L1, L2 = (4, 4, 12, 12), (8, 8, 16, 16)
B = (4, 4)
def levels(l1=L1, l2=L2, blocks=(B, B, B), objects=((), (), ()), jets=((), (), ())):
"""Level-list keywords for the scalar L1 patch with one scalar L2 child."""
return dict(refinements=[l1, l2], level_blocks=list(blocks),
level_objects=list(objects), level_jets=list(jets))
KW = levels()
def child_body(spec=None):
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
return SimObject(99, geometry=CircleGeometry(8., 8., .8), center=(8., 8.), radius=.8,
surface_actuation=spec)
def child_jet(speed=.002):
from CelerisLab.body.jets import Jet, Tube, Parallel
return Jet(Tube((6.5, 8.), 0., 3., .3), Parallel(0.), amplitude=speed)
def mocked_child():
from CelerisLab.cuda import compiler
context = Mock(activate=nullcontext, sm_arch="sm_70")
module = Mock(get_function=Mock(side_effect=lambda name: Mock(name=name)))
module.get_global.return_value = (Mock(), 20)
with patch.object(cuda, "Stream"), patch.object(cuda, "mem_get_info", return_value=(1 << 40, 1 << 40)), patch.object(compiler, "generate_config"), \
patch.object(compiler, "compile_kernel"), patch.object(compiler, "cleanup_build_dir"), \
patch.object(compiler, "load_module", return_value=module), \
patch.object(cuda, "mem_alloc", side_effect=lambda size: Mock()), \
patch.object(cuda, "memcpy_htod"):
return FixedPatchStepper(CFG, context=context,
**levels(objects=((), (), (child_body(),)), jets=((), (), (child_jet(),))))
class ThreeLevelCPU(unittest.TestCase):
def test_rejections_before_cuda(self):
context = object() # never activated: every case must fail first
bad = (levels(blocks=(B, B, None)), levels(blocks=(B, None, B)),
levels(objects=((), (circle(),), ())), levels(l2=(4, 8, 16, 16)),
levels(blocks=(B, B, (5, 4))))
for kw in bad:
with self.subTest(kw=kw), self.assertRaises(ValueError):
FixedPatchStepper(CFG, context=context, **kw)
# L2 bodies/Jets without an L2 level (one entry too many), and an L2 Jet without a body.
two_level = dict(refinements=[L1], level_blocks=[B, B])
for kw in (dict(two_level, level_objects=[(), (), (child_body(),)]),
dict(two_level, level_jets=[(), (), (child_jet(),)]),
levels(jets=((), (), (child_jet(),)))):
with self.assertRaises(ValueError):
FixedPatchStepper(CFG, context=context, **kw)
with self.assertRaises(ValueError): # several L1 patches + scalar child: not yet
FixedPatchStepper(LBMConfig(nx=32, ny=16, viscosity=.1), context=context,
**levels(l1=((4, 4, 12, 12), (20, 4, 28, 12))))
def test_four_halo_bfl_steps_and_fresh_middle_feeds(self):
run = mocked_child()
run._status = "ready"
f, g = run._levels[1:]
b = g.boundary
events, feeds = [], []
advance, gather = run._advance, run._gather_halo
def record_feed(index, buffer, **kw): # index 2: L1 hop feeds L2
if index == 2:
self.assertIs(buffer, (f.current, f.spare)[len(feeds)])
feeds.append(buffer)
advance(index, buffer, **kw)
def record_halo(level, buffer, **kw):
if level is g:
events.append(("halo", buffer))
gather(level, buffer, **kw)
b.kernel.side_effect = lambda buffer, *a, **k: events.append(("bfl", buffer))
def record_step(src, dst, halo, flags, kind, **kw):
self.assertIs(flags, b.flags)
self.assertIs(kind, g.kind)
events.append(("step", src))
g.kernel.side_effect = record_step
with patch.object(run, "_advance", side_effect=record_feed), \
patch.object(run, "_gather_halo", side_effect=record_halo):
run.step()
self.assertEqual(feeds, [f.current, f.spare])
expected = [(kind, buffer) for buffer in (g.current, g.spare, g.current, g.spare)
for kind in ("halo", "bfl", "step")]
self.assertEqual(events, expected)
self.assertGreater(len(np.unique(b.tables["block"])), 1)
# Negative control: reusing L1's old source at the midpoint must fail.
feeds.clear()
with patch.object(run, "_advance", side_effect=lambda index, buffer, **kw:
record_feed(index, f.current if index == 2 else buffer, **kw)):
with self.assertRaises(AssertionError):
run.step()
run.close()
def test_child_update_isolated_and_failure_atomic(self):
run = mocked_child()
b = run._levels[2].boundary
before = b.tables["surface_velocity"].copy()
with self.assertRaises(IndexError):
run.set_jet_amplitude(0, -.002) # default stays L1
for level in (0, 3, True):
with self.assertRaises(ValueError):
run.set_jet_amplitude(0, -.002, level=level)
with patch.object(cuda, "memcpy_htod") as upload:
run.set_jet_amplitude(0, -.002, level=2)
self.assertEqual(upload.call_count, 1)
self.assertIs(upload.call_args.args[0], b.device["surface_velocity"])
np.testing.assert_array_equal(b.tables["surface_velocity"], -before)
self.assertEqual(run._jets, ())
with patch.object(cuda, "memcpy_htod", side_effect=RuntimeError("copy failed")):
with self.assertRaisesRegex(RuntimeError, "copy failed"):
run.set_jet_amplitude(0, .002, level=2)
self.assertEqual(run._status, "failed")
self.assertEqual(b.jets[0].amplitude, -.002)
np.testing.assert_array_equal(b.tables["surface_velocity"], -before)
run.close()
def test_child_public_masks_include_solids(self):
from CelerisLab.lbm.descriptors import SOLID
run = mocked_child()
solid = run.child_boundary.flags.reshape(run.child_patch.fine_shape) & SOLID != 0
self.assertTrue(solid.any())
self.assertFalse(run._child_leaf[solid].any())
arrays = [np.ones(level.shape, np.float32) for level in run._levels]
run._mask_nonleaves(*arrays)
self.assertTrue(np.isnan(arrays[2][:, solid]).all())
with patch.object(cuda, "memcpy_htod") as upload:
run.initialize(*arrays)
sent = upload.call_args_list[-1].args[1]
dense = run.child_layout.to_dense(sent)
self.assertTrue(np.isnan(dense[:, ~run._child_leaf]).all())
run.close()
class ThreeLevelGPU(unittest.TestCase):
def test_equilibrium_is_kept_on_every_level(self):
from CelerisLab.cuda.context import CudaContext
feq = np.asarray(compute_feq_d2q9(1.0, .03, -.02), np.float32)[:, None, None]
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
shapes = ((16, 16), run.patch.fine_shape, run.child_patch.fine_shape)
run.initialize(*(np.broadcast_to(feq, (9, *s)).copy() for s in shapes))
run.step(8)
self.assertEqual(run.step_count, 8)
for level, g in enumerate(run.download()):
leaf = np.isfinite(g[0])
self.assertTrue(leaf.any(), f"level {level}")
np.testing.assert_allclose(g[:, leaf], np.broadcast_to(feq, g.shape)[:, leaf],
atol=1e-6, rtol=0, err_msg=f"level {level}")
def test_nonequilibrium_masks_and_fresh_middle_feeds(self):
from CelerisLab.cuda.context import CudaContext
with CudaContext(0) as ctx, FixedPatchStepper(CFG, context=ctx, **KW) as run:
p1, p2 = run.patch, run.child_patch
run.initialize(state(16, 16, 1), state(*p1.fine_shape, 2), state(*p2.fine_shape, 3))
f = run._levels[1]
feeds, original = [], run._advance
def record(index, buffer, **kw): # index 2: L1 hop feeds L2
if index == 2:
feeds.append(int(buffer))
original(index, buffer, **kw)
with patch.object(run, "_advance", side_effect=record):
cur, spare = int(f.current), int(f.spare)
run.step(1)
self.assertEqual(feeds, [cur, spare]) # current G, then the new G of hop 1
run.step(7)
middle = run._middle_kind
for g, leaf in zip(run.download(), (~p1.covered, middle == ACTIVE, p2.fine_kind == ACTIVE)):
self.assertTrue(np.isfinite(g[:, leaf]).all())
self.assertTrue(np.isnan(g[:, ~leaf]).all())
rc, rf, rg = raw(run)
self.assertTrue(np.isnan(rc[:, p1.covered]).all())
self.assertTrue(np.isfinite(rf[:, middle == ACTIVE]).all())
self.assertTrue(np.isnan(rf[:, middle == OUTER_GHOST]).all()) # incl. child-covered
self.assertTrue(np.isnan(rf[:, p2.covered]).all())
self.assertTrue(np.isfinite(rg[:, p2.fine_kind == ACTIVE]).all())
self.assertTrue(np.isnan(rg[:, p2.fine_kind == OUTER_GHOST]).all())
class ThreeLevelBodyGPU(unittest.TestCase):
"""Frozen gates; CPU mocks do not establish these numerical results."""
def run_case(self, mode, block, *, jets=(), spec=None, switch=False):
from CelerisLab.cuda.context import CudaContext
from CelerisLab.lbm.descriptors import SOLID
cfg = CFG if mode == "periodic" else replace(
CFG, velocity=0., wall_normal_velocity=.001, wall_slot_width=4,
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
with CudaContext(0) as ctx, FixedPatchStepper(
cfg, context=ctx, coarse_boundary=mode,
**levels(blocks=(B, B, block), objects=((), (), (child_body(spec),)),
jets=((), (), tuple(jets)))) as run:
run.initialize(*(state(*level.shape[1:], i + 1) for i, level in enumerate(run._levels)))
snapshots = []
for stop in (0, 1, 2, 4, 8):
run.step(stop - run.step_count)
if stop == 4 and switch:
run.set_jet_amplitude(0, -.002, level=2)
public, stored = run.download(), raw(run)
leaves = [~run.patch.covered, run._fine_leaf, run._child_leaf]
if mode != "periodic":
leaves[0][[0, -1]] = False
for g, mask in zip(public, leaves):
self.assertTrue(np.isfinite(g[:, mask]).all())
self.assertTrue(np.isnan(g[:, ~mask]).all())
invalid = [run.patch.covered.copy(), run._middle_kind == OUTER_GHOST,
(run.child_patch.fine_kind == OUTER_GHOST) |
((run.child_boundary.flags.reshape(run.child_patch.fine_shape) & SOLID) != 0)]
if mode != "periodic":
invalid[0][[0, -1]] = True
for g, mask in zip(stored, invalid):
self.assertTrue(np.isnan(g[:, mask]).all())
snapshots.append(public + tuple(stored))
return snapshots
def test_layouts_static_driven_switched_and_zero(self):
for mode in ("periodic", "slot_reservoir"):
cases = [self.run_case(mode, (4, 4), jets=jets, switch=switch)
for jets, switch in (((), False), ([child_jet()], False),
([child_jet()], True), ([child_jet(0.)], False))]
for a, b in zip(cases[0], cases[3]):
for x, y in zip(a, b):
np.testing.assert_array_equal(x, y)
self.assertFalse(np.array_equal(cases[1][-1][2], cases[2][-1][2], equal_nan=True))
for block in ((8, 4), (4, 8)):
for expected, (jets, switch) in zip(cases, (((), False), ([child_jet()], False),
([child_jet()], True), ([child_jet(0.)], False))):
actual = self.run_case(mode, block, jets=jets, switch=switch)
for a, b in zip(actual, expected):
for x, y in zip(a, b):
np.testing.assert_array_equal(x, y)
def test_legacy_surface_equivalence(self):
from CelerisLab.body.objects import SurfaceActuation
spec = SurfaceActuation(.7, .85, .002)
for mode in ("periodic", "slot_reservoir"):
legacy = self.run_case(mode, (4, 4), spec=spec)[-1][2]
driven = self.run_case(mode, (4, 4), jets=[spec.as_jet((8., 8.), .8)])[-1][2]
mask = np.isfinite(legacy)
np.testing.assert_array_equal(mask, np.isfinite(driven))
self.assertLessEqual(np.max(np.abs(legacy[mask] - driven[mask])), 1e-6)
if __name__ == "__main__":
unittest.main()