Files

1066 lines
49 KiB
Diff

--- a/src/CelerisLab/body/__init__.py
+++ b/src/CelerisLab/body/__init__.py
@@ -2,7 +2,7 @@
"""
Body / object management for immersed and rigid objects.
"""
-from .objects import SimObject, ObjectState, ObjectControl
+from .objects import SimObject, ObjectState, ObjectControl, SurfaceActuation
from importlib import import_module
from .registry import BodyRegistry
from .sync_plan import BodySyncPlan
@@ -11,7 +11,7 @@
from .coupling.soa_packer import pack_cut_links_to_soa, pack_sensor_to_soa
__all__ = [
- "SimObject", "ObjectState", "ObjectControl",
+ "SimObject", "ObjectState", "ObjectControl", "SurfaceActuation",
"ObjectManager",
"BodyRegistry",
"BodySyncPlan",
--- a/src/CelerisLab/body/manager.py
+++ b/src/CelerisLab/body/manager.py
@@ -108,6 +108,7 @@
# -- Registry delegation -------------------------------------------------
def add(self, obj: SimObject) -> int:
"""Register an object and return its id."""
+ obj.validate_surface_actuation(self.cfg)
body_id = self._registry.add(obj)
self._resize_buffers()
return body_id
@@ -160,6 +161,7 @@
The object is not added to the formal registry until ``commit_pending()``
is called. This implicitly activates the edit window.
"""
+ obj.validate_surface_actuation(self.cfg)
self._edit_active = True
self._pending_add.append(obj)
@@ -201,7 +203,8 @@
new = SimObject(obj_id=oid, geometry=obj.geometry,
center=obj.center, radius=obj.radius,
is_sensor=obj.is_sensor,
- is_force_region=obj.is_force_region)
+ is_force_region=obj.is_force_region,
+ surface_actuation=obj.surface_actuation)
new.state = obj.state
new.control = obj.control
return new
@@ -306,6 +309,7 @@
cl_host = plan.curved_host
field.curved.assign_host(*cl_host)
+ field.curved.assign_surface(plan.next_objects, self.cfg, plan.next_flags)
s_cells, s_ids = plan.sensor_host
field.sensors.assign_host(s_cells, s_ids)
fr_cells, fr_ids = plan.force_region_host
@@ -392,6 +396,7 @@
ex, ey = D2Q9_EX, D2Q9_EY
for obj in objects:
+ obj.validate_surface_actuation(self.cfg)
if (not obj.is_sensor) and (not obj.is_force_region) and hasattr(obj, 'get_curved_list'):
(
fluid_idx, dirs, q_vals, body_ids,
@@ -447,6 +452,13 @@
r_fr_cells = np.concatenate(fr_cells) if fr_cells else np.zeros(0, dtype=np.uint32)
r_fr_ids = np.concatenate(fr_ids) if fr_ids else np.zeros(0, dtype=np.int32)
+ if any(obj.surface_actuation is not None for obj in objects):
+ # Reject unresolved apertures/ownership before sync or restart mutates state.
+ from ..lbm.curved_links import CurvedLinkSoA
+ links = CurvedLinkSoA()
+ links.assign_host(r_idx, r_dir, r_q, r_bid, r_rx, r_ry, r_rz, r_fallback)
+ links.assign_surface(objects, self.cfg, domain_flags)
+
return (
r_idx, r_dir, r_q, r_bid,
r_rx, r_ry, r_rz, r_fallback,
@@ -466,21 +478,24 @@
"""
self._telemetry_field = field
- if rebuild_flags:
- field.flag = self.build_flags(field.build_channel_flags())
- field.upload_flags()
+ next_flags = self.build_flags(field.build_channel_flags()) if rebuild_flags else field.flag
(
cl_fluid_idx, cl_dir, cl_q, cl_body_id,
cl_rx, cl_ry, cl_rz, cl_fallback_class,
sensor_cells, sensor_obj_id,
fr_cells, fr_obj_id,
- ) = self.build_compact_lists(domain_flags=field.flag)
+ ) = self.build_compact_lists(domain_flags=next_flags)
+
+ if rebuild_flags:
+ field.flag = next_flags
+ field.upload_flags()
field.curved.assign_host(
cl_fluid_idx, cl_dir, cl_q, cl_body_id,
cl_rx, cl_ry, cl_rz, cl_fallback_class,
)
+ field.curved.assign_surface(self.objects, self.cfg, field.flag)
field.sensors.assign_host(sensor_cells, sensor_obj_id)
field.force_regions.assign_host(fr_cells, fr_obj_id)
@@ -595,6 +610,10 @@
def _upload_action_async(self, stream: cuda.Stream) -> None:
"""Async H2D of host action array to action_gpu."""
+ for obj in self.objects:
+ obj.validate_surface_actuation(self.cfg)
+ if obj.surface_actuation is not None and self.action[(obj.obj_id+1)*3*self.cfg.dim-1] != 0:
+ raise ValueError("Surface actuation cannot share the rotation action")
if self.action_gpu is None or self.action.size == 0:
return
if self._action_pinned is None or self._action_pinned.size != self.action.size:
@@ -712,6 +731,8 @@
The GPU action buffer is updated at the next ``run()`` call.
"""
self._validate_body_id(body_id)
+ obj = self.get(body_id)
+ obj.validate_surface_actuation(self.cfg, dict(vars(obj.state), omega=omega))
dim = self.cfg.dim if self.cfg else 2
slot = 3 * dim
base = body_id * slot
--- a/src/CelerisLab/body/objects.py
+++ b/src/CelerisLab/body/objects.py
@@ -15,6 +15,7 @@
from __future__ import annotations
from dataclasses import dataclass
+import math
from typing import Optional, Tuple
import numpy as np
@@ -53,6 +54,37 @@
mode: int = 0 # 0=force, 1=velocity, 2=displacement
+@dataclass(frozen=True)
+class SurfaceActuation:
+ """Fixed circular aperture: angles in radians, velocity in lattice units.
+
+ ``angle`` is measured counterclockwise from +x; ``half_width`` is the arc
+ half-angle. Positive normal_velocity blows from solid into fluid. The C1
+ profile (1-s*s)^2 uses the true hit angle, not a fluid-node aperture mask.
+ This immutable specification is set before initialization; no moving nozzle.
+ """
+ angle: float
+ half_width: float
+ normal_velocity: float
+
+ def __post_init__(self):
+ if not all(math.isfinite(v) for v in (self.angle, self.half_width, self.normal_velocity)):
+ raise ValueError("Surface actuation values must be finite")
+ if not 0 < self.half_width <= math.pi or abs(self.normal_velocity) >= 1:
+ raise ValueError("Require 0 < half_width <= pi and abs(normal_velocity) < 1")
+
+ def project(self, rx, ry):
+ """Project stored true hit-center vectors; returns velocity and aperture mask."""
+ radius = np.hypot(rx, ry)
+ if np.any(radius <= 0):
+ raise ValueError("Surface normal requires nonzero hit-center distance")
+ delta = np.arctan2(np.sin(np.arctan2(ry, rx)-self.angle),
+ np.cos(np.arctan2(ry, rx)-self.angle))
+ aperture = np.abs(delta) < self.half_width
+ speed = self.normal_velocity*np.maximum(0., 1-(delta/self.half_width)**2)**2
+ return np.stack((speed*rx/radius, speed*ry/radius), axis=-1).astype(np.float32), aperture
+
+
# ---------------------------------------------------------------------------
# SimObject container
# ---------------------------------------------------------------------------
@@ -77,6 +109,7 @@
radius: float = 0.0,
is_sensor: bool = False,
is_force_region: bool = False,
+ surface_actuation: Optional[SurfaceActuation] = None,
) -> None:
self.obj_id = obj_id
self.center = center if center is not None else (0.0, 0.0)
@@ -84,6 +117,10 @@
self._geometry = geometry
self._is_sensor = is_sensor
self._is_force_region = is_force_region
+ self._surface_actuation = (SurfaceActuation(**surface_actuation)
+ if isinstance(surface_actuation, dict) else surface_actuation)
+ if self._surface_actuation is not None and not isinstance(self._surface_actuation, SurfaceActuation):
+ raise TypeError("surface_actuation must be a SurfaceActuation or dict")
c = self.center
z0 = float(c[2]) if len(c) > 2 else 0.0
@@ -91,6 +128,22 @@
x=float(c[0]), y=float(c[1]), z=z0,
)
self.control = ObjectControl()
+
+ @property
+ def surface_actuation(self):
+ return self._surface_actuation
+
+ def validate_surface_actuation(self, cfg, state=None):
+ if self.surface_actuation is None:
+ return
+ from .geometry.circle import CircleGeometry
+ if (type(self.geometry) is not CircleGeometry or self.is_sensor or self.is_force_region
+ or cfg is None or not cfg.is_d2q9 or cfg.store_precision != "FP32"
+ or cfg.streaming != "double_buffer" or cfg.ddf_shifting):
+ raise ValueError("Surface actuation requires a circle and D2Q9/FP32/unshifted/double_buffer")
+ values = vars(self.state) if state is None else state
+ if any(values.get(k, 0.) != 0 for k in ("vx", "vy", "omega")):
+ raise ValueError("Surface actuation requires a stationary circle")
@property
def geometry(self) -> Geometry:
--- a/src/CelerisLab/common/checkpoint.py
+++ b/src/CelerisLab/common/checkpoint.py
@@ -24,7 +24,7 @@
from ..lbm.descriptors import FLUID, SOLID, OBSTACLE, SENSOR_FLAG, FRC_REGION
# File format version — bump on incompatible layout changes.
-CKPT_VERSION = 3
+CKPT_VERSION = 4
_SUPPORTED_STORE_PRECISIONS = ("FP32", "FP16S")
@@ -130,10 +130,20 @@
if not np.array_equal(flag_buf, expected_flags):
raise ValueError("Checkpoint flags do not match current object/domain geometry")
bodies.build_compact_lists_for(bodies.objects, domain_flags=expected_flags)
+ applied_action = None
+ if bodies.action_gpu is not None:
+ applied_action = np.empty_like(bodies.action)
+ cuda.memcpy_dtoh(applied_action, bodies.action_gpu)
+ for obj in bodies.objects:
+ if (obj.surface_actuation is not None
+ and applied_action[(obj.obj_id + 1) * 3 * lbm_cfg.dim - 1] != 0):
+ raise ValueError("Surface actuation requires zero applied omega")
with h5py.File(path, "w") as hf:
# Version and metadata
- hf.attrs["ckpt_version"] = CKPT_VERSION
+ # Older readers must not silently restart a driven surface as a wall.
+ hf.attrs["ckpt_version"] = (CKPT_VERSION if any(
+ obj.surface_actuation is not None for obj in bodies.objects) else 3)
hf.attrs["celerislab_version"] = __import__(
"CelerisLab").__version__
hf.attrs["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S")
@@ -156,11 +166,13 @@
compression_opts=4)
hf.attrs["geometry_json"] = json.dumps(geometry)
+ hf.attrs["surface_actuation_json"] = json.dumps([
+ asdict(obj.surface_actuation) if obj.surface_actuation is not None else None
+ for obj in bodies.objects
+ ])
hf.attrs["runtime_params_json"] = json.dumps(field._runtime_params)
hf.attrs["obs_accum_steps"] = bodies._obs_accum_steps
- if bodies.action_gpu is not None:
- applied_action = np.empty_like(bodies.action)
- cuda.memcpy_dtoh(applied_action, bodies.action_gpu)
+ if applied_action is not None:
hf.create_dataset("applied_action", data=applied_action)
if bodies.obs_gpu is not None:
obs = np.empty(bodies.obs_total_floats, dtype=np.float32)
@@ -191,7 +203,8 @@
store_precision must match the checkpoint. Other parameters
(collision, LES, …) may differ for parameter-study restarts.
v3 restores the saved domain boundaries; v1/v2 retain their default-channel
- validation. Object geometry and roles must match in every version.
+ validation. v4 also requires matching static surface actuation. Object
+ geometry and roles must match in every version.
Args:
path: HDF5 checkpoint file path.
@@ -206,7 +219,7 @@
with h5py.File(path, "r") as hf:
# Version check
ver = hf.attrs.get("ckpt_version", 0)
- if ver not in (1, 2, CKPT_VERSION):
+ if ver not in (1, 2, 3, CKPT_VERSION):
raise ValueError(
f"Checkpoint version mismatch: file={ver}, "
f"expected={CKPT_VERSION}. "
@@ -253,6 +266,14 @@
if (len(obj_data) != bodies.count
or [od.get("obj_id") for od in obj_data] != list(range(bodies.count))):
raise ValueError("Checkpoint object IDs must match the dense current registry")
+ surface_specs = [asdict(obj.surface_actuation) if obj.surface_actuation is not None else None
+ for obj in bodies.objects]
+ if ver >= 4 and "surface_actuation_json" not in hf.attrs:
+ raise ValueError("Checkpoint is missing surface actuation specification")
+ saved_surface_specs = json.loads(hf.attrs.get(
+ "surface_actuation_json", json.dumps([None] * bodies.count)))
+ if saved_surface_specs != surface_specs:
+ raise ValueError("Checkpoint surface actuation mismatch")
domain_flags = None
if ver >= 3:
if "domain_flag" not in hf:
@@ -319,6 +340,11 @@
modes = [int(od.get("control", {}).get("mode", 0)) for od in obj_data]
if not all(np.isfinite(value) for record in states + controls for value in record.values()):
raise ValueError("Checkpoint object state/control must be finite")
+ for obj, state in zip(bodies.objects, states):
+ obj.validate_surface_actuation(lbm_cfg, state)
+ if (obj.surface_actuation is not None and applied_action is not None
+ and applied_action[(obj.obj_id + 1) * 3 * lbm_cfg.dim - 1] != 0):
+ raise ValueError("Checkpoint surface actuation requires zero applied omega")
for obj, state, control, mode in zip(bodies.objects, states, controls, modes):
for key, value in state.items():
--- a/src/CelerisLab/lbm/curved_links.py
+++ b/src/CelerisLab/lbm/curved_links.py
@@ -74,6 +74,48 @@
count: int = 0
capacity: int = 0
+
+ # Optional fixed surface velocity derived from true hit-center columns.
+ surface_velocity: Optional[np.ndarray] = None
+ aperture: Optional[np.ndarray] = None
+ surface_velocity_gpu: Optional[cuda.DeviceAllocation] = None
+ link_exchange_gpu: Optional[cuda.DeviceAllocation] = None
+
+ def assign_surface(self, objects, cfg, flags):
+ """Keep the original eight-column geometry/checkpoint contract unchanged."""
+ enabled = [obj for obj in objects if obj.surface_actuation is not None]
+ self.surface_velocity = self.aperture = None
+ if not enabled:
+ return
+ from .descriptors import FLUID, SOLID
+ if np.any((flags[self.fluid_idx] & FLUID) == 0) or np.any(flags[self.fluid_idx] & SOLID):
+ raise ValueError("Surface cut links must have active fluid receivers")
+ if len(np.unique(self.fluid_idx.astype(np.uint64)*cfg.nq+self.dir)) != len(self.dir):
+ raise ValueError("Surface boundary requires unique cut-link writers")
+ self.surface_velocity = np.zeros((len(self.dir), 2), np.float32)
+ self.aperture = np.zeros(len(self.dir), bool)
+ for obj in enabled:
+ obj.validate_surface_actuation(cfg)
+ selected = self.body_id == obj.obj_id
+ velocity, aperture = obj.surface_actuation.project(self.rx[selected], self.ry[selected])
+ if not aperture.any():
+ raise ValueError("Surface aperture hits no resolved cut links")
+ self.surface_velocity[selected] = velocity
+ self.aperture[selected] = aperture
+
+ def surface_exchange(self, body_id):
+ """Latest boundary step's (Fx,Fy,Tz), split by aperture; not nozzle thrust.
+
+ Caller synchronizes the CUDA stream/context. Rebuild/restart clears this
+ diagnostic; it is not the time-accumulated rigid-body observation.
+ """
+ if self.link_exchange_gpu is None:
+ raise RuntimeError("No surface actuation diagnostic is allocated")
+ values = np.empty((self.count, 3), np.float32)
+ cuda.memcpy_dtoh(values, self.link_exchange_gpu)
+ body = self.body_id == body_id
+ return {name: values[body & mask].sum(axis=0, dtype=np.float64)
+ for name, mask in (("wall", ~self.aperture), ("aperture", self.aperture))}
def assign_host(
self,
@@ -144,6 +186,7 @@
raise ValueError(
"CurvedLinkSoA column 'fallback_class' must use classes {0, 1}"
)
+ self.surface_velocity = self.aperture = None
def upload(self, stream: Optional[cuda.Stream] = None) -> None:
"""Upload host columns to GPU with capacity-based allocation reuse.
@@ -155,6 +198,9 @@
n = int(len(self.fluid_idx))
self.count = n
if n == 0:
+ _free_alloc(self.surface_velocity_gpu)
+ _free_alloc(self.link_exchange_gpu)
+ self.surface_velocity_gpu = self.link_exchange_gpu = None
return
if n > self.capacity or self.fluid_idx_gpu is None:
@@ -190,6 +236,14 @@
(self.rz_gpu, self.rz),
(self.fallback_class_gpu, self.fallback_class),
], stream)
+ _free_alloc(self.surface_velocity_gpu)
+ _free_alloc(self.link_exchange_gpu)
+ self.surface_velocity_gpu = self.link_exchange_gpu = None
+ if self.surface_velocity is not None:
+ self.surface_velocity_gpu = cuda.mem_alloc(self.surface_velocity.nbytes)
+ self.link_exchange_gpu = cuda.mem_alloc(n*3*4)
+ _upload_columns([(self.surface_velocity_gpu, self.surface_velocity),
+ (self.link_exchange_gpu, np.zeros((n, 3), np.float32))], stream)
def free_gpu_columns(self) -> None:
"""Release device columns only (host arrays unchanged)."""
@@ -201,6 +255,9 @@
_free_alloc(self.ry_gpu)
_free_alloc(self.rz_gpu)
_free_alloc(self.fallback_class_gpu)
+ _free_alloc(self.surface_velocity_gpu)
+ _free_alloc(self.link_exchange_gpu)
+ self.surface_velocity_gpu = self.link_exchange_gpu = None
self.fluid_idx_gpu = None
self.dir_gpu = None
self.q_gpu = None
--- a/src/CelerisLab/lbm/kernels/boundary/curved_boundary.cuh
+++ b/src/CelerisLab/lbm/kernels/boundary/curved_boundary.cuh
@@ -82,7 +82,7 @@
float Uw, float Vw,
float rx, float ry,
unsigned char fallback_class,
- float* __restrict__ obs, int body_id)
+ float* __restrict__ obs, int body_id, float* link_exchange = nullptr)
{
const unsigned int dir_opp = (unsigned int)opp_dir((int)dir);
@@ -120,7 +120,7 @@
// corrected incoming population into the adjacent fluid node.
store_ddf(fi, index_f(k_s, dir_opp), f_reflected);
- if (obs != nullptr) {
+ if (obs != nullptr || link_exchange != nullptr) {
#if USE_DDF_SHIFTING
const float w_i = (float)d_w[dir];
const float fx = (float)d_cx[dir] * ((f_toward + w_i) + (f_reflected + w_i));
@@ -129,10 +129,15 @@
const float fx = (float)d_cx[dir] * (f_toward + f_reflected);
const float fy = (float)d_cy[dir] * (f_toward + f_reflected);
#endif
- atomicAdd(&obs[obs_force_index(body_id, 0)], fx);
- atomicAdd(&obs[obs_force_index(body_id, 1)], fy);
const float tz = rx * fy - ry * fx;
- atomicAdd(&obs[obs_torque_index(body_id, 0)], tz);
+ if (link_exchange != nullptr) {
+ link_exchange[0] = fx; link_exchange[1] = fy; link_exchange[2] = tz;
+ }
+ if (obs != nullptr) {
+ atomicAdd(&obs[obs_force_index(body_id, 0)], fx);
+ atomicAdd(&obs[obs_force_index(body_id, 1)], fy);
+ atomicAdd(&obs[obs_torque_index(body_id, 0)], tz);
+ }
}
}
#endif // DIM == 2
--- a/src/CelerisLab/lbm/kernels/step/aux_kernels.cu
+++ b/src/CelerisLab/lbm/kernels/step/aux_kernels.cu
@@ -43,7 +43,8 @@
const int* cl_body_id,
const float* action,
float* obs,
- unsigned int n_curved)
+ unsigned int n_curved,
+ const float* surface_velocity = nullptr, float* link_exchange = nullptr)
{
unsigned int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid >= n_curved) return;
@@ -58,10 +59,16 @@
const float omega = action_omega(action, bid);
#if DIM == 2
- const float Uw = -omega * ry;
- const float Vw = omega * rx;
+ float Uw = -omega * ry;
+ float Vw = omega * rx;
+ if (surface_velocity != nullptr) {
+ // Independent prescribed fluid velocity on fixed geometry.
+ const float u = surface_velocity[2*tid], v = surface_velocity[2*tid+1];
+ if (u != 0.0f || v != 0.0f) { Uw += u; Vw += v; }
+ }
(void)cl_rz;
- apply_bouzidi_link(dir, q, k_f, fi, Uw, Vw, rx, ry, fallback_class, obs, bid);
+ apply_bouzidi_link(dir, q, k_f, fi, Uw, Vw, rx, ry, fallback_class, obs, bid,
+ link_exchange == nullptr ? nullptr : link_exchange + 3*tid);
#elif DIM == 3
const float rz = cl_rz[tid];
const float Uw = -omega * ry;
--- a/src/CelerisLab/lbm/stepper.py
+++ b/src/CelerisLab/lbm/stepper.py
@@ -153,6 +153,7 @@
cl.rx_gpu, cl.ry_gpu, cl.rz_gpu, cl.fallback_class_gpu, cl.body_id_gpu,
action_gpu, obs_gpu,
np.uint32(f.n_curved),
+ cl.surface_velocity_gpu or np.uintp(0), cl.link_exchange_gpu or np.uintp(0),
block=(tpb, 1, 1), grid=grid_c,
**launch_kw,
)
--- a/src/CelerisLab/simulation.py
+++ b/src/CelerisLab/simulation.py
@@ -98,7 +98,7 @@
# -- Object management ---------------------------------------------------
def add_body(self, type: str = "circle", center=None,
- radius: float = 0.0, **kwargs) -> int:
+ radius: float = 0.0, *, surface_actuation=None, **kwargs) -> int:
"""Add a simulation body. Returns body_id.
Before ``initialize()`` the object is added to the formal registry
@@ -110,6 +110,10 @@
(future: ``"polygon"``, ``"mesh"``).
center: (x, y) center coordinates (lattice units).
radius: body radius (lattice units).
+ surface_actuation: Fixed circle aperture dict/SurfaceActuation:
+ angle, half_width (radians), normal_velocity (positive outward).
+ D2Q9/FP32/unshifted/DB only; set before initialization or stage
+ a new body. No runtime motion or amplitude setter.
**kwargs: passed to the geometry constructor.
Returns:
@@ -118,6 +122,8 @@
if self.lbm_cfg.y_wall_bc == "on_site_velocity":
raise ValueError("on_site_velocity currently requires an object-free domain")
type_lower = str(type).strip().lower()
+ if surface_actuation is not None and type_lower != "circle":
+ raise ValueError("Surface actuation is only supported on circles")
if type_lower == "circle":
from .body.geometry.circle import CircleGeometry
@@ -125,7 +131,7 @@
geom = CircleGeometry(cx, cy, radius)
obj = SimObject(obj_id=-1, geometry=geom,
center=center, radius=radius,
- is_sensor=False)
+ is_sensor=False, surface_actuation=surface_actuation)
elif type_lower == "sensor":
from .body.geometry.circle import CircleGeometry
cx, cy = float(center[0]), float(center[1])
@@ -189,6 +195,8 @@
center = tuple(spec.get("center", ()))
radius = float(spec.get("radius", 0.0))
omega = spec.get("omega", None)
+ if spec.get("surface_actuation") is not None and obj_type != "cylinder":
+ raise ValueError("Surface actuation is only supported on cylinders in body config")
if obj_type == "":
raise ValueError(
@@ -200,9 +208,10 @@
)
if obj_type == "cylinder":
- body_id = self.add_body("circle", center=center, radius=radius)
+ body_id = self.add_body("circle", center=center, radius=radius,
+ surface_actuation=spec.get("surface_actuation"))
if omega is not None:
- self.bodies.get(body_id).state.omega = float(omega)
+ self.bodies.set_body_state(body_id, omega=float(omega))
elif obj_type == "sensor":
self.add_body("sensor", center=center, radius=radius)
else:
@@ -225,6 +234,10 @@
vy: translation velocity y (future). ``None`` = no change.
"""
obj = self.bodies.get(id)
+ proposed = dict(vars(obj.state))
+ proposed.update({k: float(v) for k, v in (("omega", omega), ("vx", vx), ("vy", vy))
+ if v is not None})
+ obj.validate_surface_actuation(self.lbm_cfg, proposed)
changed = False
if omega is not None:
obj.state.omega = float(omega)
@@ -290,6 +303,18 @@
if self.bodies.obs_pinned is None:
raise RuntimeError("No obs buffer. Call run() first.")
return self.bodies.read_sensor(id, normalize=normalize)
+
+ @context_bound
+ def read_surface_exchange(self, id: int):
+ """Latest step's wall/aperture (Fx,Fy,Tz) exchange, not total nozzle force.
+
+ Unlike read_body this is not accumulated/time-averaged. It resets after
+ geometry sync or checkpoint load; advance before interpreting it.
+ """
+ if self.bodies.get(id).surface_actuation is None:
+ raise ValueError("Body has no surface actuation")
+ self.stream.synchronize()
+ return self.field.curved.surface_exchange(id)
@context_bound
def read_body(self, id: int, *,
--- /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()
--- /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