MovingCircleLinks computes the cut links of each translating circle from an
FP64 {cx, cy, r} table over its swept band (same q/hit/clamp/fallback order
as build_cut_links, explicit _rn operations) and applies the unchanged
apply_bouzidi_link with spin wall velocity. obs keeps its contract; a
separate per-body diagnostics buffer records CME, the Wen term u_w*dm,
link_dm and the link count. Moving tables are built at initialize and
attached to the stepper, which then launches the kernel before the static
curved kernel; until then direct and run steps stay blocked. Displacement
is disabled: set_body offset/vx/vy on a translating circle raises before
mutation. Contract section 10a documents S2a and the S3 pending rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
597 lines
26 KiB
Python
597 lines
26 KiB
Python
"""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))
|
|
|
|
|
|
@pytest.mark.parametrize("second_x,error", [(14., "unique cut-link writers"),
|
|
(17., "active fluid receivers")])
|
|
def test_passive_overlap_rejected_before_publishing_lists(second_x, error):
|
|
cfg = LBMConfig(nx=32, ny=24)
|
|
cfg.validate()
|
|
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
|
|
for x in (14., second_x):
|
|
bodies.add(obj(x=x, y=12., r=4.3))
|
|
bodies.add(obj(x=4., y=4., r=2., is_sensor=True))
|
|
counts = np.full(bodies.count, 91, np.int32)
|
|
hits = []
|
|
flags = bodies.build_flags(np.full(cfg.nx * cfg.ny, FLUID, np.uint16))
|
|
for domain_flags in (flags, None):
|
|
with pytest.raises(ValueError, match=error):
|
|
bodies.build_compact_lists_for(bodies.objects, domain_flags,
|
|
sensor_cell_counts_out=counts, hits_out=hits)
|
|
np.testing.assert_array_equal(counts, 91)
|
|
assert hits == []
|
|
|
|
|
|
@pytest.mark.parametrize("centers", [((10., 12.), (22., 12.)),
|
|
((10.5, 12.), (19.5, 12.))])
|
|
def test_separated_passive_bodies_keep_valid_writers(centers):
|
|
# The second case has a subcell physical gap with a resolved fluid column.
|
|
cfg = LBMConfig(nx=32, ny=24)
|
|
cfg.validate()
|
|
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
|
|
for x, y in centers:
|
|
bodies.add(obj(x=x, y=y, r=4.3))
|
|
flags = bodies.build_flags(np.full(cfg.nx * cfg.ny, FLUID, np.uint16))
|
|
indices, directions, _, ids, *_ = bodies.build_compact_lists(flags)
|
|
assert set(ids) == {0, 1}
|
|
assert np.all(flags[indices] & FLUID) and not np.any(flags[indices] & SOLID)
|
|
assert len(np.unique(indices.astype(np.uint64) * cfg.nq + directions)) == len(indices)
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["sync", "initialize", "runtime_edit"])
|
|
@pytest.mark.parametrize("second_x", [5., 6.])
|
|
def test_passive_overlap_failure_keeps_live_state(device, entry, second_x):
|
|
from CelerisLab.simulation import Simulation
|
|
field = make_field(device)
|
|
bodies = manager(field, [obj(), obj(x=9., y=3., r=1., is_sensor=True)])
|
|
bodies.sync_to_gpu(field)
|
|
overlapping = obj(x=second_x)
|
|
if entry == "runtime_edit":
|
|
bodies.stage_add(overlapping)
|
|
else:
|
|
bodies.add(overlapping)
|
|
objects = bodies.objects
|
|
flags, curved, counts = field.flag.copy(), field.curved.fluid_idx.copy(), bodies.sensor_cell_counts.copy()
|
|
action = bodies.action.copy()
|
|
buffers = [(block, bytes(block.data), block.freed) for block in device.blocks]
|
|
writes = device.writes
|
|
sim = SimpleNamespace(bodies=bodies, field=field, lbm_cfg=field.cfg,
|
|
_initialized=entry == "runtime_edit",
|
|
_assert_allocation_contract=lambda: None,
|
|
stream=SimpleNamespace(synchronize=device.synchronize),
|
|
stepper=SimpleNamespace(blocked=None))
|
|
for name in ("_require_usable", "_moving_bodies", "_reject_moving_bodies",
|
|
"_validate_moving_bodies", "_attach_moving"):
|
|
setattr(sim, name, getattr(Simulation, name).__get__(sim))
|
|
with pytest.raises(ValueError, match="active fluid receivers|unique cut-link writers"):
|
|
if entry == "sync":
|
|
bodies.sync_to_gpu(field)
|
|
elif entry == "initialize":
|
|
Simulation.initialize(sim)
|
|
else:
|
|
Simulation.sync_bodies(sim)
|
|
assert bodies.objects == objects
|
|
assert bodies.has_pending_edit() == (entry == "runtime_edit")
|
|
assert sim._initialized == (entry == "runtime_edit")
|
|
assert device.writes == writes and len(device.blocks) == len(buffers)
|
|
assert all(bytes(block.data) == data and block.freed == freed for block, data, freed in buffers)
|
|
np.testing.assert_array_equal(field.flag, flags)
|
|
np.testing.assert_array_equal(field.curved.fluid_idx, curved)
|
|
np.testing.assert_array_equal(bodies.sensor_cell_counts, counts)
|
|
np.testing.assert_array_equal(bodies.action, action)
|
|
|
|
|
|
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)
|