1066 lines
49 KiB
Diff
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
|