Compare commits

...
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 850b66862f examples: fixed_patch_circle reports its blocked tiles (fix unbound fine_block)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:25:22 +00:00
ClaudeandClaude Opus 5.5 fd8f1da9f5 examples: fixed-patch circle and jet run on all-blocked storage (M1)
Both public examples move off the dense refinement route, inside the scope
proven by the dense/blocked parity gate (1573942: two-level unshifted SRT,
periodic or slot/reservoir root, one circle/actuation or body-free):
- fixed_patch_jet: LEVEL_BLOCKS = [(12, 8), (8, 8)] (48x48 root, 56x40
  fine window), recorded in its report config;
- fixed_patch_circle: --base-block (default 12 8) and --fine-block
  (default 16 20); the dense 'omit --fine-block' option is gone.
Host test test_example_tiles: each example's tiles (read from the example
source without importing it) build every layout and table up to the first
CUDA call, for the circle in both coarse boundary modes.
No src change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:23:23 +00:00
ClaudeandClaude Opus 5.5 1573942673 test: ledger comparator compares labels exactly and numbers with NaN-equality
same_summary applied np.array_equal(equal_nan=True) to string metadata
('last_base_step'), a TypeError on the first D/B vs B/B ledger compare.
Numbers (bool/int/float/complex) still compare exactly with equal shapes
and NaN-equality; other values (window labels) compare by dtype and value.
New host test: a summary with string metadata equals itself, and changing
the window label, an integer, a nested float, a scalar or a NaN-bearing
array is detected in both directions. No assertion removed, no tolerance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:15:10 +00:00
ClaudeandClaude Opus 5.5 8db7cee9d5 test: dense/blocked route parity gate and route capability matrix
Test-only, on c6d5dc1. DenseBlockedRouteParity runs the same configuration,
initial state, body/Jet schedule and tilings on D/D [None, None], D/B
[None, FINE_BLOCK] and B/B [(12, 8), FINE_BLOCK], periodic and
slot/reservoir, for body-free, passive circle, actuated circle (+/-) and a
velocity Jet with an amplitude switch: every public and owner array must
match in FP32 bits and NaN masks at steps 0, 1, 2, 8 and after
re-initialize, and the D/B and B/B raw-exchange ledgers (last base step and
last substep) must be equal. No tolerance.

test_route_capability_matrix checks before CUDA what the dense route
rejects (raw exchange on a dense body level, shifted, projected, channel,
several L1 rectangles, depth >= 3 dense). The module docstring holds the
supported/unsupported table and the parked cases (MRT covered elsewhere,
shifted and depth >= 3 have no dense route, several bodies later).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:06:38 +00:00
4 changed files with 233 additions and 11 deletions
+11 -9
View File
@@ -1,9 +1,9 @@
"""Public API demo: fixed 2:1 patch with a stationary, optionally actuated circle.
From this checkout, after checking the selected physical GPU is idle:
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py --fine-block 16 20
... --speed -0.002 --fine-block 16 20 --mode slot_reservoir
Omit --fine-block for the (equivalent) dense fine storage.
PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_circle.py
... --speed -0.002 --mode slot_reservoir
Both levels use blocked storage (--base-block, --fine-block tiles).
A short smoke run (default 128 coarse steps): it shows setup, stepping and
fluid-only diagnostics, not a developed flow, accuracy or long-time stability.
@@ -71,8 +71,10 @@ def main():
parser.add_argument("--mode", choices=("periodic", "slot_reservoir"), default="periodic")
parser.add_argument("--speed", type=float, default=.002,
help="normal surface velocity, coarse lattice units (+ blows, - sucks, 0 passive)")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"),
help="blocked fine storage; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--base-block", type=int, nargs=2, metavar=("BY", "BX"), default=(12, 8),
help="root tile; must divide the 24 x 32 root")
parser.add_argument("--fine-block", type=int, nargs=2, metavar=("BY", "BX"), default=(16, 20),
help="fine tile; must divide the whole fine allocation (32 x 40 here)")
parser.add_argument("--steps", type=int, default=128)
args = parser.parse_args()
if args.steps < 0:
@@ -86,20 +88,20 @@ def main():
body = SimObject(0, geometry=CircleGeometry(*CENTER, RADIUS), center=CENTER, radius=RADIUS,
surface_actuation=spec)
cfg = config(args.mode)
fine_block = tuple(args.fine_block) if args.fine_block else None
# The caller owns the CUDA context; the stepper must be closed first (the
# nested with-statement does that).
with CudaContext(0) as context, FixedPatchStepper(
cfg, context=context, coarse_boundary=args.mode, refinements=[BOUNDS],
level_blocks=[None, fine_block], level_objects=[(), (body,)]) as run:
level_blocks=[tuple(args.base_block), tuple(args.fine_block)],
level_objects=[(), (body,)]) as run:
p = run.patch
# Rest equilibrium is already postcollision G, so no initial collision is
# needed. Non-leaf entries (covered, ghost, solid) are discarded by initialize.
rest = np.asarray(compute_feq_d2q9(1.0, 0.0, 0.0), np.float32)[:, None, None]
run.initialize(np.broadcast_to(rest, (9, *p.coarse_shape)),
np.broadcast_to(rest, (9, *p.fine_shape)))
storage = f"blocked {fine_block}" if fine_block else "dense"
print(f"mode={args.mode} fine storage={storage} speed={args.speed:+g} "
storage = f"blocked root {tuple(args.base_block)} fine {tuple(args.fine_block)}"
print(f"mode={args.mode} storage={storage} speed={args.speed:+g} "
f"(fine allocation {p.fine_shape[0]}x{p.fine_shape[1]}, fine origin {p.fine_origin})")
masks = leaf_masks(run, args.mode)
start = leaf_diagnostics(*run.download(), masks)
+5 -2
View File
@@ -31,6 +31,7 @@ from CelerisLab.lbm.refinement import ACTIVE
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
BOUNDS, AMPLITUDE, WIDTH = (16, 4, 32, 28), .02, 12
LEVEL_BLOCKS = [(12, 8), (8, 8)] # blocked storage: 48x48 root, 56x40 fine window
TIMES, MAX_MACH, MAX_SECONDS = (0, 300, 1200), .1, 120
@@ -132,7 +133,8 @@ def main():
lattice_model='D2Q9', collision='SRT', streaming='double_buffer',
store_precision='FP32', ddf_shifting=False, les_enabled=False)
report = dict(config=dict(nx=48, ny=48, bounds=BOUNDS, W=WIDTH, A=AMPLITUDE, nu=.1,
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir'),
rho=1, tau_coarse=.8, tau_fine=1.1, coarse_boundary='slot_reservoir',
level_blocks=LEVEL_BLOCKS),
guards=dict(check_every=50, positive_density=True, finite=True,
max_Mach=MAX_MACH, max_seconds=MAX_SECONDS,
timing='cooperative checks, not an exact interruption deadline'),
@@ -149,7 +151,8 @@ def main():
start, run = time.monotonic(), None
try:
with CudaContext(0) as context, FixedPatchStepper(cfg, context=context,
coarse_boundary='slot_reservoir', refinements=[BOUNDS]) as run:
coarse_boundary='slot_reservoir', refinements=[BOUNDS],
level_blocks=LEVEL_BLOCKS) as run:
p = run.patch
masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE]
masks[0][[0, -1]] = False
+65
View File
@@ -0,0 +1,65 @@
"""The public examples run all-blocked: their level tiles must reach the device unchanged.
Host only: a context whose activation raises proves construction (layouts, tables,
boundary checks) succeeded up to the first CUDA call.
"""
import ast
from dataclasses import replace
from pathlib import Path
import unittest
from unittest.mock import Mock
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper
EXAMPLES = Path(__file__).resolve().parents[2] / "examples"
def literal(name, constant):
"""Module-level literal from an example, read without importing it (no pycuda)."""
tree = ast.parse((EXAMPLES / name).read_text())
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
names = target.elts if isinstance(target, ast.Tuple) else [target]
for i, t in enumerate(names):
if isinstance(t, ast.Name) and t.id == constant:
value = node.value.elts[i] if isinstance(target, ast.Tuple) else node.value
return ast.literal_eval(value)
raise KeyError(constant)
def forbidden():
return Mock(activate=Mock(side_effect=AssertionError("CUDA reached")), sm_arch="sm_70")
class ExampleTiles(unittest.TestCase):
def test_fixed_patch_jet_tiles(self):
bounds, blocks = literal("fixed_patch_jet.py", "BOUNDS"), literal("fixed_patch_jet.py", "LEVEL_BLOCKS")
width, amplitude = literal("fixed_patch_jet.py", "WIDTH"), literal("fixed_patch_jet.py", "AMPLITUDE")
cfg = LBMConfig(nx=48, ny=48, viscosity=.1, velocity=0., wall_slot_width=width,
wall_normal_velocity=amplitude, y_wall_bc="on_site_velocity",
top_wall_bc="reservoir")
with self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary="slot_reservoir",
refinements=[bounds], level_blocks=[tuple(b) for b in blocks])
def test_fixed_patch_circle_default_tiles(self):
bounds = literal("fixed_patch_circle.py", "BOUNDS")
nx, ny = literal("fixed_patch_circle.py", "NX"), literal("fixed_patch_circle.py", "NY")
center, radius = literal("fixed_patch_circle.py", "CENTER"), literal("fixed_patch_circle.py", "RADIUS")
body = SimObject(0, geometry=CircleGeometry(*center, radius), center=center, radius=radius)
periodic = LBMConfig(nx=nx, ny=ny, viscosity=.1)
slot = replace(periodic, velocity=0, wall_normal_velocity=.001, wall_slot_width=8,
y_wall_bc="on_site_velocity", top_wall_bc="reservoir")
for mode, cfg in (("periodic", periodic), ("slot_reservoir", slot)):
with self.subTest(mode=mode), self.assertRaisesRegex(AssertionError, "CUDA reached"):
FixedPatchStepper(cfg, context=forbidden(), coarse_boundary=mode,
refinements=[bounds], level_blocks=[(12, 8), (16, 20)],
level_objects=[(), (body,)])
if __name__ == "__main__":
unittest.main()
@@ -2,6 +2,34 @@
Run CPU and GPU classes in separate processes. GPU callers must first check
resources/device availability; this file never selects or reserves a GPU.
Dense/blocked route parity (DenseBlockedRouteParity): the same configuration,
initial state, body/Jet schedule and tilings on three storage routes, only
level_blocks varying: D/D [None, None], D/B [None, FINE_BLOCK] (dense root,
the mixed pattern of current examples) and B/B [(12, 8), FINE_BLOCK]. Gate:
every public and owner array identical in FP32 bits and NaN masks at steps
0, 1, 2, 8 and after re-initialize; D/B and B/B raw-exchange ledgers equal.
No tolerance. A mismatch is reported with route pair/level/step and diagnosed
(boundary semantics vs arithmetic ordering), never widened.
Route capabilities (rejections checked before CUDA by test_route_capability_matrix):
combination D/D D/B B/B
periodic / slot_reservoir root, fine circle yes yes yes (parity gate)
actuated circle, velocity Jet + amplitude yes yes yes (parity gate)
raw/window exchange on a body level rejected yes yes
shifted storage rejected rejected yes
projected explosion rejected rejected yes
channel root rejected rejected yes
several L1 rectangles rejected rejected yes
depth >= 3 with a dense level rejected rejected yes
Slot/reservoir walls are compile-time WALL_* macros on a dense root and the
d_block_wall constant on a blocked root: BC-encoding parity, bit-identical by
this gate. Dense fine bodies use CurvedLinkSoA + CurvedBoundaryKernel
(n_objects > 0), blocked ones BlockCurvedBoundary; same links and physics,
compared through the population rows. Parked here: MRT (covered by
test_refinement_mrt_gpu::test_dense_versus_blocked_mrt_two_level), shifted and
depth >= 3 (no dense route to compare), several bodies (later slice).
"""
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import replace
@@ -20,6 +48,7 @@ from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import ACTIVE, OUTER_GHOST, FixedPatch2D
from CelerisLab.lbm.refinement_stepper import FixedPatchStepper, blocked_base_tables
from test_refinement_blocked_fine import NX, NY, BOUNDS, config, state, circle, actuation
from test_refinement_jets import SPEED, jet
BASE_BLOCKS = ((24, 32), (12, 8), (3, 4), (1, 1))
FINE_BLOCK = (16, 20)
@@ -324,5 +353,128 @@ class BlockedBaseGPU(unittest.TestCase):
print(f"PASS {label}: full finite bits/NaN masks at 0,1,2,8 + reset", flush=True)
ROUTES = (("D/D", [None, None]), ("D/B", [None, FINE_BLOCK]), ("B/B", [(12, 8), FINE_BLOCK]))
def same_summary(a, b):
"""Exact equality of nested exchange summaries (dicts, sequences, arrays, numbers)."""
if isinstance(a, dict):
return a.keys() == b.keys() and all(same_summary(a[k], b[k]) for k in a)
if isinstance(a, (list, tuple)):
return len(a) == len(b) and all(same_summary(x, y) for x, y in zip(a, b))
a, b = np.asarray(a), np.asarray(b)
if a.dtype.kind in "biufc" and b.dtype.kind in "biufc":
return a.shape == b.shape and np.array_equal(a, b, equal_nan=True)
return a.dtype == b.dtype and np.array_equal(a, b) # labels such as the window name
class DenseBlockedRouteCPU(unittest.TestCase):
def test_same_summary_compares_labels_and_every_number(self):
ledger = {"window": "last_base_step", "time_start": 3, "total": {"force": [1.0, -2.0],
"torque": 0.5}, "aperture": {"force": np.array([np.nan, 1.0])}}
self.assertTrue(same_summary(ledger, replace_value(ledger, ())))
for path, value in ((("window",), "last_substep"), (("time_start",), 4),
(("total", "force"), [1.0, -2.000001]), (("total", "torque"), 0.25),
(("aperture", "force"), np.array([0.0, 1.0]))):
with self.subTest(path=path):
self.assertFalse(same_summary(ledger, replace_value(ledger, path, value)))
self.assertFalse(same_summary(replace_value(ledger, path, value), ledger))
def test_route_capability_matrix(self):
context = Mock(activate=Mock(side_effect=AssertionError("CUDA reached")))
periodic = config("periodic")
inner = (BOUNDS[0] + 2, BOUNDS[1] + 2, BOUNDS[2] - 2, BOUNDS[3] - 2)
second = (BOUNDS[0], BOUNDS[1], BOUNDS[0] + 2, BOUNDS[1] + 2)
cases = (
("raw exchange on dense body level", periodic, dict(
level_blocks=[None, None], level_objects=[(), (circle(),)], observe_raw_exchange=True)),
("shifted D/D", replace(periodic, ddf_shifting=True), dict(level_blocks=[None, None])),
("shifted D/B", replace(periodic, ddf_shifting=True), dict(level_blocks=[None, FINE_BLOCK])),
("projected D/D", periodic, dict(level_blocks=[None, None], explosion="projected_linear")),
("projected D/B", periodic, dict(level_blocks=[None, FINE_BLOCK], explosion="projected_linear")),
("channel D/B", periodic, dict(level_blocks=[None, FINE_BLOCK], coarse_boundary="channel")),
("several L1 rectangles", periodic, dict(refinements=[[BOUNDS, second]], level_blocks=[None, None])),
("depth 3 dense", periodic, dict(refinements=[BOUNDS, inner], level_blocks=[None, None, None])),
)
for label, cfg, kw in cases:
kw.setdefault("refinements", [BOUNDS])
with self.subTest(label), self.assertRaises(ValueError):
FixedPatchStepper(cfg, context=context, **kw)
context.activate.assert_not_called()
def replace_value(tree, path, value=None):
"""Deep copy of a nested dict with the item at ``path`` replaced (no change for ())."""
if isinstance(tree, dict):
return {k: (value if path == (k,) else replace_value(v, path[1:], value) if path[:1] == (k,)
else replace_value(v, (), None)) for k, v in tree.items()}
return np.array(tree, copy=True) if isinstance(tree, np.ndarray) else tree
class DenseBlockedRouteParity(unittest.TestCase):
"""D/D vs D/B vs B/B, unshifted SRT, one circle or one velocity Jet; see module docstring."""
def run_routes(self, mode, objects=(), jets=(), switch=None):
from CelerisLab.cuda.context import CudaContext
cfg = config(mode)
p = FixedPatch2D.from_config(cfg, BOUNDS)
arrays = state(NY, NX, 1), state(*p.fine_shape, 2)
results = {}
with CudaContext(0) as context:
for name, blocks in ROUTES:
observe = bool(objects) and blocks[1] is not None
snapshots, ledgers = [], []
with FixedPatchStepper(cfg, context=context, coarse_boundary=mode, refinements=[BOUNDS],
level_blocks=blocks, level_objects=[(), tuple(objects)],
level_jets=[(), tuple(jets)],
observe_raw_exchange=observe) as backend:
backend.initialize(*arrays)
for step in (0, 1, 2, 8):
if step == 8 and switch is not None:
backend.set_jet_amplitude(0, switch)
backend.step(step - backend.step_count)
snapshots.append(backend.download() + raw(backend))
if observe and step:
ledgers.append(tuple(backend.read_raw_exchange(0, window=window)
for window in ("last_base_step", "last_substep")))
backend.initialize(*arrays)
snapshots.append(backend.download() + raw(backend))
results[name] = snapshots, ledgers
return results
def check(self, label, results):
reference, _ = results["D/D"]
for name in ("D/B", "B/B"):
for step, actual, expected in zip((0, 1, 2, 8, "reset"), results[name][0], reference):
for index, (a, b) in enumerate(zip(actual, expected)):
exact(self, a, b, f"{label} {name} vs D/D step={step} array={index}")
if results["D/B"][1]:
for step, a, b in zip((1, 2, 8), results["D/B"][1], results["B/B"][1]):
self.assertTrue(same_summary(a, b), f"{label} raw exchange D/B vs B/B step={step}")
for name, (snapshots, _) in results.items():
masses = [float(np.nansum(level.astype(np.float64))) for level in snapshots[3][:2]]
print(f"{label} {name}: step 8 mass by level {masses}", flush=True)
print(f"PASS {label}: D/D = D/B = B/B bits/NaN masks at 0,1,2,8 + reset", flush=True)
def test_body_free(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} body-free", self.run_routes(mode))
def test_passive_circle(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} circle", self.run_routes(mode, objects=[circle()]))
def test_actuated_circle(self):
for mode in ("periodic", "slot_reservoir"):
for speed in (SPEED, -SPEED):
self.check(f"{mode} actuated {speed}",
self.run_routes(mode, objects=[circle(actuation(speed))]))
def test_velocity_jet_with_amplitude_switch(self):
for mode in ("periodic", "slot_reservoir"):
self.check(f"{mode} jet switch",
self.run_routes(mode, objects=[circle()], jets=[jet()], switch=-SPEED))
if __name__ == "__main__":
unittest.main()