Add jet, surface and fixed-patch tests and runnable examples

This commit is contained in:
Frank14f committed 2026-10-15 10:13:49 +08:00
1 parent ed1a6ee54e
commit cdbb9c42f6
48 files changed
+9267 -210

No files matched your search

+24
View File
@@ -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))
+6 -5
View File
@@ -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
+7 -7
View File
@@ -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)
+219
View File
@@ -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))<tol
assert abs(np.einsum('q,qyx->',CX,delta)-count*.001)<tol
assert abs(np.einsum('q,qyx->',CY,delta)+count*.0007)<tol
finally:
sim.close()
@pytest.mark.parametrize("omega",[0.,.002])
@pytest.mark.parametrize("periodic",[True,False])
def test_curved_layout_equivalence_isolates_open_boundary(omega,periodic):
"""Both periodic and open cases must agree at the same spatial phase."""
traces=[]
for streaming in ["double_buffer","esopull"]:
sim=make_sim(nx=64,ny=32,streaming=streaming,collision="MRT",y_wall="free_slip")
try:
# Match the boundary family of the rotating-cylinder K2 benchmark.
sim.lbm_cfg.inlet_scheme="regularized"
sim.lbm_cfg.inlet_profile="uniform"
sim.lbm_cfg.inlet_regularized_neq_damp=1.
sim.add_body("circle",center=(32,16),radius=4)
sim.recompile()
sim.initialize()
flags=sim.field.flag.copy()
y,x=np.mgrid[:32,:64]
initial=equilibrium(1+.0001*np.cos(.2*x+.1*y),
.02+.001*np.sin(.3*x+.2*y),.001*np.cos(.2*x-.1*y))
install_spatial_state(sim,initial,count=1)
sim.field.flag[:]=flags # preserve obstacle and curved flags
if periodic:
grid=sim.field.flag.reshape(32,64)
grid[1:-1,0]=FLUID;grid[1:-1,-1]=FLUID
sim.field.upload_flags()
sim.set_body(0,omega=omega)
trace=[]
for step in range(20):
sim.run(1)
if step in [0,1,4,19]: trace.append(spatial_state(sim))
traces.append(trace)
finally:
sim.close()
np.testing.assert_allclose(traces[0],traces[1],atol=2e-6,rtol=0)
@@ -0,0 +1,97 @@
"""GPU ownership, isolated builds and immutable allocation contracts."""
from pathlib import Path
import numpy as np
import pycuda.driver as cuda
import pytest
import CelerisLab
from CelerisLab.cuda import compiler_v2
from ._cases import make_sim
def test_source_provenance():
root = Path(__file__).resolve().parents[2] / "src/CelerisLab"
assert Path(CelerisLab.__file__).resolve().parent == root
assert Path(compiler_v2.kernel_path("kernel_v2.cu")).resolve() == root / "lbm/kernels/kernel_v2.cu"
def test_two_contexts_interleaved_and_non_lifo_close():
before = cuda.Context.get_current()
first = make_sim(nx=32, ny=16)
second = make_sim(nx=48, ny=24, streaming="esopull", shifted=True)
try:
assert first._build_dir != second._build_dir
assert cuda.Context.get_current() == before
first.initialize()
second.initialize()
for _ in range(3):
first.run(1)
second.run(1)
assert first.get_macroscopic()["rho"].shape == (16, 32)
assert second.get_macroscopic()["rho"].shape == (24, 48)
first.close()
second.run(1)
assert np.isfinite(second.get_macroscopic()["rho"]).all()
assert cuda.Context.get_current() == before
finally:
second.close()
first.close()
def test_structural_change_rejected_before_running():
with make_sim(nx=32, ny=16) as sim:
sim.initialize()
before = sim.get_ddf().copy()
sim.lbm_cfg.nx = 64
with pytest.raises(ValueError, match="allocation|structural"):
sim.run(1)
with pytest.raises(ValueError, match="allocation|structural"):
sim._recompile()
sim.lbm_cfg.nx = 32
np.testing.assert_array_equal(sim.get_ddf(), before)
assert sim.stepper.step_count == 0
sim.run(1)
def test_viscosity_recompile_updates_device_omega():
import struct
with make_sim(nx=32, ny=16) as sim:
sim.lbm_cfg.viscosity = 0.08
sim.recompile()
assert sim.stepper.step_count == 0
sim.initialize()
with sim.ctx.activate():
ptr, _ = sim._module.get_global("d_params")
buf = np.empty(20, dtype=np.uint8)
cuda.memcpy_dtoh(buf, ptr)
omega = struct.unpack("=ffffI", buf.tobytes())[0]
assert omega == pytest.approx(sim.lbm_cfg.omega, rel=1e-7)
sim.run(1)
@pytest.mark.parametrize("streaming",["double_buffer","esopull"])
@pytest.mark.parametrize("prior_steps",[3,4])
def test_reinitialize_matches_fresh_state_and_observation_window(streaming,prior_steps):
with make_sim(nx=32,ny=16,streaming=streaming,shifted=True) as fresh, \
make_sim(nx=32,ny=16,streaming=streaming,shifted=True) as reused:
for sim in (fresh,reused):
sim.add_body("sensor",center=(16,8),radius=2)
sim.initialize()
reused.run(prior_steps)
assert reused.stepper.step_count==prior_steps
assert reused.bodies._obs_accum_steps==prior_steps
reused.initialize()
assert reused.stepper.step_count==0
assert reused.bodies._obs_accum_steps==0
np.testing.assert_array_equal(reused.bodies.obs_pinned,0)
with reused.ctx.activate():
obs=np.empty(reused.bodies.obs_total_floats,np.float32)
cuda.memcpy_dtoh(obs,reused.bodies.obs_gpu)
np.testing.assert_array_equal(obs,0)
np.testing.assert_array_equal(reused.get_ddf(),fresh.get_ddf())
for key,value in fresh.get_macroscopic().items():
np.testing.assert_array_equal(reused.get_macroscopic()[key],value)
# Equal next step proves restored parity, not only equal initialization bytes.
fresh.run(1);reused.run(1)
np.testing.assert_array_equal(reused.get_ddf(),fresh.get_ddf())
+181
View File
@@ -0,0 +1,181 @@
"""Bounded GPU regressions for repaired layout/state contracts (D2Q9, low Ma)."""
import numpy as np
import pytest
from ._cases import make_sim
from CelerisLab.lbm.descriptors import FLUID
def physical_esopull(sim):
"""Decode physical next-pull state without changing the solver clock."""
with sim.ctx.activate():
indices = np.arange(sim.field.n,dtype=np.uint32)
scratch, values, idx_gpu = sim.field.esopull_sync_decode(
indices,sim.stepper.step_count,sim.stream)
result = values.copy()
scratch.free()
idx_gpu.free()
return result
@pytest.mark.parametrize('precision',['FP32','FP16S'])
@pytest.mark.parametrize('shifted',[False,True])
def test_esopull_field_snapshot_across_one_step(precision,shifted):
with make_sim(nx=48,ny=24,streaming='esopull',precision=precision,shifted=shifted) as sim:
sim.add_body('circle',center=(16,12),radius=3)
sim.initialize()
sim.run(3)
saved = physical_esopull(sim)
sim.snapshot()
sim.set_body(0,omega=.0003)
sim.run(1)
current_step = sim.stepper.step_count
action = sim.bodies.action.copy()
sim.restore()
assert sim.stepper.step_count == current_step == 4
np.testing.assert_array_equal(sim.bodies.action,action)
# Encoding roundtrip can lose a float32 addition/subtraction ulp when
# adding lattice weights around shifted FP16 storage; it cannot stream.
np.testing.assert_allclose(physical_esopull(sim),saved,rtol=0,atol=1e-7)
@pytest.mark.parametrize('streaming',['double_buffer','esopull'])
@pytest.mark.parametrize('precision',['FP32','FP16S'])
def test_shifted_topology_add_remove_preserves_cache_and_finite_flow(streaming,precision):
with make_sim(nx=48,ny=24,streaming=streaming,precision=precision,shifted=True) as sim:
sim.initialize()
sim.run(3)
sim.get_ddf() # Fill the same-count host cache before a geometry change.
sim.add_body('circle',center=(18,12),radius=3)
sim.sync_bodies()
assert sim.stepper.step_count == 3
assert sim.field._host_ddf_step is None
sim.run(3)
assert np.all(np.isfinite(sim.read_force(0)))
sim.get_ddf()
sim.remove_body(0)
sim.sync_bodies()
assert sim.stepper.step_count == 6
assert sim.field._host_ddf_step is None
sim.run(3)
macro = sim.get_macroscopic()
fluid = (sim.get_flags().reshape(24,48) & FLUID) != 0
assert all(np.isfinite(array[fluid]).all() for array in macro.values())
assert np.min(macro['rho'][fluid]) > .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)
@@ -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()
+5 -25
View File
@@ -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()
+18 -17
View File
@@ -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)