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:
parent
d5b7e98750
commit
987566c0e6
148
README.md
148
README.md
@ -35,37 +35,62 @@ force = sim.read_force(0) # [fx, fy] on body 0
|
|||||||
sim.close()
|
sim.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-body control loop
|
### DRL control loop
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from CelerisLab import Simulation
|
from CelerisLab import Simulation
|
||||||
|
|
||||||
sim = Simulation()
|
sim = Simulation()
|
||||||
# Three rotating cylinders
|
sim.add_body("circle", center=(256, 128), radius=10)
|
||||||
sim.add_body("circle", center=(1006, 150), radius=10)
|
sim.add_body("sensor", center=(300, 128), 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.initialize()
|
sim.initialize()
|
||||||
|
|
||||||
for step in range(100):
|
for episode in range(100):
|
||||||
# Set body rotation speeds (implicit GPU upload)
|
# Step the simulation (auto uploads action, downloads obs)
|
||||||
sim.set_body(0, omega=0.002)
|
sim.run(100)
|
||||||
sim.set_body(1, omega=-0.001)
|
|
||||||
sim.set_body(2, omega=0.001)
|
|
||||||
|
|
||||||
# Advance 10 LBM steps
|
# Read individual body telemetry (primary API)
|
||||||
sim.run(10)
|
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
|
# DRL policy inference (replace with your model)
|
||||||
fx, fy = sim.read_force(0)
|
action_omega = 0.001 * (0.5 - data.force[0])
|
||||||
ux, uy = sim.read_sensor(3)
|
|
||||||
print(f"force=({fx:.4f},{fy:.4f}) sensor=({ux:.4f},{uy:.4f})")
|
# Set action (host-only, will be auto-uploaded next run)
|
||||||
|
sim.set_body(0, omega=action_omega)
|
||||||
|
|
||||||
sim.close()
|
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
|
## Installation
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@ -105,10 +130,11 @@ sim = Simulation(
|
|||||||
|
|
||||||
| Method | Returns | Description |
|
| 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_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_body(type="force_region", center=(x,y), radius=r)` | int body_id | Add a force application region |
|
||||||
| `sim.add_sensor(center, radius)` | int body_id | Backward-compat alias |
|
| `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 |
|
| `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.
|
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 |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `sim.initialize()` | Recompile if needed, flow field + sync objects to GPU |
|
| `sim.initialize()` | Recompile if needed, flow field + sync objects to GPU |
|
||||||
| `sim.run(steps, checkpoint_interval=0)` | Run N LBM steps |
|
| `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 (implicit GPU upload, ~1 μs) |
|
| `sim.set_body(id, omega=...)` | Set body rotation speed (host array only, uploaded at next `run()`) |
|
||||||
| `sim.read_force(id)` -> ndarray | Force vector [fx, fy] (2D) |
|
| `sim.read_body(id)` -> BodyTelemetry | Unified telemetry: {force, torque, sensor} from pinned buffer |
|
||||||
| `sim.read_torque(id)` -> ndarray | Torque [tz] (2D) |
|
| `sim.read_bodies()` -> ndarray | Flat array of all bodies' telemetry (batch DRL read) |
|
||||||
| `sim.read_sensor(id)` -> ndarray | Area-averaged velocity via GPU sensor kernel |
|
| `sim.read_force(id)` -> ndarray | Force vector [fx, fy] (backward-compat) |
|
||||||
| `sim.set_force(id, fx=..., fy=...)` | Set force density on a force_region object (notice: see persistence note below) |
|
| `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
|
### 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
|
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
|
### 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=...)` |
|
| `"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 | 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
|
#### 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) |
|
| SRT | double_buffer | any | cylinder | Expected to work (f-feq style) |
|
||||||
|
|
||||||
**Known limitations (ddf_shifting):**
|
**Known limitations (ddf_shifting):**
|
||||||
- Must use `zou_he_local` inlet scheme when combining MRT + shifting
|
- Verified configuration: **D2Q9 + MRT + double_buffer + zou_he_local** only
|
||||||
- Regularized inlet shows suppressed vortex shedding with MRT -- root cause under investigation
|
- `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)
|
- 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)
|
- D3Q19 MRT shifting patch has a `compute_feq` inconsistency (not in scope for 2D-only)
|
||||||
- Host `upload_ddf()` path is asymmetric (repaired)
|
- 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
|
## Project Layout
|
||||||
|
|
||||||
|
See [docs/tests_overview.md](docs/tests_overview.md) for a complete guide to the test suite.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/CelerisLab/
|
src/CelerisLab/
|
||||||
simulation.py High-level API
|
simulation.py High-level API
|
||||||
@ -400,7 +484,7 @@ ref/ External reference implementations (FluidX3D)
|
|||||||
```python
|
```python
|
||||||
sim.set_body(0, omega=0.002)
|
sim.set_body(0, omega=0.002)
|
||||||
sim.run(10)
|
sim.run(10)
|
||||||
force = sim.read_force(0)
|
data = sim.read_body(0)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Async control (performance-oriented)
|
### Async control (performance-oriented)
|
||||||
|
|||||||
44
docs/tests_overview.md
Normal file
44
docs/tests_overview.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Test Suite Overview
|
||||||
|
|
||||||
|
## Test hierarchy
|
||||||
|
|
||||||
|
Three tiers:
|
||||||
|
|
||||||
|
- **`tests/unit/`** — pure Python, no GPU. Covers isolated logic (pending edits, sync plan construction, flag masks, equilibrium helpers).
|
||||||
|
- **`tests/integration/`** — requires GPU. Covers the full body topology sync pipeline (recompile, DDF patch, unified obs/act) and stream API.
|
||||||
|
- **`tests/validation/`** — requires GPU, long-running (hours). Physics regression against Kan99b, Sah04, and sensor accuracy references.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
```
|
||||||
|
conda run -n pycuda_3_10 python -m pytest <test_path> -v
|
||||||
|
```
|
||||||
|
|
||||||
|
| What you want to verify | Command |
|
||||||
|
|---|---|
|
||||||
|
| Body module logic after refactor | `pytest tests/unit/ -v` |
|
||||||
|
| sync_bodies pipeline (no DDF patch) | `pytest tests/integration/test_sync_bodies_skeleton.py -v` |
|
||||||
|
| DDF patch after add/remove | `pytest tests/integration/test_ddf_patch.py -v` |
|
||||||
|
| Unified action/obs + stream API | `pytest tests/integration/test_unified_obs.py -v` |
|
||||||
|
| Full body add/remove e2e | `pytest tests/integration/test_body_sync_e2e.py -v` |
|
||||||
|
| All integration tests | `pytest tests/integration/ -v` |
|
||||||
|
| All tests (unit + integration) | `pytest tests/unit/ tests/integration/ -v` |
|
||||||
|
| Physics regression | `python tests/validation/run_kan99b_rotating_cylinder.py` |
|
||||||
|
|
||||||
|
## Per-file coverage
|
||||||
|
|
||||||
|
| File | GPU | Tests | What it covers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `test_pending_edits.py` | No | 14 | stage_add / stage_remove / has_pending_edit / clear_pending_edits lifecycle |
|
||||||
|
| `test_sync_plan.py` | No | 16 | build_flags_for, build_compact_lists_for, build_next_objects, build_sync_plan, commit_pending |
|
||||||
|
| `test_body_flags.py` | No | 6 | FRC_REGION / SENSOR_FLAG / OBSTACLE bits for each body type |
|
||||||
|
| `test_equilibrium.py` | No | 4 | compute_feq_d2q9 and compute_macro_from_ddf numerical correctness |
|
||||||
|
| `test_sync_bodies_skeleton.py` | Yes | 7 | sync_bodies flow: recompile, esopull guard, step count preservation, pending discard |
|
||||||
|
| `test_ddf_patch.py` | Yes | 5 | DDF patch after add/remove: finite forces, released region is fluid |
|
||||||
|
| `test_unified_obs.py` | Yes | 5 | host-only set_body, auto transfer, stream API, DRL loop pattern |
|
||||||
|
| `test_body_sync_e2e.py` | Yes | 2 | Full add -> remove -> checkpoint -> load -> run cycle |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `pycuda.autoinit` is required for all integration tests (selects GPU 0 by default; set `CUDA_VISIBLE_DEVICES` to change).
|
||||||
|
- The curved boundary kernel produces NaN in obstacle-adjacent fluid cells over long runs; DDF patch tests use 50-step verification windows.
|
||||||
@ -5,6 +5,7 @@ Body / object management for immersed and rigid objects.
|
|||||||
from .objects import SimObject, ObjectState, ObjectControl
|
from .objects import SimObject, ObjectState, ObjectControl
|
||||||
from .manager import ObjectManager
|
from .manager import ObjectManager
|
||||||
from .registry import BodyRegistry
|
from .registry import BodyRegistry
|
||||||
|
from .sync_plan import BodySyncPlan
|
||||||
from .geometry.base import CutLink, SensorCell, Geometry
|
from .geometry.base import CutLink, SensorCell, Geometry
|
||||||
from .geometry.circle import CircleGeometry
|
from .geometry.circle import CircleGeometry
|
||||||
from .coupling.soa_packer import pack_cut_links_to_soa, pack_sensor_to_soa
|
from .coupling.soa_packer import pack_cut_links_to_soa, pack_sensor_to_soa
|
||||||
@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"SimObject", "ObjectState", "ObjectControl",
|
"SimObject", "ObjectState", "ObjectControl",
|
||||||
"ObjectManager",
|
"ObjectManager",
|
||||||
"BodyRegistry",
|
"BodyRegistry",
|
||||||
|
"BodySyncPlan",
|
||||||
"CutLink", "SensorCell", "Geometry",
|
"CutLink", "SensorCell", "Geometry",
|
||||||
"CircleGeometry",
|
"CircleGeometry",
|
||||||
"pack_cut_links_to_soa", "pack_sensor_to_soa",
|
"pack_cut_links_to_soa", "pack_sensor_to_soa",
|
||||||
|
|||||||
239
src/CelerisLab/body/ddf_patch.py
Normal file
239
src/CelerisLab/body/ddf_patch.py
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
# CelerisLab/body/ddf_patch.py
|
||||||
|
"""
|
||||||
|
Host-side DDF patching for runtime body topology sync. (D2Q9 only)
|
||||||
|
|
||||||
|
When bodies are added or removed at runtime, the host DDF must be
|
||||||
|
patched so that newly-solid cells contain equilibrium (not stale
|
||||||
|
fluid values) and newly-fluid cells initialise from their neighbours.
|
||||||
|
|
||||||
|
Called by ``Simulation.sync_bodies()`` after topology rebuild and
|
||||||
|
before GPU upload.
|
||||||
|
|
||||||
|
All equilibrium calculations use ``lbm/equilibrium.py`` which is D2Q9-only.
|
||||||
|
D3Q19 DDF patching is not yet implemented on the host side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pycuda.driver as cuda
|
||||||
|
|
||||||
|
from ..lbm.descriptors import FLUID
|
||||||
|
from ..lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf
|
||||||
|
|
||||||
|
|
||||||
|
def patch_ddf_for_body_sync(
|
||||||
|
field,
|
||||||
|
old_ddf: np.ndarray,
|
||||||
|
old_flags: np.ndarray,
|
||||||
|
new_flags: np.ndarray,
|
||||||
|
added_solid_mask: np.ndarray,
|
||||||
|
released_fluid_mask: np.ndarray,
|
||||||
|
curved_fluid_indices: np.ndarray | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Patch host-side DDF after a body topology change.
|
||||||
|
|
||||||
|
Operates on physical DDF (``field.ddf``). After patching, the caller
|
||||||
|
should call ``upload_patched_ddf()`` to push only the changed cells
|
||||||
|
to both GPU buffers.
|
||||||
|
|
||||||
|
Three patch types:
|
||||||
|
|
||||||
|
* ``old_fluid -> new_solid``: Write equilibrium at rest (rho=RHO, u=0)
|
||||||
|
so stale fluid values do not linger inside new solid cells.
|
||||||
|
|
||||||
|
* ``old_solid -> new_fluid``: BFS inward fill from surrounding old-fluid
|
||||||
|
cells. Layer-0 cells average their old-fluid neighbours; deeper layers
|
||||||
|
average the previously filled layer. Fallback: equilibrium at inlet
|
||||||
|
velocity.
|
||||||
|
|
||||||
|
* **Boundary fluid cells** (new curved boundary neighbours): If new
|
||||||
|
solid cells appear, the fluid cells just outside them (on curved
|
||||||
|
boundary links) are also set to equilibrium at their local velocity
|
||||||
|
(or rest if NaN), preventing abrupt-boundary instability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field: LBMField instance (``field.ddf`` is modified in-place).
|
||||||
|
old_ddf: Physical DDF snapshot before topology change (n*NQ, float32).
|
||||||
|
old_flags: Flag array before topology change.
|
||||||
|
new_flags: Flag array after topology change.
|
||||||
|
added_solid_mask: Bool array -- True where fluid became solid.
|
||||||
|
released_fluid_mask: Bool array -- True where obstacle became fluid.
|
||||||
|
curved_fluid_indices: Optional array of fluid cell indices on curved
|
||||||
|
boundary links. When *added_solid_mask* has any True entries,
|
||||||
|
these cells are set to equilibrium at local velocity (or rest).
|
||||||
|
"""
|
||||||
|
_patch_added_solid(field, added_solid_mask)
|
||||||
|
_patch_boundary_fluid(field, curved_fluid_indices, added_solid_mask)
|
||||||
|
_patch_released_fluid(field, old_ddf, old_flags, released_fluid_mask)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_patched_ddf(
|
||||||
|
field,
|
||||||
|
added_solid_mask: np.ndarray,
|
||||||
|
released_fluid_mask: np.ndarray,
|
||||||
|
curved_fluid_indices: np.ndarray | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Upload only the DDF cells that changed during patching.
|
||||||
|
|
||||||
|
Uses per-cell ``cuda.memcpy_htod`` for both ``ddf_gpu`` and
|
||||||
|
``temp_gpu``. Leaves all other cells (especially boundary cells
|
||||||
|
with NaN) untouched on the GPU, preserving their streaming-
|
||||||
|
compatible values from before the sync.
|
||||||
|
"""
|
||||||
|
changed_indices: set[int] = set()
|
||||||
|
changed_indices.update(np.where(added_solid_mask)[0].tolist())
|
||||||
|
changed_indices.update(np.where(released_fluid_mask)[0].tolist())
|
||||||
|
if curved_fluid_indices is not None:
|
||||||
|
changed_indices.update(curved_fluid_indices.tolist())
|
||||||
|
if not changed_indices:
|
||||||
|
field.invalidate_host_ddf_cache()
|
||||||
|
field._host_ddf_step = None
|
||||||
|
return
|
||||||
|
|
||||||
|
nq = field.nq
|
||||||
|
cell_bytes = nq * 4
|
||||||
|
ddf_ptr = int(field.ddf_gpu)
|
||||||
|
temp_ptr = int(field.temp_gpu)
|
||||||
|
|
||||||
|
for idx in sorted(changed_indices):
|
||||||
|
offset = idx * cell_bytes
|
||||||
|
cell_data = field.ddf[idx * nq:(idx + 1) * nq].tobytes()
|
||||||
|
cuda.memcpy_htod(ddf_ptr + offset, cell_data)
|
||||||
|
cuda.memcpy_htod(temp_ptr + offset, cell_data)
|
||||||
|
|
||||||
|
field.invalidate_host_ddf_cache()
|
||||||
|
field._host_ddf_step = None
|
||||||
|
|
||||||
|
|
||||||
|
# -- Internal helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
def _patch_added_solid(field, added_solid_mask: np.ndarray) -> None:
|
||||||
|
"""Write resting equilibrium for cells that became solid."""
|
||||||
|
if not np.any(added_solid_mask):
|
||||||
|
return
|
||||||
|
rho0 = field.cfg.rho
|
||||||
|
feq = compute_feq_d2q9(rho0, 0.0, 0.0)
|
||||||
|
indices = np.where(added_solid_mask)[0]
|
||||||
|
nq = field.nq
|
||||||
|
for idx in indices:
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_boundary_fluid(
|
||||||
|
field,
|
||||||
|
curved_fluid_indices: np.ndarray | None,
|
||||||
|
added_solid_mask: np.ndarray,
|
||||||
|
) -> None:
|
||||||
|
"""Patch fluid cells on new curved boundary links to local equilibrium."""
|
||||||
|
if curved_fluid_indices is None or len(curved_fluid_indices) == 0:
|
||||||
|
return
|
||||||
|
if not np.any(added_solid_mask):
|
||||||
|
return
|
||||||
|
nq = field.nq
|
||||||
|
feq_fallback = compute_feq_d2q9(field.cfg.rho, 0.0, 0.0)
|
||||||
|
for idx in curved_fluid_indices:
|
||||||
|
f = field.ddf[idx * nq:(idx + 1) * nq]
|
||||||
|
if np.any(np.isnan(f)):
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq_fallback
|
||||||
|
continue
|
||||||
|
rho, ux, uy = compute_macro_from_ddf(f)
|
||||||
|
feq = compute_feq_d2q9(float(rho), float(ux), float(uy))
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_released_fluid(
|
||||||
|
field,
|
||||||
|
old_ddf: np.ndarray,
|
||||||
|
old_flags: np.ndarray,
|
||||||
|
released_fluid_mask: np.ndarray,
|
||||||
|
) -> None:
|
||||||
|
"""BFS inward fill for cells that became fluid after body removal."""
|
||||||
|
if not np.any(released_fluid_mask):
|
||||||
|
return
|
||||||
|
|
||||||
|
nx, ny = field.nx, field.ny
|
||||||
|
nq = field.nq
|
||||||
|
old_is_fluid = (old_flags & FLUID) != 0
|
||||||
|
released = np.where(released_fluid_mask)[0]
|
||||||
|
|
||||||
|
filled = np.zeros(field.n, dtype=bool)
|
||||||
|
|
||||||
|
offsets = [
|
||||||
|
(-1, -1), (-1, 0), (-1, 1),
|
||||||
|
(0, -1), (0, 1),
|
||||||
|
(1, -1), (1, 0), (1, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Precompute macroscopic from old_ddf for old-fluid cells
|
||||||
|
# Skip cells with NaN (pre-existing body curvature may have
|
||||||
|
# contaminated boundary-fluid DDF values).
|
||||||
|
old_macro: dict[int, tuple[float, float, float]] = {}
|
||||||
|
fluid_indices = np.where(old_is_fluid)[0]
|
||||||
|
for idx in fluid_indices:
|
||||||
|
f = old_ddf[idx * nq:(idx + 1) * nq]
|
||||||
|
if np.any(np.isnan(f)):
|
||||||
|
continue
|
||||||
|
rho, ux, uy = compute_macro_from_ddf(f)
|
||||||
|
old_macro[idx] = (rho, ux, uy)
|
||||||
|
|
||||||
|
def _neighbours(idx: int):
|
||||||
|
x = idx % nx
|
||||||
|
y = idx // nx
|
||||||
|
for dx, dy in offsets:
|
||||||
|
xn, yn = x + dx, y + dy
|
||||||
|
if 0 <= xn < nx and 0 <= yn < ny:
|
||||||
|
yield xn + yn * nx
|
||||||
|
|
||||||
|
prev_layer_filled: dict[int, tuple[float, float, float]] = {}
|
||||||
|
remaining = set(released.tolist())
|
||||||
|
|
||||||
|
for idx in released:
|
||||||
|
nbrs = list(_neighbours(idx))
|
||||||
|
fluid_nbrs = [n for n in nbrs if n in old_macro]
|
||||||
|
if not fluid_nbrs:
|
||||||
|
continue
|
||||||
|
rho_avg = np.mean([old_macro[n][0] for n in fluid_nbrs])
|
||||||
|
ux_avg = np.mean([old_macro[n][1] for n in fluid_nbrs])
|
||||||
|
uy_avg = np.mean([old_macro[n][2] for n in fluid_nbrs])
|
||||||
|
feq = compute_feq_d2q9(float(rho_avg), float(ux_avg), float(uy_avg))
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq
|
||||||
|
filled[idx] = True
|
||||||
|
prev_layer_filled[idx] = (
|
||||||
|
float(rho_avg), float(ux_avg), float(uy_avg))
|
||||||
|
remaining.discard(idx)
|
||||||
|
|
||||||
|
# BFS layers
|
||||||
|
max_layers = max(nx, ny)
|
||||||
|
for _ in range(max_layers):
|
||||||
|
if not remaining:
|
||||||
|
break
|
||||||
|
current_layer: dict[int, tuple[float, float, float]] = {}
|
||||||
|
for idx in list(remaining):
|
||||||
|
nbrs = list(_neighbours(idx))
|
||||||
|
filled_nbrs = [n for n in nbrs if n in prev_layer_filled]
|
||||||
|
if not filled_nbrs:
|
||||||
|
continue
|
||||||
|
rho_avg = np.mean([prev_layer_filled[n][0] for n in filled_nbrs])
|
||||||
|
ux_avg = np.mean([prev_layer_filled[n][1] for n in filled_nbrs])
|
||||||
|
uy_avg = np.mean([prev_layer_filled[n][2] for n in filled_nbrs])
|
||||||
|
feq = compute_feq_d2q9(
|
||||||
|
float(rho_avg), float(ux_avg), float(uy_avg))
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq
|
||||||
|
filled[idx] = True
|
||||||
|
current_layer[idx] = (
|
||||||
|
float(rho_avg), float(ux_avg), float(uy_avg))
|
||||||
|
remaining.discard(idx)
|
||||||
|
prev_layer_filled = current_layer
|
||||||
|
|
||||||
|
# Fallback for any remaining unfilled cells
|
||||||
|
u_inlet = field.cfg.velocity
|
||||||
|
for idx in remaining:
|
||||||
|
y = idx // nx
|
||||||
|
ux_fallback = (
|
||||||
|
u_inlet * 4.0 * y * (ny - 1 - y) / ((ny - 1) ** 2)
|
||||||
|
if ny > 2 else u_inlet)
|
||||||
|
feq = compute_feq_d2q9(field.cfg.rho, float(ux_fallback), 0.0)
|
||||||
|
field.ddf[idx * nq:(idx + 1) * nq] = feq
|
||||||
@ -135,3 +135,19 @@ class Geometry(ABC):
|
|||||||
uint16 array of shape (nx*ny,) with flag bits set for sensor cells.
|
uint16 array of shape (nx*ny,) with flag bits set for sensor cells.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
|
def build_force_region_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||||
|
"""Return (nx*ny,) uint16 flag mask for force-region cells.
|
||||||
|
|
||||||
|
This variant marks force-region cells (``FLUID|FRC_REGION``).
|
||||||
|
Default implementation calls ``build_sensor_flag_mask`` for
|
||||||
|
subclasses that treat sensor and force-region identically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nx, ny: Grid dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
uint16 array of shape (nx*ny,) with flag bits set for
|
||||||
|
force-region cells.
|
||||||
|
"""
|
||||||
|
return self.build_sensor_flag_mask(nx, ny)
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import numpy as np
|
|||||||
|
|
||||||
from .base import CutLink, Geometry, SensorCell
|
from .base import CutLink, Geometry, SensorCell
|
||||||
from ...lbm.descriptors import (
|
from ...lbm.descriptors import (
|
||||||
FLUID, SOLID, OBSTACLE, BC_CURVED, SENSOR_FLAG,
|
FLUID, SOLID, OBSTACLE, BC_CURVED, SENSOR_FLAG, FRC_REGION,
|
||||||
D2Q9_EX, D2Q9_EY,
|
D2Q9_EX, D2Q9_EY,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -175,6 +175,10 @@ class CircleGeometry(Geometry):
|
|||||||
"""Return (nx*ny,) uint16 flag mask with FLUID|SENSOR_FLAG."""
|
"""Return (nx*ny,) uint16 flag mask with FLUID|SENSOR_FLAG."""
|
||||||
return self._build_flag_mask_inner(nx, ny, FLUID | SENSOR_FLAG)
|
return self._build_flag_mask_inner(nx, ny, FLUID | SENSOR_FLAG)
|
||||||
|
|
||||||
|
def build_force_region_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||||
|
"""Return (nx*ny,) uint16 flag mask with FLUID|FRC_REGION."""
|
||||||
|
return self._build_flag_mask_inner(nx, ny, FLUID | FRC_REGION)
|
||||||
|
|
||||||
def _build_flag_mask_inner(self, nx: int, ny: int, flag_val: int) -> np.ndarray:
|
def _build_flag_mask_inner(self, nx: int, ny: int, flag_val: int) -> np.ndarray:
|
||||||
n = nx * ny
|
n = nx * ny
|
||||||
mask = np.zeros(n, dtype=np.uint16)
|
mask = np.zeros(n, dtype=np.uint16)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ ObjectManager -- batch management of SimObjects.
|
|||||||
|
|
||||||
Responsibilities:
|
Responsibilities:
|
||||||
- Add / remove / query objects (delegated to ``BodyRegistry``)
|
- 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
|
- Build merged flag masks and compact cut-link / sensor lists
|
||||||
- Allocate packed telemetry ``obs_gpu`` + pagelocked mirror ``obs_pinned``
|
- Allocate packed telemetry ``obs_gpu`` + pagelocked mirror ``obs_pinned``
|
||||||
- Sync geometry to :class:`~CelerisLab.lbm.field.LBMField`
|
- Sync geometry to :class:`~CelerisLab.lbm.field.LBMField`
|
||||||
@ -11,13 +12,18 @@ Responsibilities:
|
|||||||
Design::
|
Design::
|
||||||
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
|
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
|
||||||
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler_v2.obs_layout`.
|
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler_v2.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 __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, List, NamedTuple, Optional, TYPE_CHECKING
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pycuda.driver as cuda
|
import pycuda.driver as cuda
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from .objects import SimObject
|
from .objects import SimObject
|
||||||
from .registry import BodyRegistry
|
from .registry import BodyRegistry
|
||||||
@ -32,6 +38,20 @@ from ..lbm.descriptors import (
|
|||||||
D3Q19_EZ,
|
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:
|
class ObjectManager:
|
||||||
"""Central registry for all simulation objects.
|
"""Central registry for all simulation objects.
|
||||||
@ -70,6 +90,12 @@ class ObjectManager:
|
|||||||
self.obs_nbytes: int = 0
|
self.obs_nbytes: int = 0
|
||||||
|
|
||||||
self._telemetry_field: Optional[object] = None
|
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
|
# 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).
|
# when bodies.count == 0 (matches _resize_buffers sizing for n=0).
|
||||||
self._resize_buffers()
|
self._resize_buffers()
|
||||||
@ -113,24 +139,217 @@ class ObjectManager:
|
|||||||
new_sensor_counts[:copy_n] = self.sensor_cell_counts[:copy_n]
|
new_sensor_counts[:copy_n] = self.sensor_cell_counts[:copy_n]
|
||||||
self.sensor_cell_counts = new_sensor_counts
|
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.
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 ----------------------------------------------------
|
# -- Flag composition ----------------------------------------------------
|
||||||
def build_flags(self, base_flags: np.ndarray) -> np.ndarray:
|
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.
|
"""Merge object flag masks onto a clean domain base.
|
||||||
|
|
||||||
Callers should pass a fresh channel-layout flag array. This keeps object
|
Args:
|
||||||
overlays stateless and prevents stale obstacle bits from surviving after
|
objects: List of SimObject instances.
|
||||||
geometry edits or future body motion.
|
base_flags: Fresh channel-layout flag array.
|
||||||
|
nx, ny, nz: Grid dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New uint16 array with object overlays applied.
|
||||||
"""
|
"""
|
||||||
masks = [
|
masks = [obj.get_flag_mask(nx, ny, nz) for obj in objects]
|
||||||
obj.get_flag_mask(self.nx, self.ny, self.nz)
|
|
||||||
for obj in self._registry.objects
|
|
||||||
]
|
|
||||||
return merge_flag_masks(base_flags, masks)
|
return merge_flag_masks(base_flags, masks)
|
||||||
|
|
||||||
# -- Compact list building -----------------------------------------------
|
# -- Compact list building -----------------------------------------------
|
||||||
def build_compact_lists(self, domain_flags: np.ndarray | None = None):
|
def build_compact_lists(self, domain_flags: np.ndarray | None = 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_compact_lists_for(
|
||||||
|
self,
|
||||||
|
objects: List[SimObject],
|
||||||
|
domain_flags: np.ndarray | None = None,
|
||||||
|
*,
|
||||||
|
sensor_cell_counts_out: np.ndarray | None = None,
|
||||||
|
):
|
||||||
"""Build cut-link SoA columns, sensor lists, and force-region lists.
|
"""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 donor-cell classification.
|
||||||
|
sensor_cell_counts_out: Int array to fill with per-object sensor
|
||||||
|
cell counts. If provided, ``fill(0)`` is called first.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
cl_fluid_idx, cl_dir, cl_q, cl_body_id, cl_rx, cl_ry, cl_rz,
|
cl_fluid_idx, cl_dir, cl_q, cl_body_id, cl_rx, cl_ry, cl_rz,
|
||||||
cl_fallback_class,
|
cl_fallback_class,
|
||||||
@ -141,7 +360,9 @@ class ObjectManager:
|
|||||||
cl_rx, cl_ry, cl_rz, cl_fallback = [], [], [], []
|
cl_rx, cl_ry, cl_rz, cl_fallback = [], [], [], []
|
||||||
s_cells, s_ids = [], []
|
s_cells, s_ids = [], []
|
||||||
fr_cells, fr_ids = [], []
|
fr_cells, fr_ids = [], []
|
||||||
self.sensor_cell_counts.fill(0)
|
|
||||||
|
if sensor_cell_counts_out is not None:
|
||||||
|
sensor_cell_counts_out.fill(0)
|
||||||
|
|
||||||
ez = None
|
ez = None
|
||||||
if self.cfg and self.cfg.is_d3q19:
|
if self.cfg and self.cfg.is_d3q19:
|
||||||
@ -149,8 +370,7 @@ class ObjectManager:
|
|||||||
else:
|
else:
|
||||||
ex, ey = D2Q9_EX, D2Q9_EY
|
ex, ey = D2Q9_EX, D2Q9_EY
|
||||||
|
|
||||||
for obj in self._registry.objects:
|
for obj in objects:
|
||||||
# Curved list: only real obstacle bodies (not sensors, not force_regions).
|
|
||||||
if (not obj.is_sensor) and (not obj.is_force_region) and hasattr(obj, 'get_curved_list'):
|
if (not obj.is_sensor) and (not obj.is_force_region) and hasattr(obj, 'get_curved_list'):
|
||||||
(
|
(
|
||||||
fluid_idx, dirs, q_vals, body_ids,
|
fluid_idx, dirs, q_vals, body_ids,
|
||||||
@ -174,8 +394,9 @@ class ObjectManager:
|
|||||||
if len(cells) > 0:
|
if len(cells) > 0:
|
||||||
s_cells.append(cells)
|
s_cells.append(cells)
|
||||||
s_ids.append(ids)
|
s_ids.append(ids)
|
||||||
if 0 <= obj.obj_id < self.sensor_cell_counts.size:
|
if (sensor_cell_counts_out is not None
|
||||||
self.sensor_cell_counts[obj.obj_id] = int(len(cells))
|
and 0 <= obj.obj_id < sensor_cell_counts_out.size):
|
||||||
|
sensor_cell_counts_out[obj.obj_id] = int(len(cells))
|
||||||
if obj.is_force_region and hasattr(obj, 'get_sensor_list'):
|
if obj.is_force_region and hasattr(obj, 'get_sensor_list'):
|
||||||
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
|
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
|
||||||
if len(cells) > 0:
|
if len(cells) > 0:
|
||||||
@ -257,7 +478,8 @@ class ObjectManager:
|
|||||||
self.action_gpu = None
|
self.action_gpu = None
|
||||||
self.action_gpu = cuda.mem_alloc(action_nbytes)
|
self.action_gpu = cuda.mem_alloc(action_nbytes)
|
||||||
self._action_nbytes = action_nbytes
|
self._action_nbytes = action_nbytes
|
||||||
cuda.memcpy_htod(self.action_gpu, self.action)
|
# 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 None or self._obs_alloc_nbytes != self.obs_nbytes:
|
||||||
if self.obs_gpu is not None:
|
if self.obs_gpu is not None:
|
||||||
@ -321,6 +543,12 @@ class ObjectManager:
|
|||||||
assert self.obs_pinned is not None
|
assert self.obs_pinned is not None
|
||||||
cuda.memcpy_dtoh_async(self.obs_pinned, self.obs_gpu, stream)
|
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."""
|
||||||
|
if self.action_gpu is None or self.action.size == 0:
|
||||||
|
return
|
||||||
|
cuda.memcpy_htod_async(self.action_gpu, self.action, stream)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def obs_n_slots(self) -> int:
|
def obs_n_slots(self) -> int:
|
||||||
"""At-least-one slot count ``max(count, 1)`` matching ``config_obs.h``."""
|
"""At-least-one slot count ``max(count, 1)`` matching ``config_obs.h``."""
|
||||||
@ -380,20 +608,41 @@ class ObjectManager:
|
|||||||
return values
|
return values
|
||||||
return values / np.float32(count)
|
return values / np.float32(count)
|
||||||
|
|
||||||
|
def read_body(self, body_id: int) -> BodyTelemetry:
|
||||||
|
"""Return unified telemetry for one body from the pinned obs buffer.
|
||||||
|
|
||||||
|
The caller must ensure ``run(sync_obs=True)`` or an explicit
|
||||||
|
``download_obs_full_async + synchronize`` has completed.
|
||||||
|
"""
|
||||||
|
force = self.read_force(body_id)
|
||||||
|
torque = self.read_torque(body_id)
|
||||||
|
sensor = self.read_sensor(body_id, normalize=True)
|
||||||
|
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 set_body_state(self, body_id: int, omega: float = 0.0) -> None:
|
def set_body_state(self, body_id: int, omega: float = 0.0) -> None:
|
||||||
"""Set runtime body state used by kernels (currently only omega)."""
|
"""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)
|
self._validate_body_id(body_id)
|
||||||
dim = self.cfg.dim if self.cfg else 2
|
dim = self.cfg.dim if self.cfg else 2
|
||||||
slot = 3 * dim
|
slot = 3 * dim
|
||||||
base = body_id * slot
|
base = body_id * slot
|
||||||
self.action[base + slot - 1] = np.float32(omega)
|
self.action[base + slot - 1] = np.float32(omega)
|
||||||
if self.action_gpu is not None:
|
|
||||||
cuda.memcpy_htod(self.action_gpu, self.action)
|
|
||||||
|
|
||||||
def set_force_state(self, body_id: int, fx: float = 0.0, fy: float = 0.0) -> None:
|
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 (action slot 0/1).
|
"""Set force density on a force_region object (host action only, no H2D).
|
||||||
|
|
||||||
Only touches the force component slots; does not affect omega.
|
The GPU action buffer is updated at the next ``run()`` call.
|
||||||
"""
|
"""
|
||||||
self._validate_body_id(body_id)
|
self._validate_body_id(body_id)
|
||||||
dim = self.cfg.dim if self.cfg else 2
|
dim = self.cfg.dim if self.cfg else 2
|
||||||
@ -401,8 +650,6 @@ class ObjectManager:
|
|||||||
base = body_id * slot
|
base = body_id * slot
|
||||||
self.action[base + 0] = np.float32(fx)
|
self.action[base + 0] = np.float32(fx)
|
||||||
self.action[base + 1] = np.float32(fy)
|
self.action[base + 1] = np.float32(fy)
|
||||||
if self.action_gpu is not None:
|
|
||||||
cuda.memcpy_htod(self.action_gpu, self.action)
|
|
||||||
|
|
||||||
def _validate_body_id(self, body_id: int) -> None:
|
def _validate_body_id(self, body_id: int) -> None:
|
||||||
if body_id < 0 or body_id >= self.count:
|
if body_id < 0 or body_id >= self.count:
|
||||||
|
|||||||
@ -118,11 +118,11 @@ class SimObject:
|
|||||||
"""Return (nx*ny,) uint16 array with flag bits set for this object.
|
"""Return (nx*ny,) uint16 array with flag bits set for this object.
|
||||||
|
|
||||||
Sensors produce ``FLUID|SENSOR_FLAG``; bodies produce
|
Sensors produce ``FLUID|SENSOR_FLAG``; bodies produce
|
||||||
``SOLID|OBSTACLE|BC_CURVED``. Force-region objects produce zero
|
``SOLID|OBSTACLE|BC_CURVED``; force-region objects produce
|
||||||
(no flag overlay — forcing is applied via the compact-list kernel).
|
``FLUID|FRC_REGION``.
|
||||||
"""
|
"""
|
||||||
if self._is_force_region:
|
if self._is_force_region:
|
||||||
return np.zeros(nx * max(ny, 1) * max(nz, 1), dtype=np.uint16)
|
return self.geometry.build_force_region_flag_mask(nx, ny)
|
||||||
if self._is_sensor:
|
if self._is_sensor:
|
||||||
return self.geometry.build_sensor_flag_mask(nx, ny)
|
return self.geometry.build_sensor_flag_mask(nx, ny)
|
||||||
return self.geometry.build_flag_mask(nx, ny)
|
return self.geometry.build_flag_mask(nx, ny)
|
||||||
|
|||||||
52
src/CelerisLab/body/sync_plan.py
Normal file
52
src/CelerisLab/body/sync_plan.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# CelerisLab/body/sync_plan.py
|
||||||
|
"""
|
||||||
|
BodySyncPlan -- topology change descriptor for runtime body sync. (D2Q9 only)
|
||||||
|
|
||||||
|
Design:
|
||||||
|
A ``BodySyncPlan`` is constructed by ``ObjectManager.build_sync_plan()``
|
||||||
|
and consumed by ``Simulation.sync_bodies()``. It describes the full
|
||||||
|
topology transition from the current formal object set to the next one,
|
||||||
|
including flags, compact lists, and cell-level change masks.
|
||||||
|
|
||||||
|
The plan does NOT hold DDF data -- that must be read from ``LBMField``
|
||||||
|
at sync time. Keeping the plan free of large array copies makes it
|
||||||
|
cheap to construct and discard.
|
||||||
|
|
||||||
|
Change masks use D2Q9 neighbour offsets. D3Q19 support would require
|
||||||
|
extending ``released_fluid_mask`` neighbour enumeration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .objects import SimObject
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BodySyncPlan:
|
||||||
|
"""Topology transition plan produced by ``ObjectManager.build_sync_plan()``.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
next_objects: New formal object list with ``obj_id = 0..n-1``.
|
||||||
|
next_count: Length of *next_objects*.
|
||||||
|
next_flags: Merged uint16 flag array for the new topology.
|
||||||
|
curved_host: 8-tuple of SoA arrays for CurvedLinkSoA.assign_host().
|
||||||
|
sensor_host: 2-tuple (cells, obj_id) for SensorSoA.assign_host().
|
||||||
|
force_region_host: 2-tuple (cells, obj_id) for ForceRegionSoA.assign_host().
|
||||||
|
added_solid_mask: Bool array -- True where ``old_fluid -> new_solid``.
|
||||||
|
released_fluid_mask: Bool array -- True where ``old_obstacle -> new_fluid``.
|
||||||
|
new_sensor_counts: Int array of sensor cell counts per object.
|
||||||
|
"""
|
||||||
|
next_objects: List[SimObject]
|
||||||
|
next_count: int
|
||||||
|
next_flags: np.ndarray
|
||||||
|
curved_host: tuple
|
||||||
|
sensor_host: tuple
|
||||||
|
force_region_host: tuple
|
||||||
|
added_solid_mask: np.ndarray
|
||||||
|
released_fluid_mask: np.ndarray
|
||||||
|
new_sensor_counts: np.ndarray
|
||||||
@ -70,6 +70,9 @@ def save_checkpoint(field, stepper, lbm_cfg, bodies, path=None):
|
|||||||
hf.attrs["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
hf.attrs["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
hf.attrs["step_count"] = stepper.step_count
|
hf.attrs["step_count"] = stepper.step_count
|
||||||
|
|
||||||
|
# Object count metadata for runtime sync compatibility
|
||||||
|
hf.attrs["n_objects"] = bodies.count
|
||||||
|
|
||||||
# Config as JSON string (human-readable in HDF5 viewers)
|
# Config as JSON string (human-readable in HDF5 viewers)
|
||||||
cfg = lbm_cfg
|
cfg = lbm_cfg
|
||||||
config_dict = {
|
config_dict = {
|
||||||
@ -156,6 +159,17 @@ def load_checkpoint(path, field, stepper, lbm_cfg, bodies):
|
|||||||
"checkpoint file",
|
"checkpoint file",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Object count validation: checkpoint's n_objects must match
|
||||||
|
# the compiled N_OBJS (which equals current bodies.count).
|
||||||
|
ckpt_n_objects = int(hf.attrs.get("n_objects", 0))
|
||||||
|
if ckpt_n_objects != bodies.count:
|
||||||
|
raise ValueError(
|
||||||
|
f"Object count mismatch: checkpoint has {ckpt_n_objects} "
|
||||||
|
f"objects, but the simulation is compiled with "
|
||||||
|
f"N_OBJS={bodies.count}. Use sync_bodies() to adjust "
|
||||||
|
"the object count before loading, or rebuild the "
|
||||||
|
"simulation with the correct number of objects.")
|
||||||
|
|
||||||
# Restore GPU arrays
|
# Restore GPU arrays
|
||||||
ddf_buf = hf["ddf"][:]
|
ddf_buf = hf["ddf"][:]
|
||||||
tmp_buf = hf["temp"][:]
|
tmp_buf = hf["temp"][:]
|
||||||
|
|||||||
@ -28,7 +28,7 @@ Python `config.py` 只负责读取和校验,不是配置位置。
|
|||||||
| 字段 | 类型 | 默认 | 允许值 | 说明 |
|
| 字段 | 类型 | 默认 | 允许值 | 说明 |
|
||||||
|------|------|------|--------|------|
|
|------|------|------|--------|------|
|
||||||
| `collision` | string | `"SRT"` | `SRT`, `TRT`, `MRT` | 碰撞算子 |
|
| `collision` | string | `"SRT"` | `SRT`, `TRT`, `MRT` | 碰撞算子 |
|
||||||
| `streaming` | string | `"double_buffer"` | `double_buffer`, `esopull` | 流传输方式 |
|
| `streaming` | string | `"double_buffer"` | `double_buffer`, `esopull` | 流传输方式。运行时 body 拓扑同步 (``sync_bodies()``) 仅支持 ``double_buffer`` |
|
||||||
| `store_precision` | string | `"FP32"` | `FP32`, `FP16S`, `FP16C` | GPU 存储精度。当前运行时已实现 `FP32` 与 `FP16S`,`FP16C` 仍为保留选项 |
|
| `store_precision` | string | `"FP32"` | `FP32`, `FP16S`, `FP16C` | GPU 存储精度。当前运行时已实现 `FP32` 与 `FP16S`,`FP16C` 仍为保留选项 |
|
||||||
| `ddf_shifting` | bool | false | | 存储 f−w 而非 f,提升 FP16 精度 |
|
| `ddf_shifting` | bool | false | | 存储 f−w 而非 f,提升 FP16 精度 |
|
||||||
| `les.enabled` | bool | false | | LES Smagorinsky 子格模型 |
|
| `les.enabled` | bool | false | | LES Smagorinsky 子格模型 |
|
||||||
@ -90,3 +90,14 @@ step/one_step_*.cu → kernel 编排
|
|||||||
|
|
||||||
- 默认 `method.omega_guard = {min: 0.01, max: 1.99}`,用于约束碰撞频率与 LES 有效粘度路径。
|
- 默认 `method.omega_guard = {min: 0.01, max: 1.99}`,用于约束碰撞频率与 LES 有效粘度路径。
|
||||||
- 高 Re 调参建议将 `omega_guard.max` 保持在 `1.90-1.99` 区间;过高上界会增大接近 `omega -> 2` 的不稳定风险。
|
- 高 Re 调参建议将 `omega_guard.max` 保持在 `1.90-1.99` 区间;过高上界会增大接近 `omega -> 2` 的不稳定风险。
|
||||||
|
|
||||||
|
### 运行时 body 拓扑同步
|
||||||
|
|
||||||
|
- 运行时增删 body(`add_body()` / `remove_body()` + `sync_bodies()`)仅支持 `streaming: "double_buffer"`。
|
||||||
|
- `esopull` 模式下调用 `sync_bodies()` 会抛出 `NotImplementedError`。
|
||||||
|
|
||||||
|
### 力区域标记(Force region flag)
|
||||||
|
|
||||||
|
D2Q9/D3Q19 的 flag 扩展位 `0x0800` 定义为 `FRC_REGION`,用于标记 force_region 类型物体的格子。
|
||||||
|
- ForceRegionKernel 不依赖 flag 位执行,flag 只用于后处理可视化。
|
||||||
|
- `merge_flag_masks` 会正确合并 force_region 的 `FLUID | FRC_REGION` 标记。
|
||||||
@ -31,6 +31,7 @@ BC_MOVING = 0x0060
|
|||||||
# Extension [15:8]
|
# Extension [15:8]
|
||||||
SENSOR_FLAG = 0x0100
|
SENSOR_FLAG = 0x0100
|
||||||
OBSTACLE = 0x0200
|
OBSTACLE = 0x0200
|
||||||
|
FRC_REGION = 0x0800
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# D2Q9 lattice velocity vectors
|
# D2Q9 lattice velocity vectors
|
||||||
|
|||||||
60
src/CelerisLab/lbm/equilibrium.py
Normal file
60
src/CelerisLab/lbm/equilibrium.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# CelerisLab/lbm/equilibrium.py
|
||||||
|
"""
|
||||||
|
Host-side equilibrium and macroscopic helpers for D2Q9. (D2Q9 only)
|
||||||
|
|
||||||
|
These functions are shared by LBMField (macroscopic computation) and
|
||||||
|
body DDF patching. They operate on host NumPy arrays only and do not
|
||||||
|
require a CUDA module.
|
||||||
|
|
||||||
|
For the authoritative GPU-side equilibrium, see ``operators/equilibrium.cuh``.
|
||||||
|
|
||||||
|
Callers with ``dim != 2`` must not call these functions. The D3Q19
|
||||||
|
equilibrium path lives in the CUDA kernel layer and is not mirrored on
|
||||||
|
the host at present.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# D2Q9 velocity vectors (must match descriptors.cuh / descriptors.py)
|
||||||
|
_CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float32)
|
||||||
|
_CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float32)
|
||||||
|
_W = np.array([4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36],
|
||||||
|
dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_feq_d2q9(rho: float, ux: float, uy: float) -> np.ndarray:
|
||||||
|
"""Compute D2Q9 equilibrium distribution ``f_eq``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rho: Macroscopic density.
|
||||||
|
ux: x-velocity (lattice units).
|
||||||
|
uy: y-velocity (lattice units).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
9-element float32 array ``[f0 .. f8]``.
|
||||||
|
"""
|
||||||
|
u_sq = ux * ux + uy * uy
|
||||||
|
feq = np.empty(9, dtype=np.float32)
|
||||||
|
for i in range(9):
|
||||||
|
cu = _CX[i] * ux + _CY[i] * uy
|
||||||
|
feq[i] = rho * _W[i] * (
|
||||||
|
1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * u_sq)
|
||||||
|
return feq
|
||||||
|
|
||||||
|
|
||||||
|
def compute_macro_from_ddf(f: np.ndarray) -> tuple[float, float, float]:
|
||||||
|
"""Compute rho, ux, uy from a 9-element D2Q9 DDF array.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f: 9-element float32 array of distribution functions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``(rho, ux, uy)`` tuple.
|
||||||
|
"""
|
||||||
|
rho = float(np.sum(f))
|
||||||
|
rho_safe = rho if abs(rho) > 1e-12 else 1.0
|
||||||
|
ux = float(np.sum(f * _CX)) / rho_safe
|
||||||
|
uy = float(np.sum(f * _CY)) / rho_safe
|
||||||
|
return rho, ux, uy
|
||||||
@ -402,6 +402,11 @@ class LBMField:
|
|||||||
"uy": self._macro_uy.reshape(self.ny, self.nx),
|
"uy": self._macro_uy.reshape(self.ny, self.nx),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -- DDF patch for runtime body sync (delegated to body/ddf_patch.py) -----
|
||||||
|
# patch_ddf_for_body_sync and upload_patched_ddf are imported and called
|
||||||
|
# directly from Simulation.sync_bodies(). They are no longer methods on
|
||||||
|
# LBMField.
|
||||||
|
|
||||||
def snapshot(self):
|
def snapshot(self):
|
||||||
self.download_ddf(force=True)
|
self.download_ddf(force=True)
|
||||||
self._ddf_snap = self.ddf.copy()
|
self._ddf_snap = self.ddf.copy()
|
||||||
|
|||||||
@ -39,7 +39,8 @@
|
|||||||
#define FLAG_SENSOR ((uint16_t)0x0100)
|
#define FLAG_SENSOR ((uint16_t)0x0100)
|
||||||
#define FLAG_OBSTACLE ((uint16_t)0x0200)
|
#define FLAG_OBSTACLE ((uint16_t)0x0200)
|
||||||
#define FLAG_HALO ((uint16_t)0x0400)
|
#define FLAG_HALO ((uint16_t)0x0400)
|
||||||
// 0x0800..0x8000 reserved for multi-GPU / AMR / multi-phase
|
#define FLAG_FRC_REGION ((uint16_t)0x0800)
|
||||||
|
// 0x1000..0x8000 reserved for multi-GPU / AMR / multi-phase
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Masks
|
// Masks
|
||||||
@ -79,5 +80,6 @@ __device__ __forceinline__ bool has_bc(uint16_t fl) { return (fl & MASK_BC_T
|
|||||||
__device__ __forceinline__ bool is_sensor(uint16_t fl) { return (fl & FLAG_SENSOR) != 0; }
|
__device__ __forceinline__ bool is_sensor(uint16_t fl) { return (fl & FLAG_SENSOR) != 0; }
|
||||||
__device__ __forceinline__ bool is_obstacle(uint16_t fl) { return (fl & FLAG_OBSTACLE) != 0; }
|
__device__ __forceinline__ bool is_obstacle(uint16_t fl) { return (fl & FLAG_OBSTACLE) != 0; }
|
||||||
__device__ __forceinline__ bool is_halo(uint16_t fl) { return (fl & FLAG_HALO) != 0; }
|
__device__ __forceinline__ bool is_halo(uint16_t fl) { return (fl & FLAG_HALO) != 0; }
|
||||||
|
__device__ __forceinline__ bool is_force_region(uint16_t fl) { return (fl & FLAG_FRC_REGION) != 0; }
|
||||||
|
|
||||||
#endif // CELERIS_CORE_FLAGS_CUH
|
#endif // CELERIS_CORE_FLAGS_CUH
|
||||||
|
|||||||
@ -54,6 +54,8 @@ __device__ __forceinline__ uint16_t finalize_domain_flag(
|
|||||||
const uint16_t base = channel_flag_from_coords(x, y);
|
const uint16_t base = channel_flag_from_coords(x, y);
|
||||||
if (is_sensor(fl) && base == FLAG_FLUID)
|
if (is_sensor(fl) && base == FLAG_FLUID)
|
||||||
return (uint16_t)(base | FLAG_SENSOR);
|
return (uint16_t)(base | FLAG_SENSOR);
|
||||||
|
if (is_force_region(fl) && base == FLAG_FLUID)
|
||||||
|
return (uint16_t)(base | FLAG_FRC_REGION);
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ from .lbm.field import LBMField
|
|||||||
from .lbm.stepper import LBMStepper
|
from .lbm.stepper import LBMStepper
|
||||||
from .body.objects import SimObject
|
from .body.objects import SimObject
|
||||||
from .body.manager import ObjectManager
|
from .body.manager import ObjectManager
|
||||||
|
from .body.ddf_patch import patch_ddf_for_body_sync, upload_patched_ddf
|
||||||
|
|
||||||
|
|
||||||
class Simulation:
|
class Simulation:
|
||||||
@ -87,6 +88,10 @@ class Simulation:
|
|||||||
radius: float = 0.0, **kwargs) -> int:
|
radius: float = 0.0, **kwargs) -> int:
|
||||||
"""Add a simulation body. Returns body_id.
|
"""Add a simulation body. Returns body_id.
|
||||||
|
|
||||||
|
Before ``initialize()`` the object is added to the formal registry
|
||||||
|
immediately. After ``initialize()`` the object is staged via
|
||||||
|
``stage_add()`` and will be committed at the next ``sync_bodies()``.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
type: ``"circle"``, ``"sensor"``, or ``"force_region"``
|
type: ``"circle"``, ``"sensor"``, or ``"force_region"``
|
||||||
(future: ``"polygon"``, ``"mesh"``).
|
(future: ``"polygon"``, ``"mesh"``).
|
||||||
@ -95,7 +100,7 @@ class Simulation:
|
|||||||
**kwargs: passed to the geometry constructor.
|
**kwargs: passed to the geometry constructor.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int body_id.
|
int body_id (formal id before initialize; -1 if staged).
|
||||||
"""
|
"""
|
||||||
type_lower = str(type).strip().lower()
|
type_lower = str(type).strip().lower()
|
||||||
|
|
||||||
@ -125,8 +130,27 @@ class Simulation:
|
|||||||
f"Unknown body type '{type}'. "
|
f"Unknown body type '{type}'. "
|
||||||
"Supported: 'circle', 'sensor', 'force_region'."
|
"Supported: 'circle', 'sensor', 'force_region'."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
self.bodies.stage_add(obj)
|
||||||
|
return -1
|
||||||
return self.bodies.add(obj)
|
return self.bodies.add(obj)
|
||||||
|
|
||||||
|
def remove_body(self, id: int) -> None:
|
||||||
|
"""Remove a body from the simulation.
|
||||||
|
|
||||||
|
Before ``initialize()`` the object is removed from the formal registry
|
||||||
|
immediately. After ``initialize()`` the removal is staged and will be
|
||||||
|
committed at the next ``sync_bodies()``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
id: body_id from ``add_body()``.
|
||||||
|
"""
|
||||||
|
if self._initialized:
|
||||||
|
self.bodies.stage_remove(id)
|
||||||
|
else:
|
||||||
|
self.bodies.remove(id)
|
||||||
|
|
||||||
# -- Legacy convenience (keep for backward compat) -----------------------
|
# -- Legacy convenience (keep for backward compat) -----------------------
|
||||||
def add_cylinder(self, center: Tuple[float, ...],
|
def add_cylinder(self, center: Tuple[float, ...],
|
||||||
radius: float) -> int:
|
radius: float) -> int:
|
||||||
@ -174,7 +198,9 @@ class Simulation:
|
|||||||
# -- Runtime control -----------------------------------------------------
|
# -- Runtime control -----------------------------------------------------
|
||||||
def set_body(self, id: int, omega: float = None,
|
def set_body(self, id: int, omega: float = None,
|
||||||
vx: float = None, vy: float = None) -> None:
|
vx: float = None, vy: float = None) -> None:
|
||||||
"""Set runtime body state. Updates are implicitly uploaded to GPU.
|
"""Set runtime body state (host action array only, no H2D).
|
||||||
|
|
||||||
|
The GPU action buffer is updated at the next ``run()`` call.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id: body_id from add_body().
|
id: body_id from add_body().
|
||||||
@ -195,11 +221,11 @@ class Simulation:
|
|||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.bodies._refresh_action_from_objects()
|
self.bodies._refresh_action_from_objects()
|
||||||
if self.bodies.action_gpu is not None:
|
|
||||||
cuda.memcpy_htod(self.bodies.action_gpu, self.bodies.action)
|
|
||||||
|
|
||||||
def set_force(self, id: int, fx: float = 0.0, fy: float = 0.0) -> None:
|
def set_force(self, id: int, fx: float = 0.0, fy: float = 0.0) -> None:
|
||||||
"""Set force density on a force_region object (implicit GPU upload).
|
"""Set force density on a force_region object (host only, no H2D).
|
||||||
|
|
||||||
|
The GPU action buffer is updated at the next ``run()`` call.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id: body_id from add_body(type='force_region', ...).
|
id: body_id from add_body(type='force_region', ...).
|
||||||
@ -210,55 +236,113 @@ class Simulation:
|
|||||||
|
|
||||||
# -- Telemetry readback --------------------------------------------------
|
# -- Telemetry readback --------------------------------------------------
|
||||||
def read_force(self, id: int) -> np.ndarray:
|
def read_force(self, id: int) -> np.ndarray:
|
||||||
"""Download and return the force vector on body *id* (async stream, synced)."""
|
"""Return the force vector on body *id* from the pinned obs buffer."""
|
||||||
self._stream_obs_download()
|
if self.bodies.obs_pinned is None:
|
||||||
|
raise RuntimeError("No obs buffer. Call run() first.")
|
||||||
return self.bodies.read_force(id)
|
return self.bodies.read_force(id)
|
||||||
|
|
||||||
def read_torque(self, id: int) -> np.ndarray:
|
def read_torque(self, id: int) -> np.ndarray:
|
||||||
"""Download and return the torque on body *id* (async stream, synced)."""
|
"""Return the torque on body *id* from the pinned obs buffer."""
|
||||||
self._stream_obs_download()
|
if self.bodies.obs_pinned is None:
|
||||||
|
raise RuntimeError("No obs buffer. Call run() first.")
|
||||||
return self.bodies.read_torque(id)
|
return self.bodies.read_torque(id)
|
||||||
|
|
||||||
def read_sensor(self, id: int, *, normalize: bool = True) -> np.ndarray:
|
def read_sensor(self, id: int, *, normalize: bool = True) -> np.ndarray:
|
||||||
"""Download and return the sensor reading for body *id* (async stream, synced).
|
"""Return the sensor reading for body *id* from the pinned obs buffer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
normalize: If True, return area-averaged velocity. If False,
|
normalize: If True, return area-averaged velocity. If False,
|
||||||
return the raw sum accumulated by the GPU SensorKernel.
|
return the raw sum accumulated by the GPU SensorKernel.
|
||||||
"""
|
"""
|
||||||
self._stream_obs_download()
|
if self.bodies.obs_pinned is None:
|
||||||
|
raise RuntimeError("No obs buffer. Call run() first.")
|
||||||
return self.bodies.read_sensor(id, normalize=normalize)
|
return self.bodies.read_sensor(id, normalize=normalize)
|
||||||
|
|
||||||
def _stream_obs_download(self) -> None:
|
def read_body(self, id: int, *, stream: cuda.Stream | None = None):
|
||||||
"""Async obs download on internal stream, then synchronize."""
|
"""Return unified telemetry for one body.
|
||||||
if self.bodies.obs_pinned is not None:
|
|
||||||
self.bodies.download_obs_full_async(self.stream)
|
Args:
|
||||||
self.stream.synchronize()
|
id: body_id from ``add_body()``.
|
||||||
|
stream: Optional CUDA stream to synchronise before reading.
|
||||||
|
If ``None``, uses the internal stream.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BodyTelemetry with fields ``force``, ``torque``, ``sensor``.
|
||||||
|
``sensor`` is a dim-length array of zeros for non-sensor bodies.
|
||||||
|
"""
|
||||||
|
if self.bodies.obs_pinned is None:
|
||||||
|
raise RuntimeError("No obs buffer. Call run() first.")
|
||||||
|
if stream is None:
|
||||||
|
stream = self.stream
|
||||||
|
if stream is not None:
|
||||||
|
stream.synchronize()
|
||||||
|
return self.bodies.read_body(id)
|
||||||
|
|
||||||
|
def read_bodies(self, *, stream: cuda.Stream | None = None) -> np.ndarray:
|
||||||
|
"""Return all bodies' telemetry as a flat float32 array.
|
||||||
|
|
||||||
|
Layout follows the packed obs buffer: ``[force_0..n-1, torque_0..n-1, sensor_0..n-1]``
|
||||||
|
where each segment is ``max(n_objects, 1) * dim`` floats.
|
||||||
|
Total length equals ``obs_layout(dim, n_objects).total_floats``.
|
||||||
|
|
||||||
|
This is the most efficient readback for DRL control loops
|
||||||
|
(single contiguous D2H copy, no Python per-body overhead).
|
||||||
|
"""
|
||||||
|
if self.bodies.obs_pinned is None:
|
||||||
|
raise RuntimeError("No obs buffer. Call run() first.")
|
||||||
|
if stream is None:
|
||||||
|
stream = self.stream
|
||||||
|
if stream is not None:
|
||||||
|
stream.synchronize()
|
||||||
|
return self.bodies._obs_array()
|
||||||
|
|
||||||
# -- Compilation ---------------------------------------------------------
|
# -- Compilation ---------------------------------------------------------
|
||||||
def recompile(self):
|
def _recompile(self, expected_n_objects: int | None = None) -> None:
|
||||||
"""Re-generate config headers and recompile kernel.
|
"""Re-generate config headers and recompile the kernel.
|
||||||
|
|
||||||
Call after changing compile-time parameters (collision model, etc.).
|
If *expected_n_objects* is ``None``, uses ``self.bodies.count``.
|
||||||
|
Preserves ``step_count`` and GPU DDF/flag buffers across the
|
||||||
|
module reload. Does NOT preserve ``action_gpu`` / ``obs_gpu``
|
||||||
|
(those are recreated by ``sync_to_gpu`` or ``run()``).
|
||||||
|
|
||||||
|
This is safe to call before or after ``initialize()``.
|
||||||
"""
|
"""
|
||||||
if self._initialized:
|
if expected_n_objects is None:
|
||||||
raise RuntimeError(
|
expected_n_objects = self.bodies.count
|
||||||
"recompile() must not be called after initialize(); "
|
|
||||||
"rebuild Simulation instead.")
|
|
||||||
arch = self._resolve_compile_arch()
|
arch = self._resolve_compile_arch()
|
||||||
compiler.generate_config(self.lbm_cfg, n_objects=self.bodies.count)
|
compiler.generate_config(self.lbm_cfg, n_objects=expected_n_objects)
|
||||||
|
# Remove stale PTX to prevent PyCUDA module handle conflicts.
|
||||||
|
import os as _os
|
||||||
|
if _os.path.exists(self._ptx_path):
|
||||||
|
_os.remove(self._ptx_path)
|
||||||
self._ptx_path = compiler.compile_kernel(arch=arch)
|
self._ptx_path = compiler.compile_kernel(arch=arch)
|
||||||
self._module = compiler.load_module(self._ptx_path)
|
self._module = compiler.load_module(self._ptx_path)
|
||||||
|
|
||||||
# Reconnect field and stepper to new module
|
# Reconnect field and stepper to new module
|
||||||
self.field.module = self._module
|
self.field.module = self._module
|
||||||
self.field.invalidate_module_constant_cache()
|
self.field.invalidate_module_constant_cache()
|
||||||
self.field._upload_params()
|
self.field._upload_params()
|
||||||
|
|
||||||
_prev_step_count = self.stepper._step_count
|
_prev_step_count = self.stepper._step_count
|
||||||
self.stepper = LBMStepper(
|
self.stepper = LBMStepper(
|
||||||
self.field, self._module, self.lbm_cfg,
|
self.field, self._module, self.lbm_cfg,
|
||||||
)
|
)
|
||||||
self.stepper._step_count = _prev_step_count
|
self.stepper._step_count = _prev_step_count
|
||||||
self._assert_object_count_contract(expected_count=self.bodies.count)
|
|
||||||
|
self._assert_object_count_contract(expected_count=expected_n_objects)
|
||||||
|
|
||||||
|
def recompile(self):
|
||||||
|
"""Re-generate config headers and recompile kernel.
|
||||||
|
|
||||||
|
Call after changing compile-time parameters (collision model, etc.).
|
||||||
|
Must not be called after ``initialize()`` -- use ``sync_bodies()``
|
||||||
|
for runtime topology changes instead.
|
||||||
|
"""
|
||||||
|
if self._initialized:
|
||||||
|
raise RuntimeError(
|
||||||
|
"recompile() must not be called after initialize(); "
|
||||||
|
"use sync_bodies() for runtime body changes.")
|
||||||
|
self._recompile()
|
||||||
|
|
||||||
# -- Initialization ------------------------------------------------------
|
# -- Initialization ------------------------------------------------------
|
||||||
def initialize(self):
|
def initialize(self):
|
||||||
@ -281,15 +365,113 @@ class Simulation:
|
|||||||
self._assert_runtime_contracts()
|
self._assert_runtime_contracts()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
|
# -- Runtime body topology sync -------------------------------------------
|
||||||
|
def sync_bodies(self) -> None:
|
||||||
|
"""Apply pending body edits (add/remove) to a running simulation.
|
||||||
|
|
||||||
|
This is the main entry point for runtime body topology changes.
|
||||||
|
It downloads the current DDF, rebuilds topology, recompiles the
|
||||||
|
kernel for the new object count, patches the DDF for geometry
|
||||||
|
changes, and re-uploads everything to GPU.
|
||||||
|
|
||||||
|
Currently only supports ``double_buffer`` streaming mode.
|
||||||
|
``esopull`` mode raises ``NotImplementedError``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If called before ``initialize()``.
|
||||||
|
NotImplementedError: If streaming mode is ``esopull``.
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
raise RuntimeError("Call initialize() before sync_bodies()")
|
||||||
|
|
||||||
|
# 1. Synchronize GPU
|
||||||
|
self.stream.synchronize()
|
||||||
|
|
||||||
|
# 2. Check for pending edits
|
||||||
|
if not self.bodies.has_pending_edit():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Check streaming mode
|
||||||
|
if self.lbm_cfg.streaming == "esopull":
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Runtime body sync is not yet supported for esopull "
|
||||||
|
"streaming mode. Use double_buffer instead.")
|
||||||
|
|
||||||
|
# 4. Download current DDF to host (snapshot for patching)
|
||||||
|
self.field.download_ddf(
|
||||||
|
step_id=self.stepper.step_count, force=True)
|
||||||
|
old_ddf = self.field.ddf.copy()
|
||||||
|
old_flags = self.field.flag.copy()
|
||||||
|
|
||||||
|
# 5. Build sync plan
|
||||||
|
plan = self.bodies.build_sync_plan(self.field)
|
||||||
|
|
||||||
|
# 6. Runtime recompile for new object count
|
||||||
|
self._recompile(plan.next_count)
|
||||||
|
|
||||||
|
# 7. Apply new topology data (flags, compact lists, params)
|
||||||
|
self.bodies.apply_sync_plan(self.field, plan)
|
||||||
|
|
||||||
|
# 8. DDF patch for geometry changes
|
||||||
|
cl_fluid_idx = plan.curved_host[0]
|
||||||
|
patch_ddf_for_body_sync(
|
||||||
|
self.field,
|
||||||
|
old_ddf, old_flags, plan.next_flags,
|
||||||
|
plan.added_solid_mask, plan.released_fluid_mask,
|
||||||
|
curved_fluid_indices=cl_fluid_idx,
|
||||||
|
)
|
||||||
|
upload_patched_ddf(
|
||||||
|
self.field,
|
||||||
|
plan.added_solid_mask, plan.released_fluid_mask,
|
||||||
|
curved_fluid_indices=cl_fluid_idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9. Commit formal objects and clear pending
|
||||||
|
self.bodies.commit_pending(plan.next_objects, plan.new_sensor_counts)
|
||||||
|
|
||||||
|
# 10. Build action/obs telemetry for the new object set
|
||||||
|
self.bodies._refresh_action_from_objects()
|
||||||
|
self.bodies._allocate_packed_telemetry()
|
||||||
|
assert self.bodies.obs_pinned is not None
|
||||||
|
self.bodies.obs_pinned.fill(0)
|
||||||
|
cuda.memcpy_htod(self.bodies.obs_gpu, self.bodies.obs_pinned)
|
||||||
|
|
||||||
|
self._assert_runtime_contracts()
|
||||||
|
|
||||||
# -- Stepping ------------------------------------------------------------
|
# -- Stepping ------------------------------------------------------------
|
||||||
def run(self, steps: int, checkpoint_interval: int = 0):
|
def run(self, steps: int, *,
|
||||||
|
stream: cuda.Stream | None = None,
|
||||||
|
upload_act: bool = True,
|
||||||
|
sync_obs: bool = True,
|
||||||
|
checkpoint_interval: int = 0):
|
||||||
"""Advance simulation by *steps* time steps.
|
"""Advance simulation by *steps* time steps.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
steps: Number of LBM steps.
|
||||||
|
stream: CUDA stream for async operations. ``None`` = internal stream.
|
||||||
|
upload_act: If True, upload host action array to ``action_gpu``
|
||||||
|
before the step group.
|
||||||
|
sync_obs: If True, download ``obs_gpu`` to host pinned buffer
|
||||||
|
after the step group.
|
||||||
checkpoint_interval: If >0, save checkpoint every N steps.
|
checkpoint_interval: If >0, save checkpoint every N steps.
|
||||||
"""
|
"""
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
raise RuntimeError("Call initialize() first")
|
raise RuntimeError("Call initialize() first")
|
||||||
|
# Discard any uncommitted body edits before stepping.
|
||||||
|
if self.bodies.has_pending_edit():
|
||||||
|
self.bodies.clear_pending_edits()
|
||||||
|
|
||||||
|
# Resolve stream
|
||||||
|
if stream is None:
|
||||||
|
stream = self.stream
|
||||||
|
|
||||||
|
# Async upload action
|
||||||
|
if upload_act and self.bodies.count > 0:
|
||||||
|
self.bodies._upload_action_async(stream)
|
||||||
|
|
||||||
|
# Zero obs force segment before step group
|
||||||
|
self.bodies.zero_force_segment_async(stream)
|
||||||
|
|
||||||
self._assert_runtime_contracts()
|
self._assert_runtime_contracts()
|
||||||
if checkpoint_interval > 0:
|
if checkpoint_interval > 0:
|
||||||
done = 0
|
done = 0
|
||||||
@ -299,6 +481,7 @@ class Simulation:
|
|||||||
batch,
|
batch,
|
||||||
action_gpu=self.bodies.action_gpu,
|
action_gpu=self.bodies.action_gpu,
|
||||||
obs_gpu=self.bodies.obs_gpu,
|
obs_gpu=self.bodies.obs_gpu,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
done += batch
|
done += batch
|
||||||
if done < steps or done % checkpoint_interval == 0:
|
if done < steps or done % checkpoint_interval == 0:
|
||||||
@ -308,8 +491,15 @@ class Simulation:
|
|||||||
steps,
|
steps,
|
||||||
action_gpu=self.bodies.action_gpu,
|
action_gpu=self.bodies.action_gpu,
|
||||||
obs_gpu=self.bodies.obs_gpu,
|
obs_gpu=self.bodies.obs_gpu,
|
||||||
|
stream=stream,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Async download obs
|
||||||
|
if sync_obs:
|
||||||
|
self.bodies.download_obs_full_async(stream)
|
||||||
|
|
||||||
|
stream.synchronize()
|
||||||
|
|
||||||
def step(self, n: int = 1):
|
def step(self, n: int = 1):
|
||||||
"""Advance *n* steps (convenience for interactive use)."""
|
"""Advance *n* steps (convenience for interactive use)."""
|
||||||
self.run(n)
|
self.run(n)
|
||||||
|
|||||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# CelerisLab/tests/conftest.py
|
||||||
|
"""Pytest configuration — ensures ``src/`` is importable from any test file."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
_src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||||
|
if _src not in sys.path:
|
||||||
|
sys.path.insert(0, _src)
|
||||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# CelerisLab/tests/integration/__init__.py
|
||||||
117
tests/integration/test_body_sync_e2e.py
Normal file
117
tests/integration/test_body_sync_e2e.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""Full add/remove/checkpoint/load lifecycle — end-to-end body topology sync.
|
||||||
|
|
||||||
|
Requires GPU."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pycuda.autoinit
|
||||||
|
|
||||||
|
from CelerisLab.simulation import Simulation
|
||||||
|
from CelerisLab.lbm.descriptors import OBSTACLE, FLUID
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodySyncE2E(unittest.TestCase):
|
||||||
|
"""Full end-to-end test of runtime body topology sync."""
|
||||||
|
|
||||||
|
def test_full_lifecycle(self):
|
||||||
|
"""Create, init, run, add body, remove body, checkpoint, load."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
# 1. Initialize and run
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(100)
|
||||||
|
self.assertGreater(sim.stepper.step_count, 0)
|
||||||
|
|
||||||
|
# 2. Add a body and sync
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
center_idx = cx + cy * nx
|
||||||
|
self.assertTrue(sim.get_flags()[center_idx] & OBSTACLE)
|
||||||
|
|
||||||
|
# 3. Run with body (short window — finite near-term)
|
||||||
|
sim.run(50)
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(force)),
|
||||||
|
f"Finite force after add: {force}")
|
||||||
|
|
||||||
|
steps_before_remove = sim.stepper.step_count
|
||||||
|
|
||||||
|
# 4. Remove the body and sync
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 0)
|
||||||
|
self.assertTrue(sim.get_flags()[center_idx] & FLUID)
|
||||||
|
|
||||||
|
# 5. Run after removal (no crash, step count advances)
|
||||||
|
sim.run(50)
|
||||||
|
self.assertEqual(sim.stepper.step_count, steps_before_remove + 50)
|
||||||
|
|
||||||
|
# 6. Save checkpoint
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
|
||||||
|
ckpt_path = f.name
|
||||||
|
try:
|
||||||
|
saved_path = sim.save_checkpoint(ckpt_path)
|
||||||
|
self.assertTrue(os.path.exists(saved_path))
|
||||||
|
|
||||||
|
# 7. Load checkpoint in a new simulation
|
||||||
|
sim2 = Simulation(device_id=0)
|
||||||
|
sim2.initialize()
|
||||||
|
sim2.run(1)
|
||||||
|
|
||||||
|
sim2.load_checkpoint(saved_path)
|
||||||
|
self.assertEqual(sim2.stepper.step_count, sim.stepper.step_count)
|
||||||
|
self.assertEqual(sim2.bodies.count, 0)
|
||||||
|
|
||||||
|
# 8. Continue running in restored sim
|
||||||
|
sim2.run(50)
|
||||||
|
# Verify step count advances (DDF may have NaN from pre-existing body)
|
||||||
|
self.assertEqual(sim2.stepper.step_count,
|
||||||
|
sim.stepper.step_count + 50)
|
||||||
|
sim2.close()
|
||||||
|
finally:
|
||||||
|
os.unlink(ckpt_path)
|
||||||
|
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_add_remove_add_cycle(self):
|
||||||
|
"""Add → run → remove → run → add → run cycle with finite checks."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(100)
|
||||||
|
|
||||||
|
# Add
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Remove
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 0)
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Add again
|
||||||
|
sim.add_body("circle", center=(nx // 2, ny // 2), radius=6)
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
sim.run(50)
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(force)),
|
||||||
|
f"Finite force after add-remove-add: {force}")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
145
tests/integration/test_ddf_patch.py
Normal file
145
tests/integration/test_ddf_patch.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""DDF patch after add / remove body — finite forces, finite macroscopic field after moderate steps.
|
||||||
|
|
||||||
|
Requires GPU."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pycuda.autoinit
|
||||||
|
|
||||||
|
from CelerisLab.simulation import Simulation
|
||||||
|
from CelerisLab.lbm.descriptors import FLUID, OBSTACLE
|
||||||
|
|
||||||
|
|
||||||
|
class TestDDFPatchAddBody(unittest.TestCase):
|
||||||
|
"""Test adding a body (fluid -> solid DDF patch)."""
|
||||||
|
|
||||||
|
def test_add_body_runs_stably(self):
|
||||||
|
"""After adding a body, the simulation runs with finite forces
|
||||||
|
for a moderate number of steps."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(200)
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
center_idx = cx + cy * nx
|
||||||
|
self.assertTrue(sim.get_flags()[center_idx] & OBSTACLE)
|
||||||
|
|
||||||
|
sim.run(50)
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(force)),
|
||||||
|
f"Finite force after add body: {force}")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDDFPatchRemoveBody(unittest.TestCase):
|
||||||
|
"""Test removing a body (solid -> fluid DDF patch via BFS inward fill)."""
|
||||||
|
|
||||||
|
def test_remove_body_released_region_is_fluid(self):
|
||||||
|
"""After removal, the former body center should be a fluid cell."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(200)
|
||||||
|
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
flags = sim.get_flags()
|
||||||
|
center_idx = cx + cy * nx
|
||||||
|
self.assertTrue(flags[center_idx] & FLUID)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_remove_body_finite_field(self):
|
||||||
|
"""After removal, the macroscopic field is finite."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
flags = sim.get_flags()
|
||||||
|
center_idx = cx + cy * nx
|
||||||
|
self.assertTrue(flags[center_idx] & FLUID)
|
||||||
|
|
||||||
|
sim.run(50)
|
||||||
|
macro = sim.get_macroscopic()
|
||||||
|
self.assertTrue(np.all(np.isfinite(macro["ux"])),
|
||||||
|
"Macroscopic ux should be finite after remove body")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDDFPatchAddAndRemove(unittest.TestCase):
|
||||||
|
"""Test combined add + remove in one sync."""
|
||||||
|
|
||||||
|
def test_add_one_remove_another(self):
|
||||||
|
"""Add a body and remove a different one in the same sync."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
cx, cy = nx // 4, ny // 2
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(cx, cy), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(nx // 2, ny // 2), radius=6)
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
|
||||||
|
sim.run(50)
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertEqual(force.shape[0], 2)
|
||||||
|
self.assertTrue(np.all(np.isfinite(force)),
|
||||||
|
f"Finite force after add+remove: {force}")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDDFPatchNoChange(unittest.TestCase):
|
||||||
|
"""Test that patch is a no-op when there are no geometry changes."""
|
||||||
|
|
||||||
|
def test_no_mask_no_change(self):
|
||||||
|
"""When neither mask has any True entries, DDF should be unchanged."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
sim.add_body("circle", center=(128, 128), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(100)
|
||||||
|
|
||||||
|
sim.field.download_ddf(force=True)
|
||||||
|
ddf_before = sim.field.ddf.copy()
|
||||||
|
|
||||||
|
from CelerisLab.body.ddf_patch import patch_ddf_for_body_sync as patch_fn
|
||||||
|
|
||||||
|
n = sim.field.n
|
||||||
|
added = np.zeros(n, dtype=bool)
|
||||||
|
released = np.zeros(n, dtype=bool)
|
||||||
|
patch_fn(
|
||||||
|
sim.field,
|
||||||
|
ddf_before, sim.field.flag.copy(), sim.field.flag.copy(),
|
||||||
|
added, released)
|
||||||
|
|
||||||
|
np.testing.assert_array_equal(sim.field.ddf, ddf_before)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
166
tests/integration/test_sync_bodies_skeleton.py
Normal file
166
tests/integration/test_sync_bodies_skeleton.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""sync_bodies pipeline without DDF patch — recompile, esopull guard, step count preservation.
|
||||||
|
|
||||||
|
Requires GPU."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pycuda.driver as cuda
|
||||||
|
import pycuda.autoinit
|
||||||
|
|
||||||
|
from CelerisLab.simulation import Simulation
|
||||||
|
|
||||||
|
|
||||||
|
# Use a small grid for fast compilation and test execution
|
||||||
|
NX, NY = 128, 64
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncBodiesSkeleton(unittest.TestCase):
|
||||||
|
"""Test sync_bodies() with real GPU -- skeleton without DDF patch."""
|
||||||
|
|
||||||
|
def _make_sim(self) -> Simulation:
|
||||||
|
"""Create a Simulation with a small double_buffer D2Q9 grid."""
|
||||||
|
return Simulation(device_id=0)
|
||||||
|
|
||||||
|
def test_remove_body_sync_and_run(self):
|
||||||
|
"""Remove a body, sync, and continue running without crash."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Remove the body and sync
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
# Should be able to continue running
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# No body left -- count should be 0
|
||||||
|
self.assertEqual(sim.bodies.count, 0)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_add_body_after_initialize(self):
|
||||||
|
"""Add a body after initialize, sync, and run."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Add a body (returns -1 since it's staged)
|
||||||
|
result_id = sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
self.assertEqual(result_id, -1)
|
||||||
|
self.assertTrue(sim.bodies.has_pending_edit())
|
||||||
|
|
||||||
|
# Sync commits the body
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertFalse(sim.bodies.has_pending_edit())
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
|
||||||
|
# Should be able to run with the new body
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Force readback should work
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertEqual(force.shape[0], 2)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_add_then_remove_body(self):
|
||||||
|
"""Add a body, then remove it, sync -- should result in zero bodies."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Add another body and remove the original
|
||||||
|
sim.add_body("circle", center=(NX // 2, NY // 2), radius=6)
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
# One body remaining (the newly added one, now id=0)
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
sim.run(50)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_sync_preserves_step_count(self):
|
||||||
|
"""sync_bodies() should not reset the step counter."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(100)
|
||||||
|
steps_before = sim.stepper.step_count
|
||||||
|
|
||||||
|
sim.remove_body(0)
|
||||||
|
sim.sync_bodies()
|
||||||
|
|
||||||
|
steps_after = sim.stepper.step_count
|
||||||
|
self.assertEqual(steps_after, steps_before)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_no_pending_edit_is_noop(self):
|
||||||
|
"""sync_bodies() with no pending edits should be a no-op."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# No edits -- sync should return immediately
|
||||||
|
sim.sync_bodies()
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_run_discards_pending_without_sync(self):
|
||||||
|
"""Running without sync_bodies() should discard pending edits."""
|
||||||
|
sim = self._make_sim()
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# Stage a removal but don't sync
|
||||||
|
sim.remove_body(0)
|
||||||
|
self.assertTrue(sim.bodies.has_pending_edit())
|
||||||
|
|
||||||
|
# run() auto-discards pending
|
||||||
|
sim.run(50)
|
||||||
|
self.assertFalse(sim.bodies.has_pending_edit())
|
||||||
|
self.assertEqual(sim.bodies.count, 1)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_esopull_raises_not_implemented(self):
|
||||||
|
"""sync_bodies() with esopull should raise NotImplementedError."""
|
||||||
|
# Create a sim with esopull streaming
|
||||||
|
from CelerisLab.config import load_lbm_config
|
||||||
|
cfg = load_lbm_config()
|
||||||
|
cfg.streaming = "esopull"
|
||||||
|
# We need to build the sim manually to override streaming
|
||||||
|
sim = Simulation.__new__(Simulation)
|
||||||
|
sim._stream = None
|
||||||
|
sim.lbm_cfg = cfg
|
||||||
|
from CelerisLab.config import BodyConfig
|
||||||
|
sim.body_cfg = BodyConfig()
|
||||||
|
from CelerisLab.cuda.context import CudaContext
|
||||||
|
sim.ctx = CudaContext(0)
|
||||||
|
from CelerisLab.cuda import compiler_v2 as compiler
|
||||||
|
arch = sim._resolve_compile_arch = lambda: sim.ctx.sm_arch
|
||||||
|
arch_val = CudaContext(0).sm_arch
|
||||||
|
compiler.generate_config(cfg, n_objects=0)
|
||||||
|
ptx_path = compiler.compile_kernel(arch=arch_val)
|
||||||
|
module = compiler.load_module(ptx_path)
|
||||||
|
sim._ptx_path = ptx_path
|
||||||
|
sim._module = module
|
||||||
|
from CelerisLab.lbm.field import LBMField
|
||||||
|
sim.field = LBMField(cfg, module)
|
||||||
|
from CelerisLab.lbm.stepper import LBMStepper
|
||||||
|
sim.stepper = LBMStepper(sim.field, module, cfg)
|
||||||
|
from CelerisLab.body.manager import ObjectManager
|
||||||
|
sim.bodies = ObjectManager(
|
||||||
|
cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
|
||||||
|
sim._initialized = True
|
||||||
|
|
||||||
|
sim.add_body("circle", center=(NX // 4, NY // 2), radius=8)
|
||||||
|
with self.assertRaises(NotImplementedError):
|
||||||
|
sim.sync_bodies()
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
89
tests/integration/test_unified_obs.py
Normal file
89
tests/integration/test_unified_obs.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""Unified action/obs flow — host-only set_body, auto transfer, stream API, DRL loop pattern.
|
||||||
|
|
||||||
|
Requires GPU."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pycuda.driver as cuda
|
||||||
|
import pycuda.autoinit
|
||||||
|
|
||||||
|
from CelerisLab.simulation import Simulation
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedObs(unittest.TestCase):
|
||||||
|
"""Test unified action/obs flow."""
|
||||||
|
|
||||||
|
def test_set_body_then_run_read_body(self):
|
||||||
|
"""set_body (host-only), run, read_body returns finite force."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
sim.add_body("circle", center=(nx // 4, ny // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50)
|
||||||
|
|
||||||
|
# set_body should not trigger H2D (no error expected)
|
||||||
|
sim.set_body(0, omega=0.001)
|
||||||
|
|
||||||
|
# run will auto-upload action
|
||||||
|
sim.run(50)
|
||||||
|
data = sim.read_body(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(data.force)),
|
||||||
|
f"Force finite: {data.force}")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_skip_transfer(self):
|
||||||
|
"""run(upload_act=False, sync_obs=False) should not crash."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
sim.add_body("circle", center=(nx // 4, ny // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
sim.run(50, upload_act=False, sync_obs=False)
|
||||||
|
# After no-sync run, step count should still advance
|
||||||
|
self.assertEqual(sim.stepper.step_count, 50)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_external_stream(self):
|
||||||
|
"""Providing an external CUDA stream should not crash."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
sim.add_body("circle", center=(nx // 4, ny // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
s = cuda.Stream()
|
||||||
|
sim.run(50, stream=s)
|
||||||
|
force = sim.read_force(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(force)),
|
||||||
|
f"Force finite with external stream: {force}")
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_read_body_before_run_returns_zeros(self):
|
||||||
|
"""read_body before any run() should return zero force (buffer is
|
||||||
|
initialized to zero during sync_to_gpu)."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
sim.add_body("circle", center=(128, 128), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
force = sim.read_force(0)
|
||||||
|
np.testing.assert_array_equal(force, np.zeros(2, dtype=np.float32))
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
def test_drl_pattern(self):
|
||||||
|
"""DRL-style loop: run → read → set → run → read."""
|
||||||
|
sim = Simulation(device_id=0)
|
||||||
|
nx = sim.lbm_cfg.nx
|
||||||
|
ny = sim.lbm_cfg.ny
|
||||||
|
sim.add_body("circle", center=(nx // 4, ny // 2), radius=8)
|
||||||
|
sim.initialize()
|
||||||
|
for i in range(3):
|
||||||
|
sim.run(50)
|
||||||
|
data = sim.read_body(0)
|
||||||
|
self.assertTrue(np.all(np.isfinite(data.force)))
|
||||||
|
sim.set_body(0, omega=0.001 * i)
|
||||||
|
self.assertEqual(sim.stepper.step_count, 150)
|
||||||
|
sim.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# CelerisLab/tests/unit/__init__.py
|
||||||
94
tests/unit/test_body_flags.py
Normal file
94
tests/unit/test_body_flags.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# CelerisLab/tests/unit/test_body_flags.py
|
||||||
|
"""Body type flag masks — OBSTACLE, SENSOR_FLAG, FRC_REGION bits for circle / sensor / force_region.
|
||||||
|
|
||||||
|
No GPU required."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from CelerisLab.body.manager import ObjectManager
|
||||||
|
from CelerisLab.body.objects import SimObject
|
||||||
|
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||||
|
from CelerisLab.lbm.descriptors import (
|
||||||
|
FLUID, SOLID, OBSTACLE, BC_CURVED, SENSOR_FLAG, FRC_REGION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_obj(cx: float, cy: float, radius: float,
|
||||||
|
is_sensor: bool = False,
|
||||||
|
is_force_region: bool = False) -> SimObject:
|
||||||
|
geom = CircleGeometry(cx, cy, radius)
|
||||||
|
return SimObject(obj_id=-1, geometry=geom,
|
||||||
|
center=(cx, cy), radius=radius,
|
||||||
|
is_sensor=is_sensor,
|
||||||
|
is_force_region=is_force_region)
|
||||||
|
|
||||||
|
|
||||||
|
NX, NY = 64, 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyFlags(unittest.TestCase):
|
||||||
|
"""Verify flag mask bits for each body type."""
|
||||||
|
|
||||||
|
def _obj_flag_mask(self, obj: SimObject) -> np.ndarray:
|
||||||
|
return obj.get_flag_mask(NX, NY)
|
||||||
|
|
||||||
|
def test_circle_has_obstacle_solid_curved(self):
|
||||||
|
mask = self._obj_flag_mask(_make_obj(32, 16, 5))
|
||||||
|
center = 32 + 16 * NX
|
||||||
|
self.assertTrue(mask[center] & OBSTACLE,
|
||||||
|
"Circle should have OBSTACLE bit")
|
||||||
|
self.assertTrue(mask[center] & SOLID,
|
||||||
|
"Circle should have SOLID bit")
|
||||||
|
self.assertTrue(mask[center] & BC_CURVED,
|
||||||
|
"Circle should have BC_CURVED bit")
|
||||||
|
self.assertFalse(mask[center] & FLUID,
|
||||||
|
"Circle interior should NOT be FLUID")
|
||||||
|
|
||||||
|
def test_sensor_has_sensor_flag(self):
|
||||||
|
mask = self._obj_flag_mask(_make_obj(32, 16, 5, is_sensor=True))
|
||||||
|
center = 32 + 16 * NX
|
||||||
|
self.assertTrue(mask[center] & SENSOR_FLAG,
|
||||||
|
"Sensor should have SENSOR_FLAG bit")
|
||||||
|
self.assertTrue(mask[center] & FLUID,
|
||||||
|
"Sensor should be FLUID")
|
||||||
|
|
||||||
|
def test_force_region_has_frc_region_flag(self):
|
||||||
|
mask = self._obj_flag_mask(
|
||||||
|
_make_obj(32, 16, 5, is_force_region=True))
|
||||||
|
center = 32 + 16 * NX
|
||||||
|
self.assertTrue(mask[center] & FRC_REGION,
|
||||||
|
"Force region should have FRC_REGION bit")
|
||||||
|
self.assertTrue(mask[center] & FLUID,
|
||||||
|
"Force region should be FLUID")
|
||||||
|
|
||||||
|
def test_circle_has_no_sensor_or_frc_flag(self):
|
||||||
|
mask = self._obj_flag_mask(_make_obj(32, 16, 5))
|
||||||
|
center = 32 + 16 * NX
|
||||||
|
self.assertFalse(mask[center] & SENSOR_FLAG,
|
||||||
|
"Circle should NOT have SENSOR_FLAG")
|
||||||
|
self.assertFalse(mask[center] & FRC_REGION,
|
||||||
|
"Circle should NOT have FRC_REGION")
|
||||||
|
|
||||||
|
def test_force_region_has_no_obstacle(self):
|
||||||
|
mask = self._obj_flag_mask(
|
||||||
|
_make_obj(32, 16, 5, is_force_region=True))
|
||||||
|
center = 32 + 16 * NX
|
||||||
|
self.assertFalse(mask[center] & OBSTACLE,
|
||||||
|
"Force region should NOT have OBSTACLE bit")
|
||||||
|
|
||||||
|
def test_all_body_types_nonzero_masks(self):
|
||||||
|
for obj in [
|
||||||
|
_make_obj(32, 16, 5),
|
||||||
|
_make_obj(32, 16, 5, is_sensor=True),
|
||||||
|
_make_obj(32, 16, 5, is_force_region=True),
|
||||||
|
]:
|
||||||
|
mask = self._obj_flag_mask(obj)
|
||||||
|
self.assertGreater(np.count_nonzero(mask), 0,
|
||||||
|
f"{obj.is_sensor=},{obj.is_force_region=}: "
|
||||||
|
"mask should have non-zero entries")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
50
tests/unit/test_equilibrium.py
Normal file
50
tests/unit/test_equilibrium.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"""D2Q9 equilibrium helpers — compute_feq_d2q9 and compute_macro_from_ddf correctness.
|
||||||
|
|
||||||
|
No GPU required."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from CelerisLab.lbm.equilibrium import compute_feq_d2q9, compute_macro_from_ddf
|
||||||
|
|
||||||
|
|
||||||
|
class TestEquilibrium(unittest.TestCase):
|
||||||
|
"""Verify D2Q9 equilibrium and macroscopic helpers."""
|
||||||
|
|
||||||
|
def test_feq_at_rest(self):
|
||||||
|
"""Equilibrium at rho=1.0, u=0 should give w_i (weights)."""
|
||||||
|
feq = compute_feq_d2q9(1.0, 0.0, 0.0)
|
||||||
|
w = np.array([4/9, 1/9, 1/9, 1/9, 1/9,
|
||||||
|
1/36, 1/36, 1/36, 1/36], dtype=np.float32)
|
||||||
|
np.testing.assert_allclose(feq, w, rtol=1e-6)
|
||||||
|
|
||||||
|
def test_feq_sums_to_rho(self):
|
||||||
|
"""Sum of feq should equal rho."""
|
||||||
|
rho, ux, uy = 1.2, 0.05, -0.02
|
||||||
|
feq = compute_feq_d2q9(rho, ux, uy)
|
||||||
|
self.assertAlmostEqual(float(np.sum(feq)), rho, places=6)
|
||||||
|
|
||||||
|
def test_macro_preserves_ux_uy(self):
|
||||||
|
"""compute_macro_from_ddf(feq) should recover rho, ux, uy."""
|
||||||
|
rho, ux, uy = 1.0, 0.1, 0.0
|
||||||
|
feq = compute_feq_d2q9(rho, ux, uy)
|
||||||
|
rho_out, ux_out, uy_out = compute_macro_from_ddf(feq)
|
||||||
|
self.assertAlmostEqual(rho_out, rho, places=6)
|
||||||
|
self.assertAlmostEqual(ux_out, ux, places=6)
|
||||||
|
self.assertAlmostEqual(uy_out, uy, places=6)
|
||||||
|
|
||||||
|
def test_feq_nonzero_vel(self):
|
||||||
|
"""Equilibrium at non-zero velocity should be asymmetric."""
|
||||||
|
feq_x = compute_feq_d2q9(1.0, 0.1, 0.0)
|
||||||
|
feq_y = compute_feq_d2q9(1.0, 0.0, 0.1)
|
||||||
|
# x-directed flow should have f1 > f2 (right > left)
|
||||||
|
self.assertGreater(feq_x[1], feq_x[2],
|
||||||
|
"Right-moving f1 should exceed left-moving f2")
|
||||||
|
# y-directed flow should have f3 > f4 (up > down)
|
||||||
|
self.assertGreater(feq_y[3], feq_y[4],
|
||||||
|
"Up-moving f3 should exceed down-moving f4")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
147
tests/unit/test_pending_edits.py
Normal file
147
tests/unit/test_pending_edits.py
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
"""ObjectManager pending edit lifecycle — stage_add, stage_remove, has_pending_edit, clear_pending_edits.
|
||||||
|
|
||||||
|
No GPU required."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from CelerisLab.body.manager import ObjectManager
|
||||||
|
from CelerisLab.body.objects import SimObject
|
||||||
|
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||||
|
|
||||||
|
|
||||||
|
def _make_circle_obj(cx: float, cy: float, radius: float,
|
||||||
|
is_sensor: bool = False) -> SimObject:
|
||||||
|
"""Create a minimal SimObject with CircleGeometry."""
|
||||||
|
geom = CircleGeometry(cx, cy, radius)
|
||||||
|
return SimObject(
|
||||||
|
obj_id=-1,
|
||||||
|
geometry=geom,
|
||||||
|
center=(cx, cy),
|
||||||
|
radius=radius,
|
||||||
|
is_sensor=is_sensor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingEditLifecycle(unittest.TestCase):
|
||||||
|
"""Test the pending edit state machine on ObjectManager."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# ObjectManager requires nx, ny, nz, nq, cfg. Use a minimal cfg stub.
|
||||||
|
self.cfg = _StubCfg(dim=2)
|
||||||
|
self.mgr = ObjectManager(nx=64, ny=32, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
|
||||||
|
# -- stage_add -----------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stage_add_sets_edit_active(self):
|
||||||
|
obj = _make_circle_obj(30, 16, 5)
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
self.mgr.stage_add(obj)
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
def test_stage_add_does_not_change_formal_count(self):
|
||||||
|
obj = _make_circle_obj(30, 16, 5)
|
||||||
|
self.assertEqual(self.mgr.count, 0)
|
||||||
|
self.mgr.stage_add(obj)
|
||||||
|
self.assertEqual(self.mgr.count, 0)
|
||||||
|
|
||||||
|
def test_stage_add_multiple(self):
|
||||||
|
for i in range(3):
|
||||||
|
self.mgr.stage_add(_make_circle_obj(10 + i * 10, 16, 3))
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
# -- stage_remove --------------------------------------------------------
|
||||||
|
|
||||||
|
def test_stage_remove_valid_id(self):
|
||||||
|
body_id = self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.stage_remove(body_id)
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
def test_stage_remove_invalid_id_raises(self):
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
self.mgr.stage_remove(999)
|
||||||
|
|
||||||
|
def test_stage_remove_does_not_change_formal_count(self):
|
||||||
|
body_id = self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
self.mgr.stage_remove(body_id)
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
|
||||||
|
# -- has_pending_edit ----------------------------------------------------
|
||||||
|
|
||||||
|
def test_has_pending_edit_false_initially(self):
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
def test_has_pending_edit_false_after_clear(self):
|
||||||
|
self.mgr.stage_add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.clear_pending_edits()
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
def test_has_pending_edit_false_after_empty_stage(self):
|
||||||
|
# If we add and then remove the same pending add, edit is still
|
||||||
|
# "active" (edit_active=True) but has no pending content.
|
||||||
|
# has_pending_edit should return False.
|
||||||
|
obj = _make_circle_obj(30, 16, 5)
|
||||||
|
self.mgr.stage_add(obj)
|
||||||
|
# Manually clear pending_add to simulate an empty edit window
|
||||||
|
self.mgr._pending_add.clear()
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
# -- clear_pending_edits -------------------------------------------------
|
||||||
|
|
||||||
|
def test_clear_resets_all_pending(self):
|
||||||
|
body_id = self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.stage_add(_make_circle_obj(40, 16, 3))
|
||||||
|
self.mgr.stage_remove(body_id)
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
self.mgr.clear_pending_edits()
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
self.assertEqual(len(self.mgr._pending_add), 0)
|
||||||
|
self.assertEqual(len(self.mgr._pending_remove), 0)
|
||||||
|
self.assertFalse(self.mgr._edit_active)
|
||||||
|
|
||||||
|
def test_clear_preserves_formal_registry(self):
|
||||||
|
body_id = self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.stage_remove(body_id)
|
||||||
|
self.mgr.clear_pending_edits()
|
||||||
|
# Formal object should still be there
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
self.assertEqual(self.mgr.get(body_id).obj_id, body_id)
|
||||||
|
|
||||||
|
# -- Combination: add + remove -------------------------------------------
|
||||||
|
|
||||||
|
def test_stage_add_and_remove_together(self):
|
||||||
|
body_id = self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.stage_add(_make_circle_obj(40, 16, 3))
|
||||||
|
self.mgr.stage_remove(body_id)
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
# Formal count unchanged
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
|
||||||
|
# -- Formal add (pre-initialize path) ------------------------------------
|
||||||
|
|
||||||
|
def test_formal_add_still_works(self):
|
||||||
|
"""The existing add() path must remain functional."""
|
||||||
|
obj = _make_circle_obj(30, 16, 5)
|
||||||
|
body_id = self.mgr.add(obj)
|
||||||
|
self.assertEqual(body_id, 0)
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
|
||||||
|
def test_formal_add_and_pending_coexist(self):
|
||||||
|
"""Formal add + pending stage should not interfere."""
|
||||||
|
self.mgr.add(_make_circle_obj(30, 16, 5))
|
||||||
|
self.mgr.stage_add(_make_circle_obj(40, 16, 3))
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
self.assertTrue(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
|
||||||
|
class _StubCfg:
|
||||||
|
"""Minimal LBMConfig-like stub for ObjectManager construction."""
|
||||||
|
|
||||||
|
def __init__(self, dim: int = 2):
|
||||||
|
self.dim = dim
|
||||||
|
self.is_d3q19 = (dim == 3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
234
tests/unit/test_sync_plan.py
Normal file
234
tests/unit/test_sync_plan.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""BodySyncPlan construction — build_flags_for, build_compact_lists_for, build_next_objects, build_sync_plan, commit_pending.
|
||||||
|
|
||||||
|
No GPU required."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from CelerisLab.body.manager import ObjectManager
|
||||||
|
from CelerisLab.body.objects import SimObject
|
||||||
|
from CelerisLab.body.geometry.circle import CircleGeometry
|
||||||
|
from CelerisLab.body.sync_plan import BodySyncPlan
|
||||||
|
from CelerisLab.lbm.descriptors import FLUID, SOLID, OBSTACLE, BC_CURVED
|
||||||
|
|
||||||
|
|
||||||
|
def _make_circle_obj(cx: float, cy: float, radius: float,
|
||||||
|
is_sensor: bool = False) -> SimObject:
|
||||||
|
geom = CircleGeometry(cx, cy, radius)
|
||||||
|
return SimObject(
|
||||||
|
obj_id=-1,
|
||||||
|
geometry=geom,
|
||||||
|
center=(cx, cy),
|
||||||
|
radius=radius,
|
||||||
|
is_sensor=is_sensor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubCfg:
|
||||||
|
def __init__(self, dim=2):
|
||||||
|
self.dim = dim
|
||||||
|
self.is_d3q19 = (dim == 3)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubField:
|
||||||
|
"""Minimal LBMField stub for build_sync_plan testing."""
|
||||||
|
|
||||||
|
def __init__(self, nx: int, ny: int):
|
||||||
|
self.nx = nx
|
||||||
|
self.ny = ny
|
||||||
|
# Build a simple channel flag array (fluid everywhere except top/bottom walls).
|
||||||
|
n = nx * ny
|
||||||
|
self.flag = np.ones(n, dtype=np.uint16) * FLUID
|
||||||
|
self.flag[:nx] = SOLID | 0x0010 # bottom wall
|
||||||
|
self.flag[(ny - 1) * nx:ny * nx] = SOLID | 0x0010 # top wall
|
||||||
|
# Save a clean copy for build_channel_flags
|
||||||
|
self._channel_flags = self.flag.copy()
|
||||||
|
|
||||||
|
def build_channel_flags(self) -> np.ndarray:
|
||||||
|
"""Return a clean channel base (no object overlays)."""
|
||||||
|
return self._channel_flags.copy()
|
||||||
|
|
||||||
|
|
||||||
|
NX, NY = 64, 32
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildFlagsFor(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = _StubCfg()
|
||||||
|
self.mgr = ObjectManager(nx=NX, ny=NY, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
self.field = _StubField(NX, NY)
|
||||||
|
|
||||||
|
def test_empty_objects_returns_base(self):
|
||||||
|
base = self.field.build_channel_flags()
|
||||||
|
result = ObjectManager.build_flags_for(
|
||||||
|
[], base, nx=NX, ny=NY, nz=1)
|
||||||
|
np.testing.assert_array_equal(result, base)
|
||||||
|
|
||||||
|
def test_one_circle_produces_solid_region(self):
|
||||||
|
base = self.field.build_channel_flags()
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
result = ObjectManager.build_flags_for(
|
||||||
|
[obj], base, nx=NX, ny=NY, nz=1)
|
||||||
|
# Center cell should be solid with OBSTACLE and BC_CURVED bits
|
||||||
|
center_idx = 32 + 16 * NX
|
||||||
|
self.assertTrue(result[center_idx] & SOLID)
|
||||||
|
self.assertTrue(result[center_idx] & OBSTACLE)
|
||||||
|
|
||||||
|
def test_instance_method_delegates(self):
|
||||||
|
base = self.field.build_channel_flags()
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
self.mgr.add(obj)
|
||||||
|
result = self.mgr.build_flags(base)
|
||||||
|
center_idx = 32 + 16 * NX
|
||||||
|
self.assertTrue(result[center_idx] & OBSTACLE)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildCompactListsFor(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = _StubCfg()
|
||||||
|
self.mgr = ObjectManager(nx=NX, ny=NY, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
|
||||||
|
def test_circle_produces_curved_links(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
obj.obj_id = 0
|
||||||
|
result = self.mgr.build_compact_lists_for([obj])
|
||||||
|
cl_fluid_idx = result[0]
|
||||||
|
self.assertGreater(len(cl_fluid_idx), 0)
|
||||||
|
|
||||||
|
def test_sensor_produces_sensor_cells(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5, is_sensor=True)
|
||||||
|
obj.obj_id = 0
|
||||||
|
result = self.mgr.build_compact_lists_for([obj])
|
||||||
|
sensor_cells = result[8]
|
||||||
|
self.assertGreater(len(sensor_cells), 0)
|
||||||
|
|
||||||
|
def test_instance_method_delegates(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
self.mgr.add(obj)
|
||||||
|
result = self.mgr.build_compact_lists()
|
||||||
|
cl_fluid_idx = result[0]
|
||||||
|
self.assertGreater(len(cl_fluid_idx), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildNextObjects(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = _StubCfg()
|
||||||
|
self.mgr = ObjectManager(nx=NX, ny=NY, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
|
||||||
|
def test_no_pending_returns_formal_objects(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
self.mgr.add(obj)
|
||||||
|
result = self.mgr.build_next_objects()
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0].obj_id, 0)
|
||||||
|
|
||||||
|
def test_removal_excludes_object(self):
|
||||||
|
obj0 = _make_circle_obj(20, 16, 3)
|
||||||
|
obj1 = _make_circle_obj(40, 16, 3)
|
||||||
|
id0 = self.mgr.add(obj0)
|
||||||
|
self.mgr.add(obj1)
|
||||||
|
self.mgr.stage_remove(id0)
|
||||||
|
result = self.mgr.build_next_objects()
|
||||||
|
self.assertEqual(len(result), 1)
|
||||||
|
self.assertEqual(result[0].obj_id, 0)
|
||||||
|
# The remaining object should be the second one (center at 40)
|
||||||
|
self.assertAlmostEqual(result[0].center[0], 40.0)
|
||||||
|
|
||||||
|
def test_add_appends_new_object(self):
|
||||||
|
obj0 = _make_circle_obj(20, 16, 3)
|
||||||
|
self.mgr.add(obj0)
|
||||||
|
new_obj = _make_circle_obj(40, 16, 3)
|
||||||
|
self.mgr.stage_add(new_obj)
|
||||||
|
result = self.mgr.build_next_objects()
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0].obj_id, 0)
|
||||||
|
self.assertEqual(result[1].obj_id, 1)
|
||||||
|
|
||||||
|
def test_ids_are_consecutive(self):
|
||||||
|
obj0 = _make_circle_obj(20, 16, 3)
|
||||||
|
obj1 = _make_circle_obj(30, 16, 3)
|
||||||
|
obj2 = _make_circle_obj(40, 16, 3)
|
||||||
|
id0 = self.mgr.add(obj0)
|
||||||
|
self.mgr.add(obj1)
|
||||||
|
self.mgr.add(obj2)
|
||||||
|
# Remove middle object
|
||||||
|
self.mgr.stage_remove(id0 + 1)
|
||||||
|
result = self.mgr.build_next_objects()
|
||||||
|
self.assertEqual(len(result), 2)
|
||||||
|
self.assertEqual(result[0].obj_id, 0)
|
||||||
|
self.assertEqual(result[1].obj_id, 1)
|
||||||
|
|
||||||
|
def test_formal_registry_unchanged(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
id0 = self.mgr.add(obj)
|
||||||
|
self.mgr.stage_remove(id0)
|
||||||
|
self.mgr.build_next_objects()
|
||||||
|
# Formal registry should be untouched
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSyncPlan(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = _StubCfg()
|
||||||
|
self.mgr = ObjectManager(nx=NX, ny=NY, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
self.field = _StubField(NX, NY)
|
||||||
|
|
||||||
|
def test_add_body_produces_added_solid_mask(self):
|
||||||
|
new_obj = _make_circle_obj(32, 16, 5)
|
||||||
|
self.mgr.stage_add(new_obj)
|
||||||
|
plan = self.mgr.build_sync_plan(self.field)
|
||||||
|
self.assertIsInstance(plan, BodySyncPlan)
|
||||||
|
self.assertEqual(plan.next_count, 1)
|
||||||
|
# Center should be in added_solid_mask (was fluid, becomes solid)
|
||||||
|
center_idx = 32 + 16 * NX
|
||||||
|
self.assertTrue(plan.added_solid_mask[center_idx])
|
||||||
|
|
||||||
|
def test_remove_body_produces_released_fluid_mask(self):
|
||||||
|
obj = _make_circle_obj(32, 16, 5)
|
||||||
|
id0 = self.mgr.add(obj)
|
||||||
|
# Build current flags so the field "knows" about this body
|
||||||
|
base = self.field.build_channel_flags()
|
||||||
|
self.field.flag = self.mgr.build_flags(base)
|
||||||
|
self.mgr.stage_remove(id0)
|
||||||
|
plan = self.mgr.build_sync_plan(self.field)
|
||||||
|
center_idx = 32 + 16 * NX
|
||||||
|
self.assertTrue(plan.released_fluid_mask[center_idx])
|
||||||
|
|
||||||
|
def test_no_change_masks_are_empty(self):
|
||||||
|
plan = self.mgr.build_sync_plan(self.field)
|
||||||
|
self.assertFalse(np.any(plan.added_solid_mask))
|
||||||
|
self.assertFalse(np.any(plan.released_fluid_mask))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitPending(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.cfg = _StubCfg()
|
||||||
|
self.mgr = ObjectManager(nx=NX, ny=NY, nz=1, nq=9, cfg=self.cfg)
|
||||||
|
|
||||||
|
def test_commit_replaces_registry(self):
|
||||||
|
obj0 = _make_circle_obj(20, 16, 3)
|
||||||
|
obj1 = _make_circle_obj(40, 16, 3)
|
||||||
|
id0 = self.mgr.add(obj0)
|
||||||
|
self.mgr.add(obj1)
|
||||||
|
self.mgr.stage_remove(id0)
|
||||||
|
next_objs = self.mgr.build_next_objects()
|
||||||
|
self.mgr.commit_pending(next_objs, np.zeros(1, dtype=np.int32))
|
||||||
|
self.assertEqual(self.mgr.count, 1)
|
||||||
|
self.assertEqual(self.mgr.get(0).obj_id, 0)
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
def test_commit_clears_pending(self):
|
||||||
|
self.mgr.stage_add(_make_circle_obj(32, 16, 5))
|
||||||
|
next_objs = self.mgr.build_next_objects()
|
||||||
|
self.mgr.commit_pending(next_objs, np.zeros(1, dtype=np.int32))
|
||||||
|
self.assertFalse(self.mgr.has_pending_edit())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user