Files
CelerisLab/src/CelerisLab/body/manager.py
T
ClaudeandClaude Opus 5.5 d318911221 cuda: compiler_v2 module becomes compiler
The package already exposed it as CelerisLab.cuda.compiler; the module now
has that name and every tip caller, doc and current-tree test imports it
directly (no alias).

Historical-revision tooling is not edited: the Taylor-Green harness
(run_case.py, hash-pinned check_projected_pairs.py, assess receipts) only
runs against a135252, whose package has cuda/compiler_v2.py, and the J
campaign runners and dated docs describe past trees. Its two current-tree
tests now import that package from git (historical_package) instead of the
tip, and the kernel-root test first checks the accepted case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:15:50 +00:00

864 lines
35 KiB
Python

# CelerisLab/body/manager.py
"""
ObjectManager -- batch management of SimObjects.
Responsibilities:
- Add / remove / query objects (delegated to ``BodyRegistry``)
- Pending edit state for runtime body topology changes
- Build merged flag masks and compact cut-link / sensor lists
- Allocate packed telemetry ``obs_gpu`` + pagelocked mirror ``obs_pinned``
- Sync geometry to :class:`~CelerisLab.lbm.field.LBMField`
Design::
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler.obs_layout`.
Pending edits are lightweight: only the "intent" (add / remove) is stored.
All derived quantities (flags, compact lists, GPU buffers) are built at
``sync_bodies()`` time from the formal registry + pending edits.
"""
from __future__ import annotations
from typing import Dict, List, NamedTuple, Optional, TYPE_CHECKING
import numpy as np
import pycuda.driver as cuda
from .objects import SimObject
from .registry import BodyRegistry
from .preprocess.flag_overlay import merge_flag_masks, validate_cut_link_ownership
from ..cuda.compiler import obs_layout
from ..cuda.obs_layout import ObsLayout
from ..lbm.descriptors import (
FLUID, SOLID,
D2Q9_EX,
D2Q9_EY,
D3Q19_EX,
D3Q19_EY,
D3Q19_EZ,
)
if TYPE_CHECKING:
from .sync_plan import BodySyncPlan
class BodyTelemetry(NamedTuple):
"""Unified telemetry for one body, read from the pinned obs buffer.
After ``run(sync_obs=True)``, all fields are populated from GPU results.
``sensor`` is a dim-length array — zeros for non-sensor bodies.
"""
force: np.ndarray
torque: np.ndarray
sensor: np.ndarray
class ObjectManager:
"""Central registry for all simulation objects.
Frozen telemetry layout::
``obs`` packs force, torque, then sensor segments.
Segment offsets and sizes come from :func:`obs_layout(dim, count)`.
Device macros ``OBS_*`` in ``config_obs.h`` must match ``obs_layout(dim, count)``.
"""
def __init__(self, nx: int, ny: int, nz: int, nq: int, cfg=None):
self.nx = nx
self.ny = ny
self.nz = nz
self.nq = nq
self.cfg = cfg
self._registry = BodyRegistry()
self.jets: list = [] # scene-level Jets; selection is spatial, not per body
self.action_gpu: Optional[cuda.DeviceAllocation] = None
self.obs_gpu: Optional[cuda.DeviceAllocation] = None
self._action_nbytes: int = 0
self._obs_alloc_nbytes: int = 0
self.action = np.zeros(0, dtype=np.float32)
self._action_pinned: Optional[np.ndarray] = None
self.obs_pinned: Optional[np.ndarray] = None
self.obs_force_view: Optional[np.ndarray] = None
self.obs_torque_view: Optional[np.ndarray] = None
self.obs_sensor_view: Optional[np.ndarray] = None
self.sensor_cell_counts = np.zeros(0, dtype=np.int32)
self.slot_stride_floats: int = 0
self.torque_stride_floats: int = 0
self.torque_components: int = 0
self.torque0_floats: int = 0
self.sensor0_floats: int = 0
self.obs_total_floats: int = 0
self.obs_nbytes: int = 0
# -- Accumulated step count (for obs time-normalization) --------------
self._obs_accum_steps: int = 0
self._telemetry_field: Optional[object] = None
# -- Pending edit state (runtime body topology sync) -------------------
self._edit_active: bool = False
self._pending_add: List[SimObject] = []
self._pending_remove: set[int] = set()
# Ensure host action buffer is non-empty so CUDA alloc never sees 0 bytes
# when bodies.count == 0 (matches _resize_buffers sizing for n=0).
self._resize_buffers()
# -- 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
def remove(self, obj_id: int):
"""Remove immediately and renumber remaining IDs, as runtime sync does.
Previously returned numeric IDs are invalidated by this operation.
"""
self._registry.remove(obj_id)
remaining = self.objects
self._registry.clear()
for obj in remaining:
self._registry.add(obj)
self._resize_buffers()
self._refresh_action_from_objects()
def get(self, obj_id: int) -> SimObject:
return self._registry.get(obj_id)
@property
def objects(self) -> List[SimObject]:
return self._registry.objects
@property
def count(self) -> int:
return self._registry.count
# -- Buffer sizing -------------------------------------------------------
def _resize_buffers(self):
n = self.count
dim = self.cfg.dim if self.cfg else 2
action_slot_floats = 3 * dim
new_action = np.zeros(max(n * action_slot_floats, 1), dtype=np.float32)
if self.action.size > 0:
copy_n = min(self.action.size, new_action.size)
new_action[:copy_n] = self.action[:copy_n]
self.action = new_action
new_sensor_counts = np.zeros(max(n, 1), dtype=np.int32)
if self.sensor_cell_counts.size > 0:
copy_n = min(self.sensor_cell_counts.size, new_sensor_counts.size)
new_sensor_counts[:copy_n] = self.sensor_cell_counts[:copy_n]
self.sensor_cell_counts = new_sensor_counts
# -- Pending edit state (runtime body topology sync) ---------------------
def stage_add(self, obj: SimObject) -> None:
"""Stage an object for addition at the next ``sync_bodies()``.
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)
def stage_remove(self, obj_id: int) -> None:
"""Stage removal of a formal object at the next ``sync_bodies()``.
Args:
obj_id: Id of a currently registered formal object.
Raises:
IndexError: If *obj_id* is not in the formal registry.
"""
self._validate_body_id(obj_id)
self._edit_active = True
self._pending_remove.add(obj_id)
def has_pending_edit(self) -> bool:
"""Return True if there are uncommitted staged edits."""
return self._edit_active and bool(self._pending_add or self._pending_remove)
def clear_pending_edits(self) -> None:
"""Discard all staged edits without applying them."""
self._pending_add.clear()
self._pending_remove.clear()
self._edit_active = False
# -- Sync plan construction (runtime body topology sync) -----------------
def build_next_objects(self) -> List[SimObject]:
"""Merge formal objects with pending edits into a new object list.
Formal objects whose id is in ``_pending_remove`` are excluded.
Objects in ``_pending_add`` are appended. All returned objects
receive consecutive ``obj_id = 0..n-1``.
Returns:
New list of SimObject copies with reassigned ids.
"""
def _copy(obj, oid):
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,
surface_actuation=obj.surface_actuation)
new.state = obj.state
new.control = obj.control
return new
result: List[SimObject] = []
next_id = 0
for obj in self._registry.objects:
if obj.obj_id in self._pending_remove:
continue
result.append(_copy(obj, next_id))
next_id += 1
for obj in self._pending_add:
result.append(_copy(obj, next_id))
next_id += 1
return result
def build_sync_plan(self, field) -> 'BodySyncPlan':
"""Construct a full topology transition plan from pending edits.
Args:
field: ``LBMField`` instance (used for old flags and channel base).
Returns:
:class:`BodySyncPlan` describing the transition.
"""
from .sync_plan import BodySyncPlan
from ..lbm.descriptors import FLUID, OBSTACLE
old_flags = field.flag.copy()
next_objects = self.build_next_objects()
next_count = len(next_objects)
# Build new flags from clean channel base + new object set
base_flags = field.build_channel_flags()
next_flags = self.build_flags_for(
next_objects, base_flags,
nx=self.nx, ny=self.ny, nz=self.nz,
)
# Build compact lists for new object set
new_sensor_counts = np.zeros(max(next_count, 1), dtype=np.int32)
hits = []
(
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_for(
next_objects, domain_flags=next_flags,
sensor_cell_counts_out=new_sensor_counts, hits_out=hits,
)
# Compute change masks
old_is_solid = (old_flags & 0x0002) != 0
new_is_solid = (next_flags & 0x0002) != 0
old_is_obstacle = (old_flags & OBSTACLE) != 0
new_is_fluid = (next_flags & FLUID) != 0
added_solid_mask = (~old_is_solid) & new_is_solid
released_fluid_mask = old_is_obstacle & new_is_fluid
return BodySyncPlan(
next_objects=next_objects,
next_count=next_count,
next_flags=next_flags,
curved_host=(cl_fluid_idx, cl_dir, cl_q, cl_body_id,
cl_rx, cl_ry, cl_rz, cl_fallback_class),
sensor_host=(sensor_cells, sensor_obj_id),
force_region_host=(fr_cells, fr_obj_id),
added_solid_mask=added_solid_mask,
released_fluid_mask=released_fluid_mask,
new_sensor_counts=new_sensor_counts,
curved_hits=hits[0],
)
def commit_pending(self, next_objects: List[SimObject],
sensor_counts: np.ndarray) -> None:
"""Replace the formal registry with *next_objects* and clear pending edits.
Args:
next_objects: New formal object list with ``obj_id = 0..n-1``.
sensor_counts: New per-object sensor cell counts.
"""
self._registry.clear()
for obj in next_objects:
self._registry.add(obj)
self.sensor_cell_counts = sensor_counts.copy()
self._resize_buffers()
self.clear_pending_edits()
def apply_sync_plan(self, field, plan) -> None:
"""Apply topology data from a ``BodySyncPlan`` to the field.
Sets ``field.flag``, assigns compact list host arrays, uploads
compact lists, and refreshes action buffer from object state.
Args:
field: LBMField instance.
plan: BodySyncPlan from ``build_sync_plan()``.
"""
field.flag = plan.next_flags
field.upload_flags()
cl_host = plan.curved_host
field.curved.assign_host(*cl_host)
field.curved.assign_surface(plan.next_objects, self.cfg, plan.next_flags,
jets=self.jets, hits=plan.curved_hits)
s_cells, s_ids = plan.sensor_host
field.sensors.assign_host(s_cells, s_ids)
fr_cells, fr_ids = plan.force_region_host
field.force_regions.assign_host(fr_cells, fr_ids)
field.upload_compact_lists()
field.update_params(n_objects=plan.next_count)
# -- Flag composition ----------------------------------------------------
def build_flags(self, base_flags: np.ndarray) -> np.ndarray:
"""Merge formal object flag masks onto a clean domain base."""
return self.build_flags_for(
self._registry.objects, base_flags,
nx=self.nx, ny=self.ny, nz=self.nz,
)
@staticmethod
def build_flags_for(objects: List[SimObject],
base_flags: np.ndarray,
*, nx: int, ny: int, nz: int) -> np.ndarray:
"""Merge object flag masks onto a clean domain base.
Args:
objects: List of SimObject instances.
base_flags: Fresh channel-layout flag array.
nx, ny, nz: Grid dimensions.
Returns:
New uint16 array with object overlays applied.
"""
masks = [obj.get_flag_mask(nx, ny, nz) for obj in objects]
return merge_flag_masks(base_flags, masks)
# -- Compact list building -----------------------------------------------
def build_compact_lists(self, domain_flags: np.ndarray | None = None, *, hits_out=None):
"""Build cut-link SoA columns, sensor lists, and force-region lists
from the formal registry objects.
"""
return self.build_compact_lists_for(
self._registry.objects, domain_flags,
sensor_cell_counts_out=self.sensor_cell_counts, hits_out=hits_out,
)
def build_compact_lists_for(
self,
objects: List[SimObject],
domain_flags: np.ndarray | None = None,
*,
sensor_cell_counts_out: np.ndarray | None = None,
hits_out: list | None = None,
):
"""Build cut-link SoA columns, sensor lists, and force-region lists.
Args:
objects: List of SimObject instances to process.
domain_flags: Optional uint16 flags for receiver/donor classification.
sensor_cell_counts_out: Int array to fill with per-object sensor
cell counts after the full geometry has passed validation.
hits_out: List that receives one FP64 (n, 2) array of true hit
points row-aligned with the cut-link columns (for Jets).
Returns:
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
"""
cl_idx, cl_dir, cl_q, cl_bid = [], [], [], []
cl_rx, cl_ry, cl_rz, cl_fallback = [], [], [], []
cl_hit = []
s_cells, s_ids = [], []
fr_cells, fr_ids = [], []
if domain_flags is None:
domain_flags = self.build_flags_for(
objects, np.full(self.nx * self.ny * self.nz, FLUID, dtype=np.uint16),
nx=self.nx, ny=self.ny, nz=self.nz,
)
active_fluid = ((domain_flags & FLUID) != 0) & ((domain_flags & SOLID) == 0)
force_owners: set[int] = set()
sensor_counts = (np.zeros_like(sensor_cell_counts_out)
if sensor_cell_counts_out is not None else None)
ez = None
if self.cfg and self.cfg.is_d3q19:
ex, ey, ez = D3Q19_EX, D3Q19_EY, D3Q19_EZ
else:
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,
rx_vals, ry_vals, rz_vals, fallback_vals
) = obj.get_curved_list(
self.nx, self.ny, self.nq,
nz=self.nz, ex=ex, ey=ey, ez=ez,
domain_flags=domain_flags, hits_out=cl_hit,
)
if len(fluid_idx) > 0:
cl_idx.append(fluid_idx)
cl_dir.append(dirs)
cl_q.append(q_vals)
cl_bid.append(body_ids)
cl_rx.append(rx_vals)
cl_ry.append(ry_vals)
cl_rz.append(rz_vals)
cl_fallback.append(fallback_vals)
if obj.is_sensor and hasattr(obj, 'get_sensor_list'):
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
keep = active_fluid[cells]
cells, ids = cells[keep], ids[keep]
if len(cells) > 0:
s_cells.append(cells)
s_ids.append(ids)
if (sensor_counts is not None
and 0 <= obj.obj_id < sensor_counts.size):
sensor_counts[obj.obj_id] = int(len(cells))
if obj.is_force_region and hasattr(obj, 'get_sensor_list'):
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
keep = active_fluid[cells]
cells, ids = cells[keep], ids[keep]
if len(cells) > 0:
if force_owners.intersection(map(int, cells)):
raise ValueError("Overlapping force_region fluid cells are unsupported")
force_owners.update(map(int, cells))
fr_cells.append(cells)
fr_ids.append(ids)
r_idx = np.concatenate(cl_idx) if cl_idx else np.zeros(0, dtype=np.uint32)
r_dir = np.concatenate(cl_dir) if cl_dir else np.zeros(0, dtype=np.uint8)
r_q = np.concatenate(cl_q) if cl_q else np.zeros(0, dtype=np.float32)
r_bid = np.concatenate(cl_bid) if cl_bid else np.zeros(0, dtype=np.int32)
r_rx = np.concatenate(cl_rx) if cl_rx else np.zeros(0, dtype=np.float32)
r_ry = np.concatenate(cl_ry) if cl_ry else np.zeros(0, dtype=np.float32)
r_rz = np.concatenate(cl_rz) if cl_rz else np.zeros(0, dtype=np.float32)
r_fallback = (
np.concatenate(cl_fallback) if cl_fallback
else np.zeros(0, dtype=np.uint8)
)
sensor_cells = np.concatenate(s_cells) if s_cells else np.zeros(0, dtype=np.uint32)
sensor_obj_id = np.concatenate(s_ids) if s_ids else np.zeros(0, dtype=np.int32)
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)
r_hit = np.concatenate(cl_hit) if cl_hit else np.zeros((0, 2))
# Passive walls need the same ownership guarantees as driven walls.
# Overlapping solids are not a supported union-boundary operation.
validate_cut_link_ownership(domain_flags, r_idx, r_dir, self.nq)
if self.jets or 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, jets=self.jets, hits=r_hit)
if sensor_cell_counts_out is not None:
sensor_cell_counts_out[:] = sensor_counts
if hits_out is not None:
hits_out.append(r_hit)
return (
r_idx, r_dir, r_q, r_bid,
r_rx, r_ry, r_rz, r_fallback,
sensor_cells, sensor_obj_id,
r_fr_cells, r_fr_ids,
)
# -- GPU sync ------------------------------------------------------------
def sync_to_gpu(self, field, *, rebuild_flags: bool = True):
"""Upload compact lists, action buffer, and packed ``obs`` GPU buffer.
Args:
rebuild_flags: Rebuild ``field.flag`` from the clean channel base and
upload it before constructing compact lists. Pass ``False`` when
the caller has already prepared and uploaded the final flag field
for this geometry, such as during initialization.
"""
next_flags = self.build_flags(field.build_channel_flags()) if rebuild_flags else field.flag
hits = []
(
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=next_flags, hits_out=hits)
self._telemetry_field = field
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,
jets=self.jets, hits=hits[0])
field.sensors.assign_host(sensor_cells, sensor_obj_id)
field.force_regions.assign_host(fr_cells, fr_obj_id)
field.upload_compact_lists()
field.update_params(n_objects=self.count)
self._refresh_action_from_objects()
self._allocate_packed_telemetry()
assert self.obs_pinned is not None
self.obs_pinned.fill(0)
cuda.memcpy_htod(self.obs_gpu, self.obs_pinned)
def _allocate_packed_telemetry(self) -> None:
"""Resize ``action_gpu`` / ``obs_gpu`` / ``obs_pinned`` when layout changes."""
dim = self.cfg.dim if self.cfg else 2
lay = obs_layout(dim, self.count)
self._apply_obs_layout(lay)
# Buffer re-allocation implies a fresh start -- reset step counter.
self._obs_accum_steps = 0
action_nbytes = int(self.action.nbytes)
if self.action_gpu is None or self._action_nbytes != action_nbytes:
if self.action_gpu is not None:
self.action_gpu.free()
self.action_gpu = None
self.action_gpu = cuda.mem_alloc(action_nbytes)
self._action_nbytes = action_nbytes
# Initialise with host action (zeros until first run() upload).
cuda.memcpy_htod(self.action_gpu, self.action)
if self.obs_gpu is None or self._obs_alloc_nbytes != self.obs_nbytes:
if self.obs_gpu is not None:
self.obs_gpu.free()
self.obs_gpu = None
self.obs_gpu = cuda.mem_alloc(self.obs_nbytes)
self._obs_alloc_nbytes = self.obs_nbytes
self.obs_pinned = cuda.pagelocked_empty(lay.total_floats, np.float32)
self.obs_pinned.fill(0)
n_slots = max(self.count, 1)
self.obs_force_view = self.obs_pinned[
lay.force0_floats: lay.force0_floats + lay.force_floats
].reshape(
n_slots, dim)
self.obs_torque_view = self.obs_pinned[
lay.torque0_floats: lay.torque0_floats + lay.torque_floats
].reshape(n_slots, lay.torque_components)
self.obs_sensor_view = self.obs_pinned[
lay.sensor0_floats: lay.sensor0_floats + lay.slot_stride_floats
].reshape(n_slots, dim)
def _apply_obs_layout(self, lay: ObsLayout) -> None:
self.slot_stride_floats = lay.slot_stride_floats
self.torque_stride_floats = lay.torque_floats
self.torque_components = lay.torque_components
self.torque0_floats = lay.torque0_floats
self.sensor0_floats = lay.sensor0_floats
self.obs_total_floats = lay.total_floats
self.obs_nbytes = lay.total_floats * 4
def ensure_packed_buffers(self) -> None:
"""Allocate ``action_gpu`` and packed ``obs`` when there are zero objects.
Call after ``generate_config(..., n_objects=0)`` so ``config_obs.h`` matches
:func:`obs_layout(dim, 0)`.
"""
if self.cfg is None:
return
self._allocate_packed_telemetry()
assert self.obs_pinned is not None
self.obs_pinned.fill(0)
cuda.memcpy_htod(self.obs_gpu, self.obs_pinned)
def zero_force_segment_async(self, stream: cuda.Stream):
"""Zero body telemetry (force + torque) of ``obs_gpu``.
Deprecated: prefer :meth:`zero_obs_async` which zeros all three
obs segments (force + torque + sensor) in a single memset.
"""
n_floats = self.sensor0_floats
cuda.memset_d32_async(self.obs_gpu, 0, n_floats, stream)
def zero_sensor_segment_async(self, stream: cuda.Stream):
"""Zero the sensor segment (second stride-sized block of floats).
Deprecated: prefer :meth:`zero_obs_async` which zeros all three
obs segments (force + torque + sensor) in a single memset.
"""
offset_bytes = self.sensor0_floats * 4
ptr = int(self.obs_gpu) + offset_bytes
cuda.memset_d32_async(ptr, 0, self.slot_stride_floats, stream)
def zero_obs_async(self, stream: cuda.Stream):
"""Zero ALL obs segments (force + torque + sensor) on GPU.
One ``memset`` covers the entire ``obs_gpu`` buffer.
Resets the step accumulator so that ``read_*(normalize=True)``
returns raw values (dividing by zero is avoided -- see read methods).
"""
n_floats = self.obs_total_floats
cuda.memset_d32_async(self.obs_gpu, 0, n_floats, stream)
self._obs_accum_steps = 0
def increment_obs_steps(self, n: int):
"""Accumulate *n* LBM steps for time-normalization."""
self._obs_accum_steps += n
def download_obs_full_async(self, stream: cuda.Stream):
"""Enqueue full DTOH copy ``obs_gpu`` -> ``obs_pinned``."""
assert self.obs_pinned is not None
cuda.memcpy_dtoh_async(self.obs_pinned, self.obs_gpu, stream)
def _upload_action_async(self, stream: cuda.Stream) -> None:
"""Async H2D of host action array to action_gpu.
Single gate for every action path (setters or direct ``action`` edits):
surface-actuated and Jet-driven bodies must keep zero motion slots.
"""
slot = 3*(self.cfg.dim if self.cfg else 2)
for obj in self.objects:
obj.validate_surface_actuation(self.cfg)
base = obj.obj_id*slot
if obj.surface_actuation is not None and self.action[base+slot-1] != 0:
raise ValueError("Surface actuation cannot share the rotation action")
self.validate_jet_motion(obj.obj_id, {"vx": self.action[base], "vy": self.action[base+1],
"omega": self.action[base+slot-1]})
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:
self._action_pinned = cuda.pagelocked_empty(self.action.size, np.float32)
self._action_pinned[:] = self.action
cuda.memcpy_htod_async(self.action_gpu, self._action_pinned, stream)
@property
def obs_n_slots(self) -> int:
"""At-least-one slot count ``max(count, 1)`` matching ``config_obs.h``."""
return max(self.count, 1)
@property
def obs_slot_stride_floats(self) -> int:
"""Floats per telemetry segment (``obs_n_slots * DIM``)."""
return self.slot_stride_floats
@property
def obs_sensor_base_floats(self) -> int:
"""Float index where the sensor segment begins."""
return self.sensor0_floats
@property
def obs_torque_base_floats(self) -> int:
"""Float index where the torque segment begins."""
return self.torque0_floats
def read_force(self, body_id: int, *, normalize: bool = True) -> np.ndarray:
"""Return the DIM-vector force on body ``body_id`` from ``obs_pinned``.
When *normalize* is ``True`` (default), divides by the number of
accumulated LBM steps since the last zero so the result is the
**average force per step**. When ``False``, returns the raw
GPU-accumulated sum (no time-normalisation).
Caller must have synchronised the CUDA stream before reading.
"""
self._validate_body_id(body_id)
assert self.cfg is not None
assert self.obs_pinned is not None
d = self.cfg.dim
i0 = body_id * d
values = np.array(self.obs_pinned[i0:i0 + d], dtype=np.float32)
if normalize and self._obs_accum_steps > 0:
values /= np.float32(self._obs_accum_steps)
return values
def read_torque(self, body_id: int, *, normalize: bool = True) -> np.ndarray:
"""Return torque vector for ``body_id`` from ``obs_pinned``.
See :meth:`read_force` for the *normalize* semantics.
"""
self._validate_body_id(body_id)
assert self.obs_pinned is not None
i0 = self.torque0_floats + body_id * self.torque_components
values = np.array(
self.obs_pinned[i0:i0 + self.torque_components], dtype=np.float32)
if normalize and self._obs_accum_steps > 0:
values /= np.float32(self._obs_accum_steps)
return values
def read_sensor(self, body_id: int, *, normalize: bool = True) -> np.ndarray:
"""Return sensor accumulation for ``body_id``.
**Area-normalisation is always applied internally** -- the raw GPU
sum is divided by the number of sensor cells in the body footprint.
When *normalize* is ``True`` (default), the result is further divided
by the number of accumulated LBM steps, giving a **per-step
area-averaged velocity**. Set ``normalize=False`` to obtain the
area-averaged value without time-normalisation.
Caller must have synchronised the CUDA stream before reading.
"""
self._validate_body_id(body_id)
assert self.obs_pinned is not None
assert self.cfg is not None
d = self.cfg.dim
i0 = self.sensor0_floats + body_id * d
values = np.array(self.obs_pinned[i0:i0 + d], dtype=np.float32)
# Always area-normalise
count = int(self.sensor_cell_counts[body_id]) if body_id < self.sensor_cell_counts.size else 0
if count > 0:
values /= np.float32(count)
# Optionally time-normalise
if normalize and self._obs_accum_steps > 0:
values /= np.float32(self._obs_accum_steps)
return values
def read_body(self, body_id: int, *, normalize: bool = True) -> BodyTelemetry:
"""Return unified telemetry for one body from the pinned obs buffer.
See :meth:`read_force`, :meth:`read_torque`, and :meth:`read_sensor`
for the *normalize* semantics applied to each field.
The caller must ensure ``run(sync_obs=True)`` or an explicit
``download_obs_full_async + synchronize`` has completed.
"""
force = self.read_force(body_id, normalize=normalize)
torque = self.read_torque(body_id, normalize=normalize)
sensor = self.read_sensor(body_id, normalize=normalize)
return BodyTelemetry(force=force, torque=torque, sensor=sensor)
def _obs_array(self) -> np.ndarray:
"""Return the full pinned obs buffer as a flat float32 array.
Shape: ``(total_floats,)`` with layout ``[force|torque|sensor]``.
Efficient for DRL batch reads.
"""
assert self.obs_pinned is not None
return self.obs_pinned.copy()
def validate_jet_motion(self, body_id: int, state: dict) -> None:
"""Reject motion of a body whose synced cut links are Jet-driven.
Before the first sync no links exist; sync-time checks cover that case.
"""
field = self._telemetry_field
cl = field.curved if field is not None else None
if (cl is not None and cl.jet_id is not None
and np.any(cl.body_id[cl.jet_id >= 0] == body_id)
and any(state.get(k, 0.) != 0 for k in ("vx", "vy", "omega"))):
raise ValueError("Jet-driven bodies must stay stationary")
def set_body_state(self, body_id: int, omega: float = 0.0) -> None:
"""Set runtime body state (host action array only, no H2D).
The GPU action buffer is updated at the next ``run()`` call.
"""
self._validate_body_id(body_id)
obj = self.get(body_id)
proposed = dict(vars(obj.state), omega=float(omega))
obj.validate_surface_actuation(self.cfg, proposed)
self.validate_jet_motion(body_id, proposed)
dim = self.cfg.dim if self.cfg else 2
slot = 3 * dim
base = body_id * slot
self.get(body_id).state.omega = float(omega)
self.action[base + slot - 1] = np.float32(omega)
def set_force_state(self, body_id: int, fx: float = 0.0, fy: float = 0.0) -> None:
"""Set force density on a force_region object (host action only, no H2D).
The GPU action buffer is updated at the next ``run()`` call.
"""
self._validate_body_id(body_id)
dim = self.cfg.dim if self.cfg else 2
slot = 3 * dim
base = body_id * slot
obj = self.get(body_id)
if not obj.is_force_region:
raise ValueError("set_force_state requires a force_region object")
# vx/vy are the existing force-region action aliases. Keep their
# authoritative host state in sync so edits to other bodies cannot
# discard this control during a full action refresh or topology sync.
obj.state.vx = float(fx)
obj.state.vy = float(fy)
self.action[base + 0] = np.float32(fx)
self.action[base + 1] = np.float32(fy)
def _validate_body_id(self, body_id: int) -> None:
if body_id < 0 or body_id >= self.count:
raise IndexError(
f"body_id {body_id} out of range for {self.count} objects")
def _refresh_action_from_objects(self) -> None:
"""Populate action slots from object state using the runtime contract.
Slot layout per object (DIM=2 in this example, slots derived from 3*DIM):
[0] = vx (= fx for force_region objects; 0 for bodies/sensors)
[1] = vy (= fy for force_region objects; 0 for bodies/sensors)
[5] = omega (= body rotation for circle objects; 0 for sensors/force_region)
Slot 0/1 are consumed by ForceRegionKernel for force_region objects.
Slot -1 is consumed by CurvedBoundaryKernel for body rotation (action_omega).
Sensors do not consume action slots.
"""
if self.count == 0:
return
dim = self.cfg.dim if self.cfg else 2
slot = 3 * dim
self.action.fill(0)
for obj in self._registry.objects:
base = obj.obj_id * slot
if base + slot > self.action.size:
continue
self.action[base + 0] = np.float32(getattr(obj.state, "vx", 0.0))
self.action[base + 1] = np.float32(getattr(obj.state, "vy", 0.0))
self.action[base + slot - 1] = np.float32(
getattr(obj.state, "omega", 0.0))
def fallback_link_count(self) -> int:
"""Return number of curved links marked for fallback bounce-back."""
if self._telemetry_field is None:
return 0
curved = self._telemetry_field.curved
if curved.count == 0:
return 0
return int(np.count_nonzero(curved.fallback_class[:curved.count] != 0))
def low_q_link_count(self, threshold: float = 0.5) -> int:
"""Return count of links where q is below ``threshold``."""
if self._telemetry_field is None:
return 0
curved = self._telemetry_field.curved
if curved.count == 0:
return 0
return int(np.count_nonzero(
curved.q[:curved.count] < np.float32(threshold)))
# -- Placeholders for future use -----------------------------------------
def update_states(self, dt: float):
"""Integrate object motion (placeholder)."""
pass
def detect_collisions(self):
"""Multi-body collision detection (placeholder)."""
pass
def exchange_forces(self, obs: np.ndarray):
"""Distribute fluid forces to objects (placeholder)."""
pass