Files
CelerisLab/tests/unit/test_state_regressions.py
T
ClaudeandClaude Opus 5.5 0f8a428f07 lbm: fixed-centre moving-circle cut links (S2a)
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>
2026-10-30 07:35:02 +00:00

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)