feat(obs): unified zero_obs control and time-normalised readback

- Replace split zero_force_segment / zero_sensor_segment with unified
  zero_obs_async() — a single memset covers all three obs segments
  (force, torque, sensor), resetting the step accumulator.
- Add _obs_accum_steps counter so read_*(normalize=True) returns the
  physically meaningful per-step average for all telemetry fields.
- Sensor now always applies area-normalisation internally; the normalize
  parameter only controls the additional time-normalisation step.
- run() gains zero_obs=True parameter (default) to control reset-on-step.
- 7 new integration tests covering accumulation, zeroing, and normalise.
- Fix bug in test_sensor_accuracy.py (undefined loop variable i).
- Bump version to 0.4.0 for the API change.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14fandCursor committed 2026-06-21 00:50:20 +08:00
1 parent 7a609b2c76
commit 04c2bc75ea
10 files changed
+322 -58

No files matched your search

+38 -13
View File
@@ -144,26 +144,41 @@ 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, *, upload_act=True, sync_obs=True, stream=None)` | Run N LBM steps. See stream subsection below. |
| `sim.run(steps, *, upload_act=True, sync_obs=True, zero_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_body(id, *, normalize=True)` -> 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.read_force(id, *, normalize=True)` -> ndarray | Force vector [fx, fy] |
| `sim.read_torque(id, *, normalize=True)` -> ndarray | Torque [tz] |
| `sim.read_sensor(id, *, normalize=True)` -> ndarray | Area-averaged velocity; time-normalised when normalize=True |
| `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.
**Obs telemetry model:** GPU kernels accumulate force, torque, and sensor readings into
the ``obs_gpu`` buffer via ``atomicAdd``. By default, ``run(zero_obs=True)`` clears the
entire ``obs_gpu`` buffer (all three segments) and resets an internal step counter before
stepping. After the step group, telemetry is downloaded to a pinned host buffer when
``sync_obs=True``.
All three readback methods accept a ``normalize`` keyword:
- ``normalize=True`` (default): divides the raw GPU value by the accumulated step count,
yielding a **per-step average** — the physically meaningful quantity for most use cases.
- ``normalize=False``: returns the raw GPU-accumulated sum (no time division).
**Sensor special handling:** Area-normalisation (dividing by the number of sensor cells)
is **always applied internally** in ``read_sensor()``, regardless of the ``normalize`` flag.
The ``normalize`` parameter only controls the additional time-normalisation step.
``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.
- ``zero_obs`` (default True): Zero all obs segments (force, torque, sensor) on GPU and
reset the step accumulator before the step group. Set ``False`` to accumulate
telemetry across multiple ``run()`` calls.
- ``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.
@@ -226,7 +241,7 @@ before `run()`, the force will be reset to zero. For the common usage pattern
| Type | Flag overlay | Produces cut-links | Readback | Runtime control |
|------|-------------|-------------------|----------|-----------------|
| `"circle"` | OBSTACLE + BC_CURVED | Yes (Bouzidi) | force/torque | `set_body(id, omega=...)` |
| `"sensor"` | FLUID + SENSOR_FLAG | No | area-averaged velocity | None needed |
| `"sensor"` | FLUID + SENSOR_FLAG | No | area-averaged velocity (always); optional per-step average | None needed |
| `"force_region"` | FLUID + FRC_REGION | No | None | `set_force(id, fx=..., fy=...)` |
#### Data access
@@ -265,7 +280,7 @@ When fine-grained control is needed (e.g., custom async patterns), step manually
```python
stream = cuda.Stream()
sim.bodies.zero_force_segment_async(stream)
sim.bodies.zero_obs_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
@@ -273,7 +288,8 @@ sim.stepper.step(
stream=stream,
)
stream.synchronize()
force = sim.read_force(0)
sim.bodies.increment_obs_steps(1) # manually track steps for normalize
force = sim.read_force(0) # normalize=True: divides by 1 step
```
## Configuration
@@ -490,11 +506,20 @@ data = sim.read_body(0)
### Async control (performance-oriented)
```python
sim.set_body(0, omega=0.002) # implicit H2D, ~1 μs
sim.set_body(0, omega=0.002) # host-only, ~1 μs
sim.stepper.step(10, ..., stream=sim.stream)
sim.bodies.increment_obs_steps(10) # track steps for normalize
sim.bodies.download_obs_full_async(sim.stream)
sim.stream.synchronize()
force = sim.read_force(0)
force = sim.read_force(0) # per-step average force
```
Use ``sim.run()`` for the common case -- it stores the step count automatically:
```python
sim.set_body(0, omega=0.002)
sim.run(10, stream=sim.stream)
force = sim.read_force(0) # per-step average force
```
## Vortex initialization