feat(body): runtime body add/remove, unified action/obs, FRC_REGION flag

- Add runtime body topology sync (add_body/remove_body + sync_bodies)
  with recompile, DDF patch (feq + BFS inward fill), and commit.
- Unify action/obs flow: set_body/set_force are now host-only;
  run() auto-uploads action and downloads obs via CUDA stream.
- Add read_body(id) -> BodyTelemetry and read_bodies() for DRL loops.
- Add FRC_REGION flag (0x0800) for force_region cells.
- Extract equilibrium helpers (lbm/equilibrium.py) and DDF patch module
  (body/ddf_patch.py).
- Merge recompile / _runtime_recompile into single _recompile().
- Add n_objects to checkpoint; validate on load.
- Add test suite: 40 unit + 19 integration tests (59 total).
- Add conftest.py and docs/tests_overview.md for test documentation.
- Update README.md and CONFIG.md for new API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14fandCursor committed 2026-06-20 18:17:07 +08:00
1 parent d5b7e98750
commit 987566c0e6
28 files changed
+2112 -86

No files matched your search

+116 -32
View File
@@ -35,37 +35,62 @@ force = sim.read_force(0) # [fx, fy] on body 0
sim.close()
```
### Multi-body control loop
### DRL control loop
```python
from CelerisLab import Simulation
sim = Simulation()
# Three rotating cylinders
sim.add_body("circle", center=(1006, 150), radius=10)
sim.add_body("circle", center=(1015, 140), radius=10)
sim.add_body("circle", center=(1015, 160), radius=10)
# Downstream velocity sensor
sim.add_body("sensor", center=(1050, 150), radius=10)
sim.add_body("circle", center=(256, 128), radius=10)
sim.add_body("sensor", center=(300, 128), radius=10)
sim.initialize()
for step in range(100):
# Set body rotation speeds (implicit GPU upload)
sim.set_body(0, omega=0.002)
sim.set_body(1, omega=-0.001)
sim.set_body(2, omega=0.001)
for episode in range(100):
# Step the simulation (auto uploads action, downloads obs)
sim.run(100)
# Advance 10 LBM steps
sim.run(10)
# Read individual body telemetry (primary API)
data = sim.read_body(0)
print(f"step={sim.stepper.step_count} "
f"force=({data.force[0]:.4f},{data.force[1]:.4f}) "
f"sensor=({data.sensor[0]:.4f},{data.sensor[1]:.4f})")
# Read telemetry
fx, fy = sim.read_force(0)
ux, uy = sim.read_sensor(3)
print(f"force=({fx:.4f},{fy:.4f}) sensor=({ux:.4f},{uy:.4f})")
# DRL policy inference (replace with your model)
action_omega = 0.001 * (0.5 - data.force[0])
# Set action (host-only, will be auto-uploaded next run)
sim.set_body(0, omega=action_omega)
sim.close()
```
### Async control (performance-oriented, custom stream)
```python
import pycuda.driver as cuda
stream = cuda.Stream()
sim.set_body(0, omega=0.002) # host-only
sim.run(100, stream=stream) # action uploaded, steps run, obs downloaded on stream
# stream is synced inside run() -- obs is ready
data = sim.read_body(0)
```
### Manual stream control (max overlap)
```python
import pycuda.driver as cuda
stream = cuda.Stream()
# Skip transfers for the first batch, just enqueue kernels
sim.run(100, stream=stream, upload_act=False, sync_obs=False)
# ... other GPU work can overlap with the kernel launches ...
# Later: sync and read
stream.synchronize()
obs = sim.read_bodies(stream=stream) # sync already done, just read pinned buffer
## Installation
### Prerequisites
@@ -105,10 +130,11 @@ sim = Simulation(
| Method | Returns | Description |
|--------|---------|-------------|
| `sim.add_body(type="circle", center=(x,y), radius=r)` | int body_id | Add a cylinder body |
| `sim.add_body(type="circle", center=(x,y), radius=r)` | int body_id | Add a cylinder body (primary API) |
| `sim.add_body(type="sensor", center=(x,y), radius=r)` | int body_id | Add a velocity sensor |
| `sim.add_cylinder(center, radius)` | int body_id | Backward-compat alias |
| `sim.add_sensor(center, radius)` | int body_id | Backward-compat alias |
| `sim.add_body(type="force_region", center=(x,y), radius=r)` | int body_id | Add a force application region |
| `sim.add_cylinder(center, radius)` | int body_id | Convenience wrapper (deprecated) |
| `sim.add_sensor(center, radius)` | int body_id | Convenience wrapper (deprecated) |
| `sim.add_object(obj)` | int body_id | Add pre-configured SimObject |
Future geometry types (polygon, mesh) will use the same `add_body()` function with a different `type` parameter.
@@ -118,12 +144,64 @@ Future geometry types (polygon, mesh) will use the same `add_body()` function wi
| Method | Description |
|--------|-------------|
| `sim.initialize()` | Recompile if needed, flow field + sync objects to GPU |
| `sim.run(steps, checkpoint_interval=0)` | Run N LBM steps |
| `sim.set_body(id, omega=...)` | Set body rotation speed (implicit GPU upload, ~1 μs) |
| `sim.read_force(id)` -> ndarray | Force vector [fx, fy] (2D) |
| `sim.read_torque(id)` -> ndarray | Torque [tz] (2D) |
| `sim.read_sensor(id)` -> ndarray | Area-averaged velocity via GPU sensor kernel |
| `sim.set_force(id, fx=..., fy=...)` | Set force density on a force_region object (notice: see persistence note below) |
| `sim.run(steps, *, upload_act=True, sync_obs=True, stream=None)` | Run N LBM steps. See stream subsection below. |
| `sim.set_body(id, omega=...)` | Set body rotation speed (host array only, uploaded at next `run()`) |
| `sim.read_body(id)` -> BodyTelemetry | Unified telemetry: {force, torque, sensor} from pinned buffer |
| `sim.read_bodies()` -> ndarray | Flat array of all bodies' telemetry (batch DRL read) |
| `sim.read_force(id)` -> ndarray | Force vector [fx, fy] (backward-compat) |
| `sim.read_torque(id)` -> ndarray | Torque [tz] (backward-compat) |
| `sim.read_sensor(id)` -> ndarray | Area-averaged velocity (backward-compat) |
| `sim.set_force(id, fx=..., fy=...)` | Set force density on a force_region object |
**Action/obs transfer model:** `set_body()` / `set_force()` are host-only — they modify
the host action array without triggering GPU upload. The GPU buffer is automatically
updated at the start of the next ``run()`` call when ``upload_act=True`` (the default).
Similarly, after the step group, telemetry is downloaded to a pinned host buffer when
``sync_obs=True``. Both transfers run on the same CUDA stream as the kernels, so
they overlap with computation when possible.
``run()`` parameters:
- ``steps``: Number of LBM steps.
- ``upload_act`` (default True): Upload host action array to ``action_gpu`` before stepping.
- ``sync_obs`` (default True): Download ``obs_gpu`` to host pinned buffer after stepping.
- ``stream`` (default None): CUDA stream for all operations. ``None`` uses an internal stream.
- ``checkpoint_interval`` (default 0): If >0, save an HDF5 checkpoint every N steps.
Use ``upload_act=False, sync_obs=False`` to skip all transfers and enqueue pure
kernel launches on a user-provided stream, then sync and read later.
#### Runtime body topology sync
| Method | Description |
|--------|-------------|
| `sim.remove_body(id)` | Stage a body for removal (committed at next `sync_bodies()`) |
| `sim.sync_bodies()` | Commit pending add/remove edits: recompile kernel, rebuild flags/compact lists, patch DDF, re-upload to GPU |
`sync_bodies()` applies all staged body edits (added via `add_body()` and removed via `remove_body()`) to a running simulation without full reinitialization. The GPU flow field is preserved; only the body-related topology is rebuilt.
**Limitations:**
- Requires `streaming: "double_buffer"` (esopull raises `NotImplementedError`)
- Abrupt body introduction causes a transient; force readback is finite but may take 50+ steps to settle
- Verified for `"circle"` type bodies; sensors and force_regions are also expected to work
(they produce no curved links so the DDF patch is simpler)
```python
# Add a body to an already-initialized simulation
sim = Simulation()
sim.initialize()
sim.run(500)
sim.add_body("circle", center=(256, 128), radius=10)
sim.sync_bodies() # recompile + patch
sim.run(500)
force = sim.read_force(0)
# Remove the same body at runtime
sim.remove_body(0)
sim.sync_bodies() # recompile + patch flags/DDF
sim.run(500)
```
**Note:** If `run()` is called without a preceding `sync_bodies()`, any staged edits are silently discarded.
### force_region usage
@@ -138,7 +216,10 @@ sim.set_force(fr_id, fx=0.001, fy=0.0)
sim.set_force(fr_id, fx=0.0, fy=0.0) # disable force
```
**Persistence note:** `set_force()` writes the action buffer directly but does not update the object's state record. If `sync_to_gpu()` is called afterward, the force will be reset to zero. For the common usage pattern (initialize -> set_force -> run -> set_force -> run ...), this is not an issue. A future update will add proper force storage in the object state.
**Persistence note:** `set_force()` only updates the host action array. The GPU
buffer is synced at the next `run()` call. If `sync_to_gpu()` is called manually
before `run()`, the force will be reset to zero. For the common usage pattern
(initialize -> set_force -> run -> set_force -> run ...), this is not an issue.
### Comparison: body types
@@ -146,7 +227,7 @@ sim.set_force(fr_id, fx=0.0, fy=0.0) # disable force
|------|-------------|-------------------|----------|-----------------|
| `"circle"` | OBSTACLE + BC_CURVED | Yes (Bouzidi) | force/torque | `set_body(id, omega=...)` |
| `"sensor"` | FLUID + SENSOR_FLAG | No | area-averaged velocity | None needed |
| `"force_region"` | None (zero mask) | **No** | None | `set_force(id, fx=..., fy=...)` |
| `"force_region"` | FLUID + FRC_REGION | No | None | `set_force(id, fx=..., fy=...)` |
#### Data access
@@ -296,8 +377,9 @@ Stores `f_i - w_i` instead of `f_i` to improve FP16 accuracy. Supported with th
| SRT | double_buffer | any | cylinder | Expected to work (f-feq style) |
**Known limitations (ddf_shifting):**
- Must use `zou_he_local` inlet scheme when combining MRT + shifting
- Regularized inlet shows suppressed vortex shedding with MRT -- root cause under investigation
- Verified configuration: **D2Q9 + MRT + double_buffer + zou_he_local** only
- `regularized` inlet with `ddf_shifting` is **known incompatible / unsolved** -- use `zou_he_local`
- `esopull + ddf_shifting` has not been jointly validated
- MRT shifts to physical space before collision, shifts back after (SRT/TRT are shift-invariant natively)
- D3Q19 MRT shifting patch has a `compute_feq` inconsistency (not in scope for 2D-only)
- Host `upload_ddf()` path is asymmetric (repaired)
@@ -363,6 +445,8 @@ conda run -n pycuda_3_10 python tests/validation/run_perf_baseline.py \
## Project Layout
See [docs/tests_overview.md](docs/tests_overview.md) for a complete guide to the test suite.
```
src/CelerisLab/
simulation.py High-level API
@@ -400,7 +484,7 @@ ref/ External reference implementations (FluidX3D)
```python
sim.set_body(0, omega=0.002)
sim.run(10)
force = sim.read_force(0)
data = sim.read_body(0)
```
### Async control (performance-oriented)