From cdbb9c42f6bfc65ec319313fb4e16cdc04dfdb95 Mon Sep 17 00:00:00 2001 From: Frank14f <1515444314@qq.com> Date: Thu, 15 Oct 2026 10:13:49 +0800 Subject: [PATCH] Add jet, surface and fixed-patch tests and runnable examples --- examples/fixed_patch_jet.py | 203 ++++ examples/fixed_periodic_patch.py | 76 ++ tests/integration/_cases.py | 24 + tests/integration/test_body_sync_e2e.py | 11 +- tests/integration/test_ddf_patch.py | 14 +- tests/integration/test_numerical_gpu.py | 219 +++++ .../integration/test_runtime_contracts_gpu.py | 97 ++ tests/integration/test_state_gpu.py | 181 ++++ .../integration/test_surface_actuation_gpu.py | 249 +++++ .../integration/test_sync_bodies_skeleton.py | 30 +- tests/integration/test_unified_obs.py | 35 +- tests/unit/cpp/collision_regression.cpp | 93 ++ tests/unit/cpp/planar_velocity.cpp | 30 + tests/unit/cpp/reservoir.cpp | 26 + tests/unit/cpp/semantic_regression.cpp | 238 +++++ tests/unit/cpp/slot_profile.cpp | 10 + tests/unit/test_build_contracts.py | 152 +++ tests/unit/test_context_ownership.py | 90 ++ tests/unit/test_fine_surface.py | 215 +++++ tests/unit/test_initializers.py | 68 ++ tests/unit/test_numerical_operators.py | 140 +++ tests/unit/test_planar_velocity.py | 95 ++ tests/unit/test_refinement.py | 167 ++++ tests/unit/test_reservoir.py | 90 ++ tests/unit/test_simulation_lifecycle.py | 115 +++ tests/unit/test_slot_velocity.py | 81 ++ tests/unit/test_state_regressions.py | 517 ++++++++++ tests/unit/test_surface_actuation.py | 230 +++++ tests/validation/analyze_j3_saved_frames.py | 129 +++ tests/validation/diagnose_j3_lags.py | 276 ++++++ tests/validation/multigrid/cpu_reference.py | 475 +++++++++ .../multigrid/cpu_reference_v0.2.py | 441 +++++++++ .../multigrid/local_stress_observable_v1.py | 902 ++++++++++++++++++ .../multigrid/shear_diagnostic_v0_2.py | 790 +++++++++++++++ .../multigrid/test_cpu_reference.py | 481 ++++++++++ .../multigrid/test_fine_surface_gpu.py | 330 +++++++ .../test_open_corner_negative_control.py | 68 ++ .../multigrid/test_refinement_followup.py | 366 +++++++ .../multigrid/test_refinement_gpu.py | 221 +++++ .../multigrid/test_tron_additions.py | 229 +++++ tests/validation/run_j1.py | 248 +++++ tests/validation/run_j2_slot.py | 164 ++++ tests/validation/run_j3_coverage.py | 342 +++++++ tests/validation/run_j3_reservoir.py | 81 ++ .../run_kan99b_rotating_cylinder.py | 129 ++- tests/validation/run_poiseuille.py | 124 +++ tests/validation/run_sah04_st_matrix.py | 169 ++-- tests/validation/test_sensor_accuracy.py | 16 +- 48 files changed, 9267 insertions(+), 210 deletions(-) create mode 100644 examples/fixed_patch_jet.py create mode 100644 examples/fixed_periodic_patch.py create mode 100644 tests/integration/_cases.py create mode 100644 tests/integration/test_numerical_gpu.py create mode 100644 tests/integration/test_runtime_contracts_gpu.py create mode 100644 tests/integration/test_state_gpu.py create mode 100644 tests/integration/test_surface_actuation_gpu.py create mode 100644 tests/unit/cpp/collision_regression.cpp create mode 100644 tests/unit/cpp/planar_velocity.cpp create mode 100644 tests/unit/cpp/reservoir.cpp create mode 100644 tests/unit/cpp/semantic_regression.cpp create mode 100644 tests/unit/cpp/slot_profile.cpp create mode 100644 tests/unit/test_build_contracts.py create mode 100644 tests/unit/test_context_ownership.py create mode 100644 tests/unit/test_fine_surface.py create mode 100644 tests/unit/test_initializers.py create mode 100644 tests/unit/test_numerical_operators.py create mode 100644 tests/unit/test_planar_velocity.py create mode 100644 tests/unit/test_refinement.py create mode 100644 tests/unit/test_reservoir.py create mode 100644 tests/unit/test_simulation_lifecycle.py create mode 100644 tests/unit/test_slot_velocity.py create mode 100644 tests/unit/test_state_regressions.py create mode 100644 tests/unit/test_surface_actuation.py create mode 100644 tests/validation/analyze_j3_saved_frames.py create mode 100644 tests/validation/diagnose_j3_lags.py create mode 100644 tests/validation/multigrid/cpu_reference.py create mode 100644 tests/validation/multigrid/cpu_reference_v0.2.py create mode 100644 tests/validation/multigrid/local_stress_observable_v1.py create mode 100644 tests/validation/multigrid/shear_diagnostic_v0_2.py create mode 100644 tests/validation/multigrid/test_cpu_reference.py create mode 100644 tests/validation/multigrid/test_fine_surface_gpu.py create mode 100644 tests/validation/multigrid/test_open_corner_negative_control.py create mode 100644 tests/validation/multigrid/test_refinement_followup.py create mode 100644 tests/validation/multigrid/test_refinement_gpu.py create mode 100644 tests/validation/multigrid/test_tron_additions.py create mode 100644 tests/validation/run_j1.py create mode 100644 tests/validation/run_j2_slot.py create mode 100644 tests/validation/run_j3_coverage.py create mode 100644 tests/validation/run_j3_reservoir.py create mode 100644 tests/validation/run_poiseuille.py diff --git a/examples/fixed_patch_jet.py b/examples/fixed_patch_jet.py new file mode 100644 index 0000000..753e003 --- /dev/null +++ b/examples/fixed_patch_jet.py @@ -0,0 +1,203 @@ +"""Low-Re startup flow in an x-periodic confined domain; nozzle remains coarse. + +From this checkout, after checking the selected physical GPU is idle: + PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_patch_jet.py --output /tmp/patch-jet + +The fixed-amplitude start includes acoustic transients, not a developed jet. +Only native leaf cells are plotted. No interpolation, restart or flow oracle. +Scale estimates use the continuous peak A=.02, not inlet mean speed: W/A=600, +H/A=2250, W**2/nu=1440, H**2/nu=20250, where H=NY-3=45 between on-site planes. +An acoustic crossing (~78 steps) does not imply that transients have disappeared. +Mach is sampled over all leaves every 50 steps and is usually inlet dominated; +the discrete inlet peak is slightly below continuous A. This is not an accuracy test. +The 120s checks are cooperative, not an exact interruption deadline. main elapsed +includes construction, advancement and plotting; it is not pure CFD timing. +""" +import argparse +from dataclasses import replace +import json +import os +from pathlib import Path +import tempfile +import time + +import numpy as np + +import CelerisLab +from CelerisLab.config import load_lbm_config +from CelerisLab.cuda.context import CudaContext +from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf +from CelerisLab.lbm.refinement import ACTIVE +from CelerisLab.lbm.refinement_stepper import FixedPatchStepper + +BOUNDS, AMPLITUDE, WIDTH = (16, 4, 32, 28), .02, 12 +TIMES, MAX_MACH, MAX_SECONDS = (0, 300, 1200), .1, 120 + + +def measure(arrays, masks, centers, initial_mass): + fields = [] + for array, mask in zip(arrays, masks): + if not np.isfinite(array[:, mask]).all(): + raise RuntimeError("Nonfinite leaf populations") + field = np.full((*mask.shape, 3), np.nan) + field[mask] = [compute_macro_from_ddf(f) for f in array[:, mask].T] + fields.append(field) + leaves = np.concatenate([f[m] for f, m in zip(fields, masks)]) + speed = np.hypot(leaves[:, 1], leaves[:, 2]) + mass = sum(area*a[:, m].sum(dtype=np.float64) + for area, a, m in zip((1, .25), arrays, masks)) + record = dict(finite=bool(np.isfinite(leaves).all()), rho_min=float(leaves[:, 0].min()), + rho_max=float(leaves[:, 0].max()), speed_max=float(speed.max()), + sampled_max_Mach=float(speed.max()*np.sqrt(3)), + density_deviation=float(np.abs(leaves[:, 0]-1).max()), + discrete_leaf_mass=float(mass), mass_change=float(mass-initial_mass)) + if not record['finite'] or record['rho_min'] <= 0 or record['sampled_max_Mach'] > MAX_MACH: + raise RuntimeError(f"Finite/positive-density/Ma<={MAX_MACH} guard failed: {record}") + walls = fields[0][[1, -2]] + record['macro_sum_rho_v'] = dict(zip(('Q_in', 'Q_out'), + (walls[:, :, 0]*walls[:, :, 2]).sum(axis=1).tolist())) + record['centerline'] = [] + for level, y in ((1, 16.25), (0, 32.5)): + xs, ys = centers[level] + columns = np.argsort(np.abs(xs-24))[:2] + row = int(np.flatnonzero(ys == y)[0]) + values = fields[level][row, columns].mean(axis=0) + record['centerline'].append(dict(level=level, x=24, y=y, + sampled_x=sorted(xs[columns].tolist()), rule='arithmetic mean of two neighboring native columns', + rho_ux_uy=values.tolist())) + return fields, record + + +def draw(out, snapshots, masks, centers): + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + from matplotlib.collections import PolyCollection + + def decorate(ax): + ax.set(xlim=(0, 48), ylim=(1.5, 46.5), aspect='equal', xlabel='X', ylabel='Y') + ax.plot([16, 32, 32, 16, 16], [4, 4, 28, 28, 4], color='tab:red', lw=1) + ax.axhline(1.5, color='black', lw=1) + ax.axhline(46.5, color='black', lw=1, ls='--') + ax.plot([18, 30], [1.5, 1.5], color='tab:orange', lw=4, clip_on=False) + + fig, ax = plt.subplots(figsize=(6, 6), constrained_layout=True) + for mask, (xs, ys), dx in zip(masks, centers, (1, .5)): + yy, xx = np.meshgrid(ys, xs, indexing='ij') + cells = np.stack((xx[mask], yy[mask]), axis=-1) + vertices = cells[:, None, :] + dx*np.array([[-.5, -.5], [.5, -.5], [.5, .5], [-.5, .5]]) + ax.add_collection(PolyCollection(vertices, facecolors='none', edgecolors='0.55', linewidths=.3)) + decorate(ax) + ax.set_title('Native leaf grid: coarse dx=1, fine dx=0.5\n' + 'Red: internal patch; orange: coarse slot; dashed: reservoir\n' + 'Flow may cross patch sides; patch does not contain all startup flow', fontsize=10) + fig.savefig(out/'layout.png', dpi=150, bbox_inches='tight', pad_inches=.15) + plt.close(fig) + vmax = max(1., *(np.nanmax(np.hypot(f[..., 1], f[..., 2]))/AMPLITUDE + for fields in snapshots for f in fields)) + fig, axes = plt.subplots(1, 3, figsize=(12, 4.8), constrained_layout=True) + for ax, step, fields in zip(axes, TIMES, snapshots): + for field, mask, (xs, ys), dx in zip(fields, masks, centers, (1, .5)): + speed = np.ma.masked_where(~mask, np.hypot(field[..., 1], field[..., 2])/AMPLITUDE) + image = ax.pcolormesh(np.r_[xs-dx/2, xs[-1]+dx/2], + np.r_[ys-dx/2, ys[-1]+dx/2], speed, + shading='flat', cmap='viridis', vmin=0, vmax=vmax, rasterized=True) + stride = int(4/dx) + yy, xx = np.meshgrid(ys, xs, indexing='ij') + selection = np.zeros_like(mask) + selection[::stride, ::stride] = mask[::stride, ::stride] + ax.quiver(xx[selection], yy[selection], field[..., 1][selection], field[..., 2][selection], + angles='xy', scale_units='xy', scale=AMPLITUDE/3, width=.005, color='white') + decorate(ax) + ax.set_title(f'coarse t={step}, fine steps={2*step}') + fig.colorbar(image, ax=axes, label='speed / imposed amplitude') + fig.suptitle('Low-Re startup flow in an x-periodic confined domain\n' + 'Fixed-amplitude start includes acoustic transients; not steady/developed\n' + 'Nozzle remains coarse; flow may cross patch sides (not fully enclosed)', fontsize=11) + fig.savefig(out/'velocity.png', dpi=150) + plt.close(fig) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + out = parser.parse_args().output.resolve() + root = Path(__file__).resolve().parents[1] + if Path(CelerisLab.__file__).resolve() != root/'src/CelerisLab/__init__.py': + raise RuntimeError(f'Set PYTHONPATH to {root / "src"}') + out.mkdir(parents=True, exist_ok=False) + cfg = replace(load_lbm_config(str(root/'src/CelerisLab/configs/config_lbm.json')), + nx=48, ny=48, viscosity=.1, velocity=0., rho=1., wall_slot_width=WIDTH, + wall_normal_velocity=AMPLITUDE, y_wall_bc='on_site_velocity', top_wall_bc='reservoir', + 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'), + 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'), + scale_estimates=dict(velocity_basis='continuous peak A, not inlet mean', H=cfg.ny-3, + W_over_A=WIDTH/AMPLITUDE, H_over_A=(cfg.ny-3)/AMPLITUDE, + W_squared_over_nu=WIDTH**2/cfg.viscosity, + H_squared_over_nu=(cfg.ny-3)**2/cfg.viscosity, + acoustic_crossing=(cfg.ny-3)*np.sqrt(3)), + notes='Node mass uses areas 1 and 1/4, not an exact physical volume integral. ' + 'Q_in/Q_out are bottom/top macro sum(rho*vy), not a closed population budget. ' + 'Mach maxima are sampled every 50 steps, not every-step peaks. ' + 'One acoustic crossing does not imply transient decay; no steady-state claim.', + samples=[], status='running', last_synchronized_coarse_step=0) + start, run = time.monotonic(), None + try: + with CudaContext(0) as context, FixedPatchStepper(cfg, BOUNDS, context=context, + coarse_boundary='slot_reservoir') as run: + p = run.patch + masks = [np.ones(p.coarse_shape, bool), p.fine_kind == ACTIVE] + masks[0][[0, -1]] = False + masks[0][4:28, 16:32] = False + centers = [(origin[0]+dx*(np.arange(shape[1])+.5), + origin[1]+dx*(np.arange(shape[0])+.5)) + for shape, origin, dx in ((p.coarse_shape, (0, 0), 1), + (p.fine_shape, p.fine_origin, .5))] + initial = [np.broadcast_to(compute_feq_d2q9(1., 0., 0.)[:, None, None], (9, *m.shape)).copy() + for m in masks] + for x in range(cfg.nx): + v = AMPLITUDE*max(0., 1-((2*x-(cfg.nx-1))/WIDTH)**2)**2 + initial[0][:, 1, x] = compute_feq_d2q9(1., 0., v) + run.initialize(*initial) + mass0 = sum(area*a[:, m].sum(dtype=np.float64) + for area, a, m in zip((1, .25), initial, masks)) + snapshots = [] + for step in range(0, TIMES[-1]+1, 50): + if time.monotonic()-start > MAX_SECONDS: + raise TimeoutError('120 second demonstration limit reached') + if step: + run.step(50) + report['last_synchronized_coarse_step'] = run.step_count + if time.monotonic()-start > MAX_SECONDS: + raise TimeoutError('120 second demonstration limit reached after chunk') + fields, sample = measure(run.download(), masks, centers, mass0) + sample.update(t=step, coarse_steps=step, fine_steps=2*step) + report['samples'].append(sample) + print(json.dumps(sample), flush=True) + if step in TIMES: + snapshots.append(fields) + with tempfile.TemporaryDirectory(prefix='.plot-', dir=out) as cache: + os.environ['MPLCONFIGDIR'] = cache + draw(out, snapshots, masks, centers) + if time.monotonic()-start > MAX_SECONDS: + raise TimeoutError('120 second demonstration limit reached after plotting') + report['status'] = 'complete' + except BaseException as exc: + report.update(status='failed', error=f'{type(exc).__name__}: {exc}', + last_synchronized_coarse_step=run.step_count if run is not None else 0) + raise + finally: + report['elapsed_seconds'] = time.monotonic()-start + report['elapsed_scope'] = 'main construction, advancement and plotting; not pure CFD time' + (out/'summary.json').write_text(json.dumps(report, indent=2)+'\n') + print(f'{report["status"]}: last synchronized coarse step ' + f'{report["last_synchronized_coarse_step"]}; output={out}', flush=True) + + +if __name__ == '__main__': + main() diff --git a/examples/fixed_periodic_patch.py b/examples/fixed_periodic_patch.py new file mode 100644 index 0000000..ac73272 --- /dev/null +++ b/examples/fixed_periodic_patch.py @@ -0,0 +1,76 @@ +"""Public API demo: small periodic shear, one fixed 2:1 patch, eight coarse steps. + +From this checkout, after checking the selected physical GPU is idle: + PYTHONPATH="$PWD/src" CUDA_VISIBLE_DEVICES=0 python examples/fixed_periodic_patch.py + +This demonstrates initialization/stepping/leaf output, not interface accuracy. +There are no walls, objects, forces, checkpoint files or plots. +""" +from dataclasses import replace +from pathlib import Path + +import numpy as np + +import CelerisLab +from CelerisLab.config import load_lbm_config +from CelerisLab.cuda.context import CudaContext +from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf +from CelerisLab.lbm.refinement import ACTIVE +from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper + + +def shear_equilibrium(shape, origin, dx, period): + """Sample ux(y)=1e-3*sin(2*pi*y/period) at physical cell centers. + + Centers are origin + dx*(index+.5); this shear is independent of x. + Fine origin includes patch offset AND the four-cell halo, with dx=.5. + Equilibrium is already postcollision G, so no initial collision is needed. + """ + y = origin[1] + dx * (np.arange(shape[0]) + .5) + ux = 1e-3 * np.sin(2*np.pi*y/period) + rows = np.stack([compute_feq_d2q9(1.0, float(u), 0.0) for u in ux], axis=1) + return np.repeat(rows[:, :, None], shape[1], axis=2) + + +def leaf_summary(coarse, fine, coarse_leaf, fine_leaf): + """Use the same leaf masks/areas for initial and final mass.""" + c, f = coarse[:, coarse_leaf], fine[:, fine_leaf] + mass = c.sum(dtype=np.float64) + .25*f.sum(dtype=np.float64) + macros = np.array([compute_macro_from_ddf(v) for v in np.concatenate((c, f), axis=1).T]) + return mass, macros.min(axis=0), macros.max(axis=0) + + +def main(): + root = Path(__file__).resolve().parents[1] + if Path(CelerisLab.__file__).resolve() != root / "src/CelerisLab/__init__.py": + raise RuntimeError(f"Set PYTHONPATH to {root / 'src'}") + cfg = replace(load_lbm_config(str(root / "src/CelerisLab/configs/config_lbm.json")), + nx=16, ny=16, viscosity=.1, velocity=1e-3, rho=1.0, + lattice_model="D2Q9", collision="SRT", streaming="double_buffer", + store_precision="FP32", ddf_shifting=False, les_enabled=False) + with CudaContext(0) as context, PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as run: + p = run.patch + coarse = shear_equilibrium(p.coarse_shape, (0, 0), 1.0, cfg.ny) + fine = shear_equilibrium(p.fine_shape, p.fine_origin, .5, cfg.ny) + coarse_leaf = np.ones(p.coarse_shape, dtype=bool) + x0, y0, x1, y1 = p.bounds + coarse_leaf[y0:y1, x0:x1] = False + fine_leaf = p.fine_kind == ACTIVE + coarse[:, ~coarse_leaf] = np.nan + fine[:, ~fine_leaf] = np.nan + run.initialize(coarse, fine) + initial_mass, _, _ = leaf_summary(*run.download(), coarse_leaf, fine_leaf) + run.step(8) + coarse, fine = run.download() # Both levels synchronized, stored postcollision G. + assert np.isnan(coarse[:, ~coarse_leaf]).all() and np.isnan(fine[:, ~fine_leaf]).all() + assert np.isfinite(coarse[:, coarse_leaf]).all() and np.isfinite(fine[:, fine_leaf]).all() + mass, low, high = leaf_summary(coarse, fine, coarse_leaf, fine_leaf) + print(f"periodic patch API demo: synchronized coarse_steps={run.step_count}, fine_steps={2*run.step_count}") + print(f"leaf mass: initial={initial_mass:.12g} final={mass:.12g} drift={mass-initial_mass:+.6e}") + for name, lo, hi in zip(("rho", "ux", "uy"), low, high): + print(f"leaf {name}: [{lo:.9g}, {hi:.9g}]") + print("covered coarse / fine halo: NaN; functional demonstration only") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/_cases.py b/tests/integration/_cases.py new file mode 100644 index 0000000..7db38ee --- /dev/null +++ b/tests/integration/_cases.py @@ -0,0 +1,24 @@ +"""Explicit, isolated low-Mach GPU test configuration.""" +import json +from pathlib import Path +from tempfile import TemporaryDirectory + +from CelerisLab.simulation import Simulation + + +def make_sim(nx=128, ny=64, *, streaming="double_buffer", precision="FP32", + shifted=False, collision="SRT", y_wall="bounce_back", **physics): + config_path = Path(__file__).resolve().parents[2] / "src/CelerisLab/configs/config_lbm.json" + cfg = json.loads(config_path.read_text()) + cfg["grid"].update(nx=nx, ny=ny, nz=1, lattice_model="D2Q9") + cfg["physics"].update(viscosity=0.05, velocity=0.02, rho=1.0) + cfg["physics"].update(physics) + cfg["method"].update(collision=collision, streaming=streaming, + store_precision=precision, ddf_shifting=shifted, y_wall_bc=y_wall) + cfg["method"]["les"]["enabled"] = False + with TemporaryDirectory(prefix="celeris-test-config-") as temp: + lbm = Path(temp) / "lbm.json" + bodies = Path(temp) / "bodies.json" + lbm.write_text(json.dumps(cfg)) + bodies.write_text('{"objects": []}') + return Simulation(lbm_config_path=str(lbm), body_config_path=str(bodies)) diff --git a/tests/integration/test_body_sync_e2e.py b/tests/integration/test_body_sync_e2e.py index cebbf8a..295e504 100644 --- a/tests/integration/test_body_sync_e2e.py +++ b/tests/integration/test_body_sync_e2e.py @@ -7,9 +7,9 @@ import unittest import tempfile import numpy as np -import pycuda.autoinit from CelerisLab.simulation import Simulation +from ._cases import make_sim from CelerisLab.lbm.descriptors import OBSTACLE, FLUID @@ -18,7 +18,7 @@ class TestBodySyncE2E(unittest.TestCase): def test_full_lifecycle(self): """Create, init, run, add body, remove body, checkpoint, load.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 @@ -61,7 +61,7 @@ class TestBodySyncE2E(unittest.TestCase): self.assertTrue(os.path.exists(saved_path)) # 7. Load checkpoint in a new simulation - sim2 = Simulation(device_id=0) + sim2 = make_sim() sim2.initialize() sim2.run(1) @@ -71,9 +71,10 @@ class TestBodySyncE2E(unittest.TestCase): # 8. Continue running in restored sim sim2.run(50) - # Verify step count advances (DDF may have NaN from pre-existing body) + # Verify the restored field remains finite and stepping advances self.assertEqual(sim2.stepper.step_count, sim.stepper.step_count + 50) + self.assertTrue(np.all(np.isfinite(sim2.get_ddf()))) sim2.close() finally: os.unlink(ckpt_path) @@ -82,7 +83,7 @@ class TestBodySyncE2E(unittest.TestCase): def test_add_remove_add_cycle(self): """Add → run → remove → run → add → run cycle with finite checks.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 diff --git a/tests/integration/test_ddf_patch.py b/tests/integration/test_ddf_patch.py index 07e8b9e..7ab0104 100644 --- a/tests/integration/test_ddf_patch.py +++ b/tests/integration/test_ddf_patch.py @@ -5,9 +5,9 @@ Requires GPU.""" import unittest import numpy as np -import pycuda.autoinit from CelerisLab.simulation import Simulation +from ._cases import make_sim from CelerisLab.lbm.descriptors import FLUID, OBSTACLE @@ -17,7 +17,7 @@ class TestDDFPatchAddBody(unittest.TestCase): def test_add_body_runs_stably(self): """After adding a body, the simulation runs with finite forces for a moderate number of steps.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 @@ -44,7 +44,7 @@ class TestDDFPatchRemoveBody(unittest.TestCase): def test_remove_body_released_region_is_fluid(self): """After removal, the former body center should be a fluid cell.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 @@ -63,7 +63,7 @@ class TestDDFPatchRemoveBody(unittest.TestCase): def test_remove_body_finite_field(self): """After removal, the macroscopic field is finite.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 @@ -91,7 +91,7 @@ class TestDDFPatchAddAndRemove(unittest.TestCase): def test_add_one_remove_another(self): """Add a body and remove a different one in the same sync.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny cx, cy = nx // 4, ny // 2 @@ -119,8 +119,8 @@ class TestDDFPatchNoChange(unittest.TestCase): def test_no_mask_no_change(self): """When neither mask has any True entries, DDF should be unchanged.""" - sim = Simulation(device_id=0) - sim.add_body("circle", center=(128, 128), radius=8) + sim = make_sim() + sim.add_body("circle", center=(sim.lbm_cfg.nx // 4, sim.lbm_cfg.ny // 2), radius=8) sim.initialize() sim.run(100) diff --git a/tests/integration/test_numerical_gpu.py b/tests/integration/test_numerical_gpu.py new file mode 100644 index 0000000..bf52ff0 --- /dev/null +++ b/tests/integration/test_numerical_gpu.py @@ -0,0 +1,219 @@ +"""Bounded GPU invariants. No high-Re/benchmark-performance claim. + +Compare spatial postcollision populations, not DB macro fields against EsoPull's +next-pull diagnostics. Closed-channel evolution starts at completed count 1 with +an explicitly identical spatial state. The distinct t=0 init contract is tested +separately through the actual initialization and wall staging kernels. +""" +import numpy as np +import pytest +import pycuda.driver as cuda + +from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID, BC_WALL +from ._cases import make_sim + +CX = np.asarray(D2Q9_EX) +CY = np.asarray(D2Q9_EY) +W = np.asarray([4/9]+[1/9]*4+[1/36]*4, dtype=np.float32) + + +def equilibrium(rho, ux, uy): + cu = CX[:, None, None]*ux + CY[:, None, None]*uy + return (W[:, None, None]*rho*(1+3*cu+4.5*cu*cu-1.5*(ux*ux+uy*uy))).astype(np.float32) + + +def postcollision_to_backing(values, completed_count): + """Independent spatial encoder for the previous advancing-store contract.""" + previous = completed_count-1 + result = np.empty_like(values) + result[0] = values[0] + for i in range(1,9,2): + result[i+1 if previous % 2 else i] = np.roll(values[i], (CY[i],CX[i]), (0,1)) + result[i if previous % 2 else i+1] = values[i+1] + return result + + +def backing_to_postcollision(values, completed_count): + previous = completed_count-1 + result = np.empty_like(values) + result[0] = values[0] + for i in range(1,9,2): + result[i] = np.roll(values[i+1 if previous % 2 else i], (-CY[i],-CX[i]), (0,1)) + result[i+1] = values[i if previous % 2 else i+1] + return result + + +def install_spatial_state(sim, physical, count=1): + field = sim.field + data = physical-W[:,None,None] if sim.lbm_cfg.ddf_shifting else physical.copy() + if sim.lbm_cfg.streaming == "esopull": + data = postcollision_to_backing(data,count) + with sim.ctx.activate(): + cuda.memcpy_htod(field.ddf_gpu,np.ascontiguousarray(data,dtype=np.float32)) + cuda.memcpy_htod(field.temp_gpu,np.ascontiguousarray(data,dtype=np.float32)) + sim.stepper._step_count=count + field.invalidate_host_ddf_cache() + # x is periodic; no ghost inlet/outlet donor algorithm is under test. + field.flag[:]=FLUID + field.flag[:field.nx]=SOLID|BC_WALL + field.flag[-field.nx:]=SOLID|BC_WALL + field.upload_flags() + + +def spatial_state(sim): + field=sim.field + data=np.empty((9,field.ny,field.nx),dtype=np.float32) + with sim.ctx.activate(): + cuda.memcpy_dtoh(data,field.ddf_gpu) + if sim.lbm_cfg.streaming == "esopull": + data=backing_to_postcollision(data,sim.stepper.step_count) + if sim.lbm_cfg.ddf_shifting: + data=data+W[:,None,None] + return data + + +@pytest.mark.parametrize("collision,shifted,wall", [ + ("SRT",False,"bounce_back"), ("TRT",True,"bounce_back"), + ("MRT",False,"free_slip"), ("MRT",True,"bounce_back"), +]) +def test_nonuniform_closed_channel_matches_between_layouts(collision,shifted,wall): + traces=[] + for streaming in ["double_buffer","esopull"]: + sim=make_sim(nx=32,ny=16,streaming=streaming,shifted=shifted, + collision=collision,y_wall=wall) + try: + sim.initialize() + y,x=np.mgrid[:16,:32] + physical=equilibrium(1+.001*np.cos(.3*x+.2*y), + .01*np.sin(.2*x+.3*y),.005*np.cos(.3*x-.2*y)) + install_spatial_state(sim,physical,count=1) + trace=[] + for _ in range(4): # first launch, both parities, both adjacent rows + sim.run(1) + trace.append(spatial_state(sim)[:,1:-1,:]) + traces.append(trace) + finally: + sim.close() + # FP32 local arithmetic/roundoff; old donor-stage errors are much larger. + np.testing.assert_allclose(traces[0],traces[1],atol=2e-6,rtol=0) + + +@pytest.mark.parametrize("shifted",[False,True]) +@pytest.mark.parametrize("wall",["bounce_back","free_slip"]) +def test_actual_initialization_wall_staging(shifted,wall): + sim=make_sim(nx=16,ny=32,streaming="esopull",shifted=shifted,y_wall=wall) + try: + sim.initialize() + field=sim.field + cells=np.arange(field.n,dtype=np.uint32) + scratch, physical, indices=field.esopull_sync_decode(cells,0) + physical=physical.reshape(field.n,9).T.reshape(9,32,16).copy() + with sim.ctx.activate(): + scratch.free();indices.free() + sim.stepper.prepare_boundaries_fn(field.ddf_gpu,sim.stepper.boundary_values_gpu, + np.uint64(0),block=sim.stepper.block,grid=sim.stepper.boundary_grid) + prepared=np.empty(6*16+2*32*9,dtype=np.float32) + cuda.memcpy_dtoh(prepared,sim.stepper.boundary_values_gpu) + staged=prepared[:6*16].reshape(2,16,3) + for side,row in [(0,1),(1,30)]: + donors=[4,6,7] if side==0 else [3,5,8] + if wall=="free_slip": donors[1:]=donors[1:][::-1] + expected=physical[donors,row,:].T + if shifted: expected=expected-W[donors] + np.testing.assert_allclose(staged[side],expected,atol=1e-7,rtol=0) + donors=prepared[6*16:].reshape(2,32,9) + for side,x in [(0,1),(1,14)]: + expected=physical[:,:,x].T + if shifted: expected=expected-W + np.testing.assert_allclose(donors[side],expected,atol=1e-7,rtol=0) + sim.run(1) # actual first step consumes the staged values without a race + assert np.isfinite(spatial_state(sim)).all() + finally: + sim.close() + + +@pytest.mark.parametrize("collision",["SRT","TRT","MRT"]) +@pytest.mark.parametrize("streaming",["double_buffer","esopull"]) +def test_uniform_global_force_momentum(collision,streaming): + sim=make_sim(nx=32,ny=16,collision=collision,streaming=streaming, + shifted=collision!="SRT") + try: + sim.initialize() + initial=equilibrium(np.full((16,32),1.17),np.full((16,32),.03),np.full((16,32),-.02)) + install_spatial_state(sim,initial) + sim.field.update_params(fx=.001,fy=-.0007) + sim.run(1) + # Stay outside the wall influence region. Global force is uniform. + delta=(spatial_state(sim)-initial)[:,3:-3,3:-3] + np.testing.assert_allclose(delta.sum(axis=0),0,atol=5e-7,rtol=0) + np.testing.assert_allclose(np.einsum('q,qyx->yx',CX,delta),.001,atol=2e-7,rtol=0) + np.testing.assert_allclose(np.einsum('q,qyx->yx',CY,delta),-.0007,atol=2e-7,rtol=0) + finally: + sim.close() + + +@pytest.mark.parametrize("streaming",["double_buffer","esopull"]) +@pytest.mark.parametrize("shifted",[False,True]) +def test_force_region_impulse_budget(streaming,shifted): + sim=make_sim(nx=32,ny=16,streaming=streaming,shifted=shifted,collision="MRT") + try: + sim.add_body("force_region",center=(16,8),radius=2) + sim.initialize() + initial=equilibrium(np.full((16,32),1.17),np.full((16,32),.03),np.full((16,32),-.02)) + install_spatial_state(sim,initial) + sim.set_force(0,fx=.001,fy=-.0007) + sim.run(1) + # The region postpass uses the public layout-specific current-state + # convention. Sum global momentum so transport cannot move the target + # footprint outside a diagnostic slice. Subtract an identical zero-force + # run to cancel wall impulse and baseline FP32 collision roundoff. + forced=spatial_state(sim) + install_spatial_state(sim,initial) + sim.set_force(0,fx=0,fy=0) + sim.run(1) + delta=forced-spatial_state(sim) + count=sim.field.n_force_region + assert count>0 + # Budget scales with touched cells and FP32 subtraction error. + tol=3e-7*count + assert abs(delta.sum(dtype=np.float64))',CX,delta)-count*.001)',CY,delta)+count*.0007) .5 + assert np.max(macro['rho'][fluid]) < 1.5 + + +@pytest.mark.parametrize('streaming',['double_buffer','esopull']) +def test_checkpoint_exact_continuation_and_controls(streaming,tmp_path): + with make_sim(nx=48,ny=24,streaming=streaming,shifted=True) as sim: + sim.add_body('circle',center=(14,12),radius=3) + sim.add_body('force_region',center=(30,12),radius=2) + sim.initialize() + sim.set_force(1,fx=1e-5,fy=-2e-6) + sim.set_body(0,omega=.0002) + sim.run(3) + saved = sim.get_ddf() + path = sim.save_checkpoint(str(tmp_path/'restart.h5')) + sim.run(4) + expected = sim.get_ddf() + sim.set_force(1,fx=0,fy=0) + sim.set_body(0,omega=0) + # Force the exact count cache collision which formerly skipped D2H. + sim.field._host_ddf_step = 3 + sim.field.ddf.fill(99) + sim.load_checkpoint(path) + assert sim.stepper.step_count == 3 + np.testing.assert_array_equal(sim.get_ddf(),saved) + np.testing.assert_allclose(sim.bodies.action[[5,6,7]],[.0002,1e-5,-2e-6]) + sim.run(4) + np.testing.assert_array_equal(sim.get_ddf(),expected) + + +@pytest.mark.parametrize('streaming', ['double_buffer', 'esopull']) +@pytest.mark.parametrize('periodic_x', [False, True]) +def test_checkpoint_domain_exact_continuation(streaming, periodic_x, tmp_path): + # Same low-Mach forced channel as the Poiseuille runner, bounded to 7 steps. + kwargs = dict(nx=16, ny=18, streaming=streaming, collision='MRT', + shifted=True, viscosity=.1, velocity=0.) + with make_sim(**kwargs) as source: + source.initialize() + if periodic_x: + source.field.flag.reshape(18, 16)[1:-1, [0, -1]] = FLUID + source.field.upload_flags() + source.update_runtime_params(fx=8 * .1 * .03 / 16**2) + source.run(3) + flags = source.get_flags().copy() + saved = source.get_ddf().copy() + path = source.save_checkpoint(str(tmp_path / 'domain.h5')) + source.run(4) + expected = source.get_ddf().copy() + # Restore into this instance and also a fresh default-domain instance. + source.load_checkpoint(path) + np.testing.assert_array_equal(source.get_flags(), flags) + np.testing.assert_array_equal(source.get_ddf(), saved) + source.run(4) + np.testing.assert_array_equal(source.get_ddf(), expected) + with make_sim(**kwargs) as target: + target.initialize() + target.load_checkpoint(path) + assert target.stepper.step_count == 3 + np.testing.assert_array_equal(target.get_flags(), flags) + np.testing.assert_array_equal(target.get_ddf(), saved) + target.run(4) + np.testing.assert_array_equal(target.get_ddf(), expected) + + +@pytest.mark.parametrize('periodic_x', [False, True]) +def test_checkpoint_geometry_mismatch_leaves_device_unchanged(tmp_path, periodic_x): + with make_sim(nx=48,ny=24) as source: + source.add_body('circle',center=(14,12),radius=3) + source.initialize() + if periodic_x: + source.field.flag.reshape(24, 48)[1:-1, [0, -1]] = FLUID + source.field.upload_flags() + source.run(2) + path = source.save_checkpoint(str(tmp_path/'geometry.h5')) + with make_sim(nx=48,ny=24) as target: + target.add_body('circle',center=(28,12),radius=3) + target.initialize() + target.run(3) + before = target.get_ddf() + flags = target.get_flags() + with pytest.raises(ValueError,match='geometry'): + target.load_checkpoint(path) + assert target.stepper.step_count == 3 + target.field.invalidate_host_ddf_cache() + np.testing.assert_array_equal(target.get_ddf(),before) + np.testing.assert_array_equal(target.get_flags(),flags) + + +def test_periodic_checkpoint_restores_observation_interval(tmp_path,monkeypatch): + monkeypatch.chdir(tmp_path) + with make_sim(nx=48,ny=24) as sim: + sim.add_body('sensor',center=(24,12),radius=2) + sim.initialize() + sim.run(6,checkpoint_interval=2) + sim.load_checkpoint(str(tmp_path/'output/checkpoint_00000002.h5')) + assert sim.stepper.step_count == 2 + assert sim.bodies._obs_accum_steps == 2 + raw = sim.read_sensor(0,normalize=False) + assert np.linalg.norm(raw) > 0 + np.testing.assert_allclose(sim.read_sensor(0),raw/2,rtol=1e-6) + + +def test_checkpoint_preserves_pending_and_applied_controls(tmp_path): + with make_sim(nx=48,ny=24,shifted=True) as sim: + sim.add_body('force_region',center=(24,12),radius=3) + sim.initialize() + sim.set_force(0,fx=1e-5,fy=-2e-6) + sim.run(3) + sim.set_force(0,fx=3e-5,fy=4e-6) # Host target has not been uploaded. + pending = sim.bodies.action.copy() + path = sim.save_checkpoint(str(tmp_path/'pending.h5')) + sim.run(4,upload_act=False) + expected = sim.get_ddf() + sim.set_force(0,fx=0,fy=0) + sim.load_checkpoint(path) + np.testing.assert_array_equal(sim.bodies.action,pending) + sim.run(4,upload_act=False) + np.testing.assert_array_equal(sim.get_ddf(),expected) diff --git a/tests/integration/test_surface_actuation_gpu.py b/tests/integration/test_surface_actuation_gpu.py new file mode 100644 index 0000000..49a4c4a --- /dev/null +++ b/tests/integration/test_surface_actuation_gpu.py @@ -0,0 +1,249 @@ +"""Fixed surface actuation: discrete operator/ledger checks, not flux accuracy.""" +from dataclasses import asdict +import json + +import h5py +import numpy as np +import pycuda.driver as cuda +import pytest + +from CelerisLab.lbm.descriptors import FLUID, SOLID +from ._cases import make_sim +from .test_numerical_gpu import CX, CY, install_spatial_state, spatial_state + + +WEIGHTS = np.array([4/9] + [1/9]*4 + [1/36]*4) +OPPOSITE = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7]) +GEOMETRY = ("fluid_idx", "dir", "q", "body_id", "rx", "ry", "rz", "fallback_class") + + +def feq(rho, ux, uy): + """Independent FP64 polynomial; round only the uploaded populations.""" + cu = CX[:, None, None]*ux + CY[:, None, None]*uy + return WEIGHTS[:, None, None]*rho*(1 + 3*cu + 4.5*cu**2 - 1.5*(ux**2 + uy**2)) + + +@pytest.mark.parametrize("speed", [-.01, .01]) +def test_actual_surface_kernel_equilibrium_branches(speed): + """Both q branches, q=.5 and donor fallback; non-unit rho and signed normal.""" + sim = make_sim(nx=24, ny=16, velocity=0) + allocations = [] + try: + sim.initialize() + normal = np.array([-.6, .8]) + expected = feq(1.17, *(speed*normal))[:, 0, 0] + initial = np.broadcast_to(expected[:, None, None], (9, 16, 24)).astype(np.float32).copy() + # Distinct source/destination neighborhoods; no synthetic link race. + cells = np.array([4*24+12, 6*24+12, 8*24+12, 10*24+12], np.uint32) + directions = np.ones(4, np.uint8) + radii = np.tile(5*normal, (4, 1)).astype(np.float32) + columns = [cells, directions, np.array([.2, .5, .8, .2], np.float32), + radii[:, 0].copy(), radii[:, 1].copy(), np.zeros(4, np.float32), + np.array([0, 0, 0, 1], np.uint8), np.zeros(4, np.int32)] + with sim.ctx.activate(): + for array in columns + [np.tile(speed*normal, (4, 1)).astype(np.float32), + np.zeros((4, 3), np.float32)]: + allocations.append(cuda.to_device(array)) + cuda.memcpy_htod(sim.field.ddf_gpu, initial) + sim.stepper.curved_fn(sim.field.ddf_gpu, *allocations[:8], + np.uintp(0), np.uintp(0), np.uint32(4), + *allocations[8:], block=(32, 1, 1), grid=(1, 1)) + actual = np.empty_like(initial) + exchange = np.empty((4, 3), np.float32) + cuda.memcpy_dtoh(actual, sim.field.ddf_gpu) + cuda.memcpy_dtoh(exchange, allocations[-1]) + reflected = actual.reshape(9, -1)[2, cells+1] + np.testing.assert_allclose(reflected, expected[2], atol=7e-8, rtol=0) + # Equilibrium identity is independent of interpolation implementation. + fx = expected[1] + expected[2] + wanted = np.column_stack((np.full(4, fx), np.zeros(4), -radii[:, 1]*fx)) + np.testing.assert_allclose(exchange, wanted, atol=4e-7, rtol=0) + finally: + with sim.ctx.activate(): + for allocation in allocations: + allocation.free() + sim.close() + + +def circle_sim(speed): + sim = make_sim(nx=32, ny=24, velocity=0) + spec = None if speed is None else dict(angle=.7, half_width=.85, normal_velocity=speed) + try: + bid = sim.add_body("circle", center=(16, 12), radius=4.3, surface_actuation=spec) + sim.initialize() + flags = sim.field.flag.copy() + flags.reshape(24, 32)[1:-1, [0, -1]] = FLUID # x periodic, y halfway walls + y, x = np.mgrid[:24, :32] + initial = feq(1.17+.001*np.cos(.2*x+.3*y), + .003*np.sin(.3*y), .002*np.cos(.4*x)).astype(np.float32) + install_spatial_state(sim, initial) + sim.field.flag[:] = flags # installer intentionally resets all flags + sim.field.upload_flags() + return sim, bid, initial + except BaseException: + sim.close() + raise + + +@pytest.mark.parametrize("speed", [-.01, .01]) +def test_surface_population_ledger_and_public_diagnostics(speed): + sim, bid, initial = circle_sim(speed) + try: + cl = sim.field.curved + frozen = {name: getattr(cl, name).copy() for name in GEOMETRY} + flags = sim.field.flag.copy() + body_state = vars(sim.bodies.get(bid).state).copy() + physical = initial.astype(np.float64) + flat = physical.reshape(9, -1) + cell, direction = cl.fluid_idx.astype(int), cl.dir.astype(int) + ex, ey, q = CX[direction], CY[direction], cl.q.astype(float) + # Independently project the prescribed arc at true intersections. + angle = np.arctan2(cl.ry.astype(float), cl.rx.astype(float)) + delta = (angle-.7+np.pi) % (2*np.pi)-np.pi + aperture = np.abs(delta) < .85 + radial = np.column_stack((cl.rx, cl.ry)).astype(float) + normal = radial / np.linalg.norm(radial, axis=1)[:, None] + velocity = speed*np.maximum(0, 1-(delta/.85)**2)[:, None]**2*normal + np.testing.assert_array_equal(cl.aperture, aperture) + np.testing.assert_allclose(cl.surface_velocity, velocity, atol=1e-8, rtol=0) + assert aperture.any() and (~aperture).any() + assert (q < .5).any() and (q >= .5).any() + a = flat[direction, cell] + b = np.empty(len(cell)) + passive = np.empty(len(cell)) + for i, (k, d) in enumerate(zip(cell, direction)): + rho = flat[:, k].sum() + dot = CX[d]*velocity[i, 0] + CY[d]*velocity[i, 1] + if cl.fallback_class[i]: + passive[i] = a[i] + factor = 6 + elif q[i] < .5: + passive[i] = 2*q[i]*a[i] + (1-2*q[i])*flat[d, k-CX[d]-32*CY[d]] + factor = 6 + else: + passive[i] = a[i]/(2*q[i]) + (1-1/(2*q[i]))*flat[OPPOSITE[d], k] + factor = 3/q[i] + b[i] = passive[i]-factor*WEIGHTS[d]*rho*dot + force = np.column_stack((ex, ey))*(a+b)[:, None] + torque = cl.rx*force[:, 1]-cl.ry*force[:, 0] + wanted = np.column_stack((force, torque)) + # Fluid-node and intersection lever arms differ by q*c: same link torque. + fluid_r = radial-q[:, None]*np.column_stack((ex, ey)) + np.testing.assert_allclose(torque, fluid_r[:, 0]*force[:, 1]-fluid_r[:, 1]*force[:, 0], + atol=2e-15, rtol=0) + with sim.ctx.activate(): + sim.stepper.curved_fn(sim.field.ddf_gpu, + *(getattr(cl, name+"_gpu") for name in GEOMETRY[:3]), + cl.rx_gpu, cl.ry_gpu, cl.rz_gpu, cl.fallback_class_gpu, cl.body_id_gpu, + np.uintp(0), np.uintp(0), np.uint32(cl.count), + cl.surface_velocity_gpu, cl.link_exchange_gpu, + block=(64, 1, 1), grid=((cl.count+63)//64, 1)) + staged = np.empty_like(initial) + cuda.memcpy_dtoh(staged, sim.field.ddf_gpu) + incoming = staged.reshape(9, -1)[OPPOSITE[direction], cell+ex+32*ey].astype(float) + np.testing.assert_allclose(incoming, b, atol=8e-8, rtol=0) + # Incoming populations alone respond with the expected signed normal drive. + assert np.all((-np.sum(normal[aperture]*np.column_stack((ex, ey))[aperture], axis=1)) > 0) + core = aperture & (np.abs(delta) < .5*.85) + assert core.any() + assert np.all(speed*(incoming-passive)[core] > 0) + + # Independent pull ledger, including all planar-wall links. Use actual + # rounded incoming values to isolate bookkeeping from CUDA arithmetic. + arrived = np.stack([np.roll(physical[d], (CY[d], CX[d]), (0, 1)) for d in range(9)]) + arrived.reshape(9, -1)[OPPOSITE[direction], cell] = incoming + mass_exchange = float(np.sum(incoming-a)) + momentum_exchange = -np.sum(np.column_stack((ex, ey))*(a+incoming)[:, None], axis=0) + for y, outgoing in ((1, (4, 6, 7)), (22, (3, 5, 8))): + for d in outgoing: + values = physical[d, y, :] + arrived[OPPOSITE[d], y, :] = values + momentum_exchange -= 2*values.sum()*np.array([CX[d], CY[d]]) + fluid = ((flags & FLUID) != 0) & ((flags & SOLID) == 0) + change = (arrived-physical).reshape(9, -1)[:, fluid].sum(axis=1) + np.testing.assert_allclose([change.sum(), change@CX, change@CY], + [mass_exchange, *momentum_exchange], atol=3e-13, rtol=0) + # Start public run from the original state, not a previously staged field. + with sim.ctx.activate(): + cuda.memcpy_htod(sim.field.ddf_gpu, initial) + sim.run(1) + actual = spatial_state(sim).astype(float).reshape(9, -1)[:, fluid] + expected = arrived.reshape(9, -1)[:, fluid] + moments = np.stack((np.ones(9), CX, CY)) + np.testing.assert_allclose(moments@actual, moments@expected, atol=5e-7, rtol=0) + # FP32 collision conserves moments up to rounding, not bitwise globally. + change = (actual-physical.reshape(9, -1)[:, fluid]).sum(axis=1) + budget = 8*np.finfo(np.float32).eps*np.abs(expected).sum() + np.testing.assert_allclose([change.sum(), change@CX, change@CY], + [mass_exchange, *momentum_exchange], atol=budget, rtol=0) + diagnostic = sim.read_surface_exchange(bid) + for name, mask in (("wall", ~aperture), ("aperture", aperture)): + np.testing.assert_allclose(diagnostic[name], wanted[mask].sum(axis=0), + atol=4e-6, rtol=0) + total = diagnostic["wall"]+diagnostic["aperture"] + np.testing.assert_allclose(sim.read_force(bid, normalize=False), total[:2], atol=4e-6, rtol=0) + np.testing.assert_allclose(sim.read_torque(bid, normalize=False), total[2:], atol=4e-6, rtol=0) + for name, value in frozen.items(): + np.testing.assert_array_equal(getattr(cl, name), value) + np.testing.assert_array_equal(sim.field.flag, flags) + assert vars(sim.bodies.get(bid).state) == body_state + finally: + sim.close() + + +def test_zero_surface_drive_preserves_legacy_populations_and_geometry(): + records = [] + for speed in (None, 0.0): + sim, bid, _ = circle_sim(speed) + try: + sim.run(2) + records.append((spatial_state(sim), sim.field.flag.copy(), + {name: getattr(sim.field.curved, name).copy() for name in GEOMETRY})) + finally: + sim.close() + np.testing.assert_array_equal(records[0][0], records[1][0]) + np.testing.assert_array_equal(records[0][1], records[1][1]) + for name in GEOMETRY: + np.testing.assert_array_equal(records[0][2][name], records[1][2][name]) + + +def test_surface_checkpoint_continues_and_resets_latest_exchange(tmp_path): + sim, bid, _ = circle_sim(.01) + try: + sim.run(1) + saved = spatial_state(sim) + count = sim.stepper.step_count + spec = asdict(sim.bodies.get(bid).surface_actuation) + action = sim.bodies.action.copy() + path = sim.save_checkpoint(str(tmp_path / "surface.h5")) + with h5py.File(path, "r") as checkpoint: + assert checkpoint.attrs["ckpt_version"] == 4 + geometry = json.loads(checkpoint.attrs["geometry_json"]) + assert len(geometry[0]["projection_sha256"]) == 64 + assert json.loads(checkpoint.attrs["surface_actuation_json"]) == [spec] + applied = checkpoint["applied_action"][:] + sim.run(2) + expected = spatial_state(sim) + expected_exchange = sim.read_surface_exchange(bid) + assert np.linalg.norm(expected_exchange["aperture"]) > 0 + sim.load_checkpoint(path) + assert sim.stepper.step_count == count + np.testing.assert_array_equal(spatial_state(sim), saved) + np.testing.assert_array_equal(sim.bodies.action, action) + actual_action = np.empty_like(applied) + with sim.ctx.activate(): + cuda.memcpy_dtoh(actual_action, sim.bodies.action_gpu) + np.testing.assert_array_equal(actual_action, applied) + assert asdict(sim.bodies.get(bid).surface_actuation) == spec + for values in sim.read_surface_exchange(bid).values(): + np.testing.assert_array_equal(values, np.zeros(3)) + restored = sim.save_checkpoint(str(tmp_path / "restored.h5")) + with h5py.File(restored, "r") as checkpoint: + assert json.loads(checkpoint.attrs["geometry_json"]) == geometry + assert json.loads(checkpoint.attrs["surface_actuation_json"]) == [spec] + sim.run(2) + np.testing.assert_array_equal(spatial_state(sim), expected) + for name, values in sim.read_surface_exchange(bid).items(): + np.testing.assert_array_equal(values, expected_exchange[name]) + finally: + sim.close() diff --git a/tests/integration/test_sync_bodies_skeleton.py b/tests/integration/test_sync_bodies_skeleton.py index ec2b5ca..753b4a0 100644 --- a/tests/integration/test_sync_bodies_skeleton.py +++ b/tests/integration/test_sync_bodies_skeleton.py @@ -9,9 +9,9 @@ import json import numpy as np import pycuda.driver as cuda -import pycuda.autoinit from CelerisLab.simulation import Simulation, _scatter_to_field, _gather_from_field +from ._cases import make_sim # Use a small grid for fast compilation and test execution @@ -23,7 +23,7 @@ class TestSyncBodiesSkeleton(unittest.TestCase): def _make_sim(self) -> Simulation: """Create a Simulation with a small double_buffer D2Q9 grid.""" - return Simulation(device_id=0) + return make_sim() def test_remove_body_sync_and_run(self): """Remove a body, sync, and continue running without crash.""" @@ -134,28 +134,7 @@ class TestSyncBodiesEsoPull(unittest.TestCase): def _make_esopull_sim(self, nx=128, ny=64) -> Simulation: """Create a Simulation with a small esopull D2Q9 grid.""" - cfg = { - "grid": {"lattice_model": "D2Q9", "nx": nx, "ny": ny, "nz": 1}, - "physics": {"data_type": "FP32", "viscosity": 0.05, - "velocity": 0.03, "rho": 1.0}, - "method": { - "collision": "SRT", "streaming": "esopull", - "store_precision": "FP32", "ddf_shifting": False, - "les": {"enabled": False, "cs": 0.16, "closed_form": True}, - "trt": {"magic_param": 0.1875}, - "inlet": {"profile": "parabolic", "scheme": "zou_he_local"}, - "outlet": {"mode": "neq_extrap", "backflow_clamp": True, - "blend_alpha": 0.7}, - "y_wall_bc": "bounce_back", - "omega_guard": {"min": 0.01, "max": 1.99}, - }, - "cuda": {"threads_per_block": 256, "compute_capability": "auto"}, - } - tmpd = tempfile.mkdtemp(prefix="esopull_sync_") - lbm_path = os.path.join(tmpd, "config_lbm.json") - with open(lbm_path, "w") as f: - json.dump(cfg, f) - return Simulation(lbm_config_path=lbm_path) + return make_sim(nx=nx, ny=ny, streaming="esopull") def test_esopull_add_body(self): """EsoPull: add body, sync, run, read finite force.""" @@ -234,7 +213,8 @@ class TestSyncBodiesEsoPull(unittest.TestCase): _gather_from_field(sim.field, s_host, all_cells) sim.field.esopull_sync_encode( s_gpu, s_host, idx_gpu, len(all_cells), step, stream) - stream.synchronize() + with sim.ctx.activate(): + stream.synchronize() sim.run(100) macro = sim.get_macroscopic() diff --git a/tests/integration/test_unified_obs.py b/tests/integration/test_unified_obs.py index 2e52d27..b73d289 100644 --- a/tests/integration/test_unified_obs.py +++ b/tests/integration/test_unified_obs.py @@ -6,9 +6,9 @@ import unittest import numpy as np import pycuda.driver as cuda -import pycuda.autoinit from CelerisLab.simulation import Simulation +from ._cases import make_sim class TestUnifiedObs(unittest.TestCase): @@ -16,7 +16,7 @@ class TestUnifiedObs(unittest.TestCase): def test_set_body_then_run_read_body(self): """set_body (host-only), run, read_body returns finite force.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -35,7 +35,7 @@ class TestUnifiedObs(unittest.TestCase): def test_skip_transfer(self): """run(upload_act=False, sync_obs=False) should not crash.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -47,12 +47,13 @@ class TestUnifiedObs(unittest.TestCase): def test_external_stream(self): """Providing an external CUDA stream should not crash.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) sim.initialize() - s = cuda.Stream() + with sim.ctx.activate(): + s = cuda.Stream() sim.run(50, stream=s) force = sim.read_force(0) self.assertTrue(np.all(np.isfinite(force)), @@ -62,8 +63,8 @@ class TestUnifiedObs(unittest.TestCase): def test_read_body_before_run_returns_zeros(self): """read_body before any run() should return zero force (buffer is initialized to zero during sync_to_gpu).""" - sim = Simulation(device_id=0) - sim.add_body("circle", center=(128, 128), radius=8) + sim = make_sim() + sim.add_body("circle", center=(sim.lbm_cfg.nx // 4, sim.lbm_cfg.ny // 2), radius=8) sim.initialize() force = sim.read_force(0) np.testing.assert_array_equal(force, np.zeros(2, dtype=np.float32)) @@ -71,7 +72,7 @@ class TestUnifiedObs(unittest.TestCase): def test_drl_pattern(self): """DRL-style loop: run → read → set → run → read.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -95,7 +96,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_zero_obs_true_resets_force(self): """run(zero_obs=True) resets the step counter; force per-step should be the same order of magnitude across blocks.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -115,7 +116,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_zero_obs_false_accumulates_force(self): """run(zero_obs=False) twice → step counter accumulates.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -136,7 +137,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_zero_obs_true_resets_sensor(self): """Sensor values should not spill across run() calls with zero_obs.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("sensor", center=(nx // 2, ny // 2), radius=8) @@ -157,7 +158,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_normalize_divides_by_steps(self): """read_force(normalize=True) should give per-step force.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -175,7 +176,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_read_sensor_normalize_false(self): """read_sensor(normalize=False) returns area-averaged but not time-averaged value.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("sensor", center=(nx // 2, ny // 2), radius=8) @@ -193,7 +194,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_read_body_normalize(self): """read_body(normalize=True) divides all fields by step count.""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("circle", center=(nx // 4, ny // 2), radius=8) @@ -212,8 +213,8 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_normalize_returns_zero_before_run(self): """read(..., normalize=True) before any run() returns zeros.""" - sim = Simulation(device_id=0) - sim.add_body("circle", center=(128, 128), radius=8) + sim = make_sim() + sim.add_body("circle", center=(sim.lbm_cfg.nx // 4, sim.lbm_cfg.ny // 2), radius=8) sim.initialize() force = sim.read_force(0, normalize=True) @@ -227,7 +228,7 @@ class TestObsZeroingAndNormalize(unittest.TestCase): def test_sensor_area_always_normalized(self): """Sensor always does area-normalisation internally. normalize=False should NOT equal GPU raw (should be smaller by cell_count).""" - sim = Simulation(device_id=0) + sim = make_sim() nx = sim.lbm_cfg.nx ny = sim.lbm_cfg.ny sim.add_body("sensor", center=(nx // 2, ny // 2), radius=8) diff --git a/tests/unit/cpp/collision_regression.cpp b/tests/unit/cpp/collision_regression.cpp new file mode 100644 index 0000000..326735f --- /dev/null +++ b/tests/unit/cpp/collision_regression.cpp @@ -0,0 +1,93 @@ +// CPU qualifier shim: executes actual operators, never creates a GPU context. +#include +#include +#include +#define __device__ +#define __constant__ +#define __forceinline__ inline +#define LATTICE_D2Q9 0 +#define LATTICE_D3Q19 1 +#define NQ (DIM == 2 ? 9 : 19) +#define USE_LES 0 +#define OMEGA_COLLISION_MIN .001f +#define OMEGA_COLLISION_MAX 1.99f +struct Params {float omega, fx, fy, fz;} d_params; +#include "core/descriptors.cuh" +#include "operators/macro.cuh" +#include "operators/collision_srt.cuh" +#include "operators/collision_trt.cuh" +#include "operators/collision_mrt.cuh" +#include "operators/forcing_guo.cuh" +#include "operators/helpers.cuh" +#include "boundary/inlet/common.cuh" +void emit(const float* f) {for(int i=0;i +#include +#define __device__ +#define __forceinline__ inline +#include "boundary/planar_velocity.cuh" + +int main() { + const int cx[9]={0,1,-1,0,0,1,-1,1,-1}; + const int cy[9]={0,0,0,1,-1,1,-1,-1,1}; + const float w[9]={4.f/9,1.f/9,1.f/9,1.f/9,1.f/9,1.f/36,1.f/36,1.f/36,1.f/36}; + for (int top : {0,1}) for (float rho : {1.f,1.04f}) + for (float u : {-.02f,0.f,.02f}) for (float v : {-.01f,-.001f,0.f,.001f,.01f}) + for (int neq : {0,1}) { + float f[9]; + printf("%d %.9g %.9g %.9g %d\n",top,rho,u,v,neq); + for (int i=0;i<9;++i) { + float cu=cx[i]*u+cy[i]*v; + f[i]=w[i]*rho*(1+3*cu+4.5f*cu*cu-1.5f*(u*u+v*v)); + f[i]+=neq*.0001f*(i-4); + // Unknown inputs must be completely overwritten, never consumed. + if (cy[i]==(top?-1:1)) f[i]=-7.f-i; + printf("%.9g ",f[i]); + } + puts(""); + if (top) reconstruct_top_velocity(f,u,v); + else reconstruct_bottom_velocity(f,u,v); + for (float value : f) printf("%.9g ",value); + puts(""); + } +} diff --git a/tests/unit/cpp/reservoir.cpp b/tests/unit/cpp/reservoir.cpp new file mode 100644 index 0000000..c5add0b --- /dev/null +++ b/tests/unit/cpp/reservoir.cpp @@ -0,0 +1,26 @@ +#include +#include +#define __device__ +#define __forceinline__ inline +#define WALL_NORMAL_VELOCITY 0.0f +#include "boundary/planar_velocity.cuh" + +int main() { + const int cy[9]={0,0,0,1,-1,1,-1,-1,1}; + const float w[9]={4.f/9,1.f/9,1.f/9,1.f/9,1.f/9,1.f/36,1.f/36,1.f/36,1.f/36}; + for (float rho : {1.f,1.04f}) for (float v : {-.01f,-.001f,0.f,.001f,.01f}) + for (int neq : {0,1}) { + float f[9]; + printf("%.9g %.9g %d\n",rho,v,neq); + for (int i=0;i<9;++i) { + float cu=cy[i]*v; + f[i]=w[i]*rho*(1+3*cu+4.5f*cu*cu-1.5f*v*v)+neq*.0001f*(i-4); + if (cy[i]==-1) f[i]=-7.f-i; + printf("%.9g ",f[i]); + } + puts(""); + reconstruct_top_reservoir(f,rho); + for (float value : f) printf("%.9g ",value); + puts(""); + } +} diff --git a/tests/unit/cpp/semantic_regression.cpp b/tests/unit/cpp/semantic_regression.cpp new file mode 100644 index 0000000..ef6f140 --- /dev/null +++ b/tests/unit/cpp/semantic_regression.cpp @@ -0,0 +1,238 @@ +#include +#include + +#include +#include +#include +#include +#define __device__ +#define __global__ +#define __forceinline__ inline +#define __constant__ +#define LATTICE_D2Q9 0 +#define LATTICE_D3Q19 1 +#define LATTICE_MODEL 0 +#define DIM 2 +#define NQ 9 +#define NX 8 +#define NY 8 +#define NZ 1 +#define NT 1 +#define USE_LES 0 +#define RHO 1.0f +#ifndef COLLISION_MODEL +#define COLLISION_MODEL 0 +#endif +#define OMEGA_COLLISION_MIN .001f +#define OMEGA_COLLISION_MAX 1.99f +struct Idx {unsigned x=0,y=0,z=0;}threadIdx,blockIdx,blockDim; +struct Params{float omega=1.25f,fx=0,fy=0;}d_params; +inline float atomicAdd(float*p,float v){*p+=v;return *p;} +inline int obs_force_index(int,int){return 0;} +inline int obs_torque_index(int,int){return 0;} +#include "core/flags.cuh" +#include "core/descriptors.cuh" +#include "core/precision.cuh" +#include "core/layout.cuh" +#include "operators/macro.cuh" +#include "operators/collision_srt.cuh" +#include "operators/collision_trt.cuh" +#include "operators/collision_mrt.cuh" +#include "operators/forcing_guo.cuh" +#include "operators/helpers.cuh" +#include "streaming/esopull_single_buffer.cuh" +#include "streaming/esopull_semantic_helpers.cuh" +#include "boundary/bounce_back.cuh" +#include "boundary/curved_boundary.cuh" +#include "step/esopull_sync.cu" +#define U0 .03f +#define OBS_BODY_SLOT_FLOATS 7 +#define __launch_bounds__(...) +inline int obs_sensor_index(int,int){return 0;} +#include "boundary/inlet_outlet.cuh" +#include "streaming/pull_double_buffer.cuh" +#include "step/one_step_double.cu" +#include "step/one_step_esopull.cu" +#include "step/aux_kernels.cu" +#include "step/init_flow.cu" +#include +#include +void close(float a,float b,float tol=3e-7f) { + if(fabsf(a-b)>tol) {fprintf(stderr,"got %.9g expected %.9g\n",a,b); abort();} +} +int main() { + float fi[NX*NY*NQ],before[NX*NY*NQ],f[NQ],physical[NQ],staged[6*NX+2*NY*NQ]; + // Nonuniform populations: exact same-time address inverse, both parities. + for(unsigned long t=0;t<4;t++) { + for(int i=0;i=NY)continue; + const float* donor=staged+6*NX+(side*NY+n)*NQ; + unsigned long k=linear_index(side?NX-2:1,n); + for(unsigned i=0;i +#include +#define __device__ +#define __forceinline__ inline +#include "boundary/planar_velocity.cuh" + +int main() { + for (unsigned x=0; x 0 else 11.75), speed=speed) + expected_center = (20., 20. if speed > 0 else 19.) + assert patch.fine_origin == (2., 2.) + before = (source.center, source.radius, source.geometry.center, + source.geometry._radius, asdict(source.state), asdict(source.surface_actuation), + vars(cfg).copy()) + plan = refinement.prepare_fine_boundary(cfg, patch, [source]) + local, = plan.objects + assert local is not source and local.geometry is not source.geometry + np.testing.assert_allclose(local.center, expected_center, rtol=0, atol=1e-14) + np.testing.assert_allclose(local.geometry.center, expected_center, rtol=0, atol=1e-14) + assert local.radius == pytest.approx(4.3) + assert local.geometry._radius == pytest.approx(4.3) + assert local.surface_actuation.normal_velocity == speed # dt/dx is unchanged. + assert (source.center, source.radius, source.geometry.center, + source.geometry._radius, asdict(source.state), asdict(source.surface_actuation), + vars(cfg).copy()) == before + _, _, _, _, rx, ry, _, _ = plan.curved_host + np.testing.assert_allclose(np.hypot(rx, ry), 4.3, rtol=0, atol=6e-7) + # These world points lie on the ORIGINAL physical circle, including the +.5. + world_x = patch.fine_origin[0]+.5*(local.center[0]+rx.astype(float)+.5) + world_y = patch.fine_origin[1]+.5*(local.center[1]+ry.astype(float)+.5) + np.testing.assert_allclose(np.hypot(world_x-source.center[0], world_y-source.center[1]), + source.radius, rtol=0, atol=3e-7) + + +def test_fine_boundary_receivers_sources_and_reverse_coverage(): + cfg, patch = setup_patch() + plan = refinement.prepare_fine_boundary(cfg, patch, [circle()]) + ny, nx = patch.fine_shape + flags = plan.flags + assert flags.shape == (nx*ny,) and flags.dtype == np.uint16 + solid = (flags & SOLID) != 0 + assert solid.any() + assert np.all(patch.fine_kind.ravel()[solid] == ACTIVE) + receivers, directions, q, _, _, _, _, fallback = plan.curved_host + cx, cy = np.asarray(D2Q9_EX), np.asarray(D2Q9_EY) + sources = receivers.astype(np.int64)+cx[directions]+nx*cy[directions] + assert np.all((flags[receivers] & FLUID) != 0) + assert not np.any(flags[receivers] & SOLID) + assert np.all(solid[sources]) + assert np.all(patch.fine_kind.ravel()[receivers] == ACTIVE) + assert np.all(patch.fine_kind.ravel()[sources] == ACTIVE) + # A source population slot has a unique writer, not just a unique cell. + assert len(set(zip(sources.tolist(), directions.tolist()))) == len(receivers) + expected = set() + for cell in np.flatnonzero((patch.fine_kind.ravel() == ACTIVE) & ~solid): + y, x = divmod(int(cell), nx) + for direction in range(1, 9): + sx, sy = x+int(cx[direction]), y+int(cy[direction]) + if 0 <= sx < nx and 0 <= sy < ny and solid[sy*nx+sx]: + expected.add((int(cell), direction)) + assert set(zip(receivers.tolist(), directions.tolist())) == expected + donors = receivers[q < .5].astype(np.int64)-cx[directions[q < .5]]-nx*cy[directions[q < .5]] + assert np.all(patch.fine_kind.ravel()[donors] == ACTIVE) + assert not np.any(solid[donors]) + assert not fallback.any() + stencil = set(receivers.tolist()) | set(sources.tolist()) | set(donors.tolist()) + assert stencil == set(plan.boundary_cells.tolist()) + assert not set(plan.boundary_cells.tolist()) & set(plan.transfer_fine_cells.tolist()) + assert not set(np.flatnonzero(solid).tolist()) & set(plan.transfer_fine_cells.tolist()) + + +def test_transfer_dependencies_follow_two_pulls_and_expand_active_collision(): + cfg, patch = setup_patch() + plan = refinement.prepare_fine_boundary(cfg, patch, [circle()]) + ny, nx = patch.fine_shape + cells = nx*ny + # Closed form for exactly two hops, independent of the production traversal: + # the final inner ghost only pulls; its predecessor may have collided. + offsets = np.asarray(D2Q9_EX)+nx*np.asarray(D2Q9_EY) + visited = set() + for slot in patch.coalesce_fine.ravel(): + direction, target = divmod(int(slot), cells) + assert patch.fine_kind.ravel()[target] == INNER_GHOST + middle = target-int(offsets[direction]) + visited.update(((2, direction, target), (1, direction, middle))) + if patch.fine_kind.ravel()[middle] == ACTIVE: + visited.update((0, q, middle-int(offsets[q])) for q in range(9)) + else: + assert patch.fine_kind.ravel()[middle] == INNER_GHOST + visited.add((0, direction, middle-int(offsets[direction]))) + actual = {tuple(map(int, row)) for row in plan.transfer_reads} + assert actual == visited + assert len(plan.transfer_reads) == len(visited) + expected_fine = set(patch.ghost_fine.tolist()) | {cell for _, _, cell in visited} + expected_coarse = set(patch.ghost_coarse.tolist()) | set( + (patch.coalesce_coarse % (cfg.nx*cfg.ny)).tolist()) + assert set(plan.transfer_fine_cells.tolist()) == expected_fine + assert set(plan.transfer_coarse_cells.tolist()) == expected_coarse + for coarse_slot, children in zip(patch.coalesce_coarse, patch.coalesce_fine): + coarse_q, coarse_cell = divmod(int(coarse_slot), cfg.nx*cfg.ny) + coarse_y, coarse_x = divmod(coarse_cell, cfg.nx) + fine_q, fine_cell = np.divmod(children, cells) + np.testing.assert_array_equal(fine_q, coarse_q) + fine_y, fine_x = np.divmod(fine_cell, nx) + physical_centers = np.column_stack(( + patch.fine_origin[0]+.5*(fine_x+.5), + patch.fine_origin[1]+.5*(fine_y+.5))) + np.testing.assert_array_equal(physical_centers.mean(axis=0), + (coarse_x+.5, coarse_y+.5)) + # This case must exercise the collision fan-in, not only ghost straight lines. + active_middle = [(q, cell) for stage, q, cell in visited + if stage == 1 and patch.fine_kind.ravel()[cell] == ACTIVE] + assert active_middle + _, sample = active_middle[0] + for incoming in range(9): + source = sample-int(D2Q9_EX[incoming])-nx*int(D2Q9_EY[incoming]) + assert (0, incoming, source) in actual + + +def test_fine_plan_arrays_are_readonly_and_contiguous(): + cfg, patch = setup_patch() + plan = refinement.prepare_fine_boundary(cfg, patch, [circle()]) + assert len(plan.curved_host) == 8 + for array in (plan.flags, *plan.curved_host, plan.boundary_cells, + plan.transfer_fine_cells, plan.transfer_coarse_cells, plan.transfer_reads): + assert array.flags.c_contiguous and not array.flags.writeable + assert array.size + with pytest.raises(ValueError): + array.flat[0] = array.flat[0] + + +def test_empty_objects_keep_existing_refinement_path(): + cfg, patch = setup_patch() + assert refinement.prepare_fine_boundary(cfg, patch, []) is None + + +def test_fine_rejects_cross_level_low_q_donor_without_fallback(): + cfg, patch = setup_patch() + source = circle(center=(5.05, 12.25), radius=.7) + # In fine coordinates C=(5.6,20), R=1.4. Receiver (4,20), dir +x + # has q=.2, but its required reverse donor (3,20) is not active. + local = CircleGeometry(5.6, 20., 1.4) + links = local.build_cut_links(patch.fine_shape[1], patch.fine_shape[0]) + target, = [link for link in links if link.fluid_idx == 20*40+4 and link.dir == 1] + assert target.q == pytest.approx(.2) + assert patch.fine_kind[20, 3] != ACTIVE + with pytest.raises(ValueError): + refinement.prepare_fine_boundary(cfg, patch, [source]) + + +def test_fine_rejects_active_stencil_that_still_touches_transfer(): + cfg, patch = setup_patch() + source = circle(center=(6.05, 12.25), radius=.7) + # Move the cross-level example two fine cells inward: all boundary reads + # are now active, but the q=.2 reverse donor is still a transfer dependency. + local = CircleGeometry(7.6, 20., 1.4) + nx = patch.fine_shape[1] + solid = (local.build_flag_mask(nx, patch.fine_shape[0]) & SOLID) != 0 + assert np.all(patch.fine_kind.ravel()[solid] == ACTIVE) + links = local.build_cut_links(nx, patch.fine_shape[0]) + stencil = set() + for link in links: + offset = int(D2Q9_EX[link.dir])+nx*int(D2Q9_EY[link.dir]) + stencil.update((link.fluid_idx, link.fluid_idx+offset)) + if link.q < .5: + stencil.add(link.fluid_idx-offset) + assert np.all(patch.fine_kind.ravel()[list(stencil)] == ACTIVE) + with pytest.raises(ValueError, match='transfer footprint'): + refinement.prepare_fine_boundary(cfg, patch, [source]) + + +@pytest.mark.parametrize('change', [dict(nx=25), dict(collision='TRT'), dict(viscosity=.06)]) +def test_fine_rejects_mismatched_patch_configuration(change): + cfg, patch = setup_patch() + with pytest.raises(ValueError, match='matching'): + refinement.prepare_fine_boundary(replace(cfg, **change), patch, [circle()]) + + +def test_fine_rejects_nonzero_control_even_without_motion(): + cfg, patch = setup_patch() + obj = circle() + obj.control.ax = .001 + with pytest.raises(ValueError, match='stationary'): + refinement.prepare_fine_boundary(cfg, patch, [obj]) + + +@pytest.mark.parametrize('objects', [ + [circle(center=(4.25, 12.25), radius=1.)], + [circle(center=(12.5, 12.5), radius=.05)], + [circle(), circle(center=(13., 12.25))], +]) +def test_fine_rejects_ghost_solids_unresolved_circle_and_overlap(objects): + cfg, patch = setup_patch() + with pytest.raises(ValueError): + refinement.prepare_fine_boundary(cfg, patch, objects) diff --git a/tests/unit/test_initializers.py b/tests/unit/test_initializers.py new file mode 100644 index 0000000..34e57a6 --- /dev/null +++ b/tests/unit/test_initializers.py @@ -0,0 +1,68 @@ +"""Initializer invariants on a host field; no CUDA context is required.""" +from types import SimpleNamespace + +import numpy as np +import pytest +from scipy.special import expi + +from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY +from CelerisLab.lbm.equilibrium import compute_feq_d2q9 +from CelerisLab.lbm.initializers import _oseen_vortex, add_vortex + + +class HostField: + def __init__(self): + self.nx = self.ny = 7 + self.nq = 9 + self.cfg = SimpleNamespace(is_d2q9=True, viscosity=.02, + streaming="double_buffer") + self.ddf = np.repeat(compute_feq_d2q9(1.2, .04, .01)[:, None], + 49, axis=1).ravel() + + def download_ddf(self): + pass + + def upload_ddf(self): + pass + + def _read_lattice_vectors(self): + return np.asarray(D2Q9_EX), np.asarray(D2Q9_EY) + + +@pytest.mark.parametrize("kind", ["lamb", "oseen", "taylor"]) +def test_zero_strength_preserves_moving_nonunit_density_equilibrium(kind): + field = HostField() + before = field.ddf.copy() + add_vortex(field, (3, 3), 2., 0., kind) + np.testing.assert_allclose(field.ddf, before, rtol=2e-6, atol=2e-8) + + +def test_oseen_center_limit_and_original_model_away_from_center(): + radius, strength = 2., .02 + r = np.array([0., 1e-10, 1e-5, .2, 1., 10.]) + with np.errstate(all="raise"): + u, v, p = _oseen_vortex(r, np.zeros_like(r), radius, strength) + assert np.all(np.isfinite(p)) + assert u[0] == v[0] == 0 + np.testing.assert_allclose(p[:2], -4*radius**2*strength**2*np.log(2), + rtol=1e-12) + z = 4*r[3:]**2/radius**2 + kappa = 2*np.pi*radius**2*strength + original = -kappa**2/(8*np.pi**2*r[3:]**2) * ( + -2*z*(expi(-z)-expi(-2*z)) + (1-np.exp(-z))**2) + np.testing.assert_allclose(p[3:], original, rtol=2e-13) + + +def test_oseen_grid_center_stays_finite(): + field = HostField() + add_vortex(field, (3, 3), 2., .02, "oseen") + assert np.isfinite(field.ddf).all() + + +def test_esopull_is_rejected_before_editing_raw_storage(): + field = HostField() + field.cfg.streaming = "esopull" + before = field.ddf.copy() + with pytest.raises(NotImplementedError, match="time/parity"): + add_vortex(field, (3, 3), 2., .02) + np.testing.assert_array_equal(field.ddf, before) diff --git a/tests/unit/test_numerical_operators.py b/tests/unit/test_numerical_operators.py new file mode 100644 index 0000000..ebf261f --- /dev/null +++ b/tests/unit/test_numerical_operators.py @@ -0,0 +1,140 @@ +"""Actual CUDA operators compiled with a CPU shim; no GPU/import side effects.""" +from pathlib import Path +import shutil +import subprocess +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +KERNELS = ROOT / "src/CelerisLab/lbm/kernels" +CPP = Path(__file__).with_name("cpp") + + +def compile_run(tmp_path, source, defines): + if not shutil.which("g++"): + pytest.skip("g++ required for actual-header CPU regression") + exe = tmp_path / "operator" + command = ["g++", "-std=c++17", "-O0", f"-I{KERNELS}"] + command += [f"-D{k}={v}" for k, v in defines.items()] + subprocess.run(command + [str(CPP / source), "-o", str(exe)], check=True, capture_output=True, text=True) + return subprocess.check_output([str(exe)], text=True).splitlines() + + +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("shift", [0, 1]) +@pytest.mark.parametrize("model", [0, 1, 2]) +def test_collision_moments_and_inlet_closure(tmp_path, dim, shift, model): + lines = compile_run(tmp_path, "collision_regression.cpp", dict( + DIM=dim, LATTICE_MODEL=dim-2, USE_DDF_SHIFTING=shift, + COLLISION_MODEL=model, NY=8, U0=".03f")) + cx = np.array([0,1,-1,0,0,1,-1,1,-1] if dim == 2 else + [0,1,-1,0,0,0,0,1,-1,1,-1,0,0,1,-1,1,-1,0,0]) + cy = np.array([0,0,0,1,-1,1,-1,-1,1] if dim == 2 else + [0,0,0,1,-1,0,0,1,-1,0,0,1,-1,-1,1,0,0,1,-1]) + cz = np.zeros(9) if dim == 2 else np.array([0,0,0,0,0,1,-1,0,0,1,-1,1,-1,0,0,-1,1,-1,1]) + weights = np.array([4/9]+[1/9]*4+[1/36]*4 if dim == 2 else [1/3]+[1/18]*6+[1/36]*12) + # Independent D2 moment matrix (paired direction ordering). + M = np.array([[1]*9,[-4,-1,-1,-1,-1,2,2,2,2], + [4,-2,-2,-2,-2,1,1,1,1],[0,1,-1,0,0,1,-1,1,-1], + [0,-2,2,0,0,1,-1,1,-1],[0,0,0,1,-1,1,-1,-1,1], + [0,0,0,-2,2,1,-1,-1,1],[0,1,1,-1,-1,0,0,0,0], + [0,0,0,0,0,1,1,-1,-1]], dtype=float) + for a, omega in enumerate([.7,1.25,1.8]): + before, eq, source, after = [np.fromstring(s, sep=" ") for s in lines[4*a:4*a+4]] + delta = after-before + assert abs(delta.sum()) < 5e-7 + np.testing.assert_allclose([delta@cx,delta@cy], [.001,-.0007], atol=1.5e-7, rtol=0) + if dim == 3: + assert abs(delta@cz-.0003) < 1.5e-7 + if model == 0: + expected=before-omega*(before-eq)+(1-omega/2)*source + elif model == 1: + opp=np.array([0]+[i+1 if i%2 else i-1 for i in range(1,len(before))]) + wm=1/(.1875/(1/omega-.5)+.5) + delta_s=(before-eq+(before-eq)[opp])/2 + delta_a=(before-eq-(before-eq)[opp])/2 + expected=before-omega*delta_s-wm*delta_a+(1-omega/2)*(source+source[opp])/2+(1-wm/2)*(source-source[opp])/2 + elif dim == 2: + rates=np.array([0,1.2,1.2,0,1.2,0,1.2,omega,omega]) + expected=before+np.linalg.solve(M,-rates*(M@(before-eq))+(1-rates/2)*(M@source)) + else: + expected=None # D3 projection MRT: conservation/equilibrium only. + if expected is not None: + np.testing.assert_allclose(after,expected,atol=2e-7,rtol=0) + assert float(lines[12].split()[1]) == pytest.approx(1.17, abs=3e-7) + velocities=np.stack([cx,cy,cz],axis=1) + u=np.array([.03,-.02,.01 if dim==3 else 0]) + cu=velocities@u + eq=weights*1.17*(1+3*cu+4.5*cu**2-1.5*(u@u))-shift*weights + np.testing.assert_allclose(np.fromstring(lines[13],sep=" "),eq,atol=2e-7,rtol=0) + + +def test_shifted_mrt_weak_force_full_vector(tmp_path): + """Small populations must retain their precision through MRT relaxation.""" + lines = compile_run(tmp_path, "collision_regression.cpp", dict( + DIM=2, LATTICE_MODEL=0, USE_DDF_SHIFTING=1, COLLISION_MODEL=2, + MRT_WEAK_FORCE_CHECK=1, NY=8, U0=".03f")) + M = np.array([[1]*9,[-4,-1,-1,-1,-1,2,2,2,2], + [4,-2,-2,-2,-2,1,1,1,1],[0,1,-1,0,0,1,-1,1,-1], + [0,-2,2,0,0,1,-1,1,-1],[0,0,0,1,-1,1,-1,-1,1], + [0,0,0,-2,2,1,-1,-1,1],[0,1,1,-1,-1,0,0,0,0], + [0,0,0,0,0,1,1,-1,-1]], dtype=float) + weights = np.array([4/9]+[1/9]*4+[1/36]*4) + np.testing.assert_allclose(M @ weights, [1,-2,1,0,0,0,0,0,0], atol=3e-16) + for start in range(0, len(lines), 4): + # Nine printed digits round-trip FP32; recover those exact binary inputs + # before doing FP64 arithmetic (especially rho-1 near unit density). + omega, rho, ux, uy, fx, fy = np.fromstring(lines[start], sep=" ", dtype=np.float32).astype(float) + before, source, after = [np.fromstring(s, sep=" ", dtype=np.float32).astype(float) + for s in lines[start+1:start+4]] + # Independent LL equilibrium moments, with the exact background removed. + drho, u2 = rho-1, ux*ux+uy*uy + meq = np.array([drho, -2*drho+3*rho*u2, drho-3*rho*u2, + rho*ux, -rho*ux, rho*uy, -rho*uy, + rho*(ux*ux-uy*uy), rho*ux*uy]) + rates = np.array([0, np.float32(1.2), np.float32(1.2), 0, + np.float32(1.2), 0, np.float32(1.2), omega, omega]) + expected = before + np.linalg.solve( + M, rates*(meq-M@before)+(1-rates/2)*(M@source)) + # FP32 roundoff is relative to the stored small populations, not to w. + budget = 8*np.finfo(np.float32).eps * max(abs(before).max(), abs(source).max()) + 1e-13 + np.testing.assert_allclose(after, expected, atol=budget, rtol=0) + np.testing.assert_allclose((after-before) @ M[[0,3,5]].T, + [0,fx,fy], atol=4*budget, rtol=0) + if not before.any() and fx == 0 and fy == 0: + np.testing.assert_array_equal(after, np.zeros(9)) + + +@pytest.mark.parametrize("shift", [0, 1]) +@pytest.mark.parametrize("wall", [0, 1]) +@pytest.mark.parametrize("model", [0, 1, 2]) +def test_actual_semantic_and_auxiliary_kernels(tmp_path, shift, wall, model): + lines = compile_run(tmp_path, "semantic_regression.cpp", dict(USE_DDF_SHIFTING=shift,Y_WALL_BC=wall,COLLISION_MODEL=model)) + assert "passed" in lines[-1] + + +@pytest.mark.parametrize("model", [0, 1, 2]) +@pytest.mark.parametrize("shift", [0, 1]) +@pytest.mark.parametrize("storage", [0, 1]) +def test_d2_entrypoint_compiles_in_isolation(tmp_path, model, shift, storage): + """Both streaming entrypoints compile; generated repository headers untouched.""" + import re + nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc" + if not Path(nvcc).is_file(): + pytest.skip("CUDA toolkit required for compile-only regression") + copy = tmp_path / "kernels" + shutil.copytree(KERNELS, copy) + for file, defines in [ + ("config_grid.h", {"LATTICE_MODEL": 0, "NX": 16, "NY": 8, "NZ": 1}), + ("config_method.h", {"COLLISION_MODEL": model, "USE_DDF_SHIFTING": shift, + "STORE_PRECISION": storage, "Y_WALL_BC": shift}) + ]: + path=copy / "config" / file + source=path.read_text() + for key,value in defines.items(): + source=re.sub(r"(#define\s+"+key+r"\s+)\S+",lambda match: match[1]+str(value),source) + path.write_text(source) + result=subprocess.run([nvcc,"-ptx","-arch=sm_70","-std=c++17", + str(copy/"kernel_v2.cu"),"-o",str(tmp_path/"kernel.ptx")], + capture_output=True,text=True) + assert result.returncode == 0, result.stderr diff --git a/tests/unit/test_planar_velocity.py b/tests/unit/test_planar_velocity.py new file mode 100644 index 0000000..d007caf --- /dev/null +++ b/tests/unit/test_planar_velocity.py @@ -0,0 +1,95 @@ +"""J1 actual-header moments and bounded host capability contracts.""" +import json +from pathlib import Path +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest + +import CelerisLab +from CelerisLab.config import LBMConfig, load_lbm_config +from CelerisLab.cuda import compiler_v2 as compiler +from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint +from .test_numerical_operators import compile_run +from .test_state_regressions import device, make_field, manager + + +def test_planar_helper_moments_and_known_populations(tmp_path): + root = Path(__file__).resolve().parents[2] + assert Path(CelerisLab.__file__).resolve() == root/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == root/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + lines = compile_run(tmp_path, 'planar_velocity.cpp', dict(WALL_NORMAL_VELOCITY='0.0f')) + cx = np.array([0,1,-1,0,0,1,-1,1,-1]) + cy = np.array([0,0,0,1,-1,1,-1,-1,1]) + w = np.array([4/9]+[1/9]*4+[1/36]*4) + for start in range(0, len(lines), 3): + top, rho, u, v, neq = np.fromstring(lines[start], sep=' ') + before, after = [np.fromstring(s, sep=' ') for s in lines[start+1:start+3]] + known = cy != (-1 if top else 1) + np.testing.assert_array_equal(after[known], before[known]) + density = after.sum() + np.testing.assert_allclose([after@cx, after@cy], density*np.array([u,v]), atol=3e-7, rtol=0) + if not neq: + cu = cx*u+cy*v + expected = w*rho*(1+3*cu+4.5*cu**2-1.5*(u*u+v*v)) + np.testing.assert_allclose(after, expected, atol=3e-7, rtol=0) + assert abs(density-rho) <= 3e-7 + + +@pytest.mark.parametrize('override', [ + dict(collision='TRT'), dict(collision='MRT'), dict(streaming='esopull'), + dict(store_precision='FP16S'), dict(ddf_shifting=True), dict(les_enabled=True), + dict(lattice_model='D3Q19'), dict(wall_normal_velocity=1.), + dict(wall_normal_velocity=float('nan')), +]) +def test_planar_unsupported_configuration(override): + with pytest.raises(ValueError): + LBMConfig(y_wall_bc='on_site_velocity', **override).validate_runtime() + + +def test_planar_config_roundtrip_and_fixed_targets(tmp_path): + path = Path(__file__).resolve().parents[2]/'src/CelerisLab/configs/config_lbm.json' + data = json.loads(path.read_text()) + data['method'].update(y_wall_bc='on_site_velocity', wall_normal_velocity=-.01, + collision='SRT', streaming='double_buffer', + store_precision='FP32', ddf_shifting=False) + data['method']['les']['enabled'] = False + path = tmp_path/'config.json' + path.write_text(json.dumps(data)) + cfg = load_lbm_config(str(path)) + assert float(cfg.to_macros()['WALL_NORMAL_VELOCITY'][:-1]) == -.01 + with pytest.raises(ValueError, match='object-free'): + compiler.generate_config(cfg, n_objects=1, build_root=tmp_path) + before = cfg.allocation_signature() + cfg.wall_normal_velocity = .01 + assert cfg.allocation_signature() != before + + +def test_planar_force_rejected_before_state_change(device): + field = make_field(device, y_wall_bc='on_site_velocity') + before, writes = field._runtime_params.copy(), device.writes + with pytest.raises(ValueError, match='body forces'): + field.update_params(fx=.001) + assert field._runtime_params == before and device.writes == writes + + +@pytest.mark.parametrize('damage', ['target', 'mode', 'force']) +def test_planar_checkpoint_contract_before_writes(device, tmp_path, damage): + field = make_field(device, y_wall_bc='on_site_velocity') + bodies = manager(field) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'plane.h5')) + with h5py.File(path, 'a') as hf: + if damage == 'force': + hf.attrs['runtime_params_json'] = '{"fx":0.001}' + else: + cfg = json.loads(hf.attrs['config_json']) + cfg['wall_normal_velocity' if damage == 'target' else 'y_wall_bc'] = ( + .01 if damage == 'target' else 'bounce_back') + hf.attrs['config_json'] = json.dumps(cfg) + writes = device.writes + with pytest.raises(ValueError): + load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies) + assert device.writes == writes diff --git a/tests/unit/test_refinement.py b/tests/unit/test_refinement.py new file mode 100644 index 0000000..bfe8e74 --- /dev/null +++ b/tests/unit/test_refinement.py @@ -0,0 +1,167 @@ +"""CPU geometry/contracts only: no time stepping, CUDA or reference edits.""" +import hashlib +import importlib.util +from pathlib import Path +import sys +import unittest + +import numpy as np + +from CelerisLab.config import LBMConfig +from CelerisLab.lbm import refinement +from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY +from CelerisLab.lbm.refinement import ACTIVE, INNER_GHOST, OUTER_GHOST, FixedPatch2D + +ROOT = Path(__file__).resolve().parents[2] + + +def config(**kwargs): + return LBMConfig(**dict(nx=16, ny=16, viscosity=.1, **kwargs)) + + +class FixedPatchTests(unittest.TestCase): + def test_source_and_cpu_import_boundary(self): + self.assertEqual(Path(refinement.__file__).resolve(), + ROOT / "src/CelerisLab/lbm/refinement.py") + self.assertFalse(any(name == "pycuda" or name.startswith("pycuda.") for name in sys.modules)) + self.assertNotIn("CelerisLab.simulation", sys.modules) + self.assertNotIn("CelerisLab.lbm.field", sys.modules) + + def test_all_tables_match_pinned_reference_without_advancing(self): + path = ROOT / "tests/validation/multigrid/cpu_reference.py" + self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(), + "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e") + spec = importlib.util.spec_from_file_location("refinement_geometry_reference", path) + ref = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = ref + spec.loader.exec_module(ref) + maps = ref.build_maps(ref.make_spec(collision="srt")) + p = FixedPatch2D.from_config(config(), (4, 4, 12, 12)) + np.testing.assert_array_equal(p.fine_kind, maps.fine_class) + gy, gx = np.nonzero(~maps.fine_active) + np.testing.assert_array_equal(p.ghost_fine, gy * 24 + gx) + np.testing.assert_array_equal(p.ghost_coarse, + maps.fine_parent[gy] * 16 + maps.fine_parent[gx]) + targets = [q * 256 + y * 16 + x for q, y, x in maps.coalesce] + sources = [[q * 576 + b * 24 + a + for b in (4 + 2 * (y - 4), 5 + 2 * (y - 4)) + for a in (4 + 2 * (x - 4), 5 + 2 * (x - 4))] + for q, y, x in maps.coalesce] + np.testing.assert_array_equal(p.coalesce_coarse, targets) + np.testing.assert_array_equal(p.coalesce_fine, sources) + self.assertEqual(len(targets), 100) + self.assertEqual(len(set(p.coalesce_fine.ravel().tolist())), 400) + self.assertEqual(np.count_nonzero(p.fine_kind == ACTIVE), 256) + self.assertEqual(len(p.ghost_fine), 320) + self.assertEqual(self.corner_pass_count(p), 8) + + @staticmethod + def corner_pass_count(p): + ny, nx = p.coarse_shape + x0, y0, x1, y1 = p.bounds + count = 0 + for slot in p.coalesce_coarse.tolist(): + q, cell = divmod(slot, nx * ny) + y, x = divmod(cell, nx) + sx, sy = x - D2Q9_EX[q], y - D2Q9_EY[q] + count += not (x0 <= sx < x1 and y0 <= sy < y1) + return count + + def test_rectangular_shifted_patch_against_geometric_definition(self): + p = FixedPatch2D.from_config(LBMConfig(nx=22, ny=18, viscosity=.1), (7, 6, 13, 10)) + expected = set() + for y in range(18): + for x in range(22): + if 7 <= x < 13 and 6 <= y < 10: + continue + for q, (cx, cy) in enumerate(zip(D2Q9_EX, D2Q9_EY)): + if q and 7 <= x + .5 - cx / 2 <= 13 and 6 <= y + .5 - cy / 2 <= 10: + expected.add(q * 22 * 18 + y * 22 + x) + self.assertEqual(set(p.coalesce_coarse.tolist()), expected) + self.assertEqual(p.fine_shape, (16, 20)) + self.assertEqual(p.fine_origin, (5., 4.)) + fy, fx = p.fine_shape + for coarse_slot, children in zip(p.coalesce_coarse.tolist(), p.coalesce_fine.tolist()): + q, cell = divmod(coarse_slot, 22 * 18) + y, x = divmod(cell, 22) + centers = [] + for slot in children: + fq, fine_cell = divmod(slot, fy * fx) + b, a = divmod(fine_cell, fx) + self.assertEqual(fq, q) + self.assertEqual(p.fine_kind[b, a], INNER_GHOST) + centers.append((p.fine_origin[0] + (a + .5) / 2, + p.fine_origin[1] + (b + .5) / 2)) + np.testing.assert_array_equal(np.mean(centers, axis=0), (x + .5, y + .5)) + self.assertEqual(self.corner_pass_count(p), 8) + + def test_smallest_patch_and_extreme_halo_margin(self): + p = FixedPatch2D.from_config(LBMConfig(nx=5, ny=5, viscosity=.1), (2, 2, 3, 3)) + self.assertEqual(p.fine_shape, (10, 10)) + self.assertEqual(p.fine_origin, (0., 0.)) + self.assertEqual((int(p.ghost_coarse.min()), int(p.ghost_coarse.max())), (0, 24)) + self.assertEqual(np.count_nonzero(p.fine_kind == ACTIVE), 4) + self.assertEqual(self.corner_pass_count(p), 8) + self.assertTrue(np.all(p.fine_kind.ravel()[p.coalesce_fine % 100] == INNER_GHOST)) + + def test_config_and_level_scaling_are_isolated(self): + cfg = config() + before = vars(cfg).copy() + p = FixedPatch2D.from_config(cfg, (4, 4, 12, 12)) + self.assertEqual(vars(cfg), before) + self.assertAlmostEqual(p.tau_coarse, .8) + self.assertAlmostEqual(p.tau_fine, 1.1) + self.assertAlmostEqual((p.tau_coarse - .5) / 3, + (p.tau_fine - .5) / 3 * .5) + cfg.nx = 999 + self.assertEqual(p.coarse_shape, (16, 16)) + + def test_tables_are_compact_contiguous_and_readonly(self): + small = FixedPatch2D.from_config(config(), (4, 4, 12, 12)) + large = FixedPatch2D.from_config(LBMConfig(nx=512, ny=256, viscosity=.1), (4, 4, 12, 12)) + self.assertEqual(small.table_bytes, large.table_bytes) + self.assertEqual(small.table_bytes, 7136) + for name, dtype in (("fine_kind", np.uint8), ("ghost_fine", np.uint32), + ("ghost_coarse", np.uint32), ("coalesce_coarse", np.uint64), + ("coalesce_fine", np.uint64)): + array = getattr(small, name) + self.assertEqual(array.dtype, dtype) + self.assertTrue(array.flags.c_contiguous) + with self.assertRaises(ValueError): + array.flat[0] = 0 + + def test_invalid_geometry_and_index_overflow_rejected_before_allocation(self): + for bounds in ((4, 4, 4, 12), (12, 4, 4, 12), (1, 4, 12, 12), + (4, 4, 15, 12), (4., 4, 12, 12), (True, 4, 12, 12), + (np.bool_(True), 4, 12, 12), (4, 4, 12)): + with self.subTest(bounds=bounds), self.assertRaises(ValueError): + FixedPatch2D.from_config(config(), bounds) + for nx, ny, bounds in ((2**32, 10, (2, 2, 3, 3)), + (50000, 50000, (2, 2, 49998, 49998))): + with self.assertRaisesRegex(ValueError, "uint32"): + FixedPatch2D.from_config(LBMConfig(nx=nx, ny=ny, viscosity=.1), bounds) + + def test_unsupported_backend_and_fine_relaxation_rejected(self): + for kwargs in ({"collision": "TRT"}, {"collision": "MRT"}, {"streaming": "esopull"}, + {"ddf_shifting": True}, {"les_enabled": True}, + {"store_precision": "FP16S"}, {"lattice_model": "D3Q19"}, + {"data_type": "FP64"}, {"omega_min": 1.0}): + cfg = config(**kwargs) + before = vars(cfg).copy() + with self.subTest(kwargs=kwargs), self.assertRaises(ValueError): + FixedPatch2D.from_config(cfg, (4, 4, 12, 12)) + self.assertEqual(vars(cfg), before) + + def test_handoff_rejects_wrong_phase_and_unsynchronized_counts(self): + p = FixedPatch2D.from_config(config(), (4, 4, 12, 12)) + p.check_synchronization(0, 0, phase="postcollision_g") + p.check_synchronization(8, 16, phase="postcollision_g") + for c, f, phase in ((8, 16, "arrival_f"), (8, 15, "postcollision_g"), + (-1, -2, "postcollision_g"), (1., 2, "postcollision_g"), + (True, 2, "postcollision_g"), (1, np.bool_(True), "postcollision_g")): + with self.subTest(c=c, f=f, phase=phase), self.assertRaises(ValueError): + p.check_synchronization(c, f, phase=phase) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/unit/test_reservoir.py b/tests/unit/test_reservoir.py new file mode 100644 index 0000000..63be8fa --- /dev/null +++ b/tests/unit/test_reservoir.py @@ -0,0 +1,90 @@ +"""Actual reservoir helper moments and static boundary restart contracts.""" +import json +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest + +from CelerisLab.config import LBMConfig, load_lbm_config +from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint +from .test_numerical_operators import compile_run, ROOT +from .test_state_regressions import device, make_field, manager + + +def test_reservoir_moments_and_known_populations(tmp_path): + lines = compile_run(tmp_path, 'reservoir.cpp', {}) + cx = np.array([0,1,-1,0,0,1,-1,1,-1]) + cy = np.array([0,0,0,1,-1,1,-1,-1,1]) + weights = np.array([4/9]+[1/9]*4+[1/36]*4) + for start in range(0, len(lines), 3): + rho, v, neq = np.fromstring(lines[start], sep=' ') + before, after = [np.fromstring(s, sep=' ') for s in lines[start+1:start+3]] + known = cy >= 0 + np.testing.assert_array_equal(after[known], before[known]) + # The normal momentum reference uses ONLY prescribed rho and original known values. + jy = before[cy == 0].sum()+2*before[cy == 1].sum()-rho + np.testing.assert_allclose([after.sum(), after@cx, after@cy], [rho, 0., jy], atol=3e-7, rtol=0) + if not neq: + cu = cy*v + expected = weights*rho*(1+3*cu+4.5*cu**2-1.5*v*v) + np.testing.assert_allclose(after, expected, atol=3e-7, rtol=0) + assert abs(after@cy/rho-v) < 3e-7 + + +@pytest.mark.parametrize('override', [dict(top_wall_bc='unknown'), dict(y_wall_bc='bounce_back'), + dict(velocity=.01), dict(rho=0.)]) +def test_reservoir_invalid_configuration(override): + cfg = dict(y_wall_bc='on_site_velocity', top_wall_bc='reservoir', velocity=0.) + cfg.update(override) + with pytest.raises(ValueError): + LBMConfig(**cfg).validate_runtime() + + +def test_reservoir_json_and_static_signature(tmp_path): + data = json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) + data['physics']['velocity'] = 0. + data['method'].update(y_wall_bc='on_site_velocity', top_wall_bc='reservoir', + collision='SRT', streaming='double_buffer', store_precision='FP32', ddf_shifting=False) + data['method']['les']['enabled'] = False + path = tmp_path/'config.json' + path.write_text(json.dumps(data)) + cfg = load_lbm_config(path) + assert cfg.to_macros()['TOP_WALL_BC'] == 1 + before = cfg.allocation_signature() + cfg.rho *= 1.01 + assert before != cfg.allocation_signature() + cfg.rho /= 1.01 + cfg.top_wall_bc = 'velocity' + assert before != cfg.allocation_signature() + + +@pytest.mark.parametrize('mode, damage, accepted', [ + ('reservoir', 'mode', False), ('reservoir', 'missing', False), + ('reservoir', 'rho', False), ('reservoir', 'u', False), + ('reservoir', None, True), ('velocity', 'missing', True), ('velocity', 'mode', False), +]) +def test_reservoir_checkpoint_contract(device, tmp_path, mode, damage, accepted): + field = make_field(device, velocity=0., y_wall_bc='on_site_velocity', top_wall_bc=mode) + bodies = manager(field) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'reservoir.h5')) + with h5py.File(path, 'a') as hf: + assert hf.attrs['ckpt_version'] == 3 + cfg = json.loads(hf.attrs['config_json']) + if damage == 'missing': cfg.pop('top_wall_bc') + elif damage == 'mode': cfg['top_wall_bc'] = 'velocity' if mode == 'reservoir' else 'reservoir' + elif damage == 'rho': cfg['rho'] = 1.01 + elif damage == 'u': cfg['velocity'] = .01 + hf.attrs['config_json'] = json.dumps(cfg) + writes, before = device.writes, field.ddf.copy() + stepper = SimpleNamespace(_step_count=0) + if accepted: + load_checkpoint(path, field, stepper, field.cfg, bodies) + np.testing.assert_array_equal(field.ddf, before) + assert stepper._step_count == 3 + else: + with pytest.raises(ValueError, match='boundary mismatch'): + load_checkpoint(path, field, stepper, field.cfg, bodies) + assert device.writes == writes and stepper._step_count == 0 diff --git a/tests/unit/test_simulation_lifecycle.py b/tests/unit/test_simulation_lifecycle.py new file mode 100644 index 0000000..2405c32 --- /dev/null +++ b/tests/unit/test_simulation_lifecycle.py @@ -0,0 +1,115 @@ +"""Simulation cleanup orchestration, using a CPU owner model (no GPU calls).""" +from contextlib import contextmanager +from types import SimpleNamespace + +import pytest + +pytest.importorskip("pycuda.driver") +from CelerisLab import simulation as module +from CelerisLab.config import LBMConfig, BodyConfig + + +class Owner: + def __init__(self, *args): + self.is_active = False + self.is_closed = False + self.sm_arch = "sm_70" + self.detach_failure = False + + @contextmanager + def activate(self): + assert not self.is_closed + self.is_active = True + try: + yield self + finally: + self.is_active = False + + def close(self): + if self.detach_failure: + self.detach_failure = False + raise RuntimeError("detach failed") + self.is_closed = True + + +def make_simulation(monkeypatch): + sim = module.Simulation.__new__(module.Simulation) + sim.ctx = Owner() + sim._closed = False + sim._build_dir = "private-build" + for name in ("stepper", "bodies", "field", "_module", "_stream"): + setattr(sim, name, object()) + monkeypatch.setattr(module, "cuda", SimpleNamespace( + Context=SimpleNamespace(synchronize=lambda: None))) + monkeypatch.setattr(module.compiler, "cleanup_build_dir", lambda path: None) + return sim + + +def test_close_inside_activation_preserves_resources(monkeypatch): + sim = make_simulation(monkeypatch) + original_field = sim.field + with sim.ctx.activate(): + with pytest.raises(RuntimeError, match="active CUDA"): + sim.close() + assert sim.field is original_field + assert not sim._closed + sim.close() + assert sim._closed and sim.ctx.is_closed + + +@pytest.mark.parametrize("failure", ["synchronize", "detach", "directory"]) +def test_close_failure_is_retryable(monkeypatch, failure): + sim = make_simulation(monkeypatch) + if failure == "detach": + sim.ctx.detach_failure = True + else: + calls = [] + def fail_once(*args): + calls.append(args) + if len(calls) == 1: + raise RuntimeError(failure + " failed") + if failure == "synchronize": + monkeypatch.setattr(module.cuda.Context, "synchronize", fail_once) + else: + monkeypatch.setattr(module.compiler, "cleanup_build_dir", fail_once) + with pytest.raises(RuntimeError, match="failed"): + sim.close() + assert not sim._closed + assert sim._build_dir == "private-build" + sim.close() + assert sim._closed and sim.ctx.is_closed + assert sim._build_dir is None + sim.close() + + +def test_constructor_cleanup_preserves_original_failure(monkeypatch): + original = RuntimeError("compile setup failed") + def fail_build(*args, **kwargs): + raise original + def fail_close(self): + raise RuntimeError("cleanup failed") + monkeypatch.setattr(module, "load_lbm_config", lambda path: LBMConfig()) + monkeypatch.setattr(module, "load_body_config", lambda path: BodyConfig()) + monkeypatch.setattr(module, "CudaContext", Owner) + monkeypatch.setattr(module.compiler, "generate_config", fail_build) + monkeypatch.setattr(module.Simulation, "close", fail_close) + with pytest.raises(RuntimeError) as exc: + module.Simulation() + assert exc.value is original + + +@pytest.mark.parametrize("name,value,error", [ + ("steps", -1, ValueError), ("steps", 1.5, TypeError), + ("checkpoint_interval", -1, ValueError), + ("checkpoint_interval", 1.5, TypeError), +]) +def test_invalid_step_request_has_no_state_side_effects(monkeypatch, name, value, error): + sim = make_simulation(monkeypatch) + sim._initialized = True + calls = [] + sim._assert_runtime_contracts = lambda: calls.append("accessed runtime") + kwargs = {"steps": 1, name: value} + with pytest.raises(error): + sim.run(**kwargs) + assert calls == [] # no pending edits discarded, obs cleared or GPU work queued + sim.close() diff --git a/tests/unit/test_slot_velocity.py b/tests/unit/test_slot_velocity.py new file mode 100644 index 0000000..abd40fa --- /dev/null +++ b/tests/unit/test_slot_velocity.py @@ -0,0 +1,81 @@ +"""Static slot targets and checkpoint compatibility, using the actual CUDA helper.""" +import json +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest + +from CelerisLab.config import LBMConfig, load_lbm_config +from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint +from .test_numerical_operators import compile_run, ROOT +from .test_state_regressions import device, make_field, manager + + +@pytest.mark.parametrize('width', [0, 16, 48]) +@pytest.mark.parametrize('amplitude', [0., .002, -.002]) +def test_actual_slot_targets_and_requested_volume(tmp_path, width, amplitude): + cfg = LBMConfig(nx=48, velocity=0., y_wall_bc='on_site_velocity', + wall_slot_width=width, wall_normal_velocity=amplitude) + macros = cfg.to_macros() + values = np.array(compile_run(tmp_path, 'slot_profile.cpp', { + k: macros[k] for k in ('NX', 'WALL_SLOT_WIDTH', 'WALL_NORMAL_VELOCITY')}), dtype=float) + # Independent node quadrature; includes both zero flanks and the full-width exception. + x = np.arange(48)-23.5 + expected = amplitude*np.clip(1-(x/8)**2, 0, None)**2 if width == 16 else np.full(48, amplitude) + np.testing.assert_allclose(values, expected, atol=5e-10, rtol=0) + np.testing.assert_array_equal(values, values[::-1]) + assert abs(values.sum()-48*cfg.top_wall_normal_velocity) <= 1e-8 + assert abs(float(macros['TOP_WALL_NORMAL_VELOCITY'][:-1])-expected.mean()) < 1e-16 + + +@pytest.mark.parametrize('change', [dict(wall_slot_width=w) for w in (-1, 1, 49, 2.5, True)] + + [dict(y_wall_bc='bounce_back'), dict(velocity=.01)]) +def test_slot_invalid_configuration(change): + values = dict(nx=48, velocity=0., y_wall_bc='on_site_velocity', wall_slot_width=16) + values.update(change) + with pytest.raises(ValueError, match='wall_slot_width'): + LBMConfig(**values).validate_runtime() + + +def test_slot_json_and_static_signature(tmp_path): + data = json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) + data['physics']['velocity'] = 0. + data['method'].update(y_wall_bc='on_site_velocity', wall_slot_width=16, + collision='SRT', streaming='double_buffer', store_precision='FP32', + ddf_shifting=False) + data['method']['les']['enabled'] = False + path = tmp_path/'config.json' + path.write_text(json.dumps(data)) + cfg = load_lbm_config(path) + assert cfg.wall_slot_width == 16 + before = cfg.allocation_signature() + cfg.wall_slot_width = 18 + assert before != cfg.allocation_signature() + + +@pytest.mark.parametrize('width, saved_width, accepted', [(6, 4, False), (6, None, False), + (0, None, True), (6, 6, True)]) +def test_slot_checkpoint_width_before_device_writes(device, tmp_path, width, saved_width, accepted): + field = make_field(device, velocity=0., y_wall_bc='on_site_velocity', wall_slot_width=width) + bodies = manager(field) + bodies.sync_to_gpu(field) + stepper = SimpleNamespace(_step_count=0) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'slot.h5')) + with h5py.File(path, 'a') as hf: + assert hf.attrs['ckpt_version'] == 3 + config = json.loads(hf.attrs['config_json']) + config.pop('wall_slot_width') + if saved_width is not None: + config['wall_slot_width'] = saved_width + hf.attrs['config_json'] = json.dumps(config) + before, writes = field.ddf.copy(), device.writes + if accepted: + load_checkpoint(path, field, stepper, field.cfg, bodies) + np.testing.assert_array_equal(field.ddf, before) + assert stepper._step_count == 3 + else: + with pytest.raises(ValueError, match='wall_slot_width'): + load_checkpoint(path, field, stepper, field.cfg, bodies) + assert device.writes == writes and stepper._step_count == 0 diff --git a/tests/unit/test_state_regressions.py b/tests/unit/test_state_regressions.py new file mode 100644 index 0000000..2d3ad7b --- /dev/null +++ b/tests/unit/test_state_regressions.py @@ -0,0 +1,517 @@ +"""Real host methods with bounded device-memory doubles; no CUDA context/init. + +The memory double checks copy bounds and rejects pageable async transfers. +Kernel math is covered separately by test_numerical_operators; snapshot tests +here check host layout, current-parity arguments and field-only semantics. +""" +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest +import pycuda.driver as cuda + +from CelerisLab.config import LBMConfig +from CelerisLab.body.manager import ObjectManager +from CelerisLab.body.objects import SimObject +from CelerisLab.body.geometry.circle import CircleGeometry +from CelerisLab.body.ddf_patch import patch_ddf_for_body_sync, upload_patched_ddf +from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint +from CelerisLab.lbm.field import LBMField +from CelerisLab.lbm.descriptors import FLUID, SOLID, OBSTACLE, SENSOR_FLAG, FRC_REGION +from CelerisLab.lbm.equilibrium import compute_feq_d2q9 + +W = np.array([4/9] + [1/9]*4 + [1/36]*4, dtype=np.float32) + + +class Memory: + def __init__(self, address, size): + self.address, self.data, self.freed = address, bytearray(size), False + + def __int__(self): + return self.address + + def free(self): + assert not self.freed + self.freed = True + + +class DeviceDouble: + def __init__(self): + self.blocks, self.pinned = [], [] + self.writes, self.syncs = 0, 0 + + def alloc(self, size): + assert size > 0 + block = Memory(1000000*(len(self.blocks)+1), int(size)) + self.blocks.append(block) + return block + + def locate(self, ptr, size): + address = int(ptr) + for block in self.blocks: + if block.address <= address < block.address+len(block.data): + offset = address-block.address + assert not block.freed and offset+size <= len(block.data) + return block, offset + raise AssertionError(f"Invalid device address: {address}") + + def htod(self, ptr, array): + data = array if isinstance(array, bytes) else np.asarray(array).tobytes() + block, offset = self.locate(ptr, len(data)) + block.data[offset:offset+len(data)] = data + self.writes += 1 + + def dtoh(self, array, ptr): + block, offset = self.locate(ptr, array.nbytes) + array[:] = np.frombuffer(block.data, dtype=array.dtype, + count=array.size, offset=offset).reshape(array.shape) + + def pinned_empty(self, shape, dtype): + array = np.empty(shape, dtype=dtype) + self.pinned.append(array) + return array + + def require_pinned(self, array): + assert any(np.shares_memory(array, item) for item in self.pinned), "pageable async transfer" + + def htod_async(self, ptr, array, stream): + self.require_pinned(array) + self.htod(ptr, array) + + def dtoh_async(self, array, ptr, stream): + self.require_pinned(array) + self.dtoh(array, ptr) + + def synchronize(self): + self.syncs += 1 + + +@pytest.fixture +def device(monkeypatch): + device = DeviceDouble() + for name, method in { + "mem_alloc": device.alloc, "memcpy_htod": device.htod, + "memcpy_dtoh": device.dtoh, "memcpy_htod_async": device.htod_async, + "memcpy_dtoh_async": device.dtoh_async, "pagelocked_empty": device.pinned_empty, + }.items(): + monkeypatch.setattr(cuda, name, method) + monkeypatch.setattr(cuda, "Context", SimpleNamespace(synchronize=device.synchronize)) + return device + + +class ModuleDouble: + def __init__(self, device): + self.device, self.globals, self.calls = device, {}, [] + for name, value in { + "d_w": W, + "d_cx": np.array([0,1,-1,0,0,1,-1,1,-1], dtype=np.int32), + "d_cy": np.array([0,0,0,1,-1,1,-1,-1,1], dtype=np.int32), + "d_params": np.zeros(5, dtype=np.float32), + }.items(): + ptr = device.alloc(value.nbytes) + device.htod(ptr, value) + self.globals[name] = ptr, value.nbytes + + def get_global(self, name): + return self.globals[name] + + def get_function(self, name): + def launch(ddf, scratch, indices, count, step, **kwargs): + self.calls.append((name, int(step))) + idx = np.frombuffer(indices.data, dtype=np.uint32) + raw = np.frombuffer(ddf.data, dtype=np.float32).reshape(9, -1) + # Deliberately simple invertible parity layout. This is a host + # protocol oracle, not a substitute for testing CUDA EsoPull math. + view = raw if int(step) % 2 == 0 else raw[::-1] + local = np.frombuffer(scratch.data, dtype=np.float32).reshape(int(count), 9) + if name == "DecodeCellsToPhysical": + local[:] = view[:, idx].T + elif name == "EncodePhysicalToCells": + view[:, idx] = local.T + else: + raise AssertionError(name) + return launch + + +def make_field(device, **kwargs): + cfg = LBMConfig(nx=12, ny=10, viscosity=.1, **kwargs) + cfg.validate() + field = LBMField(cfg, ModuleDouble(device)) + field.ddf[:] = np.repeat(compute_feq_d2q9(1.1, .03, -.01)[:, None], field.n, axis=1).reshape(-1) + field.upload_ddf() + field.upload_flags() + return field + + +def obj(x=5, y=5, r=2, **kwargs): + return SimObject(-1, geometry=CircleGeometry(x, y, r), center=(x,y), radius=r, **kwargs) + + +def manager(field, objects=()): + bodies = ObjectManager(field.nx, field.ny, field.nz, field.nq, field.cfg) + for item in objects: + bodies.add(item) + return bodies + + +@pytest.mark.parametrize("precision", ["FP32", "FP16S"]) +@pytest.mark.parametrize("shift", [False, True]) +def test_patch_qmajor_storage_and_untouched_bytes(device, precision, shift): + field = make_field(device, store_precision=precision, ddf_shifting=shift) + old = field.ddf.copy() + before = bytes(field.ddf_gpu.data) + added = np.zeros(field.n, dtype=bool) + added[[5, 6, field.n-2]] = True + released = np.zeros_like(added) + patch_ddf_for_body_sync(field, old, field.flag, field.flag, added, released) + expected = old.reshape(9, -1).copy() + expected[:, added] = compute_feq_d2q9(field.cfg.rho, 0, 0)[:, None] + np.testing.assert_array_equal(field.ddf.reshape(9, -1), expected) + upload_patched_ddf(field, added, released) + encoded = (expected - W[:, None]) if shift else expected + encoded = (encoded * 32768).astype(np.float16) if precision == "FP16S" else encoded + np.testing.assert_array_equal(np.frombuffer(field.ddf_gpu.data, dtype=field.store_dtype).reshape(9,-1), encoded) + itemsize = field.store_bytes + for q in range(9): + for cell in np.flatnonzero(~added): + offset = (q*field.n+cell)*itemsize + assert field.ddf_gpu.data[offset:offset+itemsize] == before[offset:offset+itemsize] + assert field.ddf_gpu.data == field.temp_gpu.data + + +def test_released_bfs_uses_qmajor_donors(device): + field = make_field(device) + old = field.ddf.copy() + flags = np.full(field.n, FLUID, dtype=np.uint16) + released = np.zeros(field.n, dtype=bool) + released.reshape(field.ny,field.nx)[3:7,3:8] = True + flags[released] = SOLID + field.ddf.reshape(9,-1)[:,released] = -99 + patch_ddf_for_body_sync(field, old, flags, field.flag, + np.zeros(field.n,dtype=bool), released) + expected = compute_feq_d2q9(1.1,.03,-.01) + np.testing.assert_allclose(field.ddf.reshape(9,-1)[:,released], + np.repeat(expected[:,None], released.sum(), axis=1), atol=7e-8) + + +def test_force_survives_other_body_edit_sync_and_preinit_remove(device): + field = make_field(device) + bodies = manager(field, [obj(x=3), obj(x=8, is_force_region=True)]) + bodies.set_force_state(1, .004, -.003) + bodies.set_body_state(0, .2) + bodies._refresh_action_from_objects() + np.testing.assert_allclose(bodies.action[6:8], [.004,-.003]) + bodies.remove(0) + assert bodies.get(0).is_force_region and bodies.get(0).obj_id == 0 + np.testing.assert_allclose(bodies.action[:2], [.004,-.003]) + assert bodies.add(obj(x=3)) == 1 + next_objects = bodies.build_next_objects() + bodies.commit_pending(next_objects, np.zeros(2, dtype=np.int32)) + bodies._refresh_action_from_objects() + np.testing.assert_allclose(bodies.action[:2], [.004,-.003]) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_overlay_preserves_occupancy_and_filters_lists(device, reverse): + field = make_field(device) + objects = [obj(), obj(r=3,is_sensor=True), obj(r=3,is_force_region=True)] + if reverse: + objects.reverse() + bodies = manager(field, objects) + flags = bodies.build_flags(field.build_channel_flags()) + center = 5+5*field.nx + assert flags[center] & SOLID and not flags[center] & FLUID + assert not flags[center] & (SENSOR_FLAG | FRC_REGION) + arrays = bodies.build_compact_lists(flags) + assert all(flags[c] & FLUID and not flags[c] & SOLID for c in arrays[8]) + assert all(flags[c] & FLUID and not flags[c] & SOLID for c in arrays[10]) + sensor = next(o for o in bodies.objects if o.is_sensor) + assert bodies.sensor_cell_counts[sensor.obj_id] == len(arrays[8]) + assert len(arrays[8]) < len(sensor.get_sensor_list(field.nx, field.ny)[0]) + + +def test_overlapping_force_regions_rejected(device): + field = make_field(device) + bodies = manager(field, [obj(is_force_region=True), obj(x=6,is_force_region=True)]) + with pytest.raises(ValueError, match="Overlapping force_region"): + bodies.build_compact_lists(bodies.build_flags(field.flag)) + + +def test_action_upload_and_scratch_are_pinned(device): + field = make_field(device, streaming="esopull") + bodies = manager(field, [obj(is_force_region=True)]) + bodies._allocate_packed_telemetry() + bodies.set_force_state(0,.001,.002) + stream = SimpleNamespace(synchronize=device.synchronize) + bodies._upload_action_async(stream) + np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32), bodies.action) + scratch, host, indices = field.esopull_sync_decode(np.array([3,8],dtype=np.uint32),1,stream) + field.esopull_sync_encode(scratch,host,indices,2,1,stream) + assert scratch.freed and indices.freed and device.syncs >= 2 + + +def test_esopull_snapshot_field_only_across_parity(device): + field = make_field(device, streaming="esopull") + original = field.ddf.copy() + with pytest.raises(ValueError, match="step_id"): + field.snapshot() + field.snapshot(step_id=0) + field.ddf_gpu.data[:] = bytes(len(field.ddf_gpu.data)) + field.restore(step_id=7) + result = np.frombuffer(field.ddf_gpu.data,dtype=np.float32).reshape(9,-1)[::-1] + np.testing.assert_array_equal(result, original.reshape(9,-1)) + assert field.module.calls == [("DecodeCellsToPhysical",0),("EncodePhysicalToCells",7)] + assert field._host_ddf_step is None + + +def test_snapshot_double_buffer_preserves_field_only_semantics(device): + field = make_field(device) + original = field.ddf.copy() + field.snapshot() + field.ddf *= 2 + field.upload_ddf() + field.restore() + field.download_ddf(force=True) + np.testing.assert_array_equal(field.ddf,original) + + +def test_checkpoint_restores_action_cache_and_observation(device,tmp_path): + field = make_field(device) + bodies = manager(field,[obj(x=3,r=1),obj(x=8,r=1,is_force_region=True)]) + bodies.set_body_state(0,.125) + bodies.set_force_state(1,.001,-.002) + bodies.sync_to_gpu(field) + field.update_params(fx=.0002) + bodies.obs_pinned[:] = np.arange(bodies.obs_total_floats) + device.htod(bodies.obs_gpu,bodies.obs_pinned) + bodies._obs_accum_steps = 4 + stepper = SimpleNamespace(step_count=7,_step_count=7) + path = save_checkpoint(field,stepper,field.cfg,bodies,str(tmp_path/'state.h5')) + expected = bytes(field.ddf_gpu.data) + bodies.set_body_state(0,0) + bodies.set_force_state(1,0,0) + field.ddf[:] = 99 + field._host_ddf_step = 7 + field.ddf_gpu.data[:] = bytes(len(field.ddf_gpu.data)) + load_checkpoint(path,field,stepper,field.cfg,bodies) + assert bytes(field.ddf_gpu.data) == expected + assert field._host_ddf_step is None + field.download_ddf(step_id=7) + assert field.ddf.max() < 1 + np.testing.assert_allclose(bodies.action[[5,6,7]],[.125,.001,-.002]) + np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32),bodies.action) + assert bodies._obs_accum_steps == 4 + np.testing.assert_array_equal(bodies.obs_pinned,np.arange(bodies.obs_total_floats)) + assert field._runtime_params['fx'] == .0002 + + +@pytest.mark.parametrize("periodic_x", [False, True]) +@pytest.mark.parametrize("with_objects", [False, True]) +def test_checkpoint_restores_actual_domain(device, tmp_path, periodic_x, with_objects): + source = make_field(device) + objects = [obj(x=2, r=1), obj(x=10, r=2, is_sensor=True)] if with_objects else [] + bodies = manager(source, objects) + base = source.build_channel_flags() + if periodic_x: + base.reshape(source.ny, source.nx)[1:-1, [0, -1]] = FLUID + source.flag = bodies.build_flags(base) + source.upload_flags() + bodies.sync_to_gpu(source, rebuild_flags=False) + saved_flags = source.flag.copy() + # The device, not a possibly stale host mirror, is authoritative at save. + source.flag = source.build_channel_flags() + path = save_checkpoint(source, SimpleNamespace(step_count=3), source.cfg, + bodies, str(tmp_path / 'domain.h5')) + target = make_field(device) + target_bodies = manager(target, objects) + stepper = SimpleNamespace(_step_count=0) + load_checkpoint(path, target, stepper, target.cfg, target_bodies) + assert stepper._step_count == 3 + np.testing.assert_array_equal(target.flag, saved_flags) + np.testing.assert_array_equal(np.frombuffer(target.flag_gpu.data, np.uint16), saved_flags) + assert target.ddf_gpu.data == source.ddf_gpu.data + + +@pytest.mark.parametrize("periodic_x", [False, True]) +@pytest.mark.parametrize("change", ["geometry", "role", "ddf_shape"]) +def test_checkpoint_rejects_before_device_writes(device,tmp_path,change,periodic_x): + field = make_field(device) + bodies = manager(field,[obj()]) + bodies.sync_to_gpu(field) + if periodic_x: + field.flag.reshape(field.ny, field.nx)[1:-1, [0, -1]] = FLUID + field.upload_flags() + path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5')) + if change == 'geometry': + bodies.get(0).geometry = CircleGeometry(6,5,2) + elif change == 'role': + bodies.get(0)._is_sensor = True + else: + with h5py.File(path,'a') as hf: + del hf['ddf'] + hf.create_dataset('ddf',data=np.zeros(2,dtype=np.float32)) + writes = device.writes + with pytest.raises(ValueError): + load_checkpoint(path,field,SimpleNamespace(_step_count=3),field.cfg,bodies) + assert device.writes == writes + + +def test_checkpoint_parameter_study_keeps_new_viscosity(device,tmp_path): + field = make_field(device) + bodies = manager(field) + bodies.sync_to_gpu(field) + path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5')) + field.cfg.viscosity = .2 + field.cfg.validate() + load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies) + assert field._runtime_params['omega'] == field.cfg.omega + + +@pytest.mark.parametrize('version', [1, 2]) +def test_legacy_checkpoint_default_domain(device, tmp_path, version): + field = make_field(device) + bodies = manager(field, [obj()]) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, + bodies, str(tmp_path / 'legacy-domain.h5')) + with h5py.File(path, 'a') as hf: + hf.attrs['ckpt_version'] = version + del hf['domain_flag'] + load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies) + # Legacy files have no authoritative base-domain record. Keep their check. + with h5py.File(path, 'a') as hf: + hf['flag'][field.nx] = FLUID + writes = device.writes + with pytest.raises(ValueError, match='geometry'): + load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies) + assert device.writes == writes + + +@pytest.mark.parametrize('damage', ['missing', 'shape', 'dtype', 'boundary', 'hidden', 'annotation']) +def test_checkpoint_rejects_invalid_domain_before_writes(device, tmp_path, damage): + field = make_field(device) + bodies = manager(field, [obj()]) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, + bodies, str(tmp_path / 'invalid-domain.h5')) + with h5py.File(path, 'a') as hf: + if damage in ('missing', 'shape', 'dtype'): + del hf['domain_flag'] + if damage != 'missing': + hf.create_dataset('domain_flag', data=np.ones( + 2 if damage == 'shape' else field.n, + dtype=np.uint16 if damage == 'shape' else np.float32)) + elif damage == 'boundary': + hf['domain_flag'][field.nx] = FLUID + elif damage == 'hidden': + hf['domain_flag'][5 + 5 * field.nx] = SOLID + else: + # Mutate both records: overlay idempotence alone would accept this. + for name in ('domain_flag', 'flag'): + hf[name][field.nx + 1] = FLUID | SENSOR_FLAG + before = bytes(field.ddf_gpu.data), field.flag.copy() + writes = device.writes + with pytest.raises(ValueError): + load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies) + assert device.writes == writes + assert bytes(field.ddf_gpu.data) == before[0] + np.testing.assert_array_equal(field.flag, before[1]) + + +@pytest.mark.parametrize('extra', [OBSTACLE | SOLID, SENSOR_FLAG, FRC_REGION]) +def test_checkpoint_save_rejects_unregistered_overlay(device, tmp_path, extra): + field = make_field(device) + bodies = manager(field) + field.flag[field.nx + 1] |= extra + field.upload_flags() + path = tmp_path / 'unchanged.h5' + path.write_bytes(b'existing checkpoint') + with pytest.raises(ValueError): + save_checkpoint(field, SimpleNamespace(step_count=0), field.cfg, bodies, str(path)) + assert path.read_bytes() == b'existing checkpoint' + + +def test_checkpoint_domain_handles_plain_solid_mask(device, tmp_path): + class PlainSolidCircle(CircleGeometry): + def build_flag_mask(self, nx, ny): + mask = super().build_flag_mask(nx, ny) + mask[(mask & SOLID) != 0] = SOLID + return mask + + field = make_field(device) + body = obj() + body.geometry = PlainSolidCircle(5, 5, 2) + bodies = manager(field, [body]) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, + bodies, str(tmp_path / 'plain-solid.h5')) + with h5py.File(path) as hf: + assert hf['domain_flag'][5 + 5 * field.nx] == FLUID + load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies) + + +@pytest.mark.parametrize("kind",['circle','sensor','force_region']) +def test_legacy_checkpoint_supported_scope(device,tmp_path,kind): + field = make_field(device) + bodies = manager(field,[obj(is_sensor=kind=='sensor',is_force_region=kind=='force_region')]) + bodies.set_body_state(0,.12) + bodies.sync_to_gpu(field) + path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'legacy.h5')) + with h5py.File(path,'a') as hf: + hf.attrs['ckpt_version'] = 1 + del hf.attrs['geometry_json'] + del hf.attrs['runtime_params_json'] + del hf['obs'] + if kind == 'force_region': + with pytest.raises(ValueError,match='Legacy v1.*force_region'): + load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies) + else: + bodies.set_body_state(0,0) + load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies) + assert bodies.action[5] == np.float32(.12) + assert bodies._obs_accum_steps == 0 + + +def test_compact_upload_stream_uses_pinned_staging(device): + field = make_field(device) + bodies = manager(field,[obj(),obj(x=9,r=1,is_sensor=True)]) + bodies.sync_to_gpu(field) + stream = SimpleNamespace(synchronize=device.synchronize) + field.upload_compact_lists(stream=stream) + assert device.syncs >= 2 + np.testing.assert_array_equal(np.frombuffer(field.curved.fluid_idx_gpu.data,dtype=np.uint32), + field.curved.fluid_idx) + np.testing.assert_array_equal(np.frombuffer(field.sensors.cells_gpu.data,dtype=np.uint32), + field.sensors.cells) + + +@pytest.mark.parametrize('attribute,value',[ + ('runtime_params_json','{"fx": "not-a-number"}'),('obs_accum_steps','broken'), +]) +def test_checkpoint_bad_metadata_rejected_before_writes(device,tmp_path,attribute,value): + field = make_field(device) + bodies = manager(field) + bodies.sync_to_gpu(field) + path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5')) + with h5py.File(path,'a') as hf: + hf.attrs[attribute] = value + writes = device.writes + with pytest.raises(ValueError): + load_checkpoint(path,field,SimpleNamespace(_step_count=3),field.cfg,bodies) + assert device.writes == writes + + +def test_checkpoint_preserves_pending_vs_applied_action(device,tmp_path): + field = make_field(device) + bodies = manager(field,[obj(is_force_region=True)]) + bodies.set_force_state(0,.001,.002) + bodies.sync_to_gpu(field) + applied = bodies.action.copy() + bodies.set_force_state(0,.003,.004) # Next upload target; device is unchanged. + pending = bodies.action.copy() + path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5')) + bodies.set_force_state(0,0,0) + load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies) + np.testing.assert_array_equal(bodies.action,pending) + np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32),applied) diff --git a/tests/unit/test_surface_actuation.py b/tests/unit/test_surface_actuation.py new file mode 100644 index 0000000..21dff37 --- /dev/null +++ b/tests/unit/test_surface_actuation.py @@ -0,0 +1,230 @@ +"""Static circle apertures: real geometry and host/device-memory protocol tests.""" +from dataclasses import FrozenInstanceError, asdict +import json +import math +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest + +from CelerisLab.body.geometry.circle import CircleGeometry +from CelerisLab.body.manager import ObjectManager +from CelerisLab.body.objects import SimObject, SurfaceActuation +from CelerisLab.common.checkpoint import load_checkpoint, save_checkpoint +from CelerisLab.config import LBMConfig +from CelerisLab.lbm.curved_links import CurvedLinkSoA +from CelerisLab.lbm.descriptors import FLUID, SOLID +from CelerisLab.simulation import Simulation +from .test_state_regressions import device, make_field, manager + + +def circle(spec=None, *, center=(6., 5.), radius=2.999, **kwargs): + return SimObject(0, geometry=CircleGeometry(*center, radius), center=center, + radius=radius, surface_actuation=spec, **kwargs) + + +def projected(obj): + cfg = LBMConfig(nx=12, ny=10, viscosity=.1) + cfg.validate() + flags = ObjectManager.build_flags_for( + [obj], np.full(cfg.nx*cfg.ny, FLUID, np.uint16), nx=cfg.nx, ny=cfg.ny, nz=1) + cl = CurvedLinkSoA() + cl.assign_host(*obj.get_curved_list(cfg.nx, cfg.ny, cfg.nq, domain_flags=flags)) + cl.assign_surface([obj], cfg, flags) + return cl, cfg, flags + + +@pytest.mark.parametrize('speed', [.002, -.002]) +def test_aperture_uses_true_hit_not_fluid_node_or_clamped_q(speed): + # Fluid (9,5), direction (-1,+1), circle radius 2.999. Actual t < .01, + # whereas the existing Bouzidi q is clipped to .01. The narrow opening + # contains the true hit but neither the fluid-node angle nor clipped hit. + t = (6-math.sqrt(36-8*(9-2.999**2)))/4 + angle = math.atan2(t, 3-t) + spec = SurfaceActuation(angle, .0001, speed) + cl, _, _ = projected(circle(spec)) + index, = np.flatnonzero((cl.fluid_idx == 9+5*12) & (cl.dir == 8)) + assert cl.q[index] == np.float32(.01) + assert angle > spec.half_width + assert math.atan2(.01, 2.99)-angle > spec.half_width + assert cl.aperture[index] + expected = speed*np.array([3-t, t])/2.999 + np.testing.assert_allclose(cl.surface_velocity[index], expected, rtol=2e-6, atol=1e-10) + np.testing.assert_allclose(np.hypot(cl.rx, cl.ry), 2.999, rtol=0, atol=3e-7) + np.testing.assert_array_equal(cl.surface_velocity[~cl.aperture], 0.) + + +def test_surface_zero_preserves_eight_column_geometry_and_mask(): + plain, zero = circle(), circle(SurfaceActuation(0., .7, 0.)) + old, new = plain.get_curved_list(12, 10, 9), zero.get_curved_list(12, 10, 9) + assert len(old) == len(new) == 8 + for before, after in zip(old, new): + np.testing.assert_array_equal(before, after) + np.testing.assert_array_equal(plain.get_flag_mask(12, 10), zero.get_flag_mask(12, 10)) + cl, _, _ = projected(zero) + assert cl.aperture.any() + np.testing.assert_array_equal(cl.surface_velocity, 0.) + assert projected(plain)[0].surface_velocity is None + + +def test_surface_spec_is_immutable_and_wraps_angles(): + spec = SurfaceActuation(math.pi, .2, .002) + with pytest.raises(FrozenInstanceError): + spec.normal_velocity = .003 + angles = np.array([math.pi-.1, -math.pi+.1, 0.]) + velocity, mask = spec.project(np.cos(angles), np.sin(angles)) + np.testing.assert_array_equal(mask, [True, True, False]) + np.testing.assert_allclose(np.linalg.norm(velocity[:2], axis=1), .002*(1-.5**2)**2, + rtol=1e-6) + np.testing.assert_array_equal(velocity[2], 0.) + + +@pytest.mark.parametrize('values', [(0., 0., .002), (0., math.pi+.1, .002), + (0., .2, 1.), (math.nan, .2, .002), + (0., math.inf, .002), (0., .2, math.nan)]) +def test_invalid_surface_spec(values): + with pytest.raises(ValueError): + SurfaceActuation(*values) + + +@pytest.mark.parametrize('override', [dict(streaming='esopull'), dict(store_precision='FP16S'), + dict(ddf_shifting=True), dict(lattice_model='D3Q19')]) +def test_surface_rejects_unsupported_mode_before_registration(override): + cfg = LBMConfig(nx=12, ny=10, **override) + cfg.validate() + bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg) + with pytest.raises(ValueError, match='Surface actuation requires'): + bodies.add(circle(SurfaceActuation(0., .7, .002))) + assert bodies.count == 0 + + +@pytest.mark.parametrize('role', ['is_sensor', 'is_force_region']) +def test_surface_rejects_nonwall_roles(role): + with pytest.raises(ValueError): + circle(SurfaceActuation(0., .7, .002), **{role: True}).validate_surface_actuation(LBMConfig()) + + +@pytest.mark.parametrize('key', ['omega', 'vx', 'vy']) +def test_surface_set_body_rejects_motion_before_mutation(key): + cfg = LBMConfig(nx=12, ny=10) + cfg.validate() + bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg) + item = circle(SurfaceActuation(0., .7, .002)) + bodies.add(item) + sim = Simulation.__new__(Simulation) + sim.lbm_cfg, sim.bodies = cfg, bodies + state, action = asdict(item.state), bodies.action.copy() + with pytest.raises(ValueError, match='stationary'): + sim.set_body(0, **{key: .01}) + assert asdict(item.state) == state + np.testing.assert_array_equal(bodies.action, action) + + +def test_surface_projection_rejects_unresolved_aperture_and_bad_writers(): + with pytest.raises(ValueError, match='no resolved'): + projected(circle(SurfaceActuation(.123456, 1e-9, .002))) + item = circle(SurfaceActuation(0., .7, .002)) + cl, cfg, flags = projected(item) + flags[cl.fluid_idx[0]] = SOLID + with pytest.raises(ValueError, match='active fluid'): + cl.assign_surface([item], cfg, flags) + flags[cl.fluid_idx[0]] = FLUID + cl.fluid_idx[1], cl.dir[1] = cl.fluid_idx[0], cl.dir[0] + with pytest.raises(ValueError, match='unique'): + cl.assign_surface([item], cfg, flags) + + +def test_surface_public_dict_entry_and_sync_copy(): + cfg = LBMConfig(nx=12, ny=10) + cfg.validate() + sim = Simulation.__new__(Simulation) + sim.lbm_cfg, sim._initialized = cfg, False + sim.bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg) + spec = dict(angle=0., half_width=.7, normal_velocity=.002) + oid = sim.add_body('circle', center=(6., 5.), radius=2., surface_actuation=spec) + copied, = sim.bodies.build_next_objects() + assert asdict(copied.surface_actuation) == spec + assert copied.surface_actuation is sim.bodies.get(oid).surface_actuation + + +def case(device, spec): + field = make_field(device) + bodies = manager(field, [circle(spec, radius=2.)]) + bodies.sync_to_gpu(field) + return field, bodies + + +def test_surface_gpu_buffer_lifecycle_with_memory_double(device): + field, bodies = case(device, SurfaceActuation(0., .7, .002)) + cl = field.curved + velocity, exchange = cl.surface_velocity_gpu, cl.link_exchange_gpu + np.testing.assert_array_equal(np.frombuffer(velocity.data, np.float32).reshape(-1, 2), + cl.surface_velocity) + np.testing.assert_array_equal(np.frombuffer(exchange.data, np.float32), 0.) + cl.assign_surface([], field.cfg, field.flag) + cl.upload() + assert velocity.freed and exchange.freed + assert cl.surface_velocity_gpu is None and cl.link_exchange_gpu is None + cl.assign_surface(bodies.objects, field.cfg, field.flag) + cl.upload() + velocity, exchange = cl.surface_velocity_gpu, cl.link_exchange_gpu + cl.free() + assert velocity.freed and exchange.freed and cl.count == 0 + + +@pytest.mark.parametrize('damage', ['missing_spec', 'legacy_missing_spec', 'different_spec', + 'omega', 'vx', 'vy', 'applied_omega']) +def test_surface_checkpoint_rejects_mismatch_before_any_write(device, tmp_path, damage): + field, bodies = case(device, SurfaceActuation(0., .7, .002)) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'surface.h5')) + with h5py.File(path, 'a') as hf: + assert hf.attrs['ckpt_version'] == 4 + if damage in ('missing_spec', 'legacy_missing_spec'): + del hf.attrs['surface_actuation_json'] + if damage == 'legacy_missing_spec': + hf.attrs['ckpt_version'] = 3 + elif damage == 'different_spec': + records = json.loads(hf.attrs['surface_actuation_json']) + records[0]['normal_velocity'] *= -1 + hf.attrs['surface_actuation_json'] = json.dumps(records) + elif damage == 'applied_omega': + action = hf['applied_action'][:] + action[-1] = .01 + hf['applied_action'][:] = action + else: + records = json.loads(hf.attrs['objects_json']) + records[0][damage] = .01 + hf.attrs['objects_json'] = json.dumps(records) + writes, state = device.writes, asdict(bodies.get(0).state) + raw = [bytes(ptr.data) for ptr in (field.ddf_gpu, field.temp_gpu, field.flag_gpu)] + stepper = SimpleNamespace(_step_count=99) + with pytest.raises(ValueError): + load_checkpoint(path, field, stepper, field.cfg, bodies) + assert device.writes == writes and stepper._step_count == 99 + assert asdict(bodies.get(0).state) == state + assert raw == [bytes(ptr.data) for ptr in (field.ddf_gpu, field.temp_gpu, field.flag_gpu)] + + +@pytest.mark.parametrize('enabled', [False, True]) +def test_surface_checkpoint_rebuild_and_legacy_absence(device, tmp_path, enabled): + spec = SurfaceActuation(0., .7, .002) if enabled else None + field, bodies = case(device, spec) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'surface.h5')) + with h5py.File(path, 'a') as hf: + assert hf.attrs['ckpt_version'] == (4 if enabled else 3) + if not enabled: + del hf.attrs['surface_actuation_json'] + restored, restored_bodies = case(device, spec) + stepper = SimpleNamespace(_step_count=0) + load_checkpoint(path, restored, stepper, restored.cfg, restored_bodies) + assert stepper._step_count == 3 + for source, target in ((field.ddf_gpu, restored.ddf_gpu), (field.temp_gpu, restored.temp_gpu), + (field.flag_gpu, restored.flag_gpu)): + assert bytes(source.data) == bytes(target.data) + if enabled: + np.testing.assert_array_equal(field.curved.surface_velocity, restored.curved.surface_velocity) + else: + assert restored.curved.surface_velocity is None diff --git a/tests/validation/analyze_j3_saved_frames.py b/tests/validation/analyze_j3_saved_frames.py new file mode 100644 index 0000000..c917eca --- /dev/null +++ b/tests/validation/analyze_j3_saved_frames.py @@ -0,0 +1,129 @@ +"""CPU-only analysis of the existing 521 frames; no solver or CUDA imports.""" +import csv +import hashlib +import json +import os +from pathlib import Path +import resource +import sys + +import h5py +import numpy as np + +ROOT = Path('/home/frank14f/CelerisLab') +OUT = ROOT/'research/j3-offline-2026-10-12' +SOURCE = ROOT/'research/j3-lag-2026-10-12/trajectory.h5' +EXPECTED_SHA = '2b51f8ec5e0cc8ca72c73ac1b51dd611158368e6d6de791d85150fa111ea1986' +SCALE = .002 + + +def digest(path): + with Path(path).open('rb') as stream: + return hashlib.file_digest(stream, 'sha256').hexdigest() if hasattr(hashlib, 'file_digest') else hashlib.sha256(stream.read()).hexdigest() + + +def main(): + assert Path.cwd() == ROOT + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert digest(SOURCE) == EXPECTED_SHA + assert not (OUT/'results.json').exists(), 'Do not overwrite prior results' + with h5py.File(SOURCE, 'r') as hf: + step = hf['step'][:] + u, v = hf['u'][:], hf['v'][:] + assert u.shape == v.shape == (521, 33, 48) + assert np.array_equal(step, np.arange(20760, 21281)) + even = step % 2 == 0 + ne, no = int(even.sum()), int((~even).sum()) + assert (ne, no) == (261, 260) + y = np.arange(1, 34) + regions = dict(all=y > 0, bottom=y == 1, top=y == 33, + interior=(y >= 2) & (y <= 32), top4=y >= 30) + report = dict(input=str(SOURCE), input_sha256=EXPECTED_SHA, script_sha256=digest(__file__), + python=sys.executable, velocity_scale=SCALE, even_frames=ne, odd_frames=no, + lags={}, regions=[], parity={}, solver_steps=0, j3_acceptance='failed_unchanged') + maps = {} + for name, field in (('u', u), ('v', v)): + for lag in (2, 4, 8): + difference = field[lag:]-field[:-lag] + rms = np.sqrt(np.mean(difference*difference, axis=(1, 2))) + entry = dict(origins=len(difference), rms_mean=float(rms.mean()), + rms_min=float(rms.min()), rms_max=float(rms.max()), + max_abs=float(np.abs(difference).max()), + signed_mean=float(difference.mean())) + entry['rms_mean_scaled'] = entry['rms_mean']/SCALE + entry['max_abs_scaled'] = entry['max_abs']/SCALE + report['lags'].setdefault(str(lag), {})[name] = entry + mean = field.mean(axis=0) + variance = np.mean((field-mean)**2, axis=0) + delta = field[even].mean(axis=0)-field[~even].mean(axis=0) + between = (ne/521)*(no/521)*delta**2 + # Independent weighted form checks the unequal group sizes. + weighted = (ne/521)*(field[even].mean(axis=0)-mean)**2 + (no/521)*(field[~even].mean(axis=0)-mean)**2 + np.testing.assert_allclose(between, weighted, rtol=1e-10, atol=1e-28) + maps[name] = (np.sqrt(variance), delta) + for region, mask in regions.items(): + energy = float(variance[mask].sum()) + fraction = float(mask.mean()) + d = delta[mask] + delta_energy = float(np.mean(d*d)) + row = dict(component=name, region=region, nodes=int(mask.sum()*48), + node_fraction=fraction, rms=float(np.sqrt(variance[mask].mean())), + energy_fraction=energy/float(variance.sum()), + parity_explained=float(between[mask].sum()/energy), + parity_delta_rms=float(np.sqrt(delta_energy)), + parity_delta_max_abs=float(np.abs(d).max()), + parity_delta_signed_mean=float(d.mean()), + parity_uniform_fraction=float(d.mean()**2/delta_energy)) + row['concentration'] = row['energy_fraction']/fraction + report['regions'].append(row) + report['parity'][name] = next(row for row in report['regions'] if row['component'] == name and row['region'] == 'all') + print(json.dumps({k:report[k] for k in ('lags', 'regions')}), flush=True) + (OUT/'results.json').write_text(json.dumps(report, indent=2)+'\n') + with (OUT/'regions.csv').open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(report['regions'][0])) + writer.writeheader() + writer.writerows(report['regions']) + lag_rows = [dict(lag=int(lag), component=name, **entry) + for lag, components in report['lags'].items() for name, entry in components.items()] + with (OUT/'lags.csv').open('w', newline='') as stream: + writer = csv.DictWriter(stream, fieldnames=list(lag_rows[0])) + writer.writeheader() + writer.writerows(lag_rows) + + # Cache is confined to this output directory, never the global environment. + assert os.environ.get('MPLCONFIGDIR') == str(OUT/'.plot-cache') + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + fig, axes = plt.subplots(2, 2, figsize=(9, 7), constrained_layout=True) + rms_limit = max(maps[name][0].max() for name in maps)/1e-7 + parity_limit = max(np.abs(maps[name][1]).max() for name in maps)/1e-7 + for col, name in enumerate(('u', 'v')): + for row in (0, 1): + ax = axes[row, col] + image = ax.imshow(maps[name][row]/1e-7, origin='lower', extent=(-.5, 47.5, .5, 33.5), + interpolation='nearest', aspect='equal', + cmap='viridis' if row == 0 else 'RdBu_r', + vmin=0 if row == 0 else -parity_limit, + vmax=rms_limit if row == 0 else parity_limit) + ax.set_title(f'{name}: temporal RMS' if row == 0 else f'{name}: even mean - odd mean') + ax.axhline(29.5, color='black', linestyle='--', linewidth=.7) + ax.plot([15.5, 31.5], [1, 1], color='black', linewidth=2) + ax.set(xlabel='x (lattice units)', ylabel='y (lattice units)', + yticks=[1, 16, 30, 33], yticklabels=['1 bottom', '16', '30', '33 top']) + fig.colorbar(axes[0, 0].images[0], ax=axes[0, :], label='velocity / 1e-7', shrink=.85) + fig.colorbar(axes[1, 0].images[0], ax=axes[1, :], label='signed velocity difference / 1e-7', shrink=.85) + fig.suptitle('Saved J3 trajectory: steps 20760-21280 (521 frames)\nDashed: top four rows; black bottom segment: slot', fontsize=12) + fig.savefig(OUT/'localization.png', dpi=160) + plt.close(fig) + report['peak_rss_mib'] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024 + report['figure_sha256'] = digest(OUT/'localization.png') + assert digest(SOURCE) == EXPECTED_SHA + assert not any(n == 'CelerisLab' or n.startswith('pycuda') or n == 'run_j1' for n in sys.modules) + assert report['peak_rss_mib'] < 1024 + (OUT/'results.json').write_text(json.dumps(report, indent=2)+'\n') + print('CPU-only analysis complete; peak RSS MiB:', report['peak_rss_mib'], flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/validation/diagnose_j3_lags.py b/tests/validation/diagnose_j3_lags.py new file mode 100644 index 0000000..0bbbc40 --- /dev/null +++ b/tests/validation/diagnose_j3_lags.py @@ -0,0 +1,276 @@ +"""One exact J3 replay and one 520-step audit trajectory; no production edits.""" +import argparse +from dataclasses import asdict +import hashlib +import json +import os +from pathlib import Path +import sys + +import h5py +import numpy as np +import pycuda.driver as cuda +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from run_j1 import ROOT, AUDIT, make_sim, populations, observables +from run_j2_slot import targets + +START, LENGTH, SCALE = 20760, 520, .002 +PHASE = 'postcollision g(t); D2Q9 paired ordering; unshifted FP32 double_buffer; q,y,x' + + +def sha(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def buffers(sim): + arrays = dict(ddf=np.empty(sim.field.n*sim.field.nq, dtype=np.float32), + temp=np.empty(sim.field.n*sim.field.nq, dtype=np.float32), + flag=np.empty(sim.field.n, dtype=np.uint16)) + with sim.ctx.activate(): + for name, array in arrays.items(): + cuda.memcpy_dtoh(array, getattr(sim.field, name+'_gpu')) + return arrays + + +def save_verified(sim, path): + """Persist both real buffers, then restore into a new case without advancing.""" + assert not path.exists(), f'Refusing to overwrite {path}' + before, config = buffers(sim), asdict(sim.lbm_cfg) + params, count = sim.field._runtime_params.copy(), sim.stepper.step_count + sim.save_checkpoint(str(path)) + with h5py.File(path, 'a') as hf: + hf.attrs['distribution_phase'] = PHASE + hf.attrs['diagnostic_script_sha256'] = sha(__file__) + for name, array in before.items(): + assert hf[name][:].tobytes() == array.tobytes(), name + assert json.loads(hf.attrs['config_json']) == config + assert json.loads(hf.attrs['runtime_params_json']) == params + assert int(hf.attrs['step_count']) == count + with make_sim(.002, nx=48, top_u=0., slot_width=16, top_mode='reservoir') as restored: + restored.load_checkpoint(str(path)) + after = buffers(restored) + assert all(before[k].tobytes() == after[k].tobytes() for k in before) + assert asdict(restored.lbm_cfg) == config + assert restored.field._runtime_params == params + assert restored.stepper.step_count == count + return dict(path=str(path), sha256=sha(path), step=count, phase=PHASE, + no_advance_restore='passed', buffers=['ddf', 'temp', 'flag']) + + +def replay(sim): + sim.initialize() + rho, old_u, old_v = observables(populations(sim)) + initial_mass = old_mass = float(rho.sum()) + history = [dict(step=0, mass=initial_mass)] + for _ in range(40): + sim.run(519) + rho, u, v = observables(populations(sim)) + mass = float(rho.sum()) + history.append(dict(step=sim.stepper.step_count, mass=mass, + u_change=float(np.sqrt(np.mean((u-old_u)**2))/SCALE), + v_change=float(np.sqrt(np.mean((v-old_v)**2))/SCALE), + mass_change=abs(mass-old_mass)/initial_mass)) + old_u, old_v, old_mass = u, v, mass + assert sim.stepper.step_count == START + return history, (rho, u, v) + + +def record_trajectory(sim, path, reference): + """Exactly LENGTH advances. Reuses J3's already validated audit source.""" + build = compiler.generate_config(sim.lbm_cfg) + original_step = sim.stepper.step_fn + try: + source = Path(build)/'step/one_step_double.cu' + code = source.read_text() + call, marker = 'collide_dispatch(f, rho_n, ux, uy);', '__global__ __launch_bounds__(NT, 2)' + assert code.count(call) == 1 and code.count(marker) == 1 + source.write_text(code.replace(call, 'j1_collide_audit(f, rho_n, ux, uy);') + .replace(marker, AUDIT+'\n'+marker)) + audit_hash = sha(source) + assert audit_hash == reference['audit_source_sha256'], 'Audit source changed; stop' + ptx = compiler.compile_kernel(build_dir=build) + with sim.ctx.activate(), h5py.File(path, 'x') as hf: + module = compiler.load_module(ptx) + params, size = module.get_global('d_params') + cuda.memcpy_dtod(params, sim.field.module.get_global('d_params')[0], size) + incoming_ptr, _ = module.get_global('j1_incoming') + collision_ptr, size = module.get_global('j1_collision') + cuda.memset_d8(collision_ptr, 0, size) + incoming = np.empty((2, 48), dtype=np.float64) + collision = np.empty((35, 48), dtype=np.float64) + hf.attrs.update(phase=PHASE, first_step=START, last_step=START+LENGTH, + first_fluid_y=1, velocity_scale=SCALE, rho_scale=1., + recorded_samples=0, audit_source_sha256=audit_hash) + hf.create_dataset('step', data=np.arange(START, START+LENGTH+1)) + for name in ('rho', 'u', 'v'): + hf.create_dataset(name, (LENGTH+1, 33, 48), dtype='f8', chunks=(1, 33, 48), + compression='gzip') + for name in ('mass_flux_by_y', 'volume_flux_by_y'): + hf.create_dataset(name, (LENGTH+1, 33), dtype='f8') + hf.create_dataset('mass', (LENGTH+1,), dtype='f8') + for name in ('incoming', 'outgoing'): + hf.create_dataset(name, (LENGTH, 2, 48), dtype='f8', compression='gzip') + hf.create_dataset('collision', (LENGTH, 35, 48), dtype='f8', compression='gzip') + hf.attrs['boundary_order'] = 'bottom,top; all x including zero-target flanks' + bottom, top_guess, slot = targets(.002) + hf.create_dataset('bottom_slot_mask', data=slot) + hf.create_dataset('bottom_requested_velocity', data=bottom) + hf.attrs.update(requested_bottom_volume=float(bottom.sum()), top_initial_guess=float(top_guess)) + + def store_frame(n, f): + rho, u, v = observables(f) + for name, array in zip(('rho', 'u', 'v'), (rho, u, v)): + hf[name][n] = array + hf['mass'][n] = rho.sum() + hf['mass_flux_by_y'][n] = (rho*v).sum(axis=1) + hf['volume_flux_by_y'][n] = v.sum(axis=1) + hf.attrs['recorded_samples'] = n+1 + + before = populations(sim) + store_frame(0, before) + sim.stepper.step_fn = module.get_function('OneStep') + for n in range(LENGTH): + outgoing = np.stack((before[[4,6,7], 1].sum(axis=0), before[[3,5,8], -2].sum(axis=0))) + mass = before[:, 1:-1].sum() + sim.run(1) + after = populations(sim) + assert np.isfinite(after).all() + cuda.memcpy_dtoh(incoming, incoming_ptr) + cuda.memcpy_dtoh(collision, collision_ptr) + hf['incoming'][n], hf['outgoing'][n], hf['collision'][n] = incoming, outgoing, collision + store_frame(n+1, after) + if n < 16: + row = reference['steps'][n] + measured = dict(mass=mass, delta_mass=after[:, 1:-1].sum()-mass, + bottom_net_in=incoming[0].sum()-outgoing[0].sum(), + top_net_in=incoming[1].sum()-outgoing[1].sum(), + collision_roundoff=collision.sum()) + assert all(measured[k] == row[k] for k in measured), 'Prior J3 audit not reproduced' + before = after + assert sim.stepper.step_count == START+LENGTH + hf.attrs['first_16_match_j3'] = True + return audit_hash + finally: + sim.stepper.step_fn = original_step + compiler.cleanup_build_dir(build) + + +def variation(u, v): + """Per-time/per-origin spatial metrics; no assumption of independent samples.""" + delta = np.stack((u, v), axis=1) + return dict(rms=np.sqrt(np.mean(delta**2, axis=(2, 3))), + max_abs=np.max(np.abs(delta), axis=(2, 3)), + signed_mean=np.mean(delta, axis=(2, 3)), + vector_rms=np.sqrt(np.mean(u*u+v*v, axis=(1, 2))), + vector_max=np.sqrt(np.max(u*u+v*v, axis=(1, 2)))) + + +def summarize(path): + summary = dict(lags={}) + with h5py.File(path, 'a') as hf: + assert hf.attrs['recorded_samples'] == LENGTH+1 + u, v, mass = hf['u'][:], hf['v'][:], hf['mass'][:] + for lag in (1, 16, 519): + group = hf.create_group(f'lag/{lag}') + group.create_dataset('origin_step', data=np.arange(START, START+LENGTH-lag+1)) + values = variation(u[lag:]-u[:-lag], v[lag:]-v[:-lag]) + for name, array in values.items(): group.create_dataset(name, data=array) + summary['lags'][str(lag)] = dict(origins=LENGTH+1-lag, + rms_mean=values['rms'].mean(axis=0).tolist(), rms_min=values['rms'].min(axis=0).tolist(), + rms_max=values['rms'].max(axis=0).tolist(), max_abs=values['max_abs'].max(axis=0).tolist(), + rms_mean_scaled=(values['rms'].mean(axis=0)/SCALE).tolist(), + signed_mean_change=values['signed_mean'].mean(axis=0).tolist(), + signed_mean_change_per_step=(values['signed_mean'].mean(axis=0)/lag).tolist(), + vector_rms_mean=float(values['vector_rms'].mean()), + vector_max=float(values['vector_max'].max())) + summary['lags'][str(lag)]['metrics'] = { + name: {label: func(array, axis=0).tolist() + for label, func in (('mean', np.mean), ('minimum', np.min), ('maximum', np.max))} + for name, array in values.items()} + summary['lags'][str(lag)]['metrics_scaled'] = { + name: {label: (np.asarray(value)/SCALE).tolist() for label, value in metrics.items()} + for name, metrics in summary['lags'][str(lag)]['metrics'].items()} + means = hf.create_group('time_mean') + for name in ('rho', 'u', 'v'): means.create_dataset(name, data=hf[name][:].mean(axis=0)) + deviation = variation(u-u.mean(axis=0), v-v.mean(axis=0)) + group = hf.create_group('deviation') + for name, array in deviation.items(): group.create_dataset(name, data=array) + summary['time_mean_deviation'] = dict(rms=np.sqrt(np.mean(deviation['rms']**2, axis=0)).tolist(), + max_abs=deviation['max_abs'].max(axis=0).tolist()) + end_delta = np.stack((u[-1]-u[0], v[-1]-v[0])) + hf.create_dataset('end_minus_start_uv', data=end_delta) + hf.create_dataset('mean_drift_per_step_uv', data=end_delta/LENGTH) + summary['signed_end_change'] = dict(mean=end_delta.mean(axis=(1, 2)).tolist(), + minimum=end_delta.min(axis=(1, 2)).tolist(), + maximum=end_delta.max(axis=(1, 2)).tolist(), + mean_per_step=(end_delta.mean(axis=(1, 2))/LENGTH).tolist()) + incoming, outgoing, collision = hf['incoming'][:], hf['outgoing'][:], hf['collision'][:] + slot = hf['bottom_slot_mask'][:] + groups = hf.create_group('budget') + net = incoming.sum(axis=(1, 2))-outgoing.sum(axis=(1, 2)) + roundoff, delta = collision.sum(axis=(1, 2)), np.diff(mass) + residual = delta-net-roundoff + for name, array in dict(delta_mass=delta, net_boundary=net, collision_roundoff=roundoff, + physical_residual=delta-net, residual=residual).items(): + groups.create_dataset(name, data=array) + summary['budget'] = dict(mean_delta_mass=float(delta.mean()), net_boundary=float(net.mean()), + collision_roundoff=float(roundoff.mean()), max_residual=float(np.abs(residual).max()), + max_relative_residual=float(np.max(np.abs(residual)/mass[:-1])), + max_physical_relative_residual=float(np.max(np.abs(delta-net)/mass[:-1])), + groups={name:dict(incoming=float(i.mean()), outgoing=float(o.mean()), net=float((i-o).mean())) + for name, i, o in ( + ('slot', incoming[:, 0, slot].sum(axis=1), outgoing[:, 0, slot].sum(axis=1)), + ('flanks', incoming[:, 0, ~slot].sum(axis=1), outgoing[:, 0, ~slot].sum(axis=1)), + ('top', incoming[:, 1].sum(axis=1), outgoing[:, 1].sum(axis=1)))}) + summary['mass'] = dict(start=float(mass[0]), end=float(mass[-1]), delta=float(mass[-1]-mass[0]), + relative_span=float(np.ptp(mass)/mass[0]), + relative_delta=float((mass[-1]-mass[0])/mass[0])) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output-dir', type=Path, required=True) + args = parser.parse_args() + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') in ('0', '1') + out = args.output_dir.resolve() + assert out == ROOT/'research/j3-lag-2026-10-12' + baseline = json.loads((out/'baseline.json').read_text()) + assert all(sha(ROOT/name) == digest for name, digest in baseline['hashes'].items()) + for name in ('results.json', 'trajectory.h5', 'start-20760.h5', 'end-21280.h5'): + assert not (out/name).exists(), f'Refusing to overwrite {name}' + original = json.loads((ROOT/'research/j3-reservoir-2026-10-12/results.json').read_text())['cases'][0] + report = dict(python=sys.executable, package=CelerisLab.__file__, + cuda_source=compiler.kernel_path('kernel_v2.cu'), physical_gpu=os.environ['CUDA_VISIBLE_DEVICES'], + script_sha256=sha(__file__), phase=PHASE, velocity_scale=SCALE, + replay_steps=START, planned_diagnostic_steps=LENGTH, diagnostic_steps=0, + j3_acceptance='failed_unchanged') + def save_report(): (out/'results.json').write_text(json.dumps(report, indent=2)+'\n') + with make_sim(.002, nx=48, top_u=0., slot_width=16, top_mode='reservoir') as sim: + history, fields = replay(sim) + report['start_checkpoint'] = save_verified(sim, out/'start-20760.h5') + report['replay_history'] = history + report['replay_matches_j3'] = bool(history == original['history'] and all( + np.array_equal(array, original[name]) for array, name in zip(fields, ('rho', 'u', 'v')))) + save_report() + assert report['replay_matches_j3'], 'Replay differs from J3; saved real checkpoint, stopping' + print('20760 replay and no-advance checkpoint restore passed; matches J3.', flush=True) + try: + report['audit_source_sha256'] = record_trajectory(sim, out/'trajectory.h5', original['population_budget']) + finally: + report['diagnostic_steps'] = sim.stepper.step_count-START + save_report() + report['end_checkpoint'] = save_verified(sim, out/'end-21280.h5') + report.update(summarize(out/'trajectory.h5')) + report['trajectory_sha256'] = sha(out/'trajectory.h5') + save_report() + print(json.dumps({k:report[k] for k in ('lags','signed_end_change','mass','budget')}), flush=True) + + +if __name__ == '__main__': + main() diff --git a/tests/validation/multigrid/cpu_reference.py b/tests/validation/multigrid/cpu_reference.py new file mode 100644 index 0000000..fc2eb9f --- /dev/null +++ b/tests/validation/multigrid/cpu_reference.py @@ -0,0 +1,475 @@ +"""Isolated NumPy CPU reference: one static 2:1 rectangular patch, volumetric transfer. + +API version CPUREF-0.3 (ClaudeWorker, 2026-10-13). NOT EXECUTED BY THE AUTHOR: +written without a shell; Grok/Horsea run it. A test-reference CANDIDATE, not a +certified truth and not a project implementation. v0.2 (Drive +1gfYrjdpzLxQ8LCZAH_pMMQRLSSuvFMIf, SHA256 b6adbad7...) is kept unchanged. + +Scope of evidence + Checked for conservation and bookkeeping only. Interface accuracy/stress + transfer is unvalidated (for every tau, tau_coarse = 1 included). + +Changes from v0.2 (jointly agreed small patch; numerical algorithm unchanged): + * explosion_count removed (it was a constant); explosion_writes is the evidence. + * Corner-pass links are derived from the coalescence map (rows whose coarse + source cell is not covered): all 8, each with a unique key "_q", + e.g. "lower_left_q8", "lower_left_q7". corner_packets is keyed the same way. + * Diagnostic renames: fine_valid_after -> hop2_valid; budget closure_error -> + bookkeeping_check. + * Per-packet path records (coalesce_paths, origin_g copy) moved to the tests; + cheap in-code guards kept (invalid packet, path through an outer ghost). + * Coalescence moved into a _coalesce helper. + * state.tick must be a non-negative even integer (synchronised phase); other + values are rejected instead of int()-truncated. + +Contract: AMR_Shared_CPU_API_2026-10-13.md + SHA256 ee8010e763d38937b95c34dadaf97415cdb8b6c23e3c79a9f1f32bd5d4e112f6 + plus root amendment: make_spec(*, collision='identity'|'srt'), stored in Spec. +Snapshot: 20-file snapshot + SHA256 e45df47d0fabde7199d345ada0729bca9ee64a833f433cdd776a4774b6af0673 +Dependencies: NumPy and the standard library only. No CelerisLab/CUDA imports. + +Conventions + D2Q9 paired ordering cx=(0,1,-1,0,0,1,-1,1,-1), cy=(0,0,0,1,-1,1,-1,-1,1). + Arrays are Q-major [q, y, x], float64, unshifted physical populations. + Coarse cell (x, y) has centre (x+.5, y+.5) on the doubly periodic [0,16)^2. + The patch covers coarse cells [4,12)^2 (covered). Fine index a = 0..23 (halo 4) + has centre 4 + (a-4+.5)/2 and coarse parent 4 + (a-4)//2; active fine is + [:, 4:20, 4:20]. Fine cell volume is 1/4 of a coarse cell. + Invalid entries (covered coarse, fine ghosts at synchronisation) hold NaN. + +Phase rule + advance_coarse_once maps arrival f(t) to arrival f(t+1). Production stores + post-collision g. Compare C(f_ref) with g_prod at a matched tick; never + compare f with g directly and never apply C to a stored g again. + +Synchronised phase "arrival_f". One advance_coarse_once (Schornbaum & Ruede +2016, Sec. 4 order): + 1 collide coarse active cells g_c = C_c(f_c(t)) tau_c + 2 collide fine active cells g_f = C_f(f_f(t)) tau_f + 3 explode: every fine ghost cell (inner and outer) receives g_c of its coarse + parent in all directions; once per coarse step, no rescaling + 4 fine stream, hop 1; targets are active cells and inner ghosts (inner ghosts + stream but never collide) + 5 collide fine active cells only; outer ghosts are poisoned (NaN, invalid) + 6 fine stream, hop 2; active cells now hold f_f(t+1) + 7 coarse periodic stream; incoming populations of covered cells are invalid + 8 coalesce: for each non-covered coarse cell K and direction i whose link + midpoint (centre(K) - c_i/2) lies in the CLOSED patch, replace the coarse + incoming population by the mean of K's four inner-ghost children's slot i + (includes the 8 corner-pass links, e.g. (4,3)->(3,4), i=8). + +Assumptions (stated, not validated by rest-state tests) + * Ghosts hold g_c(t) for the whole coarse step and inner ghosts never collide, + so ghost-sourced inflow into the first fine layer is an uncollided coarse + packet in both hops. + * The closed corner rule matters only through the mid-step fine collision: + with identity collision the open-corner variant is also conservative, so + identity-only tests cannot distinguish the two rules; use SRT with + non-equilibrium states. + * Budget: bookkeeping_check is self-consistency of this file's flux accounting + (it subtracts interface_residual), NOT conservation evidence; test + leaf_before/leaf_after and interface_residual independently. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import NamedTuple + +import numpy as np + +API_VERSION = "CPUREF-0.3" +CONTRACT_SHA256 = "ee8010e763d38937b95c34dadaf97415cdb8b6c23e3c79a9f1f32bd5d4e112f6" +SNAPSHOT_SHA256 = "e45df47d0fabde7199d345ada0729bca9ee64a833f433cdd776a4774b6af0673" + +NQ = 9 +CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.int64) +CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.int64) +OPP = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7], dtype=np.int64) +W = np.array([4 / 9] + [1 / 9] * 4 + [1 / 36] * 4, dtype=np.float64) +FINE_VOLUME = 0.25 +# Moment weights per direction: (mass, x-momentum, y-momentum). +MOMENTS = np.stack([np.ones(NQ), CX.astype(np.float64), CY.astype(np.float64)], axis=1) + +ACTIVE, INNER_GHOST, OUTER_GHOST = 0, 1, 2 + + +@dataclass(frozen=True) +class Spec: + patch_id: str = "P0" + n_coarse: int = 16 + patch_lo: int = 4 + patch_hi: int = 12 + halo: int = 4 + dt_coarse: float = 1.0 + dt_fine: float = 0.5 + tau_coarse: float = 0.8 + tau_fine: float = 1.1 + collision: str = "identity" + + @property + def n_fine(self) -> int: + return 2 * (self.patch_hi - self.patch_lo) + 2 * self.halo + + +@dataclass(frozen=True) +class State: + coarse_f: np.ndarray + fine_f: np.ndarray + phase: str = "arrival_f" + tick: int = 0 + + +class CornerLink(NamedTuple): + key: str # "_q", unique over the 8 corner-pass links + i: int + source_xy: tuple # uncovered coarse source cell (x, y) + target_xy: tuple # coarse target cell K (x, y) + + +@dataclass(frozen=True) +class Maps: + coarse_active: np.ndarray # (n, n) bool [y, x] + coarse_covered: np.ndarray # (n, n) bool + fine_active: np.ndarray # (nf, nf) bool + fine_inner_ghost: np.ndarray # (nf, nf) bool + fine_outer_ghost: np.ndarray # (nf, nf) bool + fine_class: np.ndarray # (nf, nf) int: ACTIVE / INNER_GHOST / OUTER_GHOST + fine_parent: np.ndarray # (nf,) coarse parent index (same for x and y) + fine_centre: np.ndarray # (nf,) physical centre (same for x and y) + coalesce: np.ndarray # (M, 3) rows (i, ky, kx) + corner_links: tuple # 8 x CornerLink, sorted by key + + +def make_spec(*, collision: str = "identity") -> Spec: + if collision not in ("identity", "srt"): + raise ValueError("collision must be 'identity' or 'srt'") + spec = Spec(collision=collision) + if not np.isclose(spec.tau_fine - 0.5, 2.0 * (spec.tau_coarse - 0.5)): + raise ValueError("tau values violate acoustic 2:1 scaling") + if not np.isclose(spec.dt_fine, 0.5 * spec.dt_coarse): + raise ValueError("dt values violate 2:1 time refinement") + return spec + + +def _readonly(array) -> np.ndarray: + array = np.array(array) + array.setflags(write=False) + return array + + +def _children_start(spec: Spec, k: int) -> int: + """First fine index of the two children of coarse index k.""" + return spec.halo + 2 * (k - spec.patch_lo) + + +def build_maps(spec: Spec) -> Maps: + n, lo, hi, nf = spec.n_coarse, spec.patch_lo, spec.patch_hi, spec.n_fine + idx = np.arange(n) + inside = (idx >= lo) & (idx < hi) + covered = inside[:, None] & inside[None, :] + + a = np.arange(nf) + parent = lo + (a - spec.halo) // 2 + centre = lo + (a - spec.halo + 0.5) / 2 + active_1d = (a >= spec.halo) & (a < nf - spec.halo) + ring_1d = (parent >= lo - 1) & (parent <= hi) # children of patch or first ring + fine_active = active_1d[:, None] & active_1d[None, :] + near = ring_1d[:, None] & ring_1d[None, :] + inner = near & ~fine_active + outer = ~near + fine_class = np.where(fine_active, ACTIVE, np.where(inner, INNER_GHOST, OUTER_GHOST)) + + rows = [] + for ky in range(n): + for kx in range(n): + if covered[ky, kx]: + continue + for i in range(1, NQ): + mx = kx + 0.5 - CX[i] / 2.0 + my = ky + 0.5 - CY[i] / 2.0 + if lo <= mx <= hi and lo <= my <= hi: + rows.append((i, ky, kx)) + coalesce = np.array(rows, dtype=np.int64).reshape(-1, 3) + for i, ky, kx in coalesce: + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + if not inner[y0:y0 + 2, x0:x0 + 2].all(): + raise AssertionError("coalescence target children must be inner ghosts") + + # Corner-pass links: coalesced rows whose coarse source cell is not covered. + links = [] + for i, ky, kx in coalesce.tolist(): + sx, sy = kx - int(CX[i]), ky - int(CY[i]) + if covered[sy, sx]: + continue + mx, my = kx + 0.5 - CX[i] / 2.0, ky + 0.5 - CY[i] / 2.0 + if mx not in (lo, hi) or my not in (lo, hi): + raise AssertionError("corner-pass link must cross a patch corner point") + name = ("lower" if my == lo else "upper") + ("_left" if mx == lo else "_right") + links.append(CornerLink(f"{name}_q{i}", i, (sx, sy), (kx, ky))) + links.sort(key=lambda link: link.key) + if len(links) != 8 or len({link.key for link in links}) != 8: + raise AssertionError("expected 8 uniquely keyed corner-pass links") + + return Maps( + coarse_active=_readonly(~covered), coarse_covered=_readonly(covered), + fine_active=_readonly(fine_active), fine_inner_ghost=_readonly(inner), + fine_outer_ghost=_readonly(outer), fine_class=_readonly(fine_class), + fine_parent=_readonly(parent), fine_centre=_readonly(centre), + coalesce=_readonly(coalesce), corner_links=tuple(links)) + + +def _equilibrium(rho, ux, uy) -> np.ndarray: + """D2Q9 second-order equilibrium; inputs are 1-D arrays of equal length.""" + cu = CX[:, None] * ux[None, :] + CY[:, None] * uy[None, :] + usq = ux * ux + uy * uy + return W[:, None] * rho[None, :] * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq[None, :]) + + +def make_state(spec: Spec, rho: float = 1.0, velocity=(0.0, 0.0)) -> State: + maps = build_maps(spec) + ux, uy = velocity + feq = _equilibrium(np.array([rho], float), np.array([ux], float), np.array([uy], float))[:, 0] + n, nf = spec.n_coarse, spec.n_fine + coarse = np.full((NQ, n, n), np.nan) + fine = np.full((NQ, nf, nf), np.nan) + coarse[:, maps.coarse_active] = feq[:, None] + fine[:, maps.fine_active] = feq[:, None] + return State(coarse_f=coarse, fine_f=fine, phase="arrival_f", tick=0) + + +def _collide(spec: Spec, f: np.ndarray, valid: np.ndarray, tau: float, + mask: np.ndarray, counts: np.ndarray) -> np.ndarray: + """Collision on masked cells (counted here); other cells copied unchanged.""" + if not valid[:, mask].all(): + raise RuntimeError("collision touches a cell with an invalid direction") + g = f.copy() + if spec.collision == "srt": + fm = f[:, mask] + rho = fm.sum(axis=0) + ux = (CX[:, None] * fm).sum(axis=0) / rho + uy = (CY[:, None] * fm).sum(axis=0) / rho + g[:, mask] = fm - (fm - _equilibrium(rho, ux, uy)) / tau + counts[mask] += 1 + return g + + +def _explode(gc, coarse_valid, gf, fine_valid, maps: Maps, writes: np.ndarray) -> None: + """Copy each ghost's coarse parent into it (all directions); counts writes.""" + ghosts = maps.fine_inner_ghost | maps.fine_outer_ghost + gy, gx = np.nonzero(ghosts) + py, px = maps.fine_parent[gy], maps.fine_parent[gx] + if not coarse_valid[:, py, px].all(): + raise RuntimeError("explosion source is not a valid coarse cell") + gf[:, gy, gx] = gc[:, py, px] + fine_valid[:, gy, gx] = True + np.add.at(writes, (gy, gx), 1) + + +def _stream_periodic(g: np.ndarray) -> np.ndarray: + out = np.empty_like(g) + for i in range(NQ): + out[i] = np.roll(g[i], (CY[i], CX[i]), axis=(0, 1)) # out[y,x] = g[y-cy, x-cx] + return out + + +def _shift(a: np.ndarray, dy: int, dx: int, fill) -> np.ndarray: + """out[y, x] = a[y-dy, x-dx]; `fill` where the source lies outside the array.""" + out = np.full(a.shape, fill, dtype=a.dtype) + ny, nx = a.shape + out[max(dy, 0):ny + min(dy, 0), max(dx, 0):nx + min(dx, 0)] = \ + a[max(-dy, 0):ny + min(-dy, 0), max(-dx, 0):nx + min(-dx, 0)] + return out + + +def _fine_hop(g: np.ndarray, valid: np.ndarray, maps: Maps): + """One fine pull-stream to active cells and inner ghosts, with exchange records.""" + targets = maps.fine_active | maps.fine_inner_ghost + src_class = maps.fine_class.astype(np.float64) + out = np.full_like(g, np.nan) + out_valid = np.zeros_like(valid) + inflow = np.zeros(3) # into active cells from ghost sources (fine-volume units) + outflow = np.zeros(3) # from active sources into inner-ghost targets + sources = {"active": 0, "inner_ghost": 0, "outer_ghost": 0, "outside": 0} + with np.errstate(invalid="ignore"): + for i in range(NQ): + dy, dx = int(CY[i]), int(CX[i]) + moved = _shift(g[i], dy, dx, np.nan) + cls = _shift(src_class, dy, dx, np.nan) + out[i] = moved + out_valid[i] = _shift(valid[i], dy, dx, False) & targets + from_ghost = maps.fine_active & (cls >= INNER_GHOST) + to_ghost = maps.fine_inner_ghost & (cls == ACTIVE) + inflow += MOMENTS[i] * moved[from_ghost].sum() + outflow += MOMENTS[i] * moved[to_ghost].sum() + act_cls = cls[maps.fine_active] + sources["active"] += int(np.sum(act_cls == ACTIVE)) + sources["inner_ghost"] += int(np.sum(act_cls == INNER_GHOST)) + sources["outer_ghost"] += int(np.sum(act_cls == OUTER_GHOST)) + sources["outside"] += int(np.sum(np.isnan(act_cls))) + out[:, ~targets] = np.nan + out[~out_valid] = np.nan + return out, out_valid, FINE_VOLUME * inflow, FINE_VOLUME * outflow, sources + + +def _coalesce(spec: Spec, maps: Maps, f2, hop2_valid, fc_new, new_valid, owner): + """Overwrite each listed coarse incoming slot with the mean of its four + inner-ghost children (fc_new, new_valid, owner updated in place). + Returns (replaced, coalesced, writes, reads).""" + n, nf = spec.n_coarse, spec.n_fine + replaced = np.zeros(3) + coalesced = np.zeros(3) + writes = np.zeros((NQ, n, n), dtype=np.int64) + reads = np.zeros((NQ, nf, nf), dtype=np.int64) + for i, ky, kx in maps.coalesce.tolist(): + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + kids = (slice(y0, y0 + 2), slice(x0, x0 + 2)) + if not hop2_valid[i][kids].all(): + raise RuntimeError(f"invalid coalescence packet (i={i}, K=({kx},{ky}))") + ym, xm = y0 - int(CY[i]), x0 - int(CX[i]) # hop-2 sources (intermediates) + if (maps.fine_class[ym:ym + 2, xm:xm + 2] == OUTER_GHOST).any(): + raise RuntimeError("coalescence packet passed through an outer ghost") + value = f2[i][kids].mean() + if new_valid[i, ky, kx]: + replaced += MOMENTS[i] * fc_new[i, ky, kx] + fc_new[i, ky, kx] = value + new_valid[i, ky, kx] = True + owner[i, ky, kx] = 2 + coalesced += MOMENTS[i] * value + writes[i, ky, kx] += 1 + reads[i][kids] += 1 + return replaced, coalesced, writes, reads + + +def _leaf_totals(coarse_f, fine_f, maps: Maps) -> np.ndarray: + mc = coarse_f[:, maps.coarse_active] + mf = fine_f[:, maps.fine_active] + return MOMENTS.T @ mc.sum(axis=1) + FINE_VOLUME * (MOMENTS.T @ mf.sum(axis=1)) + + +def _moments_of_difference(after, before, mask) -> np.ndarray: + return MOMENTS.T @ (after[:, mask] - before[:, mask]).sum(axis=1) + + +def advance_coarse_once(spec: Spec, maps: Maps, state: State): + n, nf = spec.n_coarse, spec.n_fine + if state.phase != "arrival_f": + raise ValueError("state.phase must be 'arrival_f'") + tick = state.tick + if isinstance(tick, bool) or not isinstance(tick, (int, np.integer)) or tick < 0 or tick % 2: + raise ValueError("state.tick must be a non-negative even integer (synchronised phase)") + tick = int(tick) + if state.coarse_f.shape != (NQ, n, n) or state.fine_f.shape != (NQ, nf, nf): + raise ValueError("state arrays have the wrong shape") + fc = np.array(state.coarse_f, dtype=np.float64) + ff = np.array(state.fine_f, dtype=np.float64) + if not (np.isfinite(fc[:, maps.coarse_active]).all() and np.isfinite(ff[:, maps.fine_active]).all()): + raise ValueError("active populations must be finite") + fc[:, ~maps.coarse_active] = np.nan # invalid input entries are never trusted + ff[:, ~maps.fine_active] = np.nan + coarse_valid = np.broadcast_to(maps.coarse_active, (NQ, n, n)).copy() + fine_valid = np.broadcast_to(maps.fine_active, (NQ, nf, nf)).copy() + leaf_before = _leaf_totals(fc, ff, maps) + + count_c = np.zeros((n, n), dtype=np.int64) + count_f = np.zeros((nf, nf), dtype=np.int64) + explosion_writes = np.zeros((nf, nf), dtype=np.int64) + + # 1-2: collisions at t + gc = _collide(spec, fc, coarse_valid, spec.tau_coarse, maps.coarse_active, count_c) + gf = _collide(spec, ff, fine_valid, spec.tau_fine, maps.fine_active, count_f) + coarse_collision = _moments_of_difference(gc, fc, maps.coarse_active) + fine_collision = FINE_VOLUME * _moments_of_difference(gf, ff, maps.fine_active) + + # 3: explosion, once per coarse step + _explode(gc, coarse_valid, gf, fine_valid, maps, explosion_writes) + + # 4: hop 1 + f1, valid1, in1, out1, src1 = _fine_hop(gf, fine_valid, maps) + # 5: mid-step fine collision, active only; outer ghosts poisoned + g1 = _collide(spec, f1, valid1, spec.tau_fine, maps.fine_active, count_f) + fine_collision += FINE_VOLUME * _moments_of_difference(g1, f1, maps.fine_active) + valid1 = valid1.copy() + g1[:, maps.fine_outer_ghost] = np.nan + valid1[:, maps.fine_outer_ghost] = False + # 6: hop 2 + f2, hop2_valid, in2, out2, src2 = _fine_hop(g1, valid1, maps) + + # 7: coarse stream + streamed = _stream_periodic(gc) + streamed_valid = np.stack([np.roll(coarse_valid[i], (CY[i], CX[i]), axis=(0, 1)) for i in range(NQ)]) + to_covered = np.zeros(3) + for i in range(NQ): + to_covered += MOMENTS[i] * streamed[i][maps.coarse_covered & streamed_valid[i]].sum() + fc_new = streamed.copy() + new_valid = streamed_valid & maps.coarse_active[None, :, :] + fc_new[~new_valid] = np.nan + owner = np.zeros((NQ, n, n), dtype=np.int64) + owner[new_valid] = 1 + + # 8: coalescence of incoming directions from the fine region + replaced, coalesced, coalesce_writes, coalesce_reads = _coalesce( + spec, maps, f2, hop2_valid, fc_new, new_valid, owner) + + if not new_valid[:, maps.coarse_active].all() or not np.isfinite(fc_new[:, maps.coarse_active]).all(): + raise RuntimeError("coarse incoming population without a valid owner") + if not hop2_valid[:, maps.fine_active].all() or not np.isfinite(f2[:, maps.fine_active]).all(): + raise RuntimeError("fine active population received an invalid value") + ff_new = np.full((NQ, nf, nf), np.nan) + ff_new[:, maps.fine_active] = f2[:, maps.fine_active] + leaf_after = _leaf_totals(fc_new, ff_new, maps) + + fine_inflow, fine_outflow = in1 + in2, out1 + out2 + interface = (coalesced - to_covered - replaced) + (fine_inflow - fine_outflow) + budget = { + "coarse_collision": coarse_collision, + "fine_collision": fine_collision, + "coarse_to_covered": to_covered, + "coarse_replaced": replaced, + "coalesced": coalesced, + "fine_inflow": fine_inflow, + "fine_outflow": fine_outflow, + "interface_residual": interface, + # Self-consistency of this file's accounting only; defined using interface_residual. + "bookkeeping_check": (leaf_after - leaf_before) - (coarse_collision + fine_collision + interface), + } + + corner_packets = {} + for link in maps.corner_links: + kx, ky = link.target_xy + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + kids = [(y, x) for y in (y0, y0 + 1) for x in (x0, x0 + 1)] + corner_packets[link.key] = { + "direction": link.i, + "source_coarse_xy": link.source_xy, + "target_coarse_xy": link.target_xy, + "children_fine_yx": kids, + "children_centre_xy": [(float(maps.fine_centre[x]), float(maps.fine_centre[y])) for y, x in kids], + "packets": [float(f2[link.i, y, x]) for y, x in kids], + "weights": [0.25] * 4, + "coalesced": float(fc_new[link.i, ky, kx]), + } + + diagnostics = { + "api_version": API_VERSION, + "collision": spec.collision, + "tick_before": tick, + "tick_after": tick + 2, + "collision_count_coarse": count_c, + "collision_count_fine": count_f, + "explosion_writes": explosion_writes, + "coarse_incoming_owner": owner, # 0 invalid (covered), 1 coarse stream, 2 coalescence + "coalesce_writes": coalesce_writes, + "coalesce_reads": coalesce_reads, + "fine_hop_sources": {"hop1": src1, "hop2": src2}, + # Pre-cleanup hop-2 validity, including valid inner ghosts. The returned + # fine_f is valid exactly on maps.fine_active and NaN elsewhere. + "hop2_valid": hop2_valid, + "coarse_valid_after": new_valid, + "leaf_before": leaf_before, # (mass, momentum_x, momentum_y) + "leaf_after": leaf_after, + "budget": budget, + "corner_packets": corner_packets, # 8 entries keyed "_q" + } + new_state = State(coarse_f=fc_new, fine_f=ff_new, phase="arrival_f", tick=tick + 2) + return new_state, diagnostics diff --git a/tests/validation/multigrid/cpu_reference_v0.2.py b/tests/validation/multigrid/cpu_reference_v0.2.py new file mode 100644 index 0000000..accfa95 --- /dev/null +++ b/tests/validation/multigrid/cpu_reference_v0.2.py @@ -0,0 +1,441 @@ +"""Isolated NumPy CPU reference: one static 2:1 rectangular patch, volumetric transfer. + +API version CPUREF-0.2 (ClaudeWorker, 2026-10-13). NOT EXECUTED BY THE AUTHOR: +written without a shell; Grok/Horsea runs it. An unvalidated reference to be +TESTED, not a certified truth. v0.1 (Drive 11eiOGWOwevZHpfopCvnEX1NZS9wIUwOn) is +kept unchanged for Grok's current tests. + +Changes from v0.1 (no change to the numerical algorithm or public API): + * Collision and explosion counts are incremented inside the operations + (_collide, _explode), not filled from expected masks. + * Per-direction validity is propagated: fine validity (9, ny, nx) through + explosion, both hops, outer-ghost poisoning; coarse validity through stream + and coalescence. A collision requires all 9 directions valid at each cell it + touches; every consumed coalescence packet must be valid; otherwise raise. + * New diagnostics: "coalesce_paths" certifies each consumed packet's two-hop + path (intermediate and origin fine cell, their classes, validity), and + "fine_valid_after" / "coarse_valid_after". + * Budget note: closure_error is bookkeeping self-consistency only. It is + defined by subtracting interface_residual, so it is NOT conservation evidence; + test leaf_before/leaf_after and interface_residual independently. + +Contract: AMR_Shared_CPU_API_2026-10-13.md + SHA256 ee8010e763d38937b95c34dadaf97415cdb8b6c23e3c79a9f1f32bd5d4e112f6 + plus root amendment: make_spec(*, collision='identity'|'srt'), stored in Spec. +Snapshot: 20-file snapshot + SHA256 e45df47d0fabde7199d345ada0729bca9ee64a833f433cdd776a4774b6af0673 +Dependencies: NumPy and the standard library only. No CelerisLab/CUDA imports. + +Conventions + D2Q9 paired ordering cx=(0,1,-1,0,0,1,-1,1,-1), cy=(0,0,0,1,-1,1,-1,-1,1). + Arrays are Q-major [q, y, x], float64, unshifted physical populations. + Coarse cell (x, y) has centre (x+.5, y+.5) on the doubly periodic [0,16)^2. + The patch covers coarse cells [4,12)^2 (covered). Fine index a = 0..23 (halo 4) + has centre 4 + (a-4+.5)/2 and coarse parent 4 + (a-4)//2; active fine is + [:, 4:20, 4:20]. Fine cell volume is 1/4 of a coarse cell. + Invalid entries (covered coarse, fine ghosts at synchronisation) hold NaN. + +Synchronised phase "arrival_f". One advance_coarse_once (Schornbaum & Ruede +2016, Sec. 4 order): + 1 collide coarse active cells g_c = C_c(f_c(t)) tau_c + 2 collide fine active cells g_f = C_f(f_f(t)) tau_f + 3 explode: every fine ghost cell (inner and outer) receives g_c of its coarse + parent in all directions; once per coarse step, no rescaling + 4 fine stream, hop 1; targets are active cells and inner ghosts (inner ghosts + stream but never collide) + 5 collide fine active cells only; outer ghosts are poisoned (NaN, invalid) + 6 fine stream, hop 2; active cells now hold f_f(t+1) + 7 coarse periodic stream; incoming populations of covered cells are invalid + 8 coalesce: for each non-covered coarse cell K and direction i whose link + midpoint (centre(K) - c_i/2) lies in the CLOSED patch, replace the coarse + incoming population by the mean of K's four inner-ghost children's slot i + (includes the diagonal corner links, e.g. (4,3)->(3,4), i=8). +""" +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +API_VERSION = "CPUREF-0.2" +CONTRACT_SHA256 = "ee8010e763d38937b95c34dadaf97415cdb8b6c23e3c79a9f1f32bd5d4e112f6" +SNAPSHOT_SHA256 = "e45df47d0fabde7199d345ada0729bca9ee64a833f433cdd776a4774b6af0673" + +NQ = 9 +CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.int64) +CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.int64) +OPP = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7], dtype=np.int64) +W = np.array([4 / 9] + [1 / 9] * 4 + [1 / 36] * 4, dtype=np.float64) +FINE_VOLUME = 0.25 +# Moment weights per direction: (mass, x-momentum, y-momentum). +MOMENTS = np.stack([np.ones(NQ), CX.astype(np.float64), CY.astype(np.float64)], axis=1) + +ACTIVE, INNER_GHOST, OUTER_GHOST = 0, 1, 2 + + +@dataclass(frozen=True) +class Spec: + patch_id: str = "P0" + n_coarse: int = 16 + patch_lo: int = 4 + patch_hi: int = 12 + halo: int = 4 + dt_coarse: float = 1.0 + dt_fine: float = 0.5 + tau_coarse: float = 0.8 + tau_fine: float = 1.1 + collision: str = "identity" + + @property + def n_fine(self) -> int: + return 2 * (self.patch_hi - self.patch_lo) + 2 * self.halo + + +@dataclass(frozen=True) +class State: + coarse_f: np.ndarray + fine_f: np.ndarray + phase: str = "arrival_f" + tick: int = 0 + + +@dataclass(frozen=True) +class Maps: + coarse_active: np.ndarray # (n, n) bool [y, x] + coarse_covered: np.ndarray # (n, n) bool + fine_active: np.ndarray # (nf, nf) bool + fine_inner_ghost: np.ndarray # (nf, nf) bool + fine_outer_ghost: np.ndarray # (nf, nf) bool + fine_class: np.ndarray # (nf, nf) int: ACTIVE / INNER_GHOST / OUTER_GHOST + fine_parent: np.ndarray # (nf,) coarse parent index (same for x and y) + fine_centre: np.ndarray # (nf,) physical centre (same for x and y) + coalesce: np.ndarray # (M, 3) rows (i, ky, kx) + corner_links: tuple # 4 x (name, i, (sx, sy), (kx, ky)) + + +def make_spec(*, collision: str = "identity") -> Spec: + if collision not in ("identity", "srt"): + raise ValueError("collision must be 'identity' or 'srt'") + spec = Spec(collision=collision) + if not np.isclose(spec.tau_fine - 0.5, 2.0 * (spec.tau_coarse - 0.5)): + raise ValueError("tau values violate acoustic 2:1 scaling") + if not np.isclose(spec.dt_fine, 0.5 * spec.dt_coarse): + raise ValueError("dt values violate 2:1 time refinement") + return spec + + +def _readonly(array) -> np.ndarray: + array = np.array(array) + array.setflags(write=False) + return array + + +def _children_start(spec: Spec, k: int) -> int: + """First fine index of the two children of coarse index k.""" + return spec.halo + 2 * (k - spec.patch_lo) + + +def build_maps(spec: Spec) -> Maps: + n, lo, hi, nf = spec.n_coarse, spec.patch_lo, spec.patch_hi, spec.n_fine + idx = np.arange(n) + inside = (idx >= lo) & (idx < hi) + covered = inside[:, None] & inside[None, :] + + a = np.arange(nf) + parent = lo + (a - spec.halo) // 2 + centre = lo + (a - spec.halo + 0.5) / 2 + active_1d = (a >= spec.halo) & (a < nf - spec.halo) + ring_1d = (parent >= lo - 1) & (parent <= hi) # children of patch or first ring + fine_active = active_1d[:, None] & active_1d[None, :] + near = ring_1d[:, None] & ring_1d[None, :] + inner = near & ~fine_active + outer = ~near + fine_class = np.where(fine_active, ACTIVE, np.where(inner, INNER_GHOST, OUTER_GHOST)) + + rows = [] + for ky in range(n): + for kx in range(n): + if covered[ky, kx]: + continue + for i in range(1, NQ): + mx = kx + 0.5 - CX[i] / 2.0 + my = ky + 0.5 - CY[i] / 2.0 + if lo <= mx <= hi and lo <= my <= hi: + rows.append((i, ky, kx)) + coalesce = np.array(rows, dtype=np.int64).reshape(-1, 3) + for i, ky, kx in coalesce: + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + if not inner[y0:y0 + 2, x0:x0 + 2].all(): + raise AssertionError("coalescence target children must be inner ghosts") + + corners = ( + ("lower_left", 8, (lo, lo - 1), (lo - 1, lo)), + ("lower_right", 5, (hi - 1, lo - 1), (hi, lo)), + ("upper_left", 6, (lo, hi), (lo - 1, hi - 1)), + ("upper_right", 7, (hi - 1, hi), (hi, hi - 1)), + ) + listed = {tuple(r) for r in coalesce.tolist()} + for _, i, (sx, sy), (kx, ky) in corners: + if (kx - sx, ky - sy) != (CX[i], CY[i]) or (i, ky, kx) not in listed: + raise AssertionError("corner link inconsistent with coalescence map") + + return Maps( + coarse_active=_readonly(~covered), coarse_covered=_readonly(covered), + fine_active=_readonly(fine_active), fine_inner_ghost=_readonly(inner), + fine_outer_ghost=_readonly(outer), fine_class=_readonly(fine_class), + fine_parent=_readonly(parent), fine_centre=_readonly(centre), + coalesce=_readonly(coalesce), corner_links=corners) + + +def _equilibrium(rho, ux, uy) -> np.ndarray: + """D2Q9 second-order equilibrium; inputs are 1-D arrays of equal length.""" + cu = CX[:, None] * ux[None, :] + CY[:, None] * uy[None, :] + usq = ux * ux + uy * uy + return W[:, None] * rho[None, :] * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq[None, :]) + + +def make_state(spec: Spec, rho: float = 1.0, velocity=(0.0, 0.0)) -> State: + maps = build_maps(spec) + ux, uy = velocity + feq = _equilibrium(np.array([rho], float), np.array([ux], float), np.array([uy], float))[:, 0] + n, nf = spec.n_coarse, spec.n_fine + coarse = np.full((NQ, n, n), np.nan) + fine = np.full((NQ, nf, nf), np.nan) + coarse[:, maps.coarse_active] = feq[:, None] + fine[:, maps.fine_active] = feq[:, None] + return State(coarse_f=coarse, fine_f=fine, phase="arrival_f", tick=0) + + +def _collide(spec: Spec, f: np.ndarray, valid: np.ndarray, tau: float, + mask: np.ndarray, counts: np.ndarray) -> np.ndarray: + """Collision on masked cells (counted here); other cells copied unchanged.""" + if not valid[:, mask].all(): + raise RuntimeError("collision touches a cell with an invalid direction") + g = f.copy() + if spec.collision == "srt": + fm = f[:, mask] + rho = fm.sum(axis=0) + ux = (CX[:, None] * fm).sum(axis=0) / rho + uy = (CY[:, None] * fm).sum(axis=0) / rho + g[:, mask] = fm - (fm - _equilibrium(rho, ux, uy)) / tau + counts[mask] += 1 + return g + + +def _explode(gc, coarse_valid, gf, fine_valid, maps: Maps, writes: np.ndarray) -> int: + """Copy each ghost's coarse parent into it (all directions); counts writes.""" + ghosts = maps.fine_inner_ghost | maps.fine_outer_ghost + gy, gx = np.nonzero(ghosts) + py, px = maps.fine_parent[gy], maps.fine_parent[gx] + if not coarse_valid[:, py, px].all(): + raise RuntimeError("explosion source is not a valid coarse cell") + gf[:, gy, gx] = gc[:, py, px] + fine_valid[:, gy, gx] = True + np.add.at(writes, (gy, gx), 1) + return 1 + + +def _stream_periodic(g: np.ndarray) -> np.ndarray: + out = np.empty_like(g) + for i in range(NQ): + out[i] = np.roll(g[i], (CY[i], CX[i]), axis=(0, 1)) # out[y,x] = g[y-cy, x-cx] + return out + + +def _shift(a: np.ndarray, dy: int, dx: int, fill) -> np.ndarray: + """out[y, x] = a[y-dy, x-dx]; `fill` where the source lies outside the array.""" + out = np.full(a.shape, fill, dtype=a.dtype) + ny, nx = a.shape + out[max(dy, 0):ny + min(dy, 0), max(dx, 0):nx + min(dx, 0)] = \ + a[max(-dy, 0):ny + min(-dy, 0), max(-dx, 0):nx + min(-dx, 0)] + return out + + +def _fine_hop(g: np.ndarray, valid: np.ndarray, maps: Maps): + """One fine pull-stream to active cells and inner ghosts, with exchange records.""" + targets = maps.fine_active | maps.fine_inner_ghost + src_class = maps.fine_class.astype(np.float64) + out = np.full_like(g, np.nan) + out_valid = np.zeros_like(valid) + inflow = np.zeros(3) # into active cells from ghost sources (fine-volume units) + outflow = np.zeros(3) # from active sources into inner-ghost targets + sources = {"active": 0, "inner_ghost": 0, "outer_ghost": 0, "outside": 0} + with np.errstate(invalid="ignore"): + for i in range(NQ): + dy, dx = int(CY[i]), int(CX[i]) + moved = _shift(g[i], dy, dx, np.nan) + cls = _shift(src_class, dy, dx, np.nan) + out[i] = moved + out_valid[i] = _shift(valid[i], dy, dx, False) & targets + from_ghost = maps.fine_active & (cls >= INNER_GHOST) + to_ghost = maps.fine_inner_ghost & (cls == ACTIVE) + inflow += MOMENTS[i] * moved[from_ghost].sum() + outflow += MOMENTS[i] * moved[to_ghost].sum() + act_cls = cls[maps.fine_active] + sources["active"] += int(np.sum(act_cls == ACTIVE)) + sources["inner_ghost"] += int(np.sum(act_cls == INNER_GHOST)) + sources["outer_ghost"] += int(np.sum(act_cls == OUTER_GHOST)) + sources["outside"] += int(np.sum(np.isnan(act_cls))) + out[:, ~targets] = np.nan + out[~out_valid] = np.nan + return out, out_valid, FINE_VOLUME * inflow, FINE_VOLUME * outflow, sources + + +def _leaf_totals(coarse_f, fine_f, maps: Maps) -> np.ndarray: + mc = coarse_f[:, maps.coarse_active] + mf = fine_f[:, maps.fine_active] + return MOMENTS.T @ mc.sum(axis=1) + FINE_VOLUME * (MOMENTS.T @ mf.sum(axis=1)) + + +def _moments_of_difference(after, before, mask) -> np.ndarray: + return MOMENTS.T @ (after[:, mask] - before[:, mask]).sum(axis=1) + + +def advance_coarse_once(spec: Spec, maps: Maps, state: State): + n, nf = spec.n_coarse, spec.n_fine + if state.phase != "arrival_f": + raise ValueError("state.phase must be 'arrival_f'") + if state.coarse_f.shape != (NQ, n, n) or state.fine_f.shape != (NQ, nf, nf): + raise ValueError("state arrays have the wrong shape") + fc = np.array(state.coarse_f, dtype=np.float64) + ff = np.array(state.fine_f, dtype=np.float64) + if not (np.isfinite(fc[:, maps.coarse_active]).all() and np.isfinite(ff[:, maps.fine_active]).all()): + raise ValueError("active populations must be finite") + fc[:, ~maps.coarse_active] = np.nan # invalid input entries are never trusted + ff[:, ~maps.fine_active] = np.nan + coarse_valid = np.broadcast_to(maps.coarse_active, (NQ, n, n)).copy() + fine_valid = np.broadcast_to(maps.fine_active, (NQ, nf, nf)).copy() + leaf_before = _leaf_totals(fc, ff, maps) + + count_c = np.zeros((n, n), dtype=np.int64) + count_f = np.zeros((nf, nf), dtype=np.int64) + explosion_writes = np.zeros((nf, nf), dtype=np.int64) + + # 1-2: collisions at t + gc = _collide(spec, fc, coarse_valid, spec.tau_coarse, maps.coarse_active, count_c) + gf = _collide(spec, ff, fine_valid, spec.tau_fine, maps.fine_active, count_f) + coarse_collision = _moments_of_difference(gc, fc, maps.coarse_active) + fine_collision = FINE_VOLUME * _moments_of_difference(gf, ff, maps.fine_active) + + # 3: explosion, once per coarse step + explosion_count = _explode(gc, coarse_valid, gf, fine_valid, maps, explosion_writes) + origin_g = gf.copy() # hop-1 sources, kept for path certification + + # 4: hop 1 + f1, valid1, in1, out1, src1 = _fine_hop(gf, fine_valid, maps) + # 5: mid-step fine collision, active only; outer ghosts poisoned + g1 = _collide(spec, f1, valid1, spec.tau_fine, maps.fine_active, count_f) + fine_collision += FINE_VOLUME * _moments_of_difference(g1, f1, maps.fine_active) + valid1 = valid1.copy() + g1[:, maps.fine_outer_ghost] = np.nan + valid1[:, maps.fine_outer_ghost] = False + # 6: hop 2 + f2, valid2, in2, out2, src2 = _fine_hop(g1, valid1, maps) + + # 7: coarse stream + streamed = _stream_periodic(gc) + streamed_valid = np.stack([np.roll(coarse_valid[i], (CY[i], CX[i]), axis=(0, 1)) for i in range(NQ)]) + to_covered = np.zeros(3) + for i in range(NQ): + to_covered += MOMENTS[i] * streamed[i][maps.coarse_covered & streamed_valid[i]].sum() + fc_new = streamed.copy() + new_valid = streamed_valid & maps.coarse_active[None, :, :] + fc_new[~new_valid] = np.nan + owner = np.zeros((NQ, n, n), dtype=np.int64) + owner[new_valid] = 1 + + # 8: coalescence of incoming directions from the fine region + replaced = np.zeros(3) + coalesced = np.zeros(3) + coalesce_writes = np.zeros((NQ, n, n), dtype=np.int64) + coalesce_reads = np.zeros((NQ, nf, nf), dtype=np.int64) + m = len(maps.coalesce) + path_class = np.zeros((m, 4, 2), dtype=np.int64) # (intermediate, origin) class + path_cells = np.zeros((m, 4, 2, 2), dtype=np.int64) # (intermediate, origin) (y, x) + path_valid = np.zeros((m, 4), dtype=bool) + for r, (i, ky, kx) in enumerate(maps.coalesce): + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + kids = [(y, x) for y in (y0, y0 + 1) for x in (x0, x0 + 1)] + for k, (y, x) in enumerate(kids): + ym, xm = y - CY[i], x - CX[i] + yo, xo = y - 2 * CY[i], x - 2 * CX[i] + path_cells[r, k] = ((ym, xm), (yo, xo)) + path_class[r, k] = (maps.fine_class[ym, xm], maps.fine_class[yo, xo]) + path_valid[r, k] = bool(valid2[i, y, x] and valid1[i, ym, xm] + and fine_valid[i, yo, xo] and np.isfinite(origin_g[i, yo, xo])) + if not path_valid[r, k]: + raise RuntimeError(f"invalid coalescence packet (i={i}, K=({kx},{ky}), child=({x},{y}))") + if path_class[r, k, 0] == OUTER_GHOST: + raise RuntimeError("coalescence packet passed through an outer ghost") + value = f2[i, y0:y0 + 2, x0:x0 + 2].mean() + if new_valid[i, ky, kx]: + replaced += MOMENTS[i] * fc_new[i, ky, kx] + fc_new[i, ky, kx] = value + new_valid[i, ky, kx] = True + coalesced += MOMENTS[i] * value + owner[i, ky, kx] = 2 + coalesce_writes[i, ky, kx] += 1 + coalesce_reads[i, y0:y0 + 2, x0:x0 + 2] += 1 + + if not new_valid[:, maps.coarse_active].all() or not np.isfinite(fc_new[:, maps.coarse_active]).all(): + raise RuntimeError("coarse incoming population without a valid owner") + if not valid2[:, maps.fine_active].all() or not np.isfinite(f2[:, maps.fine_active]).all(): + raise RuntimeError("fine active population received an invalid value") + ff_new = np.full((NQ, nf, nf), np.nan) + ff_new[:, maps.fine_active] = f2[:, maps.fine_active] + leaf_after = _leaf_totals(fc_new, ff_new, maps) + + fine_inflow, fine_outflow = in1 + in2, out1 + out2 + interface = (coalesced - to_covered - replaced) + (fine_inflow - fine_outflow) + budget = { + "coarse_collision": coarse_collision, + "fine_collision": fine_collision, + "coarse_to_covered": to_covered, + "coarse_replaced": replaced, + "coalesced": coalesced, + "fine_inflow": fine_inflow, + "fine_outflow": fine_outflow, + "interface_residual": interface, + # Bookkeeping self-check only; defined using interface_residual. + "closure_error": (leaf_after - leaf_before) - (coarse_collision + fine_collision + interface), + } + + corner_packets = {} + for name, i, (sx, sy), (kx, ky) in maps.corner_links: + y0, x0 = _children_start(spec, ky), _children_start(spec, kx) + kids = [(y, x) for y in (y0, y0 + 1) for x in (x0, x0 + 1)] + corner_packets[name] = { + "direction": int(i), + "source_coarse_xy": (int(sx), int(sy)), + "target_coarse_xy": (int(kx), int(ky)), + "children_fine_yx": kids, + "children_centre_xy": [(float(maps.fine_centre[x]), float(maps.fine_centre[y])) for y, x in kids], + "packets": [float(f2[i, y, x]) for y, x in kids], + "weights": [0.25] * 4, + "coalesced": float(fc_new[i, ky, kx]), + } + + diagnostics = { + "api_version": API_VERSION, + "collision": spec.collision, + "tick_before": int(state.tick), + "tick_after": int(state.tick) + 2, + "collision_count_coarse": count_c, + "collision_count_fine": count_f, + "explosion_count": explosion_count, + "explosion_writes": explosion_writes, + "coarse_incoming_owner": owner, # 0 invalid (covered), 1 coarse stream, 2 coalescence + "coalesce_writes": coalesce_writes, + "coalesce_reads": coalesce_reads, + "coalesce_paths": {"rows": np.array(maps.coalesce), "cells_yx": path_cells, + "classes": path_class, "valid": path_valid}, + "fine_hop_sources": {"hop1": src1, "hop2": src2}, + "fine_valid_after": valid2, + "coarse_valid_after": new_valid, + "leaf_before": leaf_before, # (mass, momentum_x, momentum_y) + "leaf_after": leaf_after, + "budget": budget, + "corner_packets": corner_packets, + } + new_state = State(coarse_f=fc_new, fine_f=ff_new, phase="arrival_f", tick=int(state.tick) + 2) + return new_state, diagnostics diff --git a/tests/validation/multigrid/local_stress_observable_v1.py b/tests/validation/multigrid/local_stress_observable_v1.py new file mode 100644 index 0000000..1f63f4d --- /dev/null +++ b/tests/validation/multigrid/local_stress_observable_v1.py @@ -0,0 +1,902 @@ +#!/usr/bin/env python3 +"""Local-stress observable v1 — observation-only addition on unchanged CPUREF-0.3. + +Frozen spec: 18_local_stress_observable_spec_v1.md (Drive 1IgggnRfJrdvRtRo-07pmpUtILRjFmY2V) +Thread: C0C78UFKGRM / 1791869055.115119 + +Unchanged from shear_diagnostic_v0.2: same 7x32 runs, same 22 hard checks, same +resource bounds, reference file unmodified. Addition: at coarse step 32, from +ARRIVAL (post-stream, pre-collision) populations, report scalar R and one signed +horizontal-face profile. Diagnostic values only — no thresholds / mechanism claims. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import os +import sys +import time +from dataclasses import dataclass + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +REF_PATH = os.environ.get( + "CPU_REFERENCE_PATH", os.path.join(HERE, "cpu_reference_v0.3.py") +) + +U0 = 1e-3 +N_COARSE = 16 +N_FINE_DOM = 32 +PATCH_LO, PATCH_HI = 4, 12 +TAU_C, TAU_F = 0.8, 1.1 +N_STEPS = 32 +FIT_LO, FIT_HI = 4, 32 +CONS_TOL = 1e-12 +TRANS_TOL = 1e-12 * U0 +INIT_TOL = CONS_TOL +T0_TOL = TRANS_TOL +K_PHYS = 2.0 * np.pi / 16.0 +CS2 = 1.0 / 3.0 +NU = CS2 * (TAU_C - 0.5) # 0.1 coarse units +RHO0 = 1.0 + +CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.int64) +CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.int64) +W = np.array([4 / 9] + [1 / 9] * 4 + [1 / 36] * 4, dtype=np.float64) +NQ = 9 + + +def _load_ref(): + spec = importlib.util.spec_from_file_location("cpu_ref_v03", REF_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def sinc(x): + x = np.asarray(x, dtype=np.float64) + out = np.empty_like(x, dtype=np.float64) + small = np.abs(x) < 1e-14 + out[small] = 1.0 + xs = x[~small] + out[~small] = np.sin(xs) / xs + return float(out) if out.ndim == 0 else out + + +def feq(rho, ux, uy): + rho = np.asarray(rho, dtype=np.float64) + ux = np.asarray(ux, dtype=np.float64) + uy = np.asarray(uy, dtype=np.float64) + cu = CX[:, None, None] * ux + CY[:, None, None] * uy + usq = ux * ux + uy * uy + return W[:, None, None] * rho * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq) + + +def srt_collide(f, tau, mask=None): + g = f.copy() + if mask is None: + mask = np.ones(f.shape[1:], dtype=bool) + fm = f[:, mask] + rho = fm.sum(axis=0) + ux = (CX[:, None] * fm).sum(axis=0) / rho + uy = (CY[:, None] * fm).sum(axis=0) / rho + rho_g = np.zeros(f.shape[1:]) + ux_g = np.zeros(f.shape[1:]) + uy_g = np.zeros(f.shape[1:]) + rho_g[mask] = rho + ux_g[mask] = ux + uy_g[mask] = uy + eq_full = feq(rho_g, ux_g, uy_g) + g[:, mask] = fm - (fm - eq_full[:, mask]) / tau + return g + + +def stream_periodic(g): + out = np.empty_like(g) + for i in range(NQ): + out[i] = np.roll(g[i], (int(CY[i]), int(CX[i])), axis=(0, 1)) + return out + + +def moments(f): + rho = f.sum(axis=0) + jx = (CX[:, None, None] * f).sum(axis=0) + jy = (CY[:, None, None] * f).sum(axis=0) + return rho, jx, jy + + +def cell_avg_ux(y_centers, dx, phi): + factor = float(sinc(K_PHYS * dx / 2.0)) + return U0 * factor * np.sin(K_PHYS * y_centers + phi) + + +def mode_template(phi): + yc = np.arange(N_COARSE, dtype=np.float64) + 0.5 + m1d = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * yc + phi) + return np.broadcast_to(m1d[:, None], (N_COARSE, N_COARSE)).copy() + + +def project_amp(ux, phi): + m = mode_template(phi) + return float(np.sum(ux * m) / np.sum(m * m)) + + +def restrict_fine_to_coarse(rho_f, jx_f, jy_f): + assert rho_f.shape == (N_FINE_DOM, N_FINE_DOM) + rho = rho_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + jx = jx_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + jy = jy_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + return rho, jx, jy, jx / rho, jy / rho + + +def volume_avg_2x2(field_f): + """2x2 volume-average of a fine 32^2 field onto 16^2.""" + assert field_f.shape == (N_FINE_DOM, N_FINE_DOM) + return field_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + + +def sigma_xy_cell(f, tau): + """Per-cell corrected stress from ARRIVAL populations. + Π^neq_xy = Σ c_x c_y f − ρ u_x u_y + σ_xy = −(1 − 1/(2τ)) Π^neq_xy + No extra dx/dt factor ((dx/dt)^2 = 1). + """ + rho, jx, jy = moments(f) + ux = jx / rho + uy = jy / rho + pi_neq = (CX[:, None, None] * CY[:, None, None] * f).sum(axis=0) - rho * ux * uy + sigma = -(1.0 - 1.0 / (2.0 * tau)) * pi_neq + return sigma, rho, ux, uy + + +def project_a_u_P_sigma(u_field, sigma, phi, transpose=False): + """Fourier projections over all 16^2 cells (spec §3). + a_u = (2/256) Σ u · sin(k· + φ); P_σ = (2/256) Σ σ · cos(k· + φ) + Non-transpose: u_x vs y; transpose: u_y vs x. + """ + centers = np.arange(N_COARSE, dtype=np.float64) + 0.5 + if transpose: + # u_y(x): template varies with x (columns) + sin_t = np.sin(K_PHYS * centers[None, :] + phi) + cos_t = np.cos(K_PHYS * centers[None, :] + phi) + else: + # u_x(y): template varies with y (rows) + sin_t = np.sin(K_PHYS * centers[:, None] + phi) + cos_t = np.cos(K_PHYS * centers[:, None] + phi) + a_u = (2.0 / 256.0) * float(np.sum(u_field * sin_t)) + P_s = (2.0 / 256.0) * float(np.sum(sigma * cos_t)) + return a_u, P_s + + +def profile_s(sigma, a_u, transpose=False): + """Signed horizontal-face profile: mean over x∈[4,12), / (ρ0 ν k a_u). + Transpose: no profile (returns None). + """ + if transpose: + return None + denom = RHO0 * NU * K_PHYS * a_u + row_mean = sigma[:, PATCH_LO:PATCH_HI].mean(axis=1) # j=0..15 + return row_mean / denom + + +@dataclass +class Series: + name: str + phi: float + A: np.ndarray + e_if: np.ndarray + e_cb: np.ndarray + e_if_patch: np.ndarray + e_if_out: np.ndarray + nonmodal: np.ndarray + max_uy: np.ndarray + max_drho: np.ndarray + mass: np.ndarray + momx: np.ndarray + momy: np.ndarray + pi_xy_mean: np.ndarray + finite_ok: bool + cons_ok: bool + mass_drift: float = 0.0 + momx_drift: float = 0.0 + momy_drift: float = 0.0 + notes: str = "" + + +def init_uniform(n, dx, phi, transpose=False): + centers = (np.arange(n, dtype=np.float64) + 0.5) * dx + Y, X = np.meshgrid(centers, centers, indexing="ij") + rho = np.ones((n, n), dtype=np.float64) + if transpose: + ux = np.zeros_like(rho) + uy = cell_avg_ux(X, dx, phi) + else: + ux = cell_avg_ux(Y, dx, phi) + uy = np.zeros_like(rho) + return feq(rho, ux, uy) + + +def advance_uniform(f, tau, steps): + for _ in range(steps): + f = stream_periodic(srt_collide(f, tau)) + return f + + +def observe_uniform(f, dx, phi, transpose=False): + rho, jx, jy = moments(f) + if dx < 1.0 - 1e-12: + rho_c, jx_c, jy_c, ux, uy = restrict_fine_to_coarse(rho, jx, jy) + else: + rho_c, jx_c, jy_c = rho, jx, jy + ux, uy = jx / rho, jy / rho + if transpose: + xc = np.arange(N_COARSE, dtype=np.float64) + 0.5 + mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi) + mT = np.broadcast_to(mx[None, :], (N_COARSE, N_COARSE)).copy() + amp = float(np.sum(uy * mT) / np.sum(mT * mT)) + modal = amp * mT + nonmodal = float(np.linalg.norm(uy - modal) / U0) + max_cross = float(np.max(np.abs(ux)) / U0) + else: + amp = project_amp(ux, phi) + m = mode_template(phi) + modal = amp * m + nonmodal = float(np.linalg.norm(ux - modal) / U0) + max_cross = float(np.max(np.abs(uy)) / U0) + return { + "amp": amp, + "ux": ux, + "uy": uy, + "rho": rho_c, + "jx": jx_c, + "jy": jy_c, + "nonmodal": nonmodal, + "max_cross": max_cross, + "max_drho": float(np.max(np.abs(rho_c - 1.0))), + "mass": float(rho_c.sum()), + "momx": float(jx_c.sum()), + "momy": float(jy_c.sum()), + } + + +def run_uniform(kind, phi, transpose=False): + if kind == "B": + n, dx, tau, sub = N_FINE_DOM, 0.5, TAU_F, 2 + else: + n, dx, tau, sub = N_COARSE, 1.0, TAU_C, 1 + f = init_uniform(n, dx, phi, transpose=transpose) + amps, nonmodal, max_cross, max_drho = [], [], [], [] + mass, momx, momy = [], [], [] + fields = [] + finite_ok = True + for tick in range(N_STEPS + 1): + if not np.isfinite(f).all(): + finite_ok = False + obs = observe_uniform(f, dx, phi, transpose=transpose) + amps.append(obs["amp"]) + nonmodal.append(obs["nonmodal"]) + max_cross.append(obs["max_cross"]) + max_drho.append(obs["max_drho"]) + mass.append(obs["mass"]) + momx.append(obs["momx"]) + momy.append(obs["momy"]) + fields.append( + (obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(), + obs["jx"].copy(), obs["jy"].copy()) + ) + if tick < N_STEPS: + f = advance_uniform(f, tau, sub) + # After N_STEPS advances, f is ARRIVAL at coarse step 32 (post-stream). + arrival_name = ( + f"uniform_{kind}_f_after_{N_STEPS * sub}_stream_steps" + f"_(=coarse_step_{N_STEPS}_arrival; collide-then-stream loop)" + ) + mass = np.asarray(mass) + momx = np.asarray(momx) + momy = np.asarray(momy) + mass_drift = float(np.max(np.abs(mass - mass[0]))) + momx_drift = float(np.max(np.abs(momx - momx[0]))) + momy_drift = float(np.max(np.abs(momy - momy[0]))) + tol_abs = CONS_TOL * abs(mass[0]) + cons_ok = ( + mass_drift <= tol_abs + and momx_drift <= tol_abs + and momy_drift <= tol_abs + ) + ser = Series( + name=f"{kind}_phi{phi:.4f}{'_T' if transpose else ''}", + phi=phi, + A=np.asarray(amps), + e_if=np.full(N_STEPS + 1, np.nan), + e_cb=np.full(N_STEPS + 1, np.nan), + e_if_patch=np.full(N_STEPS + 1, np.nan), + e_if_out=np.full(N_STEPS + 1, np.nan), + nonmodal=np.asarray(nonmodal), + max_uy=np.asarray(max_cross), + max_drho=np.asarray(max_drho), + mass=mass, + momx=momx, + momy=momy, + pi_xy_mean=np.full(N_STEPS + 1, np.nan), + finite_ok=finite_ok, + cons_ok=bool(cons_ok), + mass_drift=mass_drift, + momx_drift=momx_drift, + momy_drift=momy_drift, + ) + return ser, fields, f, tau, arrival_name + + +def init_composite(ref, spec, maps, phi, transpose=False): + n, nf = spec.n_coarse, spec.n_fine + coarse = np.full((NQ, n, n), np.nan) + fine = np.full((NQ, nf, nf), np.nan) + yc = np.arange(n, dtype=np.float64) + 0.5 + xc = np.arange(n, dtype=np.float64) + 0.5 + Yc, Xc = np.meshgrid(yc, xc, indexing="ij") + if transpose: + ux_c = np.zeros((n, n)) + uy_c = cell_avg_ux(Xc, 1.0, phi) + else: + ux_c = cell_avg_ux(Yc, 1.0, phi) + uy_c = np.zeros((n, n)) + coarse[:, :, :] = feq(np.ones((n, n)), ux_c, uy_c) + coarse[:, maps.coarse_covered] = np.nan + fy = maps.fine_centre + fx = maps.fine_centre + Yf, Xf = np.meshgrid(fy, fx, indexing="ij") + if transpose: + ux_f = np.zeros((nf, nf)) + uy_f = cell_avg_ux(Xf, 0.5, phi) + else: + ux_f = cell_avg_ux(Yf, 0.5, phi) + uy_f = np.zeros((nf, nf)) + eq_f = feq(np.ones((nf, nf)), ux_f, uy_f) + fine[:, maps.fine_active] = eq_f[:, maps.fine_active] + return ref.State(coarse_f=coarse, fine_f=fine, phase="arrival_f", tick=0) + + +def observe_composite(ref, maps, state, phi, transpose=False): + fc, ff = state.coarse_f, state.fine_f + n = N_COARSE + rho_c = np.full((n, n), np.nan) + jx_c = np.full((n, n), np.nan) + jy_c = np.full((n, n), np.nan) + for y in range(n): + for x in range(n): + if maps.coarse_active[y, x]: + sl = fc[:, y, x] + rho_c[y, x] = sl.sum() + jx_c[y, x] = (CX * sl).sum() + jy_c[y, x] = (CY * sl).sum() + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + kids = [] + for dy in (0, 1): + for dx in (0, 1): + sl = ff[:, y0 + dy, x0 + dx] + kids.append((sl.sum(), (CX * sl).sum(), (CY * sl).sum())) + rho_c[ky, kx] = np.mean([k[0] for k in kids]) + jx_c[ky, kx] = np.mean([k[1] for k in kids]) + jy_c[ky, kx] = np.mean([k[2] for k in kids]) + ux = jx_c / rho_c + uy = jy_c / rho_c + if transpose: + xc = np.arange(n, dtype=np.float64) + 0.5 + mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi) + mT = np.broadcast_to(mx[None, :], (n, n)).copy() + amp = float(np.sum(uy * mT) / np.sum(mT * mT)) + modal = amp * mT + nonmodal = float(np.linalg.norm(uy - modal) / U0) + max_cross = float(np.max(np.abs(ux)) / U0) + else: + amp = project_amp(ux, phi) + m = mode_template(phi) + modal = amp * m + nonmodal = float(np.linalg.norm(ux - modal) / U0) + max_cross = float(np.max(np.abs(uy)) / U0) + leaf = ref._leaf_totals(fc, ff, maps) + return { + "amp": amp, + "ux": ux, + "uy": uy, + "rho": rho_c, + "jx": jx_c, + "jy": jy_c, + "nonmodal": nonmodal, + "max_cross": max_cross, + "max_drho": float(np.max(np.abs(rho_c - 1.0))), + "mass": float(leaf[0]), + "momx": float(leaf[1]), + "momy": float(leaf[2]), + "finite_active": bool( + np.isfinite(fc[:, maps.coarse_active]).all() + and np.isfinite(ff[:, maps.fine_active]).all() + ), + } + + +def spec_children_start(k): + return 4 + 2 * (k - PATCH_LO) + + +def sigma_composite_16(maps, state): + """Build 16^2 corrected σ from arrival_f composite state. + Patch cells: 2x2 volume-average of fine σ (τ_f). Outside: coarse σ (τ_c). + Never uses coarse underlay under the patch. + """ + fc, ff = state.coarse_f, state.fine_f + n = N_COARSE + sig = np.full((n, n), np.nan) + # outside / active coarse + for y in range(n): + for x in range(n): + if maps.coarse_active[y, x]: + f1 = fc[:, y : y + 1, x : x + 1] + s1, _, _, _ = sigma_xy_cell(f1, TAU_C) + sig[y, x] = float(s1[0, 0]) + # patch from fine + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + kids = [] + for dy in (0, 1): + for dx in (0, 1): + f1 = ff[:, y0 + dy : y0 + dy + 1, x0 + dx : x0 + dx + 1] + s1, _, _, _ = sigma_xy_cell(f1, TAU_F) + kids.append(float(s1[0, 0])) + sig[ky, kx] = float(np.mean(kids)) + return sig + + +def run_composite(ref, phi, transpose=False): + spec = ref.make_spec(collision="srt") + assert abs(spec.tau_coarse - TAU_C) < 1e-15 and abs(spec.tau_fine - TAU_F) < 1e-15 + maps = ref.build_maps(spec) + st = init_composite(ref, spec, maps, phi, transpose=transpose) + yc = np.arange(N_COARSE) + 0.5 + xc = np.arange(N_COARSE) + 0.5 + init_mismatch = 0.0 + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + rhos, jxs, jys = [], [], [] + for dy in (0, 1): + for dx in (0, 1): + sl = st.fine_f[:, y0 + dy, x0 + dx] + rhos.append(sl.sum()) + jxs.append((CX * sl).sum()) + jys.append((CY * sl).sum()) + rho_r, jx_r, jy_r = np.mean(rhos), np.mean(jxs), np.mean(jys) + if transpose: + ux, uy = 0.0, float(cell_avg_ux(xc[kx], 1.0, phi)) + else: + ux, uy = float(cell_avg_ux(yc[ky], 1.0, phi)), 0.0 + init_mismatch = max( + init_mismatch, abs(rho_r - 1.0), abs(jx_r - ux), abs(jy_r - uy) + ) + amps, nonmodal, max_cross, max_drho = [], [], [], [] + mass, momx, momy = [], [], [] + fields = [] + finite_ok = True + for tick in range(N_STEPS + 1): + obs = observe_composite(ref, maps, st, phi, transpose=transpose) + if not obs["finite_active"]: + finite_ok = False + amps.append(obs["amp"]) + nonmodal.append(obs["nonmodal"]) + max_cross.append(obs["max_cross"]) + max_drho.append(obs["max_drho"]) + mass.append(obs["mass"]) + momx.append(obs["momx"]) + momy.append(obs["momy"]) + fields.append( + (obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(), + obs["jx"].copy(), obs["jy"].copy()) + ) + if tick < N_STEPS: + st, _ = ref.advance_coarse_once(spec, maps, st) + # After 32 advances: tick=64, phase=arrival_f — post-stream, pre-collision. + arrival_name = ( + f"State.coarse_f + State.fine_f at phase={st.phase!r} tick={st.tick} " + f"(after {N_STEPS} advance_coarse_once; arrival_f = post-stream pre-collision)" + ) + mass = np.asarray(mass) + momx = np.asarray(momx) + momy = np.asarray(momy) + mass_drift = float(np.max(np.abs(mass - mass[0]))) + momx_drift = float(np.max(np.abs(momx - momx[0]))) + momy_drift = float(np.max(np.abs(momy - momy[0]))) + tol_abs = CONS_TOL * max(abs(mass[0]), 1.0) + cons_ok = ( + mass_drift <= tol_abs + and momx_drift <= tol_abs + and momy_drift <= tol_abs + ) + ser = Series( + name=f"A_phi{phi:.4f}{'_T' if transpose else ''}", + phi=phi, + A=np.asarray(amps), + e_if=np.full(N_STEPS + 1, np.nan), + e_cb=np.full(N_STEPS + 1, np.nan), + e_if_patch=np.full(N_STEPS + 1, np.nan), + e_if_out=np.full(N_STEPS + 1, np.nan), + nonmodal=np.asarray(nonmodal), + max_uy=np.asarray(max_cross), + max_drho=np.asarray(max_drho), + mass=mass, + momx=momx, + momy=momy, + pi_xy_mean=np.full(N_STEPS + 1, np.nan), + finite_ok=finite_ok, + cons_ok=bool(cons_ok), + mass_drift=mass_drift, + momx_drift=momx_drift, + momy_drift=momy_drift, + notes=f"init_rho_j_mismatch_vs_coarse_formula={init_mismatch:.3e}", + ) + return ser, fields, st, maps, init_mismatch, arrival_name + + +def fit_lambda(amps): + t = np.arange(FIT_LO, FIT_HI + 1, dtype=np.float64) + y = np.log(np.abs(amps[FIT_LO : FIT_HI + 1])) + n = len(t) + tbar, ybar = t.mean(), y.mean() + sxx = np.sum((t - tbar) ** 2) + slope = np.sum((t - tbar) * (y - ybar)) / sxx + intercept = ybar - slope * tbar + pred = intercept + slope * t + resid = y - pred + dof = max(n - 2, 1) + s_err = float(np.sqrt(np.sum(resid**2) / dof / sxx)) + ss_tot = np.sum((y - ybar) ** 2) + r2 = float(1.0 - np.sum(resid**2) / ss_tot) if ss_tot > 0 else 1.0 + return float(-slope), s_err, r2 + + +def field_l2(a, b): + return float(np.linalg.norm(a - b)) + + +def stress_from_uniform_arrival(f, tau, phi, transpose=False): + """σ on 16^2 from uniform arrival f; also ux/uy restricted for a_u.""" + sig, rho, ux, uy = sigma_xy_cell(f, tau) + if f.shape[1] == N_FINE_DOM: + sig16 = volume_avg_2x2(sig) + rho_f, jx_f, jy_f = moments(f) + _, _, _, ux16, uy16 = restrict_fine_to_coarse(rho_f, jx_f, jy_f) + else: + sig16 = sig + ux16, uy16 = ux, uy + u_proj = uy16 if transpose else ux16 + a_u, P_s = project_a_u_P_sigma(u_proj, sig16, phi, transpose=transpose) + R = P_s / (RHO0 * NU * K_PHYS * a_u) + s_prof = profile_s(sig16, a_u, transpose=transpose) + return { + "sigma16": sig16, + "ux16": ux16, + "uy16": uy16, + "a_u": a_u, + "P_sigma": P_s, + "R": R, + "s": s_prof, + "finite": bool(np.isfinite(sig16).all() and np.isfinite(a_u) and np.isfinite(P_s) + and np.isfinite(R) and (s_prof is None or np.isfinite(s_prof).all())), + } + + +def stress_from_composite_arrival(maps, state, phi, transpose=False): + sig16 = sigma_composite_16(maps, state) + # u from observe path (same restriction rule) + obs = None + # rebuild ux/uy on 16^2 from arrival populations (owned cells only) + n = N_COARSE + rho_c = np.full((n, n), np.nan) + jx_c = np.full((n, n), np.nan) + jy_c = np.full((n, n), np.nan) + fc, ff = state.coarse_f, state.fine_f + for y in range(n): + for x in range(n): + if maps.coarse_active[y, x]: + sl = fc[:, y, x] + rho_c[y, x] = sl.sum() + jx_c[y, x] = (CX * sl).sum() + jy_c[y, x] = (CY * sl).sum() + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + kids = [] + for dy in (0, 1): + for dx in (0, 1): + sl = ff[:, y0 + dy, x0 + dx] + kids.append((sl.sum(), (CX * sl).sum(), (CY * sl).sum())) + rho_c[ky, kx] = np.mean([k[0] for k in kids]) + jx_c[ky, kx] = np.mean([k[1] for k in kids]) + jy_c[ky, kx] = np.mean([k[2] for k in kids]) + ux16 = jx_c / rho_c + uy16 = jy_c / rho_c + u_proj = uy16 if transpose else ux16 + a_u, P_s = project_a_u_P_sigma(u_proj, sig16, phi, transpose=transpose) + R = P_s / (RHO0 * NU * K_PHYS * a_u) + s_prof = profile_s(sig16, a_u, transpose=transpose) + return { + "sigma16": sig16, + "ux16": ux16, + "uy16": uy16, + "a_u": a_u, + "P_sigma": P_s, + "R": R, + "s": s_prof, + "finite": bool(np.isfinite(sig16).all() and np.isfinite(a_u) and np.isfinite(P_s) + and np.isfinite(R) and (s_prof is None or np.isfinite(s_prof).all())), + } + + +def main(): + t0 = time.time() + print("=== local_stress_observable_v1 against CPUREF-0.3 ===") + print(f"REF_PATH={REF_PATH}") + ref_sha = sha256_file(REF_PATH) + print(f"REF_SHA256={ref_sha}") + expected_ref = "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e" + print(f"REF_SHA_MATCH={ref_sha == expected_ref}") + ref = _load_ref() + print(f"API_VERSION={getattr(ref, 'API_VERSION', '?')}") + assert getattr(ref, "API_VERSION", None) == "CPUREF-0.3" + print(f"U0={U0} tau_c={TAU_C} tau_f={TAU_F} steps={N_STEPS} nu={NU} k={K_PHYS}") + print(f"nu*k^2={NU * K_PHYS**2:.8f}") + print("ARRIVAL_STATE_POLICY: read CPUREF-0.3 State.coarse_f/fine_f at phase='arrival_f'") + print(" after N advance_coarse_once calls (tick=2N). That array IS post-stream pre-collision.") + print(" Uniform B/C use local collide-then-stream; f after 2N (B) or N (C) streams is arrival.") + print(" If arrival were unreachable without changing the reference, this script would STOP.") + print("STRESS: Pi_neq=sum(cx*cy*f)-rho*ux*uy; sigma=-(1-1/(2*tau))*Pi_neq; 2x2 vol-avg of sigma.") + print(" No extra dx/dt factor. a_u=(2/256)sum u sin; P_sigma=(2/256)sum sigma cos; own a_u.") + print(" Profile s(j)=mean_x[4,12)(sigma)/ (rho0*nu*k*a_u). Transposed: R only, no profile.") + print() + + phis = [0.0, 0.5 * np.pi] + results = {} + fields = {} + init_mm = {} + arrivals = {} + stress = {} + + for phi in phis: + tag = f"phi{phi:.4f}" + print(f"-- running C (uniform-coarse) {tag}") + ser_c, fld_c, f_c, tau_c, an_c = run_uniform("C", phi) + results[f"C_{tag}"] = ser_c + fields[f"C_{tag}"] = fld_c + arrivals[f"C_{tag}"] = an_c + stress[f"C_{tag}"] = stress_from_uniform_arrival(f_c, tau_c, phi) + print(f" ARRIVAL_ARRAY: {an_c}") + + print(f"-- running B (uniform-fine) {tag}") + ser_b, fld_b, f_b, tau_b, an_b = run_uniform("B", phi) + results[f"B_{tag}"] = ser_b + fields[f"B_{tag}"] = fld_b + arrivals[f"B_{tag}"] = an_b + stress[f"B_{tag}"] = stress_from_uniform_arrival(f_b, tau_b, phi) + print(f" ARRIVAL_ARRAY: {an_b}") + + print(f"-- running A (composite CPUREF) {tag}") + ser_a, fld_a, st_a, maps_a, mism, an_a = run_composite(ref, phi) + init_mm[tag] = mism + results[f"A_{tag}"] = ser_a + fields[f"A_{tag}"] = fld_a + arrivals[f"A_{tag}"] = an_a + stress[f"A_{tag}"] = stress_from_composite_arrival(maps_a, st_a, phi) + print(f" ARRIVAL_ARRAY: {an_a}") + print(f" state.phase={st_a.phase} state.tick={st_a.tick}") + + patch = np.zeros((N_COARSE, N_COARSE), dtype=bool) + patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True + out = ~patch + for t in range(N_STEPS + 1): + du = fld_a[t][0] - fld_b[t][0] + ser_a.e_if[t] = float(np.linalg.norm(du)) + ser_a.e_if_patch[t] = float(np.linalg.norm(du[patch])) + ser_a.e_if_out[t] = float(np.linalg.norm(du[out])) + ser_a.e_cb[t] = field_l2(fld_c[t][0], fld_b[t][0]) + + phi_t = 0.5 * np.pi + print(f"-- running A transpose phi={phi_t}") + ser_at, fld_at, st_at, maps_at, mism_t, an_at = run_composite(ref, phi_t, transpose=True) + init_mm["A_T"] = mism_t + results["A_T_phi1.5708"] = ser_at + fields["A_T_phi1.5708"] = fld_at + arrivals["A_T_phi1.5708"] = an_at + stress["A_T_phi1.5708"] = stress_from_composite_arrival( + maps_at, st_at, phi_t, transpose=True + ) + print(f" ARRIVAL_ARRAY: {an_at}") + + ser_a_ref = results["A_phi1.5708"] + fld_a_ref = fields["A_phi1.5708"] + trans_err = 0.0 + for t in range(N_STEPS + 1): + ux, uy, rho, jx, jy = fld_a_ref[t] + ux_t, uy_t, rho_t, jx_t, jy_t = fld_at[t] + err = max( + float(np.max(np.abs(ux_t - uy.T))), + float(np.max(np.abs(uy_t - ux.T))), + float(np.max(np.abs(rho_t - rho.T))), + ) + trans_err = max(trans_err, err) + transpose_ok = trans_err <= TRANS_TOL + + print("\n=== HARD CHECKS (unchanged v0.2 set) ===") + hard = {} + hard_keys = [] + for phi in phis: + tag = f"phi{phi:.4f}" + hard_keys.extend([f"A_{tag}", f"B_{tag}", f"C_{tag}"]) + hard_keys.append("A_T_phi1.5708") + for key in hard_keys: + s = results[key] + hard[f"finite_{key}"] = s.finite_ok + hard[f"cons_{key}"] = s.cons_ok + tol_abs = CONS_TOL * max(abs(s.mass[0]), 1.0) + print( + f" finite[{key}]={s.finite_ok} cons={s.cons_ok} " + f"mass_drift={s.mass_drift:.3e} momx_drift={s.momx_drift:.3e} " + f"momy_drift={s.momy_drift:.3e} tol_abs={tol_abs:.3e} {s.notes}" + ) + hard["transpose_ok"] = transpose_ok + print(f" transpose_ok={transpose_ok} max_err={trans_err:.3e} (tol={TRANS_TOL:.3e})") + + for phi in phis: + tag = f"phi{phi:.4f}" + ux_a = fields[f"A_{tag}"][0][0] + ux_b = fields[f"B_{tag}"][0][0] + ux_c = fields[f"C_{tag}"][0][0] + patch = np.zeros((16, 16), dtype=bool) + patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True + d_ab_p = float(np.max(np.abs(ux_a[patch] - ux_b[patch]))) + d_ac_o = float(np.max(np.abs(ux_a[~patch] - ux_c[~patch]))) + hard[f"t0_AB_patch_ok_{tag}"] = bool(d_ab_p <= T0_TOL) + hard[f"t0_AC_out_ok_{tag}"] = bool(d_ac_o <= T0_TOL) + hard[f"init_mismatch_ok_{tag}"] = bool(init_mm[tag] <= INIT_TOL) + print( + f" t0 {tag}: AB_patch={d_ab_p:.3e} ok={hard[f't0_AB_patch_ok_{tag}']} " + f"AC_out={d_ac_o:.3e} ok={hard[f't0_AC_out_ok_{tag}']} " + f"init_mm={init_mm[tag]:.3e} ok={hard[f'init_mismatch_ok_{tag}']}" + ) + hard["init_mismatch_ok_A_T_phi1.5708"] = bool(init_mm["A_T"] <= INIT_TOL) + print(f" init_mismatch[A_T]={init_mm['A_T']:.3e} ok={hard['init_mismatch_ok_A_T_phi1.5708']}") + + # New finite checks for stress arrays + print("\n=== STRESS FINITE CHECKS (addition) ===") + for key, stv in stress.items(): + ok = stv["finite"] + hard[f"stress_finite_{key}"] = ok + print( + f" stress_finite[{key}]={ok} R={stv['R']:.8f} a_u={stv['a_u']:.6e} " + f"P_sigma={stv['P_sigma']:.6e}" + ) + + _bool_keys = sorted(k for k, v in hard.items() if isinstance(v, bool)) + # Report both: original-22-style (without stress_finite) and all including stress + v02_bool = [k for k in _bool_keys if not k.startswith("stress_finite_")] + print(f" N_BOOLEAN_HARD_CHECKS_V02={len(v02_bool)}: " + ",".join(v02_bool)) + print(f" N_BOOLEAN_HARD_CHECKS_WITH_STRESS={len(_bool_keys)}") + + print("\n=== LOCAL STRESS OBSERVABLE at coarse step 32 ===") + print("FORMULA: R = P_sigma / (rho0 * nu * k * a_u); a_u from SAME run/tick/arrival") + print("WORDING: reported diagnostics only; phi probes phase structure only; no mechanism claim") + table_rows = [] + for phi in phis: + tag = f"phi{phi:.4f}" + sa, sb, sc = stress[f"A_{tag}"], stress[f"B_{tag}"], stress[f"C_{tag}"] + ra, rb, rc = sa["R"], sb["R"], sc["R"] + print(f"\n-- {tag} --") + print(f" R_A={ra:.10f} R_B={rb:.10f} R_C={rc:.10f}") + print(f" R_A/R_B={ra/rb:.10f} R_A/R_C={ra/rc:.10f}") + print(f" a_u A/B/C: {sa['a_u']:.8e} {sb['a_u']:.8e} {sc['a_u']:.8e}") + print(f" P_sigma A/B/C: {sa['P_sigma']:.8e} {sb['P_sigma']:.8e} {sc['P_sigma']:.8e}") + # profiles + sA, sB, sC = sa["s"], sb["s"], sc["s"] + dAB = sA - sB + dAC = sA - sC + face = {3, 4, 11, 12} + print(" s_A(j): " + " ".join(f"{v:.6e}" for v in sA)) + print(" s_B(j): " + " ".join(f"{v:.6e}" for v in sB)) + print(" s_C(j): " + " ".join(f"{v:.6e}" for v in sC)) + print(" s_A-s_B: " + " ".join(f"{v:.6e}" for v in dAB)) + print(" s_A-s_C: " + " ".join(f"{v:.6e}" for v in dAC)) + print( + " FACE_ROWS j=3,4,11,12 flagged: " + + " ".join( + f"j{j}:A={sA[j]:.5e},B={sB[j]:.5e},C={sC[j]:.5e}," + f"A-B={dAB[j]:.5e},A-C={dAC[j]:.5e}" + for j in (3, 4, 11, 12) + ) + ) + table_rows.append( + { + "phi": phi, + "tag": tag, + "R_A": ra, + "R_B": rb, + "R_C": rc, + "R_A/R_B": ra / rb, + "R_A/R_C": ra / rc, + "s_A": sA, + "s_B": sB, + "s_C": sC, + "dAB": dAB, + "dAC": dAC, + } + ) + + stT = stress["A_T_phi1.5708"] + print(f"\n-- A_T phi=1.5708 (R only, no profile) --") + print(f" R_A_T={stT['R']:.10f} a_u={stT['a_u']:.8e} P_sigma={stT['P_sigma']:.8e}") + print(f" profile={'None (transpose)'}") + + print("\n=== DECAY FITS (informational; same as v0.2) ===") + lam_analytic = NU * K_PHYS**2 + print(f" analytic lambda = {lam_analytic:.8f}") + for phi in phis: + tag = f"phi{phi:.4f}" + for kind in ("A", "B", "C"): + s = results[f"{kind}_{tag}"] + lam, se, r2 = fit_lambda(s.A) + print( + f" {kind} {tag}: lambda={lam:.8f} stderr={se:.3e} R2={r2:.6f} " + f"A0={s.A[0]:.6e} A32={s.A[-1]:.6e}" + ) + + all_hard_pass = all(v for k, v in hard.items() if isinstance(v, bool)) + v02_pass = all(hard[k] for k in v02_bool) + print("\n=== NUMERIC SUMMARY ===") + print(f"ALL_HARD_CHECKS_PASS_V02={v02_pass}") + print(f"ALL_HARD_CHECKS_PASS_WITH_STRESS={all_hard_pass}") + print(f"runtime_sec={time.time()-t0:.2f}") + + print("SUMMARY_LINES_BEGIN") + for row in table_rows: + print( + f"R {row['tag']} A={row['R_A']:.12f} B={row['R_B']:.12f} C={row['R_C']:.12f} " + f"A/B={row['R_A/R_B']:.12f} A/C={row['R_A/R_C']:.12f}" + ) + print( + f"PROFILE_A {row['tag']} " + " ".join(f"{v:.8e}" for v in row["s_A"]) + ) + print( + f"PROFILE_B {row['tag']} " + " ".join(f"{v:.8e}" for v in row["s_B"]) + ) + print( + f"PROFILE_C {row['tag']} " + " ".join(f"{v:.8e}" for v in row["s_C"]) + ) + print( + f"PROFILE_A-B {row['tag']} " + " ".join(f"{v:.8e}" for v in row["dAB"]) + ) + print( + f"PROFILE_A-C {row['tag']} " + " ".join(f"{v:.8e}" for v in row["dAC"]) + ) + print(f"R A_T_phi1.5708 R={stT['R']:.12f}") + print(f"TRANSPOSE err={trans_err:.6e} ok={transpose_ok}") + print(f"REF_SHA256={ref_sha}") + print("SUMMARY_LINES_END") + return 0 if all_hard_pass else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/validation/multigrid/shear_diagnostic_v0_2.py b/tests/validation/multigrid/shear_diagnostic_v0_2.py new file mode 100644 index 0000000..f33c3b6 --- /dev/null +++ b/tests/validation/multigrid/shear_diagnostic_v0_2.py @@ -0,0 +1,790 @@ +#!/usr/bin/env python3 +"""Frozen v0.2 shear interface diagnostic (test-side) against unchanged CPUREF-0.3. + +v0.2 change (root/TRON checker fix only): t0 convention mismatches and init_mismatch +are now explicit Boolean hard checks (INIT_TOL=CONS_TOL for O(1) rho/j restriction, +TRANS_TOL=1e-12*U0 for t0 velocity matches); per-cell RMS of e_IF patch/outside +printed as reported diagnostics. No algorithm, threshold or reference change. + +v0.1 changes (Claude review B1-B4): include A_T in hard checks; print mass/mom +drifts with relative tol wording; clarify coarse-step vs CPUREF-tick units and +unnormalised L2 norms; split e_IF patch/outside + print (uA-uB)(y) profiles; +report Pi_xy mean; drop made-up ±10% bracket boolean (print s alone). + +TRON freeze (thread 1791864801.121109) + implementation ask (1791865457.667669): + 7 runs, tau_c=0.8 / tau_f=1.1, 32 coarse steps, U0=1e-3, phi in {0, pi/2}, + A/B/C plus A transpose. Equilibrium-only from exact cell-average sine velocity; + restrict rho/j then divide; common 16^2 projection; startup 0-3 reported, fit 4-32. + Hard checks: finite active states, conservation, conventions, transpose. + Rate brackets / acoustic / interface errors are reported diagnostics only. + NumPy+stdlib only; reference file unchanged; no project integration. + +Label: init is equilibrium of *averaged velocity*, NOT the exact cell-average of +the nonlinear equilibrium population — population equality at t=0 is NOT asserted. +""" +from __future__ import annotations + +import hashlib +import importlib.util +import os +import sys +import time +from dataclasses import dataclass + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +REF_PATH = os.environ.get( + "CPU_REFERENCE_PATH", os.path.join(HERE, "cpu_reference_v0.3.py") +) + +U0 = 1e-3 +N_COARSE = 16 +N_FINE_DOM = 32 # uniform-fine domain in fine cells over physical [0,16) +PATCH_LO, PATCH_HI = 4, 12 +TAU_C, TAU_F = 0.8, 1.1 +N_STEPS = 32 +FIT_LO, FIT_HI = 4, 32 # inclusive *coarse steps*; CPUREF ticks = 2*step (fine hops) +CONS_TOL = 1e-12 +TRANS_TOL = 1e-12 * U0 +INIT_TOL = CONS_TOL # round-off tol for restricted rho,j vs coarse formula (O(1) quantities) +T0_TOL = TRANS_TOL # round-off tol for t0 velocity matches (O(U0) quantities) +K_PHYS = 2.0 * np.pi / 16.0 # physical wave number on [0,16) +CS2 = 1.0 / 3.0 +NU = CS2 * (TAU_C - 0.5) # = 0.1 in physical (coarse) units; matches fine scaling + +CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.int64) +CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.int64) +W = np.array([4 / 9] + [1 / 9] * 4 + [1 / 36] * 4, dtype=np.float64) +NQ = 9 + + +def _load_ref(): + spec = importlib.util.spec_from_file_location("cpu_ref_v03", REF_PATH) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def sha256_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 16), b""): + h.update(chunk) + return h.hexdigest() + + +def sinc(x: np.ndarray | float) -> np.ndarray | float: + x = np.asarray(x, dtype=np.float64) + out = np.empty_like(x, dtype=np.float64) + small = np.abs(x) < 1e-14 + out[small] = 1.0 + xs = x[~small] + out[~small] = np.sin(xs) / xs + return float(out) if out.ndim == 0 else out + + +def feq(rho, ux, uy): + """Second-order D2Q9 equilibrium; rho,ux,uy broadcastable to (ny,nx).""" + rho = np.asarray(rho, dtype=np.float64) + ux = np.asarray(ux, dtype=np.float64) + uy = np.asarray(uy, dtype=np.float64) + cu = CX[:, None, None] * ux + CY[:, None, None] * uy + usq = ux * ux + uy * uy + return W[:, None, None] * rho * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq) + + +def srt_collide(f, tau, mask=None): + g = f.copy() + if mask is None: + mask = np.ones(f.shape[1:], dtype=bool) + fm = f[:, mask] + rho = fm.sum(axis=0) + ux = (CX[:, None] * fm).sum(axis=0) / rho + uy = (CY[:, None] * fm).sum(axis=0) / rho + eq = feq(rho, ux, uy) # shape (9, ny, nx) but we need only masked — rebuild + # rebuild eq on full grid then mask + rho_g = np.zeros(f.shape[1:]) + ux_g = np.zeros(f.shape[1:]) + uy_g = np.zeros(f.shape[1:]) + rho_g[mask] = rho + ux_g[mask] = ux + uy_g[mask] = uy + eq_full = feq(rho_g, ux_g, uy_g) + g[:, mask] = fm - (fm - eq_full[:, mask]) / tau + return g + + +def stream_periodic(g): + out = np.empty_like(g) + for i in range(NQ): + out[i] = np.roll(g[i], (int(CY[i]), int(CX[i])), axis=(0, 1)) + return out + + +def moments(f): + rho = f.sum(axis=0) + jx = (CX[:, None, None] * f).sum(axis=0) + jy = (CY[:, None, None] * f).sum(axis=0) + return rho, jx, jy + + +def cell_avg_ux(y_centers, dx, phi, transpose=False): + """Exact cell-average of U0 sin(k y + phi) [or sin(k x + phi) if transpose].""" + factor = float(sinc(K_PHYS * dx / 2.0)) + if transpose: + # shear u_y(x): need x centers; caller passes x as y_centers arg + return U0 * factor * np.sin(K_PHYS * y_centers + phi) + return U0 * factor * np.sin(K_PHYS * y_centers + phi) + + +def mode_template(phi): + """m on 16^2 coarse centers: sinc(k/2) sin(k yc + phi).""" + yc = np.arange(N_COARSE, dtype=np.float64) + 0.5 + m1d = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * yc + phi) + return np.broadcast_to(m1d[:, None], (N_COARSE, N_COARSE)).copy() # [y,x] + + +def project_amp(ux, phi): + m = mode_template(phi) + return float(np.sum(ux * m) / np.sum(m * m)) + + +def restrict_fine_to_coarse(rho_f, jx_f, jy_f): + """2x2 arithmetic mean of conserved rho,j on 32^2 -> 16^2, then u=j/rho.""" + assert rho_f.shape == (N_FINE_DOM, N_FINE_DOM) + rho = rho_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + jx = jx_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + jy = jy_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3)) + ux = jx / rho + uy = jy / rho + return rho, jx, jy, ux, uy + + +def pi_xy(f, tau): + """Pre-collision Π_xy neq proxy: sum c_x c_y f (lattice units of that grid).""" + return (CX[:, None, None] * CY[:, None, None] * f).sum(axis=0) + + +@dataclass +class Series: + name: str + phi: float + A: np.ndarray # length 33 (coarse steps 0..32) + e_if: np.ndarray # ||u - u_B|| unnormalised L2 over 256 cells (filled later for A) + e_cb: np.ndarray + e_if_patch: np.ndarray # L2 on patch cells only (filled for A) + e_if_out: np.ndarray # L2 on outside cells only (filled for A) + nonmodal: np.ndarray + max_uy: np.ndarray + max_drho: np.ndarray + mass: np.ndarray + momx: np.ndarray + momy: np.ndarray + pi_xy_mean: np.ndarray # mean pre-collision Pi_xy proxy (uniform grids); NaN for composite + finite_ok: bool + cons_ok: bool + mass_drift: float = 0.0 + momx_drift: float = 0.0 + momy_drift: float = 0.0 + notes: str = "" + + +def init_uniform(n, dx, tau, phi, transpose=False): + """Initialize n×n periodic SRT lattice; dx = cell size in physical units.""" + centers = (np.arange(n, dtype=np.float64) + 0.5) * dx + # physical coords + Y, X = np.meshgrid(centers, centers, indexing="ij") # [y,x] + rho = np.ones((n, n), dtype=np.float64) + if transpose: + ux = np.zeros_like(rho) + uy = cell_avg_ux(X, dx, phi) # u_y = U0 sinc sin(k x + phi) + else: + ux = cell_avg_ux(Y, dx, phi) + uy = np.zeros_like(rho) + f = feq(rho, ux, uy) + return f + + +def advance_uniform(f, tau, steps): + for _ in range(steps): + g = srt_collide(f, tau) + f = stream_periodic(g) + return f + + +def observe_uniform(f, dx, phi, transpose=False): + rho, jx, jy = moments(f) + if dx < 1.0 - 1e-12: # fine 32^2 -> restrict + rho_c, jx_c, jy_c, ux, uy = restrict_fine_to_coarse(rho, jx, jy) + else: + rho_c, jx_c, jy_c = rho, jx, jy + ux, uy = jx / rho, jy / rho + if transpose: + # project onto transposed mode using uy vs m(x) + m = mode_template(phi) # m[y,x] = sinc sin(k yc+phi) — for transpose need m_x + xc = np.arange(N_COARSE, dtype=np.float64) + 0.5 + mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi) + mT = np.broadcast_to(mx[None, :], (N_COARSE, N_COARSE)).copy() + amp = float(np.sum(uy * mT) / np.sum(mT * mT)) + modal = amp * mT + nonmodal = float(np.linalg.norm(uy - modal) / U0) + max_cross = float(np.max(np.abs(ux)) / U0) + else: + amp = project_amp(ux, phi) + m = mode_template(phi) + modal = amp * m + nonmodal = float(np.linalg.norm(ux - modal) / U0) + max_cross = float(np.max(np.abs(uy)) / U0) + max_drho = float(np.max(np.abs(rho_c - 1.0))) + return { + "amp": amp, + "ux": ux, + "uy": uy, + "rho": rho_c, + "jx": jx_c, + "jy": jy_c, + "nonmodal": nonmodal, + "max_cross": max_cross, + "max_drho": max_drho, + "mass": float(rho_c.sum()), + "momx": float(jx_c.sum()), + "momy": float(jy_c.sum()), + "pi_xy_mean": float(np.nanmean(pi_xy(f, None))), + } + + +def run_uniform(kind, phi, transpose=False): + """kind: 'B' fine 32^2 or 'C' coarse 16^2. Returns Series + per-tick ux fields.""" + if kind == "B": + n, dx, tau, sub = N_FINE_DOM, 0.5, TAU_F, 2 + else: + n, dx, tau, sub = N_COARSE, 1.0, TAU_C, 1 + f = init_uniform(n, dx, tau, phi, transpose=transpose) + amps = [] + nonmodal = [] + max_cross = [] + max_drho = [] + mass = [] + momx = [] + momy = [] + pi_means = [] + fields = [] + finite_ok = True + for tick in range(N_STEPS + 1): + if not np.isfinite(f).all(): + finite_ok = False + obs = observe_uniform(f, dx, phi, transpose=transpose) + amps.append(obs["amp"]) + nonmodal.append(obs["nonmodal"]) + max_cross.append(obs["max_cross"]) + max_drho.append(obs["max_drho"]) + mass.append(obs["mass"]) + momx.append(obs["momx"]) + momy.append(obs["momy"]) + pi_means.append(obs["pi_xy_mean"]) + fields.append((obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(), + obs["jx"].copy(), obs["jy"].copy())) + if tick < N_STEPS: + f = advance_uniform(f, tau, sub) + mass = np.asarray(mass) + momx = np.asarray(momx) + momy = np.asarray(momy) + mass_drift = float(np.max(np.abs(mass - mass[0]))) + momx_drift = float(np.max(np.abs(momx - momx[0]))) + momy_drift = float(np.max(np.abs(momy - momy[0]))) + tol_abs = CONS_TOL * abs(mass[0]) + cons_ok = ( + mass_drift <= tol_abs + and momx_drift <= tol_abs + and momy_drift <= tol_abs + ) + ser = Series( + name=f"{kind}_phi{phi:.4f}{'_T' if transpose else ''}", + phi=phi, + A=np.asarray(amps), + e_if=np.full(N_STEPS + 1, np.nan), + e_cb=np.full(N_STEPS + 1, np.nan), + e_if_patch=np.full(N_STEPS + 1, np.nan), + e_if_out=np.full(N_STEPS + 1, np.nan), + nonmodal=np.asarray(nonmodal), + max_uy=np.asarray(max_cross), + max_drho=np.asarray(max_drho), + mass=mass, + momx=momx, + momy=momy, + pi_xy_mean=np.asarray(pi_means), + finite_ok=finite_ok, + cons_ok=bool(cons_ok), + mass_drift=mass_drift, + momx_drift=momx_drift, + momy_drift=momy_drift, + ) + return ser, fields + + +def init_composite(ref, spec, maps, phi, transpose=False): + """Equilibrium-only init on composite from cell-average sine velocity.""" + n, nf = spec.n_coarse, spec.n_fine + coarse = np.full((NQ, n, n), np.nan) + fine = np.full((NQ, nf, nf), np.nan) + # coarse cells (including covered): physical centers + yc = np.arange(n, dtype=np.float64) + 0.5 + xc = np.arange(n, dtype=np.float64) + 0.5 + Yc, Xc = np.meshgrid(yc, xc, indexing="ij") + if transpose: + ux_c = np.zeros((n, n)) + uy_c = cell_avg_ux(Xc, 1.0, phi) + else: + ux_c = cell_avg_ux(Yc, 1.0, phi) + uy_c = np.zeros((n, n)) + eq_c = feq(np.ones((n, n)), ux_c, uy_c) + coarse[:, :, :] = eq_c # covered also filled; advance will NaN them as inactive reads + # but make_state only fills active — we must match API: covered should be NaN for advance + coarse[:, maps.coarse_covered] = np.nan + + # fine active: physical centres from maps.fine_centre (includes patch offset) + fy = maps.fine_centre # length nf + fx = maps.fine_centre + Yf, Xf = np.meshgrid(fy, fx, indexing="ij") + if transpose: + ux_f = np.zeros((nf, nf)) + uy_f = cell_avg_ux(Xf, 0.5, phi) + else: + ux_f = cell_avg_ux(Yf, 0.5, phi) + uy_f = np.zeros((nf, nf)) + eq_f = feq(np.ones((nf, nf)), ux_f, uy_f) + fine[:, maps.fine_active] = eq_f[:, maps.fine_active] + return ref.State(coarse_f=coarse, fine_f=fine, phase="arrival_f", tick=0) + + +def observe_composite(ref, maps, state, phi, transpose=False): + fc, ff = state.coarse_f, state.fine_f + # Build full 32^2 physical fine field for restriction: outside patch use + # exploded-from-coarse equivalent = coarse cell values duplicated 2x2. + # Observables: restrict rho/j then u=j/rho on common 16^2. + # Outside patch: use coarse as stored. Inside patch: 2x2 mean of fine. + n = N_COARSE + rho_c = np.full((n, n), np.nan) + jx_c = np.full((n, n), np.nan) + jy_c = np.full((n, n), np.nan) + + # uncovered coarse + rho_a, jx_a, jy_a = moments(np.nan_to_num(fc, nan=0.0)) + # only trust active + for y in range(n): + for x in range(n): + if maps.coarse_active[y, x]: + sl = fc[:, y, x] + rho_c[y, x] = sl.sum() + jx_c[y, x] = (CX * sl).sum() + jy_c[y, x] = (CY * sl).sum() + + # fine patch -> restrict into covered coarse cells + # fine active indices a=4..19 map to physical coarse [4,12) + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + kids = [] + for dy in (0, 1): + for dx in (0, 1): + fy, fx = y0 + dy, x0 + dx + sl = ff[:, fy, fx] + kids.append((sl.sum(), (CX * sl).sum(), (CY * sl).sum())) + rho_c[ky, kx] = np.mean([k[0] for k in kids]) + jx_c[ky, kx] = np.mean([k[1] for k in kids]) + jy_c[ky, kx] = np.mean([k[2] for k in kids]) + + ux = jx_c / rho_c + uy = jy_c / rho_c + if transpose: + xc = np.arange(n, dtype=np.float64) + 0.5 + mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi) + mT = np.broadcast_to(mx[None, :], (n, n)).copy() + amp = float(np.sum(uy * mT) / np.sum(mT * mT)) + modal = amp * mT + nonmodal = float(np.linalg.norm(uy - modal) / U0) + max_cross = float(np.max(np.abs(ux)) / U0) + else: + amp = project_amp(ux, phi) + m = mode_template(phi) + modal = amp * m + nonmodal = float(np.linalg.norm(ux - modal) / U0) + max_cross = float(np.max(np.abs(uy)) / U0) + + # leaf totals for conservation (volume-weighted) + leaf = ref._leaf_totals(fc, ff, maps) + + return { + "amp": amp, + "ux": ux, + "uy": uy, + "rho": rho_c, + "jx": jx_c, + "jy": jy_c, + "nonmodal": nonmodal, + "max_cross": max_cross, + "max_drho": float(np.max(np.abs(rho_c - 1.0))), + "mass": float(leaf[0]), + "momx": float(leaf[1]), + "momy": float(leaf[2]), + "finite_active": bool( + np.isfinite(fc[:, maps.coarse_active]).all() + and np.isfinite(ff[:, maps.fine_active]).all() + ), + } + + +def spec_children_start(k): + return 4 + 2 * (k - PATCH_LO) # halo=4 + + +def run_composite(ref, phi, transpose=False): + spec = ref.make_spec(collision="srt") + # force tau (already default) + assert abs(spec.tau_coarse - TAU_C) < 1e-15 and abs(spec.tau_fine - TAU_F) < 1e-15 + maps = ref.build_maps(spec) + st = init_composite(ref, spec, maps, phi, transpose=transpose) + + # hard check: initial restricted rho,j under patch match coarse formula to round-off + # Compare fine-restricted vs what coarse cell-average would be under patch + yc = np.arange(N_COARSE) + 0.5 + xc = np.arange(N_COARSE) + 0.5 + init_mismatch = 0.0 + for ky in range(PATCH_LO, PATCH_HI): + for kx in range(PATCH_LO, PATCH_HI): + y0 = spec_children_start(ky) + x0 = spec_children_start(kx) + rhos, jxs, jys = [], [], [] + for dy in (0, 1): + for dx in (0, 1): + sl = st.fine_f[:, y0 + dy, x0 + dx] + rhos.append(sl.sum()) + jxs.append((CX * sl).sum()) + jys.append((CY * sl).sum()) + rho_r, jx_r, jy_r = np.mean(rhos), np.mean(jxs), np.mean(jys) + # coarse formula at (kx,ky) + if transpose: + ux = 0.0 + uy = float(cell_avg_ux(xc[kx], 1.0, phi)) + else: + ux = float(cell_avg_ux(yc[ky], 1.0, phi)) + uy = 0.0 + init_mismatch = max( + init_mismatch, + abs(rho_r - 1.0), + abs(jx_r - ux), + abs(jy_r - uy), + ) + + amps, nonmodal, max_cross, max_drho = [], [], [], [] + mass, momx, momy = [], [], [] + fields = [] + finite_ok = True + for tick in range(N_STEPS + 1): + obs = observe_composite(ref, maps, st, phi, transpose=transpose) + if not obs["finite_active"]: + finite_ok = False + amps.append(obs["amp"]) + nonmodal.append(obs["nonmodal"]) + max_cross.append(obs["max_cross"]) + max_drho.append(obs["max_drho"]) + mass.append(obs["mass"]) + momx.append(obs["momx"]) + momy.append(obs["momy"]) + fields.append((obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(), + obs["jx"].copy(), obs["jy"].copy())) + if tick < N_STEPS: + st, _ = ref.advance_coarse_once(spec, maps, st) + + mass = np.asarray(mass) + momx = np.asarray(momx) + momy = np.asarray(momy) + mass_drift = float(np.max(np.abs(mass - mass[0]))) + momx_drift = float(np.max(np.abs(momx - momx[0]))) + momy_drift = float(np.max(np.abs(momy - momy[0]))) + tol_abs = CONS_TOL * max(abs(mass[0]), 1.0) + cons_ok = ( + mass_drift <= tol_abs + and momx_drift <= tol_abs + and momy_drift <= tol_abs + ) + ser = Series( + name=f"A_phi{phi:.4f}{'_T' if transpose else ''}", + phi=phi, + A=np.asarray(amps), + e_if=np.full(N_STEPS + 1, np.nan), + e_cb=np.full(N_STEPS + 1, np.nan), + e_if_patch=np.full(N_STEPS + 1, np.nan), + e_if_out=np.full(N_STEPS + 1, np.nan), + nonmodal=np.asarray(nonmodal), + max_uy=np.asarray(max_cross), + max_drho=np.asarray(max_drho), + mass=mass, + momx=momx, + momy=momy, + pi_xy_mean=np.full(N_STEPS + 1, np.nan), + finite_ok=finite_ok, + cons_ok=bool(cons_ok), + mass_drift=mass_drift, + momx_drift=momx_drift, + momy_drift=momy_drift, + notes=f"init_rho_j_mismatch_vs_coarse_formula={init_mismatch:.3e}", + ) + return ser, fields, init_mismatch + + +def fit_lambda(amps): + """Log-linear fit of A(t) over ticks FIT_LO..FIT_HI; return lambda, stderr, R2.""" + t = np.arange(FIT_LO, FIT_HI + 1, dtype=np.float64) + y = np.log(np.abs(amps[FIT_LO : FIT_HI + 1])) + # A(t) ~ A0 exp(-lambda t) => log|A| = log|A0| - lambda t + n = len(t) + tbar, ybar = t.mean(), y.mean() + sxx = np.sum((t - tbar) ** 2) + slope = np.sum((t - tbar) * (y - ybar)) / sxx # = -lambda + intercept = ybar - slope * tbar + pred = intercept + slope * t + resid = y - pred + dof = max(n - 2, 1) + s_err = float(np.sqrt(np.sum(resid**2) / dof / sxx)) + ss_tot = np.sum((y - ybar) ** 2) + r2 = float(1.0 - np.sum(resid**2) / ss_tot) if ss_tot > 0 else 1.0 + return float(-slope), s_err, r2 + + +def field_l2(a, b): + return float(np.linalg.norm(a - b)) + + +def main(): + t0 = time.time() + print("=== shear_diagnostic_v0.2 against CPUREF-0.3 ===") + print(f"REF_PATH={REF_PATH}") + print(f"REF_SHA256={sha256_file(REF_PATH)}") + ref = _load_ref() + print(f"API_VERSION={getattr(ref, 'API_VERSION', '?')}") + assert getattr(ref, "API_VERSION", None) == "CPUREF-0.3" + print(f"U0={U0} tau_c={TAU_C} tau_f={TAU_F} steps={N_STEPS} nu={NU} k={K_PHYS}") + print(f"nu*k^2={NU * K_PHYS**2:.8f} (analytic decay rate)") + print("INIT: equilibrium of averaged velocity (NOT exact cell-avg of nonlinear feq)") + print("UNITS: fit/report 'steps' are coarse steps; CPUREF tick = fine step count") + print(f" fit window coarse steps {FIT_LO}..{FIT_HI} = CPUREF ticks {2*FIT_LO}..{2*FIT_HI}") + print(f" startup coarse steps 0..3 = CPUREF ticks 0..6") + print(" nonmodal/e_IF/e_CB are unnormalised L2 sums over 256 coarse cells") + print(" (per-cell RMS = value/16); nonmodal is already /U0") + print(f"CONS_TOL={CONS_TOL} relative to mass (~256) => abs ~{CONS_TOL*256:.3e}") + print() + + phis = [0.0, 0.5 * np.pi] + results = {} + fields = {} + init_mm = {} + + # C and B for both phases + for phi in phis: + tag = f"phi{phi:.4f}" + print(f"-- running C (uniform-coarse) {tag}") + ser_c, fld_c = run_uniform("C", phi) + results[f"C_{tag}"] = ser_c + fields[f"C_{tag}"] = fld_c + print(f"-- running B (uniform-fine) {tag}") + ser_b, fld_b = run_uniform("B", phi) + results[f"B_{tag}"] = ser_b + fields[f"B_{tag}"] = fld_b + print(f"-- running A (composite CPUREF) {tag}") + ser_a, fld_a, mism = run_composite(ref, phi) + init_mm[tag] = mism + results[f"A_{tag}"] = ser_a + fields[f"A_{tag}"] = fld_a + # fill e_IF = ||A-B||, e_CB = ||C-B|| on restricted ux (unnormalised L2) + patch = np.zeros((N_COARSE, N_COARSE), dtype=bool) + patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True + out = ~patch + for t in range(N_STEPS + 1): + du = fld_a[t][0] - fld_b[t][0] + ser_a.e_if[t] = float(np.linalg.norm(du)) + ser_a.e_if_patch[t] = float(np.linalg.norm(du[patch])) + ser_a.e_if_out[t] = float(np.linalg.norm(du[out])) + ser_a.e_cb[t] = field_l2(fld_c[t][0], fld_b[t][0]) + ser_c.e_cb[t] = ser_a.e_cb[t] + ser_b.e_if[t] = 0.0 + + # A transpose at phi=pi/2 + phi_t = 0.5 * np.pi + print(f"-- running A transpose phi={phi_t}") + ser_at, fld_at, mism_t = run_composite(ref, phi_t, transpose=True) + init_mm["A_T"] = mism_t + results["A_T_phi1.5708"] = ser_at + fields["A_T_phi1.5708"] = fld_at + + # Also need B transpose? For e_IF on transpose — freeze says A transposed only. + # Transpose hard check: A_T fields equal transpose of A at same phi. + ser_a_ref = results["A_phi1.5708"] + fld_a_ref = fields["A_phi1.5708"] + trans_err = 0.0 + for t in range(N_STEPS + 1): + ux, uy, rho, jx, jy = fld_a_ref[t] + ux_t, uy_t, rho_t, jx_t, jy_t = fld_at[t] + # original: ux(y), uy~0; transpose: uy(x), ux~0 + # transpose of original velocity: u'_x(x,y)=uy(y,x), u'_y(x,y)=ux(y,x) + err = max( + float(np.max(np.abs(ux_t - uy.T))), + float(np.max(np.abs(uy_t - ux.T))), + float(np.max(np.abs(rho_t - rho.T))), + ) + trans_err = max(trans_err, err) + transpose_ok = trans_err <= TRANS_TOL + + # Convention match A/B/C at t=0: restricted rho,j under patch + print("\n=== HARD CHECKS ===") + hard = {} + hard_keys = [] + for phi in phis: + tag = f"phi{phi:.4f}" + hard_keys.extend([f"A_{tag}", f"B_{tag}", f"C_{tag}"]) + hard_keys.append("A_T_phi1.5708") + for key in hard_keys: + s = results[key] + hard[f"finite_{key}"] = s.finite_ok + hard[f"cons_{key}"] = s.cons_ok + tol_abs = CONS_TOL * max(abs(s.mass[0]), 1.0) + print( + f" finite[{key}]={s.finite_ok} cons={s.cons_ok} " + f"mass_drift={s.mass_drift:.3e} momx_drift={s.momx_drift:.3e} " + f"momy_drift={s.momy_drift:.3e} tol_abs={tol_abs:.3e} " + f"(CONS_TOL*mass={CONS_TOL}*|{s.mass[0]:.6g}|) {s.notes}" + ) + hard["transpose_ok"] = transpose_ok + hard["transpose_err"] = trans_err + print(f" transpose_ok={transpose_ok} max_err={trans_err:.3e} (tol={TRANS_TOL:.3e})") + + # t=0 convention: A vs B vs C amplitudes and restricted fields + for phi in phis: + tag = f"phi{phi:.4f}" + a0 = results[f"A_{tag}"].A[0] + b0 = results[f"B_{tag}"].A[0] + c0 = results[f"C_{tag}"].A[0] + print(f" t0 amp A/B/C {tag}: {a0:.8e} {b0:.8e} {c0:.8e}") + # restricted j under patch: A vs theoretical + ux_a = fields[f"A_{tag}"][0][0] + ux_b = fields[f"B_{tag}"][0][0] + ux_c = fields[f"C_{tag}"][0][0] + # under patch and outside + patch = np.zeros((16, 16), dtype=bool) + patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True + d_ab_p = float(np.max(np.abs(ux_a[patch] - ux_b[patch]))) + d_ac_o = float(np.max(np.abs(ux_a[~patch] - ux_c[~patch]))) + d_bc = float(np.max(np.abs(ux_b - ux_c))) + print(f" t0 max|ux A-B| patch={d_ab_p:.3e} |A-C| outside={d_ac_o:.3e} |B-C| all={d_bc:.3e}") + hard[f"t0_AB_patch_{tag}"] = d_ab_p + hard[f"t0_AC_out_{tag}"] = d_ac_o + hard[f"t0_AB_patch_ok_{tag}"] = bool(d_ab_p <= T0_TOL) + hard[f"t0_AC_out_ok_{tag}"] = bool(d_ac_o <= T0_TOL) + hard[f"init_mismatch_ok_{tag}"] = bool(init_mm[tag] <= INIT_TOL) + print(f" t0_AB_patch_ok={hard[f't0_AB_patch_ok_{tag}']} t0_AC_out_ok={hard[f't0_AC_out_ok_{tag}']} " + f"(T0_TOL={T0_TOL:.1e}) init_mismatch={init_mm[tag]:.3e} ok={hard[f'init_mismatch_ok_{tag}']} " + f"(INIT_TOL={INIT_TOL:.1e})") + hard["init_mismatch_ok_A_T_phi1.5708"] = bool(init_mm["A_T"] <= INIT_TOL) + print(f" init_mismatch[A_T]={init_mm['A_T']:.3e} ok={hard['init_mismatch_ok_A_T_phi1.5708']} (INIT_TOL={INIT_TOL:.1e})") + _bool_keys = sorted(k for k, v in hard.items() if isinstance(v, bool)) + print(f" N_BOOLEAN_HARD_CHECKS={len(_bool_keys)}: " + ",".join(_bool_keys)) + + print("\n=== DECAY FITS (coarse steps 4..32 = CPUREF ticks 8..64) ===") + lam_analytic = NU * K_PHYS**2 + print(f" analytic lambda = nu*k^2 = {lam_analytic:.8f}") + fit_table = {} + patch = np.zeros((N_COARSE, N_COARSE), dtype=bool) + patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True + for phi in phis: + tag = f"phi{phi:.4f}" + row = {} + for kind in ("A", "B", "C"): + s = results[f"{kind}_{tag}"] + lam, se, r2 = fit_lambda(s.A) + row[kind] = (lam, se, r2, s.A[0], s.A[-1]) + print( + f" {kind} {tag}: lambda={lam:.8f} stderr={se:.3e} R2={r2:.6f} " + f"A0={s.A[0]:.6e} A32={s.A[-1]:.6e}" + ) + fit_table[tag] = row + la, lb, lc = row["A"][0], row["B"][0], row["C"][0] + s_flag = (la - lb) / (lc - lb) if abs(lc - lb) > 1e-16 else float("nan") + print(f" DIAG s=(lambda_A-lambda_B)/(lambda_C-lambda_B) {tag}: s={s_flag:.4f} " + f"(lambda_C-lambda_B)={lc-lb:.6e}") + sa = results[f"A_{tag}"] + print( + f" DIAG e_IF/e_CB step32 (unnorm L2/256cells): e_IF={sa.e_if[-1]:.6e} " + f"e_IF_patch={sa.e_if_patch[-1]:.6e} e_IF_out={sa.e_if_out[-1]:.6e} " + f"e_CB={sa.e_cb[-1]:.6e} ratio_IF/CB={sa.e_if[-1]/max(sa.e_cb[-1],1e-30):.4f}" + ) + n_p, n_o = int(patch.sum()), int((~patch).sum()) + rms_p = sa.e_if_patch[-1] / np.sqrt(n_p) + rms_o = sa.e_if_out[-1] / np.sqrt(n_o) + frac_p = sa.e_if_patch[-1] ** 2 / max(sa.e_if[-1] ** 2, 1e-300) + print( + f" DIAG per-cell RMS (uA-uB) step32 {tag}: patch={rms_p:.4e} ({n_p} cells) " + f"outside={rms_o:.4e} ({n_o} cells) ratio={rms_p/rms_o:.3f} " + f"patch_energy_frac={frac_p:.4f} (area frac {n_p/(n_p+n_o):.2f})" + ) + print( + f" DIAG sensors step32: nonmodal(L2/U0)={sa.nonmodal[-1]:.4e} " + f"max|uy|/U0={sa.max_uy[-1]:.4e} max|drho|={sa.max_drho[-1]:.4e}" + ) + # x-averaged (u_A - u_B)(y) at step 32 + du = fields[f"A_{tag}"][32][0] - fields[f"B_{tag}"][32][0] + profile = du.mean(axis=1) # average over x + print(f" DIAG (uA-uB)_xavg(y) step32 {tag}: " + + " ".join(f"{v:.3e}" for v in profile)) + # Pi_xy at step 32 for uniforms (arrival_f / post-stream) + sb = results[f"B_{tag}"] + sc = results[f"C_{tag}"] + print( + f" DIAG Pi_xy_mean step32 {tag}: B={sb.pi_xy_mean[32]:.6e} " + f"C={sc.pi_xy_mean[32]:.6e} (composite=NaN; arrival_f proxy on uniforms)" + ) + + print("\n=== PER-TICK AMPLITUDE (selected) ===") + hdr = "tick " + " ".join( + f"{k:>14}" for k in ["A_p0", "B_p0", "C_p0", "A_p90", "B_p90", "C_p90", "exact"] + ) + print(hdr) + for t in list(range(0, 4)) + list(range(4, 33, 4)): + exact = results["B_phi0.0000"].A[0] * np.exp(-lam_analytic * t) + vals = [ + results["A_phi0.0000"].A[t], + results["B_phi0.0000"].A[t], + results["C_phi0.0000"].A[t], + results["A_phi1.5708"].A[t], + results["B_phi1.5708"].A[t], + results["C_phi1.5708"].A[t], + exact, + ] + print(f"{t:4d} " + " ".join(f"{v:14.6e}" for v in vals)) + + print("\n=== NUMERIC SUMMARY ===") + all_hard_pass = all( + v for k, v in hard.items() if isinstance(v, bool) + ) + print(f"ALL_HARD_CHECKS_PASS={all_hard_pass}") + print(f"runtime_sec={time.time()-t0:.2f}") + # compact JSON-ish summary lines for Drive + print("SUMMARY_LINES_BEGIN") + for phi in phis: + tag = f"phi{phi:.4f}" + for kind in ("A", "B", "C"): + lam, se, r2, a0, a32 = fit_table[tag][kind] + print( + f"FIT {kind} {tag} lambda={lam:.10f} stderr={se:.6e} R2={r2:.8f} " + f"A0={a0:.10e} A32={a32:.10e}" + ) + print(f"TRANSPOSE err={trans_err:.6e} ok={transpose_ok}") + print(f"ANALYTIC lambda={lam_analytic:.10f}") + print("SUMMARY_LINES_END") + return 0 if all_hard_pass else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/validation/multigrid/test_cpu_reference.py b/tests/validation/multigrid/test_cpu_reference.py new file mode 100644 index 0000000..b7711a2 --- /dev/null +++ b/tests/validation/multigrid/test_cpu_reference.py @@ -0,0 +1,481 @@ +"""Independent unittest suite for the AMR 2:1 CPU volumetric reference (Grok, 2026-10-13). + +Target: cpu_reference.py, API CPUREF-0.3 (also accepts CPU_REFERENCE_PATH env var). +Contract: AMR_Shared_CPU_API_2026-10-13.md, SHA256 ee8010e7...e112f6, plus +make_spec(*, collision='identity'|'srt'). + +Independence: expected values come from (a) a separate scalar-loop implementation +of the contract written here from the contract text and S-R Alg. 1 (it never uses +reference maps, masks, or helpers), and (b) a hand-derived lower-left SRT corner +oracle. Reference diagnostics are only checked against independently derived counts. +NumPy + stdlib only. <= 8 coarse steps per case. +""" +import importlib.util +import os +import sys +import unittest + +import numpy as np + +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _load_reference(): + cands = [os.environ.get("CPU_REFERENCE_PATH", ""), + os.path.join(_HERE, "cpu_reference.py"), + os.path.join(_HERE, "cpu_reference_v0.3.py"), + os.path.join(_HERE, "cpu_reference_v0.2.py")] + for p in cands: + if p and os.path.exists(p): + spec = importlib.util.spec_from_file_location("cpu_reference_under_test", p) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod, p + raise FileNotFoundError("cpu_reference.py not found") + + +ref, REF_PATH = _load_reference() + +# ---- independent constants from the contract ------------------------------- +CX = (0, 1, -1, 0, 0, 1, -1, 1, -1) +CY = (0, 0, 0, 1, -1, 1, -1, -1, 1) +OPP = (0, 2, 1, 4, 3, 6, 5, 8, 7) +WT = (4 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 36, 1 / 36, 1 / 36, 1 / 36) +N, NF, LO, HI, H = 16, 24, 4, 12, 4 +TAU_C, TAU_F = 0.8, 1.1 +# composite conservation tolerance: relative 1e-14 of |leaf total| over 8 steps (round-off only) +CONS_TOL = 1e-14 + + +def covered(x, y): + return LO <= x < HI and LO <= y < HI + + +def f_active(a, b): + return H <= a < NF - H and H <= b < NF - H + + +def parent(a): + return LO + (a - H) // 2 + + +def f_inner(a, b): + return (not f_active(a, b)) and LO - 1 <= parent(a) <= HI and LO - 1 <= parent(b) <= HI + + +def f_outer(a, b): + return not f_active(a, b) and not f_inner(a, b) + + +def children(kx, ky): + a0, b0 = H + 2 * (kx - LO), H + 2 * (ky - LO) + return [(a, b) for b in (b0, b0 + 1) for a in (a0, a0 + 1)] # (x, y) + + +def feq_point(rho, ux, uy): + out = [] + for q in range(9): + cu = CX[q] * ux + CY[q] * uy + out.append(WT[q] * rho * (1 + 3 * cu + 4.5 * cu * cu - 1.5 * (ux * ux + uy * uy))) + return out + + +def srt_point(f, tau): + rho = sum(f) + ux = sum(CX[q] * f[q] for q in range(9)) / rho + uy = sum(CY[q] * f[q] for q in range(9)) / rho + e = feq_point(rho, ux, uy) + return [f[q] - (f[q] - e[q]) / tau for q in range(9)] + + +def slot_kind(q, kx, ky): + """Coalescence slot of non-covered coarse K, direction q, from link geometry only.""" + if q == 0 or covered(kx, ky): + return None + sx, sy = (kx - CX[q]) % N, (ky - CY[q]) % N + if covered(sx, sy): + return "face_or_diag_from_patch" + if CX[q] != 0 and CY[q] != 0: + cxp, cyp = kx + 0.5 - CX[q] / 2, ky + 0.5 - CY[q] / 2 # shared corner point + if cxp in (LO, HI) and cyp in (LO, HI): + return "corner_pass" + return None + + +def independent_step(fc, ff, srt): + """Scalar-loop implementation of one synchronised coarse step (contract + S-R Alg. 1).""" + col = (lambda v, t: srt_point(v, t)) if srt else (lambda v, t: list(v)) + gc = np.full_like(fc, np.nan) + for y in range(N): + for x in range(N): + if not covered(x, y): + gc[:, y, x] = col(fc[:, y, x], TAU_C) + gf = np.full_like(ff, np.nan) + for b in range(NF): + for a in range(NF): + if f_active(a, b): + gf[:, b, a] = col(ff[:, b, a], TAU_F) + else: # explosion into every ghost, once + gf[:, b, a] = gc[:, parent(b), parent(a)] + + def hop(g): + out = np.full_like(g, np.nan) + for b in range(NF): + for a in range(NF): + if f_outer(a, b): + continue + for q in range(9): + sa, sb = a - CX[q], b - CY[q] + if 0 <= sa < NF and 0 <= sb < NF: + out[q, b, a] = g[q, sb, sa] + return out + + f1 = hop(gf) + g1 = np.full_like(f1, np.nan) + for b in range(NF): + for a in range(NF): + if f_active(a, b): + g1[:, b, a] = col(f1[:, b, a], TAU_F) + elif f_inner(a, b): + g1[:, b, a] = f1[:, b, a] + f2 = hop(g1) + fc_new = np.full_like(fc, np.nan) + for y in range(N): + for x in range(N): + if covered(x, y): + continue + for q in range(9): + if slot_kind(q, x, y): + vals = [f2[q, b, a] for a, b in children(x, y)] + fc_new[q, y, x] = sum(vals) / 4.0 + else: + fc_new[q, y, x] = gc[q, (y - CY[q]) % N, (x - CX[q]) % N] + ff_new = np.full_like(ff, np.nan) + for b in range(NF): + for a in range(NF): + if f_active(a, b): + ff_new[:, b, a] = f2[:, b, a] + return fc_new, ff_new + + +def leaf(fc, ff): + tot = np.zeros(3) + for y in range(N): + for x in range(N): + if not covered(x, y): + for q in range(9): + tot += fc[q, y, x] * np.array([1.0, CX[q], CY[q]]) + for b in range(NF): + for a in range(NF): + if f_active(a, b): + for q in range(9): + tot += 0.25 * ff[q, b, a] * np.array([1.0, CX[q], CY[q]]) + return tot + + +def perturbed_state(spec, seed, amp=1e-3, u=(0.03, -0.02)): + st = ref.make_state(spec, rho=1.0, velocity=u) + rng = np.random.default_rng(seed) + fc, ff = st.coarse_f.copy(), st.fine_f.copy() + fc = np.where(np.isfinite(fc), fc * (1 + amp * rng.standard_normal(fc.shape)), np.nan) + ff = np.where(np.isfinite(ff), ff * (1 + amp * rng.standard_normal(ff.shape)), np.nan) + return ref.State(coarse_f=fc, fine_f=ff, phase="arrival_f", tick=st.tick) + + +def run(spec, st, steps): + maps = ref.build_maps(spec) + diags = [] + for _ in range(steps): + st, d = ref.advance_coarse_once(spec, maps, st) + diags.append(d) + return st, diags + + +class TestIdentityTransportConservation(unittest.TestCase): + def test_leaf_mass_momentum_8_steps(self): + spec = ref.make_spec(collision="identity") + st = perturbed_state(spec, 1) + m0 = leaf(st.coarse_f, st.fine_f) + st8, diags = run(spec, st, 8) + m8 = leaf(st8.coarse_f, st8.fine_f) + print(f"\n[identity] leaf0={m0.tolist()} leaf8={m8.tolist()} drift={(m8 - m0).tolist()}") + np.testing.assert_allclose(m8, m0, rtol=0, atol=CONS_TOL * np.abs(m0).max()) + for d in diags: + np.testing.assert_allclose(d["budget"]["interface_residual"], 0, atol=1e-12) + np.testing.assert_allclose(d["leaf_after"] - d["leaf_before"], 0, atol=1e-12) + self.assertEqual(st8.tick, 16) + + def test_matches_independent_implementation(self): + spec = ref.make_spec(collision="identity") + st = perturbed_state(spec, 2) + fc, ff = st.coarse_f.copy(), st.fine_f.copy() + maps = ref.build_maps(spec) + worst = 0.0 + for _ in range(3): + st, _ = ref.advance_coarse_once(spec, maps, st) + fc, ff = independent_step(fc, ff, srt=False) + np.testing.assert_array_equal(np.isnan(st.coarse_f), np.isnan(fc)) + np.testing.assert_array_equal(np.isnan(st.fine_f), np.isnan(ff)) + worst = max(worst, np.nanmax(np.abs(st.coarse_f - fc)), np.nanmax(np.abs(st.fine_f - ff))) + print(f"\n[identity] max |ref - independent| over 3 steps = {worst:.3e}") + self.assertLessEqual(worst, 1e-15) + + +class TestSRT(unittest.TestCase): + def test_matches_independent_implementation(self): + spec = ref.make_spec(collision="srt") + st = perturbed_state(spec, 3) + fc, ff = st.coarse_f.copy(), st.fine_f.copy() + maps = ref.build_maps(spec) + worst = 0.0 + for _ in range(3): + st, _ = ref.advance_coarse_once(spec, maps, st) + fc, ff = independent_step(fc, ff, srt=True) + np.testing.assert_array_equal(np.isnan(st.coarse_f), np.isnan(fc)) + worst = max(worst, np.nanmax(np.abs(st.coarse_f - fc)), np.nanmax(np.abs(st.fine_f - ff))) + print(f"\n[srt] max |ref - independent| over 3 steps = {worst:.3e}") + self.assertLessEqual(worst, 1e-14) + + def test_leaf_conservation_and_collision_moments_8_steps(self): + spec = ref.make_spec(collision="srt") + st = perturbed_state(spec, 4) + m0 = leaf(st.coarse_f, st.fine_f) + st8, diags = run(spec, st, 8) + m8 = leaf(st8.coarse_f, st8.fine_f) + print(f"\n[srt] leaf0={m0.tolist()} leaf8={m8.tolist()} drift={(m8 - m0).tolist()}") + np.testing.assert_allclose(m8, m0, rtol=0, atol=CONS_TOL * np.abs(m0).max()) + for d in diags: + np.testing.assert_allclose(d["budget"]["coarse_collision"], 0, atol=1e-12) + np.testing.assert_allclose(d["budget"]["fine_collision"], 0, atol=1e-12) + np.testing.assert_allclose(d["budget"]["interface_residual"], 0, atol=1e-12) + # independent per-cell check of SRT moment conservation + rng = np.random.default_rng(9) + f = np.array(feq_point(1.0, 0.04, -0.01)) * (1 + 1e-2 * rng.standard_normal(9)) + g = srt_point(list(f), TAU_F) + for w in ([1.0] * 9, CX, CY): + self.assertAlmostEqual(float(np.dot(w, g)), float(np.dot(w, f)), places=14) + + +class TestOwnershipPoison(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.spec = ref.make_spec(collision="srt") + cls.maps = ref.build_maps(cls.spec) + cls.st = perturbed_state(cls.spec, 5) + cls.out, cls.d = ref.advance_coarse_once(cls.spec, cls.maps, cls.st) + + def test_collision_and_explosion_counts(self): + d = self.d + exp_c = np.array([[0 if covered(x, y) else 1 for x in range(N)] for y in range(N)]) + exp_f = np.array([[2 if f_active(a, b) else 0 for a in range(NF)] for b in range(NF)]) + exp_e = np.array([[0 if f_active(a, b) else 1 for a in range(NF)] for b in range(NF)]) + np.testing.assert_array_equal(d["collision_count_coarse"], exp_c) + np.testing.assert_array_equal(d["collision_count_fine"], exp_f) + np.testing.assert_array_equal(d["explosion_writes"], exp_e) + self.assertNotIn("explosion_count", d) + self.assertNotIn("fine_valid_after", d) + self.assertIn("hop2_valid", d) + self.assertNotIn("closure_error", d["budget"]) + self.assertIn("bookkeeping_check", d["budget"]) + # invalid tick rejection (v0.3) + for bad in (1, -2, 1.0, True, 2.5, "0"): + with self.assertRaises(ValueError): + ref.advance_coarse_once(self.spec, self.maps, + ref.State(coarse_f=self.st.coarse_f, fine_f=self.st.fine_f, phase="arrival_f", tick=bad)) + out4, d4 = ref.advance_coarse_once(self.spec, self.maps, + ref.State(coarse_f=self.st.coarse_f, fine_f=self.st.fine_f, phase="arrival_f", tick=4)) + self.assertEqual((d4["tick_before"], d4["tick_after"], out4.tick), (4, 6, 6)) + print(f"\ncounts: coarse cells collided once={int(exp_c.sum())}, fine twice={int((exp_f == 2).sum())}, " + f"ghost explosion writes={int(exp_e.sum())}; tick validation OK") + + def test_coalescence_slots_reads_owner(self): + d = self.d + slots = {(q, y, x): slot_kind(q, x, y) for y in range(N) for x in range(N) for q in range(9) + if slot_kind(q, x, y)} + n_pass = sum(1 for v in slots.values() if v == "corner_pass") + got = {tuple(int(v) for v in r) for r in np.argwhere(d["coalesce_writes"] > 0)} + self.assertEqual(got, set(slots)) + self.assertTrue((d["coalesce_writes"][d["coalesce_writes"] > 0] == 1).all()) + exp_reads = np.zeros((9, NF, NF), int) + for (q, y, x) in slots: + for a, b in children(x, y): + self.assertTrue(f_inner(a, b)) + exp_reads[q, b, a] += 1 + np.testing.assert_array_equal(d["coalesce_reads"], exp_reads) + own = d["coarse_incoming_owner"] + n_cov = sum(covered(x, y) for x in range(N) for y in range(N)) + self.assertEqual(int((own == 2).sum()), len(slots)) + self.assertEqual(int((own == 0).sum()), 9 * n_cov) + self.assertEqual(int((own == 1).sum()), 9 * (N * N - n_cov) - len(slots)) + print(f"\nslots={len(slots)} (corner-pass {n_pass}), unique reads={int((exp_reads == 1).sum())}, " + f"max read multiplicity={int(exp_reads.max())}") + + def test_hop_sources_never_outer(self): + for h in ("hop1", "hop2"): + s = self.d["fine_hop_sources"][h] + self.assertEqual(s["outer_ghost"], 0) + self.assertEqual(s["outside"], 0) + self.assertEqual(sum(s.values()), 9 * 256) + + def test_invalid_input_entries_ignored_and_poisoned(self): + fc, ff = self.st.coarse_f.copy(), self.st.fine_f.copy() + fc[:, LO:HI, LO:HI] = 1e300 + for b in range(NF): + for a in range(NF): + if not f_active(a, b): + ff[:, b, a] = -7.0 + st2 = ref.State(coarse_f=fc, fine_f=ff, phase="arrival_f", tick=0) + out2, _ = ref.advance_coarse_once(self.spec, self.maps, st2) + np.testing.assert_array_equal(out2.coarse_f, self.out.coarse_f) + np.testing.assert_array_equal(out2.fine_f, self.out.fine_f) + self.assertTrue(np.isnan(self.out.coarse_f[:, LO:HI, LO:HI]).all()) + ghost = np.array([[not f_active(a, b) for a in range(NF)] for b in range(NF)]) + self.assertTrue(np.isnan(self.out.fine_f[:, ghost]).all()) + self.assertTrue(np.isfinite(self.out.fine_f[:, ~ghost]).all()) + + def test_inputs_not_mutated_and_maps_readonly(self): + spec = self.spec + maps = ref.build_maps(spec) + st = perturbed_state(spec, 6) + c0, f0 = st.coarse_f.copy(), st.fine_f.copy() + ref.advance_coarse_once(spec, maps, st) + np.testing.assert_array_equal(st.coarse_f, c0) + np.testing.assert_array_equal(st.fine_f, f0) + for name in ("coarse_active", "fine_active", "fine_inner_ghost", "coalesce"): + with self.assertRaises(ValueError): + getattr(maps, name)[...] = 0 + + def test_nonfinite_active_input_rejected(self): + fc = self.st.coarse_f.copy() + fc[3, 0, 0] = np.nan + with self.assertRaises(ValueError): + ref.advance_coarse_once(self.spec, self.maps, + ref.State(coarse_f=fc, fine_f=self.st.fine_f, phase="arrival_f", tick=0)) + + def test_make_spec_api(self): + self.assertEqual(ref.make_spec().collision, "identity") + self.assertEqual(ref.make_spec(collision="srt").collision, "srt") + with self.assertRaises(ValueError): + ref.make_spec(collision="mrt") + + +# corner-pass links: (name, q, source (x,y), target K (x,y)); 8 total, two per corner +CORNER_PASS = [ + ("LL_8", 8, (4, 3), (3, 4)), ("LL_7", 7, (3, 4), (4, 3)), + ("LR_5", 5, (11, 3), (12, 4)), ("LR_6", 6, (12, 4), (11, 3)), + ("UL_6", 6, (4, 12), (3, 11)), ("UL_5", 5, (3, 11), (4, 12)), + ("UR_7", 7, (11, 12), (12, 11)), ("UR_8", 8, (12, 11), (11, 12)), +] + + +class TestFourCorners(unittest.TestCase): + def test_corner_pass_links_identity_marker(self): + """Identity: a marker in the source's post-collision slot q must arrive exactly at K.""" + spec = ref.make_spec(collision="identity") + maps = ref.build_maps(spec) + for name, q, (sx, sy), (kx, ky) in CORNER_PASS: + self.assertEqual(slot_kind(q, kx, ky), "corner_pass", name) + st = ref.make_state(spec) + fc = st.coarse_f.copy() + fc[q, sy, sx] += 0.01 + out, d = ref.advance_coarse_once(spec, maps, ref.State(fc, st.fine_f, "arrival_f", 0)) + self.assertEqual(int(d["coarse_incoming_owner"][q, ky, kx]), 2, name) + self.assertAlmostEqual(out.coarse_f[q, ky, kx], fc[q, sy, sx], places=15, msg=name) + listed = {(link.key, link.i, link.source_xy, link.target_xy) for link in maps.corner_links} + self.assertEqual(len(listed), 8) + self.assertEqual(len({link.key for link in maps.corner_links}), 8) + print(f"\nreference corner_links lists {len(listed)} uniquely keyed corner-pass links") + + def test_four_corner_symmetry_srt(self): + """Mirror the lower-left SRT corner case to the other three corners; values must match.""" + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + eps = 1e-3 + flips = {"LL": (False, False), "LR": (True, False), "UL": (False, True), "UR": (True, True)} + mirror_q = {(True, False): (0, 2, 1, 3, 4, 8, 7, 6, 5), (False, True): (0, 1, 2, 4, 3, 7, 8, 5, 6)} + + def mq(q, fx, fy): + if fx: + q = mirror_q[(True, False)][q] + if fy: + q = mirror_q[(False, True)][q] + return q + + vals = {} + for tag, (fx, fy) in flips.items(): + st = ref.make_state(spec) + fc = st.coarse_f.copy() + sx, sy = (N - 1 - 4 if fx else 4), (N - 1 - 3 if fy else 3) + for q, s in ((5, 1), (6, 1), (7, -1), (8, -1)): + fc[mq(q, fx, fy), sy, sx] += s * eps + out, _ = ref.advance_coarse_once(spec, maps, ref.State(fc, st.fine_f, "arrival_f", 0)) + kx, ky = (N - 1 - 3 if fx else 3), (N - 1 - 4 if fy else 4) + vals[tag] = out.coarse_f[mq(8, fx, fy), ky, kx] + print(f"\ncorner SRT symmetry values: {vals}") + for tag in vals: + self.assertAlmostEqual(vals[tag], vals["LL"], places=15, msg=tag) + + +class TestSRTCornerOracle(unittest.TestCase): + """Contract oracle: LL corner, coarse source (4.5,3.5) -> target (3.5,4.5), q=8, + source perturbed by eps*(e5+e6-e7-e8). Expected value computed by hand, no reference maps.""" + + def _oracle(self, eps, srt): + base = feq_point(1.0, 0.0, 0.0) + src = list(base) + for q, s in ((5, 1), (6, 1), (7, -1), (8, -1)): + src[q] += s * eps + g_src = srt_point(src, TAU_C) if srt else src # coarse collision at (4,3) + # Children of coarse (4,3): fine (4,2),(5,2),(4,3),(5,3). Only (5,3)->(4,4) hits an + # active intermediate; the other three pass inner ghosts (3,3),(4,3),(3,4) uncollided. + # Arrival at active (4,4) after hop 1: q=8 from (5,3) [parent (4,3)] carries g_src[8]; + # q=3 from (4,3) [parent (4,3)] carries g_src[3] (unperturbed); every other direction + # comes from unperturbed equilibrium cells (active or ghost of unperturbed parents). + arr = list(base) + arr[8] = g_src[8] + arr[3] = g_src[3] + mid = srt_point(arr, TAU_F) if srt else arr + return (3 * g_src[8] + mid[8]) / 4.0, g_src[8] + + def _run(self, collision, eps): + spec = ref.make_spec(collision=collision) + st = ref.make_state(spec) + fc = st.coarse_f.copy() + for q, s in ((5, 1), (6, 1), (7, -1), (8, -1)): + fc[q, 3, 4] += s * eps + out, d = ref.advance_coarse_once(spec, ref.build_maps(spec), ref.State(fc, st.fine_f, "arrival_f", 0)) + return out.coarse_f[8, 4, 3], d + + def test_srt_oracle(self): + eps = 1e-3 + exp, g8 = self._oracle(eps, True) + got, d = self._run("srt", eps) + print(f"\n[srt corner] expected={exp!r} got={got!r} diff={got - exp:.3e} (uncoalesced g8={g8!r})") + self.assertAlmostEqual(got, exp, places=15) + self.assertNotAlmostEqual(exp, g8, places=9) # mid-step collision really matters + self.assertAlmostEqual(d["corner_packets"]["lower_left_q8"]["coalesced"], exp, places=15) + + def test_identity_changes_expected_packet(self): + eps = 1e-3 + exp_srt, _ = self._oracle(eps, True) + exp_id, _ = self._oracle(eps, False) + got_id, _ = self._run("identity", eps) + print(f"\n[identity corner] expected={exp_id!r} got={got_id!r}; srt-expected differs by {exp_srt - exp_id:.3e}") + self.assertAlmostEqual(got_id, exp_id, places=15) + self.assertAlmostEqual(exp_id, 1 / 36 - eps, places=15) + self.assertGreater(abs(exp_srt - exp_id), 1e-6) + + +if __name__ == "__main__": + import platform + import resource + import sys + import hashlib + print("reference:", REF_PATH, "API", ref.API_VERSION) + for p in (REF_PATH, os.path.abspath(__file__)): + print("sha256", hashlib.sha256(open(p, "rb").read()).hexdigest(), os.path.basename(p)) + print("python", sys.version.split()[0], "numpy", np.__version__, platform.platform()) + prog = unittest.main(exit=False, verbosity=2) + print("peak_rss_MiB", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024) + sys.exit(0 if prog.result.wasSuccessful() else 1) diff --git a/tests/validation/multigrid/test_fine_surface_gpu.py b/tests/validation/multigrid/test_fine_surface_gpu.py new file mode 100644 index 0000000..a8fca1b --- /dev/null +++ b/tests/validation/multigrid/test_fine_surface_gpu.py @@ -0,0 +1,330 @@ +"""Two coarse/four fine steps: isolated fine circle, no flux-accuracy claim. + +Run only through the bounded GPU runner after a fresh resource check. The test +hooks read actual kernel inputs/outputs; they never change the stepping order. +""" +import importlib.util +from contextlib import nullcontext +from pathlib import Path +from threading import RLock +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import numpy as np +import pycuda.driver as cuda +import pytest + +import CelerisLab +from CelerisLab.body.geometry.circle import CircleGeometry +from CelerisLab.body.objects import SimObject +from CelerisLab.config import LBMConfig +from CelerisLab.cuda import compiler_v2 as compiler +from CelerisLab.cuda.context import CudaContext +from CelerisLab.lbm import refinement_stepper as implementation +from CelerisLab.lbm.descriptors import FLUID, SOLID + + +ROOT = Path(__file__).resolve().parents[3] +CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1]) +CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1]) +OPP = np.array([0, 2, 1, 4, 3, 6, 5, 8, 7]) +W = np.array([4/9]+[1/9]*4+[1/36]*4) +MOMENTS = np.stack((np.ones(9), CX, CY)) +GEOMETRY = ("fluid_idx", "dir", "q", "body_id", "rx", "ry", "rz", "fallback_class") +BOUNDS = (4, 4, 20, 20) + + +def read(pointer, shape, dtype=np.float32): + values = np.empty(shape, dtype) + cuda.memcpy_dtoh(values, pointer) + return values + + +def rest(shape): + return np.broadcast_to(W[:, None, None], (9, *shape)).astype(np.float32).copy() + + +def surface(speed): + return None if speed is None else dict(angle=.7, half_width=.85, normal_velocity=speed) + + +def object_spec(speed): + return SimObject(0, CircleGeometry(12.25, 12.25, 2.15), center=(12.25, 12.25), + radius=2.15, surface_actuation=surface(speed)) + + +def audit_compiler(original): + def compile_private(*args, **kwargs): + entry = Path(kwargs["build_dir"])/"fine_surface_audit.cu" + entry.write_text('#define CELERIS_REFINEMENT_AUDIT\n#include "refinement.cu"\n') + kwargs["source"] = entry.name + return original(*args, **kwargs) + return compile_private + + +def reflection_oracle(before, cl, speed, *, verify_projection=True): + """FP64 BFL from frozen spatial populations, independent arc projection.""" + flat = before.astype(float).reshape(9, -1) + radius = np.hypot(cl.rx.astype(float), cl.ry.astype(float)) + delta = (np.arctan2(cl.ry.astype(float), cl.rx.astype(float))-.7+np.pi) % (2*np.pi)-np.pi + amplitude = 0 if speed is None else speed + strength = amplitude*np.maximum(0, 1-(delta/.85)**2)**2 + velocity = strength[:, None]*np.column_stack((cl.rx, cl.ry))/radius[:, None] + if verify_projection and cl.surface_velocity is not None: + np.testing.assert_allclose(cl.surface_velocity, velocity, atol=3e-9, rtol=0) + incoming = [] + for i, (k, d, q, fallback) in enumerate(zip(cl.fluid_idx, cl.dir, cl.q, cl.fallback_class)): + k, d, q = int(k), int(d), float(q) + rho = flat[:, k].sum() + dot = CX[d]*velocity[i, 0]+CY[d]*velocity[i, 1] + if fallback: + value = flat[d, k]-6*W[d]*rho*dot + elif q < .5: + value = 2*q*flat[d, k]+(1-2*q)*flat[d, k-CX[d]-40*CY[d]]-6*W[d]*rho*dot + else: + value = flat[d, k]/(2*q)+(1-1/(2*q))*flat[OPP[d], k]-3*W[d]*rho*dot/q + incoming.append(value) + return np.array(incoming) + + +def fine_flux(before, staged, active, solid): + """Population ledger across the fine leaf boundary: solid versus halo. + + Internal fluid-to-fluid streaming cancels pairwise; solid and halo packets + are counted in their actual direction, before any collision. + """ + boundary, transfer = np.zeros(9), np.zeros(9) + magnitude = 0.0 + for d, (cx, cy) in enumerate(zip(CX, CY)): + incoming = active & ~np.roll(active, (cy, cx), (0, 1)) + outgoing = active & ~np.roll(active, (-cy, -cx), (0, 1)) + from_solid = np.roll(solid, (cy, cx), (0, 1)) + into_solid = np.roll(solid, (-cy, -cx), (0, 1)) + received = np.roll(staged[d], (cy, cx), (0, 1)).astype(float) + sent = before[d].astype(float) + for vector, inc, out in ((boundary, incoming & from_solid, outgoing & into_solid), + (transfer, incoming & ~from_solid, outgoing & ~into_solid)): + assert np.isfinite(received[inc]).all() and np.isfinite(sent[out]).all() + vector[d] = received[inc].sum()-sent[out].sum() + magnitude += np.abs(received[inc]).sum()+np.abs(sent[out]).sum() + return boundary, transfer, magnitude + + +def coarse_flux(before, fine, active): + """Derive every replacement from physical midpoints, not production tables.""" + x0, y0, x1, y1 = BOUNDS + incoming = np.stack([np.roll(before[d], (CY[d], CX[d]), (0, 1)) for d in range(9)]).astype(float) + magnitude = 0.0 + for y, x in np.argwhere(active): + for d in range(1, 9): + if x0 <= x+.5-CX[d]/2 <= x1 and y0 <= y+.5-CY[d]/2 <= y1: + a, b = 4+2*(x-x0), 4+2*(y-y0) + children = fine[d, b:b+2, a:a+2].astype(float) + assert children.shape == (2, 2) and np.isfinite(children).all() + incoming[d, y, x] = children.sum()/4 + magnitude += np.abs(children).sum()/4 + assert np.isfinite(incoming[:, active]).all() + # This retains the eight grazing-corner replacements of uncovered donors. + flux = (incoming[:, active]-before[:, active].astype(float)).sum(axis=1) + magnitude += np.abs(incoming[:, active]).sum()+np.abs(before[:, active]).sum(dtype=float) + return flux, magnitude + + +def run_patch(speed, *, audit=True): + cfg = LBMConfig(nx=24, ny=24, viscosity=.05, velocity=0, rho=1) + coarse_active = np.ones((24, 24), bool) + coarse_active[4:20, 4:20] = False + source = object_spec(speed) + state = vars(source.state).copy() + with CudaContext(0) as context: + build = (patch.object(compiler, "compile_kernel", audit_compiler(compiler.compile_kernel)) + if audit else nullcontext()) + with build: + backend = implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source]) + with backend, context.activate(): + c, f = backend._levels + cl = backend._fine_curved + geometry = {name: getattr(cl, name).copy() for name in GEOMETRY} + flags = backend.fine_boundary.flags.reshape(40, 40) + solid = (flags & SOLID) != 0 + active = np.zeros((40, 40), bool) + active[4:-4, 4:-4] = True + active &= (flags & FLUID) != 0 + active &= ~solid + local = backend.fine_boundary.objects[0] + assert local.center == (20., 20.) and local.radius == 4.3 + assert source.center == (12.25, 12.25) and source.radius == 2.15 + yy, xx = np.mgrid[:40, :40] + roi = active & ((xx-20)**2+(yy-20)**2 <= 9**2) + backend.initialize(rest((24, 24)), rest((40, 40))) + if audit: + for level in backend._levels: + for name in ("collision_counts", "explosion_counts", "incoming_counts"): + pointer, size = level.module.get_global(name) + cuda.memset_d8(pointer, 0, size) + initial = backend.download() + initial_pop = initial[0][:, coarse_active].sum(axis=1, dtype=float) + initial_pop += .25*initial[1][:, active].sum(axis=1, dtype=float) + boundary, fine_transfer, coarse_transfer = np.zeros(9), np.zeros(9), np.zeros(9) + budget_sum, reflection_error, drive_mass = 0., 0., 0. + traces = [] + original_curved, original_fine, original_coarse = backend._curved, f.kernel, c.kernel + + def curved(*args, **kwargs): + nonlocal boundary, fine_transfer, budget_sum, reflection_error, drive_mass + backend._stream.synchronize() + before = read(args[0], f.shape) + expected = reflection_oracle(before, cl, speed) + original_curved(*args, **kwargs) + backend._stream.synchronize() + staged = read(args[0], f.shape) + d, k = cl.dir.astype(int), cl.fluid_idx.astype(int) + values = staged.reshape(9, -1)[OPP[d], k+CX[d]+40*CY[d]] + passive = reflection_oracle(before, cl, None, verify_projection=False) + drive_mass += .25*float(np.sum(values-passive)) + error = float(np.max(np.abs(values-expected))) + reflection_error = max(reflection_error, error) + assert error <= 1e-7 + np.testing.assert_array_equal(staged[:, active], before[:, active]) + b, transfer, magnitude = fine_flux(before, staged, active, solid) + boundary += .25*b + fine_transfer += .25*transfer + budget_sum += .25*magnitude + + def fine(*args, **kwargs): + nonlocal budget_sum + backend._stream.synchronize() + staged = read(args[0], f.shape) + original_fine(*args, **kwargs) + backend._stream.synchronize() + advanced = read(args[1], f.shape) + assert np.isfinite(advanced[:, active]).all() + assert np.isnan(advanced[:, solid]).all() + # Include actual collision operands in the FP32 rounding budget. + budget_sum += .25*np.abs(staged[:, active]).sum(dtype=float) + traces.append(advanced[:, roi].copy()) + + def coarse(*args, **kwargs): + nonlocal coarse_transfer, budget_sum + backend._stream.synchronize() + before, fine_values = read(args[0], c.shape), read(args[2], f.shape) + transfer, magnitude = coarse_flux(before, fine_values, coarse_active) + coarse_transfer += transfer + budget_sum += magnitude + return original_coarse(*args, **kwargs) + + with patch.object(backend, "_curved", curved), patch.object(f, "kernel", fine), \ + patch.object(c, "kernel", coarse): + backend.step(2) + actual = backend.download() + population = actual[0][:, coarse_active].sum(axis=1, dtype=float) + population += .25*actual[1][:, active].sum(axis=1, dtype=float) + ledger = boundary+fine_transfer+coarse_transfer + error = MOMENTS@(population-initial_pop-ledger) + budget = 8*np.finfo(np.float32).eps*budget_sum + assert np.isfinite(budget_sum) and budget_sum > 0 + assert np.max(np.abs(error)) <= budget + if speed: + assert speed*drive_mass > 0 + assert len(traces) == 4 and backend.step_count == 2 + if audit: + for level, mask, count in ((c, coarse_active, 2), (f, active, 4)): + pointer, size = level.module.get_global("collision_counts") + counts = read(pointer, mask.shape, np.uint32) + assert size == counts.nbytes + np.testing.assert_array_equal(counts, count*mask) + for name, values in geometry.items(): + np.testing.assert_array_equal(getattr(cl, name), values) + assert vars(source.state) == state and source.geometry.center == source.center + print(f"speed={speed} audit={audit} BFL={reflection_error:.9g} ledger={error.tolist()} " + f"budget={budget:.9g} boundary={list(MOMENTS@boundary)} " + f"transfer={list(MOMENTS@(fine_transfer+coarse_transfer))} " + f"drive_mass={drive_mass:+.9g}; counts={'PASS' if audit else 'normal build'}", flush=True) + return traces, roi, actual + + +def uniform_reference(speed, roi): + # Reuse the existing explicit config helper without importing unrelated tests. + spec = importlib.util.spec_from_file_location("fine_surface_cases", ROOT/"tests/integration/_cases.py") + cases = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cases) + with cases.make_sim(nx=48, ny=48, viscosity=.1, velocity=0, rho=1) as sim: + sim.add_body("circle", center=(24, 24), radius=4.3, surface_actuation=surface(speed)) + sim.initialize() + # Uniform 48^2 has the same dx=dt=.5 representation. Its distant channel + # boundaries cannot influence the circle ROI within these four hops. + traces = [] + for _ in range(4): + sim.run(1) + traces.append(sim.get_ddf().reshape(9, 48, 48)[:, 4:44, 4:44][:, roi].copy()) + return traces + + +@pytest.mark.parametrize("speed", [0., .002, -.002]) +def test_fine_surface_four_hops(speed): + assert Path(CelerisLab.__file__).resolve() == ROOT/"src/CelerisLab/__init__.py" + assert Path(compiler._KERNEL_DIR).resolve() == ROOT/"src/CelerisLab/lbm/kernels" + assert Path(implementation.__file__).resolve() == ROOT/"src/CelerisLab/lbm/refinement_stepper.py" + actual, roi, final = run_patch(speed) + reference = uniform_reference(speed, roi) + for got, wanted in zip(actual, reference): + np.testing.assert_allclose(got, wanted, atol=3e-6, rtol=0) + np.testing.assert_allclose(MOMENTS@got.astype(float), MOMENTS@wanted.astype(float), + atol=2e-5, rtol=0) + if speed == 0: + passive, passive_roi, passive_final = run_patch(None, audit=False) + np.testing.assert_array_equal(passive_roi, roi) + for got, wanted in zip(actual+list(final), passive+list(passive_final)): + np.testing.assert_array_equal(got, wanted) + + +@pytest.mark.parametrize("missing", ["surface_velocity_gpu", "link_exchange_gpu"]) +def test_missing_surface_device_buffer_rejects_before_launch(missing): + backend = implementation.FixedPatchStepper.__new__(implementation.FixedPatchStepper) + backend.fine_boundary = SimpleNamespace(objects=[object_spec(.002)]) + backend._fine_curved = SimpleNamespace(fluid_idx_gpu=object(), + surface_velocity_gpu=object(), link_exchange_gpu=object()) + setattr(backend._fine_curved, missing, None) + backend._curved = Mock() + with pytest.raises(RuntimeError, match="Surface boundary device buffers are missing"): + backend._apply_fine_boundary(object()) + backend._curved.assert_not_called() + + +def test_close_frees_fine_boundary_after_stream_failure_once(): + backend = implementation.FixedPatchStepper.__new__(implementation.FixedPatchStepper) + backend.ctx = SimpleNamespace(activate=nullcontext) + backend._lock = RLock() + backend._status = "ready" + stream, soa, first, second = Mock(), Mock(), Mock(), Mock() + stream.synchronize.side_effect = RuntimeError("stream synchronization failed") + backend._stream = stream + backend._fine_curved = soa + backend._fine_flags = first + backend._curved = Mock() + backend._levels = [object(), object()] + backend._allocations = [first, second] + with pytest.raises(RuntimeError, match="stream synchronization failed"): + backend.close() + assert backend._status == "closed" + assert backend._levels == [] and backend._allocations == [] + assert backend._fine_curved is None and backend._fine_flags is None + assert backend._curved is None and backend._stream is None + backend.close() + stream.synchronize.assert_called_once_with() + soa.free.assert_called_once_with() + first.free.assert_called_once_with() + second.free.assert_called_once_with() + + +def test_cross_interface_donor_rejects_before_cuda(): + cfg = LBMConfig(nx=24, ny=24, viscosity=.05) + source = SimObject(0, CircleGeometry(5.05, 12.25, .7), + center=(5.05, 12.25), radius=.7, surface_actuation=surface(.002)) + context = Mock() + context.activate.side_effect = AssertionError("must reject before CUDA activation") + with patch.object(cuda, "mem_alloc") as allocate: + with pytest.raises(ValueError, match="donor crosses"): + implementation.FixedPatchStepper(cfg, BOUNDS, context=context, fine_objects=[source]) + context.activate.assert_not_called() + allocate.assert_not_called() diff --git a/tests/validation/multigrid/test_open_corner_negative_control.py b/tests/validation/multigrid/test_open_corner_negative_control.py new file mode 100644 index 0000000..e7e0c32 --- /dev/null +++ b/tests/validation/multigrid/test_open_corner_negative_control.py @@ -0,0 +1,68 @@ +"""Grok test-only negative control for CPUREF-0.3: open-corner variant. +Open rule = drop coalescence rows whose coarse source cell is uncovered (the 8 +corner-pass diagonal links), so those links stay purely coarse-streamed. +The reference file is NOT modified; the variant only swaps maps.coalesce. +Conservation is judged independently: leaf totals summed here (not via the +reference's helpers) minus collision budgets. Expected: closed ~0 always. +Open: identity stays conservative (the active packet m' leaving through the +corner equals the exploded ghost packet m it received, so (m-m')/4 = 0); +SRT breaks conservation (mid-step fine collision makes m' != m). +Run 1 asserted open != 0 for identity too; that assumption was mine and wrong.""" +import dataclasses, os, sys, unittest +import numpy as np +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import cpu_reference as R + +def leaf(state, maps): + c = state.coarse_f[:, maps.coarse_active]; f = state.fine_f[:, maps.fine_active] + cx = R.CX.astype(float); cy = R.CY.astype(float) + tot = lambda a, w: np.array([a.sum(), (cx[:, None]*a).sum(), (cy[:, None]*a).sum()]) * w + return tot(c, 1.0) + tot(f, 0.25) + +def random_state(spec, maps, seed): + rng = np.random.default_rng(seed) + s = R.make_state(spec) + c = s.coarse_f.copy(); f = s.fine_f.copy() + c[:, maps.coarse_active] *= 1 + 0.2*rng.uniform(-1, 1, c[:, maps.coarse_active].shape) + f[:, maps.fine_active] *= 1 + 0.2*rng.uniform(-1, 1, f[:, maps.fine_active].shape) + return dataclasses.replace(s, coarse_f=c, fine_f=f) + +def open_maps(spec, maps): + n = spec.n_coarse + keep = [r for r in maps.coalesce.tolist() + if maps.coarse_covered[(r[1]-R.CY[r[0]]) % n, (r[2]-R.CX[r[0]]) % n]] + return dataclasses.replace(maps, coalesce=np.array(keep, dtype=np.int64).reshape(-1, 3), + corner_links=()) + +def run(spec, maps, state, steps): + worst = np.zeros(3); res = np.zeros(3) + for _ in range(steps): + before = leaf(state, maps) + state, d = R.advance_coarse_once(spec, maps, state) + b = d["budget"] + drift = leaf(state, maps) - before - (b["coarse_collision"] + b["fine_collision"]) + worst = np.maximum(worst, np.abs(drift)); res = np.maximum(res, np.abs(b["interface_residual"])) + return worst, res + +class OpenCornerNegativeControl(unittest.TestCase): + def test_open_vs_closed(self): + for coll in ("identity", "srt"): + spec = R.make_spec(collision=coll); closed = R.build_maps(spec); opn = open_maps(spec, closed) + print(f"[{coll}] coalesce rows closed={len(closed.coalesce)} open={len(opn.coalesce)} " + f"dropped={len(closed.coalesce)-len(opn.coalesce)}") + for seed in (1, 2): + for steps in (1, 8): + s0 = random_state(spec, closed, seed) + wc, rc = run(spec, closed, s0, steps); wo, ro = run(spec, opn, s0, steps) + print(f"[{coll}] seed={seed} steps={steps} closed: max|leaf drift|={wc.max():.3e} " + f"max|interface_residual|={rc.max():.3e} | OPEN: max|leaf drift| (mass,mx,my)=" + f"({wo[0]:.3e},{wo[1]:.3e},{wo[2]:.3e}) max|interface_residual|={ro.max():.3e}") + self.assertLess(wc.max(), 1e-12); self.assertLess(rc.max(), 1e-12) + if coll == "identity": + self.assertLess(wo.max(), 1e-12); self.assertLess(ro.max(), 1e-12) + else: + self.assertGreater(wo.max(), 1e-6) # control must fail conservation + self.assertGreater(ro.max(), 1e-6) # and the reported residual must see it + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/validation/multigrid/test_refinement_followup.py b/tests/validation/multigrid/test_refinement_followup.py new file mode 100644 index 0000000..889bd4b --- /dev/null +++ b/tests/validation/multigrid/test_refinement_followup.py @@ -0,0 +1,366 @@ +"""Bounded fixed-patch checks: fault guards, periodic geometry and coarse slot BC. + +Use the existing bounded runner/environment; no audit-build rerun. The scalar +oracle covers the fixed tau pair and two boundary modes used by these cases. +""" +from contextlib import nullcontext +from dataclasses import replace +import json +from pathlib import Path +import resource +from threading import RLock +import unittest +from unittest.mock import MagicMock, Mock, patch + +import numpy as np +import pycuda.driver as cuda + +import CelerisLab +from CelerisLab.lbm import refinement_stepper as implementation +from test_refinement_gpu import (ROOT, POP_ATOL, MOMENT_ATOL, ref, postcollision, + totals, LBMConfig, CudaContext, compiler, + PeriodicPatchStepper) + + +def scalar_interval(coarse, fine, bounds, slot=None): + """Independent G→G interval; optional (amplitude, width) bottom slot/top rho=1.""" + _, ny, nx = coarse.shape + _, fy, fx = fine.shape + x0, y0, x1, y1 = bounds + cx, cy = (0, 1, -1, 0, 0, 1, -1, 1, -1), (0, 0, 0, 1, -1, 1, -1, -1, 1) + weights = (4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36) + + def collide(values, tau): + rho = sum(values) + ux = sum(c * v for c, v in zip(cx, values)) / rho + uy = sum(c * v for c, v in zip(cy, values)) / rho + equilibrium = [w * rho * (1 + 3*(a*ux+b*uy) + 4.5*(a*ux+b*uy)**2 + - 1.5*(ux*ux+uy*uy)) for w, a, b in zip(weights, cx, cy)] + return [v - (v-e)/tau for v, e in zip(values, equilibrium)] + + current = fine.astype(np.float64).copy() + for y in range(fy): + for x in range(fx): + if not (4 <= x < fx-4 and 4 <= y < fy-4): + current[:, y, x] = coarse[:, y0+(y-4)//2, x0+(x-4)//2] + for _ in range(2): + out = np.full_like(current, np.nan) + for y in range(2, fy-2): + for x in range(2, fx-2): + values = [current[q, y-cy[q], x-cx[q]] for q in range(9)] + out[:, y, x] = (collide(values, 1.1) + if 4 <= x < fx-4 and 4 <= y < fy-4 else values) + current = out + result = np.full(coarse.shape, np.nan) + for y in range(ny): + for x in range(nx): + if (x0 <= x < x1 and y0 <= y < y1) or (slot is not None and y in (0, ny-1)): + continue + values = [] + for q in range(9): + mx, my = x+.5-cx[q]/2, y+.5-cy[q]/2 + if x0 <= mx <= x1 and y0 <= my <= y1: + a, b = 4+2*(x-x0), 4+2*(y-y0) + value = sum(current[q, b+dy, a+dx] for dy in (0, 1) for dx in (0, 1))/4 + else: + value = coarse[q, (y-cy[q]) % ny, (x-cx[q]) % nx] + values.append(value) + if slot is not None and y == 1: + amplitude, width = slot + s = 2*((x+.5)-nx/2)/width + v = amplitude * max(0.0, 1-s*s)**2 + rho = (values[0]+values[1]+values[2] + + 2*(values[4]+values[6]+values[7]))/(1-v) + values[3] = values[4] + 2*rho*v/3 + values[5] = values[6] + (values[2]-values[1])/2 + rho*v/6 + values[8] = values[7] + (values[1]-values[2])/2 + rho*v/6 + elif slot is not None and y == ny-2: + jy = values[0]+values[1]+values[2]+2*(values[3]+values[5]+values[8])-1 + values[4] = values[3] - 2*jy/3 + values[6] = values[5] + (values[1]-values[2])/2 - jy/6 + values[7] = values[8] + (values[2]-values[1])/2 - jy/6 + result[:, y, x] = collide(values, .8) + return result, current + + +class FaultGuardsCPU(unittest.TestCase): + def test_boundary_mode_and_margin_contract(self): + cfg = LBMConfig(nx=32, ny=24, viscosity=.1, velocity=0, wall_normal_velocity=.001, + wall_slot_width=8, y_wall_bc="on_site_velocity", top_wall_bc="reservoir") + context = Mock() + context.activate.side_effect = RuntimeError("validated before CUDA") + for mode, bounds in (("unknown", (12, 4, 20, 10)), + ("slot_reservoir", (12, 3, 20, 10)), + ("slot_reservoir", (12, 4, 20, 21))): + with self.subTest(mode=mode, bounds=bounds): + with self.assertRaises(ValueError): + implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode) + context.activate.assert_not_called() + # Both exact channel limits and the original periodic two-cell margin + # must reach context activation, with no allocation or device required. + for mode, bounds in (("slot_reservoir", (12, 4, 20, 20)), + ("periodic", (2, 2, 6, 5))): + with self.subTest(mode=mode, bounds=bounds): + with self.assertRaisesRegex(RuntimeError, "validated before CUDA"): + implementation.FixedPatchStepper(cfg, bounds, context=context, coarse_boundary=mode) + + def fake_backend(self): + backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper) + backend.ctx = Mock(activate=nullcontext) + backend._lock = RLock() + backend._status = "ready" + backend._stream = Mock() + backend._levels = [Mock(shape=(9, 1, 1)), Mock(shape=(9, 1, 1))] + backend._allocations = [Mock(), Mock()] + return backend + + def test_failed_download_blocks_read_and_step(self): + # Fail on each level separately, including after coarse data was read. + for failed_read in (0, 1): + backend = self.fake_backend() + reads = [None] * failed_read + [RuntimeError("device read failed")] + with patch.object(cuda, "memcpy_dtoh", side_effect=reads) as read: + with self.assertRaisesRegex(RuntimeError, "device read failed"): + backend.download() + calls = read.call_count + for operation in (backend.download, backend.step): + with self.assertRaisesRegex(RuntimeError, "failed"): + operation() + self.assertEqual(read.call_count, calls) + backend.close() + + def test_activation_failure_retains_resources_for_retry(self): + backend = self.fake_backend() + levels, allocations, stream = backend._levels[:], backend._allocations[:], backend._stream + unavailable = MagicMock() + unavailable.__enter__.side_effect = RuntimeError("activation failed") + backend.ctx.activate = Mock(side_effect=[unavailable, nullcontext()]) + with self.assertRaisesRegex(RuntimeError, "activation failed"): + backend.close() + self.assertEqual(backend._status, "failed") + self.assertEqual(backend._levels, levels) + self.assertEqual(backend._allocations, allocations) + self.assertIs(backend._stream, stream) + for allocation in allocations: + allocation.free.assert_not_called() + backend.close() + self.assertEqual(backend._status, "closed") + for allocation in allocations: + allocation.free.assert_called_once() + + def test_kernel_contracts_reject_before_upload(self): + cfg = LBMConfig(nx=16, ny=16, viscosity=.1) + context = Mock(activate=nullcontext, sm_arch="sm_70") + with patch.object(implementation, "HALO", 2): + with self.assertRaisesRegex(RuntimeError, "four-cell"): + PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) + p = implementation.FixedPatch2D.from_config(cfg, (4, 4, 12, 12)) + with patch.object(cuda, "Stream"), patch.object(implementation.FixedPatch2D, "from_config", + return_value=replace(p, tau_coarse=.9)): + with self.assertRaisesRegex(RuntimeError, "omega/tau"): + PeriodicPatchStepper(cfg, p.bounds, context=context) + module = Mock() + module.get_global.return_value = (Mock(), 16) + with patch.object(cuda, "Stream"), 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, "memcpy_htod") as upload: + with self.assertRaisesRegex(RuntimeError, "layout mismatch"): + PeriodicPatchStepper(cfg, p.bounds, context=context) + upload.assert_not_called() + + +class NormalBuildGPU(unittest.TestCase): + def setUp(self): + self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py") + self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels") + self.assertEqual(Path(implementation.__file__).resolve(), + ROOT / "src/CelerisLab/lbm/refinement_stepper.py") + + def tearDown(self): + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + print(f"host_peak_rss={rss:.3f} MiB", flush=True) + + def test_slot_reservoir_eight_steps(self): + nx, ny, bounds, amplitude, width = 32, 24, (12, 4, 20, 10), .001, 8 + x0, y0, x1, y1 = bounds + fx, fy = 2*(x1-x0)+8, 2*(y1-y0)+8 + cfg = LBMConfig(nx=nx, ny=ny, viscosity=.1, velocity=0, rho=1, + wall_normal_velocity=amplitude, wall_slot_width=width, + y_wall_bc="on_site_velocity", top_wall_bc="reservoir") + cx = np.array((0, 1, -1, 0, 0, 1, -1, 1, -1)) + cy = np.array((0, 0, 0, 1, -1, 1, -1, -1, 1)) + weights = np.array((4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36)) + + def slot_velocity(x): + return amplitude * np.maximum(0, 1-(2*(x-nx/2)/width)**2)**2 + + def equilibrium(shape, origin, dx): + ys = origin[1] + dx*(np.arange(shape[0])+.5) + xs = origin[0] + dx*(np.arange(shape[1])+.5) + v = slot_velocity(xs)[None, :] * (1-(ys[:, None]-1.5)/(ny-3)) + cu = cy[:, None, None] * v + return np.array(weights[:, None, None]*(1+3*cu+4.5*cu*cu-1.5*v*v), np.float32) + + coarse_leaf = np.ones((ny, nx), bool) + coarse_leaf[[0, -1]] = False + coarse_leaf[y0:y1, x0:x1] = False + fine_leaf = np.zeros((fy, fx), bool) + fine_leaf[4:-4, 4:-4] = True + initial = [equilibrium((ny, nx), (0, 0), 1), + equilibrium((fy, fx), (x0-2, y0-2), .5)] + initial[0][:, ~coarse_leaf] = np.nan + initial[1][:, ~fine_leaf] = np.nan + # A postcollision zero-mass/zero-momentum perturbation at a grazing + # source distinguishes closed-corner coupling from ordinary coarse pull. + initial[0][[5, 6], y0-1, x0] += 1e-5 + initial[0][[7, 8], y0-1, x0] -= 1e-5 + expected = tuple(a.astype(np.float64) for a in initial) + + def discrete_mass(arrays): + return (arrays[0][:, coarse_leaf].sum(dtype=np.float64) + + .25*arrays[1][:, fine_leaf].sum(dtype=np.float64)) + + # Observe the actual generated headers, without changing generation or + # compilation. In particular the fine module must have slot BC disabled. + generated = [] + generate = compiler.generate_config + + def record_config(*args, **kwargs): + build = generate(*args, **kwargs) + metadata = json.loads((Path(build)/".celeris-build").read_text()) + definitions = {} + for line in (Path(build)/"config/config_method.h").read_text().splitlines(): + parts = line.split() + if len(parts) == 3 and parts[0] == "#define": + definitions[parts[1]] = parts[2] + generated.append((metadata["config"], definitions)) + return build + + max_error = max_bc_error = 0.0 + with CudaContext(0) as context: + with patch.object(compiler, "generate_config", side_effect=record_config): + backend = implementation.FixedPatchStepper(cfg, bounds, context=context, + coarse_boundary="slot_reservoir") + with backend: + self.assertEqual(len(generated), 2) + fine_config, fine_defines = generated[1] + self.assertEqual((fine_config["nx"], fine_config["ny"]), (fx, fy)) + for name in ("Y_WALL_BC", "TOP_WALL_BC", "WALL_SLOT_WIDTH", + "WALL_NORMAL_VELOCITY", "TOP_WALL_NORMAL_VELOCITY"): + self.assertEqual(float(fine_defines[name].removesuffix("f")), 0, name) + self.assertEqual(int(generated[0][1]["Y_WALL_BC"]), 2) + self.assertEqual(int(generated[0][1]["TOP_WALL_BC"]), 1) + # Independently derive all target links, including the eight + # whose coarse sources are uncovered, from physical midpoints. + targets, corners = set(), set() + for y, x in np.argwhere(coarse_leaf): + for q in range(1, 9): + if x0 <= x+.5-cx[q]/2 <= x1 and y0 <= y+.5-cy[q]/2 <= y1: + target = q*nx*ny+y*nx+x + targets.add(target) + sx, sy = x-cx[q], y-cy[q] + if not (x0 <= sx < x1 and y0 <= sy < y1): + corners.add(target) + self.assertEqual(len(corners), 8) + self.assertEqual(set(backend.patch.coalesce_coarse.tolist()), targets) + backend.initialize(*initial) + before = discrete_mass(initial) + for step in range(1, 9): + expected = scalar_interval(*expected, bounds, slot=(amplitude, width)) + backend.step() + self.assertEqual(backend.step_count, step) + raw = [np.empty(a.shape, np.float32) for a in initial] + for level, array in zip(backend._levels, raw): + cuda.memcpy_dtoh(array, level.current) + # Read raw storage before masking: active NaNs cannot be + # hidden by the public download's non-leaf cleanup. + self.assertTrue(np.isfinite(raw[0][:, coarse_leaf]).all()) + self.assertTrue(np.isfinite(raw[1][:, fine_leaf]).all()) + self.assertTrue(np.isnan(raw[0][:, ~coarse_leaf]).all()) + for got, want in zip(raw, expected): + np.testing.assert_array_equal(np.isnan(got), np.isnan(want)) + max_error = max(max_error, float(np.nanmax(np.abs(got-want)))) + self.assertLessEqual(max_error, POP_ATOL) + public = backend.download() + np.testing.assert_array_equal(public[0], raw[0]) + np.testing.assert_array_equal(public[1][:, fine_leaf], raw[1][:, fine_leaf]) + self.assertTrue(np.isnan(public[1][:, ~fine_leaf]).all()) + walls = raw[0][:, [1, ny-2], :].astype(np.float64) + rho = walls.sum(axis=0) + ux = np.einsum("q,qyx->yx", cx, walls)/rho + vy = np.einsum("q,qyx->yx", cy, walls)/rho + max_bc_error = max(max_bc_error, float(np.abs(ux).max()), + float(np.abs(vy[0]-slot_velocity(np.arange(nx)+.5)).max()), + float(np.abs(rho[1]-1).max())) + self.assertLessEqual(max_bc_error, 2e-6) + change = discrete_mass(raw)-before + bottom_flux, top_flux = (rho*vy).sum(axis=1) + print(f"slot/reservoir steps=8 max_population={max_error:.9g} " + f"max_BC_error={max_bc_error:.9g} discrete_leaf_mass_change={change:+.9g} " + f"final_sum_rho_v_bottom={bottom_flux:.9g} top={top_flux:.9g}; " + "raw halos/corners/fine BC isolation PASS", flush=True) + + def test_nonequilibrium_seed1_eight_steps(self): + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + initial = ref.make_state(spec, velocity=(.03, -.02)) + rng = np.random.default_rng(1) + state = ref.State(*(a * (1 + 1e-3*rng.standard_normal(a.shape)) + for a in (initial.coarse_f, initial.fine_f))) + max_pop = max_drift = max_moment = 0.0 + with CudaContext(0) as context, PeriodicPatchStepper( + LBMConfig(nx=16, ny=16, viscosity=.1), (4, 4, 12, 12), context=context) as backend: + backend.initialize(*postcollision(state, spec, maps)) + before = totals(backend.download(), maps) + for step in range(1, 9): + state, _ = ref.advance_coarse_once(spec, maps, state) + backend.step() + actual, expected = backend.download(), postcollision(state, spec, maps) + self.assertEqual(backend.step_count, step) + for got, want in zip(actual, expected): + np.testing.assert_array_equal(np.isnan(got), np.isnan(want)) + max_pop = max(max_pop, float(np.nanmax(np.abs(got-want)))) + max_drift = max(max_drift, float(np.max(np.abs(totals(actual, maps)-before)))) + max_moment = max(max_moment, float(np.max(np.abs(totals(actual, maps)-totals(expected, maps))))) + self.assertLessEqual(max_pop, POP_ATOL) + self.assertLessEqual(max_drift, MOMENT_ATOL) + self.assertLessEqual(max_moment, MOMENT_ATOL) + print(f"normal seed1 steps=8: max_population={max_pop:.9g} " + f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment:.9g}", flush=True) + + def test_offcenter_rectangle_margin_two_one_step(self): + cfg, bounds = LBMConfig(nx=17, ny=13, viscosity=.1), (2, 2, 6, 5) + rng = np.random.default_rng(7) + # Explicit asymmetric, nonequilibrium G0, not an arrival array to collide again. + initial = [ref.W[:, None, None] * (1+1e-3*rng.standard_normal((9, ny, nx))) + for ny, nx in ((13, 17), (14, 16))] + initial = [np.array(a, dtype=np.float32) for a in initial] + initial[0][:, 2:5, 2:6] = np.nan + fine_mask = np.zeros((14, 16), bool) + fine_mask[4:-4, 4:-4] = True + initial[1][:, ~fine_mask] = np.nan + expected = scalar_interval(*[a.astype(np.float64) for a in initial], bounds) + with CudaContext(0) as context, PeriodicPatchStepper(cfg, bounds, context=context) as backend: + self.assertEqual(backend.patch.fine_shape, (14, 16)) + p = backend.patch + allocated = 2 * 9 * (17*13+16*14) * 4 + sum(a.nbytes for a in + (p.ghost_fine, p.ghost_coarse, p.coalesce_coarse, p.coalesce_fine)) + self.assertLess(allocated, 128*1024) + backend.initialize(*initial) + backend.step() + coarse, fine = backend.download() + raw_fine = np.empty((9, 14, 16), np.float32) + cuda.memcpy_dtoh(raw_fine, backend._levels[1].current) + max_error = 0.0 + for got, want in zip((coarse, raw_fine), expected): + np.testing.assert_array_equal(np.isnan(got), np.isnan(want)) + max_error = max(max_error, float(np.nanmax(np.abs(got-want)))) + self.assertLessEqual(max_error, POP_ATOL) + self.assertTrue(np.isnan(fine[:, ~fine_mask]).all()) + print(f"normal rectangle 17x13 bounds={bounds} fine=16x14 steps=1: " + f"max_population={max_error:.9g} device_arrays={allocated}; halo/stride PASS", flush=True) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/validation/multigrid/test_refinement_gpu.py b/tests/validation/multigrid/test_refinement_gpu.py new file mode 100644 index 0000000..924b8ba --- /dev/null +++ b/tests/validation/multigrid/test_refinement_gpu.py @@ -0,0 +1,221 @@ +"""Bounded FP32 GPU / pinned FP64 CPUREF-0.3 equivalence, not accuracy validation. + +Run explicitly in pycuda_3_10 with PYTHONPATH=/src, one BLAS thread, +and CUDA_VISIBLE_DEVICES=0 (or 1) only after checking the physical GPU is idle. +At most eight coarse steps per case. No files, plots or flow arrays are saved. +""" +import hashlib +import importlib.util +from contextlib import nullcontext +from pathlib import Path +import resource +import sys +from threading import RLock +import unittest +from unittest.mock import Mock, patch + +import numpy as np +import pycuda.driver as cuda + +import CelerisLab +from CelerisLab.config import LBMConfig +from CelerisLab.cuda import compiler_v2 as compiler +from CelerisLab.cuda.context import CudaContext +from CelerisLab.lbm.refinement_stepper import PeriodicPatchStepper + +ROOT = Path(__file__).resolve().parents[3] +POP_ATOL = 3e-6 +MOMENT_ATOL = 2e-4 +REFERENCE_SHA = "bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e" +REF_PATH = Path(__file__).with_name("cpu_reference.py") +assert hashlib.sha256(REF_PATH.read_bytes()).hexdigest() == REFERENCE_SHA +loader = importlib.util.spec_from_file_location("pinned_patch_reference", REF_PATH) +ref = importlib.util.module_from_spec(loader) +sys.modules[loader.name] = ref +loader.loader.exec_module(ref) + + +def postcollision(state, spec, maps): + # Observation conversion only. Never applied to production G. + return tuple(ref._collide(spec, a, np.isfinite(a), tau, mask, + np.zeros(mask.shape, dtype=np.int64)) + for a, tau, mask in ((state.coarse_f, spec.tau_coarse, maps.coarse_active), + (state.fine_f, spec.tau_fine, maps.fine_active))) + + +def totals(arrays, maps): + # Leaf sum independent of the reference's flux/budget bookkeeping helpers. + c, f = arrays + populations = c[:, maps.coarse_active].astype(np.float64).sum(axis=1) + populations += .25 * f[:, maps.fine_active].astype(np.float64).sum(axis=1) + return np.array([populations.sum(), populations @ ref.CX, populations @ ref.CY]) + + +def global_array(module, name, shape): + out = np.empty(shape, dtype=np.uint32) + ptr, size = module.get_global(name) + assert size == out.nbytes + cuda.memcpy_dtoh(out, ptr) + return out + + +class CoupledGPU(unittest.TestCase): + def test_cleanup_attempts_every_allocation(self): + backend = PeriodicPatchStepper.__new__(PeriodicPatchStepper) + backend.ctx = Mock(activate=nullcontext) + backend._lock = RLock() + backend._status = "failed" + backend._stream = Mock() + backend._stream.synchronize.side_effect = RuntimeError("original stream failure") + first, second = Mock(), Mock() + first.free.side_effect = RuntimeError("secondary free failure") + backend._allocations = [first, second] + backend._levels = [Mock()] + with self.assertRaisesRegex(RuntimeError, "original stream failure"): + backend.close() + first.free.assert_called_once() + second.free.assert_called_once() + self.assertEqual(backend._allocations, []) + self.assertEqual(backend._levels, []) + backend.close() + + def test_partial_advance_cannot_be_published(self): + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + with CudaContext(0) as context: + with PeriodicPatchStepper(LBMConfig(nx=16, ny=16, viscosity=.1), + (4, 4, 12, 12), context=context) as backend: + backend.initialize(*postcollision(ref.make_state(spec), spec, maps)) + coarse, fine = backend._levels + + def fail_after_fine(*args, **kwargs): + # Ensure both queued fine hops actually completed before failing. + backend._stream.synchronize() + raise RuntimeError("coarse launch failed after two fine hops") + + with patch.object(coarse, "kernel", side_effect=fail_after_fine): + with self.assertRaisesRegex(RuntimeError, "after two fine hops"): + backend.step() + self.assertEqual(backend.step_count, 0) + for operation in (backend.download, backend.step): + with self.assertRaisesRegex(RuntimeError, "failed"): + operation() + raw = np.empty(fine.shape, np.float32) + cuda.memcpy_dtoh(raw, fine.current) + self.assertTrue(np.isfinite(raw.ravel()[backend.patch.coalesce_fine]).all()) + self.assertFalse(context.is_closed) + print("partial failure: two fine hops completed, no coarse step published; cleanup PASS", flush=True) + + def test_bounded_equivalence_and_ownership(self): + self.assertEqual(Path(CelerisLab.__file__).resolve(), ROOT / "src/CelerisLab/__init__.py") + self.assertEqual(Path(compiler._KERNEL_DIR).resolve(), ROOT / "src/CelerisLab/lbm/kernels") + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + cfg = LBMConfig(nx=16, ny=16, viscosity=.1) + compile_original = compiler.compile_kernel + + def audit_compile(*args, **kwargs): + # Only this private build's entry differs. Production source unchanged. + entry = Path(kwargs["build_dir"]) / "refinement_audit.cu" + entry.write_text('#define CELERIS_REFINEMENT_AUDIT\n#include "refinement.cu"\n') + kwargs["source"] = entry.name + return compile_original(*args, **kwargs) + + with CudaContext(0) as context: + with patch.object(compiler, "compile_kernel", audit_compile): + backend = PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) + with backend: + # Production allocations: four DDF arrays and four compact tables. + device_bytes = 2 * 9 * (16**2 + 24**2) * 4 + p = backend.patch + device_bytes += sum(a.nbytes for a in (p.ghost_fine, p.ghost_coarse, + p.coalesce_coarse, p.coalesce_fine)) + audit_bytes = sum((2 + 9) * np.prod(level.shape[1:]) * 4 + for level in backend._levels) + self.assertLess(device_bytes + audit_bytes, 128 * 1024) + print(f"device_arrays={device_bytes} audit_arrays={audit_bytes} bytes", flush=True) + for case in ("rest", "nonequilibrium_seed1"): + with self.subTest(case=case): + state = ref.make_state(spec, velocity=(0, 0) if case == "rest" else (.03, -.02)) + if case != "rest": + rng = np.random.default_rng(1) + state = ref.State(*(a * (1 + 1e-3 * rng.standard_normal(a.shape)) + for a in (state.coarse_f, state.fine_f))) + initial = postcollision(state, spec, maps) + backend.initialize(*initial) + for level in backend._levels: + for name in ("collision_counts", "explosion_counts", "incoming_counts"): + ptr, size = level.module.get_global(name) + cuda.memset_d8(ptr, 0, size) + before = totals(backend.download(), maps) + max_error = max_drift = max_moment_error = max_corner = 0.0 + for step in range(1, 9): + state, diagnostic = ref.advance_coarse_once(spec, maps, state) + backend.step() + actual = backend.download() + expected = postcollision(state, spec, maps) + self.assertEqual(backend.step_count, step) + p.check_synchronization(step, state.tick, phase="postcollision_g") + for got, want in zip(actual, expected): + np.testing.assert_array_equal(np.isnan(got), np.isnan(want)) + error = float(np.nanmax(np.abs(got - want))) + self.assertLessEqual(error, POP_ATOL) + max_error = max(max_error, error) + drift = float(np.max(np.abs(totals(actual, maps) - before))) + moment_error = float(np.max(np.abs(totals(actual, maps) - totals(expected, maps)))) + self.assertLessEqual(drift, MOMENT_ATOL) + self.assertLessEqual(moment_error, MOMENT_ATOL) + max_drift, max_moment_error = max(max_drift, drift), max(max_moment_error, moment_error) + c, f = backend._levels + np.testing.assert_array_equal(global_array(c.module, "collision_counts", (16, 16)), + step * maps.coarse_active) + np.testing.assert_array_equal(global_array(f.module, "collision_counts", (24, 24)), + 2 * step * maps.fine_active) + np.testing.assert_array_equal(global_array(f.module, "explosion_counts", (24, 24)), + step * ~maps.fine_active) + owners = np.zeros((9, 16, 16), np.uint32) + owners.ravel()[p.coalesce_coarse] = step + np.testing.assert_array_equal(global_array(c.module, "incoming_counts", owners.shape), owners) + raw = np.empty(f.shape, np.float32) + cuda.memcpy_dtoh(raw, f.current) + np.testing.assert_array_equal(np.isfinite(raw), diagnostic["hop2_valid"]) + self.assertTrue(np.isfinite(raw.ravel()[p.coalesce_fine]).all()) + self.assertEqual(len(diagnostic["corner_packets"]), 8) + for packet in diagnostic["corner_packets"].values(): + sx, sy = packet["source_coarse_xy"] + self.assertTrue(maps.coarse_active[sy, sx]) + values = [raw[packet["direction"], y, x] + for y, x in packet["children_fine_yx"]] + error = float(np.max(np.abs(np.array(values) - packet["packets"]))) + self.assertLessEqual(error, POP_ATOL) + max_corner = max(max_corner, error) + print(f"{case}: steps=8 max_population={max_error:.9g} " + f"max_leaf_drift={max_drift:.9g} max_leaf_error={max_moment_error:.9g} " + f"max_corner_packet={max_corner:.9g}; ownership/counts PASS", flush=True) + + # Fail before any additional time step; no stale synchronized export. + with patch.object(backend, "_explode", side_effect=RuntimeError("injected launch failure")): + with self.assertRaisesRegex(RuntimeError, "injected"): + backend.step() + with self.assertRaisesRegex(RuntimeError, "failed"): + backend.download() + with self.assertRaisesRegex(RuntimeError, "failed"): + backend.step() + self.assertFalse(context.is_closed) + with self.assertRaisesRegex(RuntimeError, "closed"): + backend.download() + + # Compile and execute the actual non-audited entry as well: one step. + with PeriodicPatchStepper(cfg, (4, 4, 12, 12), context=context) as plain: + rest = postcollision(ref.make_state(spec), spec, maps) + plain.initialize(*rest) + plain.step() + for got, expected in zip(plain.download(), rest): + np.testing.assert_allclose(got, expected, rtol=0, atol=POP_ATOL, equal_nan=True) + print("normal-build rest: steps=1 PASS; source/kernel paths pinned", flush=True) + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 + print(f"host_peak_rss={rss:.3f} MiB", flush=True) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/validation/multigrid/test_tron_additions.py b/tests/validation/multigrid/test_tron_additions.py new file mode 100644 index 0000000..b2ceb4f --- /dev/null +++ b/tests/validation/multigrid/test_tron_additions.py @@ -0,0 +1,229 @@ +"""Grok: v0.3 adaptations of TRON test-review additions (2026-10-13), test-only. +1) SRT NaN-mask equality on coarse and fine. +2) Corner packets: all 8 unique corner+direction keys; four child packets + 0.25 weights. +3) coalesce_paths reconstructed test-side (removed from reference); hop2_valid semantics. +4) Diagnostic field delta vs v0.2. +""" +import os, sys, unittest, hashlib, platform, resource, importlib.util +import numpy as np +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import test_cpu_reference as T +ref = T.ref +CX, CY, N, NF, LO, HI, H = T.CX, T.CY, T.N, T.NF, T.LO, T.HI, T.H + + +def cls_of(a, b): + if T.f_active(a, b): + return "A" + return "I" if T.f_inner(a, b) else "O" + + +ENC = {"A": ref.ACTIVE, "I": ref.INNER_GHOST, "O": ref.OUTER_GHOST} + + +def reconstruct_coalesce_paths(maps): + """Independent path records formerly returned as diagnostics.coalesce_paths.""" + rows = [tuple(int(v) for v in r) for r in maps.coalesce.tolist()] + cells = np.zeros((len(rows), 4, 2, 2), dtype=np.int64) # [r,k] -> (intermed yx, origin yx) + classes = np.zeros((len(rows), 4, 2), dtype=np.int64) + for r, (q, ky, kx) in enumerate(rows): + for k, (a, b) in enumerate(T.children(kx, ky)): # (x,y) children + ym, xm = b - CY[q], a - CX[q] + yo, xo = b - 2 * CY[q], a - 2 * CX[q] + cells[r, k, 0] = (ym, xm) + cells[r, k, 1] = (yo, xo) + classes[r, k, 0] = ENC[cls_of(xm, ym)] + classes[r, k, 1] = ENC[cls_of(xo, yo)] + return {"rows": rows, "cells_yx": cells, "classes": classes, + "valid": np.ones(len(rows), dtype=bool)} + + +class TestSRTNanMask(unittest.TestCase): + def test_srt_coarse_and_fine_nan_masks_equal(self): + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + st = T.perturbed_state(spec, 3) + fc, ff = st.coarse_f.copy(), st.fine_f.copy() + worst = 0.0 + for _ in range(3): + st, _ = ref.advance_coarse_once(spec, maps, st) + fc, ff = T.independent_step(fc, ff, srt=True) + np.testing.assert_array_equal(np.isnan(st.coarse_f), np.isnan(fc)) + np.testing.assert_array_equal(np.isnan(st.fine_f), np.isnan(ff)) + exp_fine_nan = np.array([[not T.f_active(a, b) for a in range(NF)] for b in range(NF)]) + np.testing.assert_array_equal(np.isnan(st.fine_f), np.broadcast_to(exp_fine_nan, st.fine_f.shape)) + self.assertTrue(np.isfinite(st.fine_f[:, ~exp_fine_nan]).all()) + worst = max(worst, float(np.max(np.abs(np.nan_to_num(st.coarse_f - fc)))), + float(np.max(np.abs(np.nan_to_num(st.fine_f - ff))))) + print(f"\n[srt masks] coarse+fine NaN masks equal over 3 steps; max diff {worst:.3e}") + self.assertLessEqual(worst, 1e-14) + + +FLIPS = {"lower_left": (False, False), "lower_right": (True, False), + "upper_left": (False, True), "upper_right": (True, True)} +MX = (0, 2, 1, 3, 4, 8, 7, 6, 5) +MY = (0, 1, 2, 4, 3, 7, 8, 5, 6) + + +def mq(q, fx, fy): + if fx: q = MX[q] + if fy: q = MY[q] + return q + + +class TestCornerIndividualPackets(unittest.TestCase): + def test_eight_unique_keys_and_child_packets(self): + spec = ref.make_spec(collision="srt") + maps = ref.build_maps(spec) + keys = [link.key for link in maps.corner_links] + self.assertEqual(len(keys), 8) + self.assertEqual(len(set(keys)), 8) + # expected keys from independent CORNER_PASS geometry + expected = set() + for tag, (fx, fy) in FLIPS.items(): + for q0 in (7, 8): + expected.add(f"{tag}_q{mq(q0, fx, fy)}") + self.assertEqual(set(keys), expected) + eps = 1e-3 + exp_avg, g8 = T.TestSRTCornerOracle()._oracle(eps, True) + mid8 = 4 * exp_avg - 3 * g8 + for name, (fx, fy) in FLIPS.items(): + st = ref.make_state(spec) + fc = st.coarse_f.copy() + sx, sy = (N - 1 - 4 if fx else 4), (N - 1 - 3 if fy else 3) + for q, s in ((5, 1), (6, 1), (7, -1), (8, -1)): + fc[mq(q, fx, fy), sy, sx] += s * eps + out, d = ref.advance_coarse_once(spec, maps, ref.State(fc, st.fine_f, "arrival_f", 0)) + self.assertEqual(len(d["corner_packets"]), 8) + self.assertEqual(set(d["corner_packets"]), set(keys)) + key = f"{name}_q{mq(8, fx, fy)}" + cp = d["corner_packets"][key] + kx, ky = (N - 1 - 3 if fx else 3), (N - 1 - 4 if fy else 4) + q = mq(8, fx, fy) + self.assertEqual(cp["direction"], q, key) + self.assertEqual(tuple(cp["target_coarse_xy"]), (kx, ky), key) + kids_xy = T.children(kx, ky) + self.assertEqual({(y, x) for y, x in cp["children_fine_yx"]}, {(b, a) for a, b in kids_xy}, key) + ax, ay = (NF - 1 - 3 if fx else 3), (NF - 1 - 5 if fy else 5) + for (y, x), p in zip(cp["children_fine_yx"], cp["packets"]): + xm, ym = x - CX[q], y - CY[q] + expect = mid8 if (x, y) == (ax, ay) else g8 + self.assertEqual(cls_of(xm, ym) == "A", (x, y) == (ax, ay), key) + self.assertAlmostEqual(p, expect, places=15, msg=f"{key} child {(x, y)}") + self.assertEqual(list(cp["weights"]), [0.25] * 4, key) + self.assertAlmostEqual(sum(w * p for w, p in zip(cp["weights"], cp["packets"])), cp["coalesced"], places=16) + self.assertAlmostEqual(cp["coalesced"], out.coarse_f[q, ky, kx], places=16) + self.assertAlmostEqual(cp["coalesced"], exp_avg, places=15) + print(f"\n[8 corner keys] {sorted(keys)}; packets 3xg8+1xmid8, weights 0.25, avg={exp_avg!r}") + + +class TestRetainedDiagnostics(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.spec = ref.make_spec(collision="srt") + cls.maps = ref.build_maps(cls.spec) + cls.out, cls.d = ref.advance_coarse_once(cls.spec, cls.maps, T.perturbed_state(cls.spec, 6)) + + def test_coalesce_paths_reconstructed_test_side(self): + self.assertNotIn("coalesce_paths", self.d) + cp = reconstruct_coalesce_paths(self.maps) + rows = cp["rows"] + slots = {(q, y, x) for y in range(N) for x in range(N) for q in range(9) if T.slot_kind(q, x, y)} + self.assertEqual(len(rows), len(set(rows))) + self.assertEqual(set(rows), slots) + self.assertEqual(cp["cells_yx"].shape, (len(rows), 4, 2, 2)) + self.assertTrue(np.asarray(cp["valid"]).all()) + hist = {} + for r, (q, ky, kx) in enumerate(rows): + kids = {(b, a) for a, b in T.children(kx, ky)} + seen = set() + for k in range(4): + (ym, xm), (yo, xo) = [tuple(int(v) for v in c) for c in cp["cells_yx"][r, k]] + y, x = ym + CY[q], xm + CX[q] + self.assertIn((y, x), kids) + seen.add((y, x)) + self.assertEqual((yo, xo), (y - 2 * CY[q], x - 2 * CX[q])) + cm, co = cls_of(xm, ym), cls_of(xo, yo) + self.assertEqual(int(cp["classes"][r, k, 0]), ENC[cm]) + self.assertEqual(int(cp["classes"][r, k, 1]), ENC[co]) + self.assertNotEqual(cm, "O") + self.assertEqual(cls_of(x, y), "I") + kind = T.slot_kind(q, kx, ky) + hist[(kind, cm, co)] = hist.get((kind, cm, co), 0) + 1 + self.assertEqual(seen, kids) + print(f"\n[coalesce_paths test-side] {len(rows)} rows x 4; hist={dict(sorted(hist.items()))}") + + def test_hop2_mask_semantics(self): + self.assertNotIn("fine_valid_after", self.d) + v2 = np.asarray(self.d["hop2_valid"]) + tgt = lambda a, b: cls_of(a, b) != "O" + inb = lambda a, b: 0 <= a < NF and 0 <= b < NF + exp1 = np.zeros((9, NF, NF), bool) + exp2 = np.zeros((9, NF, NF), bool) + for q in range(9): + for b in range(NF): + for a in range(NF): + sa, sb = a - CX[q], b - CY[q] + exp1[q, b, a] = tgt(a, b) and inb(sa, sb) + for b in range(NF): + for a in range(NF): + sa, sb = a - CX[q], b - CY[q] + exp2[q, b, a] = (tgt(a, b) and inb(sa, sb) and cls_of(sa, sb) != "O" + and exp1[q, sb, sa]) + np.testing.assert_array_equal(v2, exp2) + act = np.array([[T.f_active(a, b) for a in range(NF)] for b in range(NF)]) + inner = np.array([[cls_of(a, b) == "I" for a in range(NF)] for b in range(NF)]) + self.assertTrue(v2[:, act].all()) + self.assertFalse(v2[:, ~(act | inner)].any()) + self.assertTrue(np.isnan(self.out.fine_f[:, ~act]).all()) + n_valid_inner = int(v2[:, inner].sum()) + self.assertGreater(n_valid_inner, 0) + reads = self.d["coalesce_reads"] > 0 + self.assertTrue(v2[reads].all()) + print(f"\n[hop2_valid] matches independent derivation; valid inner={n_valid_inner}; " + f"reads hop2-valid={int(reads.sum())}") + + +class TestV02V03FieldDelta(unittest.TestCase): + def test_diagnostic_field_delta(self): + """Compare top-level diagnostic keys and budget keys between v0.2 and v0.3.""" + here = os.path.dirname(os.path.abspath(__file__)) + def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + v2 = load("cpu_ref_v02", os.path.join(here, "cpu_reference_v0.2.py")) + v3 = ref + s2, s3 = v2.make_spec(collision="identity"), v3.make_spec(collision="identity") + m2, m3 = v2.build_maps(s2), v3.build_maps(s3) + st2, st3 = v2.make_state(s2), v3.make_state(s3) + _, d2 = v2.advance_coarse_once(s2, m2, st2) + _, d3 = v3.advance_coarse_once(s3, m3, st3) + k2, k3 = set(d2), set(d3) + removed = sorted(k2 - k3) + added = sorted(k3 - k2) + renamed = {"explosion_count", "coalesce_paths", "fine_valid_after"} + self.assertTrue(set(removed) <= renamed | {"explosion_count"}) + self.assertIn("hop2_valid", added) + self.assertNotIn("coalesce_paths", d3) + self.assertNotIn("explosion_count", d3) + self.assertNotIn("fine_valid_after", d3) + self.assertIn("closure_error", d2["budget"]) + self.assertIn("bookkeeping_check", d3["budget"]) + self.assertNotIn("closure_error", d3["budget"]) + self.assertEqual(len(d2["corner_packets"]), 4) # v0.2 overwrite bug + self.assertEqual(len(d3["corner_packets"]), 8) + print(f"\n[v0.2→v0.3 fields] removed={removed} added={added}; " + f"budget rename closure_error→bookkeeping_check; corner_packets 4→8") + + +if __name__ == "__main__": + print("reference:", T.REF_PATH, "API", ref.API_VERSION) + for p in (T.REF_PATH, T.__file__, os.path.abspath(__file__)): + print("sha256", hashlib.sha256(open(p, "rb").read()).hexdigest(), os.path.basename(p)) + print("python", sys.version.split()[0], "numpy", np.__version__, platform.platform()) + prog = unittest.main(exit=False, verbosity=2) + print("peak_rss_MiB", resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024) + sys.exit(0 if prog.result.wasSuccessful() else 1) diff --git a/tests/validation/run_j1.py b/tests/validation/run_j1.py new file mode 100644 index 0000000..cf73802 --- /dev/null +++ b/tests/validation/run_j1.py @@ -0,0 +1,248 @@ +"""Bounded J1 Couette-throughflow validation; gates fixed before first run. + +See research/j1-2026-10-12/REVIEW.md. No production audit kernel or extra launch +is added: a temporary test build records actual incoming populations and the +roundoff of the real collision, then is compared with uninstrumented stepping. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import numpy as np +import pycuda.driver as cuda + +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from CelerisLab.simulation import Simulation + +ROOT = Path(__file__).resolve().parents[2] +CX = np.array([0,1,-1,0,0,1,-1,1,-1]) +CY = np.array([0,0,0,1,-1,1,-1,-1,1]) +GATES = dict(u_relative_l2=1e-3, v_max_error=2e-6, flux_relative_error=1e-3, + mass_offset=1e-3, mass_window_span=6e-6, mass_chunk_change=2e-6, + u_change=2e-5, v_change=2e-7, budget_relative_residual=2e-7) + + +def make_sim(v, mode='on_site_velocity', nx=16, top_u=.02, slot_width=0, top_mode='velocity'): + config = json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) + config['grid'].update(nx=nx, ny=35, nz=1, lattice_model='D2Q9') + config['physics'].update(viscosity=.1, velocity=top_u, rho=1.) + config['method'].update(collision='SRT', streaming='double_buffer', + store_precision='FP32', ddf_shifting=False, + y_wall_bc=mode, wall_normal_velocity=v, wall_slot_width=slot_width, + top_wall_bc=top_mode) + config['method']['les']['enabled'] = False + with TemporaryDirectory(prefix='celeris-j1-config-') as folder: + lbm, body = Path(folder)/'lbm.json', Path(folder)/'body.json' + lbm.write_text(json.dumps(config)) + body.write_text('{"objects":[]}') + return Simulation(str(lbm), str(body), device_id=0) + + +def populations(sim): + return sim.get_ddf().reshape(9, sim.field.ny, sim.field.nx).astype(np.float64) + + +def observables(f): + interior = f[:, 1:-1, :] + rho = interior.sum(axis=0) + ux = np.einsum('q,qyx->yx', CX, interior)/rho + uy = np.einsum('q,qyx->yx', CY, interior)/rho + return rho, ux, uy + + +AUDIT = r''' +__device__ double j1_incoming[2*NX]; +__device__ double j1_collision[NX*NY]; +__device__ void j1_collide_audit(float* f, float rho, float ux, float uy) { + const unsigned x=threadIdx.x+blockIdx.x*blockDim.x, y=blockIdx.y; + double before=0.0; + for(int i=0;i= 8 and all( + r['u_change'] <= GATES['u_change'] and r['v_change'] <= GATES['v_change'] + and r['mass_change'] <= GATES['mass_chunk_change'] for r in tail) + steady = steady and np.ptp([r['mass'] for r in history[-5:]])/initial_mass <= GATES['mass_window_span'] + if steady: + break + y = np.arange(height+1) + exact = speed*y/height if v == 0 else speed*np.expm1(v*y/nu)/np.expm1(v*height/nu) + relative_l2 = float(np.linalg.norm(u[1:-1]-exact[1:-1])/np.linalg.norm(exact[1:-1])) + v_error = float(np.max(np.abs(uy-v))) + sections = [8,16,24] # Distances above physical bottom; none is a BC row. + flux = [float(np.sum(rho[s]*uy[s])) for s in sections] + target_flux = float(rho.mean()*v*nx) + flux_error = max(abs(q-target_flux)/abs(target_flux) for q in flux) if v else max(abs(q)/nx for q in flux) + mass_span = float(np.ptp([r['mass'] for r in history[-5:]])/initial_mass) + offset = float(abs(mass-initial_mass)/initial_mass) + result = dict(v=v, height=height, nx=nx, nu=nu, top_u=speed, + pe=v*height/nu, re=speed*height/nu, + ma=float(np.hypot(speed,v)*np.sqrt(3)), + steps=sim.stepper.step_count, max_steps=40*chunk, steady=bool(steady), + relative_l2=relative_l2, v_max_error=v_error, + mass_offset=offset, mass_window_span=mass_span, + rho_range=[float(rho.min()),float(rho.max())], + section_y=sections, measured_flux=flux, reference_flux=target_flux, + flux_error=flux_error, profile_u=u.tolist(), profile_v=normal.tolist(), + analytic_u=exact.tolist(), history=history) + budget = population_budget(sim) + result['population_budget'] = budget + result['passed'] = bool(steady and relative_l2 <= GATES['u_relative_l2'] + and v_error <= GATES['v_max_error'] + and flux_error <= (GATES['flux_relative_error'] if v else GATES['v_max_error']) + and offset <= GATES['mass_offset'] and mass_span <= GATES['mass_window_span'] + and budget['instrumented_matches_plain'] + and budget['max_relative_residual'] <= GATES['budget_relative_residual']) + return result + + +def off_path_check(baseline): + outcomes = [] + for kernel_root in (baseline, Path(compiler.kernel_path(''))): + with patch.object(compiler, '_KERNEL_DIR', str(kernel_root)): + with make_sim(0., mode='bounce_back') as sim: + sim.initialize() + sim.run(7) + outcomes.append((sim.get_ddf().copy(), sim.get_flags().copy())) + return bool(all(np.array_equal(a,b) for a,b in zip(*outcomes))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-kernels', type=Path, + help='Optional pre-change CUDA source for the off-mode comparison') + args = parser.parse_args() + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') in ('0','1') + report = dict(python=sys.executable, package=CelerisLab.__file__, + cuda_source=compiler.kernel_path('kernel_v2.cu'), + physical_gpu=os.environ['CUDA_VISIBLE_DEVICES'], gates=GATES, cases=[]) + report['off_path_bitwise_equal'] = (off_path_check(args.baseline_kernels) + if args.baseline_kernels else None) + for v in (0., .001, -.001, .01, -.01): + case = run_case(v) + report['cases'].append(case) + args.output.write_text(json.dumps(report, indent=2)+'\n') + print(json.dumps({k:case[k] for k in ('v','steps','steady','relative_l2','v_max_error', + 'mass_offset','mass_window_span','flux_error','passed')}), flush=True) + return 0 if report['off_path_bitwise_equal'] is not False and all(c['passed'] for c in report['cases']) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/validation/run_j2_slot.py b/tests/validation/run_j2_slot.py new file mode 100644 index 0000000..8a37272 --- /dev/null +++ b/tests/validation/run_j2_slot.py @@ -0,0 +1,164 @@ +"""Bounded static-slot validation; frozen gates in research/j2-slot-2026-10-12/REVIEW.md.""" +import argparse +import json +import os +from pathlib import Path +import sys +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import numpy as np +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from run_j1 import ROOT, make_sim, populations, observables, population_budget, off_path_check + +NX, WIDTH, CHUNK = 48, 16, 519 +GATES = dict(velocity_change=5e-5, mass_change=2e-6, mass_window_span=6e-6, + mass_offset=1e-3, rho_error=.005, speed=.01, zero_speed=2e-6, + flux_error=.005, flux_span=.002, boundary_error=2e-6, + rho_symmetry=2e-5, velocity_symmetry=.002, budget_residual=2e-7) + + +def targets(amplitude): + # Independent discrete quadrature, including the zero-target flanks. + x = np.arange(NX)-(NX-1)/2 + bottom = amplitude*np.clip(1-(x/(WIDTH/2))**2, 0, None)**2 + return bottom, bottom.mean(), np.abs(x) < WIDTH/2 + + +def full_width_check(baseline): + snapshots = [] + for source, width in ((baseline, 0), (Path(compiler.kernel_path('')), NX)): + with patch.object(compiler, '_KERNEL_DIR', str(source)): + with make_sim(.002, nx=NX, top_u=0., slot_width=width) as sim: + sim.initialize() + initial = sim.get_ddf().copy() + sim.run(64) + snapshots.append((initial, sim.get_ddf().copy(), sim.get_flags().copy())) + return bool(all(np.array_equal(a, b) for a, b in zip(*snapshots))) + + +def restart_check(sim, amplitude, top_mode='velocity'): + with TemporaryDirectory(prefix='celeris-j2-checkpoint-') as folder: + path = sim.save_checkpoint(str(Path(folder)/'slot.h5')) + sim.run(7) + expected, flags, count = sim.get_ddf().copy(), sim.get_flags().copy(), sim.stepper.step_count + with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH, top_mode=top_mode) as target: + target.load_checkpoint(path) + target.run(7) + equal = (np.array_equal(expected, target.get_ddf()) + and np.array_equal(flags, target.get_flags()) and count == target.stepper.step_count) + sim.load_checkpoint(path) + return bool(equal) + + +def run_case(amplitude, top_mode='velocity', check_restart=True): + bottom, top, slot = targets(amplitude) + scale = max(abs(amplitude), .002) + with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH, top_mode=top_mode) as sim: + sim.initialize() + rho, ux, uy = observables(populations(sim)) + initial_mass = float(rho.sum()) + initial_error = float(max(np.abs(uy[0]-bottom).max(), np.abs(uy[-1]-top).max(), np.abs(ux).max())) + old_u, old_v, old_mass = ux.copy(), uy.copy(), initial_mass + history = [dict(step=0, mass=initial_mass)] + steady = False + for sample in range(1, 41): + sim.run(CHUNK) + f = populations(sim) + assert np.isfinite(f).all(), 'Nonfinite slot populations' + rho, ux, uy = observables(f) + mass = float(rho.sum()) + history.append(dict(step=sim.stepper.step_count, mass=mass, + u_change=float(np.sqrt(np.mean((ux-old_u)**2))/scale), + v_change=float(np.sqrt(np.mean((uy-old_v)**2))/scale), + mass_change=abs(mass-old_mass)/initial_mass)) + old_u, old_v, old_mass = ux.copy(), uy.copy(), mass + span = float(np.ptp([r['mass'] for r in history[-5:]])/initial_mass) + steady = bool(sample >= 8 and span <= GATES['mass_window_span'] and all( + r['u_change'] <= GATES['velocity_change'] and r['v_change'] <= GATES['velocity_change'] + and r['mass_change'] <= GATES['mass_change'] for r in history[-4:])) + if steady: + break + sections = [8, 16, 24] + actual_mass_flux = (rho*uy).sum(axis=1) + actual_volume_flux = uy.sum(axis=1) + request_q = float(bottom.sum()) + reference = float(rho.mean()*request_q) + flux_error = float(np.max(np.abs(actual_mass_flux[sections]-reference))/abs(reference)) if amplitude else 0. + flux_span = float(np.ptp(actual_mass_flux[sections])/abs(reference)) if amplitude else 0. + symmetry = dict(rho=float(np.max(np.abs(rho-rho[:, ::-1]))), + u=float(np.max(np.abs(ux+ux[:, ::-1]))/scale), + v=float(np.max(np.abs(uy-uy[:, ::-1]))/scale)) + boundary_error = float(max(np.abs(uy[0]-bottom).max(), + np.abs(uy[-1]-top).max() if top_mode == 'velocity' else 0., + np.abs(ux[[0, -1]]).max())) + top_density_error = float(np.abs(rho[-1]-sim.lbm_cfg.rho).max()/sim.lbm_cfg.rho) + offset = abs(mass-initial_mass)/initial_mass + speed = float(np.hypot(ux, uy).max()) + density_error = float(np.abs(rho-1).max()) + nonuniform = bool(uy[8, NX//2]*amplitude > 0 and np.abs(ux).max() > 1e-5) if amplitude else True + restart = restart_check(sim, amplitude, top_mode) if check_restart and amplitude > 0 else None + budget = population_budget(sim, bottom_slot=slot) + checks = dict(steady=steady, initialization=initial_error <= GATES['boundary_error'], + boundary=boundary_error <= GATES['boundary_error'], + mass=offset <= GATES['mass_offset'] and span <= GATES['mass_window_span'], + bounded=density_error <= GATES['rho_error'] and speed <= GATES['speed'], + flux=flux_error <= GATES['flux_error'] and flux_span <= GATES['flux_span'], + symmetry=symmetry['rho'] <= GATES['rho_symmetry'] and + max(symmetry['u'], symmetry['v']) <= GATES['velocity_symmetry'], + audit=budget['instrumented_matches_plain'] and + budget['max_relative_residual'] <= GATES['budget_residual']) + if amplitude: + checks['nonuniform'] = nonuniform + else: + checks.pop('flux') # Relative flux error has no nonzero reference here. + checks['zero_speed'] = speed <= GATES['zero_speed'] + if restart is not None: + checks['restart'] = restart + if top_mode == 'reservoir': + checks['top_density'] = top_density_error <= 5e-7 + checks = {name: bool(passed) for name, passed in checks.items()} + return dict(amplitude=amplitude, width=WIDTH, nx=NX, height=32, viscosity=.1, top_u=0., + steps=sim.stepper.step_count-16, max_steps=40*CHUNK, checks=checks, passed=all(checks.values()), + requested_volume=request_q, top_initial_guess=top, + top_target=top if top_mode == 'velocity' else None, bottom_targets=bottom.tolist(), + velocity_scale=scale, rho_scale=1., initial_boundary_error=initial_error, + top_mode=top_mode, top_density_error=top_density_error, + top_velocity_range=[float(uy[-1].min()), float(uy[-1].max())], + boundary_error=boundary_error, mass_offset=offset, mass_window_span=span, + flux_error=flux_error, flux_span=flux_span, section_y=sections, + mass_flux_by_y=actual_mass_flux.tolist(), volume_flux_by_y=actual_volume_flux.tolist(), + symmetry=symmetry, max_speed=speed, rho_error=density_error, + rho=rho.tolist(), u=ux.tolist(), v=uy.tolist(), history=history, + checkpoint_bitwise_equal=restart, population_budget=budget) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-kernels', type=Path, required=True, + help='Pre-J2 source for the required full-width and default-mode comparisons') + args = parser.parse_args() + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') in ('0', '1') + report = dict(python=sys.executable, package=CelerisLab.__file__, + cuda_source=compiler.kernel_path('kernel_v2.cu'), physical_gpu=os.environ['CUDA_VISIBLE_DEVICES'], + gates=GATES, full_width_bitwise_equal=None, off_path_bitwise_equal=None, cases=[]) + report['full_width_bitwise_equal'] = full_width_check(args.baseline_kernels) + report['off_path_bitwise_equal'] = off_path_check(args.baseline_kernels) + for amplitude in (0., .002, -.002): + case = run_case(amplitude) + report['cases'].append(case) + args.output.write_text(json.dumps(report, indent=2)+'\n') + print(json.dumps({k: case[k] for k in ('amplitude', 'steps', 'checks', 'mass_offset', + 'mass_window_span', 'flux_error', 'symmetry')}), flush=True) + return 0 if (report['full_width_bitwise_equal'] is True and report['off_path_bitwise_equal'] is True + and all(c['passed'] for c in report['cases'])) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/validation/run_j3_coverage.py b/tests/validation/run_j3_coverage.py new file mode 100644 index 0000000..d66c299 --- /dev/null +++ b/tests/validation/run_j3_coverage.py @@ -0,0 +1,342 @@ +"""One bounded J3 restart/zero/negative run; frozen plan in research/j3-coverage-2026-10-12.""" +from dataclasses import asdict +import hashlib +import json +import os +from pathlib import Path +import resource +import subprocess +import sys +import traceback + +import h5py +import numpy as np +import pycuda.driver as cuda +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from run_j1 import ROOT, AUDIT, make_sim, populations, observables +from run_j2_slot import GATES, targets + +OUT = ROOT/'research/j3-coverage-2026-10-12' +SOURCE = ROOT/'research/j3-lag-2026-10-12/end-21280.h5' +SOURCE_SHA = '19a94e9e8db6dd9ac981effe06032c351992250b58768d9c090409193893d588' +SCALE, CHUNK, LIMIT = .002, 519, 20760 + + +def sha(path): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024*1024), b''): + digest.update(chunk) + return digest.hexdigest() + + +def create(amplitude): + return make_sim(amplitude, nx=48, top_u=0., slot_width=16, top_mode='reservoir') + + +def state(sim): + arrays = dict(ddf=np.empty(sim.field.n*9, dtype=np.float32), + temp=np.empty(sim.field.n*9, dtype=np.float32), + flag=np.empty(sim.field.n, dtype=np.uint16)) + with sim.ctx.activate(): + for name, array in arrays.items(): + cuda.memcpy_dtoh(array, getattr(sim.field, name+'_gpu')) + return dict(arrays=arrays, step=sim.stepper.step_count, + config=asdict(sim.lbm_cfg), runtime=sim.field._runtime_params.copy()) + + +def compare(a, b): + checks = {name: a['arrays'][name].tobytes() == b['arrays'][name].tobytes() + for name in a['arrays']} + checks.update({name: a[name] == b[name] for name in ('step', 'config', 'runtime')}) + return checks + + +def persist(sim, name): + path = OUT/name + assert not path.exists(), path + sim.save_checkpoint(str(path)) + return dict(path=str(path), sha256=sha(path), step=sim.stepper.step_count) + + +def measure(f, amplitude, initial_mass, previous=None): + rho, u, v = observables(f) + bottom, top, _ = targets(amplitude) + mass = float(rho.sum()) + flux = (rho*v).sum(axis=1) + reference = float(rho.mean()*bottom.sum()) + row = dict(mass=mass, mass_offset=abs(mass-initial_mass)/initial_mass, + rho_range=[float(rho.min()), float(rho.max())], rho_error=float(np.abs(rho-1).max()), + max_speed=float(np.hypot(u, v).max()), u_mean=float(u.mean()), v_mean=float(v.mean()), + boundary_error=float(max(np.abs(v[0]-bottom).max(), np.abs(u[[0, -1]]).max())), + top_density_error=float(np.abs(rho[-1]-1).max()), + top_velocity_range=[float(v[-1].min()), float(v[-1].max())], + mass_flux_by_y=flux.tolist(), volume_flux_by_y=v.sum(axis=1).tolist(), + flux_error=float(np.max(np.abs(flux[[8, 16, 24]]-reference))/abs(reference)) if amplitude else None, + flux_span=float(np.ptp(flux[[8, 16, 24]])/abs(reference)) if amplitude else None, + symmetry=dict(rho=float(np.max(np.abs(rho-rho[:, ::-1]))), + u=float(np.max(np.abs(u+u[:, ::-1]))/SCALE), + v=float(np.max(np.abs(v-v[:, ::-1]))/SCALE)), + center_v=float(v[8, 24]), max_abs_u=float(np.abs(u).max()), + nonuniform=bool(v[8, 24]*amplitude > 0 and np.abs(u).max() > 1e-5) if amplitude else None, + finite=bool(np.isfinite(f).all() and np.isfinite(rho).all() and + np.isfinite(u).all() and np.isfinite(v).all())) + if previous is not None: + row.update(u_change=float(np.sqrt(np.mean((u-previous[0])**2))/SCALE), + v_change=float(np.sqrt(np.mean((v-previous[1])**2))/SCALE), + u_signed_change=float((u-previous[0]).mean()), + v_signed_change=float((v-previous[1]).mean())) + return row, (rho, u, v) + + +def check_step(sim, mass0, extrema): + f = populations(sim) + rho, u, v = observables(f) + values = dict(rho_error=float(np.abs(rho-1).max()), speed=float(np.hypot(u, v).max()), + mass_offset=abs(float(rho.sum())-mass0)/mass0) + finite = bool(np.isfinite(f).all() and all(np.isfinite(a).all() for a in (rho, u, v))) + for key, value in values.items(): + extrema[key] = max(extrema.get(key, 0.), value) + if not finite or any(value > GATES[key] for key, value in values.items()): + raise RuntimeError(f'Hard stop at step {sim.stepper.step_count}: finite={finite}, {values}') + return f, (rho, u, v) + + +def restart(report, save): + with h5py.File(SOURCE, 'r') as hf: + assert int(hf.attrs['step_count']) == 21280 + assert str(hf.attrs['distribution_phase']).startswith('postcollision g(t)') + config, runtime = json.loads(hf.attrs['config_json']), json.loads(hf.attrs['runtime_params_json']) + saved = {name: hf[name][:] for name in ('ddf', 'temp', 'flag')} + row = report['restart'] = dict(status='running', source=str(SOURCE), source_sha256=sha(SOURCE), + start=21280, end=21296, a_steps=0, b_steps=0, extrema={}) + for label in ('a', 'b'): + with create(.002) as sim: + sim.load_checkpoint(str(SOURCE if label == 'a' else OUT/'restart-21280.h5')) + before = state(sim) + assert before['config'] == config and before['runtime'] == runtime + checks = compare(dict(arrays=saved, step=21280, config=config, runtime=runtime), before) + row[label+'_initial_equal'] = checks + assert all(checks.values()), checks + mass0 = float(observables(populations(sim))[0].sum()) + check_step(sim, mass0, row['extrema']) + if label == 'a': + row['checkpoint'] = persist(sim, 'restart-21280.h5') + for _ in range(16): + sim.run(1) + row[label+'_steps'] += 1 + check_step(sim, mass0, row['extrema']) + if label == 'a': + expected = state(sim) + row['reference_end'] = persist(sim, 'restart-reference-21296.h5') + else: + row['continued_equal'] = compare(expected, state(sim)) + row['status'] = 'passed' if all(row['continued_equal'].values()) else 'failed' + save() + assert row['status'] == 'passed', row['continued_equal'] + print('RESTART', json.dumps(row), flush=True) + + +def zero(report, save): + row = report['zero'] = dict(status='running', steps=0, history=[], extrema={}, + steady='not_tested_short_case', flux_relative='not_applicable_zero_reference') + with create(0.) as sim: + sim.initialize() + mass0 = float(observables(populations(sim))[0].sum()) + for step in range(65): + if step: + sim.run(1) + row['steps'] = step + f, fields = check_step(sim, mass0, row['extrema']) + sample, _ = measure(f, 0., mass0) + sample['step'] = step + row['history'].append(sample) + if sample['max_speed'] > GATES['zero_speed']: + raise RuntimeError(f'Zero speed exceeds gate at {step}') + if np.ptp([s['mass'] for s in row['history']])/mass0 > GATES['mass_window_span']: + raise RuntimeError(f'Zero mass span exceeds gate at {step}') + if step and abs(sample['mass']-row['history'][-2]['mass'])/mass0 > GATES['mass_change']: + raise RuntimeError(f'Zero mass change exceeds gate at {step}') + row['mass_span'] = float(np.ptp([s['mass'] for s in row['history']])/mass0) + row['checks'] = dict(zero_speed=row['extrema']['speed'] <= GATES['zero_speed'], + mass_offset=row['extrema']['mass_offset'] <= GATES['mass_offset'], + mass_span=row['mass_span'] <= GATES['mass_window_span'], + mass_change=max(abs(s['mass']-p['mass'])/mass0 for p,s in zip(row['history'],row['history'][1:])) <= GATES['mass_change'], + boundary=max(s['boundary_error'] for s in row['history']) <= GATES['boundary_error'], + top_density=max(s['top_density_error'] for s in row['history']) <= 5e-7, + finite=all(s['finite'] for s in row['history'])) + row['status'] = 'passed' if all(row['checks'].values()) else 'failed' + save() + assert row['status'] == 'passed', row['checks'] + print('ZERO', json.dumps({k:v for k,v in row.items() if k != 'history'}), flush=True) + + +def audit(reference, mass0, report): + result = dict(status='running', start=LIMIT-16, end=LIMIT, plain_steps=16, audit_steps=0, steps=[]) + report['negative']['population_budget'] = result + with create(-.002) as sim: + sim.load_checkpoint(str(OUT/'negative-budget-20744.h5')) + build = compiler.generate_config(sim.lbm_cfg) + original = sim.stepper.step_fn + try: + source = Path(build)/'step/one_step_double.cu' + code = source.read_text() + call, marker = 'collide_dispatch(f, rho_n, ux, uy);', '__global__ __launch_bounds__(NT, 2)' + assert code.count(call) == code.count(marker) == 1 + source.write_text(code.replace(call, 'j1_collide_audit(f, rho_n, ux, uy);').replace(marker, AUDIT+'\n'+marker)) + result['audit_source_sha256'] = sha(source) + ptx = compiler.compile_kernel(build_dir=build) + with sim.ctx.activate(): + module = compiler.load_module(ptx) + ptr, size = module.get_global('d_params') + cuda.memcpy_dtod(ptr, sim.field.module.get_global('d_params')[0], size) + iptr, _ = module.get_global('j1_incoming') + cptr, size = module.get_global('j1_collision') + cuda.memset_d8(cptr, 0, size) + incoming, collision = np.empty((2,48)), np.empty((35,48)) + sim.stepper.step_fn = module.get_function('OneStep') + slot = targets(-.002)[2] + for _ in range(16): + before = populations(sim) + outgoing = np.stack((before[[4,6,7],1].sum(axis=0), before[[3,5,8],-2].sum(axis=0))) + mass = float(before[:,1:-1].sum()) + sim.run(1) + result['audit_steps'] += 1 + after, _ = check_step(sim, mass0, report['negative']['extrema']) + cuda.memcpy_dtoh(incoming, iptr) + cuda.memcpy_dtoh(collision, cptr) + delta = float(after[:,1:-1].sum()-mass) + net = float(incoming.sum()-outgoing.sum()) + rounding = float(collision.sum()) + entry = dict(step=sim.stepper.step_count, mass=mass, delta_mass=delta, + net_boundary=net, collision_roundoff=rounding, + physical_residual=delta-net, budget_residual=delta-net-rounding, + exchange={name:dict(incoming=float(i.sum()), outgoing=float(o.sum())) + for name,i,o in [('slot',incoming[0,slot],outgoing[0,slot]), + ('flanks',incoming[0,~slot],outgoing[0,~slot]), + ('top',incoming[1],outgoing[1])]}) + result['steps'].append(entry) + if abs(entry['budget_residual'])/mass > GATES['budget_residual']: + raise RuntimeError('Population budget gate exceeded') + result['continued_equal'] = compare(reference, state(sim)) + result['max_relative_residual'] = max(abs(s['budget_residual'])/s['mass'] for s in result['steps']) + result['max_physical_relative_residual'] = max(abs(s['physical_residual'])/s['mass'] for s in result['steps']) + result['status'] = 'passed' if all(result['continued_equal'].values()) else 'failed' + finally: + sim.stepper.step_fn = original + compiler.cleanup_build_dir(build) + return result + + +def negative(report, save): + row = report['negative'] = dict(status='running', amplitude=-.002, steps=0, main_steps=0, + history=[], extrema={}, population_budget=dict(status='not_tested')) + with create(-.002) as sim: + sim.initialize() + initial = populations(sim) + mass0 = float(observables(initial)[0].sum()) + first, fields = measure(initial, -.002, mass0) + first.update(step=0) + row['history'].append(first) + bottom, top, _ = targets(-.002) + row['initial_error'] = float(max(np.abs(fields[2][0]-bottom).max(), np.abs(fields[2][-1]-top).max(), np.abs(fields[1]).max())) + previous, mass_previous = fields[1:], mass0 + check_step(sim, mass0, row['extrema']) + try: + for interval in range(1,41): + mean = np.zeros((2,33,48)); m2 = np.zeros_like(mean) + low, high = np.full_like(mean, np.inf), np.full_like(mean, -np.inf) + for n in range(1,CHUNK+1): + if sim.stepper.step_count == LIMIT-16: + row['budget_start'] = persist(sim, 'negative-budget-20744.h5') + sim.run(1) + row['steps'] = row['main_steps'] = sim.stepper.step_count + f, fields = check_step(sim, mass0, row['extrema']) + uv = np.stack(fields[1:]) + delta = uv-mean + mean += delta/n + m2 += delta*(uv-mean) + low, high = np.minimum(low,uv), np.maximum(high,uv) + sample, fields = measure(f, -.002, mass0, previous) + sample.update(step=sim.stepper.step_count, mass_change=abs(sample['mass']-mass_previous)/mass0, + fluctuation=dict(samples=CHUNK, first_step=sim.stepper.step_count-CHUNK+1, + rms=np.sqrt(np.mean(m2/CHUNK,axis=(1,2))).tolist(), + max_node_peak_to_peak=np.max(high-low,axis=(1,2)).tolist())) + row['history'].append(sample) + sample['mass_window_span'] = float(np.ptp([s['mass'] for s in row['history'][-5:]])/mass0) + previous, mass_previous = fields[1:], sample['mass'] + save() + print('NEGATIVE', json.dumps({k:sample[k] for k in ('step','mass_offset','mass_change','mass_window_span','u_change','v_change','flux_error','boundary_error')}), flush=True) + if sample['mass_change'] > GATES['mass_change'] or sample['mass_window_span'] > GATES['mass_window_span']: + raise RuntimeError('Negative sampled mass hard gate exceeded') + assert resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024 < 512 + reference = state(sim) + row['end_checkpoint'] = persist(sim, 'negative-end-20760.h5') + row['final_boundary'] = dict(bottom_rho=fields[0][0].tolist(), bottom_u=fields[1][0].tolist(), + bottom_v=fields[2][0].tolist(), top_rho=fields[0][-1].tolist(), + top_u=fields[1][-1].tolist(), top_v=fields[2][-1].tolist()) + except Exception: + row['failure_checkpoint'] = persist(sim, 'negative-failure.h5') + raise + budget = audit(reference, mass0, report) + last, tail = row['history'][-1], row['history'][-4:] + checks = dict(steady=len(row['history']) >= 9 and all(s['u_change'] <= GATES['velocity_change'] and s['v_change'] <= GATES['velocity_change'] and s['mass_change'] <= GATES['mass_change'] for s in tail) and last['mass_window_span'] <= GATES['mass_window_span'], + initialization=row['initial_error'] <= GATES['boundary_error'], + boundary=last['boundary_error'] <= GATES['boundary_error'], + top_density=last['top_density_error'] <= 5e-7, + mass=last['mass_offset'] <= GATES['mass_offset'] and last['mass_window_span'] <= GATES['mass_window_span'], + bounded=last['rho_error'] <= GATES['rho_error'] and last['max_speed'] <= GATES['speed'], + flux=last['flux_error'] <= GATES['flux_error'] and last['flux_span'] <= GATES['flux_span'], + symmetry=last['symmetry']['rho'] <= GATES['rho_symmetry'] and max(last['symmetry']['u'],last['symmetry']['v']) <= GATES['velocity_symmetry'], + nonuniform=last['nonuniform'], audit=budget['status'] == 'passed' and budget['max_relative_residual'] <= GATES['budget_residual']) + row['checks'] = {k:bool(v) for k,v in checks.items()} + row['status'] = 'passed' if all(checks.values()) else 'failed' + save() + print('NEGATIVE RESULT',json.dumps(row['checks']),flush=True) + + +def main(): + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') == '0' + assert not (OUT/'results.json').exists(), 'Do not repeat this run' + baseline = json.loads((OUT/'baseline.json').read_text()) + assert all(sha(ROOT/name) == digest for name,digest in baseline['hashes'].items()) + assert sha(SOURCE) == SOURCE_SHA + gpu = subprocess.check_output(['nvidia-smi','-i','0','--query-gpu=memory.used,utilization.gpu','--format=csv,noheader,nounits'],text=True) + used, utilization = map(int,gpu.strip().split(',')) + apps = subprocess.check_output(['nvidia-smi','-i','0','--query-compute-apps=pid','--format=csv,noheader'],text=True) + assert not apps.strip() and used < 100 and utilization == 0, (gpu,apps) + report = dict(python=sys.executable, package=CelerisLab.__file__, cuda_source=compiler.kernel_path('kernel_v2.cu'), + script_sha256=sha(__file__), gpu='0', gpu_before=gpu, gates=dict(GATES,top_density_error=5e-7), + restart=dict(status='not_tested'),zero=dict(status='not_tested'),negative=dict(status='not_tested')) + + def save(): + report['peak_rss_mib'] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024 + (OUT/'results.json').write_text(json.dumps(report,indent=2)+'\n') + + print('SOURCES',report['python'],report['package'],report['cuda_source'],flush=True) + save() + try: + restart(report,save) + zero(report,save) + negative(report,save) + except Exception as error: + report['error'] = repr(error) + for name in ('restart','zero','negative'): + if report[name]['status'] == 'running': report[name]['status'] = 'failed' + budget = report['negative'].get('population_budget', {}) + if budget.get('status') == 'running': budget['status'] = 'failed' + traceback.print_exc() + finally: + report['old_files_unchanged'] = all(sha(ROOT/name) == digest for name,digest in baseline['hashes'].items()) + save() + print('FINAL',json.dumps({name:report[name]['status'] for name in ('restart','zero','negative')}),flush=True) + return 0 if report['old_files_unchanged'] and all(report[n]['status']=='passed' for n in ('restart','zero','negative')) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/validation/run_j3_reservoir.py b/tests/validation/run_j3_reservoir.py new file mode 100644 index 0000000..6867139 --- /dev/null +++ b/tests/validation/run_j3_reservoir.py @@ -0,0 +1,81 @@ +"""Sequential reservoir experiment: stop after a failed positive case.""" +import argparse +import json +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import numpy as np +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from run_j1 import ROOT, make_sim, off_path_check +from run_j2_slot import NX, WIDTH, GATES, run_case, restart_check + + +def old_velocity_check(baseline, width, top_u): + states = [] + for source in (baseline, Path(compiler.kernel_path(''))): + with patch.object(compiler, '_KERNEL_DIR', str(source)): + with make_sim(.002, nx=NX, slot_width=width, top_u=top_u) as sim: + sim.initialize() + initial = sim.get_ddf().copy() + sim.run(64) + states.append((initial, sim.get_ddf().copy(), sim.get_flags().copy())) + return bool(all(np.array_equal(a, b) for a, b in zip(*states))) + + +def checkpoint_check(): + with make_sim(.002, nx=NX, top_u=0., slot_width=WIDTH, top_mode='reservoir') as sim: + sim.initialize() + sim.run(11) + return restart_check(sim, .002, top_mode='reservoir') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-kernels', type=Path, required=True) + args = parser.parse_args() + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') in ('0', '1') + report = dict(python=sys.executable, package=CelerisLab.__file__, + cuda_source=compiler.kernel_path('kernel_v2.cu'), physical_gpu=os.environ['CUDA_VISIBLE_DEVICES'], + gates=dict(GATES, top_density_error=5e-7), regressions={}, cases=[], + status=dict(positive='not_tested', negative='not_tested', zero='not_tested', + checkpoint='not_tested')) + + def save(): + args.output.write_text(json.dumps(report, indent=2)+'\n') + + for name, result in ( + ('j1_velocity_top', old_velocity_check(args.baseline_kernels, 0, .02)), + ('j2_velocity_top', old_velocity_check(args.baseline_kernels, WIDTH, 0.)), + ('default_off', off_path_check(args.baseline_kernels)), + ): + report['regressions'][name] = 'passed' if result else 'failed' + save() + print(json.dumps(report['regressions']), flush=True) + if 'failed' in report['regressions'].values(): + return 1 + for name, amplitude in (('positive', .002), ('negative', -.002), ('zero', 0.)): + case = run_case(amplitude, top_mode='reservoir', check_restart=False) + report['cases'].append(case) + report['status'][name] = 'passed' if case['passed'] else 'failed' + save() + print(json.dumps({k: case[k] for k in ('amplitude', 'steps', 'checks', 'mass_offset', + 'mass_window_span', 'flux_error', 'top_density_error', + 'top_velocity_range', 'symmetry')}), flush=True) + if not case['passed']: + return 1 + report['status']['checkpoint'] = 'passed' if checkpoint_check() else 'failed' + save() + print(json.dumps(report['status']), flush=True) + return 0 if all(status == 'passed' for status in report['status'].values()) else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/validation/run_kan99b_rotating_cylinder.py b/tests/validation/run_kan99b_rotating_cylinder.py index 3c02652..739a0f5 100644 --- a/tests/validation/run_kan99b_rotating_cylinder.py +++ b/tests/validation/run_kan99b_rotating_cylinder.py @@ -20,7 +20,6 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np -import pycuda.driver as cuda _REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) _DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json") @@ -272,76 +271,71 @@ def _run_one( ) body = _body_doc(domain.center, alpha=spec.alpha) - tmpd = tempfile.mkdtemp(prefix="celeris_kan99b_mrt_") - lbm_tmp = os.path.join(tmpd, "config_lbm.json") - body_tmp = os.path.join(tmpd, "config_body.json") - _write_json(lbm_tmp, cfg) - _write_json(body_tmp, body) + from CelerisLab import Simulation - from CelerisLab import Simulation # noqa: WPS433 + with tempfile.TemporaryDirectory(prefix="celeris_kan99b_mrt_") as tmpd: + lbm_tmp = os.path.join(tmpd, "config_lbm.json") + body_tmp = os.path.join(tmpd, "config_body.json") + _write_json(lbm_tmp, cfg) + _write_json(body_tmp, body) + with Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp) as sim, sim.ctx.activate(): + if sim.bodies.count < 1: + raise RuntimeError("Expected one cylinder in body config.") + sim.bodies.get(0).state.omega = np.float32(_omega_body(spec.alpha)) + sim.initialize() - sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp) - if sim.bodies.count < 1: - sim.close() - raise RuntimeError("Expected one cylinder in body config.") - sim.bodies.get(0).state.omega = np.float32(_omega_body(spec.alpha)) - sim.initialize() + stream = sim.stream + rec = max(1, int(record_every)) + total = int(spec.burn) + int(spec.steps) + if total < 1: + raise ValueError("burn + steps must be >= 1") - stream = cuda.Stream() - rec = max(1, int(record_every)) - total = int(spec.burn) + int(spec.steps) - if total < 1: - sim.close() - raise ValueError("burn + steps must be >= 1") + step_hist: List[int] = [] + fx_hist: List[float] = [] + fy_hist: List[float] = [] + field_snapshots: List[str] = [] + run_id = _run_id(spec) + snap_dir = os.path.join(out_dir, "fields", run_id) + if field_every > 0: + os.makedirs(snap_dir, exist_ok=True) - step_hist: List[int] = [] - fx_hist: List[float] = [] - fy_hist: List[float] = [] - field_snapshots: List[str] = [] - run_id = _run_id(spec) - snap_dir = os.path.join(out_dir, "fields", run_id) - if field_every > 0: - os.makedirs(snap_dir, exist_ok=True) + for step in range(1, total + 1): + sim.bodies.zero_force_segment_async(stream) + sim.stepper.step( + 1, + action_gpu=sim.bodies.action_gpu, + obs_gpu=sim.bodies.obs_gpu, + stream=stream, + ) + if step % rec == 0 or step == total: + stream.synchronize() + sim.bodies.download_obs_full_async(stream) + stream.synchronize() + force = sim.bodies.read_force(0, normalize=False) + fx = float(force[0]) + fy = float(force[1]) + if not np.isfinite(fx) or not np.isfinite(fy): + raise RuntimeError(f"NaN/Inf force at step {step}") + step_hist.append(step) + fx_hist.append(fx) + fy_hist.append(fy) + if field_every > 0 and (step % int(field_every) == 0 or step == total): + stream.synchronize() + macro = sim.get_macroscopic() + snap_path = os.path.join(snap_dir, f"macro_step{step:08d}.npz") + np.savez_compressed( + snap_path, + rho=np.asarray(macro["rho"], dtype=np.float32), + ux=np.asarray(macro["ux"], dtype=np.float32), + uy=np.asarray(macro["uy"], dtype=np.float32), + ) + field_snapshots.append(snap_path) - for step in range(1, total + 1): - sim.bodies.zero_force_segment_async(stream) - sim.stepper.step( - 1, - action_gpu=sim.bodies.action_gpu, - obs_gpu=sim.bodies.obs_gpu, - stream=stream, - ) - if step % rec == 0 or step == total: stream.synchronize() - sim.bodies.download_obs_full_async(stream) - stream.synchronize() - force = sim.bodies.read_force(0, normalize=False) - fx = float(force[0]) - fy = float(force[1]) - if not np.isfinite(fx) or not np.isfinite(fy): - sim.close() - raise RuntimeError(f"NaN/Inf force at step {step}") - step_hist.append(step) - fx_hist.append(fx) - fy_hist.append(fy) - if field_every > 0 and (step % int(field_every) == 0 or step == total): - stream.synchronize() - macro = sim.get_macroscopic() - snap_path = os.path.join(snap_dir, f"macro_step{step:08d}.npz") - np.savez_compressed( - snap_path, - rho=np.asarray(macro["rho"], dtype=np.float32), - ux=np.asarray(macro["ux"], dtype=np.float32), - uy=np.asarray(macro["uy"], dtype=np.float32), - ) - field_snapshots.append(snap_path) - - stream.synchronize() - macro_last = sim.get_macroscopic() - ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(domain.ny, domain.nx) - uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(domain.ny, domain.nx) - rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(domain.ny, domain.nx) - sim.close() + macro_last = sim.get_macroscopic() + ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(domain.ny, domain.nx) + uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(domain.ny, domain.nx) + rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(domain.ny, domain.nx) step_arr = np.asarray(step_hist, dtype=np.int64) fx_arr = np.asarray(fx_hist, dtype=np.float64) @@ -686,7 +680,10 @@ def main() -> int: print(f"Wrote: {summary_csv}", flush=True) print(f"Wrote: {json_out}", flush=True) - return 0 + failed = any("error" in row for row in rows) + if not args.smoke: + failed |= any(not gate["pass_all"] for gate in k2_gate) + return int(failed) if __name__ == "__main__": diff --git a/tests/validation/run_poiseuille.py b/tests/validation/run_poiseuille.py new file mode 100644 index 0000000..cacff06 --- /dev/null +++ b/tests/validation/run_poiseuille.py @@ -0,0 +1,124 @@ +"""Bounded analytic Poiseuille validation; run on an explicitly selected GPU. + +Reference: ref/lbmpy/src/lbmpy/analytical_solutions.py::poiseuille_flow and +ref/lbmpy/tests/poiseuille.py (x periodic, half-way walls, postcollision F/2 +velocity correction). D2Q9 FP32/shifted, no LES, nu=.1, Re=Umax*H/nu=4.8. +Half-way walls are y=.5 and H+.5; u=F*s*(H-s)/(2*rho0*nu). +No fully periodic Taylor-Green claim: current step kernels always correct the +rows y=1,NY-2 as walls, even if their neighboring flags are made fluid. +""" +import argparse +import json +import os +from pathlib import Path +from tempfile import TemporaryDirectory +import time + +import numpy as np +import pycuda.driver as cuda +import CelerisLab +from CelerisLab.simulation import Simulation +from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID, BC_WALL + +ROOT=Path(__file__).resolve().parents[2] +CX=np.asarray(D2Q9_EX) +CY=np.asarray(D2Q9_EY) +W=np.asarray([4/9]+[1/9]*4+[1/36]*4,dtype=np.float32) + + +def spatial_populations(sim): + f=np.empty((9,sim.field.ny,sim.field.nx),np.float32) + with sim.ctx.activate(): + cuda.memcpy_dtoh(f,sim.field.ddf_gpu) + if sim.lbm_cfg.streaming=="esopull": + p=sim.stepper.step_count-1 + out=np.empty_like(f);out[0]=f[0] + for i in range(1,9,2): + out[i]=np.roll(f[i+1 if p%2 else i],(-CY[i],-CX[i]),(0,1)) + out[i+1]=f[i if p%2 else i+1] + f=out + return f.astype(np.float64)+W[:,None,None] + + +def run(height,collision,streaming,reynolds=4.8): + nu=.1;umax=reynolds*nu/height;force=8*nu*umax/height**2 + cfg=json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) + cfg['grid'].update(nx=16,ny=height+2,nz=1,lattice_model='D2Q9') + cfg['physics'].update(viscosity=nu,velocity=0.,rho=1.) + cfg['method'].update(collision=collision,streaming=streaming,store_precision='FP32', + ddf_shifting=True,y_wall_bc='bounce_back') + cfg['method']['les']['enabled']=False + started=time.monotonic() + with TemporaryDirectory(prefix='celeris-poiseuille-') as directory: + lbm=Path(directory)/'lbm.json';body=Path(directory)/'body.json' + lbm.write_text(json.dumps(cfg));body.write_text('{"objects":[]}') + sim=Simulation(str(lbm),str(body),device_id=0) + try: + sim.initialize() + # Use implemented modulo neighbors in x, retaining real y walls. + flag=sim.field.flag.reshape(height+2,16) + flag[:]=FLUID;flag[[0,-1],:]=SOLID|BC_WALL + sim.field.upload_flags() + sim.field.update_params(fx=force) + decay_time=height**2/(np.pi**2*nu) + chunk=int(np.ceil(.5*decay_time));previous=np.zeros(height) + history=[] + for _ in range(24): # at most 12 slowest diffusion decay times + sim.run(chunk) + f=spatial_populations(sim)[:,1:-1,:] + rho=f.sum(axis=0) + ux=(np.einsum('q,qyx->yx',CX,f)-force/2)/rho + uy=np.einsum('q,qyx->yx',CY,f)/rho + profile=ux.mean(axis=1) + residual=float(np.linalg.norm(profile-previous)/np.sqrt(height)/umax) + history.append(dict(step=sim.stepper.step_count,residual=residual)) + if residual<2e-5: break + previous=profile + s=np.arange(height)+.5 + exact=force*s*(height-s)/(2*nu) + error=float(np.linalg.norm(profile-exact)/np.linalg.norm(exact)) + mass_drift=float(abs(rho.mean()-1)) + transverse=float(np.max(np.abs(uy))) + limit={16:.01,32:.003,64:.0015}.get(height,.01) + result=dict(height=height,collision=collision,streaming=streaming, + nu=nu,umax=umax,force_density=force,re=umax*height/nu, + ma=umax*np.sqrt(3),steps=sim.stepper.step_count, + diffusion_times=sim.stepper.step_count/decay_time, + relative_l2=error,relative_l2_limit=limit, + mass_drift=mass_drift,max_transverse=transverse, + steady_residual=residual,profile=profile.tolist(), + analytic_profile=exact.tolist(),history=history, + seconds=time.monotonic()-started) + result['passed']=bool(np.isfinite(f).all() and error<=limit and mass_drift<=5e-4 + and transverse<=1e-5 and residual<=2e-5) + return result + finally: + sim.close() + + +def main(): + parser=argparse.ArgumentParser(description=__doc__) + parser.add_argument('--heights',type=int,nargs='+',default=[16,32,64]) + parser.add_argument('--collisions',nargs='+',default=['SRT','TRT','MRT']) + parser.add_argument('--streaming',nargs='+',default=['double_buffer','esopull']) + parser.add_argument('--re',type=float,default=4.8,help='Reynolds number; default is the refinement baseline') + parser.add_argument('--output',type=Path,default=Path('/tmp/celeris-poiseuille.json')) + args=parser.parse_args() + if not os.environ.get('CUDA_VISIBLE_DEVICES'): + parser.error('Select a physical GPU explicitly with CUDA_VISIBLE_DEVICES') + assert Path(CelerisLab.__file__).resolve()==ROOT/'src/CelerisLab/__init__.py' + report=dict(package=CelerisLab.__file__,cuda_visible_devices=os.environ['CUDA_VISIBLE_DEVICES'],device_id=0, + reference='ref/lbmpy/tests/poiseuille.py',results=[]) + for h in args.heights: + for collision in args.collisions: + for streaming in args.streaming: + item=run(h,collision,streaming,args.re) + report['results'].append(item) + args.output.write_text(json.dumps(report,indent=2)) + print(json.dumps({k:v for k,v in item.items() if k not in + ('profile','analytic_profile','history')}),flush=True) + return 0 if all(r['passed'] for r in report['results']) else 1 + + +if __name__=='__main__': + raise SystemExit(main()) diff --git a/tests/validation/run_sah04_st_matrix.py b/tests/validation/run_sah04_st_matrix.py index fb8a9cb..661fa10 100644 --- a/tests/validation/run_sah04_st_matrix.py +++ b/tests/validation/run_sah04_st_matrix.py @@ -26,7 +26,6 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Sequence, Tuple import numpy as np -import pycuda.driver as cuda _PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) _DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json") @@ -288,95 +287,92 @@ def run_one_simulation( ] } - tmpd = tempfile.mkdtemp(prefix="celeris_sah04_mrt_") - lbm_tmp = os.path.join(tmpd, "config_lbm.json") - body_tmp = os.path.join(tmpd, "config_body.json") - _write_json(lbm_tmp, cfg) - _write_json(body_tmp, body_doc) + from CelerisLab import Simulation - from CelerisLab import Simulation # noqa: WPS433 + with tempfile.TemporaryDirectory(prefix="celeris_sah04_mrt_") as tmpd: + lbm_tmp = os.path.join(tmpd, "config_lbm.json") + body_tmp = os.path.join(tmpd, "config_body.json") + _write_json(lbm_tmp, cfg) + _write_json(body_tmp, body_doc) + with Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp) as sim, sim.ctx.activate(): + sim.initialize() - sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp) - sim.initialize() + stream = sim.stream + rec_every = max(1, int(record_every)) + lift_hist: List[float] = [] + fx_hist: List[float] = [] + step_hist: List[int] = [] - stream = cuda.Stream() - rec_every = max(1, int(record_every)) - lift_hist: List[float] = [] - fx_hist: List[float] = [] - step_hist: List[int] = [] + n_curved = int(sim.field.n_curved) + fallback_links = int(sim.bodies.fallback_link_count()) + low_q_links = int(sim.bodies.low_q_link_count()) - n_curved = int(sim.field.n_curved) - fallback_links = int(sim.bodies.fallback_link_count()) - low_q_links = int(sim.bodies.low_q_link_count()) - - for step in range(1, int(steps) + 1): - sim.bodies.zero_force_segment_async(stream) - sim.stepper.step( - 1, - action_gpu=sim.bodies.action_gpu, - obs_gpu=sim.bodies.obs_gpu, - stream=stream, - ) - if step % rec_every == 0 or step == int(steps): - stream.synchronize() - sim.bodies.download_obs_full_async(stream) - stream.synchronize() - fvec = sim.bodies.read_force(0, normalize=False) - lift = float(fvec[1]) - drag = float(fvec[0]) - if not np.isfinite(lift) or not np.isfinite(drag): - crash_npz_path: Optional[str] = None - crash_png_path: Optional[str] = None - if crash_dump_dir: - os.makedirs(crash_dump_dir, exist_ok=True) + for step in range(1, int(steps) + 1): + sim.bodies.zero_force_segment_async(stream) + sim.stepper.step( + 1, + action_gpu=sim.bodies.action_gpu, + obs_gpu=sim.bodies.obs_gpu, + stream=stream, + ) + if step % rec_every == 0 or step == int(steps): stream.synchronize() - macro_bad = sim.get_macroscopic() - ux_bad = np.asarray(macro_bad["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - uy_bad = np.asarray(macro_bad["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - rho_bad = np.asarray(macro_bad["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - prefix = f"{case.case_id.lower()}_{str(collision).lower()}_crash_step{step}" - crash_npz_path = os.path.join(crash_dump_dir, f"{prefix}.npz") - np.savez_compressed( - crash_npz_path, - rho=rho_bad.astype(np.float32), - ux=ux_bad.astype(np.float32), - uy=uy_bad.astype(np.float32), - step=np.array([int(step)], dtype=np.int64), - case_id=np.array([case.case_id]), - collision=np.array([str(collision)]), - inlet_scheme=np.array([str(inlet_scheme)]), - outlet_mode=np.array([str(outlet_mode)]), - ) - crash_png_path = os.path.join(crash_dump_dir, f"{prefix}.png") - try: - save_final_vorticity_png( - crash_png_path, - ux_bad, - uy_bad, - title=( - f"Sah04 {case.case_id} {collision} crash@{step} " - f"D={int(geometry.diameter)} H={geometry.h_fluid}" - ), - ) - except Exception: # noqa: BLE001 - crash_png_path = None - sim.close() - msg = f"NaN/Inf force at step {step}" - if crash_npz_path: - msg += f"; crash_npz={crash_npz_path}" - if crash_png_path: - msg += f"; crash_png={crash_png_path}" - raise RuntimeError(msg) - lift_hist.append(lift) - fx_hist.append(drag) - step_hist.append(step) + sim.bodies.download_obs_full_async(stream) + stream.synchronize() + fvec = sim.bodies.read_force(0, normalize=False) + lift = float(fvec[1]) + drag = float(fvec[0]) + if not np.isfinite(lift) or not np.isfinite(drag): + crash_npz_path: Optional[str] = None + crash_png_path: Optional[str] = None + if crash_dump_dir: + os.makedirs(crash_dump_dir, exist_ok=True) + stream.synchronize() + macro_bad = sim.get_macroscopic() + ux_bad = np.asarray(macro_bad["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx) + uy_bad = np.asarray(macro_bad["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx) + rho_bad = np.asarray(macro_bad["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx) + prefix = f"{case.case_id.lower()}_{str(collision).lower()}_crash_step{step}" + crash_npz_path = os.path.join(crash_dump_dir, f"{prefix}.npz") + np.savez_compressed( + crash_npz_path, + rho=rho_bad.astype(np.float32), + ux=ux_bad.astype(np.float32), + uy=uy_bad.astype(np.float32), + step=np.array([int(step)], dtype=np.int64), + case_id=np.array([case.case_id]), + collision=np.array([str(collision)]), + inlet_scheme=np.array([str(inlet_scheme)]), + outlet_mode=np.array([str(outlet_mode)]), + ) + crash_png_path = os.path.join(crash_dump_dir, f"{prefix}.png") + try: + save_final_vorticity_png( + crash_png_path, + ux_bad, + uy_bad, + title=( + f"Sah04 {case.case_id} {collision} crash@{step} " + f"D={int(geometry.diameter)} H={geometry.h_fluid}" + ), + ) + except Exception: # noqa: BLE001 + crash_png_path = None + msg = f"NaN/Inf force at step {step}" + if crash_npz_path: + msg += f"; crash_npz={crash_npz_path}" + if crash_png_path: + msg += f"; crash_png={crash_png_path}" + raise RuntimeError(msg) + lift_hist.append(lift) + fx_hist.append(drag) + step_hist.append(step) - stream.synchronize() - macro_last = sim.get_macroscopic() - ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx) - sim.close() + stream.synchronize() + macro_last = sim.get_macroscopic() + ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx) + uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx) + rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx) if final_vorticity_png_path: out_dir = os.path.dirname(os.path.abspath(final_vorticity_png_path)) @@ -652,7 +648,10 @@ def main() -> int: }, ) print(f"Wrote: {json_out_path}", flush=True) - return 0 + failed = evaluation["cases_failed"] > 0 + if not args.smoke: + failed |= not evaluation["pass_gate_all_completed"] + return int(failed) if __name__ == "__main__": diff --git a/tests/validation/test_sensor_accuracy.py b/tests/validation/test_sensor_accuracy.py index f633b2b..f3de369 100644 --- a/tests/validation/test_sensor_accuracy.py +++ b/tests/validation/test_sensor_accuracy.py @@ -25,7 +25,7 @@ sys.path.insert(0, str(_REPO / "src")) from CelerisLab import Simulation -def test_sensor_accuracy() -> dict: +def run_sensor_accuracy() -> dict: """Run sensor accuracy validation with multiple sensor positions.""" cfg = json.loads( (Path(_REPO) / "src" / "CelerisLab" / "configs" / "config_lbm.json").read_text() @@ -45,7 +45,9 @@ def test_sensor_accuracy() -> dict: with open(lbm_path, "w") as f: json.dump(cfg, f) - sim = Simulation(lbm_config_path=lbm_path) + body_path = os.path.join(tmpd, "config_body.json") + Path(body_path).write_text('{"objects": []}') + sim = Simulation(lbm_config_path=lbm_path, body_config_path=body_path) sim.add_body("circle", center=(80, 64), radius=15) positions = [(120, 50), (120, 64), (120, 78), (150, 64)] @@ -63,8 +65,7 @@ def test_sensor_accuracy() -> dict: sim.run(20) # Get macroscopic field after one more step (with sensor accumulation) - import pycuda.driver as cuda - stream = cuda.Stream() + stream = sim.stream sim.run(1, zero_obs=True, upload_act=False, sync_obs=True, stream=stream) # stream.synchronize() is called inside run() @@ -118,6 +119,11 @@ def test_sensor_accuracy() -> dict: return summary +def test_sensor_accuracy(): + result = run_sensor_accuracy() + assert result["all_pass"], result + + if __name__ == "__main__": - result = test_sensor_accuracy() + result = run_sensor_accuracy() sys.exit(0 if result["all_pass"] else 1)