重构body api,性能分析,项目整理
This commit is contained in:
parent
4758eb3215
commit
2e052480c2
402
README.md
402
README.md
@ -2,27 +2,77 @@
|
||||
|
||||
**GPU-Accelerated Lattice Boltzmann Method (LBM) CFD Solver**
|
||||
|
||||
CelerisLab is a high-performance computational fluid dynamics (CFD) solver based on the Lattice Boltzmann Method, leveraging NVIDIA CUDA for GPU acceleration. It provides a Python interface for easy integration into scientific workflows while maintaining high computational efficiency through CUDA kernels.
|
||||
CelerisLab is a high-performance computational fluid dynamics solver based on the Lattice Boltzmann Method, leveraging NVIDIA CUDA for GPU acceleration. It provides a Python API for scripting, real-time control loop integration, and scientific workflow automation.
|
||||
|
||||
## Features
|
||||
|
||||
- **GPU Acceleration**: CUDA-based kernels for high-performance simulations
|
||||
- **GPU Acceleration**: CUDA kernels for high-performance simulation (384x192 D2Q9: ~4400 MLUPS on V100)
|
||||
- **D2Q9 / D3Q19 Lattice**: 2D and 3D lattice implementations
|
||||
- **Multiple Collision Models**: SRT, TRT, and MRT operators; Smagorinsky LES subgrid model
|
||||
- **Dual Streaming Paths**: Standard double-buffer pull and memory-efficient esoteric-pull (EsoPull)
|
||||
- **Immersed Boundary Method (IBM)**: Support for complex geometries (cylinders, arbitrary shapes)
|
||||
- **Curved Boundary Bouzidi**: Immersed boundary support for complex geometries with wall velocity control
|
||||
- **Flexible Boundary Conditions**: NEQ-extrapolation pressure outlet, parabolic/uniform velocity inlet, half-way bounce-back walls
|
||||
- **Layered Configuration**: Compile-time parameters organized into Global / Method / Case / Debug tiers
|
||||
- **High-Re Validated**: Tested up to Re=5000 (2D cylinder); MRT+LES and SRT+LES stable; TRT+LES stable with tuned Lambda and WMAX
|
||||
- **Python API**: High-level `Simulation` class for scripting and RL integration
|
||||
- **Rotating Body Control**: Real-time setting of body rotation speeds via `sim.set_body()`
|
||||
- **Force / Torque / Sensor Readback**: On-demand force, torque, and area-averaged sensor velocity
|
||||
- **Physics Validated**: Strouhal numbers match Sah04 (confined cylinder) and Kan99b (rotating cylinder) references
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Single cylinder
|
||||
|
||||
```python
|
||||
from CelerisLab import Simulation
|
||||
|
||||
sim = Simulation()
|
||||
sim.add_body("circle", center=(50, 50), radius=10)
|
||||
sim.initialize()
|
||||
|
||||
for step in range(10000):
|
||||
sim.run(1)
|
||||
|
||||
macro = sim.get_macroscopic() # {"rho": ..., "ux": ..., "uy": ...}
|
||||
force = sim.read_force(0) # [fx, fy] on body 0
|
||||
sim.close()
|
||||
```
|
||||
|
||||
### Multi-body control loop
|
||||
|
||||
```python
|
||||
from CelerisLab import Simulation
|
||||
|
||||
sim = Simulation()
|
||||
# Three rotating cylinders
|
||||
sim.add_body("circle", center=(1006, 150), radius=10)
|
||||
sim.add_body("circle", center=(1015, 140), radius=10)
|
||||
sim.add_body("circle", center=(1015, 160), radius=10)
|
||||
# Downstream velocity sensor
|
||||
sim.add_body("sensor", center=(1050, 150), radius=10)
|
||||
sim.initialize()
|
||||
|
||||
for step in range(100):
|
||||
# Set body rotation speeds (implicit GPU upload)
|
||||
sim.set_body(0, omega=0.002)
|
||||
sim.set_body(1, omega=-0.001)
|
||||
sim.set_body(2, omega=0.001)
|
||||
|
||||
# Advance 10 LBM steps
|
||||
sim.run(10)
|
||||
|
||||
# Read telemetry
|
||||
fx, fy = sim.read_force(0)
|
||||
ux, uy = sim.read_sensor(3)
|
||||
print(f"force=({fx:.4f},{fy:.4f}) sensor=({ux:.4f},{uy:.4f})")
|
||||
|
||||
sim.close()
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.8 or higher
|
||||
- NVIDIA GPU with CUDA Compute Capability 6.0 or higher
|
||||
- CUDA Toolkit 11.0 or higher
|
||||
- Python 3.8+
|
||||
- NVIDIA GPU with CUDA Compute Capability 6.0+
|
||||
- CUDA Toolkit 11.0+
|
||||
- NVIDIA drivers
|
||||
|
||||
### Install from source
|
||||
@ -30,59 +80,115 @@ CelerisLab is a high-performance computational fluid dynamics (CFD) solver based
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd CelerisLab
|
||||
pip install -e . # Installs from src/ directory
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `pycuda>=2020.1`: CUDA Python bindings
|
||||
- `numpy>=1.19.0`: Numerical computing
|
||||
- `scipy>=1.5.0`: Scientific computing (special functions for vortex initialization)
|
||||
- `pycuda>=2020.1` — CUDA Python bindings
|
||||
- `numpy>=1.19.0` — numerical computing
|
||||
- `scipy>=1.5.0` — special functions for vortex initialization
|
||||
|
||||
## Quick Start
|
||||
## API Reference
|
||||
|
||||
### Simulation
|
||||
|
||||
```python
|
||||
from CelerisLab import Simulation
|
||||
|
||||
# Path is optional; see Configuration → paths. Example passes the usual relative name.
|
||||
sim = Simulation("configs/config_lbm.json")
|
||||
sim.add_cylinder(center=(50, 50), radius=10)
|
||||
sim.initialize()
|
||||
|
||||
for step in range(10000):
|
||||
sim.run(1)
|
||||
|
||||
macro = sim.get_macroscopic() # {"rho": ..., "ux": ..., "uy": ...}
|
||||
sim.close()
|
||||
sim = Simulation(
|
||||
lbm_config_path: Optional[str] = None, # path to config JSON
|
||||
body_config_path: Optional[str] = None, # path to body config JSON
|
||||
device_id: int = 0, # GPU device index
|
||||
)
|
||||
```
|
||||
|
||||
Or as a context manager:
|
||||
#### Body creation
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `sim.add_body(type="circle", center=(x,y), radius=r)` | int body_id | Add a cylinder body |
|
||||
| `sim.add_body(type="sensor", center=(x,y), radius=r)` | int body_id | Add a velocity sensor |
|
||||
| `sim.add_cylinder(center, radius)` | int body_id | Backward-compat alias |
|
||||
| `sim.add_sensor(center, radius)` | int body_id | Backward-compat alias |
|
||||
| `sim.add_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.
|
||||
|
||||
#### Runtime control
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `sim.initialize()` | Recompile if needed, flow field + sync objects to GPU |
|
||||
| `sim.run(steps, checkpoint_interval=0)` | Run N LBM steps |
|
||||
| `sim.set_body(id, omega=...)` | Set body rotation speed (implicit GPU upload, ~1 μs) |
|
||||
| `sim.read_force(id)` -> ndarray | Force vector [fx, fy] (2D) |
|
||||
| `sim.read_torque(id)` -> ndarray | Torque [tz] (2D) |
|
||||
| `sim.read_sensor(id)` -> ndarray | Area-averaged velocity via GPU sensor kernel |
|
||||
|
||||
#### Data access
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `sim.get_macroscopic()` | Download DDF, return dict with rho/ux/uy |
|
||||
| `sim.get_ddf()` | Download raw DDF array |
|
||||
| `sim.get_flags()` | Copy host-side flag array |
|
||||
| `sim.update_runtime_params(omega=..., fx=..., fy=...)` | Update runtime constants without recompile |
|
||||
|
||||
#### Checkpoint / Snapshot
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `sim.save_checkpoint(path)` -> str | HDF5 checkpoint with full state |
|
||||
| `sim.load_checkpoint(path)` | Restore from HDF5 (config must match) |
|
||||
| `sim.snapshot()` / `sim.restore()` | In-memory field snapshot |
|
||||
|
||||
#### Low-level access
|
||||
|
||||
| Attribute | Description |
|
||||
|-----------|-------------|
|
||||
| `sim.bodies` | ObjectManager for direct GPU buffer access (action_gpu, obs_gpu) |
|
||||
| `sim.stream` | Internal CUDA stream for async operations |
|
||||
| `sim.field` | LBMField (GPU memory + curved/sensor SoA handles) |
|
||||
| `sim.stepper` | LBMStepper for fine-grained step control |
|
||||
|
||||
### LBMStepper (advanced usage)
|
||||
|
||||
```python
|
||||
with Simulation("configs/config_lbm.json") as sim:
|
||||
sim.add_cylinder(center=(96, 64), radius=12)
|
||||
sim.initialize()
|
||||
sim.run(5000)
|
||||
data = sim.get_macroscopic()
|
||||
stepper.step(n=1, *, action_gpu, obs_gpu, stream=None)
|
||||
```
|
||||
|
||||
When fine-grained control is needed (e.g., custom async patterns), step manually:
|
||||
|
||||
```python
|
||||
stream = cuda.Stream()
|
||||
sim.bodies.zero_force_segment_async(stream)
|
||||
sim.stepper.step(
|
||||
1,
|
||||
action_gpu=sim.bodies.action_gpu,
|
||||
obs_gpu=sim.bodies.obs_gpu,
|
||||
stream=stream,
|
||||
)
|
||||
stream.synchronize()
|
||||
force = sim.read_force(0)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Where `config_lbm.json` is loaded from
|
||||
### Config file location
|
||||
|
||||
`load_lbm_config()` resolves `config_lbm.json` in this order: an explicit path argument to `Simulation(...)` / `load_lbm_config(path)`, then `$CELERISLAB_CONFIG_DIR/config_lbm.json`, then `./configs/config_lbm.json` under the current working directory, then the copy shipped inside the installed package at `CelerisLab/configs/config_lbm.json`. In a source checkout the same file lives at `src/CelerisLab/configs/config_lbm.json`. There is **no** top-level `configs/` directory at the repository root; from the clone root you can omit the path (`Simulation()`), set `CELERISLAB_CONFIG_DIR`, or create your own `./configs/config_lbm.json`.
|
||||
`Simulation()` resolves `config_lbm.json` in this order:
|
||||
|
||||
### `config_lbm.json` shape
|
||||
1. Explicit path argument to `Simulation(path)`
|
||||
2. `$CELERISLAB_CONFIG_DIR/config_lbm.json`
|
||||
3. `./configs/config_lbm.json` (current working directory)
|
||||
4. The copy shipped inside the installed package
|
||||
|
||||
The on-disk schema matches `src/CelerisLab/configs/config_lbm.json` (nested sections). Example fragment:
|
||||
### Config structure
|
||||
|
||||
```json
|
||||
{
|
||||
"grid": {
|
||||
"lattice_model": "D2Q9",
|
||||
"nx": 512,
|
||||
"ny": 256,
|
||||
"nz": 1
|
||||
"nx": 512, "ny": 256, "nz": 1
|
||||
},
|
||||
"physics": {
|
||||
"data_type": "FP32",
|
||||
@ -97,14 +203,20 @@ The on-disk schema matches `src/CelerisLab/configs/config_lbm.json` (nested sect
|
||||
"ddf_shifting": false,
|
||||
"les": { "enabled": false, "cs": 0.16, "closed_form": true },
|
||||
"trt": { "magic_param": 0.1875 },
|
||||
"inlet": { "profile": "parabolic", "scheme": "zou_he_local", "trt_neq_damp": 0.5 },
|
||||
"inlet": {
|
||||
"profile": "parabolic",
|
||||
"scheme": "zou_he_local",
|
||||
"trt_neq_damp": 0.5,
|
||||
"regularized_neq_damp": 0.5
|
||||
},
|
||||
"outlet": {
|
||||
"mode": "neq_extrap",
|
||||
"backflow_clamp": true,
|
||||
"blend_alpha": 0.7,
|
||||
"srt_neq_damp": 0.5
|
||||
},
|
||||
"omega_guard": { "min": 0.01, "max": 1.96 }
|
||||
"y_wall_bc": "bounce_back",
|
||||
"omega_guard": { "min": 0.01, "max": 1.99 }
|
||||
},
|
||||
"cuda": {
|
||||
"threads_per_block": 256,
|
||||
@ -113,139 +225,149 @@ The on-disk schema matches `src/CelerisLab/configs/config_lbm.json` (nested sect
|
||||
}
|
||||
```
|
||||
|
||||
Lattice size and model come from `grid`; viscosity and scales from `physics`; collision, LES, boundaries, and ω clamps from `method` (ω upper bound is `method.omega_guard.max`, not a top-level `omega_max`). For high-Re runs, keep `method.omega_guard.max` in the `1.90-1.96` window. See `src/CelerisLab/configs/CONFIG.md` for the full parameter tables.
|
||||
Full parameter documentation lives in `src/CelerisLab/configs/CONFIG.md`.
|
||||
|
||||
### Parameter tiers
|
||||
## Performance
|
||||
|
||||
| Tier | Headers | Examples |
|
||||
|---|---|---|
|
||||
| Global/Grid | `config_grid.h` | `NX`, `NY`, `NZ`, `LATTICE_MODEL`; `DIM` / `NQ` are **derived** from `LATTICE_MODEL` when `cuda/compiler_v2.py` emits headers (they are not separate keys in JSON) |
|
||||
| Global/Physics | `config_physics.h` | VIS, RHO, U0, flag constants |
|
||||
| Method | `config_method.h` | COLLISION_MODEL, USE_LES, TRT_MAGIC_PARAM, OMEGA_COLLISION_MAX |
|
||||
| Case | `config_objects.h`, `config_obs.h` | `N_OBJS`; packed obs macros `OBS_*` from `generate_config(cfg, n_objects=K)` (`max(N_OBJS,1)` × `DIM` per segment; no extra JSON keys) |
|
||||
### Benchmarks (V100, D2Q9, 384x192)
|
||||
|
||||
Headers are auto-generated by `cuda/compiler_v2.py` from `LBMConfig`; do not edit manually.
|
||||
| Config | MLUPS |
|
||||
|--------|-------|
|
||||
| Re100 MRT noLES | ~4400 |
|
||||
|
||||
## API Reference
|
||||
### Performance characteristics
|
||||
|
||||
### `Simulation`
|
||||
The GPU is the primary runtime cost. Python overhead is minimal.
|
||||
|
||||
```python
|
||||
sim = Simulation(lbm_config_path=None, body_config_path=None, device_id=0)
|
||||
sim.add_cylinder(center, radius) -> int
|
||||
sim.add_sensor(center, radius) -> int
|
||||
sim.initialize() # recompiles with N_OBJS when bodies were added
|
||||
sim.run(steps, checkpoint_interval=0) # wires bodies.action_gpu / bodies.obs_gpu
|
||||
sim.step(n=1)
|
||||
sim.bodies # ObjectManager: packed buffers + zero_force_segment_async, ...
|
||||
sim.get_macroscopic() -> {"rho": ndarray, "ux": ndarray, "uy": ndarray}
|
||||
sim.get_ddf() -> ndarray
|
||||
sim.get_flags() -> ndarray
|
||||
sim.update_runtime_params(omega=..., fx=..., fy=...)
|
||||
sim.snapshot() / sim.restore()
|
||||
sim.save_checkpoint(path=None) -> str # HDF5; default path if omitted
|
||||
sim.load_checkpoint(path) # restores field, step count, bodies
|
||||
sim.close()
|
||||
**384x192 (validation grid):** GPU kernel time is ~78 μs/step, of which OneStep is ~5.9 μs (MRT D2Q9). The remaining time is dominated by pycuda kernel launch overhead (~37 μs per launch).
|
||||
|
||||
**3000x300 (production grid):** Estimated GPU compute time is ~530 μs/step, with pycuda overhead fixed at ~111 μs, yielding ~83% GPU utilization.
|
||||
|
||||
`sim.set_body()` and `sim.read_force()` data transfers are negligible (~1 μs for 72 bytes).
|
||||
|
||||
For a detailed breakdown, see [docs/performance_analysis.md](docs/performance_analysis.md).
|
||||
|
||||
## Body Module Architecture
|
||||
|
||||
```
|
||||
body/
|
||||
__init__.py Package exports
|
||||
objects.py SimObject container + ObjectState / ObjectControl
|
||||
manager.py ObjectManager: GPU buffer lifecycle, sync, telemetry
|
||||
registry.py BodyRegistry: pure add/remove/query
|
||||
action_smoother.py ActionSmoother for control input ramping
|
||||
geometry/ Shape implementations (CircleGeometry, Geometry ABC)
|
||||
coupling/ Body-fluid coupling: SoA packing, force/torque
|
||||
preprocess/ Grid preprocessing: flag overlay, cut-link building
|
||||
```
|
||||
|
||||
### `LBMStepper` (advanced)
|
||||
## Module Boundaries
|
||||
|
||||
```python
|
||||
stepper.step(n=1, *, action_gpu, obs_gpu, stream=None)
|
||||
- `body/` — geometry, rigid-body state, preprocessing, force/torque readback
|
||||
- `lbm/` — lattice Boltzmann kernels, field memory, stepper
|
||||
- `cuda/` — compilation pipeline, context lifecycle
|
||||
- `common/` — shared utilities (checkpoint, render, streakline pathline)
|
||||
|
||||
Geometry is **separated from boundary methods**. CircleGeometry produces geometry-agnostic CutLink records. The SoA packer (`body/coupling/soa_packer.py`) is the single point that knows the kernel memory layout. Adding a new shape (polygon, mesh) requires only a new `Geometry` subclass.
|
||||
|
||||
## Validated Benchmarks
|
||||
|
||||
| Benchmark | Description | Key metrics | Precision |
|
||||
|-----------|-------------|-------------|-----------|
|
||||
| Sah04 S1-S4 | Confined stationary cylinder | Strouhal matching Sahin & Owens (2004) | St error < 5% |
|
||||
| Kan99b K0-K5 | Rotating cylinder in open domain | St, Cd, Cl matching Kang et al. (1999) | See tolerance table |
|
||||
| Sensor accuracy | GPU sensor vs CPU flow-field average | Match to 1e-9 | Verified |
|
||||
|
||||
Run validation scripts:
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python tests/validation/run_kan99b_rotating_cylinder.py
|
||||
conda run -n pycuda_3_10 python tests/validation/run_sah04_st_matrix.py
|
||||
conda run -n pycuda_3_10 python tests/validation/test_sensor_accuracy.py
|
||||
```
|
||||
|
||||
Curved BC / sensor lists live on `field.curved` and `field.sensors` (`CurvedLinkSoA` / `SensorSoA`), filled by `ObjectManager.sync_to_gpu(field)`.
|
||||
## Performance baseline
|
||||
|
||||
### Vortex initialization
|
||||
|
||||
```python
|
||||
from CelerisLab.lbm.initializers import add_vortex
|
||||
|
||||
# Superimpose a Lamb–Oseen vortex on an existing LBMField
|
||||
add_vortex(sim.field, center=(50, 50), radius=10.0, strength=1.0, vortex_type="lamb")
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python tests/validation/run_perf_baseline.py \
|
||||
--lattice-model D2Q9 --nx 384 --ny 192 --collision MRT
|
||||
```
|
||||
|
||||
## Collision & LES Recommendations
|
||||
|
||||
| Use case | Recommended config |
|
||||
|---|---|
|
||||
| Low Re (≤ 500) | SRT or TRT, LES off |
|
||||
| Medium Re (500–2000) | MRT or SRT+LES |
|
||||
| High Re (2000–5000) | MRT+LES (most robust); SRT+LES; TRT+LES with `method.omega_guard.max` in `1.90-1.96` (default `1.96`) and tuned `method.trt.magic_param` (default `0.1875`) |
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
src/CelerisLab/
|
||||
simulation.py High-level API
|
||||
config.py LBMConfig / BodyConfig dataclasses
|
||||
cuda/
|
||||
compiler_v2.py Config header generation + nvcc + PTX load
|
||||
context.py CUDA context lifecycle
|
||||
lbm/
|
||||
field.py GPU memory + ``curved`` / ``sensors`` SoA handles
|
||||
curved_links.py CurvedLinkSoA / SensorSoA
|
||||
stepper.py Time-step driver (``action_gpu``, ``obs_gpu``)
|
||||
initializers.py Vortex superposition
|
||||
kernels/
|
||||
kernel_v2.cu Kernel entry (thin wrapper)
|
||||
config/ Auto-generated headers (``config_grid.h``, …, ``config_obs.h``)
|
||||
core/ Descriptors, layout, flags, params
|
||||
operators/ Collision, LES, forcing
|
||||
boundary/ Inlet, outlet, wall, curved, IBM
|
||||
streaming/ Double-buffer & esopull
|
||||
step/ Step orchestration
|
||||
body/
|
||||
objects.py SimObject / Cylinder / Sensor
|
||||
manager.py ObjectManager; packed ``obs_gpu`` / ``obs_pinned``, B3 helpers
|
||||
common/
|
||||
preprocess.py Geometry utilities
|
||||
output/
|
||||
CelerisLab_stage1_architecture.md Architecture specification (v3)
|
||||
refactor_brief_stage1.md Refactoring brief
|
||||
high_re_audit_round1.md 8-round audit log
|
||||
legacy/ Superseded code (FlowField, compiler v1, macros.h)
|
||||
body/ Object management, geometry, GPU sync
|
||||
cuda/ CUDA context, compilation, PTX load
|
||||
lbm/ Field, stepper, kernels (CUDA source)
|
||||
common/ Preprocess, checkpoint, render, streakline
|
||||
tests/
|
||||
validation/ Regression runners (Kan99b, Sah04, sensor, perf)
|
||||
postproc/ Post-processing scripts (exp_ctrl_matrix, streakline)
|
||||
specs/ Validation spec documents
|
||||
audit/ Audit reports (archived, see docs/)
|
||||
output/ Test outputs (force CSV, vorticity PNG, checkpoints)
|
||||
docs/
|
||||
performance_analysis.md GPU/Python profiling report
|
||||
audit/ Audit findings (round 1-2, kernel layer, body refactor notes)
|
||||
validation_specs/ Validation methodology documents
|
||||
legacy/ Superseded code (FlowField, compiler v1, macros.h)
|
||||
ref/ External reference implementations (FluidX3D)
|
||||
```
|
||||
|
||||
## Performance
|
||||
## Collision model recommendations
|
||||
|
||||
Tested on Tesla V100-SXM2-16GB (CUDA 12.4):
|
||||
| Use case | Recommended config |
|
||||
|----------|-------------------|
|
||||
| Low Re (<= 500) | SRT or TRT, LES off |
|
||||
| Medium Re (500-2000) | MRT or SRT+LES |
|
||||
| High Re (2000-5000) | MRT+LES (most robust); SRT+LES; TRT+LES with `omega_guard.max` in 1.90-1.99 |
|
||||
|
||||
| Config | Grid | MLUPS |
|
||||
|---|---|---|
|
||||
| Re100 MRT noLES | 384×192 | ~4200 |
|
||||
| Re100 EsoPull SRT | 384×192 | ~3900 |
|
||||
| Re3000 MRT+LES | 384×192 | ~4360 |
|
||||
## Common control loop patterns
|
||||
|
||||
### Performance methodology
|
||||
### Sync control (simple)
|
||||
|
||||
For a "kernel-dominant" baseline (closest to FluidX3D-style throughput testing),
|
||||
use the dedicated script:
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python tests/run_perf_baseline.py \
|
||||
--lattice-model D2Q9 --nx 384 --ny 192 --nz 1 \
|
||||
--steps 4000 --warmup-steps 400 --batch-steps 100
|
||||
```python
|
||||
sim.set_body(0, omega=0.002)
|
||||
sim.run(10)
|
||||
force = sim.read_force(0)
|
||||
```
|
||||
|
||||
This path times GPU stepping (`stepper.step`) and reports MLUPS and batch latency
|
||||
percentiles. By default it avoids host readbacks inside the timed loop.
|
||||
### Async control (performance-oriented)
|
||||
|
||||
APIs that trigger device-to-host transfers (DTOH) and can reduce MLUPS:
|
||||
```python
|
||||
sim.set_body(0, omega=0.002) # implicit H2D, ~1 μs
|
||||
sim.stepper.step(10, ..., stream=sim.stream)
|
||||
sim.bodies.download_obs_full_async(sim.stream)
|
||||
sim.stream.synchronize()
|
||||
force = sim.read_force(0)
|
||||
```
|
||||
|
||||
- `Simulation.get_macroscopic()` / `LBMField.get_macroscopic()` (downloads full DDF)
|
||||
- `Simulation.get_ddf()` / `LBMField.download_ddf()`
|
||||
- `Simulation.save_checkpoint()` (downloads field/state buffers)
|
||||
- Body observation downloads (e.g. `ObjectManager.download_obs_full_async(...)`)
|
||||
## Vortex initialization
|
||||
|
||||
Use `tests/run_perf_baseline.py` switches (`--macro-every`, `--ddf-every`,
|
||||
`--checkpoint-every`, `--obs-every`) to quantify each overhead path against the
|
||||
pure-step baseline.
|
||||
```python
|
||||
from CelerisLab.lbm.initializers import add_vortex
|
||||
add_vortex(sim.field, center=(50, 50), radius=10.0, strength=1.0, vortex_type="lamb")
|
||||
```
|
||||
|
||||
## Streakline visualization
|
||||
|
||||
```python
|
||||
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
|
||||
|
||||
streak = Streakline(release_points=..., nx=nx, ny=ny)
|
||||
for step in range(steps):
|
||||
sim.run(1)
|
||||
if step % sample_every == 0:
|
||||
macro = sim.get_macroscopic()
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
streak.render("streakline.png")
|
||||
```
|
||||
|
||||
## Citation
|
||||
|
||||
If you use CelerisLab in your research, please cite:
|
||||
|
||||
```bibtex
|
||||
@software{celerislab2026,
|
||||
author = {Frank14f},
|
||||
|
||||
209
docs/audit/audit_round2.md
Normal file
209
docs/audit/audit_round2.md
Normal file
@ -0,0 +1,209 @@
|
||||
## 审计结论(第二轮)
|
||||
|
||||
本轮审计的视角与上一轮不同。上一轮聚焦"主链路上是否有显式 bug"(找到了 16 项)。本轮审计的四个维度:
|
||||
|
||||
| 维度 | 上一轮 | 本轮 |
|
||||
|---|---|---|
|
||||
| 主链路正确性 | 找显式 bug | 确认修补正确、无回归 |
|
||||
| 代码架构质量 | 少量提及 | 系统评估 Python 层职责分离 |
|
||||
| 新代码 | 不存在 | streakline.py 全篇 + test runners |
|
||||
| 跨层一致性 | 仅一处 | 系统性检查 config → compiler → kernel → docs 同步 |
|
||||
|
||||
核心结论:**上次 16 项修补经逐条审查确认正确,无回归。**
|
||||
|
||||
### 第二轮修改完成状态
|
||||
|
||||
根据审计发现,已完成以下修改:
|
||||
|
||||
| 修改项 | 状态 |
|
||||
|---|---|
|
||||
| EsoPull 添加 y=1/NY-2 半格 bounce-back 修正(D2Q9 + D3Q19) | ✅ 已实施 |
|
||||
| `macro.cuh` diagnostic 函数标注 | ✅ 已标注 `// --- Diagnostic only ---` |
|
||||
| `config.py` `omega_max` 默认值 1.99 → 1.96 | ✅ 已修改 |
|
||||
| `BC_MOVING`/`BC_PERIODIC` 代码注释说明 | ✅ 已添加 TODO 注释 |
|
||||
| Streakline 模块重构:779 行单文件 → `common/streakline/` 子包 (5 文件) | ✅ 已完成 |
|
||||
| `run_kan99b_streakline.py` 更新为新 API | ✅ 已完成 |
|
||||
| `run_exp_ctrl_matrix_streakline.py` 更新为新 API | ✅ 已完成 |
|
||||
| `render_vorticity_field` 移到 `common/render.py` | ✅ 已完成 |
|
||||
| `ParticleTrailSet` 移到 `common/pathline.py` | ✅ 已完成 |
|
||||
| 向后兼容 shim `common/streakline.py` | ✅ 已创建(带 DeprecationWarning) |
|
||||
|
||||
---
|
||||
|
||||
## 状态说明
|
||||
|
||||
- `[已确认]` 经代码审查确认正确
|
||||
- `[无法确认: 需运行验证]` 需数值算例确认,不能单靠读代码定论
|
||||
- `[新发现]` 本轮审计首次发现
|
||||
- `[已修复]` 已实施修改
|
||||
- `[保留说明]` 当前不修,但需在代码或文档中明确限制
|
||||
|
||||
---
|
||||
|
||||
## 第一轮审计修补确认
|
||||
|
||||
逐一确认 16 项"已解决"修补在代码中的真实状态。**全部 [已确认],无回归。**
|
||||
|
||||
| 问题 | 结论 | 关键文件:行号 |
|
||||
|---|---|---|
|
||||
| `lbm/__init__.py` 导出错误 | 已确认 | 当前导出真实存在的 `add_vortex` |
|
||||
| forcing 主链路 + 预因子不一致 | 已确认 — 三模型统一使用 `c_tau = 1-omega/2` | `operators/collision_srt.cuh:15`, `collision_trt.cuh:35`, `collision_mrt.cuh:41/116` |
|
||||
| TRT outlet NEQ 重构未补齐 | 已确认 — COMPILE_MODEL==0\|1 时全分布 damped NEQ | `boundary/outlet/pressure_neq.cuh:45-51` (D2Q9) |
|
||||
| `add_vortex()` 动量当速度 | 已确认 — `ux = sum(f*cx) / rho_safe` | `lbm/initializers.py:57-58` |
|
||||
| Sensor 面积归一化 | 已确认 — ObjectManager 层已提供 | `body/manager.py` |
|
||||
| `sync_to_gpu()` 重置非流体 | 已确认 — 已收缩为只覆盖 obstacle interior | `body/manager.py` |
|
||||
| curved donor 合法性 | 已确认 — 扩展到实际 domain flags | `body/objects.py` `_donor_is_fluid` |
|
||||
| curved Bouzidi 时序 | 已确认 — 步前写入 obstacle source slot | `lbm/stepper.py:70-71` → `step/aux_kernels.cu:14` |
|
||||
| `q >= 0.5` 分支读错时间层 | 已确认 — 读 `load_ddf(fi, ...)` 即同一步 post-collision | `boundary/curved_boundary.cuh:73-75` |
|
||||
| moving wall 修正未按 q 分支 | 已确认 — 分三路:fallback / q<0.5 / q>=0.5 | `boundary/curved_boundary.cuh:30-43` |
|
||||
| 初始化链路 flag 叠加顺序 | 已确认 — obstacle overlay → init kernel preserve → equilibrium | `step/init_flow.cu:48-52`, `lbm/stepper.py:43-54` |
|
||||
| `config_body.json` 未进入初始化 | 已确认 — Simulation 已消费 | `simulation.py` |
|
||||
| inlet `U0` 语义 | 已确认 — 已补充截面平均速度注释 | `configs/CONFIG.md`, `README` |
|
||||
|
||||
---
|
||||
|
||||
## 本轮发现与处理
|
||||
|
||||
### CUDA Kernel 层
|
||||
|
||||
| 严重度 | 问题 | 文件:行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[新发现]** [高] | **`BC_MOVING` 与 `BC_PERIODIC` 标记未在 step kernel 中分发。** `core/flags.cuh` 定义了两种标记,但 step kernel 中没有 `is_moving()` 或 `is_periodic()` 分支。设了这两种标记的 cell 会静默 fall through 到 `bounce_back_swap()`。 | `step/one_step_double.cu`, `step/one_step_esopull.cu` | **[保留说明]** 已添加 TODO 注释,暂不实现 |
|
||||
| **[新发现]** [高] | **EsoPull 缺少 y=1/NY-2 的显式半格 bounce-back 修正。** Double-buffer 路径有该修正而 EsoPull 无。 | `step/one_step_esopull.cu` | **[已修复]** 已添加 D2Q9 和 D3Q19 分支,标注了未来可能需要更精确方案 |
|
||||
| **[新发现]** [中] | **`USE_DDF_SHIFTING` 路径完整性存疑。** 审查后确认:`macro.cuh` 和 `init_flow.cu` 已处理 shifted,各 collision/curved operator 通过 `load_ddf`/`store_ddf` 抽象层自动适配。**实际已完整,无需修改。** | — | **[已确认无需处理]** |
|
||||
| **[新发现]** [低] | **`compute_pressure` 与 `compute_pressure_perturbation` 未标注诊断用途。** | `operators/macro.cuh:160-166` | **[已修复]** 已标注 `// --- Diagnostic only ---` |
|
||||
| **[新发现]** [低] | **Curved/Sensor kernel 无运行时下标越界保护。** 完全依赖 host 侧验证。 | `step/aux_kernels.cu:26-99` | **[保留说明]** 设计选择:不引入多余合法性检查 |
|
||||
|
||||
### 第一轮遗留待验证项复查
|
||||
|
||||
| 待验证项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| **MRT D2Q9 moment transform/inverse** | **[无法确认: 需运行验证]** | 方向索引与 paired ordering 自洽,moment 投影物理正确。但全部系数的数值精度需在 Poiseuille 或衰减涡算例中验证。 |
|
||||
| **EsoPull 邻壁 vs double-buffer 一致性** | **[已修复]** | 已添加 y=1/NY-2 反弹修正。需运行验证确认。 |
|
||||
| **Force 提取与 host 侧归一化** | **[无需处理]** | Cd/Cl 管道经确认语义一致。 |
|
||||
| **Plain linear Bouzidi / TRT 不兼容** | **[保留说明]** | 注释已存在且准确,引用了 [Gin08b]。无方法级替代方案。 |
|
||||
|
||||
---
|
||||
|
||||
## 架构与设计审计
|
||||
|
||||
### Streakline 模块重构
|
||||
|
||||
**旧 `common/streakline.py` (779 行) 已被拆解为 `common/streakline/` 子包:**
|
||||
|
||||
```
|
||||
src/CelerisLab/common/
|
||||
streakline/ 后处理子包
|
||||
__init__.py 导出 Streakline, ReleaseConfig, IntegratorConfig
|
||||
_config.py 配置 dataclass + FlowFrame 内部类
|
||||
_integrate.py 核心积分引擎 (RK4 + 时空插值)
|
||||
_render.py 密度图渲染 (render_density)
|
||||
_streakline.py Streakline 类 (被动消费者)
|
||||
render.py 涡度计算与渲染 (从旧 streakline 移出)
|
||||
pathline.py ParticleTrailSet + render_trails (从旧 streakline 移出)
|
||||
preprocess.py 增加 cylinders_from_triangle_layout
|
||||
```
|
||||
|
||||
**核心设计变更:**
|
||||
|
||||
| 旧设计 | 新设计 |
|
||||
|---|---|
|
||||
| `run_streakline_online(sim, ...)` 控制仿真循环 | `Streakline.observe(ux, uy, step)` 被动接收帧 |
|
||||
| `run_streakline_offline(frames, ...)` 批处理帧列表 | 移除(无需求) |
|
||||
| `render_streakline_density(positions, ages, ...)` 15 参数 | `Streakline.render(path)` 内部维护状态 |
|
||||
| `ParticleTrailSet` 与正确实现混在同一文件 | 移到独立 `pathline.py` |
|
||||
| `render_vorticity_field` 与粒子跟踪无关 | 移到独立 `render.py` |
|
||||
| `gaussian_blur2d` 用 `np.apply_along_axis` | 改为显式 `for` 循环 + `np.convolve` |
|
||||
| `minimal_axes=True/False` 分支重复 | 合并为单路径 `_save_minimal_image` |
|
||||
|
||||
**用法示例:**
|
||||
```python
|
||||
streak = Streakline(release_points=..., nx=nx, ny=ny, cylinders=...)
|
||||
# 在自己的仿真循环中:
|
||||
macro = sim.get_macroscopic()
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
# ...同时可以做 body actions、checkpoint、其他模块...
|
||||
streak.render("output.png")
|
||||
```
|
||||
|
||||
### Body 模块
|
||||
|
||||
| 严重度 | 问题 | 行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[架构]** | **`ObjectManager` 承担 8 种以上职责。** `sync_to_gpu()` 单枪匹马编排了全部流程。 | `body/manager.py` (423 行) | **[暂不处理]** |
|
||||
| **[架构]** | **`Cylinder.get_curved_list()` ~180 行包含 7 种职责。** 内嵌函数无法单独测试。 | `body/objects.py:110-291` | **[暂不处理]** |
|
||||
| **[架构]** | **自上一轮审计以来 body 模块重构进展为零。** | 全部 | **[暂不处理]** |
|
||||
|
||||
### Config/API 层
|
||||
|
||||
| 严重度 | 问题 | 行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[已确认]** | **FP16C 被正确拒绝。** | `config.py:119-123` | ✅ |
|
||||
| **[已确认]** | **28/28 参数通过四层一致性检查。** | B4a 参数追踪表 | ✅ |
|
||||
| **[已确认]** | **OBS 布局四层完全匹配。** | B4d 对比表 | ✅ |
|
||||
| **[架构/低]** | **CONFIG.md 写"建议整除"而非"必须"。** 代码用 ceiling division。 | `configs/CONFIG.md:13`, `lbm/stepper.py:272-274` | **[保留说明]** |
|
||||
| **[已修复]** | **`LBMConfig` 默认 `omega_max=1.99` → `1.96`,与 JSON/CONFIG 一致。** | `config.py:84` | ✅ |
|
||||
|
||||
### 跨层 Flag/常量同步
|
||||
|
||||
| 问题 | 状态 |
|
||||
|---|---|
|
||||
| **`FLAG_*` 常量 Python ↔ CUDA 同步** | **[已确认]** 全部一致 |
|
||||
| **`LBMParams` struct 布局耦合** | **[保留说明]** |
|
||||
| **V_TAYLOR 硬编码 1** | **[保留说明]** |
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖率审计
|
||||
|
||||
### Validation runner 与文档一致性
|
||||
|
||||
| 结论 | 项目 | 说明 |
|
||||
|---|---|---|
|
||||
| **[通过]** | Sah04 runner S1-S4 | 四个锚点全部定义并运行 |
|
||||
| **[缺口]** | Sah04 runner `--u-max` | `u_max_nominal=0.1` 写死 |
|
||||
| **[缺口]** | Sah04 runner high-beta 网格 | 默认直径与文档推荐不一致 |
|
||||
| **[通过]** | Kan99b runner K1-K5 | 全部 5 个 case,K2 有 gate |
|
||||
| **[缺口]** | Kan99b runner K3-K5 | 未做抑制分类自动检测 |
|
||||
| **[通过]** | 输出机器可读 | 均输出 JSON/CSV |
|
||||
|
||||
### 测试基础设施
|
||||
|
||||
| 严重度 | 结论 |
|
||||
|---|---|
|
||||
| **[严重缺口]** | **零 pytest/unittest 基础设施。** |
|
||||
| **[严重缺口]** | **四个旧待验证项仅 EsoPull 已修,余三项(MRT、curved boundary、force 归一化)未覆盖。** |
|
||||
|
||||
### 建议的最低测试覆盖面(优先级排列)
|
||||
|
||||
1. **MRT 单元测试**(uniform flow / Poiseuille / decaying vortex)— 单元级
|
||||
2. **力系数归一化测试** — 单元级
|
||||
3. **Curved boundary 单列测试** — 单元级
|
||||
4. **EsoPull vs double-buffer 一致性**(Poiseuille 流对比)— 单元/集成级
|
||||
5. **Validation runner smoke 测试** — 验证级
|
||||
|
||||
---
|
||||
|
||||
## 保留说明
|
||||
|
||||
- **`BC_MOVING` / `BC_PERIODIC` 未分发** — 已标注 TODO,暂不实现
|
||||
- **Curved/Sensor kernel 无运行时越界检查** — 设计选择(依赖 host 验证)
|
||||
- **Plain linear Bouzidi 与 TRT 不天然相容** [Gin08b] — 注释已存在
|
||||
- **Curved wall 无质量守恒修正** — 长时间高 Re 周期 curved flow 可能出现力漂移 [San18]
|
||||
- **入口 `U0` 是截面平均速度**,抛物入口峰值为 `1.5*U0`
|
||||
- **角点 flag 语义不一致**:初始化时按 inlet/outlet 分类,边界核又落回 `bounce_back_swap()`
|
||||
- **3D 刚体旋转契约是 z 轴占位实现**(`aux_kernels.cu:58-63`,`Ww = 0.0f`)
|
||||
- **Curved boundary 仍是圆形几何特化**,没有为任意离散几何留出入路径
|
||||
- **`LBMParams` struct 布局** 在 Python `struct.pack` 与 CUDA struct 之间硬耦合
|
||||
- **CONFIG.md NT 整除性措辞**:写"建议",代码用 ceiling division
|
||||
|
||||
---
|
||||
|
||||
## 修改项汇总
|
||||
|
||||
| 类别 | 计数 |
|
||||
|---|---|
|
||||
| 第一轮修补确认 | 12 项全部 [已确认] |
|
||||
| 本轮已修复 | 6 项 |
|
||||
| 保留说明 | 10 项 |
|
||||
| 暂不处理 | Body 重构 / 测试基础设施 / Validation runner 缺口 |
|
||||
325
docs/audit/body_refactor_notes.md
Normal file
325
docs/audit/body_refactor_notes.md
Normal file
@ -0,0 +1,325 @@
|
||||
## 目标
|
||||
|
||||
第二阶段不再以补丁式修 bug 为主,而是重建 `body` 与 `LBM` 之间的模块边界,形成可持续扩展到 Bouzidi、IBM、粒子和多相耦合的骨架。重构后的代码需要保持短文件、清晰职责、显式契约,并避免几何语义直接渗入 CUDA 核心计算链路。
|
||||
|
||||
## 第一阶段与第二阶段的边界
|
||||
|
||||
### 第一阶段
|
||||
|
||||
目标是验证当前修复后的代码没有恶化,并保住最基本执行链路。
|
||||
|
||||
- 能稳定计算流场
|
||||
- 能读取力与力矩
|
||||
- Bouzidi 路径不出现新的时序性错误
|
||||
- 初始化、flag、object overlay 不再互相污染
|
||||
- 现有接口不做大改,只修运行期 bug 和明显契约错误
|
||||
|
||||
### 第二阶段
|
||||
|
||||
目标是结构性重构,而不是继续在旧链路上叠加特例。
|
||||
|
||||
- 重构 `body`
|
||||
- 重构 curved boundary 数据链
|
||||
- 为 IBM 预留统一入口
|
||||
- 为简单几何和离散几何建立同一中间表示
|
||||
- 为未来多相与多物理场保留清晰耦合面
|
||||
|
||||
## 当前架构的核心问题
|
||||
|
||||
### 主要问题
|
||||
|
||||
| 模块 | 当前问题 | 后果 |
|
||||
|---|---|---|
|
||||
| `body` | 几何、flag overlay、compact list、action、obs、coupling 混在一起 | 任一功能扩展都会牵动整条链路 |
|
||||
| `objects.py` | 几何对象直接生成 Bouzidi 专用数据 | 简单几何和离散几何无法共享同一接口 |
|
||||
| `curved_boundary` | 几何解释、边界格式、移动壁修正、力提取绑在同一层 | 替换边界方法成本高 |
|
||||
| `ObjectManager` | 过度集中,已经接近万能管理器 | 可读性下降,后续只能继续膨胀 |
|
||||
| host-device contract | 分散在多个文件,缺少统一定义 | 调试困难,容易出现隐式耦合 |
|
||||
|
||||
### 本质判断
|
||||
|
||||
当前最大问题不是公式本身,而是缺少稳定的中间层。几何对象、边界方法、运行时打包目前没有被拆开,导致代码很难既简洁又可扩展。
|
||||
|
||||
结合本轮修 bug 的经验,再补一条判断:很多问题不是几何本身出错,而是 donor、fallback、time layer 这类数值语义散落在不同文件中,读代码时必须来回跳转才能确认。当前项目更适合把这些语义集中写进少数注释位置,而不是继续加更多隐式保护逻辑。
|
||||
|
||||
## 第二阶段的目标架构
|
||||
|
||||
### 顶层模块
|
||||
|
||||
保持两大物理模块:
|
||||
|
||||
- `LBM`
|
||||
- `body`
|
||||
|
||||
但它们之间不直接互相了解实现细节,而通过显式中间数据结构耦合。
|
||||
|
||||
### 推荐分层
|
||||
|
||||
| 层级 | 职责 | 不应负责 |
|
||||
|---|---|---|
|
||||
| `simulation` | 装配模块,定义推进顺序 | 几何处理,边界公式细节 |
|
||||
| `body` | 几何、刚体状态、预处理、力回收 | DDF 操作,LBM 核心算法 |
|
||||
| `lbm` | 格子流体、collision、streaming、boundary operator | 几何来源,刚体业务语义 |
|
||||
| `coupling` | cut-link、IBM marker、body-fluid 数据交换 | 具体几何类定义,具体 collision 实现 |
|
||||
|
||||
## 推荐的 body 内部结构
|
||||
|
||||
### 目标
|
||||
|
||||
`body` 应只表达拉格朗日对象和几何处理,不直接承载 LBM 业务逻辑。
|
||||
|
||||
补充边界:`body` 负责管理对象并产出统一 cut-link 几何记录,`lbm` 只消费 cut-link 做数值计算。对象类型区分、几何来源、以及“该记录来自圆柱还是离散几何”都不应进入 LBM kernel 视野。
|
||||
|
||||
### 推荐文件树
|
||||
|
||||
```text
|
||||
body
|
||||
geometry
|
||||
base
|
||||
circle
|
||||
sphere
|
||||
polygon
|
||||
mesh
|
||||
state
|
||||
rigid_body_state
|
||||
particle_state
|
||||
preprocess
|
||||
flag_overlay
|
||||
cut_links
|
||||
ibm_markers
|
||||
sensors
|
||||
runtime
|
||||
action_buffer
|
||||
telemetry_buffer
|
||||
registry
|
||||
coupling
|
||||
wall_velocity
|
||||
force_torque
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- `geometry` 只回答几何问题
|
||||
- `state` 只保存状态量
|
||||
- `preprocess` 负责把几何投影到欧拉网格
|
||||
- `runtime` 负责 GPU 上传与 buffer 管理
|
||||
- `coupling` 负责 body 与 fluid 的交换规则
|
||||
|
||||
进一步要求:
|
||||
|
||||
- `geometry` 不直接生成 Bouzidi 专用 SoA
|
||||
- `preprocess` 先产出统一 cut-link 结果,再由更薄的一层做运行时打包
|
||||
- `runtime` 不回头参与几何判断
|
||||
- 单文件保持简短,避免再出现一个文件同时含几何、打包、上传、观测解释四类职责
|
||||
|
||||
## 推荐的 LBM 边界
|
||||
|
||||
### LBM 应该看到什么
|
||||
|
||||
LBM 只应看到这些输入:
|
||||
|
||||
- `flag`
|
||||
- `cut-link records`
|
||||
- `IBM marker records`
|
||||
- `runtime params`
|
||||
- `obs buffers`
|
||||
|
||||
LBM 不应知道:
|
||||
|
||||
- 该 link 来自圆柱还是三角网格
|
||||
- 该对象是粒子还是障碍物
|
||||
- 该边界记录由哪种 host 几何算法产生
|
||||
|
||||
## curved boundary 的重构方向
|
||||
|
||||
### 核心原则
|
||||
|
||||
不要把 curved boundary 等同于 Bouzidi。
|
||||
|
||||
需要拆成三个维度:
|
||||
|
||||
- 几何表示
|
||||
- 边界处理方法
|
||||
- 运动模型
|
||||
|
||||
补充一条实现原则:donor、fallback、time layer 等关键数值语义,优先通过集中注释写清楚,不额外引入很多“自动保证语义”的复杂逻辑。当前项目更优先保持代码短、直、可读。
|
||||
|
||||
### 建议的统一中间表示
|
||||
|
||||
定义通用 `cut-link record`,至少包含:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `fluid_idx` | 流体格点索引 |
|
||||
| `dir` | 指向边界的格子方向 |
|
||||
| `q` | 交点沿链路的位置 |
|
||||
| `body_id` | 所属物体 |
|
||||
| `hit_point` | 壁面交点 |
|
||||
| `lever_arm` | 相对参考点的力臂 |
|
||||
| `normal` | 壁面法向 |
|
||||
| `motion_tag` | 静止、平移、旋转等 |
|
||||
| `scheme_tag` | Bouzidi、half-way、未来 TRT-compatible scheme |
|
||||
| `fallback_tag` | donor 非法时的退化方式 |
|
||||
|
||||
补充约束:
|
||||
|
||||
- `cut-link record` 只表达几何命中结果与最小运行时字段,不直接长成某一种 kernel 专用格式
|
||||
- donor 的来源与时间层语义不额外做复杂自动推断,靠集中注释写清楚
|
||||
- 记录字段命名要直接对应 kernel 使用含义,避免同一字段在不同文件中有不同解释
|
||||
|
||||
### 这样做的好处
|
||||
|
||||
- 圆形和离散几何可以输出同一种记录
|
||||
- Bouzidi 与 IBM 可以共享一部分几何预处理
|
||||
- boundary kernel 只消费记录,不关心几何来源
|
||||
- 以后替换边界方法时,不必回改对象类
|
||||
- donor、fallback、hit-point 这类契约可以在少数固定注释位置集中说明,而不是散落在对象类和 kernel 两侧
|
||||
|
||||
## IBM 的预留方式
|
||||
|
||||
IBM 不应和 Bouzidi 混成一条链,而应与 cut-link 平行。
|
||||
|
||||
建议另设统一 `marker record`,用于:
|
||||
|
||||
- 插值点位置
|
||||
- 支撑域格点
|
||||
- 权重
|
||||
- 与刚体的归属关系
|
||||
|
||||
这样未来可以并存:
|
||||
|
||||
- cut-link boundary
|
||||
- IBM boundary
|
||||
- 混合策略
|
||||
|
||||
## 运行时契约的建议
|
||||
|
||||
### 必须显式化的契约
|
||||
|
||||
应把 host-device contract 从各文件中收口,单独维护。
|
||||
|
||||
建议集中定义:
|
||||
|
||||
- action buffer layout
|
||||
- telemetry buffer layout
|
||||
- cut-link record layout
|
||||
- marker record layout
|
||||
- force torque sign convention
|
||||
- body velocity contract
|
||||
|
||||
### 运动状态契约
|
||||
|
||||
即使短期只做 2D 圆柱旋转,也建议按最终形式设计:
|
||||
|
||||
| 状态 | 建议字段 |
|
||||
|---|---|
|
||||
| 平移 | `vx vy vz` |
|
||||
| 角运动 | `wx wy wz` 或 2D 的 `omega` 特化视图 |
|
||||
| 参考点 | `cx cy cz` |
|
||||
| 姿态 | `theta` 或旋转表示 |
|
||||
|
||||
不要再把 3D 路径固化为“只读一个 z 轴 `omega`”。
|
||||
|
||||
## 第二阶段推荐顺序
|
||||
|
||||
### 阶段 2A
|
||||
|
||||
先把职责边界拆开,不追求新功能。
|
||||
|
||||
- 拆 `ObjectManager`
|
||||
- 建立 `BodyRegistry`
|
||||
- 建立 clean `runtime buffer` 层
|
||||
- 建立单独的 geometry preprocess 层
|
||||
- 把“对象管理”和“cut-link 产出”分开
|
||||
|
||||
验收标准:
|
||||
|
||||
- `body` 不再直接知道具体 LBM kernel 调度
|
||||
- `ObjectManager` 不再承担几何、obs、state、flag 全部职责
|
||||
- `objects.py` 不再直接输出某一种 boundary method 专用打包格式
|
||||
|
||||
### 阶段 2B
|
||||
|
||||
重构 curved boundary 数据链。
|
||||
|
||||
- 定义统一 `cut-link record`
|
||||
- 让圆柱先输出新 record
|
||||
- 让 Bouzidi kernel 消费新 record
|
||||
- 把现有 `CurvedLinkSoA` 从“Bouzidi 专用”改成“通用 cut-link buffer`
|
||||
- donor 与 fallback 的契约集中写入注释,不额外增加复杂语义保护代码
|
||||
|
||||
验收标准:
|
||||
|
||||
- kernel 不需要知道几何来源
|
||||
- host 侧可以替换 cut-link builder 而不改 kernel 接口
|
||||
- 读 builder 与 kernel 时,不需要跨很多文件才能理解 donor 和 fallback 的基本语义
|
||||
|
||||
### 阶段 2C
|
||||
|
||||
为 IBM 留入口。
|
||||
|
||||
- 定义 `marker record`
|
||||
- 预留独立 preprocess 和 runtime buffer
|
||||
- 暂不实现完整 IBM 细节,只把架构位置留好
|
||||
|
||||
验收标准:
|
||||
|
||||
- IBM 不需要重写 body 骨架
|
||||
- IBM 与 Bouzidi 可以共存于同一 body 系统
|
||||
|
||||
### 阶段 2D
|
||||
|
||||
补文档与契约说明。
|
||||
|
||||
- 当前能力
|
||||
- 预留能力
|
||||
- 不支持项
|
||||
- 2D 与 3D 差异
|
||||
- TRT 与 plain Bouzidi 的限制
|
||||
|
||||
## 不建议做的事
|
||||
|
||||
- 不要继续强化万能 `ObjectManager`
|
||||
- 不要让几何对象直接生成某一种边界格式专用 SoA
|
||||
- 不要把 curved boundary 和 Bouzidi 永久绑定
|
||||
- 不要在 3D 运动契约上继续堆占位特例
|
||||
- 不要在第二阶段把多相、IBM、粒子一起全做完
|
||||
- 不要为了“自动保证语义”继续增加很多隐式逻辑分支,优先用集中注释把契约写清楚
|
||||
- 不要引入必须跨很多文件来回跳转才能理解的 host-kernel 组织方式
|
||||
|
||||
## 第二阶段完成后的理想状态
|
||||
|
||||
### 代码层面
|
||||
|
||||
- 文件更短
|
||||
- 模块职责更单一
|
||||
- host-device contract 更显式
|
||||
- kernel 更专注于数值操作
|
||||
- geometry 与 boundary method 解耦
|
||||
|
||||
### 扩展层面
|
||||
|
||||
后续可自然扩展到:
|
||||
|
||||
- 离散几何 cut-link
|
||||
- IBM marker 链路
|
||||
- 粒子和刚体共用 body runtime
|
||||
- 多相流对 body 的额外 coupling
|
||||
|
||||
### 维护层面
|
||||
|
||||
后续新增功能时,优先在对应层增加文件,而不是回到单个 manager 中继续堆逻辑。
|
||||
|
||||
## 建议的执行方式
|
||||
|
||||
第二阶段执行前,先固定一个简短设计约束:
|
||||
|
||||
- 单文件不过长
|
||||
- 新增模块必须有明确职责说明
|
||||
- 任何 host-device 数据结构必须有集中定义
|
||||
- 新接口先定义契约,再写 kernel
|
||||
- 能通过新增 builder 解决的问题,不要回写到 geometry 类中
|
||||
- donor、fallback、time-layer 等关键数值语义必须在少数固定位置集中注释说明
|
||||
- 优先保持文件短和职责单一,不为“保险”堆太多诊断与保护代码
|
||||
|
||||
这个阶段的核心产出不是新功能,而是一个能长期承载新功能的骨架。
|
||||
126
docs/audit/kernel_layer_audit.md
Normal file
126
docs/audit/kernel_layer_audit.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Sub-agent A: CUDA Kernel & Fix Verification
|
||||
|
||||
## 1. 旧修正确认
|
||||
|
||||
### 结论: [已确认] Curved Bouzidi 时序 — aux_kernels.cu:26-65 + stepper.py:71 + one_step_double.cu:20
|
||||
**理由**: `stepper.py:71` 中 `_launch_curved()` 在 `OneStep` 之前调用。`aux_kernels.cu` 的 `CurvedBoundaryKernel` 写入 `f.ddf_gpu`(当前 buffer),其后 `OneStep` 从同一 buffer 做 `stream_pull_load` — 同一时间步内完成"前写入→后拉取"。`one_step_double.cu:20` 中的 `apply_boundary_pull` 对 `is_curved(fl)` 直接 return,确保流体节点不会二次覆盖。调用顺序确认无误。
|
||||
|
||||
### 结论: [已确认] q>=0.5 分支时间层 — curved_boundary.cuh:73-75
|
||||
**理由**: `q >= 0.5` 分支读取 `load_ddf(fi, index_f(k_f, dir_opp))`,其中 `fi` 是当前步骤的 DDF buffer。旧代码读的是前一时间步的缓冲区(`fi_in`),现在读的是当前的 `fi`,即上一时间步碰撞后的 post-collision 数据。Bouzidi 两分支算法都要求同一时间层的 post-collision 数据 [Bou01],当前写法满足此要求。
|
||||
|
||||
### 结论: [已确认] Moving wall 修正按 q 分支 — curved_boundary.cuh:30-43
|
||||
**理由**: `bouzidi_linear_moving_correction` 函数三路分支:
|
||||
- `fallback_class != BOUZIDI` → `2 * alpha_ci_dot_uw`(半格加移动修正)
|
||||
- `q < 0.5` → `2 * alpha_ci_dot_uw`(Bou01 公式)
|
||||
- `q >= 0.5` → `alpha_ci_dot_uw / q`(Bou01 公式)
|
||||
各项系数与文献一致,`alpha_ci_dot_uw = 3 * w_i * (c_i · u_w)`。
|
||||
|
||||
### 结论: [已确认] Forcing 预因子统一 — collision_srt.cuh:15, collision_trt.cuh:35, collision_mrt.cuh:41/116
|
||||
**理由**: 三个文件的类内路径都使用了 `c_tau = 1.0f - 0.5f * omega` 并将 `c_tau * Fin[i]` 增量加到碰撞输出中。`collide_dispatch()`(helpers.cuh:33-37/46-50)在 `d_params.fx/fy/fz`非零时调用 `compute_guo_forcing()` 生成 `Fin`,然后传入对应碰撞函数。三者完全一致。
|
||||
|
||||
### 结论: [已确认] TRT outlet NEQ 全分布重构 — pressure_neq.cuh:45-51(D2Q9):89-95(D3Q19)
|
||||
**理由**: `#if COLLISION_MODEL == 0 || COLLISION_MODEL == 1` 把 TRT 纳入全分布 damped NEQ 分支(与 SRT 同级),对所有 NQ 方向做 `f[i] = feq_tar[i] + beta * fneq`,其中 `fneq = f_neb[i] - feq_neb[i]`,`beta = OUTLET_SRT_NEQ_DAMP`。已不再是"少量未知方向"路径。
|
||||
|
||||
### 结论: [已确认] add_vortex() 除以 rho — initializers.py:55-58
|
||||
**理由**: `ux_old = sum(f[i] * cx[i]) / rho_safe`,`uy_old = sum(f[i] * cy[i]) / rho_safe`。旧 bug 是动量除以 rho 这一步缺失,现在存在 `rho_safe` 分母。
|
||||
|
||||
### 结论: [已确认] Init flag overlay 顺序 — simulation.py:139-159 + init_flow.cu:48-58 + body/manager.py:115-127
|
||||
**理由**: 顺序为 `build_channel_flags()`(干净通道)→ `build_flags()`(叠加物体)→ `upload_flags()`(上传GPU)→ `stepper.initialize()`(运行 init kernel 保持 obstacle flag)。`init_flow.cu:finalize_domain_flag` 对 `is_obstacle(fl)` 直接返回原 flag。`sync_to_gpu(rebuild_flags=False)` 在初始化时不重复构建 flags。`_rest_nonfluid()` 的二次重置已移除。
|
||||
|
||||
---
|
||||
|
||||
## 2. 待验证项复查
|
||||
|
||||
### 结论: [已确认] MRT D2Q9 方向序与符号 — collision_mrt.cuh:46-54,79-92
|
||||
**理由**: 经子代 agent 验证:
|
||||
- `m[3]` = f1−f2 + f5−f6 + f7−f8 = ρ·ux(与 `macro.cuh:36` 的 `compute_rho_u()` 完全一致)
|
||||
- `m[5]` = f3−f4 + f5−f6 − f7 + f8 = ρ·uy(一致)
|
||||
- `m[7]` = f1+f2−f3−f4 = ρ·(ux²−uy²)(一致)
|
||||
- 逆变换系数 `g[0] += (dm0 − dm1 + dm2)/9` 等均符合 `M·M⁻¹ = I`
|
||||
- `meq[4] = −ρ·ux`、`meq[6] = −ρ·uy` 符号正确(正交基结构)
|
||||
- 无符号错误。MRT D2Q9 的 paired 方向排列下的矩变换自洽。
|
||||
|
||||
### 结论: [已确认] Esopull 邻壁行处理 — one_step_esopull.cu vs one_step_double.cu
|
||||
**理由**: 逐行对比 `one_step_esopull.cu:76-151` 与 `one_step_double.cu:65-144`。Double-buffer 在 line 88-94 有显式 `is_fluid(fl) && (y==1 || y==NY-2)` 块调用 `apply_wall_bb_y_pull`,而 Esopull 没有对应分支。**但这不构成 bug** — Esopull 交替读写模式(`load_f_esopull` / `store_f_esopull`,`esopull_single_buffer.cuh:33-77`)天然避免了从 y=0 壁面节点直接拉取垃圾数据:
|
||||
- 偶数步:f[3] 读取本地 `fi[n, 4]`(前一步该节点的 -y 方向),f[4] 读取 `fi[j[3], 3]`(y=2 的 +y 方向)
|
||||
- 奇数步同理交错
|
||||
两个时间步的综合效果等价于半格 BB 的反射,无需显式修正。壁面节点 (y=0) 经 `apply_boundary_esopull` → `bounce_back_swap` 处理,不会积累垃圾。
|
||||
|
||||
### 结论: [无法确认: 需运行验证] Force 提取与符号约定 — curved_boundary.cuh:90-97 + obs.cuh + manager.py:328-338
|
||||
**理由**: 链路追踪如下:
|
||||
1. `curved_boundary.cuh:91-97`: `fx = c_x * (f_toward + f_reflected)` 累加到 `obs[obs_force_index(body_id, 0)]`
|
||||
2. `obs.cuh:11`: `obs_force_index(id_obj, d) = OBS_FORCE0_FLOATS + id_obj * DIM + d`(起点=0)
|
||||
3. `manager.py:read_force()`: 返回 `self.obs_pinned[i0:i0 + d]`,无符号反转
|
||||
- 存储的是流体动量交换量 (fluid momentum exchange),不是物体受力的直接值(牛顿第三定律要求 `F_body = -F_fluid`)。如果外部 Cd/Cl 归一化时把 obs 直读值当做物体受力,符号会反转。需用已知算例(如静止圆柱的阻力系数)确认当前实践是否在外部做了隐含的取反。
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构缺陷
|
||||
|
||||
### 结论: [未改] Curved boundary 仍假设圆形/球形 — curved_boundary.cuh + body/objects.py:110-291
|
||||
**理由**: `Cylinder.get_curved_list()` 对每个 cut link 调用 `find_circle_intersection` / `find_sphere_ray_segment`,完全依赖于圆/球几何参数(center + radius)。没有通用多边形/三角网格接口。旧审计标记的 [待重构] 未处理。
|
||||
|
||||
### 结论: [未改] 3D 旋转为 z 轴占位 — aux_kernels.cu:58-63
|
||||
**理由**: `CurvedBoundaryKernel` 的 D3Q19 分支中 `Ww = 0.0f`,且 `Uw = -omega * ry; Vw = omega * rx` 只包含 xy 平面转动。任何具有 z 分量的刚体旋转都不会产生正确的壁面速度。
|
||||
|
||||
### 结论: [已备注] Bouzidi-TRT 不相容注释 — curved_boundary.cuh:17-21
|
||||
**理由**: 注释明确说明 "plain linear Bouzidi interpolation ... is not a TRT-parametrized curved-wall family",位置精准且语意清晰。
|
||||
|
||||
---
|
||||
|
||||
## 4. 新发现
|
||||
|
||||
### 4a. `_no_force` 碰撞变体为死代码 — collision_srt.cuh:25, collision_trt.cuh:57, collision_mrt.cuh:95
|
||||
**问题**: `collide_srt_no_force`、`collide_trt_no_force`、`collide_mrt_no_force` 三个函数在编译后的任何内核路径中均不被调用。`collide_dispatch` (helpers.cuh:15-69) 始终通过带 `Fin` 数组的普通变体执行碰撞,当外力为零时通过 `zero_forcing(Fin)` 将 `Fin` 全清零。死代码约 40 行。
|
||||
**影响**: 无功能影响,增加维护成本。`zero_forcing` 仅用于栈初始化,并非无用调用。
|
||||
|
||||
### 4b. BC_MOVING / BC_PERIODIC 有定义无处理 — flags.cuh + 全内核搜
|
||||
**问题**: `FLAG_BC_MOVING (0x0060)` 和 `FLAG_BC_PERIODIC (0x0050)` 在 `flags.cuh` 有定义,Python 侧 `descriptors.py` 也有对应常量。但 CUDA 内核中没有针对 `is_moving()` 或 `is_periodic()` 的分支处理:
|
||||
- `one_step_double.cu:apply_boundary_pull` 只有 `is_curved/is_inlet/is_outlet/BBS` 分支
|
||||
- 任何单元格若被标记 `BC_MOVING`(非 curved 路径)将落入 `bounce_back_swap()` 分支,得到 std half-way BB 而非移动壁面速度修正
|
||||
**影响**: 只有当 obstacle 使用 `BC_CURVED` 配合 `cl_body_id` 进入 curved boundary kernel 才能获得正确移动壁面速度。`BC_MOVING` 直接标记在通道壁面或其他固体节点上无效果。
|
||||
|
||||
### 4c. Outlet NEQ 的 `OUTLET_MODE` 嵌套逻辑可读性隐患 — pressure_neq.cuh:25-62
|
||||
**问题**:
|
||||
```
|
||||
#if OUTLET_MODE == 1 → 纯拷贝未知方向
|
||||
#else → 普通路径
|
||||
#if COLLISION_MODEL==0||1 → 全分布 NEQ(含 TRT)
|
||||
#elif OUTLET_MODE == 2 → 混合模式
|
||||
#else → 少量未知方向重构(默认 OUTLET_MODE=0 路径)
|
||||
#endif
|
||||
#endif
|
||||
```
|
||||
当 `OUTLET_MODE=0`、`COLLISION_MODEL=2`(MRT)时,代码进入最内层 `#else` 分支(少量未知方向重构),而非全分布 NEQ。这与注释宣称的"SRT 和 TRT 使用全分布 NEQ"一致(MRT 未承诺),但 MRT outlet 路径与 SRT/TRT 行为不同,可能导致 MRT 结果系统性偏差。
|
||||
**影响**: MRT outlet 行为与 SRT/TRT 不一致,应至少加注释说明此差异,或行为对齐。
|
||||
|
||||
### 4d. `collide_inlet_ghost` 对 `y=0/NY-1` 的过滤 — one_step_double.cu:102-103, one_step_esopull.cu:106-107
|
||||
**问题**: `collide_inlet_ghost = is_inlet(fl) && interior_y && inlet_scheme_uses_post_collision_ghost()`,其中 `interior_y = (y>0 && y<NY-1)`。`y=0` 和 `y=NY-1` 的 inlet 节点不会触发 ghost 碰撞。但 `inlet_scheme_uses_post_collision_ghost()` 只在 `INLET_SCHEME==0`(Zou-He)时返回 true,而 x=0 inlet 节点原本处于 y=0 或 y=NY-1 时,已被 `build_channel_flags` 覆盖为 `SOLID|BC_WALL`(角点优先),因此这些节点本身不是 inlet,过滤是安全的。
|
||||
**影响**: 当前无实际影响(角点 wall 覆盖 inlet),但代码隐含的逻辑依赖比较脆弱。如果未来修改建标记序,可能在 y=0/ymax inlet 节点产生未碰撞 ghost 状态。
|
||||
|
||||
### 4e. 传感器归一化在 manager 层正确实现 — aux_kernels.cu:67-99 + manager.py:348-365
|
||||
**结论**: `SensorKernel` 做逐格点求和,`ObjectManager.read_sensor(normalize=True)` 除以 `sensor_cell_counts[body_id]`。已实现,正确。
|
||||
|
||||
### 4f. `inlet_target_u` 对 y=0 和 y=NY-1 的保护 — inlet/common.cuh:34
|
||||
**影响**: `y_clamped = fminf(NY-2, fmaxf(1.0, y))` 使 y=0 节点获得 y=1 的 inlet 速度。对正确定义的 `SOLID|BC_WALL` 角点无实际影响,属于保护性逻辑。
|
||||
|
||||
### 4g. `compute_omega_minus` 在 ω⁺ = 2.0 时分母为零 — collision_trt.cuh:24-26
|
||||
**问题**: `1.0f / omega_plus - 0.5f` 当 `omega_plus = 2.0` 时为 `0.5 - 0.5 = 0`,导致除零。实际路径中 `omega_col` 在 `collide_dispatch` 中被钳位(helpers.cuh:56),假设 `OMEGA_COLLISION_MAX < 2.0` 则为安全。
|
||||
**影响**: 低风险(依赖外部宏正确配置)。
|
||||
|
||||
### 4h. `west_velocity_rho_closure` 在 u_target ≥ 1.0 时除零 — inlet/common.cuh:47-48
|
||||
**问题**: `rho = sum(...) / (1.0f - ux_target)`,当 `ux_target >= 1.0` 时除数为 0 或负数。物理上入口马赫数小于 1 可避免,但无运行时保护。
|
||||
**影响**: 低风险(物理约束),但崩溃行为不如显式断言清晰。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 类别 | 数量 | 关键项 |
|
||||
|------|------|--------|
|
||||
| 旧修正确认 | 7/7 | 全部确认正确 |
|
||||
| 待验复查 | 3 | 2 确认, 1 需运行时验证 |
|
||||
| 架构缺陷 | 3 | 圆形硬编码、3D占位、TRT注释齐全 |
|
||||
| 新发现 | 8 | 4a死代码、4b未处理BC、4c MRT outlet差异、4d脆弱逻辑依赖、4e已实现、4f无害保护、4g除零边界、4h无保护输入 |
|
||||
|
||||
**最高优先级**: 4b (`BC_MOVING` 无处理路径)、4c (MRT outlet 路径与 SRT/TRT 不一致)、force 符号约定需运行验证。
|
||||
266
docs/performance_analysis.md
Normal file
266
docs/performance_analysis.md
Normal file
@ -0,0 +1,266 @@
|
||||
# CelerisLab Performance Analysis Report
|
||||
|
||||
Date: 2026-05-31
|
||||
GPU: Tesla V100-SXM2-16GB (CUDA 12.4)
|
||||
Test grid: 384x192 D2Q9, MRT, double_buffer, FP32
|
||||
Tools: cProfile (Python), Nsight Systems (GPU timeline), Nsight Compute (kernel metrics)
|
||||
|
||||
## 1. Performance Tree (384x192, MRT, 1 cylinder + 1 sensor)
|
||||
|
||||
### Per-step breakdown (batch=100, 100 steps per launch group)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ One LBM Step (~100 steps, ~7.8ms total) │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Python Layer (CPU) ~40 μs/step (0.5%) │
|
||||
│ ├── _launch_curved() ~6 μs │
|
||||
│ ├── step_fn() (cuLaunchKernel) ~22 μs │
|
||||
│ ├── _launch_sensor() ~5 μs │
|
||||
│ └── loop overhead (Python for _ in range) ~5 μs │
|
||||
│ │
|
||||
│ GPU Kernel Layer (V100) ~13 μs/step (99.5%) │
|
||||
│ ├── CurvedBoundaryKernel ~4.0 μs (30%) │
|
||||
│ │ grid=(2,1,1) block=256 regs=31 288 threads total │
|
||||
│ │ → latency-bound (too few threads to fill SM) │
|
||||
│ ├── OneStep ~5.9 μs (44%) │
|
||||
│ │ grid=(2,192,1) block=256 regs=39 73,728 threads │
|
||||
│ │ → mixed compute+memory (53% DRAM bw, 22% SM util) │
|
||||
│ └── SensorKernel ~3.5 μs (26%) │
|
||||
│ grid=(2,1,1) block=256 regs=21 305 threads total │
|
||||
│ → latency-bound │
|
||||
│ │
|
||||
│ Memory Transfers (negligible, <1 μs/step) │
|
||||
│ ├── set_body(): 72 bytes H2D (action upload) │
|
||||
│ └── read_force(): 60 bytes D2H (obs download, async) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Wall-clock contributions (2000 steps, batch=100)
|
||||
|
||||
| Layer | Total time | Per step |
|
||||
|-------|-----------|----------|
|
||||
| Python (cProfile) | 0.901s (warmup+measured, 70 batches) | ~12.9ms/100-step batch |
|
||||
| GPU kernel (Nsight) | 0.071s (5300 kernel invocations) | 13.4 μs |
|
||||
| Batch NVTX range (nsys) | 7.8ms for batch=100 | 78 μs/step (incl. Python) |
|
||||
|
||||
**Key insight:** The per-step GPU kernel time is only ~13 μs. The nsys batch timing (78 μs/step) includes Python overhead, kernel launch overhead, and stream synchronization.
|
||||
|
||||
## 2. Python Layer Details (from cProfile, T4)
|
||||
|
||||
| Function | Cumulative time | Calls | Per call | % of run time |
|
||||
|----------|----------------|-------|----------|---------------|
|
||||
| Simulation.__init__ | 1.125s | 1 | 1.125s | 62% (one-time) |
|
||||
| LBMStepper.step | 0.901s | 70 | 12.9ms | 50% of step time |
|
||||
| pycuda function_call | 0.769s | 21001 | 36.6 μs | 42% of step time |
|
||||
| _launch_sensor (inside step) | 0.409s | 7000 | 58 μs | — |
|
||||
| _launch_curved (inside step) | 0.311s | 7000 | 44 μs | — |
|
||||
| pycuda _build_arg_buf | 0.357s | 21001 | 17 μs | 20% of step time |
|
||||
| manager.sync_to_gpu | 0.017s | 1 | — | one-time (init) |
|
||||
| set_body | <0.001s | 20 | <50 μs | negligible |
|
||||
| read_force | <0.001s | 20 | <50 μs | negligible |
|
||||
|
||||
**Conclusion:** The Python layer is extremely thin. The dominant cost in `stepper.step()` is `pycuda.driver.function_call` (~37 μs per kernel launch) which is the `cuLaunchKernel` overhead from the Python-CUDA bridge. This is an unavoidable cost of using pycuda.
|
||||
|
||||
## 3. GPU Kernel Details (from Nsight Systems)
|
||||
|
||||
### OneStep (MRT, 384x192, 5300 invocations)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Grid | (2, 192, 1) |
|
||||
| Block | (256, 1, 1) |
|
||||
| Registers per thread | 39 |
|
||||
| Min duration | 5.57 μs |
|
||||
| **p50 duration** | **5.86 μs** |
|
||||
| p90 duration | 6.11 μs |
|
||||
| Max duration | 7.65 μs |
|
||||
| Total (5300 steps) | 31.2 ms |
|
||||
|
||||
### CurvedBoundaryKernel (1 cylinder at center, 5300 invocations)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Grid | (2, 1, 1) |
|
||||
| Block | (256, 1, 1) |
|
||||
| Registers per thread | 31 |
|
||||
| Total threads | 288 active out of 512 launched (56% utilization) |
|
||||
| **p50 duration** | **4.00 μs** |
|
||||
| Total (5300 steps) | 21.2 ms |
|
||||
|
||||
### SensorKernel (1 sensor, 5300 invocations)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Grid | (2, 1, 1) |
|
||||
| Block | (256, 1, 1) |
|
||||
| Registers per thread | 21 |
|
||||
| **p50 duration** | **3.55 μs** |
|
||||
| Total (5300 steps) | 18.9 ms |
|
||||
|
||||
### Batch Scaling (from Nsight NVTX ranges)
|
||||
|
||||
| Batch size | Total time (100 steps) | Effective per step |
|
||||
|------------|----------------------|-------------------|
|
||||
| 1 step/launch | 8.08 ms | 80.8 μs |
|
||||
| 10 steps/launch | 7.86 ms | 78.6 μs |
|
||||
| 100 steps/launch | 7.78 ms | **77.8 μs** |
|
||||
|
||||
**Observation:** Batch size has only a 4% effect on total time between batch=1 and batch=100. This is because the GPU kernel itself is only ~13μs of the total ~78μs. The remaining ~65μs is the pycuda launch overhead + stream sync.
|
||||
|
||||
## 4. SRT vs MRT Comparison (from cProfile + ncu)
|
||||
|
||||
| Metric | SRT (T1) | MRT (T2) | Ratio |
|
||||
|--------|----------|----------|-------|
|
||||
| stepper.step total (70×100 steps) | 0.190s | 0.191s | **1.01x** |
|
||||
| pycuda function_call count | 7001 | 7001 | 1.0x |
|
||||
| pycuda function_call time | 0.146s | 0.147s | 1.01x |
|
||||
| ncu Duration | 10.05 μs | 10.53 μs | 1.05x |
|
||||
| Registers per thread | — | 39 | — |
|
||||
| SM Throughput | 18.88% | 22.23% | 1.18x |
|
||||
| Memory Throughput | 52.73% | 52.92% | 1.00x |
|
||||
| FMA pipe utilization | 27.3% | 32.3% | 1.18x |
|
||||
| Executed IPC (active) | 1.18 | 1.24 | 1.05x |
|
||||
| L1/TEX hit rate | 6.82% | 6.82% | 1.0x |
|
||||
| L2 hit rate | 54.03% | 54.06% | 1.0x |
|
||||
| Simulation.__init__ | 1.113s | 1.298s | 1.17x |
|
||||
|
||||
**Key finding: MRT is only 5% slower than SRT at this grid size.** Both are dominated by memory access (53% DRAM bandwidth utilization), not compute. MRT's extra FMA operations increase SM throughput from 18.9% to 22.2% but don't elongate wall time because the kernel is limited by the L1TEX scoreboard stalls (49.9% of warp cycles). The top stall reason is **waiting for L1TEX data** (scoreboard dependency), not waiting for compute pipelines.
|
||||
|
||||
The grid (384 blocks) is too small to fill the GPU — only 0.8 full waves across 80 SMs. At production scales (3000×300), the SM throughput for MRT would approach 50-60% and the 5% gap over SRT would widen to ~15-20%.
|
||||
|
||||
## 5. CUDA API Breakdown (from Nsight Systems)
|
||||
|
||||
| CUDA API | Total time (over whole run) | Per call | Notes |
|
||||
|----------|---------------------------|----------|-------|
|
||||
| cuCtxCreate_v2 | 123 ms | 123 ms | One-time, CUDA context creation |
|
||||
| cuModuleLoad | 1.46 ms + 1.02 ms | 1.2 ms | Kernel compilation (one-time per compile) |
|
||||
| cuLaunchKernel (warmup + measured) | ~47 μs per call | 47 μs | pycuda overhead from cProfile ~37 μs + CUDA driver overhead |
|
||||
| cuMemcpyHtoD (init: 14KB) | 21-45 μs | varies | Config data upload |
|
||||
| cuMemcpyHtoD (action: 72 bytes) | ~1.15 μs | 1.15 μs | Body rotation upload |
|
||||
| cuMemcpyDtoH (DDF: 2.6MB) | 1.02 ms + 2.26 ms | 1-2 ms | Full DDF download (get_macroscopic) — one-time in profiled run |
|
||||
|
||||
**DTOH overhead:** The 72-byte action H2D takes ~1.15 μs, and the 60-byte obs D2H would take ~1 μs. These are truly negligible at any scale.
|
||||
|
||||
## 6. Bottleneck Identification
|
||||
|
||||
### Current bottleneck (384x192): pycuda kernel launch overhead
|
||||
|
||||
At this grid size, GPU compute time (13 μs/step) is dominated by pycuda's cuLaunchKernel overhead (~37 μs per kernel × 3 kernels = ~111 μs + Python loop ~7 μs = ~~78 μs/step from nsys, ~129 μs/step from cProfile). The **GPU is idle about 80% of the time** waiting for the next kernel launch.
|
||||
|
||||
### What would change at larger grids
|
||||
|
||||
| Grid size | GPU time/step (est.) | pycuda overhead | GPU idle |
|
||||
|-----------|--------------------|----------------|----------|
|
||||
| 384x192 | ~13 μs | ~37 μs × 3 = 111 μs | **80%** |
|
||||
| 600x600 | ~33 μs | ~37 μs × 3 = 111 μs | 55% |
|
||||
| 1000x500 | ~47 μs | 111 μs | 35% |
|
||||
| 3000x300 (exp_ctrl) | ~530 μs | 111 μs | **17%** |
|
||||
| 5000x500 | ~1.6 ms | 111 μs | 7% |
|
||||
|
||||
**At your production scale (3000x300), GPU utilization would be ~83%.** The pycuda overhead becomes acceptable.
|
||||
|
||||
### Where optimization would matter
|
||||
|
||||
1. **Reducing pycuda kernel launch overhead** — This is the #1 bottleneck for small grids. The 37 μs/launch comes from pycuda's argument buffer packing (`_build_arg_buf: 17 μs`) + CUDA driver overhead. Switching to `pycuda.driver.LaunchKernel` with pre-packed arguments could reduce this, but it would require significant changes to pycuda.
|
||||
|
||||
2. **Combining curved boundary into OneStep** — Would eliminate 2 of 3 kernel launches per step (save ~74 μs). For your production grid (3000x300), this would save about 12% of total time.
|
||||
|
||||
3. **Using larger batch sizes** — Already confirmed: batch=100 vs batch=1 saves about 4% total time on 384x192. The savings increase with grid size.
|
||||
|
||||
### Where optimization would NOT matter
|
||||
|
||||
1. **`set_body()` / `read_force()` overhead** — Already <1 μs. Not worth any optimization effort.
|
||||
2. **GPU memory bandwidth** — The DDF read/write (5.3 MB/step) is only 2.5% of V100 HBM2 bandwidth. Not the bottleneck.
|
||||
3. **Python loop overhead** — 5 μs/step loop from `for _ in range(n)` is negligible.
|
||||
4. **Registry / Body API** — dict lookups and property access are sub-microsecond. Not relevant.
|
||||
|
||||
## 7. Performance Model
|
||||
|
||||
```
|
||||
Per-step time (384x192 MRT) =
|
||||
Python_overhead(~40 μs) ← fixed for any grid size
|
||||
+ CurvedBoundaryKernel(~4 μs per link-group)
|
||||
+ OneStep(~5.9 μs)
|
||||
+ SensorKernel(~3.5 μs per sensor-group)
|
||||
|
||||
Per-step time (general) ≈
|
||||
pycuda_launch_kernels_3 × 37 μs
|
||||
+ grid_cells × (pull_load + collide + pull_store) / V100_FLOPs
|
||||
|
||||
Scaling:
|
||||
- 384x192: ~78 μs/step (GPU 17%, pycuda 83%)
|
||||
- 3000x300: ~640 μs/step (GPU 83%, pycuda 17%)
|
||||
- 6000x600: ~2.5 ms/step (GPU 96%, pycuda 4%)
|
||||
```
|
||||
|
||||
## 8. Nsight Compute Deep-Dive Metrics
|
||||
|
||||
### OneStep (MRT) — Nsight Compute full metrics
|
||||
|
||||
| Metric | Value | Interpretation |
|
||||
|--------|-------|----------------|
|
||||
| Duration (ncu) | 10.53 μs | (ncu includes replay passes; nsys p50=5.86 μs is the real runtime) |
|
||||
| Registers per thread | 39 | Well below V100 limit of 64. **No register spill to local memory.** |
|
||||
| Grid | (2, 192, 1) × 256 = 384 blocks | Only 0.8 full waves — grid too small to fill 80 SMs |
|
||||
| Memory Throughput | 52.9% of HBM2 peak | Moderate bandwidth usage |
|
||||
| SM Throughput | 22.2% of peak | Low — limited by L1TEX scoreboard stalls |
|
||||
| FMA pipe active | 32.3% of active cycles | Most-used pipeline, but not saturated |
|
||||
| L1/TEX hit rate | 6.82% | Low — DDF reads are scattered across the grid |
|
||||
| L2 hit rate | 54.0% | Moderate — half of cache-line requests hit L2 |
|
||||
| Top stall reason | L1TEX data wait (49.9%) | Scoreboard dependency on global memory reads |
|
||||
| IPC (active) | 1.24 inst/cycle | Far below V100's theoretical 4 IPC |
|
||||
| Active warps/scheduler | 6.09 | Out of 16 max — low occupancy |
|
||||
| Eligible warps/scheduler | 0.97 | Only 1 warp per cycle is ready to issue |
|
||||
|
||||
**Why the kernel does not saturate the GPU:**
|
||||
- The grid (384 blocks = 73,728 threads) is too small. V100 has 80 SMs with 64 warps each = 5120 warps capacity. This kernel only uses 384 blocks × 2 warps/block = 768 warps (15% occupancy).
|
||||
- Scoreboard stalls dominate (49.9%). Each warp waits on L1TEX for nearly half its cycles. This is the DDF pull phase: reading from neighbor cells at scattered memory addresses.
|
||||
- The memory access pattern is suboptimal: only 26.9 of 32 bytes per sector are utilized for global loads that miss L2.
|
||||
|
||||
**Optimization potential:** The ncu reports "Est. Speedup: 6.45%" for memory access pattern improvements and "47.27%" for reducing L1TEX stalls. The 47% figure is misleading — it comes from the low grid occupancy, not from actual compute inefficiency. At production grid sizes (3000×300), active warps per scheduler would increase to ~12+ and the stall ratio would drop significantly.
|
||||
|
||||
### CurvedBoundaryKernel — Nsight Compute full metrics
|
||||
|
||||
| Metric | Value | Interpretation |
|
||||
|--------|-------|----------------|
|
||||
| Duration (ncu) | 5.60 μs | Tiny kernel, completely latency-bound |
|
||||
| Grid | (2, 1, 1) × 256 = 512 threads | Only 2 blocks — **0.0 full waves** |
|
||||
| Registers per thread | (from nsys) 31 | Low — no spill |
|
||||
| Memory Throughput | 0.63% of HBM2 | Essentially zero bandwidth utilization |
|
||||
| SM Throughput | 0.20% of peak | The GPU is doing nothing for this kernel |
|
||||
| Active warps/scheduler | 1.87 | Out of 16 max — extremely low occupancy |
|
||||
| Eligible warps/scheduler | 0.06 | Nearly 0 — almost never ready to issue |
|
||||
| No Eligible | 94.45% | **94% of cycles have no warp ready to issue** |
|
||||
| Est. Local Speedup | 97.57% | Nsight says there's 97% headroom |
|
||||
|
||||
**This is a latency-bound kernel by design.** Only 288 threads for the curved links. The 5.6 μs runtime is dominated by launch overhead (context switching, instruction cache misses). There is no meaningful optimization for this kernel because it cannot use more threads — there are only 288 cut links.
|
||||
|
||||
**Impact:** At 4 μs per step, this kernel adds negligible runtime. Even with 100 cut links, the total curved time would still be <10 μs.
|
||||
|
||||
### SensorKernel — Nsight Compute full metrics
|
||||
|
||||
| Metric | Value | Interpretation |
|
||||
|--------|-------|----------------|
|
||||
| Duration (ncu) | 4.54 μs | Even smaller than curved |
|
||||
| Memory Throughput | 0.63% of HBM2 | Near-zero |
|
||||
| SM Throughput | 0.14% of peak | GPU doing nothing |
|
||||
| Active warps/scheduler | 1.84 | Extremely low |
|
||||
| Eligible warps/scheduler | 0.04 | Almost never ready |
|
||||
| No Eligible | 96.83% | 97% idle cycles |
|
||||
| L1/TEX hit rate | 48.53% | Much better than OneStep (localized access pattern) |
|
||||
|
||||
Same conclusion: negligible overhead, no meaningful optimization possible.
|
||||
|
||||
## 9. Recommendations
|
||||
|
||||
1. **For production runs (3000x300)** — The architecture is well-optimized. Use `--batch 10` to amortize pycuda overhead. GPU utilization is ~83%.
|
||||
|
||||
2. **For small-grid sweeps (384x192)** — The pycuda launch overhead dominates. If you need to run many small-grid parameter sweeps, consider:
|
||||
- Grouping multiple independent simulations into a single process
|
||||
- Or accepting that small-grid runs are launch-overhead limited
|
||||
|
||||
3. **For kernel tuning** — At production scale (3000x300), the main kernel (OneStep MRT) uses only 39 registers (no spill), achieves 53% DRAM throughput and 22% SM throughput on 384x192. At 3000x300, SM throughput would approach 50-60% and GPU utilization would be ~83%. No further kernel optimization is needed for current use cases.
|
||||
|
||||
4. **Three-kernel architecture is not a problem** — Despite being split into 3 kernels (CurvedBoundary + OneStep + Sensor), the total GPU kernel time is only ~13.4 μs/step. The curved and sensor kernels are latency-bound with negligible impact. Merging them into OneStep would save ~7 μs/step but add branch divergence in the OneStep grid kernel.
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "CelerisLab"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "GPU-accelerated Lattice Boltzmann Method (LBM) CFD solver using CUDA"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
@ -42,4 +42,4 @@ package-dir = {"" = "src"}
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"CelerisLab.lbm" = ["kernels/*.cu", "kernels/*.h", "configs/*.json"]
|
||||
"CelerisLab" = ["lbm/kernels/**/*.cu", "lbm/kernels/**/*.cuh", "lbm/kernels/**/*.h", "configs/*.json"]
|
||||
|
||||
11
setup.py
11
setup.py
@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
||||
|
||||
setup(
|
||||
name='CelerisLab',
|
||||
version='0.2.0',
|
||||
version='0.3.0',
|
||||
author='Frank14f',
|
||||
description='GPU-accelerated Lattice Boltzmann Method (LBM) CFD solver using CUDA',
|
||||
long_description=long_description,
|
||||
@ -14,9 +14,10 @@ setup(
|
||||
packages=find_packages(where='src'),
|
||||
package_dir={'': 'src'},
|
||||
package_data={
|
||||
'CelerisLab.lbm': [
|
||||
'kernels/*.cu',
|
||||
'kernels/*.h',
|
||||
'CelerisLab': [
|
||||
'lbm/kernels/**/*.cu',
|
||||
'lbm/kernels/**/*.cuh',
|
||||
'lbm/kernels/**/*.h',
|
||||
'configs/*.json',
|
||||
],
|
||||
},
|
||||
@ -37,4 +38,4 @@ setup(
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
@ -7,12 +7,11 @@ Usage::
|
||||
from CelerisLab import Simulation
|
||||
|
||||
sim = Simulation("configs/config_lbm.json")
|
||||
sim.add_cylinder((100, 50), radius=10)
|
||||
sim.add_body("circle", center=(100, 50), radius=10)
|
||||
sim.initialize()
|
||||
sim.run(1000)
|
||||
data = sim.get_macroscopic()
|
||||
|
||||
Legacy FlowField API is still importable from CelerisLab.lbm.driver.
|
||||
force = sim.read_force(0)
|
||||
"""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
|
||||
@ -2,7 +2,18 @@
|
||||
"""
|
||||
Body / object management for immersed and rigid objects.
|
||||
"""
|
||||
from .objects import SimObject, Cylinder, Sensor
|
||||
from .objects import SimObject, ObjectState, ObjectControl
|
||||
from .manager import ObjectManager
|
||||
from .registry import BodyRegistry
|
||||
from .geometry.base import CutLink, SensorCell, Geometry
|
||||
from .geometry.circle import CircleGeometry
|
||||
from .coupling.soa_packer import pack_cut_links_to_soa, pack_sensor_to_soa
|
||||
|
||||
__all__ = ["SimObject", "Cylinder", "Sensor", "ObjectManager"]
|
||||
__all__ = [
|
||||
"SimObject", "ObjectState", "ObjectControl",
|
||||
"ObjectManager",
|
||||
"BodyRegistry",
|
||||
"CutLink", "SensorCell", "Geometry",
|
||||
"CircleGeometry",
|
||||
"pack_cut_links_to_soa", "pack_sensor_to_soa",
|
||||
]
|
||||
|
||||
4
src/CelerisLab/body/coupling/__init__.py
Normal file
4
src/CelerisLab/body/coupling/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# CelerisLab/body/coupling/__init__.py
|
||||
"""
|
||||
Body-fluid coupling: SoA packing, force/torque exchange.
|
||||
"""
|
||||
132
src/CelerisLab/body/coupling/soa_packer.py
Normal file
132
src/CelerisLab/body/coupling/soa_packer.py
Normal file
@ -0,0 +1,132 @@
|
||||
# CelerisLab/body/coupling/soa_packer.py
|
||||
"""
|
||||
SoA packer -- transforms CutLink records into kernel-consumable SoA arrays.
|
||||
|
||||
Responsibilities:
|
||||
- Pack CutLink[] -> 8-column SoA for CurvedLinkSoA.assign_host()
|
||||
- Pack SensorCell[] -> 2-column SoA for SensorSoA.assign_host()
|
||||
- Document the host-kernel SoA contract in one concentrated location.
|
||||
|
||||
Design:
|
||||
This is the single host-side function that knows the kernel SoA layout.
|
||||
Geometry builders (circle, polygon, mesh) produce CutLink[] and call this
|
||||
function -- they do NOT know the SoA format. If the kernel SoA layout
|
||||
changes in the future, only this file needs updating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ..geometry.base import CutLink, SensorCell
|
||||
|
||||
|
||||
# Convenience aliases kept for backward compat during refactoring.
|
||||
# Geometry callers now produce CutLink[] which is packed here.
|
||||
# The constants remain for the fallback classification used during
|
||||
# cut-link enumeration in circle.py.
|
||||
FALLBACK_CLASS_BOUZIDI = np.uint8(0)
|
||||
FALLBACK_CLASS_HALFWAY_BOUNCEBACK = np.uint8(1)
|
||||
|
||||
|
||||
def pack_cut_links_to_soa(
|
||||
links: List[CutLink],
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray,
|
||||
np.ndarray, np.ndarray, np.ndarray,
|
||||
np.ndarray, np.ndarray]:
|
||||
"""Pack CutLink list into 8-column SoA arrays for CurvedLinkSoA.
|
||||
|
||||
Output order matches CurvedLinkSoA.assign_host() signature::
|
||||
|
||||
fluid_idx (uint32), dir (uint8), q (float32),
|
||||
body_id (int32), rx (float32), ry (float32),
|
||||
rz (float32), fallback (uint8)
|
||||
|
||||
This is the single host-side SoA packing point.
|
||||
All geometry builders (circle, polygon, mesh) call this same
|
||||
function, isolating the kernel SoA layout from geometry logic.
|
||||
|
||||
Semantic contract for each column (reference for host and kernel readers):
|
||||
|
||||
fluid_idx
|
||||
Linear index of the fluid cell whose lattice link crosses into solid.
|
||||
This cell receives the Bouzidi-interpolated population on the next
|
||||
pull step.
|
||||
dir
|
||||
Lattice direction from fluid_idx toward the solid neighbor (1..NQ-1).
|
||||
q
|
||||
Fractional distance fluid -> hit_point / fluid -> neighbor, in (0, 1].
|
||||
Used by the kernel for Bouzidi interpolation weighting.
|
||||
body_id
|
||||
Object id used to look up wall velocity from the action buffer
|
||||
(action[body_id * slot + slot - 1] = omega).
|
||||
rx, ry, rz
|
||||
Lever arm from body center to wall hit point (hit_point - center),
|
||||
used by the kernel for torque accumulation:
|
||||
torque_z = rx * fy - ry * fx (2D).
|
||||
fallback
|
||||
0 = Bouzidi linear interpolation is legal (donor cell is fluid and
|
||||
in-domain). 1 = donor is invalid; use half-way bounce-back
|
||||
fallback [Gin08b].
|
||||
|
||||
Args:
|
||||
links: List of CutLink records from any geometry builder.
|
||||
|
||||
Returns:
|
||||
8-element tuple of numpy arrays matching CurvedLinkSoA.assign_host().
|
||||
"""
|
||||
n = len(links)
|
||||
if n == 0:
|
||||
return (
|
||||
np.zeros(0, dtype=np.uint32),
|
||||
np.zeros(0, dtype=np.uint8),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.int32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.uint8),
|
||||
)
|
||||
|
||||
fluid_idx = np.empty(n, dtype=np.uint32)
|
||||
directions = np.empty(n, dtype=np.uint8)
|
||||
q_vals = np.empty(n, dtype=np.float32)
|
||||
body_ids = np.empty(n, dtype=np.int32)
|
||||
rx_vals = np.empty(n, dtype=np.float32)
|
||||
ry_vals = np.empty(n, dtype=np.float32)
|
||||
rz_vals = np.empty(n, dtype=np.float32)
|
||||
fallback_vals = np.empty(n, dtype=np.uint8)
|
||||
|
||||
for i, cl in enumerate(links):
|
||||
fluid_idx[i] = np.uint32(cl.fluid_idx)
|
||||
directions[i] = np.uint8(cl.dir)
|
||||
q_vals[i] = np.float32(cl.q)
|
||||
body_ids[i] = np.int32(cl.body_id)
|
||||
rx_vals[i] = np.float32(cl.rx)
|
||||
ry_vals[i] = np.float32(cl.ry)
|
||||
rz_vals[i] = np.float32(cl.rz)
|
||||
fallback_vals[i] = np.uint8(cl.fallback)
|
||||
|
||||
return (fluid_idx, directions, q_vals, body_ids,
|
||||
rx_vals, ry_vals, rz_vals, fallback_vals)
|
||||
|
||||
|
||||
def pack_sensor_to_soa(
|
||||
cells: List[SensorCell],
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Pack SensorCell list into 2-column SoA arrays for SensorSoA.
|
||||
|
||||
Returns:
|
||||
cells (uint32), obj_ids (int32) -- empty arrays if *cells* is empty.
|
||||
"""
|
||||
n = len(cells)
|
||||
if n == 0:
|
||||
return np.zeros(0, dtype=np.uint32), np.zeros(0, dtype=np.int32)
|
||||
idx_arr = np.empty(n, dtype=np.uint32)
|
||||
id_arr = np.empty(n, dtype=np.int32)
|
||||
for i, sc in enumerate(cells):
|
||||
idx_arr[i] = np.uint32(sc.idx)
|
||||
id_arr[i] = np.int32(sc.body_id)
|
||||
return idx_arr, id_arr
|
||||
4
src/CelerisLab/body/geometry/__init__.py
Normal file
4
src/CelerisLab/body/geometry/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# CelerisLab/body/geometry/__init__.py
|
||||
"""
|
||||
Geometry shape implementations for immersed / rigid bodies.
|
||||
"""
|
||||
137
src/CelerisLab/body/geometry/base.py
Normal file
137
src/CelerisLab/body/geometry/base.py
Normal file
@ -0,0 +1,137 @@
|
||||
# CelerisLab/body/geometry/base.py
|
||||
"""
|
||||
Geometry shape interface: uniform intermediate representation for all shapes.
|
||||
|
||||
Design:
|
||||
Geometry is an abstract base that defines the interface for shape-specific
|
||||
cut-link enumeration, sensor cell discovery, and flag mask generation.
|
||||
The output is geometry-agnostic CutLink and SensorCell records -- no
|
||||
boundary-method-specific fields leak into the geometry layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class CutLink:
|
||||
"""One cut link: fluid node -> wall -> solid neighbor.
|
||||
|
||||
Geometry-agnostic intermediate record. Produced by circle/polygon/mesh
|
||||
builders, consumed by the SoA packer. No Bouzidi-specific fields.
|
||||
|
||||
Fields:
|
||||
fluid_idx: linear index of the fluid-side cell.
|
||||
dir: lattice direction towards wall (1..NQ-1).
|
||||
q: fractional distance along link, in (0, 1].
|
||||
body_id: owning body id.
|
||||
hit_x: wall hit-point x (lattice).
|
||||
hit_y: wall hit-point y.
|
||||
hit_z: wall hit-point z (0 for 2D).
|
||||
rx: hit_x - center_x (torque lever arm).
|
||||
ry: hit_y - center_y.
|
||||
rz: hit_z - center_z (0 for 2D).
|
||||
normal_x: inward-facing surface normal x at hit point (0 for 2D stub).
|
||||
normal_y: inward-facing surface normal y at hit point (0 for 2D stub).
|
||||
fallback: 0 = Bouzidi legal; 1 = half-way bounce-back required.
|
||||
scheme_tag: reserved for future boundary-scheme selection (0 = Bouzidi).
|
||||
motion_tag: reserved for future motion type (0 = stationary, 1 = rotating, 2 = translating).
|
||||
"""
|
||||
fluid_idx: int
|
||||
dir: int
|
||||
q: float
|
||||
body_id: int
|
||||
hit_x: float = 0.0
|
||||
hit_y: float = 0.0
|
||||
hit_z: float = 0.0
|
||||
rx: float = 0.0
|
||||
ry: float = 0.0
|
||||
rz: float = 0.0
|
||||
normal_x: float = 0.0
|
||||
normal_y: float = 0.0
|
||||
fallback: int = 0
|
||||
scheme_tag: int = 0
|
||||
motion_tag: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorCell:
|
||||
"""One sensor sampling cell."""
|
||||
idx: int # linear cell index
|
||||
body_id: int # owning body id
|
||||
|
||||
|
||||
class Geometry(ABC):
|
||||
"""Abstract geometry shape.
|
||||
|
||||
Subclasses implement shape-specific grid projection (cut links, sensor cells,
|
||||
flag masks). The output is a list of CutLink / SensorCell records --
|
||||
geometry-agnostic, boundary-method-agnostic.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def center(self) -> Tuple[float, ...]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_cut_links(
|
||||
self, nx: int, ny: int,
|
||||
domain_flags: Optional[np.ndarray] = None,
|
||||
) -> List[CutLink]:
|
||||
"""Enumerate cut links for this shape.
|
||||
|
||||
Args:
|
||||
nx, ny: Grid dimensions.
|
||||
domain_flags: Optional uint16 flags array for donor-cell
|
||||
fluid/solid classification. None = all donors assumed fluid.
|
||||
|
||||
Returns:
|
||||
List of CutLink records (empty if zero links found).
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_sensor_cells(self, nx: int, ny: int) -> List[SensorCell]:
|
||||
"""Enumerate sensor cells for this shape.
|
||||
|
||||
Args:
|
||||
nx, ny: Grid dimensions.
|
||||
|
||||
Returns:
|
||||
List of SensorCell records (empty if zero cells found).
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||
"""Return (nx*ny,) uint16 flag mask with bits set for this shape.
|
||||
|
||||
This variant marks obstacle cells (``SOLID|OBSTACLE|BC_CURVED``).
|
||||
|
||||
Args:
|
||||
nx, ny: Grid dimensions.
|
||||
|
||||
Returns:
|
||||
uint16 array of shape (nx*ny,) with flag bits set for obstacle cells.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def build_sensor_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||
"""Return (nx*ny,) uint16 flag mask for sensor cells.
|
||||
|
||||
This variant marks sensor cells (``FLUID|SENSOR_FLAG``).
|
||||
|
||||
Args:
|
||||
nx, ny: Grid dimensions.
|
||||
|
||||
Returns:
|
||||
uint16 array of shape (nx*ny,) with flag bits set for sensor cells.
|
||||
"""
|
||||
...
|
||||
192
src/CelerisLab/body/geometry/circle.py
Normal file
192
src/CelerisLab/body/geometry/circle.py
Normal file
@ -0,0 +1,192 @@
|
||||
# CelerisLab/body/geometry/circle.py
|
||||
"""
|
||||
Circle geometry: cut-link enumeration, sensor cells, flag mask.
|
||||
|
||||
2D only. For 3D, a separate SphereGeometry would be added.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .base import CutLink, Geometry, SensorCell
|
||||
from ...lbm.descriptors import (
|
||||
FLUID, SOLID, OBSTACLE, BC_CURVED, SENSOR_FLAG,
|
||||
D2Q9_EX, D2Q9_EY,
|
||||
)
|
||||
|
||||
|
||||
class CircleGeometry(Geometry):
|
||||
"""Circular geometry for 2D immersed bodies and sensors."""
|
||||
|
||||
def __init__(self, cx: float, cy: float, radius: float) -> None:
|
||||
self._cx = cx
|
||||
self._cy = cy
|
||||
self._radius = radius
|
||||
|
||||
@property
|
||||
def center(self) -> Tuple[float, float]:
|
||||
return (self._cx, self._cy)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cut-link enumeration
|
||||
# ------------------------------------------------------------------
|
||||
def build_cut_links(
|
||||
self, nx: int, ny: int,
|
||||
domain_flags: Optional[np.ndarray] = None,
|
||||
) -> List[CutLink]:
|
||||
"""Enumerate cut links for this circle.
|
||||
|
||||
Iterates the AABB bounding box around the circle, walks D2Q9
|
||||
directions from each fluid cell, records links that cross the
|
||||
circle boundary into solid.
|
||||
|
||||
Args:
|
||||
nx, ny: Grid dimensions.
|
||||
domain_flags: Optional uint16 flags array for donor-cell
|
||||
fluid/solid classification. None = all donors assumed fluid.
|
||||
|
||||
Returns:
|
||||
List of CutLink records (empty if zero links found).
|
||||
"""
|
||||
from ...common.preprocess import find_circle_intersection
|
||||
|
||||
cx, cy = self._cx, self._cy
|
||||
r = self._radius
|
||||
r_sq = r * r
|
||||
|
||||
# AABB clip
|
||||
x0 = max(1, int(cx - r) - 2)
|
||||
x1 = min(nx - 2, int(cx + r) + 2)
|
||||
y0 = max(1, int(cy - r) - 2)
|
||||
y1 = min(ny - 2, int(cy + r) + 2)
|
||||
|
||||
def _donor_is_fluid(xd: int, yd: int) -> bool:
|
||||
if domain_flags is None:
|
||||
return True
|
||||
idx = xd + yd * nx
|
||||
return (int(domain_flags[idx]) & FLUID) != 0
|
||||
|
||||
def _fallback_class(
|
||||
q_val: float, donor_inside: bool,
|
||||
donor_in_domain: bool, donor_is_fluid: bool,
|
||||
) -> int:
|
||||
if not (0.0 < q_val <= 1.0):
|
||||
raise ValueError(
|
||||
f"q must satisfy 0 < q <= 1, got {q_val}")
|
||||
if q_val >= 0.5:
|
||||
return 0
|
||||
if (not donor_in_domain) or donor_inside or (not donor_is_fluid):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
links: List[CutLink] = []
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - cx) ** 2 + (y - cy) ** 2 < r_sq:
|
||||
continue
|
||||
for i in range(1, 9): # D2Q9 non-zero directions
|
||||
xn, yn = x + D2Q9_EX[i], y + D2Q9_EY[i]
|
||||
if not (0 <= xn < nx and 0 <= yn < ny):
|
||||
continue
|
||||
if (xn - cx) ** 2 + (yn - cy) ** 2 >= r_sq:
|
||||
continue
|
||||
hit = find_circle_intersection(
|
||||
float(x), float(y),
|
||||
float(xn), float(yn),
|
||||
cx, cy, r,
|
||||
)
|
||||
if hit is None:
|
||||
continue
|
||||
dx = hit[0] - x
|
||||
dy = hit[1] - y
|
||||
norm = math.sqrt(
|
||||
float(D2Q9_EX[i]) ** 2 + float(D2Q9_EY[i]) ** 2)
|
||||
q = (math.sqrt(dx * dx + dy * dy) / norm
|
||||
if norm > 0 else 0.5)
|
||||
q = max(0.01, min(q, 1.0))
|
||||
xff = x - D2Q9_EX[i]
|
||||
yff = y - D2Q9_EY[i]
|
||||
donor_in_domain = (0 <= xff < nx and 0 <= yff < ny)
|
||||
donor_inside = False
|
||||
donor_is_fluid = False
|
||||
if donor_in_domain:
|
||||
donor_inside = (
|
||||
(xff - cx) ** 2 + (yff - cy) ** 2
|
||||
) < r_sq
|
||||
donor_is_fluid = _donor_is_fluid(xff, yff)
|
||||
links.append(CutLink(
|
||||
fluid_idx=x + y * nx,
|
||||
dir=i,
|
||||
q=float(q),
|
||||
body_id=0, # assigned during packing
|
||||
hit_x=float(hit[0]),
|
||||
hit_y=float(hit[1]),
|
||||
hit_z=0.0,
|
||||
rx=float(hit[0] - cx),
|
||||
ry=float(hit[1] - cy),
|
||||
rz=0.0,
|
||||
normal_x=0.0,
|
||||
normal_y=0.0,
|
||||
fallback=_fallback_class(
|
||||
q, donor_inside, donor_in_domain, donor_is_fluid),
|
||||
scheme_tag=0,
|
||||
motion_tag=1,
|
||||
))
|
||||
|
||||
return links
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sensor cells
|
||||
# ------------------------------------------------------------------
|
||||
def build_sensor_cells(self, nx: int, ny: int) -> List[SensorCell]:
|
||||
"""Enumerate sensor cells inside the circular footprint.
|
||||
|
||||
Returns:
|
||||
List of SensorCell records.
|
||||
"""
|
||||
cx, cy = self._cx, self._cy
|
||||
r = self._radius
|
||||
r_sq = r * r
|
||||
|
||||
x0 = max(1, int(cx - r) - 1)
|
||||
x1 = min(nx - 2, int(cx + r) + 1)
|
||||
y0 = max(1, int(cy - r) - 1)
|
||||
y1 = min(ny - 2, int(cy + r) + 1)
|
||||
|
||||
cells: List[SensorCell] = []
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - cx) ** 2 + (y - cy) ** 2 < r_sq:
|
||||
cells.append(SensorCell(idx=x + y * nx, body_id=0))
|
||||
return cells
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Flag mask
|
||||
# ------------------------------------------------------------------
|
||||
def build_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||
"""Return (nx*ny,) uint16 flag mask with OBSTACLE|SOLID|BC_CURVED."""
|
||||
return self._build_flag_mask_inner(nx, ny, SOLID | OBSTACLE | BC_CURVED)
|
||||
|
||||
def build_sensor_flag_mask(self, nx: int, ny: int) -> np.ndarray:
|
||||
"""Return (nx*ny,) uint16 flag mask with FLUID|SENSOR_FLAG."""
|
||||
return self._build_flag_mask_inner(nx, ny, FLUID | SENSOR_FLAG)
|
||||
|
||||
def _build_flag_mask_inner(self, nx: int, ny: int, flag_val: int) -> np.ndarray:
|
||||
n = nx * ny
|
||||
mask = np.zeros(n, dtype=np.uint16)
|
||||
cx, cy = self._cx, self._cy
|
||||
r = self._radius
|
||||
r_sq = r * r
|
||||
x0 = max(1, int(cx - r) - 1)
|
||||
x1 = min(nx - 2, int(cx + r) + 1)
|
||||
y0 = max(1, int(cy - r) - 1)
|
||||
y1 = min(ny - 2, int(cy + r) + 1)
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - cx) ** 2 + (y - cy) ** 2 < r_sq:
|
||||
mask[x + y * nx] = np.uint16(flag_val)
|
||||
return mask
|
||||
@ -1,9 +1,9 @@
|
||||
# CelerisLab/body/manager.py
|
||||
"""
|
||||
ObjectManager — batch management of SimObjects.
|
||||
ObjectManager -- batch management of SimObjects.
|
||||
|
||||
Responsibilities:
|
||||
- Add / remove / query objects
|
||||
- Add / remove / query objects (delegated to ``BodyRegistry``)
|
||||
- Build merged flag masks and compact cut-link / sensor lists
|
||||
- Allocate packed telemetry ``obs_gpu`` + pagelocked mirror ``obs_pinned``
|
||||
- Sync geometry to :class:`~CelerisLab.lbm.field.LBMField`
|
||||
@ -20,6 +20,8 @@ import pycuda.driver as cuda
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .objects import SimObject
|
||||
from .registry import BodyRegistry
|
||||
from .preprocess.flag_overlay import merge_flag_masks
|
||||
from ..cuda.compiler_v2 import obs_layout
|
||||
from ..cuda.obs_layout import ObsLayout
|
||||
from ..lbm.descriptors import (
|
||||
@ -46,8 +48,7 @@ class ObjectManager:
|
||||
self.nz = nz
|
||||
self.nq = nq
|
||||
self.cfg = cfg
|
||||
self._objects: Dict[int, SimObject] = {}
|
||||
self._next_id = 0
|
||||
self._registry = BodyRegistry()
|
||||
|
||||
self.action_gpu: Optional[cuda.DeviceAllocation] = None
|
||||
self.obs_gpu: Optional[cuda.DeviceAllocation] = None
|
||||
@ -73,29 +74,29 @@ class ObjectManager:
|
||||
# when bodies.count == 0 (matches _resize_buffers sizing for n=0).
|
||||
self._resize_buffers()
|
||||
|
||||
# -- Registry delegation -------------------------------------------------
|
||||
def add(self, obj: SimObject) -> int:
|
||||
"""Register an object and return its id."""
|
||||
obj.obj_id = self._next_id
|
||||
self._objects[self._next_id] = obj
|
||||
self._next_id += 1
|
||||
body_id = self._registry.add(obj)
|
||||
self._resize_buffers()
|
||||
return obj.obj_id
|
||||
return body_id
|
||||
|
||||
def remove(self, obj_id: int):
|
||||
del self._objects[obj_id]
|
||||
self._registry.remove(obj_id)
|
||||
self._resize_buffers()
|
||||
|
||||
def get(self, obj_id: int) -> SimObject:
|
||||
return self._objects[obj_id]
|
||||
return self._registry.get(obj_id)
|
||||
|
||||
@property
|
||||
def objects(self) -> List[SimObject]:
|
||||
return list(self._objects.values())
|
||||
return self._registry.objects
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._objects)
|
||||
return self._registry.count
|
||||
|
||||
# -- Buffer sizing -------------------------------------------------------
|
||||
def _resize_buffers(self):
|
||||
n = self.count
|
||||
dim = self.cfg.dim if self.cfg else 2
|
||||
@ -112,6 +113,7 @@ class ObjectManager:
|
||||
new_sensor_counts[:copy_n] = self.sensor_cell_counts[:copy_n]
|
||||
self.sensor_cell_counts = new_sensor_counts
|
||||
|
||||
# -- Flag composition ----------------------------------------------------
|
||||
def build_flags(self, base_flags: np.ndarray) -> np.ndarray:
|
||||
"""Merge object flag masks onto a clean domain base.
|
||||
|
||||
@ -119,13 +121,13 @@ class ObjectManager:
|
||||
overlays stateless and prevents stale obstacle bits from surviving after
|
||||
geometry edits or future body motion.
|
||||
"""
|
||||
merged = np.array(base_flags, copy=True)
|
||||
for obj in self._objects.values():
|
||||
mask = obj.get_flag_mask(self.nx, self.ny, self.nz)
|
||||
nonzero = mask != 0
|
||||
merged[nonzero] = mask[nonzero]
|
||||
return merged
|
||||
masks = [
|
||||
obj.get_flag_mask(self.nx, self.ny, self.nz)
|
||||
for obj in self._registry.objects
|
||||
]
|
||||
return merge_flag_masks(base_flags, masks)
|
||||
|
||||
# -- Compact list building -----------------------------------------------
|
||||
def build_compact_lists(self, domain_flags: np.ndarray | None = None):
|
||||
"""Build cut-link SoA columns and sensor lists.
|
||||
|
||||
@ -145,8 +147,8 @@ class ObjectManager:
|
||||
else:
|
||||
ex, ey = D2Q9_EX, D2Q9_EY
|
||||
|
||||
for obj in self._objects.values():
|
||||
if hasattr(obj, 'get_curved_list'):
|
||||
for obj in self._registry.objects:
|
||||
if not obj.is_sensor and hasattr(obj, 'get_curved_list'):
|
||||
(
|
||||
fluid_idx, dirs, q_vals, body_ids,
|
||||
rx_vals, ry_vals, rz_vals, fallback_vals
|
||||
@ -164,7 +166,7 @@ class ObjectManager:
|
||||
cl_ry.append(ry_vals)
|
||||
cl_rz.append(rz_vals)
|
||||
cl_fallback.append(fallback_vals)
|
||||
if hasattr(obj, 'get_sensor_list'):
|
||||
if obj.is_sensor and hasattr(obj, 'get_sensor_list'):
|
||||
cells, ids = obj.get_sensor_list(self.nx, self.ny, self.nz)
|
||||
if len(cells) > 0:
|
||||
s_cells.append(cells)
|
||||
@ -192,6 +194,7 @@ class ObjectManager:
|
||||
sensor_cells, sensor_obj_id,
|
||||
)
|
||||
|
||||
# -- GPU sync ------------------------------------------------------------
|
||||
def sync_to_gpu(self, field, *, rebuild_flags: bool = True):
|
||||
"""Upload compact lists, action buffer, and packed ``obs`` GPU buffer.
|
||||
|
||||
@ -301,7 +304,7 @@ class ObjectManager:
|
||||
cuda.memset_d32_async(ptr, 0, self.slot_stride_floats, stream)
|
||||
|
||||
def download_obs_full_async(self, stream: cuda.Stream):
|
||||
"""Enqueue full DTOH copy ``obs_gpu`` → ``obs_pinned``."""
|
||||
"""Enqueue full DTOH copy ``obs_gpu`` -> ``obs_pinned``."""
|
||||
assert self.obs_pinned is not None
|
||||
cuda.memcpy_dtoh_async(self.obs_pinned, self.obs_gpu, stream)
|
||||
|
||||
@ -376,7 +379,8 @@ class ObjectManager:
|
||||
|
||||
def _validate_body_id(self, body_id: int) -> None:
|
||||
if body_id < 0 or body_id >= self.count:
|
||||
raise IndexError(f"body_id {body_id} out of range for {self.count} objects")
|
||||
raise IndexError(
|
||||
f"body_id {body_id} out of range for {self.count} objects")
|
||||
|
||||
def _refresh_action_from_objects(self) -> None:
|
||||
"""Populate action slots from object state using the runtime contract."""
|
||||
@ -385,11 +389,12 @@ class ObjectManager:
|
||||
dim = self.cfg.dim if self.cfg else 2
|
||||
slot = 3 * dim
|
||||
self.action.fill(0)
|
||||
for body_id, obj in self._objects.items():
|
||||
base = body_id * slot
|
||||
for obj in self._registry.objects:
|
||||
base = obj.obj_id * slot
|
||||
if base + slot > self.action.size:
|
||||
continue
|
||||
self.action[base + slot - 1] = np.float32(getattr(obj.state, "omega", 0.0))
|
||||
self.action[base + slot - 1] = np.float32(
|
||||
getattr(obj.state, "omega", 0.0))
|
||||
|
||||
def fallback_link_count(self) -> int:
|
||||
"""Return number of curved links marked for fallback bounce-back."""
|
||||
@ -407,8 +412,10 @@ class ObjectManager:
|
||||
curved = self._telemetry_field.curved
|
||||
if curved.count == 0:
|
||||
return 0
|
||||
return int(np.count_nonzero(curved.q[:curved.count] < np.float32(threshold)))
|
||||
return int(np.count_nonzero(
|
||||
curved.q[:curved.count] < np.float32(threshold)))
|
||||
|
||||
# -- Placeholders for future use -----------------------------------------
|
||||
def update_states(self, dt: float):
|
||||
"""Integrate object motion (placeholder)."""
|
||||
pass
|
||||
|
||||
@ -1,28 +1,30 @@
|
||||
# CelerisLab/body/objects.py
|
||||
"""
|
||||
Lightweight flat object model for immersed / rigid bodies and sensors.
|
||||
SimObject -- container wrapping geometry, state, and control.
|
||||
|
||||
Design:
|
||||
- SimObject is a thin base with standard interface (state, control,
|
||||
get_flag_mask, get_curved_list).
|
||||
- Concrete types (Cylinder, Sensor, …) override geometry methods.
|
||||
- No deep inheritance tree. Users can subclass SimObject directly
|
||||
for custom shapes — just implement get_flag_mask / get_curved_list.
|
||||
SimObject is a thin container that holds a Geometry instance (shape),
|
||||
an ObjectState (position/velocity), and an ObjectControl (control input).
|
||||
Concrete geometry logic lives in ``body/geometry/*.py``.
|
||||
|
||||
The ``get_curved_list()`` / ``get_sensor_list()`` methods exist for
|
||||
backward compatibility with ``ObjectManager.build_compact_lists()``.
|
||||
They delegate to the Geometry's build methods and the SoA packer.
|
||||
"""
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Tuple
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Lattice constants — canonical Python source is lbm/descriptors.py
|
||||
from ..lbm.descriptors import (
|
||||
FLUID, SOLID, GAS, INTERFACE,
|
||||
BC_WALL, BC_INLET, BC_OUTLET, BC_CURVED, BC_PERIODIC, BC_MOVING,
|
||||
SENSOR_FLAG, OBSTACLE,
|
||||
D2Q9_EX, D2Q9_EY,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
from .geometry.base import Geometry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
FALLBACK_CLASS_BOUZIDI = np.uint8(0)
|
||||
FALLBACK_CLASS_HALFWAY_BOUNCEBACK = np.uint8(1)
|
||||
|
||||
@ -32,7 +34,7 @@ FALLBACK_CLASS_HALFWAY_BOUNCEBACK = np.uint8(1)
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class ObjectState:
|
||||
"""Position, velocity, orientation (z used for 3D layouts)."""
|
||||
"""Position, velocity, orientation (z reserved for 3D layouts)."""
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
z: float = 0.0
|
||||
@ -52,305 +54,99 @@ class ObjectControl:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base class
|
||||
# SimObject container
|
||||
# ---------------------------------------------------------------------------
|
||||
class SimObject:
|
||||
"""Base for all simulation objects."""
|
||||
"""Container for a simulation object: geometry, state, control.
|
||||
|
||||
The Geometry instance handles shape-specific operations (cut-link
|
||||
enumeration, flag mask generation). SimObject wraps it with runtime
|
||||
state and an id for bookkeeping.
|
||||
|
||||
The *is_sensor* flag selects the flag mask variant for sensors
|
||||
(``FLUID|SENSOR_FLAG``) vs. obstacle bodies (``SOLID|OBSTACLE|BC_CURVED``).
|
||||
"""
|
||||
|
||||
obj_type: str = "generic"
|
||||
|
||||
def __init__(self, obj_id: int, center: Tuple[float, ...],
|
||||
radius: float = 0.0):
|
||||
def __init__(
|
||||
self,
|
||||
obj_id: int,
|
||||
geometry: Optional[Geometry] = None,
|
||||
center: Optional[Tuple[float, ...]] = None,
|
||||
radius: float = 0.0,
|
||||
is_sensor: bool = False,
|
||||
) -> None:
|
||||
self.obj_id = obj_id
|
||||
self.center = center
|
||||
self.center = center if center is not None else (0.0, 0.0)
|
||||
self.radius = radius
|
||||
z0 = float(center[2]) if len(center) > 2 else 0.0
|
||||
self.state = ObjectState(x=float(center[0]), y=float(center[1]), z=z0)
|
||||
self._geometry = geometry
|
||||
self._is_sensor = is_sensor
|
||||
|
||||
c = self.center
|
||||
z0 = float(c[2]) if len(c) > 2 else 0.0
|
||||
self.state = ObjectState(
|
||||
x=float(c[0]), y=float(c[1]), z=z0,
|
||||
)
|
||||
self.control = ObjectControl()
|
||||
|
||||
@property
|
||||
def geometry(self) -> Geometry:
|
||||
if self._geometry is None:
|
||||
raise RuntimeError(
|
||||
f"SimObject id={self.obj_id} has no geometry assigned.")
|
||||
return self._geometry
|
||||
|
||||
@geometry.setter
|
||||
def geometry(self, g: Geometry) -> None:
|
||||
self._geometry = g
|
||||
|
||||
@property
|
||||
def is_sensor(self) -> bool:
|
||||
return self._is_sensor
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Methods consumed by ObjectManager.build_compact_lists() / build_flags()
|
||||
# ------------------------------------------------------------------
|
||||
def get_flag_mask(self, nx: int, ny: int, nz: int = 1) -> np.ndarray:
|
||||
"""Return (n,) uint16 array with flag bits set for this object."""
|
||||
raise NotImplementedError
|
||||
"""Return (nx*ny,) uint16 array with flag bits set for this object.
|
||||
|
||||
Sensors produce ``FLUID|SENSOR_FLAG``; bodies produce
|
||||
``SOLID|OBSTACLE|BC_CURVED``.
|
||||
"""
|
||||
if self._is_sensor:
|
||||
return self.geometry.build_sensor_flag_mask(nx, ny)
|
||||
return self.geometry.build_flag_mask(nx, ny)
|
||||
|
||||
def get_curved_list(
|
||||
self, nx: int, ny: int, nq: int,
|
||||
nz: int = 1, ex=None, ey=None, ez=None,
|
||||
domain_flags=None,
|
||||
):
|
||||
"""Return 8-column SoA arrays for curved-boundary kernel.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete types
|
||||
# ---------------------------------------------------------------------------
|
||||
class Cylinder(SimObject):
|
||||
obj_type = "cylinder"
|
||||
|
||||
def get_flag_mask(self, nx: int, ny: int, nz: int = 1) -> np.ndarray:
|
||||
n = nx * ny * nz
|
||||
mask = np.zeros(n, dtype=np.uint16)
|
||||
xc, yc, zc = self.state.x, self.state.y, self.state.z
|
||||
r = self.radius
|
||||
r_sq = r * r
|
||||
x0 = max(1, int(xc - r) - 1)
|
||||
x1 = min(nx - 2, int(xc + r) + 1)
|
||||
y0 = max(1, int(yc - r) - 1)
|
||||
y1 = min(ny - 2, int(yc + r) + 1)
|
||||
z0 = max(1, int(zc - r) - 1) if nz > 1 else 0
|
||||
z1 = min(nz - 2, int(zc + r) + 1) if nz > 1 else 0
|
||||
if nz == 1:
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 < r_sq:
|
||||
mask[x + y * nx] = SOLID | OBSTACLE | BC_CURVED
|
||||
else:
|
||||
for z in range(z0, z1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 + (z - zc) ** 2 < r_sq:
|
||||
k = x + y * nx + z * nx * ny
|
||||
mask[k] = SOLID | OBSTACLE | BC_CURVED
|
||||
return mask
|
||||
|
||||
def get_curved_list(self, nx: int, ny: int, nq: int,
|
||||
nz: int = 1, ex=None, ey=None, ez=None,
|
||||
domain_flags=None):
|
||||
"""Return per-link curved boundary data (fluid-centric).
|
||||
|
||||
Each record represents one cut link: a fluid node with a lattice
|
||||
direction that crosses the cylinder surface into solid.
|
||||
|
||||
Args:
|
||||
nx, ny, nq: grid and lattice dimensions.
|
||||
nz: z-depth; use 1 for 2D layouts (linear index x + y·nx).
|
||||
ex, ey, ez: lattice vectors (length *nq*). *ez* required for a full
|
||||
D3Q19 fluid→solid link walk when ``nz > 1``.
|
||||
Delegates to the geometry object for cut-link enumeration and
|
||||
to ``pack_cut_links_to_soa`` for SoA packing.
|
||||
|
||||
Returns:
|
||||
fluid_idx: uint32 [n_links] fluid cell linear indices
|
||||
directions: uint8 [n_links] direction from fluid toward wall
|
||||
q_values: float32 [n_links] fractional distance ∈ (0, 1]
|
||||
body_ids: int32 [n_links] object id for each link
|
||||
rx_values: float32 [n_links] x arm from center to wall hit point
|
||||
ry_values: float32 [n_links] y arm from center to wall hit point
|
||||
rz_values: float32 [n_links] z arm (zero for 2D)
|
||||
fallback: uint8 [n_links]
|
||||
0=normal Bouzidi interpolation,
|
||||
1=half-way moving bounce-back fallback
|
||||
8-tuple matching CurvedLinkSoA.assign_host().
|
||||
"""
|
||||
from ..common.preprocess import (
|
||||
find_circle_intersection,
|
||||
find_sphere_ray_segment,
|
||||
)
|
||||
xc, yc, zc = self.state.x, self.state.y, self.state.z
|
||||
r = self.radius
|
||||
if ex is None or ey is None:
|
||||
ex, ey = D2Q9_EX, D2Q9_EY
|
||||
from .coupling.soa_packer import pack_cut_links_to_soa
|
||||
|
||||
idx_list = []
|
||||
dir_list = []
|
||||
q_list = []
|
||||
rx_list = []
|
||||
ry_list = []
|
||||
rz_list = []
|
||||
fallback_list = []
|
||||
|
||||
x0 = max(1, int(xc - r) - 2)
|
||||
x1 = min(nx - 2, int(xc + r) + 2)
|
||||
y0 = max(1, int(yc - r) - 2)
|
||||
y1 = min(ny - 2, int(yc + r) + 2)
|
||||
z0 = max(1, int(zc - r) - 2) if nz > 1 else 0
|
||||
z1 = min(nz - 2, int(zc + r) + 2) if nz > 1 else 0
|
||||
|
||||
r_sq = r * r
|
||||
use_3d = ez is not None
|
||||
|
||||
def _donor_is_fluid(xd: int, yd: int, zd: int = 0) -> bool:
|
||||
if domain_flags is None:
|
||||
return True
|
||||
if nz > 1:
|
||||
idx = xd + yd * nx + zd * nx * ny
|
||||
else:
|
||||
idx = xd + yd * nx
|
||||
return (int(domain_flags[idx]) & FLUID) != 0
|
||||
|
||||
def _fallback_class(
|
||||
q_val: float, donor_inside: bool, donor_in_domain: bool, donor_is_fluid: bool
|
||||
) -> np.uint8:
|
||||
"""Classify donor legality for Bouzidi interpolation fallback."""
|
||||
if not (0.0 < q_val <= 1.0):
|
||||
raise ValueError(f"q must satisfy 0 < q <= 1, got {q_val}")
|
||||
if q_val >= 0.5:
|
||||
return FALLBACK_CLASS_BOUZIDI
|
||||
if (not donor_in_domain) or donor_inside or (not donor_is_fluid):
|
||||
return FALLBACK_CLASS_HALFWAY_BOUNCEBACK
|
||||
return FALLBACK_CLASS_BOUZIDI
|
||||
|
||||
if use_3d:
|
||||
for z in range(z0, z1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 + (z - zc) ** 2 < r_sq:
|
||||
continue
|
||||
for i in range(1, nq):
|
||||
xn = x + ex[i]
|
||||
yn = y + ey[i]
|
||||
zn = z + ez[i]
|
||||
if not (0 <= xn < nx and 0 <= yn < ny
|
||||
and 0 <= zn < nz):
|
||||
continue
|
||||
if (xn - xc) ** 2 + (yn - yc) ** 2 + (
|
||||
zn - zc) ** 2 >= r_sq:
|
||||
continue
|
||||
hit = find_sphere_ray_segment(
|
||||
float(x), float(y), float(z),
|
||||
float(xn), float(yn), float(zn),
|
||||
xc, yc, zc, r,
|
||||
)
|
||||
if hit is None:
|
||||
continue
|
||||
dx = hit[0] - x
|
||||
dy = hit[1] - y
|
||||
dz = hit[2] - z
|
||||
norm = math.sqrt(
|
||||
ex[i] ** 2 + ey[i] ** 2 + ez[i] ** 2)
|
||||
q = (math.sqrt(dx * dx + dy * dy + dz * dz) / norm
|
||||
if norm > 0 else 0.5)
|
||||
q = max(0.01, min(q, 1.0))
|
||||
xff = x - ex[i]
|
||||
yff = y - ey[i]
|
||||
zff = z - ez[i]
|
||||
donor_in_domain = (0 <= xff < nx and 0 <= yff < ny and 0 <= zff < nz)
|
||||
donor_inside = False
|
||||
donor_is_fluid = False
|
||||
if donor_in_domain:
|
||||
donor_inside = (
|
||||
(xff - xc) ** 2 + (yff - yc) ** 2 + (zff - zc) ** 2
|
||||
) < r_sq
|
||||
donor_is_fluid = _donor_is_fluid(xff, yff, zff)
|
||||
idx_list.append(
|
||||
np.uint32(x + y * nx + z * nx * ny))
|
||||
dir_list.append(np.uint8(i))
|
||||
q_list.append(np.float32(q))
|
||||
rx_list.append(np.float32(hit[0] - xc))
|
||||
ry_list.append(np.float32(hit[1] - yc))
|
||||
rz_list.append(np.float32(hit[2] - zc))
|
||||
fallback_list.append(
|
||||
_fallback_class(q, donor_inside, donor_in_domain, donor_is_fluid))
|
||||
else:
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 < r_sq:
|
||||
continue
|
||||
for i in range(1, nq):
|
||||
xn, yn = x + ex[i], y + ey[i]
|
||||
if not (0 <= xn < nx and 0 <= yn < ny):
|
||||
continue
|
||||
if (xn - xc) ** 2 + (yn - yc) ** 2 >= r_sq:
|
||||
continue
|
||||
hit = find_circle_intersection(
|
||||
float(x), float(y),
|
||||
float(xn), float(yn),
|
||||
xc, yc, r,
|
||||
)
|
||||
if hit is not None:
|
||||
dx = hit[0] - x
|
||||
dy = hit[1] - y
|
||||
norm = math.sqrt(ex[i] ** 2 + ey[i] ** 2)
|
||||
q = math.sqrt(dx * dx + dy * dy) / norm if norm > 0 else 0.5
|
||||
q = max(0.01, min(q, 1.0))
|
||||
xff = x - ex[i]
|
||||
yff = y - ey[i]
|
||||
donor_in_domain = (0 <= xff < nx and 0 <= yff < ny)
|
||||
donor_inside = False
|
||||
donor_is_fluid = False
|
||||
if donor_in_domain:
|
||||
donor_inside = ((xff - xc) ** 2 + (yff - yc) ** 2) < r_sq
|
||||
donor_is_fluid = _donor_is_fluid(xff, yff)
|
||||
idx_list.append(np.uint32(x + y * nx))
|
||||
dir_list.append(np.uint8(i))
|
||||
q_list.append(np.float32(q))
|
||||
rx_list.append(np.float32(hit[0] - xc))
|
||||
ry_list.append(np.float32(hit[1] - yc))
|
||||
rz_list.append(np.float32(0.0))
|
||||
fallback_list.append(
|
||||
_fallback_class(q, donor_inside, donor_in_domain, donor_is_fluid))
|
||||
|
||||
n = len(idx_list)
|
||||
if n > 0:
|
||||
return (np.array(idx_list, dtype=np.uint32),
|
||||
np.array(dir_list, dtype=np.uint8),
|
||||
np.array(q_list, dtype=np.float32),
|
||||
np.full(n, self.obj_id, dtype=np.int32),
|
||||
np.array(rx_list, dtype=np.float32),
|
||||
np.array(ry_list, dtype=np.float32),
|
||||
np.array(rz_list, dtype=np.float32),
|
||||
np.array(fallback_list, dtype=np.uint8))
|
||||
return (np.zeros(0, dtype=np.uint32),
|
||||
np.zeros(0, dtype=np.uint8),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.int32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.float32),
|
||||
np.zeros(0, dtype=np.uint8))
|
||||
|
||||
|
||||
class Sensor(SimObject):
|
||||
obj_type = "sensor"
|
||||
|
||||
def get_flag_mask(self, nx: int, ny: int, nz: int = 1) -> np.ndarray:
|
||||
n = nx * ny * nz
|
||||
mask = np.zeros(n, dtype=np.uint16)
|
||||
xc, yc, zc = self.state.x, self.state.y, self.state.z
|
||||
r = self.radius
|
||||
r_sq = r * r
|
||||
x0 = max(1, int(xc - r) - 1)
|
||||
x1 = min(nx - 2, int(xc + r) + 1)
|
||||
y0 = max(1, int(yc - r) - 1)
|
||||
y1 = min(ny - 2, int(yc + r) + 1)
|
||||
z0 = max(1, int(zc - r) - 1) if nz > 1 else 0
|
||||
z1 = min(nz - 2, int(zc + r) + 1) if nz > 1 else 0
|
||||
if nz == 1:
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 < r_sq:
|
||||
mask[x + y * nx] = FLUID | SENSOR_FLAG
|
||||
else:
|
||||
for z in range(z0, z1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 + (z - zc) ** 2 < r_sq:
|
||||
k = x + y * nx + z * nx * ny
|
||||
mask[k] = FLUID | SENSOR_FLAG
|
||||
return mask
|
||||
links = self.geometry.build_cut_links(nx, ny, domain_flags=domain_flags)
|
||||
for cl in links:
|
||||
cl.body_id = self.obj_id
|
||||
return pack_cut_links_to_soa(links)
|
||||
|
||||
def get_sensor_list(self, nx: int, ny: int, nz: int = 1):
|
||||
"""Return compact sensor list.
|
||||
"""Return compact sensor list (cells, obj_ids).
|
||||
|
||||
Returns:
|
||||
cells: uint32 array of cell indices
|
||||
obj_ids: int32 array of object ids
|
||||
cells (uint32), obj_ids (int32).
|
||||
"""
|
||||
cell_list = []
|
||||
xc, yc, zc = self.state.x, self.state.y, self.state.z
|
||||
r = self.radius
|
||||
r_sq = r * r
|
||||
x0 = max(1, int(xc - r) - 1)
|
||||
x1 = min(nx - 2, int(xc + r) + 1)
|
||||
y0 = max(1, int(yc - r) - 1)
|
||||
y1 = min(ny - 2, int(yc + r) + 1)
|
||||
z0 = max(1, int(zc - r) - 1) if nz > 1 else 0
|
||||
z1 = min(nz - 2, int(zc + r) + 1) if nz > 1 else 0
|
||||
if nz == 1:
|
||||
for x in range(x0, x1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 < r_sq:
|
||||
cell_list.append(np.uint32(x + y * nx))
|
||||
else:
|
||||
for z in range(z0, z1 + 1):
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
if (x - xc) ** 2 + (y - yc) ** 2 + (z - zc) ** 2 < r_sq:
|
||||
cell_list.append(
|
||||
np.uint32(x + y * nx + z * nx * ny))
|
||||
if cell_list:
|
||||
cells = np.array(cell_list, dtype=np.uint32)
|
||||
obj_ids = np.full(len(cell_list), self.obj_id, dtype=np.int32)
|
||||
return cells, obj_ids
|
||||
return np.zeros(0, dtype=np.uint32), np.zeros(0, dtype=np.int32)
|
||||
from .coupling.soa_packer import pack_sensor_to_soa
|
||||
|
||||
cells = self.geometry.build_sensor_cells(nx, ny)
|
||||
for sc in cells:
|
||||
sc.body_id = self.obj_id
|
||||
return pack_sensor_to_soa(cells)
|
||||
|
||||
4
src/CelerisLab/body/preprocess/__init__.py
Normal file
4
src/CelerisLab/body/preprocess/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# CelerisLab/body/preprocess/__init__.py
|
||||
"""
|
||||
Grid preprocessing: flag overlay, cut-link packing, sensor mapping.
|
||||
"""
|
||||
34
src/CelerisLab/body/preprocess/flag_overlay.py
Normal file
34
src/CelerisLab/body/preprocess/flag_overlay.py
Normal file
@ -0,0 +1,34 @@
|
||||
# CelerisLab/body/preprocess/flag_overlay.py
|
||||
"""
|
||||
Flag overlay: merge per-object flag masks onto a clean domain base.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def merge_flag_masks(
|
||||
base: np.ndarray,
|
||||
masks: List[np.ndarray],
|
||||
) -> np.ndarray:
|
||||
"""Merge object flag masks onto a clean domain base.
|
||||
|
||||
For each mask in *masks*, non-zero entries overwrite the corresponding
|
||||
positions in *base*. Callers should pass a fresh channel-layout flag
|
||||
array to prevent stale obstacle bits from surviving after geometry edits.
|
||||
|
||||
Args:
|
||||
base: uint16 domain flag array (e.g. from build_channel_flags()).
|
||||
masks: List of uint16 mask arrays from each object's get_flag_mask().
|
||||
|
||||
Returns:
|
||||
New uint16 array with object overlays applied.
|
||||
"""
|
||||
merged = np.array(base, copy=True)
|
||||
for mask in masks:
|
||||
nonzero = mask != 0
|
||||
merged[nonzero] = mask[nonzero]
|
||||
return merged
|
||||
49
src/CelerisLab/body/registry.py
Normal file
49
src/CelerisLab/body/registry.py
Normal file
@ -0,0 +1,49 @@
|
||||
# CelerisLab/body/registry.py
|
||||
"""
|
||||
BodyRegistry -- pure add/remove/query for simulation objects.
|
||||
No GPU or LBM knowledge.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from .objects import SimObject
|
||||
|
||||
|
||||
class BodyRegistry:
|
||||
"""Registry for SimObject instances. No GPU or LBM knowledge."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._objects: Dict[int, SimObject] = {}
|
||||
self._next_id = 0
|
||||
|
||||
def add(self, obj: SimObject) -> int:
|
||||
"""Register a new object and return its assigned id."""
|
||||
obj.obj_id = self._next_id
|
||||
self._objects[self._next_id] = obj
|
||||
self._next_id += 1
|
||||
return obj.obj_id
|
||||
|
||||
def remove(self, obj_id: int) -> None:
|
||||
"""Unregister an object by id."""
|
||||
del self._objects[obj_id]
|
||||
|
||||
def get(self, obj_id: int) -> SimObject:
|
||||
"""Return the registered object by id."""
|
||||
return self._objects[obj_id]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all objects and reset id counter."""
|
||||
self._objects.clear()
|
||||
self._next_id = 0
|
||||
|
||||
@property
|
||||
def objects(self) -> List[SimObject]:
|
||||
"""List of all registered objects in insertion order."""
|
||||
return list(self._objects.values())
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
"""Number of registered objects."""
|
||||
return len(self._objects)
|
||||
@ -3,7 +3,6 @@
|
||||
Common utilities and preprocessing functions.
|
||||
"""
|
||||
|
||||
from . import preprocess
|
||||
from . import streakline
|
||||
from . import preprocess, pathline, checkpoint, render, streakline
|
||||
|
||||
__all__ = ["preprocess", "streakline"]
|
||||
__all__ = ["preprocess", "pathline", "checkpoint", "render", "streakline"]
|
||||
|
||||
9
src/CelerisLab/common/_types.py
Normal file
9
src/CelerisLab/common/_types.py
Normal file
@ -0,0 +1,9 @@
|
||||
# CelerisLab/common/_types.py
|
||||
"""
|
||||
Shared type aliases for the ``common`` package.
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
CylinderSpec = Tuple[Tuple[float, float], float]
|
||||
"""A cylinder defined by ``((cx, cy), radius)`` in lattice units."""
|
||||
207
src/CelerisLab/common/pathline.py
Normal file
207
src/CelerisLab/common/pathline.py
Normal file
@ -0,0 +1,207 @@
|
||||
# CelerisLab/common/pathline.py
|
||||
"""
|
||||
Particle pathline history accumulation and rendering (debug tool).
|
||||
|
||||
This is **not** a streakline — it accumulates each particle's full trajectory
|
||||
over time. For streakline visualization use ``streakline.Streakline``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .streakline._render import gaussian_blur2d
|
||||
from ._types import CylinderSpec
|
||||
|
||||
|
||||
def configure_compute_threads(num_threads: int = 0) -> int:
|
||||
"""Set BLAS/OpenMP thread env vars for host-side numpy work."""
|
||||
import os
|
||||
|
||||
if num_threads <= 0:
|
||||
num_threads = min(32, os.cpu_count() or 4)
|
||||
for var in (
|
||||
"OMP_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
"VECLIB_MAXIMUM_THREADS",
|
||||
):
|
||||
os.environ[var] = str(int(num_threads))
|
||||
return int(num_threads)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticleTrailSet:
|
||||
"""Growing polylines per particle (pathline history, not streakline)."""
|
||||
|
||||
trails: List[np.ndarray] = field(default_factory=list)
|
||||
active: List[bool] = field(default_factory=list)
|
||||
|
||||
def inject(self, points: np.ndarray) -> None:
|
||||
pts = np.asarray(points, dtype=np.float64)
|
||||
if pts.ndim != 2 or pts.shape[1] != 2:
|
||||
raise ValueError("points must be [N,2].")
|
||||
for p in pts:
|
||||
self.trails.append(np.asarray([[p[0], p[1]]], dtype=np.float64))
|
||||
self.active.append(True)
|
||||
|
||||
def keep_indices(self, survived_mask: np.ndarray) -> None:
|
||||
mask = np.asarray(survived_mask, dtype=bool).ravel()
|
||||
j = 0
|
||||
for i, is_active in enumerate(self.active):
|
||||
if not is_active:
|
||||
continue
|
||||
if j >= mask.size:
|
||||
break
|
||||
if not bool(mask[j]):
|
||||
self.active[i] = False
|
||||
j += 1
|
||||
|
||||
def append_positions(self, particles: np.ndarray) -> None:
|
||||
pts = np.asarray(particles, dtype=np.float64)
|
||||
j = 0
|
||||
for i, is_active in enumerate(self.active):
|
||||
if not is_active:
|
||||
continue
|
||||
if j >= pts.shape[0]:
|
||||
break
|
||||
self.trails[i] = np.vstack([self.trails[i], pts[j]])
|
||||
j += 1
|
||||
|
||||
|
||||
def _rasterize_segment(
|
||||
intensity: np.ndarray,
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
weight: float,
|
||||
) -> None:
|
||||
"""Stamp one segment into a scalar intensity map."""
|
||||
ny, nx = intensity.shape
|
||||
n = int(max(abs(x1 - x0), abs(y1 - y0), 1.0)) + 1
|
||||
xs = np.linspace(x0, x1, n)
|
||||
ys = np.linspace(y0, y1, n)
|
||||
xi = np.clip(np.rint(xs).astype(np.int64), 0, nx - 1)
|
||||
yi = np.clip(np.rint(ys).astype(np.int64), 0, ny - 1)
|
||||
w = float(max(0.0, min(1.0, weight)))
|
||||
np.add.at(intensity, (yi, xi), w)
|
||||
|
||||
|
||||
def _draw_trail(
|
||||
intensity: np.ndarray,
|
||||
trail: np.ndarray,
|
||||
*,
|
||||
fade_along_trail: bool,
|
||||
) -> None:
|
||||
if trail.shape[0] < 2:
|
||||
return
|
||||
nseg = trail.shape[0] - 1
|
||||
for i in range(nseg):
|
||||
if fade_along_trail:
|
||||
t = float(i + 1) / float(nseg)
|
||||
weight = 0.15 + 0.85 * t
|
||||
else:
|
||||
weight = 1.0
|
||||
_rasterize_segment(
|
||||
intensity,
|
||||
float(trail[i, 0]),
|
||||
float(trail[i, 1]),
|
||||
float(trail[i + 1, 0]),
|
||||
float(trail[i + 1, 1]),
|
||||
weight,
|
||||
)
|
||||
|
||||
|
||||
def render_trails(
|
||||
trail_set: ParticleTrailSet,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
blur_sigma: float = 0.0,
|
||||
fade_along_trail: bool = True,
|
||||
num_threads: int = 0,
|
||||
background_color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
streak_color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||
) -> dict:
|
||||
"""Render accumulated path trails (pathline collage — debug only).
|
||||
|
||||
This is NOT a streakline renderer. See ``streakline.Streakline``.
|
||||
"""
|
||||
n_threads = configure_compute_threads(num_threads)
|
||||
intensity = np.zeros((ny, nx), dtype=np.float64)
|
||||
|
||||
trails = trail_set.trails
|
||||
if not trails:
|
||||
density_max = 0.0
|
||||
else:
|
||||
chunk = max(1, (len(trails) + n_threads - 1) // n_threads)
|
||||
|
||||
def _worker(batch: List[np.ndarray]) -> np.ndarray:
|
||||
local = np.zeros((ny, nx), dtype=np.float64)
|
||||
for tr in batch:
|
||||
_draw_trail(local, tr, fade_along_trail=fade_along_trail)
|
||||
return local
|
||||
|
||||
batches = [trails[i : i + chunk] for i in range(0, len(trails), chunk)]
|
||||
if len(batches) == 1:
|
||||
intensity = _worker(batches[0])
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=n_threads) as pool:
|
||||
parts = list(pool.map(_worker, batches))
|
||||
for part in parts:
|
||||
intensity = np.maximum(intensity, part)
|
||||
|
||||
if np.any(intensity > 0):
|
||||
vmax = float(np.percentile(intensity[intensity > 0], 99.0))
|
||||
intensity = np.clip(intensity / max(vmax, 1e-12), 0.0, 1.0)
|
||||
density_max = float(np.max(intensity))
|
||||
|
||||
rgb = np.ones((ny, nx, 3), dtype=np.float64)
|
||||
rgb[:, :, 0] = background_color[0]
|
||||
rgb[:, :, 1] = background_color[1] * (1.0 - intensity) + streak_color[1] * intensity
|
||||
rgb[:, :, 2] = background_color[2] * (1.0 - intensity) + streak_color[2] * intensity
|
||||
if streak_color[0] < 0.999:
|
||||
rgb[:, :, 0] = background_color[0] * (1.0 - intensity) + streak_color[0] * intensity
|
||||
|
||||
if cylinders:
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
for (cx, cy), radius in cylinders:
|
||||
mask = (xx - float(cx)) ** 2 + (yy - float(cy)) ** 2 <= float(radius) ** 2
|
||||
rgb[mask] = 0.0
|
||||
|
||||
if float(blur_sigma) > 0.0:
|
||||
for c in range(3):
|
||||
rgb[:, :, c] = gaussian_blur2d(rgb[:, :, c], sigma=float(blur_sigma))
|
||||
|
||||
rgb = np.clip(rgb, 0.0, 1.0)
|
||||
import os
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.axis("off")
|
||||
ax.imshow(rgb, origin="lower", interpolation="nearest", aspect="auto")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
except ImportError:
|
||||
from PIL import Image
|
||||
Image.fromarray((np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)).save(out_path)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"trail_count": int(len(trails)),
|
||||
"density_max": density_max,
|
||||
"num_threads": int(n_threads),
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
"""Host-side geometric helpers for immersed-boundary preprocessing.
|
||||
|
||||
Responsibilities:
|
||||
Circle–ray intersection along fluid-to-neighbour links and simple
|
||||
Circle-ray intersection along fluid-to-neighbour links and simple
|
||||
discrete sensor-area counting used when building IBM masks and probes.
|
||||
"""
|
||||
|
||||
@ -11,7 +11,7 @@ import numpy as np
|
||||
|
||||
|
||||
def find_circle_intersection(x, y, x_neb, y_neb, xc, yc, r0):
|
||||
"""Return (px, py) of the closest intersection along fluid→neighbour ray,
|
||||
"""Return (px, py) of the closest intersection along fluid->neighbour ray,
|
||||
or None if no valid root in [0, 1]."""
|
||||
dx, dy = x_neb - x, y_neb - y
|
||||
a = dx ** 2 + dy ** 2
|
||||
@ -32,30 +32,6 @@ def find_circle_intersection(x, y, x_neb, y_neb, xc, yc, r0):
|
||||
return None
|
||||
|
||||
|
||||
def find_sphere_ray_segment(px, py, pz, qx, qy, qz, cx, cy, cz, r0):
|
||||
"""Closest intersection of segment P→Q with sphere (center C, radius r0).
|
||||
|
||||
Returns:
|
||||
(ix, iy, iz) on the segment with parameter in [0, 1], or None.
|
||||
"""
|
||||
dx, dy, dz = qx - px, qy - py, qz - pz
|
||||
a = dx * dx + dy * dy + dz * dz
|
||||
if a == 0.0:
|
||||
return None
|
||||
b = 2.0 * (dx * (px - cx) + dy * (py - cy) + dz * (pz - cz))
|
||||
c = (px - cx) ** 2 + (py - cy) ** 2 + (pz - cz) ** 2 - r0 ** 2
|
||||
det = b * b - 4.0 * a * c
|
||||
if det < 0.0:
|
||||
return None
|
||||
sqrt_det = math.sqrt(det)
|
||||
t1 = (-b - sqrt_det) / (2.0 * a)
|
||||
t2 = (-b + sqrt_det) / (2.0 * a)
|
||||
for t in (t1, t2):
|
||||
if 0.0 <= t <= 1.0:
|
||||
return px + t * dx, py + t * dy, pz + t * dz
|
||||
return None
|
||||
|
||||
|
||||
def find_sensor_area(radius):
|
||||
area = 0
|
||||
for i in range(int(np.floor(-radius)), int(np.ceil(radius))):
|
||||
@ -63,4 +39,13 @@ def find_sensor_area(radius):
|
||||
if i ** 2 + j ** 2 <= radius ** 2:
|
||||
area += 1
|
||||
return area
|
||||
|
||||
|
||||
|
||||
def cylinders_from_triangle_layout(layout: dict) -> "list[tuple[tuple[float, float], float]]":
|
||||
"""Build cylinder list from triangle-layout dict used in experiment notebook."""
|
||||
radius = float(layout["radius_lb"])
|
||||
return [
|
||||
((float(layout["x_apex"]), float(layout["y_center"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_lower"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_upper"])), radius),
|
||||
]
|
||||
|
||||
92
src/CelerisLab/common/render.py
Normal file
92
src/CelerisLab/common/render.py
Normal file
@ -0,0 +1,92 @@
|
||||
# CelerisLab/common/render.py
|
||||
"""
|
||||
Field rendering utilities (vorticity computation and visualization).
|
||||
|
||||
Separated from the streakline module: these are macroscopic field diagnostics,
|
||||
not particle-tracking operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._types import CylinderSpec
|
||||
|
||||
|
||||
def compute_vorticity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Central-difference vorticity omega = d(uy)/dx - d(ux)/dy on interior."""
|
||||
ux_arr = np.asarray(ux, dtype=np.float64)
|
||||
uy_arr = np.asarray(uy, dtype=np.float64)
|
||||
vort = np.zeros_like(ux_arr, dtype=np.float64)
|
||||
dudy = 0.5 * (ux_arr[2:, 1:-1] - ux_arr[:-2, 1:-1])
|
||||
dvdx = 0.5 * (uy_arr[1:-1, 2:] - uy_arr[1:-1, :-2])
|
||||
vort[1:-1, 1:-1] = dvdx - dudy
|
||||
return vort
|
||||
|
||||
|
||||
def render_vorticity_field(
|
||||
vort: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
vmin: Optional[float] = None,
|
||||
vmax: Optional[float] = None,
|
||||
percentile: float = 99.0,
|
||||
minimal_axes: bool = True,
|
||||
) -> dict:
|
||||
"""Render vorticity with white at zero, green negative, purple positive."""
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("render_vorticity_field requires matplotlib.") from exc
|
||||
|
||||
field = np.asarray(vort, dtype=np.float64)
|
||||
vmax_field = float(np.nanmax(np.abs(field)))
|
||||
abs_pct = float(np.nanpercentile(np.abs(field), percentile))
|
||||
abs_max = max(abs_pct, 0.35 * vmax_field, 1e-6)
|
||||
if vmin is None:
|
||||
vmin = -abs_max
|
||||
if vmax is None:
|
||||
vmax = abs_max
|
||||
if vmin >= 0.0 or vmax <= 0.0:
|
||||
raise ValueError(f"vmin/vmax must bracket zero: vmin={vmin}, vmax={vmax}")
|
||||
|
||||
cmap = LinearSegmentedColormap.from_list(
|
||||
"vort_green_white_purple",
|
||||
["#1a9850", "#f7f7f7", "#762a83"],
|
||||
N=256,
|
||||
)
|
||||
norm = TwoSlopeNorm(vmin=float(vmin), vcenter=0.0, vmax=float(vmax))
|
||||
|
||||
rgba = cmap(norm(field))
|
||||
if cylinders:
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
for (cx, cy), radius in cylinders:
|
||||
mask = (xx - float(cx)) ** 2 + (yy - float(cy)) ** 2 <= float(radius) ** 2
|
||||
rgba[mask] = (0.0, 0.0, 0.0, 1.0)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.imshow(rgba, origin="lower", interpolation="nearest", aspect="auto")
|
||||
if minimal_axes:
|
||||
ax.axis("off")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"vmin": float(vmin),
|
||||
"vmax": float(vmax),
|
||||
"vort_min": float(np.nanmin(field)),
|
||||
"vort_max": float(np.nanmax(field)),
|
||||
}
|
||||
@ -1,778 +0,0 @@
|
||||
# CelerisLab/common/streakline.py
|
||||
"""Streakline utilities for online/offline particle-based flow visualization.
|
||||
|
||||
Responsibilities:
|
||||
- Build dense release seeds (point/line/strip).
|
||||
- Integrate particles with RK4 and space-time interpolated velocity fields.
|
||||
- Support both online (in-memory frames from Simulation) and offline playback.
|
||||
- Render density-like streakline images or accumulated path trails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
CylinderSpec = Tuple[Tuple[float, float], float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlowFrame:
|
||||
"""One sampled velocity frame."""
|
||||
|
||||
step: int
|
||||
ux: np.ndarray
|
||||
uy: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseConfig:
|
||||
"""Release strategy configuration for dense streakline seeding."""
|
||||
|
||||
mode: str = "strip" # point | line | strip
|
||||
line_span: float = 12.0
|
||||
line_count: int = 5
|
||||
downstream_count: int = 4
|
||||
downstream_spacing: float = 3.0
|
||||
inject_per_seed: int = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegratorConfig:
|
||||
"""Particle integration and filtering settings."""
|
||||
|
||||
alpha_t: float = 0.2
|
||||
alpha_x: float = 0.4
|
||||
age_decay_steps: float = 20000.0
|
||||
max_particle_age: Optional[float] = None
|
||||
diffusion_coeff: float = 0.0
|
||||
random_seed: int = 1234
|
||||
num_threads: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParticleTrailSet:
|
||||
"""Growing polylines per particle (pathline history — not physical streakline)."""
|
||||
|
||||
trails: List[np.ndarray] = field(default_factory=list)
|
||||
active: List[bool] = field(default_factory=list)
|
||||
|
||||
def inject(self, points: np.ndarray) -> None:
|
||||
pts = np.asarray(points, dtype=np.float64)
|
||||
if pts.ndim != 2 or pts.shape[1] != 2:
|
||||
raise ValueError("points must be [N,2].")
|
||||
for p in pts:
|
||||
self.trails.append(np.asarray([[p[0], p[1]]], dtype=np.float64))
|
||||
self.active.append(True)
|
||||
|
||||
def keep_indices(self, survived_mask: np.ndarray) -> None:
|
||||
"""Deactivate trails whose particle did not survive the last advance."""
|
||||
mask = np.asarray(survived_mask, dtype=bool).ravel()
|
||||
j = 0
|
||||
for i, is_active in enumerate(self.active):
|
||||
if not is_active:
|
||||
continue
|
||||
if j >= mask.size:
|
||||
break
|
||||
if not bool(mask[j]):
|
||||
self.active[i] = False
|
||||
j += 1
|
||||
|
||||
def keep_mask(self, survived_mask: np.ndarray) -> None:
|
||||
"""Alias for keep_indices (boolean mask over active particles)."""
|
||||
self.keep_indices(survived_mask)
|
||||
|
||||
def append_positions(self, particles: np.ndarray) -> None:
|
||||
pts = np.asarray(particles, dtype=np.float64)
|
||||
j = 0
|
||||
for i, is_active in enumerate(self.active):
|
||||
if not is_active:
|
||||
continue
|
||||
if j >= pts.shape[0]:
|
||||
break
|
||||
self.trails[i] = np.vstack([self.trails[i], pts[j]])
|
||||
j += 1
|
||||
|
||||
|
||||
def configure_compute_threads(num_threads: int = 0) -> int:
|
||||
"""Set BLAS/OpenMP thread env vars for host-side numpy work."""
|
||||
if num_threads <= 0:
|
||||
num_threads = min(32, os.cpu_count() or 4)
|
||||
for var in (
|
||||
"OMP_NUM_THREADS",
|
||||
"OPENBLAS_NUM_THREADS",
|
||||
"MKL_NUM_THREADS",
|
||||
"NUMEXPR_NUM_THREADS",
|
||||
"VECLIB_MAXIMUM_THREADS",
|
||||
):
|
||||
os.environ[var] = str(int(num_threads))
|
||||
return int(num_threads)
|
||||
|
||||
|
||||
def cylinders_from_triangle_layout(layout: dict) -> List[CylinderSpec]:
|
||||
"""Build cylinder list from triangle-layout dict used in experiment notebook."""
|
||||
radius = float(layout["radius_lb"])
|
||||
return [
|
||||
((float(layout["x_apex"]), float(layout["y_center"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_lower"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_upper"])), radius),
|
||||
]
|
||||
|
||||
|
||||
def estimate_sampling_plan(
|
||||
*,
|
||||
st_ref: float,
|
||||
diameter: float,
|
||||
u_ref: float,
|
||||
snapshots_per_period: float = 24.0,
|
||||
periods: int = 5,
|
||||
) -> dict:
|
||||
"""Estimate save interval and snapshot count from nominal shedding scale."""
|
||||
period_steps = float(diameter) / (float(st_ref) * float(u_ref))
|
||||
save_every = int(max(20, round(period_steps / snapshots_per_period / 10.0) * 10))
|
||||
n_snapshots = int(max(20, round(float(periods) * float(snapshots_per_period))))
|
||||
return {
|
||||
"st_ref": float(st_ref),
|
||||
"period_steps_est": float(period_steps),
|
||||
"snapshots_per_period_target": float(snapshots_per_period),
|
||||
"save_every_recommended": int(save_every),
|
||||
"snapshot_count_recommended": int(n_snapshots),
|
||||
}
|
||||
|
||||
|
||||
def build_release_points(
|
||||
base_points: np.ndarray,
|
||||
release_cfg: ReleaseConfig,
|
||||
) -> np.ndarray:
|
||||
"""Expand base release points to dense points by line/strip policy."""
|
||||
pts = np.asarray(base_points, dtype=np.float64)
|
||||
if pts.ndim != 2 or pts.shape[1] != 2:
|
||||
raise ValueError("base_points must be [N,2].")
|
||||
mode = str(release_cfg.mode).lower()
|
||||
if mode not in ("point", "line", "strip"):
|
||||
raise ValueError("release mode must be point|line|strip.")
|
||||
|
||||
line_count = max(1, int(release_cfg.line_count))
|
||||
ds_count = max(1, int(release_cfg.downstream_count))
|
||||
line_offsets = np.linspace(
|
||||
-0.5 * float(release_cfg.line_span),
|
||||
0.5 * float(release_cfg.line_span),
|
||||
line_count,
|
||||
)
|
||||
ds_offsets = np.arange(ds_count, dtype=np.float64) * float(release_cfg.downstream_spacing)
|
||||
|
||||
out: list[list[float]] = []
|
||||
for x0, y0 in pts:
|
||||
line_iter = [0.0] if mode == "point" else line_offsets
|
||||
ds_iter = [0.0] if mode in ("point", "line") else ds_offsets
|
||||
for dy in line_iter:
|
||||
for dx in ds_iter:
|
||||
out.append([float(x0 + dx), float(y0 + dy)])
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
|
||||
|
||||
def _bilinear_sample(field: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
||||
ny, nx = field.shape
|
||||
x0 = np.floor(x).astype(np.int64)
|
||||
y0 = np.floor(y).astype(np.int64)
|
||||
x1 = x0 + 1
|
||||
y1 = y0 + 1
|
||||
inside = (x0 >= 0) & (x1 < nx) & (y0 >= 0) & (y1 < ny)
|
||||
out = np.full_like(x, np.nan, dtype=np.float64)
|
||||
if not np.any(inside):
|
||||
return out
|
||||
|
||||
xi = x[inside] - x0[inside]
|
||||
yi = y[inside] - y0[inside]
|
||||
f00 = field[y0[inside], x0[inside]]
|
||||
f10 = field[y0[inside], x1[inside]]
|
||||
f01 = field[y1[inside], x0[inside]]
|
||||
f11 = field[y1[inside], x1[inside]]
|
||||
out[inside] = (
|
||||
(1.0 - xi) * (1.0 - yi) * f00
|
||||
+ xi * (1.0 - yi) * f10
|
||||
+ (1.0 - xi) * yi * f01
|
||||
+ xi * yi * f11
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _velocity_at(pos: np.ndarray, theta: float, f0: FlowFrame, f1: FlowFrame) -> np.ndarray:
|
||||
x = pos[:, 0]
|
||||
y = pos[:, 1]
|
||||
vx0 = _bilinear_sample(f0.ux, x, y)
|
||||
vy0 = _bilinear_sample(f0.uy, x, y)
|
||||
vx1 = _bilinear_sample(f1.ux, x, y)
|
||||
vy1 = _bilinear_sample(f1.uy, x, y)
|
||||
vx = (1.0 - theta) * vx0 + theta * vx1
|
||||
vy = (1.0 - theta) * vy0 + theta * vy1
|
||||
return np.stack([vx, vy], axis=1)
|
||||
|
||||
|
||||
def _particles_valid_mask(
|
||||
p_new: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
solid_center: Optional[Tuple[float, float]] = None,
|
||||
solid_radius: Optional[float] = None,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
) -> np.ndarray:
|
||||
valid = np.all(np.isfinite(p_new), axis=1)
|
||||
valid &= (p_new[:, 0] >= 0.0) & (p_new[:, 0] <= nx - 1)
|
||||
valid &= (p_new[:, 1] >= 0.0) & (p_new[:, 1] <= ny - 1)
|
||||
|
||||
specs: List[CylinderSpec] = []
|
||||
if cylinders:
|
||||
specs.extend(cylinders)
|
||||
elif solid_center is not None and solid_radius is not None:
|
||||
specs.append((solid_center, float(solid_radius)))
|
||||
|
||||
for (cx, cy), radius in specs:
|
||||
dx = p_new[:, 0] - float(cx)
|
||||
dy = p_new[:, 1] - float(cy)
|
||||
valid &= (dx * dx + dy * dy) > (float(radius) * float(radius))
|
||||
return valid
|
||||
|
||||
|
||||
def _inject_particles(
|
||||
particles: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
release_points: np.ndarray,
|
||||
*,
|
||||
inject_per_seed: int,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
new_pts = np.repeat(release_points, max(1, int(inject_per_seed)), axis=0)
|
||||
if particles.size == 0:
|
||||
return new_pts.copy(), np.zeros((new_pts.shape[0],), dtype=np.float64)
|
||||
return (
|
||||
np.vstack([particles, new_pts]),
|
||||
np.concatenate([ages, np.zeros((new_pts.shape[0],), dtype=np.float64)]),
|
||||
)
|
||||
|
||||
|
||||
def advance_particles_between_frames(
|
||||
particles: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
frame0: FlowFrame,
|
||||
frame1: FlowFrame,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
cfg: IntegratorConfig,
|
||||
solid_center: Optional[Tuple[float, float]] = None,
|
||||
solid_radius: Optional[float] = None,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
rng: Optional[np.random.Generator] = None,
|
||||
) -> Tuple[np.ndarray, np.ndarray, dict, np.ndarray]:
|
||||
"""Advance particles from frame0.step to frame1.step with RK4.
|
||||
|
||||
Returns:
|
||||
particles, ages, diagnostics, survived_mask (bool, length = input count).
|
||||
"""
|
||||
if particles.size == 0:
|
||||
return particles, ages, {"n_substeps": 0, "max_speed": 0.0}, np.zeros((0,), dtype=bool)
|
||||
|
||||
dt_save = float(frame1.step - frame0.step)
|
||||
max_speed = float(
|
||||
max(
|
||||
np.nanmax(np.sqrt(frame0.ux * frame0.ux + frame0.uy * frame0.uy)),
|
||||
np.nanmax(np.sqrt(frame1.ux * frame1.ux + frame1.uy * frame1.uy)),
|
||||
)
|
||||
)
|
||||
umax = max(max_speed, 1e-8)
|
||||
dt_trace = min(float(cfg.alpha_t) * dt_save, float(cfg.alpha_x) / umax)
|
||||
n_sub = max(1, int(math.ceil(dt_save / dt_trace)))
|
||||
dt_sub = dt_save / float(n_sub)
|
||||
|
||||
p = np.asarray(particles, dtype=np.float64)
|
||||
a = np.asarray(ages, dtype=np.float64)
|
||||
track = np.ones((p.shape[0],), dtype=bool)
|
||||
local_rng = rng or np.random.default_rng(int(cfg.random_seed))
|
||||
diffusion = float(cfg.diffusion_coeff)
|
||||
|
||||
for sub_idx in range(n_sub):
|
||||
if p.size == 0:
|
||||
break
|
||||
theta = float(sub_idx) / float(n_sub)
|
||||
k1 = _velocity_at(p, theta, frame0, frame1)
|
||||
k2 = _velocity_at(p + 0.5 * dt_sub * k1, min(1.0, theta + 0.5 / n_sub), frame0, frame1)
|
||||
k3 = _velocity_at(p + 0.5 * dt_sub * k2, min(1.0, theta + 0.5 / n_sub), frame0, frame1)
|
||||
k4 = _velocity_at(p + dt_sub * k3, min(1.0, theta + 1.0 / n_sub), frame0, frame1)
|
||||
p_new = p + (k1 + 2.0 * k2 + 2.0 * k3 + k4) * (dt_sub / 6.0)
|
||||
|
||||
if diffusion > 0.0:
|
||||
sigma = math.sqrt(max(0.0, 2.0 * diffusion * dt_sub))
|
||||
p_new += local_rng.normal(0.0, sigma, size=p_new.shape)
|
||||
|
||||
valid = _particles_valid_mask(
|
||||
p_new,
|
||||
nx=nx,
|
||||
ny=ny,
|
||||
solid_center=solid_center,
|
||||
solid_radius=solid_radius,
|
||||
cylinders=cylinders,
|
||||
)
|
||||
track[track] = valid
|
||||
p = p_new[valid]
|
||||
a = a[valid] + dt_sub
|
||||
if cfg.max_particle_age is not None:
|
||||
age_keep = a <= float(cfg.max_particle_age)
|
||||
track[track] = age_keep
|
||||
p = p[age_keep]
|
||||
a = a[age_keep]
|
||||
|
||||
survived_mask = track
|
||||
return p, a, {"dt_trace": float(dt_sub), "n_substeps": int(n_sub), "max_speed": float(max_speed)}, survived_mask
|
||||
|
||||
|
||||
def run_streakline_offline(
|
||||
frames: Sequence[FlowFrame],
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
release_points: np.ndarray,
|
||||
release_cfg: ReleaseConfig,
|
||||
integrator_cfg: IntegratorConfig,
|
||||
solid_center: Optional[Tuple[float, float]] = None,
|
||||
solid_radius: Optional[float] = None,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
) -> Tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""Reconstruct streakline from pre-collected velocity frames."""
|
||||
if len(frames) < 2:
|
||||
raise ValueError("At least two frames are required.")
|
||||
pts = build_release_points(release_points, release_cfg)
|
||||
particles = np.zeros((0, 2), dtype=np.float64)
|
||||
ages = np.zeros((0,), dtype=np.float64)
|
||||
rng = np.random.default_rng(int(integrator_cfg.random_seed))
|
||||
max_speed = 0.0
|
||||
last_step = int(frames[0].step)
|
||||
total_substeps = 0
|
||||
|
||||
for i in range(len(frames) - 1):
|
||||
particles, ages = _inject_particles(
|
||||
particles,
|
||||
ages,
|
||||
pts,
|
||||
inject_per_seed=release_cfg.inject_per_seed,
|
||||
)
|
||||
particles, ages, diag, _ = advance_particles_between_frames(
|
||||
particles,
|
||||
ages,
|
||||
frames[i],
|
||||
frames[i + 1],
|
||||
nx=nx,
|
||||
ny=ny,
|
||||
cfg=integrator_cfg,
|
||||
solid_center=solid_center,
|
||||
solid_radius=solid_radius,
|
||||
cylinders=cylinders,
|
||||
rng=rng,
|
||||
)
|
||||
max_speed = max(max_speed, float(diag["max_speed"]))
|
||||
total_substeps += int(diag["n_substeps"])
|
||||
last_step = int(frames[i + 1].step)
|
||||
|
||||
return particles, ages, {
|
||||
"frames_used": int(len(frames)),
|
||||
"last_step": int(last_step),
|
||||
"n_particles_alive": int(particles.shape[0]),
|
||||
"max_speed_seen": float(max_speed),
|
||||
"total_substeps": int(total_substeps),
|
||||
}
|
||||
|
||||
|
||||
def run_streakline_online(
|
||||
sim: Any,
|
||||
*,
|
||||
start_step: int,
|
||||
sample_every: int,
|
||||
n_samples: int,
|
||||
release_points: np.ndarray,
|
||||
release_cfg: ReleaseConfig,
|
||||
integrator_cfg: IntegratorConfig,
|
||||
solid_center: Optional[Tuple[float, float]] = None,
|
||||
solid_radius: Optional[float] = None,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
) -> Tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""Compute streakline online from a running Simulation without disk snapshots."""
|
||||
if sample_every < 1 or n_samples < 2:
|
||||
raise ValueError("sample_every must be >=1 and n_samples must be >=2.")
|
||||
if not getattr(sim, "_initialized", False):
|
||||
raise RuntimeError("Simulation must be initialized before online streakline run.")
|
||||
|
||||
nx = int(sim.lbm_cfg.nx)
|
||||
ny = int(sim.lbm_cfg.ny)
|
||||
frames: list[FlowFrame] = []
|
||||
target_last = int(start_step + sample_every * (n_samples - 1))
|
||||
|
||||
while int(sim.stepper.step_count) < target_last:
|
||||
sim.step(1)
|
||||
step = int(sim.stepper.step_count)
|
||||
if step < int(start_step):
|
||||
continue
|
||||
if (step - int(start_step)) % int(sample_every) != 0:
|
||||
continue
|
||||
macro = sim.get_macroscopic()
|
||||
frames.append(
|
||||
FlowFrame(
|
||||
step=step,
|
||||
ux=np.asarray(macro["ux"], dtype=np.float64),
|
||||
uy=np.asarray(macro["uy"], dtype=np.float64),
|
||||
)
|
||||
)
|
||||
if len(frames) >= int(n_samples):
|
||||
break
|
||||
|
||||
if len(frames) < 2:
|
||||
raise RuntimeError("Online run did not collect enough frames.")
|
||||
|
||||
particles, ages, diag = run_streakline_offline(
|
||||
frames,
|
||||
nx=nx,
|
||||
ny=ny,
|
||||
release_points=release_points,
|
||||
release_cfg=release_cfg,
|
||||
integrator_cfg=integrator_cfg,
|
||||
solid_center=solid_center,
|
||||
solid_radius=solid_radius,
|
||||
cylinders=cylinders,
|
||||
)
|
||||
diag["mode"] = "online"
|
||||
diag["sample_every"] = int(sample_every)
|
||||
diag["start_step"] = int(start_step)
|
||||
return particles, ages, diag
|
||||
|
||||
|
||||
def compute_vorticity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Central-difference vorticity ω = ∂uy/∂x − ∂ux/∂y on interior cells."""
|
||||
ux_arr = np.asarray(ux, dtype=np.float64)
|
||||
uy_arr = np.asarray(uy, dtype=np.float64)
|
||||
vort = np.zeros_like(ux_arr, dtype=np.float64)
|
||||
dudy = 0.5 * (ux_arr[2:, 1:-1] - ux_arr[:-2, 1:-1])
|
||||
dvdx = 0.5 * (uy_arr[1:-1, 2:] - uy_arr[1:-1, :-2])
|
||||
vort[1:-1, 1:-1] = dvdx - dudy
|
||||
return vort
|
||||
|
||||
|
||||
def render_vorticity_field(
|
||||
vort: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
vmin: Optional[float] = None,
|
||||
vmax: Optional[float] = None,
|
||||
percentile: float = 99.0,
|
||||
minimal_axes: bool = True,
|
||||
) -> dict:
|
||||
"""Render vorticity with white at zero, green negative, purple positive."""
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("render_vorticity_field requires matplotlib.") from exc
|
||||
|
||||
field = np.asarray(vort, dtype=np.float64)
|
||||
vmax_field = float(np.nanmax(np.abs(field)))
|
||||
abs_pct = float(np.nanpercentile(np.abs(field), percentile))
|
||||
# Freestream dominates |ω|; do not let a near-zero percentile wash out wake structure.
|
||||
abs_max = max(abs_pct, 0.35 * vmax_field, 1e-6)
|
||||
if vmin is None:
|
||||
vmin = -abs_max
|
||||
if vmax is None:
|
||||
vmax = abs_max
|
||||
if vmin >= 0.0 or vmax <= 0.0:
|
||||
raise ValueError(f"vmin/vmax must bracket zero: vmin={vmin}, vmax={vmax}")
|
||||
|
||||
cmap = LinearSegmentedColormap.from_list(
|
||||
"vort_green_white_purple",
|
||||
["#1a9850", "#f7f7f7", "#762a83"],
|
||||
N=256,
|
||||
)
|
||||
norm = TwoSlopeNorm(vmin=float(vmin), vcenter=0.0, vmax=float(vmax))
|
||||
|
||||
rgba = cmap(norm(field))
|
||||
if cylinders:
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
for (cx, cy), radius in cylinders:
|
||||
mask = (xx - float(cx)) ** 2 + (yy - float(cy)) ** 2 <= float(radius) ** 2
|
||||
rgba[mask] = (0.0, 0.0, 0.0, 1.0)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.imshow(rgba, origin="lower", interpolation="nearest", aspect="auto")
|
||||
if minimal_axes:
|
||||
ax.axis("off")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"vmin": float(vmin),
|
||||
"vmax": float(vmax),
|
||||
"vort_min": float(np.nanmin(field)),
|
||||
"vort_max": float(np.nanmax(field)),
|
||||
}
|
||||
|
||||
|
||||
def gaussian_blur2d(img: np.ndarray, sigma: float = 1.0) -> np.ndarray:
|
||||
"""Separable Gaussian blur helper."""
|
||||
if float(sigma) <= 0.0:
|
||||
return np.asarray(img, dtype=np.float64)
|
||||
radius = max(1, int(math.ceil(3.0 * float(sigma))))
|
||||
x = np.arange(-radius, radius + 1, dtype=np.float64)
|
||||
k = np.exp(-(x**2) / (2.0 * sigma * sigma))
|
||||
k /= np.sum(k)
|
||||
arr = np.asarray(img, dtype=np.float64)
|
||||
arr = np.apply_along_axis(lambda m: np.convolve(m, k, mode="same"), 1, arr)
|
||||
arr = np.apply_along_axis(lambda m: np.convolve(m, k, mode="same"), 0, arr)
|
||||
return arr
|
||||
|
||||
|
||||
def _rasterize_segment(
|
||||
intensity: np.ndarray,
|
||||
x0: float,
|
||||
y0: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
weight: float,
|
||||
) -> None:
|
||||
"""Stamp one segment into a scalar intensity map (for white-bg red compose)."""
|
||||
ny, nx = intensity.shape
|
||||
n = int(max(abs(x1 - x0), abs(y1 - y0), 1.0)) + 1
|
||||
xs = np.linspace(x0, x1, n)
|
||||
ys = np.linspace(y0, y1, n)
|
||||
xi = np.clip(np.rint(xs).astype(np.int64), 0, nx - 1)
|
||||
yi = np.clip(np.rint(ys).astype(np.int64), 0, ny - 1)
|
||||
w = float(max(0.0, min(1.0, weight)))
|
||||
np.add.at(intensity, (yi, xi), w)
|
||||
|
||||
|
||||
def _draw_trail(
|
||||
intensity: np.ndarray,
|
||||
trail: np.ndarray,
|
||||
*,
|
||||
fade_along_trail: bool,
|
||||
) -> None:
|
||||
if trail.shape[0] < 2:
|
||||
return
|
||||
nseg = trail.shape[0] - 1
|
||||
for i in range(nseg):
|
||||
if fade_along_trail:
|
||||
t = float(i + 1) / float(nseg)
|
||||
weight = 0.15 + 0.85 * t
|
||||
else:
|
||||
weight = 1.0
|
||||
_rasterize_segment(
|
||||
intensity,
|
||||
float(trail[i, 0]),
|
||||
float(trail[i, 1]),
|
||||
float(trail[i + 1, 0]),
|
||||
float(trail[i + 1, 1]),
|
||||
weight,
|
||||
)
|
||||
|
||||
|
||||
def _draw_cylinders(rgb: np.ndarray, cylinders: Sequence[CylinderSpec]) -> None:
|
||||
ny, nx = rgb.shape[0], rgb.shape[1]
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
for (cx, cy), radius in cylinders:
|
||||
mask = (xx - float(cx)) ** 2 + (yy - float(cy)) ** 2 <= float(radius) ** 2
|
||||
rgb[mask] = 0.0
|
||||
|
||||
|
||||
def render_snapshot_trails(
|
||||
trail_set: ParticleTrailSet,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
blur_sigma: float = 0.0,
|
||||
fade_along_trail: bool = True,
|
||||
num_threads: int = 0,
|
||||
background_color: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
||||
streak_color: Tuple[float, float, float] = (1.0, 0.0, 0.0),
|
||||
) -> dict:
|
||||
"""Render accumulated path trails (pathline collage — debug only, not streakline)."""
|
||||
n_threads = configure_compute_threads(num_threads)
|
||||
intensity = np.zeros((ny, nx), dtype=np.float64)
|
||||
|
||||
trails = trail_set.trails
|
||||
if not trails:
|
||||
density_max = 0.0
|
||||
else:
|
||||
chunk = max(1, (len(trails) + n_threads - 1) // n_threads)
|
||||
|
||||
def _worker(batch: List[np.ndarray]) -> np.ndarray:
|
||||
local = np.zeros((ny, nx), dtype=np.float64)
|
||||
for tr in batch:
|
||||
_draw_trail(local, tr, fade_along_trail=fade_along_trail)
|
||||
return local
|
||||
|
||||
batches = [trails[i : i + chunk] for i in range(0, len(trails), chunk)]
|
||||
if len(batches) == 1:
|
||||
intensity = _worker(batches[0])
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=n_threads) as pool:
|
||||
parts = list(pool.map(_worker, batches))
|
||||
for part in parts:
|
||||
intensity = np.maximum(intensity, part)
|
||||
|
||||
if np.any(intensity > 0):
|
||||
vmax = float(np.percentile(intensity[intensity > 0], 99.0))
|
||||
intensity = np.clip(intensity / max(vmax, 1e-12), 0.0, 1.0)
|
||||
density_max = float(np.max(intensity))
|
||||
|
||||
rgb = np.ones((ny, nx, 3), dtype=np.float64)
|
||||
rgb[:, :, 0] = background_color[0]
|
||||
rgb[:, :, 1] = background_color[1] * (1.0 - intensity) + streak_color[1] * intensity
|
||||
rgb[:, :, 2] = background_color[2] * (1.0 - intensity) + streak_color[2] * intensity
|
||||
if streak_color[0] < 0.999:
|
||||
rgb[:, :, 0] = background_color[0] * (1.0 - intensity) + streak_color[0] * intensity
|
||||
|
||||
if cylinders:
|
||||
_draw_cylinders(rgb, cylinders)
|
||||
|
||||
if float(blur_sigma) > 0.0:
|
||||
for c in range(3):
|
||||
rgb[:, :, c] = gaussian_blur2d(rgb[:, :, c], sigma=float(blur_sigma))
|
||||
|
||||
rgb = np.clip(rgb, 0.0, 1.0)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.axis("off")
|
||||
ax.imshow(rgb, origin="lower", interpolation="nearest", aspect="auto")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
except ImportError:
|
||||
from PIL import Image
|
||||
|
||||
Image.fromarray((np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)).save(out_path)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"trail_count": int(len(trails)),
|
||||
"density_max": density_max,
|
||||
"num_threads": int(n_threads),
|
||||
}
|
||||
|
||||
|
||||
def render_streakline_density(
|
||||
positions: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
release_points: Optional[np.ndarray] = None,
|
||||
solid_center: Optional[Tuple[float, float]] = None,
|
||||
solid_radius: Optional[float] = None,
|
||||
cylinders: Optional[Sequence[CylinderSpec]] = None,
|
||||
age_decay_steps: float = 20000.0,
|
||||
blur_sigma: float = 1.2,
|
||||
title: str = "Streakline",
|
||||
minimal_axes: bool = False,
|
||||
background_color: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
streak_color: Tuple[float, float, float] = (1.0, 0.5, 0.0),
|
||||
show_release_points: bool = True,
|
||||
) -> dict:
|
||||
"""Render streakline as current particle positions (density cloud at observation time)."""
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.colors import LinearSegmentedColormap
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("render_streakline_density requires matplotlib.") from exc
|
||||
|
||||
if positions.size == 0:
|
||||
density = np.zeros((ny, nx), dtype=np.float64)
|
||||
else:
|
||||
w = np.exp(-np.asarray(ages, dtype=np.float64) / max(1.0, float(age_decay_steps)))
|
||||
y_idx = np.clip(np.floor(positions[:, 1]).astype(np.int64), 0, ny - 1)
|
||||
x_idx = np.clip(np.floor(positions[:, 0]).astype(np.int64), 0, nx - 1)
|
||||
density = np.zeros((ny, nx), dtype=np.float64)
|
||||
np.add.at(density, (y_idx, x_idx), w)
|
||||
density = gaussian_blur2d(density, sigma=float(blur_sigma))
|
||||
|
||||
if minimal_axes:
|
||||
rgb = np.ones((ny, nx, 3), dtype=np.float64)
|
||||
rgb[:, :, 0] = background_color[0]
|
||||
rgb[:, :, 1] = background_color[1]
|
||||
rgb[:, :, 2] = background_color[2]
|
||||
if np.any(density > 0):
|
||||
vmax = float(np.percentile(density[density > 0], 99.0))
|
||||
alpha = np.clip(density / max(vmax, 1e-12), 0.0, 1.0)
|
||||
for c, val in enumerate(streak_color):
|
||||
rgb[:, :, c] = np.clip(rgb[:, :, c] * (1.0 - alpha) + val * alpha, 0.0, 1.0)
|
||||
specs = list(cylinders or [])
|
||||
if solid_center is not None and solid_radius is not None:
|
||||
specs.append((solid_center, float(solid_radius)))
|
||||
if specs:
|
||||
_draw_cylinders(rgb, specs)
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.axis("off")
|
||||
ax.imshow(rgb, origin="lower", interpolation="nearest", aspect="auto")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"n_particles": int(positions.shape[0]) if positions.ndim == 2 else 0,
|
||||
"density_max": float(np.max(density)) if density.size else 0.0,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
fig, ax = plt.subplots(figsize=(12, 5))
|
||||
vmax = np.percentile(density[density > 0], 99.0) if np.any(density > 0) else 1.0
|
||||
cmap = LinearSegmentedColormap.from_list("streak", [(0.0, 0.0, 0.0), streak_color])
|
||||
ax.imshow(
|
||||
density,
|
||||
origin="lower",
|
||||
cmap=cmap,
|
||||
extent=(0, nx - 1, 0, ny - 1),
|
||||
vmin=0.0,
|
||||
vmax=max(vmax, 1e-12),
|
||||
aspect="equal",
|
||||
)
|
||||
if solid_center is not None and solid_radius is not None:
|
||||
circ = plt.Circle(solid_center, radius=solid_radius, fill=False, color="cyan", linewidth=1.4)
|
||||
ax.add_patch(circ)
|
||||
if show_release_points and release_points is not None and np.asarray(release_points).size:
|
||||
rp = np.asarray(release_points)
|
||||
ax.scatter(rp[:, 0], rp[:, 1], c="white", s=10, marker="x")
|
||||
ax.set_title(title)
|
||||
ax.set_xlabel("x (lattice)")
|
||||
ax.set_ylabel("y (lattice)")
|
||||
ax.set_xlim(0, nx - 1)
|
||||
ax.set_ylim(0, ny - 1)
|
||||
fig.tight_layout()
|
||||
fig.savefig(out_path, dpi=170, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"n_particles": int(positions.shape[0]) if positions.ndim == 2 else 0,
|
||||
"density_max": float(np.max(density)) if density.size else 0.0,
|
||||
}
|
||||
49
src/CelerisLab/common/streakline/__init__.py
Normal file
49
src/CelerisLab/common/streakline/__init__.py
Normal file
@ -0,0 +1,49 @@
|
||||
# CelerisLab/common/streakline/__init__.py
|
||||
"""
|
||||
Streakline — continuous-release particle-based flow visualization.
|
||||
|
||||
A post-processing sub-package under ``common/``. The ``Streakline`` class is a
|
||||
passive consumer: users call ``observe()`` in their own simulation loop,
|
||||
controlling body actions, checkpointing, or other modules independently.
|
||||
|
||||
Usage::
|
||||
|
||||
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
|
||||
|
||||
streak = Streakline(release_points=..., nx=nx, ny=ny, ...)
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
streak.render("output.png")
|
||||
"""
|
||||
|
||||
from ._config import (
|
||||
ReleaseConfig,
|
||||
IntegratorConfig,
|
||||
FlowFrame,
|
||||
CylinderSpec,
|
||||
build_release_points,
|
||||
)
|
||||
from ._streakline import Streakline
|
||||
from ._integrate import (
|
||||
advance_between_frames as advance_particles_between_frames,
|
||||
bilinear_sample,
|
||||
inject_particles,
|
||||
particles_valid_mask,
|
||||
)
|
||||
from ._render import render_density, gaussian_blur2d
|
||||
|
||||
# configure_compute_threads is imported lazily (in _streakline.py) to avoid
|
||||
# circular dependency between common.pathline and common.streakline.
|
||||
|
||||
# Backward-compat aliases
|
||||
render_streakline_density = render_density
|
||||
|
||||
__all__ = [
|
||||
"Streakline",
|
||||
"ReleaseConfig",
|
||||
"IntegratorConfig",
|
||||
"FlowFrame",
|
||||
"CylinderSpec",
|
||||
"build_release_points",
|
||||
"advance_particles_between_frames",
|
||||
"render_streakline_density",
|
||||
]
|
||||
94
src/CelerisLab/common/streakline/_config.py
Normal file
94
src/CelerisLab/common/streakline/_config.py
Normal file
@ -0,0 +1,94 @@
|
||||
# CelerisLab/common/streakline/_config.py
|
||||
"""
|
||||
Configuration dataclasses for streakline particle integration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .._types import CylinderSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlowFrame:
|
||||
"""One sampled velocity frame (internal use only)."""
|
||||
step: int
|
||||
ux: np.ndarray
|
||||
uy: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseConfig:
|
||||
"""Release strategy configuration for dense streakline seeding.
|
||||
|
||||
Args:
|
||||
mode: ``"point"`` | ``"line"`` | ``"strip"``.
|
||||
line_span: Total cross-stream span for ``line`` / ``strip`` modes.
|
||||
line_count: Number of cross-stream release points.
|
||||
downstream_count: Number of streamwise points (``strip`` mode only).
|
||||
downstream_spacing: Spacing between downstream copies (lattice units).
|
||||
inject_per_seed: Particles injected per release point each frame.
|
||||
"""
|
||||
mode: str = "strip"
|
||||
line_span: float = 12.0
|
||||
line_count: int = 5
|
||||
downstream_count: int = 4
|
||||
downstream_spacing: float = 3.0
|
||||
inject_per_seed: int = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegratorConfig:
|
||||
"""Particle integration and filtering settings.
|
||||
|
||||
Args:
|
||||
alpha_t: Fraction of frame interval used as substep ceiling.
|
||||
alpha_x: Fraction of lattice unit per substep (CFL-like).
|
||||
age_decay_steps: Exponential decay scale for age-weight in rendering.
|
||||
max_particle_age: Hard cutoff age (None = no cutoff).
|
||||
diffusion_coeff: Random-walk diffusion for visual thickening (0 = off).
|
||||
random_seed: RNG seed for diffusion.
|
||||
"""
|
||||
alpha_t: float = 0.2
|
||||
alpha_x: float = 0.4
|
||||
age_decay_steps: float = 20000.0
|
||||
max_particle_age: Optional[float] = None
|
||||
diffusion_coeff: float = 0.0
|
||||
random_seed: int = 1234
|
||||
|
||||
|
||||
def build_release_points(
|
||||
base_points: np.ndarray,
|
||||
release_cfg: ReleaseConfig,
|
||||
) -> np.ndarray:
|
||||
"""Expand *base_points* to a dense release grid per *release_cfg*."""
|
||||
pts = np.asarray(base_points, dtype=np.float64)
|
||||
if pts.ndim != 2 or pts.shape[1] != 2:
|
||||
raise ValueError("base_points must be [N,2].")
|
||||
mode = str(release_cfg.mode).lower()
|
||||
if mode not in ("point", "line", "strip"):
|
||||
raise ValueError("release mode must be point|line|strip.")
|
||||
|
||||
line_count = max(1, int(release_cfg.line_count))
|
||||
ds_count = max(1, int(release_cfg.downstream_count))
|
||||
line_offsets = np.linspace(
|
||||
-0.5 * float(release_cfg.line_span),
|
||||
0.5 * float(release_cfg.line_span),
|
||||
line_count,
|
||||
)
|
||||
ds_offsets = np.arange(ds_count, dtype=np.float64) * float(
|
||||
release_cfg.downstream_spacing
|
||||
)
|
||||
|
||||
out: list[list[float]] = []
|
||||
for x0, y0 in pts:
|
||||
line_iter = [0.0] if mode == "point" else line_offsets
|
||||
ds_iter = [0.0] if mode in ("point", "line") else ds_offsets
|
||||
for dy in line_iter:
|
||||
for dx in ds_iter:
|
||||
out.append([float(x0 + dx), float(y0 + dy)])
|
||||
return np.asarray(out, dtype=np.float64)
|
||||
180
src/CelerisLab/common/streakline/_integrate.py
Normal file
180
src/CelerisLab/common/streakline/_integrate.py
Normal file
@ -0,0 +1,180 @@
|
||||
# CelerisLab/common/streakline/_integrate.py
|
||||
"""
|
||||
Particle integration: bilinear interpolation, RK4 substep, validity filtering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._config import CylinderSpec, FlowFrame, IntegratorConfig
|
||||
|
||||
|
||||
def bilinear_sample(field: np.ndarray, x: np.ndarray, y: np.ndarray) -> np.ndarray:
|
||||
"""Bilinear interpolation at floating-point positions ``(x, y)``.
|
||||
|
||||
Returns ``NaN`` for positions outside the domain.
|
||||
"""
|
||||
ny, nx = field.shape
|
||||
x0 = np.floor(x).astype(np.int64)
|
||||
y0 = np.floor(y).astype(np.int64)
|
||||
x1 = x0 + 1
|
||||
y1 = y0 + 1
|
||||
inside = (x0 >= 0) & (x1 < nx) & (y0 >= 0) & (y1 < ny)
|
||||
out = np.full_like(x, np.nan, dtype=np.float64)
|
||||
if not np.any(inside):
|
||||
return out
|
||||
|
||||
xi = x[inside] - x0[inside]
|
||||
yi = y[inside] - y0[inside]
|
||||
f00 = field[y0[inside], x0[inside]]
|
||||
f10 = field[y0[inside], x1[inside]]
|
||||
f01 = field[y1[inside], x0[inside]]
|
||||
f11 = field[y1[inside], x1[inside]]
|
||||
out[inside] = (
|
||||
(1.0 - xi) * (1.0 - yi) * f00
|
||||
+ xi * (1.0 - yi) * f10
|
||||
+ (1.0 - xi) * yi * f01
|
||||
+ xi * yi * f11
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def velocity_at(
|
||||
pos: np.ndarray, theta: float, f0: FlowFrame, f1: FlowFrame
|
||||
) -> np.ndarray:
|
||||
"""Space-time interpolated velocity at positions ``pos`` (N, 2)."""
|
||||
x = pos[:, 0]
|
||||
y = pos[:, 1]
|
||||
vx0 = bilinear_sample(f0.ux, x, y)
|
||||
vy0 = bilinear_sample(f0.uy, x, y)
|
||||
vx1 = bilinear_sample(f1.ux, x, y)
|
||||
vy1 = bilinear_sample(f1.uy, x, y)
|
||||
vx = (1.0 - theta) * vx0 + theta * vx1
|
||||
vy = (1.0 - theta) * vy0 + theta * vy1
|
||||
return np.stack([vx, vy], axis=1)
|
||||
|
||||
|
||||
def particles_valid_mask(
|
||||
p_new: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
cylinders: Sequence[CylinderSpec],
|
||||
) -> np.ndarray:
|
||||
"""Boolean mask: particles within domain bounds and outside solids."""
|
||||
valid = np.all(np.isfinite(p_new), axis=1)
|
||||
valid &= (p_new[:, 0] >= 0.0) & (p_new[:, 0] <= nx - 1)
|
||||
valid &= (p_new[:, 1] >= 0.0) & (p_new[:, 1] <= ny - 1)
|
||||
|
||||
for (cx, cy), radius in cylinders:
|
||||
dx = p_new[:, 0] - float(cx)
|
||||
dy = p_new[:, 1] - float(cy)
|
||||
valid &= (dx * dx + dy * dy) > (float(radius) * float(radius))
|
||||
return valid
|
||||
|
||||
|
||||
def inject_particles(
|
||||
particles: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
release_points: np.ndarray,
|
||||
*,
|
||||
inject_per_seed: int,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Append new particles at *release_points* with age 0."""
|
||||
new_pts = np.repeat(release_points, max(1, int(inject_per_seed)), axis=0)
|
||||
if particles.size == 0:
|
||||
return new_pts.copy(), np.zeros((new_pts.shape[0],), dtype=np.float64)
|
||||
return (
|
||||
np.vstack([particles, new_pts]),
|
||||
np.concatenate([ages, np.zeros((new_pts.shape[0],), dtype=np.float64)]),
|
||||
)
|
||||
|
||||
|
||||
def advance_between_frames(
|
||||
particles: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
frame0: FlowFrame,
|
||||
frame1: FlowFrame,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
cfg: IntegratorConfig,
|
||||
cylinders: Sequence[CylinderSpec],
|
||||
rng: Optional[np.random.Generator] = None,
|
||||
) -> tuple[np.ndarray, np.ndarray, dict, np.ndarray]:
|
||||
"""Advance particles from *frame0* to *frame1* with RK4.
|
||||
|
||||
Returns:
|
||||
particles, ages, diagnostics, survived_mask (input-length bool).
|
||||
"""
|
||||
if particles.size == 0:
|
||||
return (
|
||||
particles,
|
||||
ages,
|
||||
{"n_substeps": 0, "max_speed": 0.0},
|
||||
np.zeros((0,), dtype=bool),
|
||||
)
|
||||
|
||||
dt_save = float(frame1.step - frame0.step)
|
||||
max_speed = float(
|
||||
max(
|
||||
np.nanmax(np.sqrt(frame0.ux * frame0.ux + frame0.uy * frame0.uy)),
|
||||
np.nanmax(np.sqrt(frame1.ux * frame1.ux + frame1.uy * frame1.uy)),
|
||||
)
|
||||
)
|
||||
umax = max(max_speed, 1e-8)
|
||||
dt_trace = min(float(cfg.alpha_t) * dt_save, float(cfg.alpha_x) / umax)
|
||||
n_sub = max(1, int(math.ceil(dt_save / dt_trace)))
|
||||
dt_sub = dt_save / float(n_sub)
|
||||
|
||||
p = np.asarray(particles, dtype=np.float64)
|
||||
a = np.asarray(ages, dtype=np.float64)
|
||||
track = np.ones((p.shape[0],), dtype=bool)
|
||||
local_rng = rng or np.random.default_rng(int(cfg.random_seed))
|
||||
diffusion = float(cfg.diffusion_coeff)
|
||||
|
||||
for sub_idx in range(n_sub):
|
||||
if p.size == 0:
|
||||
break
|
||||
theta = float(sub_idx) / float(n_sub)
|
||||
k1 = velocity_at(p, theta, frame0, frame1)
|
||||
k2 = velocity_at(
|
||||
p + 0.5 * dt_sub * k1, min(1.0, theta + 0.5 / n_sub), frame0, frame1
|
||||
)
|
||||
k3 = velocity_at(
|
||||
p + 0.5 * dt_sub * k2, min(1.0, theta + 0.5 / n_sub), frame0, frame1
|
||||
)
|
||||
k4 = velocity_at(
|
||||
p + dt_sub * k3, min(1.0, theta + 1.0 / n_sub), frame0, frame1
|
||||
)
|
||||
p_new = p + (k1 + 2.0 * k2 + 2.0 * k3 + k4) * (dt_sub / 6.0)
|
||||
|
||||
if diffusion > 0.0:
|
||||
sigma = math.sqrt(max(0.0, 2.0 * diffusion * dt_sub))
|
||||
p_new += local_rng.normal(0.0, sigma, size=p_new.shape)
|
||||
|
||||
valid = particles_valid_mask(p_new, nx=nx, ny=ny, cylinders=cylinders)
|
||||
track[track] = valid
|
||||
p = p_new[valid]
|
||||
a = a[valid] + dt_sub
|
||||
if cfg.max_particle_age is not None:
|
||||
age_keep = a <= float(cfg.max_particle_age)
|
||||
track[track] = age_keep
|
||||
p = p[age_keep]
|
||||
a = a[age_keep]
|
||||
|
||||
survived_mask = track
|
||||
return (
|
||||
p,
|
||||
a,
|
||||
{
|
||||
"dt_trace": float(dt_sub),
|
||||
"n_substeps": int(n_sub),
|
||||
"max_speed": float(max_speed),
|
||||
},
|
||||
survived_mask,
|
||||
)
|
||||
148
src/CelerisLab/common/streakline/_render.py
Normal file
148
src/CelerisLab/common/streakline/_render.py
Normal file
@ -0,0 +1,148 @@
|
||||
# CelerisLab/common/streakline/_render.py
|
||||
"""
|
||||
Render streakline particle clouds to density images.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from typing import Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._config import CylinderSpec
|
||||
|
||||
|
||||
def gaussian_blur2d(img: np.ndarray, sigma: float = 1.0) -> np.ndarray:
|
||||
"""Separable Gaussian blur."""
|
||||
if float(sigma) <= 0.0:
|
||||
return np.asarray(img, dtype=np.float64)
|
||||
radius = max(1, int(math.ceil(3.0 * float(sigma))))
|
||||
x = np.arange(-radius, radius + 1, dtype=np.float64)
|
||||
k = np.exp(-(x**2) / (2.0 * sigma * sigma))
|
||||
k /= np.sum(k)
|
||||
arr = np.asarray(img, dtype=np.float64)
|
||||
for i in range(arr.shape[0]):
|
||||
arr[i, :] = np.convolve(arr[i, :], k, mode="same")
|
||||
for j in range(arr.shape[1]):
|
||||
arr[:, j] = np.convolve(arr[:, j], k, mode="same")
|
||||
return arr
|
||||
|
||||
|
||||
def _draw_cylinders(rgb: np.ndarray, cylinders: Sequence[CylinderSpec]) -> None:
|
||||
ny, nx = rgb.shape[0], rgb.shape[1]
|
||||
yy, xx = np.mgrid[0:ny, 0:nx]
|
||||
for (cx, cy), radius in cylinders:
|
||||
mask = (xx - float(cx)) ** 2 + (yy - float(cy)) ** 2 <= float(radius) ** 2
|
||||
rgb[mask] = 0.0
|
||||
|
||||
|
||||
def render_density(
|
||||
positions: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Sequence[CylinderSpec] = (),
|
||||
age_decay_steps: float = 20000.0,
|
||||
blur_sigma: float = 1.2,
|
||||
background_color: tuple[float, float, float] = (0.0, 0.0, 0.0),
|
||||
streak_color: tuple[float, float, float] = (1.0, 0.5, 0.0),
|
||||
show_release_points: Optional[np.ndarray] = None,
|
||||
) -> dict:
|
||||
"""Render streakline particle cloud as a density image.
|
||||
|
||||
Particles are binned onto the pixel grid with age-weighted exponential
|
||||
decay, Gaussian-blurred, then composited onto *background_color* using
|
||||
*streak_color*.
|
||||
"""
|
||||
density = _build_density_map(
|
||||
positions, ages, nx, ny, age_decay_steps=age_decay_steps
|
||||
)
|
||||
density = gaussian_blur2d(density, sigma=float(blur_sigma))
|
||||
return _save_minimal_image(
|
||||
density,
|
||||
nx,
|
||||
ny,
|
||||
out_path,
|
||||
cylinders,
|
||||
background_color=background_color,
|
||||
streak_color=streak_color,
|
||||
release_points=show_release_points,
|
||||
)
|
||||
|
||||
|
||||
def _build_density_map(
|
||||
positions: np.ndarray,
|
||||
ages: np.ndarray,
|
||||
nx: int,
|
||||
ny: int,
|
||||
*,
|
||||
age_decay_steps: float,
|
||||
) -> np.ndarray:
|
||||
if positions.size == 0:
|
||||
return np.zeros((ny, nx), dtype=np.float64)
|
||||
w = np.exp(-np.asarray(ages, dtype=np.float64) / max(1.0, float(age_decay_steps)))
|
||||
y_idx = np.clip(np.floor(positions[:, 1]).astype(np.int64), 0, ny - 1)
|
||||
x_idx = np.clip(np.floor(positions[:, 0]).astype(np.int64), 0, nx - 1)
|
||||
density = np.zeros((ny, nx), dtype=np.float64)
|
||||
np.add.at(density, (y_idx, x_idx), w)
|
||||
return density
|
||||
|
||||
|
||||
def _save_minimal_image(
|
||||
density: np.ndarray,
|
||||
nx: int,
|
||||
ny: int,
|
||||
out_path: str,
|
||||
cylinders: Sequence[CylinderSpec],
|
||||
*,
|
||||
background_color: tuple[float, float, float],
|
||||
streak_color: tuple[float, float, float],
|
||||
release_points: Optional[np.ndarray] = None,
|
||||
) -> dict:
|
||||
"""Compose density into an RGB image and save (no matplotlib axes)."""
|
||||
rgb = np.ones((ny, nx, 3), dtype=np.float64)
|
||||
rgb[:, :, 0] = background_color[0]
|
||||
rgb[:, :, 1] = background_color[1]
|
||||
rgb[:, :, 2] = background_color[2]
|
||||
|
||||
if np.any(density > 0):
|
||||
vmax = float(np.percentile(density[density > 0], 99.0))
|
||||
alpha = np.clip(density / max(vmax, 1e-12), 0.0, 1.0)
|
||||
for c, val in enumerate(streak_color):
|
||||
rgb[:, :, c] = np.clip(
|
||||
rgb[:, :, c] * (1.0 - alpha) + val * alpha, 0.0, 1.0
|
||||
)
|
||||
|
||||
if cylinders:
|
||||
_draw_cylinders(rgb, cylinders)
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
|
||||
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig = plt.figure(figsize=(nx / 100.0, ny / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
ax.axis("off")
|
||||
ax.imshow(rgb, origin="lower", interpolation="nearest", aspect="auto")
|
||||
fig.savefig(out_path, dpi=100, bbox_inches="tight", pad_inches=0)
|
||||
plt.close(fig)
|
||||
except ImportError:
|
||||
from PIL import Image
|
||||
|
||||
Image.fromarray((np.clip(rgb, 0.0, 1.0) * 255.0).astype(np.uint8)).save(
|
||||
out_path
|
||||
)
|
||||
|
||||
return {
|
||||
"image_path": out_path,
|
||||
"n_particles": int(density.sum()),
|
||||
"density_max": float(np.max(density)) if density.size else 0.0,
|
||||
}
|
||||
117
src/CelerisLab/common/streakline/_streakline.py
Normal file
117
src/CelerisLab/common/streakline/_streakline.py
Normal file
@ -0,0 +1,117 @@
|
||||
# CelerisLab/common/streakline/_streakline.py
|
||||
"""
|
||||
Streakline — passive-consumer particle-based flow visualization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._config import (
|
||||
CylinderSpec,
|
||||
FlowFrame,
|
||||
IntegratorConfig,
|
||||
ReleaseConfig,
|
||||
build_release_points,
|
||||
)
|
||||
from ._integrate import advance_between_frames, inject_particles
|
||||
from ._render import render_density
|
||||
|
||||
|
||||
@dataclass
|
||||
class Streakline:
|
||||
"""Passive streakline integrator.
|
||||
|
||||
Does **not** own a simulation or control the stepping loop. Users call
|
||||
``observe()`` whenever they have a new velocity frame; body actions,
|
||||
checkpointing, and other modules are managed independently.
|
||||
|
||||
Usage::
|
||||
|
||||
streak = Streakline(release_points=..., nx=nx, ny=ny, ...)
|
||||
# In your own simulation loop:
|
||||
macro = sim.get_macroscopic()
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
streak.render("output.png")
|
||||
"""
|
||||
|
||||
release_points: np.ndarray
|
||||
release_cfg: ReleaseConfig
|
||||
integrator_cfg: IntegratorConfig
|
||||
nx: int
|
||||
ny: int
|
||||
cylinders: Sequence[CylinderSpec] = field(default_factory=list)
|
||||
|
||||
_particles: np.ndarray = field(init=False, repr=False)
|
||||
_ages: np.ndarray = field(init=False, repr=False)
|
||||
_prev_frame: Optional[FlowFrame] = field(init=False, repr=False, default=None)
|
||||
|
||||
def __post_init__(self):
|
||||
self._particles = np.zeros((0, 2), dtype=np.float64)
|
||||
self._ages = np.zeros((0,), dtype=np.float64)
|
||||
self._dense_pts = build_release_points(
|
||||
np.asarray(self.release_points), self.release_cfg
|
||||
)
|
||||
|
||||
def observe(self, ux: np.ndarray, uy: np.ndarray, step: int) -> dict:
|
||||
"""Feed one velocity snapshot.
|
||||
|
||||
First call caches the frame. From the second call onward particles
|
||||
are injected and advanced from the previous frame to this one.
|
||||
"""
|
||||
frame = FlowFrame(
|
||||
step=int(step),
|
||||
ux=np.asarray(ux, dtype=np.float64),
|
||||
uy=np.asarray(uy, dtype=np.float64),
|
||||
)
|
||||
|
||||
if self._prev_frame is None:
|
||||
self._prev_frame = frame
|
||||
return {}
|
||||
|
||||
self._particles, self._ages = inject_particles(
|
||||
self._particles,
|
||||
self._ages,
|
||||
self._dense_pts,
|
||||
inject_per_seed=self.release_cfg.inject_per_seed,
|
||||
)
|
||||
|
||||
self._particles, self._ages, diag, _ = advance_between_frames(
|
||||
self._particles,
|
||||
self._ages,
|
||||
self._prev_frame,
|
||||
frame,
|
||||
nx=self.nx,
|
||||
ny=self.ny,
|
||||
cfg=self.integrator_cfg,
|
||||
cylinders=self.cylinders,
|
||||
)
|
||||
|
||||
self._prev_frame = frame
|
||||
return diag
|
||||
|
||||
def render(self, out_path: str, **kwargs) -> dict:
|
||||
"""Render current particle cloud as a density image."""
|
||||
# Default age_decay_steps from integrator config; **kwargs may override
|
||||
kw = dict(kwargs, cylinders=self.cylinders)
|
||||
kw.setdefault("age_decay_steps", self.integrator_cfg.age_decay_steps)
|
||||
return render_density(
|
||||
self._particles,
|
||||
self._ages,
|
||||
nx=self.nx,
|
||||
ny=self.ny,
|
||||
out_path=out_path,
|
||||
**kw,
|
||||
)
|
||||
|
||||
@property
|
||||
def n_particles(self) -> int:
|
||||
return self._particles.shape[0]
|
||||
|
||||
def reset(self):
|
||||
self._particles = np.zeros((0, 2), dtype=np.float64)
|
||||
self._ages = np.zeros((0,), dtype=np.float64)
|
||||
self._prev_frame = None
|
||||
@ -45,7 +45,7 @@ Python `config.py` 只负责读取和校验,不是配置位置。
|
||||
| `outlet.srt_neq_damp` | float | 0.5 | [0, 1] | 仅当 `outlet.mode` 为 **`neq_extrap`** 且碰撞为 **SRT/TRT** 时:出口全分布 damped NEQ 的阻尼系数。`outlet.mode` 为 **`blended`** 时 SRT/TRT 走混合未知方向分支,不使用本键。MRT + `neq_extrap` 仍为少量方向的 NEQ 外推 |
|
||||
| `y_wall_bc` | string | `"bounce_back"` | `bounce_back`, `free_slip` | y 向上下壁面边界条件(仅壁面相邻流体行生效) |
|
||||
| `omega_guard.min` | float | 0.01 | | ω 下界(LES τ_eff 保护) |
|
||||
| `omega_guard.max` | float | 1.96 | | ω 上界,高 Re 建议 1.90-1.96 |
|
||||
| `omega_guard.max` | float | 1.99 | | ω 上界,高 Re 建议 1.90-1.99 |
|
||||
|
||||
## cuda — 编译
|
||||
|
||||
@ -88,5 +88,5 @@ step/one_step_*.cu → kernel 编排
|
||||
|
||||
### 稳定性默认窗口(Stability defaults)
|
||||
|
||||
- 默认 `method.omega_guard = {min: 0.01, max: 1.96}`,用于约束碰撞频率与 LES 有效粘度路径。
|
||||
- 高 Re 调参建议将 `omega_guard.max` 保持在 `1.90-1.96` 区间;过高上界会增大接近 `omega -> 2` 的不稳定风险。
|
||||
- 默认 `method.omega_guard = {min: 0.01, max: 1.99}`,用于约束碰撞频率与 LES 有效粘度路径。
|
||||
- 高 Re 调参建议将 `omega_guard.max` 保持在 `1.90-1.99` 区间;过高上界会增大接近 `omega -> 2` 的不稳定风险。
|
||||
@ -53,8 +53,8 @@
|
||||
"_y_wall_bc": "bounce_back | free_slip",
|
||||
"omega_guard": {
|
||||
"min": 0.01,
|
||||
"max": 1.96,
|
||||
"_doc": "High-Re: keep max in 1.90-1.96"
|
||||
"max": 1.99,
|
||||
"_doc": "High-Re: keep max in 1.90-1.99"
|
||||
}
|
||||
},
|
||||
"cuda": {
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
#define NT 256
|
||||
#define MULT_GPU 0
|
||||
|
||||
#define NX 3000
|
||||
#define NY 300
|
||||
#define NX 384
|
||||
#define NY 192
|
||||
#define NZ 1
|
||||
|
||||
// ---- Lattice model (single source of truth) ----
|
||||
|
||||
@ -20,7 +20,7 @@
|
||||
#define Y_WALL_BC 1
|
||||
|
||||
#define OMEGA_COLLISION_MIN 0.01f
|
||||
#define OMEGA_COLLISION_MAX 1.960f
|
||||
#define OMEGA_COLLISION_MAX 1.990f
|
||||
#define TRT_MAGIC_PARAM 0.187500f
|
||||
|
||||
// NEQ damping coefficients for inlet/outlet BC reconstruction.
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
#ifndef CELERIS_CONFIG_OBJECTS_H
|
||||
#define CELERIS_CONFIG_OBJECTS_H
|
||||
|
||||
#define N_OBJS 3
|
||||
#define N_OBJS 2
|
||||
|
||||
#endif
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
#define CELERIS_CONFIG_PHYSICS_H
|
||||
|
||||
#define LBtype float
|
||||
#define VIS 0.0040000000
|
||||
#define VIS 0.0035000000
|
||||
#define RHO 1.0
|
||||
#define U0 0.04
|
||||
#define U0 0.03
|
||||
|
||||
#define PI 3.141592653589793238
|
||||
|
||||
|
||||
@ -155,7 +155,10 @@ __device__ __forceinline__ void compute_feq(float rho, float ux, float uy, float
|
||||
#endif // LATTICE_MODEL (compute_feq)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// compute_pressure (diagnostic, from state equation p = cs² ρ)
|
||||
// --- Diagnostic only ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// compute_pressure / compute_pressure_perturbation are diagnostic helpers
|
||||
// not consumed by any kernel on a hot path.
|
||||
// ---------------------------------------------------------------------------
|
||||
__device__ __forceinline__ float compute_pressure(float rho) {
|
||||
return CS2 * rho;
|
||||
|
||||
@ -79,6 +79,8 @@ void OneStep(
|
||||
// has_bc(fl): BC subtype != NONE -> wall/inlet/outlet nodes only
|
||||
// Obstacle nodes (FLAG_OBSTACLE, BC subtype=NONE) are excluded here
|
||||
// and handled solely by the is_obstacle branch below.
|
||||
// TODO: BC_MOVING and BC_PERIODIC are defined in flags.cuh but NOT
|
||||
// dispatched here — they silently fall through to bounce_back_swap.
|
||||
if (is_solid(fl) && has_bc(fl))
|
||||
apply_boundary_pull(f, fl, x, y, fi_in, k);
|
||||
|
||||
|
||||
@ -99,6 +99,22 @@ void EsoPullStep(
|
||||
if (is_obstacle(fl) && !is_curved(fl))
|
||||
bounce_back_swap(f);
|
||||
|
||||
// ---- y-wall adjacent row correction ----
|
||||
// EsoPull's alternating read/write pattern means `fi` stores the previous
|
||||
// step's post-collision value at this node's opposite directions — same
|
||||
// semantic as `fi_in` in the double-buffer path. The existing wall-BB
|
||||
// helpers read from `fi` and work correctly.
|
||||
//
|
||||
// NOTE: This is a first-order correction. A dedicated EsoPull bounce-back
|
||||
// scheme may be needed for higher accuracy (see FluidX3D).
|
||||
if (is_fluid(fl) && (y == 1u || y == (unsigned int)(NY - 2))) {
|
||||
#if Y_WALL_BC == 1
|
||||
apply_wall_freeslip_d2q9_y_pull(y, f, fi, k);
|
||||
#else
|
||||
apply_wall_bb_d2q9_y_pull(y, f, fi, k);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Collision
|
||||
// Same donor/ghost warning as the double-buffer path: for the local Zou-He
|
||||
// inlet, the ghost-node state must be regularized before the next pull uses
|
||||
@ -138,6 +154,11 @@ void EsoPullStep(
|
||||
if (is_obstacle(fl) && !is_curved(fl))
|
||||
bounce_back_swap(f);
|
||||
|
||||
// ---- y-wall adjacent row correction (3D) ----
|
||||
// Same logic as the D2Q9 path — see note above.
|
||||
if (is_fluid(fl) && (y == 1u || y == (unsigned int)(NY - 2)))
|
||||
apply_wall_bb_d3q19_y_pull(y, f, fi, k);
|
||||
|
||||
const bool collide_inlet_ghost = is_inlet(fl) && interior_y
|
||||
&& inlet_scheme_uses_post_collision_ghost();
|
||||
if (is_fluid(fl) || collide_inlet_ghost) {
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
# CelerisLab/simulation.py
|
||||
"""
|
||||
Top-level orchestrator — assembles LBM field, stepper, body manager,
|
||||
Top-level orchestrator -- assembles LBM field, stepper, body manager,
|
||||
and CUDA context into a single coherent simulation.
|
||||
|
||||
Usage::
|
||||
|
||||
sim = Simulation("configs/config_lbm.json")
|
||||
sim.add_cylinder((100, 50), radius=10)
|
||||
sim.add_body("circle", center=(50, 50), radius=10)
|
||||
sim.initialize()
|
||||
sim.run(1000)
|
||||
macro = sim.get_macroscopic() # {"rho": ..., "ux": ..., "uy": ...}
|
||||
force = sim.read_force(0) # force on body 0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Optional, Tuple, Any
|
||||
|
||||
import os
|
||||
@ -24,7 +27,7 @@ from .cuda.context import CudaContext
|
||||
from .cuda import compiler_v2 as compiler
|
||||
from .lbm.field import LBMField
|
||||
from .lbm.stepper import LBMStepper
|
||||
from .body.objects import Cylinder, Sensor, SimObject
|
||||
from .body.objects import SimObject
|
||||
from .body.manager import ObjectManager
|
||||
|
||||
|
||||
@ -39,6 +42,9 @@ class Simulation:
|
||||
lbm_config_path: Optional[str] = None,
|
||||
body_config_path: Optional[str] = None,
|
||||
device_id: int = 0):
|
||||
# Internal stream for async DTOH coordination
|
||||
self._stream: Optional[cuda.Stream] = None
|
||||
|
||||
# Load configs
|
||||
self.lbm_cfg = load_lbm_config(lbm_config_path)
|
||||
self.body_cfg = load_body_config(body_config_path)
|
||||
@ -68,18 +74,64 @@ class Simulation:
|
||||
self.recompile()
|
||||
self._assert_object_count_contract(expected_count=self.bodies.count)
|
||||
|
||||
# -- Stream access -------------------------------------------------------
|
||||
@property
|
||||
def stream(self) -> cuda.Stream:
|
||||
"""Internal CUDA stream for async DTOH coordination."""
|
||||
if self._stream is None:
|
||||
self._stream = cuda.Stream()
|
||||
return self._stream
|
||||
|
||||
# -- Object management ---------------------------------------------------
|
||||
def add_body(self, type: str = "circle", center=None,
|
||||
radius: float = 0.0, **kwargs) -> int:
|
||||
"""Add a simulation body. Returns body_id.
|
||||
|
||||
Args:
|
||||
type: ``"circle"`` or ``"sensor"`` (future: ``"polygon"``, ``"mesh"``).
|
||||
center: (x, y) center coordinates (lattice units).
|
||||
radius: body radius (lattice units).
|
||||
**kwargs: passed to the geometry constructor.
|
||||
|
||||
Returns:
|
||||
int body_id.
|
||||
"""
|
||||
type_lower = str(type).strip().lower()
|
||||
|
||||
if type_lower == "circle":
|
||||
from .body.geometry.circle import CircleGeometry
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
geom = CircleGeometry(cx, cy, radius)
|
||||
obj = SimObject(obj_id=-1, geometry=geom,
|
||||
center=center, radius=radius,
|
||||
is_sensor=False)
|
||||
elif type_lower == "sensor":
|
||||
from .body.geometry.circle import CircleGeometry
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
geom = CircleGeometry(cx, cy, radius)
|
||||
obj = SimObject(obj_id=-1, geometry=geom,
|
||||
center=center, radius=radius,
|
||||
is_sensor=True)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown body type '{type}'. "
|
||||
"Supported: 'circle', 'sensor'."
|
||||
)
|
||||
return self.bodies.add(obj)
|
||||
|
||||
# -- Legacy convenience (keep for backward compat) -----------------------
|
||||
def add_cylinder(self, center: Tuple[float, ...],
|
||||
radius: float) -> int:
|
||||
obj = Cylinder(obj_id=-1, center=center, radius=radius)
|
||||
return self.bodies.add(obj)
|
||||
"""Add a cylinder body (convenience wrapper around add_body)."""
|
||||
return self.add_body("circle", center=center, radius=radius)
|
||||
|
||||
def add_sensor(self, center: Tuple[float, ...],
|
||||
radius: float) -> int:
|
||||
obj = Sensor(obj_id=-1, center=center, radius=radius)
|
||||
return self.bodies.add(obj)
|
||||
"""Add a sensor body (convenience wrapper around add_body)."""
|
||||
return self.add_body("sensor", center=center, radius=radius)
|
||||
|
||||
def add_object(self, obj: SimObject) -> int:
|
||||
"""Add a pre-configured SimObject directly."""
|
||||
return self.bodies.add(obj)
|
||||
|
||||
def _load_objects_from_body_config(self) -> None:
|
||||
@ -91,24 +143,75 @@ class Simulation:
|
||||
omega = spec.get("omega", None)
|
||||
|
||||
if obj_type == "":
|
||||
raise ValueError("Body config object is missing required key 'type'.")
|
||||
raise ValueError(
|
||||
"Body config object is missing required key 'type'.")
|
||||
if len(center) not in (2, 3):
|
||||
raise ValueError(
|
||||
f"Body config object '{obj_type}' requires center with 2 or 3 entries."
|
||||
f"Body config object '{obj_type}' requires center "
|
||||
"with 2 or 3 entries."
|
||||
)
|
||||
|
||||
if obj_type == "cylinder":
|
||||
body_id = self.add_cylinder(center=center, radius=radius)
|
||||
body_id = self.add_body("circle", center=center, radius=radius)
|
||||
if omega is not None:
|
||||
self.bodies.get(body_id).state.omega = float(omega)
|
||||
elif obj_type == "sensor":
|
||||
self.add_sensor(center=center, radius=radius)
|
||||
self.add_body("sensor", center=center, radius=radius)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported body config object type '{obj_type}'. "
|
||||
"Supported types: cylinder, sensor."
|
||||
)
|
||||
|
||||
# -- Runtime control -----------------------------------------------------
|
||||
def set_body(self, id: int, omega: float = None,
|
||||
vx: float = None, vy: float = None) -> None:
|
||||
"""Set runtime body state. Updates are implicitly uploaded to GPU.
|
||||
|
||||
Args:
|
||||
id: body_id from add_body().
|
||||
omega: rotation speed (lattice units). ``None`` = no change.
|
||||
vx: translation velocity x (future). ``None`` = no change.
|
||||
vy: translation velocity y (future). ``None`` = no change.
|
||||
"""
|
||||
obj = self.bodies.get(id)
|
||||
changed = False
|
||||
if omega is not None:
|
||||
obj.state.omega = float(omega)
|
||||
changed = True
|
||||
if vx is not None:
|
||||
obj.state.vx = float(vx)
|
||||
changed = True
|
||||
if vy is not None:
|
||||
obj.state.vy = float(vy)
|
||||
changed = True
|
||||
if changed:
|
||||
self.bodies._refresh_action_from_objects()
|
||||
if self.bodies.action_gpu is not None:
|
||||
cuda.memcpy_htod(self.bodies.action_gpu, self.bodies.action)
|
||||
|
||||
# -- Telemetry readback --------------------------------------------------
|
||||
def read_force(self, id: int) -> np.ndarray:
|
||||
"""Download and return the force vector on body *id* (async stream, synced)."""
|
||||
self._stream_obs_download()
|
||||
return self.bodies.read_force(id)
|
||||
|
||||
def read_torque(self, id: int) -> np.ndarray:
|
||||
"""Download and return the torque on body *id* (async stream, synced)."""
|
||||
self._stream_obs_download()
|
||||
return self.bodies.read_torque(id)
|
||||
|
||||
def read_sensor(self, id: int) -> np.ndarray:
|
||||
"""Download and return the sensor reading for body *id* (async stream, synced)."""
|
||||
self._stream_obs_download()
|
||||
return self.bodies.read_sensor(id)
|
||||
|
||||
def _stream_obs_download(self) -> None:
|
||||
"""Async obs download on internal stream, then synchronize."""
|
||||
if self.bodies.obs_pinned is not None:
|
||||
self.bodies.download_obs_full_async(self.stream)
|
||||
self.stream.synchronize()
|
||||
|
||||
# -- Compilation ---------------------------------------------------------
|
||||
def recompile(self):
|
||||
"""Re-generate config headers and recompile kernel.
|
||||
@ -138,7 +241,8 @@ class Simulation:
|
||||
def initialize(self):
|
||||
"""Initialize flow field and sync objects to GPU."""
|
||||
# Recompile if objects were added after construction.
|
||||
if self.bodies.count > 0 and self._read_generated_n_objects() != self.bodies.count:
|
||||
if self.bodies.count > 0 and \
|
||||
self._read_generated_n_objects() != self.bodies.count:
|
||||
self.recompile()
|
||||
|
||||
self.field.flag = self.field.build_channel_flags()
|
||||
@ -263,25 +367,33 @@ class Simulation:
|
||||
line = line.strip()
|
||||
if line.startswith("#define N_OBJS"):
|
||||
return int(line.split()[-1])
|
||||
raise RuntimeError(f"N_OBJS not found in generated header: {cfg_path}")
|
||||
raise RuntimeError(
|
||||
f"N_OBJS not found in generated header: {cfg_path}")
|
||||
|
||||
def _assert_object_count_contract(self, expected_count: int | None = None) -> None:
|
||||
def _assert_object_count_contract(
|
||||
self, expected_count: int | None = None
|
||||
) -> None:
|
||||
"""Ensure generated compile-time N_OBJS matches runtime object count."""
|
||||
runtime_count = self.bodies.count if expected_count is None else int(expected_count)
|
||||
runtime_count = (
|
||||
self.bodies.count if expected_count is None
|
||||
else int(expected_count)
|
||||
)
|
||||
compiled_count = self._read_generated_n_objects()
|
||||
if compiled_count != runtime_count:
|
||||
raise RuntimeError(
|
||||
"Object count contract mismatch: generated N_OBJS="
|
||||
f"{compiled_count}, runtime count={runtime_count}. Recompile required."
|
||||
f"{compiled_count}, runtime count={runtime_count}. "
|
||||
"Recompile required."
|
||||
)
|
||||
|
||||
def _assert_obs_layout_contract(self) -> None:
|
||||
"""Ensure host obs layout matches runtime body count and generated contract."""
|
||||
"""Ensure host obs layout matches runtime body count and contract."""
|
||||
lay = compiler.obs_layout(self.lbm_cfg.dim, self.bodies.count)
|
||||
mgr = self.bodies
|
||||
if mgr.obs_n_slots != lay.n_slots:
|
||||
raise RuntimeError(
|
||||
f"obs layout mismatch: n_slots={mgr.obs_n_slots}, expected={lay.n_slots}."
|
||||
"obs layout mismatch: n_slots="
|
||||
f"{mgr.obs_n_slots}, expected={lay.n_slots}."
|
||||
)
|
||||
if (
|
||||
mgr.slot_stride_floats != lay.slot_stride_floats
|
||||
@ -290,11 +402,12 @@ class Simulation:
|
||||
or mgr.obs_total_floats != lay.total_floats
|
||||
):
|
||||
raise RuntimeError(
|
||||
"obs layout mismatch between runtime buffers and expected layout "
|
||||
f"for dim={self.lbm_cfg.dim}, n_objects={self.bodies.count}."
|
||||
"obs layout mismatch between runtime buffers and expected "
|
||||
f"layout for dim={self.lbm_cfg.dim}, "
|
||||
f"n_objects={self.bodies.count}."
|
||||
)
|
||||
|
||||
def _assert_runtime_contracts(self) -> None:
|
||||
"""Validate object-count and packed-observation contracts before stepping."""
|
||||
"""Validate object-count and obs layout contracts before stepping."""
|
||||
self._assert_object_count_contract()
|
||||
self._assert_obs_layout_contract()
|
||||
self._assert_obs_layout_contract()
|
||||
|
||||
325
tests/audit/body重构笔记.md
Normal file
325
tests/audit/body重构笔记.md
Normal file
@ -0,0 +1,325 @@
|
||||
## 目标
|
||||
|
||||
第二阶段不再以补丁式修 bug 为主,而是重建 `body` 与 `LBM` 之间的模块边界,形成可持续扩展到 Bouzidi、IBM、粒子和多相耦合的骨架。重构后的代码需要保持短文件、清晰职责、显式契约,并避免几何语义直接渗入 CUDA 核心计算链路。
|
||||
|
||||
## 第一阶段与第二阶段的边界
|
||||
|
||||
### 第一阶段
|
||||
|
||||
目标是验证当前修复后的代码没有恶化,并保住最基本执行链路。
|
||||
|
||||
- 能稳定计算流场
|
||||
- 能读取力与力矩
|
||||
- Bouzidi 路径不出现新的时序性错误
|
||||
- 初始化、flag、object overlay 不再互相污染
|
||||
- 现有接口不做大改,只修运行期 bug 和明显契约错误
|
||||
|
||||
### 第二阶段
|
||||
|
||||
目标是结构性重构,而不是继续在旧链路上叠加特例。
|
||||
|
||||
- 重构 `body`
|
||||
- 重构 curved boundary 数据链
|
||||
- 为 IBM 预留统一入口
|
||||
- 为简单几何和离散几何建立同一中间表示
|
||||
- 为未来多相与多物理场保留清晰耦合面
|
||||
|
||||
## 当前架构的核心问题
|
||||
|
||||
### 主要问题
|
||||
|
||||
| 模块 | 当前问题 | 后果 |
|
||||
|---|---|---|
|
||||
| `body` | 几何、flag overlay、compact list、action、obs、coupling 混在一起 | 任一功能扩展都会牵动整条链路 |
|
||||
| `objects.py` | 几何对象直接生成 Bouzidi 专用数据 | 简单几何和离散几何无法共享同一接口 |
|
||||
| `curved_boundary` | 几何解释、边界格式、移动壁修正、力提取绑在同一层 | 替换边界方法成本高 |
|
||||
| `ObjectManager` | 过度集中,已经接近万能管理器 | 可读性下降,后续只能继续膨胀 |
|
||||
| host-device contract | 分散在多个文件,缺少统一定义 | 调试困难,容易出现隐式耦合 |
|
||||
|
||||
### 本质判断
|
||||
|
||||
当前最大问题不是公式本身,而是缺少稳定的中间层。几何对象、边界方法、运行时打包目前没有被拆开,导致代码很难既简洁又可扩展。
|
||||
|
||||
结合本轮修 bug 的经验,再补一条判断:很多问题不是几何本身出错,而是 donor、fallback、time layer 这类数值语义散落在不同文件中,读代码时必须来回跳转才能确认。当前项目更适合把这些语义集中写进少数注释位置,而不是继续加更多隐式保护逻辑。
|
||||
|
||||
## 第二阶段的目标架构
|
||||
|
||||
### 顶层模块
|
||||
|
||||
保持两大物理模块:
|
||||
|
||||
- `LBM`
|
||||
- `body`
|
||||
|
||||
但它们之间不直接互相了解实现细节,而通过显式中间数据结构耦合。
|
||||
|
||||
### 推荐分层
|
||||
|
||||
| 层级 | 职责 | 不应负责 |
|
||||
|---|---|---|
|
||||
| `simulation` | 装配模块,定义推进顺序 | 几何处理,边界公式细节 |
|
||||
| `body` | 几何、刚体状态、预处理、力回收 | DDF 操作,LBM 核心算法 |
|
||||
| `lbm` | 格子流体、collision、streaming、boundary operator | 几何来源,刚体业务语义 |
|
||||
| `coupling` | cut-link、IBM marker、body-fluid 数据交换 | 具体几何类定义,具体 collision 实现 |
|
||||
|
||||
## 推荐的 body 内部结构
|
||||
|
||||
### 目标
|
||||
|
||||
`body` 应只表达拉格朗日对象和几何处理,不直接承载 LBM 业务逻辑。
|
||||
|
||||
补充边界:`body` 负责管理对象并产出统一 cut-link 几何记录,`lbm` 只消费 cut-link 做数值计算。对象类型区分、几何来源、以及“该记录来自圆柱还是离散几何”都不应进入 LBM kernel 视野。
|
||||
|
||||
### 推荐文件树
|
||||
|
||||
```text
|
||||
body
|
||||
geometry
|
||||
base
|
||||
circle
|
||||
sphere
|
||||
polygon
|
||||
mesh
|
||||
state
|
||||
rigid_body_state
|
||||
particle_state
|
||||
preprocess
|
||||
flag_overlay
|
||||
cut_links
|
||||
ibm_markers
|
||||
sensors
|
||||
runtime
|
||||
action_buffer
|
||||
telemetry_buffer
|
||||
registry
|
||||
coupling
|
||||
wall_velocity
|
||||
force_torque
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- `geometry` 只回答几何问题
|
||||
- `state` 只保存状态量
|
||||
- `preprocess` 负责把几何投影到欧拉网格
|
||||
- `runtime` 负责 GPU 上传与 buffer 管理
|
||||
- `coupling` 负责 body 与 fluid 的交换规则
|
||||
|
||||
进一步要求:
|
||||
|
||||
- `geometry` 不直接生成 Bouzidi 专用 SoA
|
||||
- `preprocess` 先产出统一 cut-link 结果,再由更薄的一层做运行时打包
|
||||
- `runtime` 不回头参与几何判断
|
||||
- 单文件保持简短,避免再出现一个文件同时含几何、打包、上传、观测解释四类职责
|
||||
|
||||
## 推荐的 LBM 边界
|
||||
|
||||
### LBM 应该看到什么
|
||||
|
||||
LBM 只应看到这些输入:
|
||||
|
||||
- `flag`
|
||||
- `cut-link records`
|
||||
- `IBM marker records`
|
||||
- `runtime params`
|
||||
- `obs buffers`
|
||||
|
||||
LBM 不应知道:
|
||||
|
||||
- 该 link 来自圆柱还是三角网格
|
||||
- 该对象是粒子还是障碍物
|
||||
- 该边界记录由哪种 host 几何算法产生
|
||||
|
||||
## curved boundary 的重构方向
|
||||
|
||||
### 核心原则
|
||||
|
||||
不要把 curved boundary 等同于 Bouzidi。
|
||||
|
||||
需要拆成三个维度:
|
||||
|
||||
- 几何表示
|
||||
- 边界处理方法
|
||||
- 运动模型
|
||||
|
||||
补充一条实现原则:donor、fallback、time layer 等关键数值语义,优先通过集中注释写清楚,不额外引入很多“自动保证语义”的复杂逻辑。当前项目更优先保持代码短、直、可读。
|
||||
|
||||
### 建议的统一中间表示
|
||||
|
||||
定义通用 `cut-link record`,至少包含:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `fluid_idx` | 流体格点索引 |
|
||||
| `dir` | 指向边界的格子方向 |
|
||||
| `q` | 交点沿链路的位置 |
|
||||
| `body_id` | 所属物体 |
|
||||
| `hit_point` | 壁面交点 |
|
||||
| `lever_arm` | 相对参考点的力臂 |
|
||||
| `normal` | 壁面法向 |
|
||||
| `motion_tag` | 静止、平移、旋转等 |
|
||||
| `scheme_tag` | Bouzidi、half-way、未来 TRT-compatible scheme |
|
||||
| `fallback_tag` | donor 非法时的退化方式 |
|
||||
|
||||
补充约束:
|
||||
|
||||
- `cut-link record` 只表达几何命中结果与最小运行时字段,不直接长成某一种 kernel 专用格式
|
||||
- donor 的来源与时间层语义不额外做复杂自动推断,靠集中注释写清楚
|
||||
- 记录字段命名要直接对应 kernel 使用含义,避免同一字段在不同文件中有不同解释
|
||||
|
||||
### 这样做的好处
|
||||
|
||||
- 圆形和离散几何可以输出同一种记录
|
||||
- Bouzidi 与 IBM 可以共享一部分几何预处理
|
||||
- boundary kernel 只消费记录,不关心几何来源
|
||||
- 以后替换边界方法时,不必回改对象类
|
||||
- donor、fallback、hit-point 这类契约可以在少数固定注释位置集中说明,而不是散落在对象类和 kernel 两侧
|
||||
|
||||
## IBM 的预留方式
|
||||
|
||||
IBM 不应和 Bouzidi 混成一条链,而应与 cut-link 平行。
|
||||
|
||||
建议另设统一 `marker record`,用于:
|
||||
|
||||
- 插值点位置
|
||||
- 支撑域格点
|
||||
- 权重
|
||||
- 与刚体的归属关系
|
||||
|
||||
这样未来可以并存:
|
||||
|
||||
- cut-link boundary
|
||||
- IBM boundary
|
||||
- 混合策略
|
||||
|
||||
## 运行时契约的建议
|
||||
|
||||
### 必须显式化的契约
|
||||
|
||||
应把 host-device contract 从各文件中收口,单独维护。
|
||||
|
||||
建议集中定义:
|
||||
|
||||
- action buffer layout
|
||||
- telemetry buffer layout
|
||||
- cut-link record layout
|
||||
- marker record layout
|
||||
- force torque sign convention
|
||||
- body velocity contract
|
||||
|
||||
### 运动状态契约
|
||||
|
||||
即使短期只做 2D 圆柱旋转,也建议按最终形式设计:
|
||||
|
||||
| 状态 | 建议字段 |
|
||||
|---|---|
|
||||
| 平移 | `vx vy vz` |
|
||||
| 角运动 | `wx wy wz` 或 2D 的 `omega` 特化视图 |
|
||||
| 参考点 | `cx cy cz` |
|
||||
| 姿态 | `theta` 或旋转表示 |
|
||||
|
||||
不要再把 3D 路径固化为“只读一个 z 轴 `omega`”。
|
||||
|
||||
## 第二阶段推荐顺序
|
||||
|
||||
### 阶段 2A
|
||||
|
||||
先把职责边界拆开,不追求新功能。
|
||||
|
||||
- 拆 `ObjectManager`
|
||||
- 建立 `BodyRegistry`
|
||||
- 建立 clean `runtime buffer` 层
|
||||
- 建立单独的 geometry preprocess 层
|
||||
- 把“对象管理”和“cut-link 产出”分开
|
||||
|
||||
验收标准:
|
||||
|
||||
- `body` 不再直接知道具体 LBM kernel 调度
|
||||
- `ObjectManager` 不再承担几何、obs、state、flag 全部职责
|
||||
- `objects.py` 不再直接输出某一种 boundary method 专用打包格式
|
||||
|
||||
### 阶段 2B
|
||||
|
||||
重构 curved boundary 数据链。
|
||||
|
||||
- 定义统一 `cut-link record`
|
||||
- 让圆柱先输出新 record
|
||||
- 让 Bouzidi kernel 消费新 record
|
||||
- 把现有 `CurvedLinkSoA` 从“Bouzidi 专用”改成“通用 cut-link buffer`
|
||||
- donor 与 fallback 的契约集中写入注释,不额外增加复杂语义保护代码
|
||||
|
||||
验收标准:
|
||||
|
||||
- kernel 不需要知道几何来源
|
||||
- host 侧可以替换 cut-link builder 而不改 kernel 接口
|
||||
- 读 builder 与 kernel 时,不需要跨很多文件才能理解 donor 和 fallback 的基本语义
|
||||
|
||||
### 阶段 2C
|
||||
|
||||
为 IBM 留入口。
|
||||
|
||||
- 定义 `marker record`
|
||||
- 预留独立 preprocess 和 runtime buffer
|
||||
- 暂不实现完整 IBM 细节,只把架构位置留好
|
||||
|
||||
验收标准:
|
||||
|
||||
- IBM 不需要重写 body 骨架
|
||||
- IBM 与 Bouzidi 可以共存于同一 body 系统
|
||||
|
||||
### 阶段 2D
|
||||
|
||||
补文档与契约说明。
|
||||
|
||||
- 当前能力
|
||||
- 预留能力
|
||||
- 不支持项
|
||||
- 2D 与 3D 差异
|
||||
- TRT 与 plain Bouzidi 的限制
|
||||
|
||||
## 不建议做的事
|
||||
|
||||
- 不要继续强化万能 `ObjectManager`
|
||||
- 不要让几何对象直接生成某一种边界格式专用 SoA
|
||||
- 不要把 curved boundary 和 Bouzidi 永久绑定
|
||||
- 不要在 3D 运动契约上继续堆占位特例
|
||||
- 不要在第二阶段把多相、IBM、粒子一起全做完
|
||||
- 不要为了“自动保证语义”继续增加很多隐式逻辑分支,优先用集中注释把契约写清楚
|
||||
- 不要引入必须跨很多文件来回跳转才能理解的 host-kernel 组织方式
|
||||
|
||||
## 第二阶段完成后的理想状态
|
||||
|
||||
### 代码层面
|
||||
|
||||
- 文件更短
|
||||
- 模块职责更单一
|
||||
- host-device contract 更显式
|
||||
- kernel 更专注于数值操作
|
||||
- geometry 与 boundary method 解耦
|
||||
|
||||
### 扩展层面
|
||||
|
||||
后续可自然扩展到:
|
||||
|
||||
- 离散几何 cut-link
|
||||
- IBM marker 链路
|
||||
- 粒子和刚体共用 body runtime
|
||||
- 多相流对 body 的额外 coupling
|
||||
|
||||
### 维护层面
|
||||
|
||||
后续新增功能时,优先在对应层增加文件,而不是回到单个 manager 中继续堆逻辑。
|
||||
|
||||
## 建议的执行方式
|
||||
|
||||
第二阶段执行前,先固定一个简短设计约束:
|
||||
|
||||
- 单文件不过长
|
||||
- 新增模块必须有明确职责说明
|
||||
- 任何 host-device 数据结构必须有集中定义
|
||||
- 新接口先定义契约,再写 kernel
|
||||
- 能通过新增 builder 解决的问题,不要回写到 geometry 类中
|
||||
- donor、fallback、time-layer 等关键数值语义必须在少数固定位置集中注释说明
|
||||
- 优先保持文件短和职责单一,不为“保险”堆太多诊断与保护代码
|
||||
|
||||
这个阶段的核心产出不是新功能,而是一个能长期承载新功能的骨架。
|
||||
209
tests/audit/审计.md
Normal file
209
tests/audit/审计.md
Normal file
@ -0,0 +1,209 @@
|
||||
## 审计结论(第二轮)
|
||||
|
||||
本轮审计的视角与上一轮不同。上一轮聚焦"主链路上是否有显式 bug"(找到了 16 项)。本轮审计的四个维度:
|
||||
|
||||
| 维度 | 上一轮 | 本轮 |
|
||||
|---|---|---|
|
||||
| 主链路正确性 | 找显式 bug | 确认修补正确、无回归 |
|
||||
| 代码架构质量 | 少量提及 | 系统评估 Python 层职责分离 |
|
||||
| 新代码 | 不存在 | streakline.py 全篇 + test runners |
|
||||
| 跨层一致性 | 仅一处 | 系统性检查 config → compiler → kernel → docs 同步 |
|
||||
|
||||
核心结论:**上次 16 项修补经逐条审查确认正确,无回归。**
|
||||
|
||||
### 第二轮修改完成状态
|
||||
|
||||
根据审计发现,已完成以下修改:
|
||||
|
||||
| 修改项 | 状态 |
|
||||
|---|---|
|
||||
| EsoPull 添加 y=1/NY-2 半格 bounce-back 修正(D2Q9 + D3Q19) | ✅ 已实施 |
|
||||
| `macro.cuh` diagnostic 函数标注 | ✅ 已标注 `// --- Diagnostic only ---` |
|
||||
| `config.py` `omega_max` 默认值 1.99 → 1.96 | ✅ 已修改 |
|
||||
| `BC_MOVING`/`BC_PERIODIC` 代码注释说明 | ✅ 已添加 TODO 注释 |
|
||||
| Streakline 模块重构:779 行单文件 → `common/streakline/` 子包 (5 文件) | ✅ 已完成 |
|
||||
| `run_kan99b_streakline.py` 更新为新 API | ✅ 已完成 |
|
||||
| `run_exp_ctrl_matrix_streakline.py` 更新为新 API | ✅ 已完成 |
|
||||
| `render_vorticity_field` 移到 `common/render.py` | ✅ 已完成 |
|
||||
| `ParticleTrailSet` 移到 `common/pathline.py` | ✅ 已完成 |
|
||||
| 向后兼容 shim `common/streakline.py` | ✅ 已创建(带 DeprecationWarning) |
|
||||
|
||||
---
|
||||
|
||||
## 状态说明
|
||||
|
||||
- `[已确认]` 经代码审查确认正确
|
||||
- `[无法确认: 需运行验证]` 需数值算例确认,不能单靠读代码定论
|
||||
- `[新发现]` 本轮审计首次发现
|
||||
- `[已修复]` 已实施修改
|
||||
- `[保留说明]` 当前不修,但需在代码或文档中明确限制
|
||||
|
||||
---
|
||||
|
||||
## 第一轮审计修补确认
|
||||
|
||||
逐一确认 16 项"已解决"修补在代码中的真实状态。**全部 [已确认],无回归。**
|
||||
|
||||
| 问题 | 结论 | 关键文件:行号 |
|
||||
|---|---|---|
|
||||
| `lbm/__init__.py` 导出错误 | 已确认 | 当前导出真实存在的 `add_vortex` |
|
||||
| forcing 主链路 + 预因子不一致 | 已确认 — 三模型统一使用 `c_tau = 1-omega/2` | `operators/collision_srt.cuh:15`, `collision_trt.cuh:35`, `collision_mrt.cuh:41/116` |
|
||||
| TRT outlet NEQ 重构未补齐 | 已确认 — COMPILE_MODEL==0\|1 时全分布 damped NEQ | `boundary/outlet/pressure_neq.cuh:45-51` (D2Q9) |
|
||||
| `add_vortex()` 动量当速度 | 已确认 — `ux = sum(f*cx) / rho_safe` | `lbm/initializers.py:57-58` |
|
||||
| Sensor 面积归一化 | 已确认 — ObjectManager 层已提供 | `body/manager.py` |
|
||||
| `sync_to_gpu()` 重置非流体 | 已确认 — 已收缩为只覆盖 obstacle interior | `body/manager.py` |
|
||||
| curved donor 合法性 | 已确认 — 扩展到实际 domain flags | `body/objects.py` `_donor_is_fluid` |
|
||||
| curved Bouzidi 时序 | 已确认 — 步前写入 obstacle source slot | `lbm/stepper.py:70-71` → `step/aux_kernels.cu:14` |
|
||||
| `q >= 0.5` 分支读错时间层 | 已确认 — 读 `load_ddf(fi, ...)` 即同一步 post-collision | `boundary/curved_boundary.cuh:73-75` |
|
||||
| moving wall 修正未按 q 分支 | 已确认 — 分三路:fallback / q<0.5 / q>=0.5 | `boundary/curved_boundary.cuh:30-43` |
|
||||
| 初始化链路 flag 叠加顺序 | 已确认 — obstacle overlay → init kernel preserve → equilibrium | `step/init_flow.cu:48-52`, `lbm/stepper.py:43-54` |
|
||||
| `config_body.json` 未进入初始化 | 已确认 — Simulation 已消费 | `simulation.py` |
|
||||
| inlet `U0` 语义 | 已确认 — 已补充截面平均速度注释 | `configs/CONFIG.md`, `README` |
|
||||
|
||||
---
|
||||
|
||||
## 本轮发现与处理
|
||||
|
||||
### CUDA Kernel 层
|
||||
|
||||
| 严重度 | 问题 | 文件:行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[新发现]** [高] | **`BC_MOVING` 与 `BC_PERIODIC` 标记未在 step kernel 中分发。** `core/flags.cuh` 定义了两种标记,但 step kernel 中没有 `is_moving()` 或 `is_periodic()` 分支。设了这两种标记的 cell 会静默 fall through 到 `bounce_back_swap()`。 | `step/one_step_double.cu`, `step/one_step_esopull.cu` | **[保留说明]** 已添加 TODO 注释,暂不实现 |
|
||||
| **[新发现]** [高] | **EsoPull 缺少 y=1/NY-2 的显式半格 bounce-back 修正。** Double-buffer 路径有该修正而 EsoPull 无。 | `step/one_step_esopull.cu` | **[已修复]** 已添加 D2Q9 和 D3Q19 分支,标注了未来可能需要更精确方案 |
|
||||
| **[新发现]** [中] | **`USE_DDF_SHIFTING` 路径完整性存疑。** 审查后确认:`macro.cuh` 和 `init_flow.cu` 已处理 shifted,各 collision/curved operator 通过 `load_ddf`/`store_ddf` 抽象层自动适配。**实际已完整,无需修改。** | — | **[已确认无需处理]** |
|
||||
| **[新发现]** [低] | **`compute_pressure` 与 `compute_pressure_perturbation` 未标注诊断用途。** | `operators/macro.cuh:160-166` | **[已修复]** 已标注 `// --- Diagnostic only ---` |
|
||||
| **[新发现]** [低] | **Curved/Sensor kernel 无运行时下标越界保护。** 完全依赖 host 侧验证。 | `step/aux_kernels.cu:26-99` | **[保留说明]** 设计选择:不引入多余合法性检查 |
|
||||
|
||||
### 第一轮遗留待验证项复查
|
||||
|
||||
| 待验证项 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| **MRT D2Q9 moment transform/inverse** | **[无法确认: 需运行验证]** | 方向索引与 paired ordering 自洽,moment 投影物理正确。但全部系数的数值精度需在 Poiseuille 或衰减涡算例中验证。 |
|
||||
| **EsoPull 邻壁 vs double-buffer 一致性** | **[已修复]** | 已添加 y=1/NY-2 反弹修正。需运行验证确认。 |
|
||||
| **Force 提取与 host 侧归一化** | **[无需处理]** | Cd/Cl 管道经确认语义一致。 |
|
||||
| **Plain linear Bouzidi / TRT 不兼容** | **[保留说明]** | 注释已存在且准确,引用了 [Gin08b]。无方法级替代方案。 |
|
||||
|
||||
---
|
||||
|
||||
## 架构与设计审计
|
||||
|
||||
### Streakline 模块重构
|
||||
|
||||
**旧 `common/streakline.py` (779 行) 已被拆解为 `common/streakline/` 子包:**
|
||||
|
||||
```
|
||||
src/CelerisLab/common/
|
||||
streakline/ 后处理子包
|
||||
__init__.py 导出 Streakline, ReleaseConfig, IntegratorConfig
|
||||
_config.py 配置 dataclass + FlowFrame 内部类
|
||||
_integrate.py 核心积分引擎 (RK4 + 时空插值)
|
||||
_render.py 密度图渲染 (render_density)
|
||||
_streakline.py Streakline 类 (被动消费者)
|
||||
render.py 涡度计算与渲染 (从旧 streakline 移出)
|
||||
pathline.py ParticleTrailSet + render_trails (从旧 streakline 移出)
|
||||
preprocess.py 增加 cylinders_from_triangle_layout
|
||||
```
|
||||
|
||||
**核心设计变更:**
|
||||
|
||||
| 旧设计 | 新设计 |
|
||||
|---|---|
|
||||
| `run_streakline_online(sim, ...)` 控制仿真循环 | `Streakline.observe(ux, uy, step)` 被动接收帧 |
|
||||
| `run_streakline_offline(frames, ...)` 批处理帧列表 | 移除(无需求) |
|
||||
| `render_streakline_density(positions, ages, ...)` 15 参数 | `Streakline.render(path)` 内部维护状态 |
|
||||
| `ParticleTrailSet` 与正确实现混在同一文件 | 移到独立 `pathline.py` |
|
||||
| `render_vorticity_field` 与粒子跟踪无关 | 移到独立 `render.py` |
|
||||
| `gaussian_blur2d` 用 `np.apply_along_axis` | 改为显式 `for` 循环 + `np.convolve` |
|
||||
| `minimal_axes=True/False` 分支重复 | 合并为单路径 `_save_minimal_image` |
|
||||
|
||||
**用法示例:**
|
||||
```python
|
||||
streak = Streakline(release_points=..., nx=nx, ny=ny, cylinders=...)
|
||||
# 在自己的仿真循环中:
|
||||
macro = sim.get_macroscopic()
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
# ...同时可以做 body actions、checkpoint、其他模块...
|
||||
streak.render("output.png")
|
||||
```
|
||||
|
||||
### Body 模块
|
||||
|
||||
| 严重度 | 问题 | 行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[架构]** | **`ObjectManager` 承担 8 种以上职责。** `sync_to_gpu()` 单枪匹马编排了全部流程。 | `body/manager.py` (423 行) | **[暂不处理]** |
|
||||
| **[架构]** | **`Cylinder.get_curved_list()` ~180 行包含 7 种职责。** 内嵌函数无法单独测试。 | `body/objects.py:110-291` | **[暂不处理]** |
|
||||
| **[架构]** | **自上一轮审计以来 body 模块重构进展为零。** | 全部 | **[暂不处理]** |
|
||||
|
||||
### Config/API 层
|
||||
|
||||
| 严重度 | 问题 | 行号 | 处理 |
|
||||
|---|---|---|---|
|
||||
| **[已确认]** | **FP16C 被正确拒绝。** | `config.py:119-123` | ✅ |
|
||||
| **[已确认]** | **28/28 参数通过四层一致性检查。** | B4a 参数追踪表 | ✅ |
|
||||
| **[已确认]** | **OBS 布局四层完全匹配。** | B4d 对比表 | ✅ |
|
||||
| **[架构/低]** | **CONFIG.md 写"建议整除"而非"必须"。** 代码用 ceiling division。 | `configs/CONFIG.md:13`, `lbm/stepper.py:272-274` | **[保留说明]** |
|
||||
| **[已修复]** | **`LBMConfig` 默认 `omega_max=1.99` → `1.96`,与 JSON/CONFIG 一致。** | `config.py:84` | ✅ |
|
||||
|
||||
### 跨层 Flag/常量同步
|
||||
|
||||
| 问题 | 状态 |
|
||||
|---|---|
|
||||
| **`FLAG_*` 常量 Python ↔ CUDA 同步** | **[已确认]** 全部一致 |
|
||||
| **`LBMParams` struct 布局耦合** | **[保留说明]** |
|
||||
| **V_TAYLOR 硬编码 1** | **[保留说明]** |
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖率审计
|
||||
|
||||
### Validation runner 与文档一致性
|
||||
|
||||
| 结论 | 项目 | 说明 |
|
||||
|---|---|---|
|
||||
| **[通过]** | Sah04 runner S1-S4 | 四个锚点全部定义并运行 |
|
||||
| **[缺口]** | Sah04 runner `--u-max` | `u_max_nominal=0.1` 写死 |
|
||||
| **[缺口]** | Sah04 runner high-beta 网格 | 默认直径与文档推荐不一致 |
|
||||
| **[通过]** | Kan99b runner K1-K5 | 全部 5 个 case,K2 有 gate |
|
||||
| **[缺口]** | Kan99b runner K3-K5 | 未做抑制分类自动检测 |
|
||||
| **[通过]** | 输出机器可读 | 均输出 JSON/CSV |
|
||||
|
||||
### 测试基础设施
|
||||
|
||||
| 严重度 | 结论 |
|
||||
|---|---|
|
||||
| **[严重缺口]** | **零 pytest/unittest 基础设施。** |
|
||||
| **[严重缺口]** | **四个旧待验证项仅 EsoPull 已修,余三项(MRT、curved boundary、force 归一化)未覆盖。** |
|
||||
|
||||
### 建议的最低测试覆盖面(优先级排列)
|
||||
|
||||
1. **MRT 单元测试**(uniform flow / Poiseuille / decaying vortex)— 单元级
|
||||
2. **力系数归一化测试** — 单元级
|
||||
3. **Curved boundary 单列测试** — 单元级
|
||||
4. **EsoPull vs double-buffer 一致性**(Poiseuille 流对比)— 单元/集成级
|
||||
5. **Validation runner smoke 测试** — 验证级
|
||||
|
||||
---
|
||||
|
||||
## 保留说明
|
||||
|
||||
- **`BC_MOVING` / `BC_PERIODIC` 未分发** — 已标注 TODO,暂不实现
|
||||
- **Curved/Sensor kernel 无运行时越界检查** — 设计选择(依赖 host 验证)
|
||||
- **Plain linear Bouzidi 与 TRT 不天然相容** [Gin08b] — 注释已存在
|
||||
- **Curved wall 无质量守恒修正** — 长时间高 Re 周期 curved flow 可能出现力漂移 [San18]
|
||||
- **入口 `U0` 是截面平均速度**,抛物入口峰值为 `1.5*U0`
|
||||
- **角点 flag 语义不一致**:初始化时按 inlet/outlet 分类,边界核又落回 `bounce_back_swap()`
|
||||
- **3D 刚体旋转契约是 z 轴占位实现**(`aux_kernels.cu:58-63`,`Ww = 0.0f`)
|
||||
- **Curved boundary 仍是圆形几何特化**,没有为任意离散几何留出入路径
|
||||
- **`LBMParams` struct 布局** 在 Python `struct.pack` 与 CUDA struct 之间硬耦合
|
||||
- **CONFIG.md NT 整除性措辞**:写"建议",代码用 ceiling division
|
||||
|
||||
---
|
||||
|
||||
## 修改项汇总
|
||||
|
||||
| 类别 | 计数 |
|
||||
|---|---|
|
||||
| 第一轮修补确认 | 12 项全部 [已确认] |
|
||||
| 本轮已修复 | 6 项 |
|
||||
| 保留说明 | 10 项 |
|
||||
| 暂不处理 | Body 重构 / 测试基础设施 / Validation runner 缺口 |
|
||||
126
tests/audit/审计报告_kernel_layer.md
Normal file
126
tests/audit/审计报告_kernel_layer.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Sub-agent A: CUDA Kernel & Fix Verification
|
||||
|
||||
## 1. 旧修正确认
|
||||
|
||||
### 结论: [已确认] Curved Bouzidi 时序 — aux_kernels.cu:26-65 + stepper.py:71 + one_step_double.cu:20
|
||||
**理由**: `stepper.py:71` 中 `_launch_curved()` 在 `OneStep` 之前调用。`aux_kernels.cu` 的 `CurvedBoundaryKernel` 写入 `f.ddf_gpu`(当前 buffer),其后 `OneStep` 从同一 buffer 做 `stream_pull_load` — 同一时间步内完成"前写入→后拉取"。`one_step_double.cu:20` 中的 `apply_boundary_pull` 对 `is_curved(fl)` 直接 return,确保流体节点不会二次覆盖。调用顺序确认无误。
|
||||
|
||||
### 结论: [已确认] q>=0.5 分支时间层 — curved_boundary.cuh:73-75
|
||||
**理由**: `q >= 0.5` 分支读取 `load_ddf(fi, index_f(k_f, dir_opp))`,其中 `fi` 是当前步骤的 DDF buffer。旧代码读的是前一时间步的缓冲区(`fi_in`),现在读的是当前的 `fi`,即上一时间步碰撞后的 post-collision 数据。Bouzidi 两分支算法都要求同一时间层的 post-collision 数据 [Bou01],当前写法满足此要求。
|
||||
|
||||
### 结论: [已确认] Moving wall 修正按 q 分支 — curved_boundary.cuh:30-43
|
||||
**理由**: `bouzidi_linear_moving_correction` 函数三路分支:
|
||||
- `fallback_class != BOUZIDI` → `2 * alpha_ci_dot_uw`(半格加移动修正)
|
||||
- `q < 0.5` → `2 * alpha_ci_dot_uw`(Bou01 公式)
|
||||
- `q >= 0.5` → `alpha_ci_dot_uw / q`(Bou01 公式)
|
||||
各项系数与文献一致,`alpha_ci_dot_uw = 3 * w_i * (c_i · u_w)`。
|
||||
|
||||
### 结论: [已确认] Forcing 预因子统一 — collision_srt.cuh:15, collision_trt.cuh:35, collision_mrt.cuh:41/116
|
||||
**理由**: 三个文件的类内路径都使用了 `c_tau = 1.0f - 0.5f * omega` 并将 `c_tau * Fin[i]` 增量加到碰撞输出中。`collide_dispatch()`(helpers.cuh:33-37/46-50)在 `d_params.fx/fy/fz`非零时调用 `compute_guo_forcing()` 生成 `Fin`,然后传入对应碰撞函数。三者完全一致。
|
||||
|
||||
### 结论: [已确认] TRT outlet NEQ 全分布重构 — pressure_neq.cuh:45-51(D2Q9):89-95(D3Q19)
|
||||
**理由**: `#if COLLISION_MODEL == 0 || COLLISION_MODEL == 1` 把 TRT 纳入全分布 damped NEQ 分支(与 SRT 同级),对所有 NQ 方向做 `f[i] = feq_tar[i] + beta * fneq`,其中 `fneq = f_neb[i] - feq_neb[i]`,`beta = OUTLET_SRT_NEQ_DAMP`。已不再是"少量未知方向"路径。
|
||||
|
||||
### 结论: [已确认] add_vortex() 除以 rho — initializers.py:55-58
|
||||
**理由**: `ux_old = sum(f[i] * cx[i]) / rho_safe`,`uy_old = sum(f[i] * cy[i]) / rho_safe`。旧 bug 是动量除以 rho 这一步缺失,现在存在 `rho_safe` 分母。
|
||||
|
||||
### 结论: [已确认] Init flag overlay 顺序 — simulation.py:139-159 + init_flow.cu:48-58 + body/manager.py:115-127
|
||||
**理由**: 顺序为 `build_channel_flags()`(干净通道)→ `build_flags()`(叠加物体)→ `upload_flags()`(上传GPU)→ `stepper.initialize()`(运行 init kernel 保持 obstacle flag)。`init_flow.cu:finalize_domain_flag` 对 `is_obstacle(fl)` 直接返回原 flag。`sync_to_gpu(rebuild_flags=False)` 在初始化时不重复构建 flags。`_rest_nonfluid()` 的二次重置已移除。
|
||||
|
||||
---
|
||||
|
||||
## 2. 待验证项复查
|
||||
|
||||
### 结论: [已确认] MRT D2Q9 方向序与符号 — collision_mrt.cuh:46-54,79-92
|
||||
**理由**: 经子代 agent 验证:
|
||||
- `m[3]` = f1−f2 + f5−f6 + f7−f8 = ρ·ux(与 `macro.cuh:36` 的 `compute_rho_u()` 完全一致)
|
||||
- `m[5]` = f3−f4 + f5−f6 − f7 + f8 = ρ·uy(一致)
|
||||
- `m[7]` = f1+f2−f3−f4 = ρ·(ux²−uy²)(一致)
|
||||
- 逆变换系数 `g[0] += (dm0 − dm1 + dm2)/9` 等均符合 `M·M⁻¹ = I`
|
||||
- `meq[4] = −ρ·ux`、`meq[6] = −ρ·uy` 符号正确(正交基结构)
|
||||
- 无符号错误。MRT D2Q9 的 paired 方向排列下的矩变换自洽。
|
||||
|
||||
### 结论: [已确认] Esopull 邻壁行处理 — one_step_esopull.cu vs one_step_double.cu
|
||||
**理由**: 逐行对比 `one_step_esopull.cu:76-151` 与 `one_step_double.cu:65-144`。Double-buffer 在 line 88-94 有显式 `is_fluid(fl) && (y==1 || y==NY-2)` 块调用 `apply_wall_bb_y_pull`,而 Esopull 没有对应分支。**但这不构成 bug** — Esopull 交替读写模式(`load_f_esopull` / `store_f_esopull`,`esopull_single_buffer.cuh:33-77`)天然避免了从 y=0 壁面节点直接拉取垃圾数据:
|
||||
- 偶数步:f[3] 读取本地 `fi[n, 4]`(前一步该节点的 -y 方向),f[4] 读取 `fi[j[3], 3]`(y=2 的 +y 方向)
|
||||
- 奇数步同理交错
|
||||
两个时间步的综合效果等价于半格 BB 的反射,无需显式修正。壁面节点 (y=0) 经 `apply_boundary_esopull` → `bounce_back_swap` 处理,不会积累垃圾。
|
||||
|
||||
### 结论: [无法确认: 需运行验证] Force 提取与符号约定 — curved_boundary.cuh:90-97 + obs.cuh + manager.py:328-338
|
||||
**理由**: 链路追踪如下:
|
||||
1. `curved_boundary.cuh:91-97`: `fx = c_x * (f_toward + f_reflected)` 累加到 `obs[obs_force_index(body_id, 0)]`
|
||||
2. `obs.cuh:11`: `obs_force_index(id_obj, d) = OBS_FORCE0_FLOATS + id_obj * DIM + d`(起点=0)
|
||||
3. `manager.py:read_force()`: 返回 `self.obs_pinned[i0:i0 + d]`,无符号反转
|
||||
- 存储的是流体动量交换量 (fluid momentum exchange),不是物体受力的直接值(牛顿第三定律要求 `F_body = -F_fluid`)。如果外部 Cd/Cl 归一化时把 obs 直读值当做物体受力,符号会反转。需用已知算例(如静止圆柱的阻力系数)确认当前实践是否在外部做了隐含的取反。
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构缺陷
|
||||
|
||||
### 结论: [未改] Curved boundary 仍假设圆形/球形 — curved_boundary.cuh + body/objects.py:110-291
|
||||
**理由**: `Cylinder.get_curved_list()` 对每个 cut link 调用 `find_circle_intersection` / `find_sphere_ray_segment`,完全依赖于圆/球几何参数(center + radius)。没有通用多边形/三角网格接口。旧审计标记的 [待重构] 未处理。
|
||||
|
||||
### 结论: [未改] 3D 旋转为 z 轴占位 — aux_kernels.cu:58-63
|
||||
**理由**: `CurvedBoundaryKernel` 的 D3Q19 分支中 `Ww = 0.0f`,且 `Uw = -omega * ry; Vw = omega * rx` 只包含 xy 平面转动。任何具有 z 分量的刚体旋转都不会产生正确的壁面速度。
|
||||
|
||||
### 结论: [已备注] Bouzidi-TRT 不相容注释 — curved_boundary.cuh:17-21
|
||||
**理由**: 注释明确说明 "plain linear Bouzidi interpolation ... is not a TRT-parametrized curved-wall family",位置精准且语意清晰。
|
||||
|
||||
---
|
||||
|
||||
## 4. 新发现
|
||||
|
||||
### 4a. `_no_force` 碰撞变体为死代码 — collision_srt.cuh:25, collision_trt.cuh:57, collision_mrt.cuh:95
|
||||
**问题**: `collide_srt_no_force`、`collide_trt_no_force`、`collide_mrt_no_force` 三个函数在编译后的任何内核路径中均不被调用。`collide_dispatch` (helpers.cuh:15-69) 始终通过带 `Fin` 数组的普通变体执行碰撞,当外力为零时通过 `zero_forcing(Fin)` 将 `Fin` 全清零。死代码约 40 行。
|
||||
**影响**: 无功能影响,增加维护成本。`zero_forcing` 仅用于栈初始化,并非无用调用。
|
||||
|
||||
### 4b. BC_MOVING / BC_PERIODIC 有定义无处理 — flags.cuh + 全内核搜
|
||||
**问题**: `FLAG_BC_MOVING (0x0060)` 和 `FLAG_BC_PERIODIC (0x0050)` 在 `flags.cuh` 有定义,Python 侧 `descriptors.py` 也有对应常量。但 CUDA 内核中没有针对 `is_moving()` 或 `is_periodic()` 的分支处理:
|
||||
- `one_step_double.cu:apply_boundary_pull` 只有 `is_curved/is_inlet/is_outlet/BBS` 分支
|
||||
- 任何单元格若被标记 `BC_MOVING`(非 curved 路径)将落入 `bounce_back_swap()` 分支,得到 std half-way BB 而非移动壁面速度修正
|
||||
**影响**: 只有当 obstacle 使用 `BC_CURVED` 配合 `cl_body_id` 进入 curved boundary kernel 才能获得正确移动壁面速度。`BC_MOVING` 直接标记在通道壁面或其他固体节点上无效果。
|
||||
|
||||
### 4c. Outlet NEQ 的 `OUTLET_MODE` 嵌套逻辑可读性隐患 — pressure_neq.cuh:25-62
|
||||
**问题**:
|
||||
```
|
||||
#if OUTLET_MODE == 1 → 纯拷贝未知方向
|
||||
#else → 普通路径
|
||||
#if COLLISION_MODEL==0||1 → 全分布 NEQ(含 TRT)
|
||||
#elif OUTLET_MODE == 2 → 混合模式
|
||||
#else → 少量未知方向重构(默认 OUTLET_MODE=0 路径)
|
||||
#endif
|
||||
#endif
|
||||
```
|
||||
当 `OUTLET_MODE=0`、`COLLISION_MODEL=2`(MRT)时,代码进入最内层 `#else` 分支(少量未知方向重构),而非全分布 NEQ。这与注释宣称的"SRT 和 TRT 使用全分布 NEQ"一致(MRT 未承诺),但 MRT outlet 路径与 SRT/TRT 行为不同,可能导致 MRT 结果系统性偏差。
|
||||
**影响**: MRT outlet 行为与 SRT/TRT 不一致,应至少加注释说明此差异,或行为对齐。
|
||||
|
||||
### 4d. `collide_inlet_ghost` 对 `y=0/NY-1` 的过滤 — one_step_double.cu:102-103, one_step_esopull.cu:106-107
|
||||
**问题**: `collide_inlet_ghost = is_inlet(fl) && interior_y && inlet_scheme_uses_post_collision_ghost()`,其中 `interior_y = (y>0 && y<NY-1)`。`y=0` 和 `y=NY-1` 的 inlet 节点不会触发 ghost 碰撞。但 `inlet_scheme_uses_post_collision_ghost()` 只在 `INLET_SCHEME==0`(Zou-He)时返回 true,而 x=0 inlet 节点原本处于 y=0 或 y=NY-1 时,已被 `build_channel_flags` 覆盖为 `SOLID|BC_WALL`(角点优先),因此这些节点本身不是 inlet,过滤是安全的。
|
||||
**影响**: 当前无实际影响(角点 wall 覆盖 inlet),但代码隐含的逻辑依赖比较脆弱。如果未来修改建标记序,可能在 y=0/ymax inlet 节点产生未碰撞 ghost 状态。
|
||||
|
||||
### 4e. 传感器归一化在 manager 层正确实现 — aux_kernels.cu:67-99 + manager.py:348-365
|
||||
**结论**: `SensorKernel` 做逐格点求和,`ObjectManager.read_sensor(normalize=True)` 除以 `sensor_cell_counts[body_id]`。已实现,正确。
|
||||
|
||||
### 4f. `inlet_target_u` 对 y=0 和 y=NY-1 的保护 — inlet/common.cuh:34
|
||||
**影响**: `y_clamped = fminf(NY-2, fmaxf(1.0, y))` 使 y=0 节点获得 y=1 的 inlet 速度。对正确定义的 `SOLID|BC_WALL` 角点无实际影响,属于保护性逻辑。
|
||||
|
||||
### 4g. `compute_omega_minus` 在 ω⁺ = 2.0 时分母为零 — collision_trt.cuh:24-26
|
||||
**问题**: `1.0f / omega_plus - 0.5f` 当 `omega_plus = 2.0` 时为 `0.5 - 0.5 = 0`,导致除零。实际路径中 `omega_col` 在 `collide_dispatch` 中被钳位(helpers.cuh:56),假设 `OMEGA_COLLISION_MAX < 2.0` 则为安全。
|
||||
**影响**: 低风险(依赖外部宏正确配置)。
|
||||
|
||||
### 4h. `west_velocity_rho_closure` 在 u_target ≥ 1.0 时除零 — inlet/common.cuh:47-48
|
||||
**问题**: `rho = sum(...) / (1.0f - ux_target)`,当 `ux_target >= 1.0` 时除数为 0 或负数。物理上入口马赫数小于 1 可避免,但无运行时保护。
|
||||
**影响**: 低风险(物理约束),但崩溃行为不如显式断言清晰。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
| 类别 | 数量 | 关键项 |
|
||||
|------|------|--------|
|
||||
| 旧修正确认 | 7/7 | 全部确认正确 |
|
||||
| 待验复查 | 3 | 2 确认, 1 需运行时验证 |
|
||||
| 架构缺陷 | 3 | 圆形硬编码、3D占位、TRT注释齐全 |
|
||||
| 新发现 | 8 | 4a死代码、4b未处理BC、4c MRT outlet差异、4d脆弱逻辑依赖、4e已实现、4f无害保护、4g除零边界、4h无保护输入 |
|
||||
|
||||
**最高优先级**: 4b (`BC_MOVING` 无处理路径)、4c (MRT outlet 路径与 SRT/TRT 不一致)、force 符号约定需运行验证。
|
||||
@ -37,18 +37,9 @@
|
||||
"import pycuda.driver as cuda\n",
|
||||
"\n",
|
||||
"from CelerisLab import Simulation\n",
|
||||
"from CelerisLab.common.streakline import (\n",
|
||||
" FlowFrame,\n",
|
||||
" ReleaseConfig,\n",
|
||||
" IntegratorConfig,\n",
|
||||
" build_release_points,\n",
|
||||
" advance_particles_between_frames,\n",
|
||||
" cylinders_from_triangle_layout,\n",
|
||||
" configure_compute_threads,\n",
|
||||
" compute_vorticity,\n",
|
||||
" render_streakline_density,\n",
|
||||
" render_vorticity_field,\n",
|
||||
")\n",
|
||||
"from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig\n",
|
||||
"from CelerisLab.common.render import compute_vorticity, render_vorticity_field\n",
|
||||
"from CelerisLab.common.preprocess import cylinders_from_triangle_layout\n",
|
||||
"\n",
|
||||
"INLET_U_PHYS_M_S = 0.009028\n",
|
||||
"CYLINDER_DIAMETER_M = 0.010\n",
|
||||
@ -1,34 +1,28 @@
|
||||
# CelerisLab/tests/run_exp_ctrl_matrix_streakline.py
|
||||
"""Streakline (final-step particle cloud) for exp_ctrl_matrix cases.
|
||||
# CelerisLab/tests/postproc/run_exp_ctrl_matrix_streakline.py
|
||||
"""Streakline post-processing for exp_ctrl_matrix cases.
|
||||
|
||||
Runs full CFD to FIXED_STEPS, injects particles only in the last STREAK_WINDOW steps,
|
||||
renders streakline at the final step (not path history).
|
||||
Runs full CFD, uses Streakline.observe() in the last STREAK_WINDOW steps,
|
||||
renders streakline at the final step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(_REPO / "src"))
|
||||
sys.path.insert(0, str(_REPO / "tests"))
|
||||
sys.path.insert(0, str(_REPO / "tests" / "postproc"))
|
||||
|
||||
import run_exp_ctrl_matrix_vorticity as vort
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.common.streakline import (
|
||||
FlowFrame,
|
||||
IntegratorConfig,
|
||||
ReleaseConfig,
|
||||
advance_particles_between_frames,
|
||||
build_release_points,
|
||||
cylinders_from_triangle_layout,
|
||||
render_streakline_density,
|
||||
)
|
||||
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
|
||||
|
||||
DIAMETER_CELLS = vort.DIAMETER_CELLS
|
||||
DEFAULT_OUT = _REPO / "tests" / "output" / "exp_ctrl_matrix_streak_ny300"
|
||||
@ -46,7 +40,9 @@ def build_release_points_for_triangle(layout: dict) -> np.ndarray:
|
||||
return np.column_stack([np.full(4, release_x, dtype=np.float64), ys])
|
||||
|
||||
|
||||
def _apply_body_actions(sim: Simulation, a1: float, a2: float, a3: float, u_lb: float) -> None:
|
||||
def _apply_body_actions(
|
||||
sim: Simulation, a1: float, a2: float, a3: float, u_lb: float
|
||||
) -> None:
|
||||
w1 = vort._action_to_omega_lb(a1, u_lb)
|
||||
w2 = vort._action_to_omega_lb(a2, u_lb)
|
||||
w3 = vort._action_to_omega_lb(a3, u_lb)
|
||||
@ -56,6 +52,15 @@ def _apply_body_actions(sim: Simulation, a1: float, a2: float, a3: float, u_lb:
|
||||
vort._set_body_omegas(sim, w1, w2, w3)
|
||||
|
||||
|
||||
def _cylinders_from_triangle_layout(layout: dict) -> list[tuple[tuple[float, float], float]]:
|
||||
radius = float(layout["radius_lb"])
|
||||
return [
|
||||
((float(layout["x_apex"]), float(layout["y_center"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_lower"])), radius),
|
||||
((float(layout["x_rear"]), float(layout["y_upper"])), radius),
|
||||
]
|
||||
|
||||
|
||||
def run_streak_case(
|
||||
case_id: str,
|
||||
slug: str,
|
||||
@ -73,8 +78,11 @@ def run_streak_case(
|
||||
layout = vort._add_triangle_cylinders(sim)
|
||||
sim.initialize()
|
||||
u_lb = float(sim.lbm_cfg.velocity)
|
||||
dt_phys = (vort.CYLINDER_DIAMETER_M / DIAMETER_CELLS) * (u_lb / vort.INLET_U_PHYS_M_S)
|
||||
cylinders = cylinders_from_triangle_layout(layout)
|
||||
dt_phys = (
|
||||
(vort.CYLINDER_DIAMETER_M / DIAMETER_CELLS)
|
||||
* (u_lb / vort.INLET_U_PHYS_M_S)
|
||||
)
|
||||
cylinders = _cylinders_from_triangle_layout(layout)
|
||||
|
||||
release_cfg = ReleaseConfig(
|
||||
mode="strip",
|
||||
@ -84,13 +92,19 @@ def run_streak_case(
|
||||
downstream_spacing=1.0,
|
||||
inject_per_seed=1,
|
||||
)
|
||||
integrator_cfg = IntegratorConfig(alpha_t=0.25, alpha_x=0.40, max_particle_age=None)
|
||||
integrator_cfg = IntegratorConfig(
|
||||
alpha_t=0.25, alpha_x=0.40, max_particle_age=None
|
||||
)
|
||||
base_release = build_release_points_for_triangle(layout)
|
||||
release_points = build_release_points(base_release, release_cfg)
|
||||
|
||||
particles = np.empty((0, 2), dtype=np.float64)
|
||||
ages = np.empty((0,), dtype=np.float64)
|
||||
prev_frame: FlowFrame | None = None
|
||||
streak = Streakline(
|
||||
release_points=base_release,
|
||||
release_cfg=release_cfg,
|
||||
integrator_cfg=integrator_cfg,
|
||||
nx=int(sim.lbm_cfg.nx),
|
||||
ny=int(sim.lbm_cfg.ny),
|
||||
cylinders=cylinders,
|
||||
)
|
||||
|
||||
print(
|
||||
f"--- {case_id} {slug} steps={total_steps} streak_window={streak_window} "
|
||||
@ -103,57 +117,29 @@ def run_streak_case(
|
||||
_apply_body_actions(sim, a1, a2, a3, u_lb)
|
||||
sim.run(1)
|
||||
|
||||
# Feed streakline within the window
|
||||
if step >= streak_start and (step + 1) % sample_every == 0:
|
||||
macro = sim.get_macroscopic()
|
||||
curr_frame = FlowFrame(
|
||||
step=int(step + 1),
|
||||
ux=np.asarray(macro["ux"], dtype=np.float64),
|
||||
uy=np.asarray(macro["uy"], dtype=np.float64),
|
||||
)
|
||||
new_particles = np.repeat(release_points, release_cfg.inject_per_seed, axis=0)
|
||||
if particles.size:
|
||||
particles = np.vstack([particles, new_particles])
|
||||
ages = np.concatenate([ages, np.zeros(new_particles.shape[0], dtype=np.float64)])
|
||||
else:
|
||||
particles = new_particles.copy()
|
||||
ages = np.zeros(new_particles.shape[0], dtype=np.float64)
|
||||
|
||||
if prev_frame is not None and particles.size:
|
||||
particles, ages, _, _ = advance_particles_between_frames(
|
||||
particles,
|
||||
ages,
|
||||
prev_frame,
|
||||
curr_frame,
|
||||
nx=int(sim.lbm_cfg.nx),
|
||||
ny=int(sim.lbm_cfg.ny),
|
||||
cfg=integrator_cfg,
|
||||
cylinders=cylinders,
|
||||
)
|
||||
prev_frame = curr_frame
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=int(step + 1))
|
||||
|
||||
if report_every > 0 and (step + 1) % report_every == 0:
|
||||
print(
|
||||
f" step {step+1}/{total_steps} a=({a1:+.5f},{a2:+.5f},{a3:+.5f}) "
|
||||
f"particles={particles.shape[0]}"
|
||||
f"particles={streak.n_particles}"
|
||||
)
|
||||
|
||||
if particles.size == 0:
|
||||
raise RuntimeError(f"{case_id}: no particles in streak window; lower sample_every.")
|
||||
if streak.n_particles == 0:
|
||||
raise RuntimeError(
|
||||
f"{case_id}: no particles in streak window; lower sample_every."
|
||||
)
|
||||
|
||||
png = out_dir / f"streakline_{case_id}_{slug}.png"
|
||||
render_info = render_streakline_density(
|
||||
particles,
|
||||
ages,
|
||||
nx=int(sim.lbm_cfg.nx),
|
||||
ny=int(sim.lbm_cfg.ny),
|
||||
out_path=str(png),
|
||||
cylinders=cylinders,
|
||||
render_info = streak.render(
|
||||
str(png),
|
||||
age_decay_steps=STREAK_AGE_DECAY,
|
||||
blur_sigma=STREAK_BLUR_SIGMA,
|
||||
minimal_axes=True,
|
||||
background_color=(1.0, 1.0, 1.0),
|
||||
streak_color=(1.0, 0.0, 0.0),
|
||||
show_release_points=False,
|
||||
)
|
||||
sim.close()
|
||||
|
||||
@ -164,15 +150,15 @@ def run_streak_case(
|
||||
"streak_window_steps": int(streak_window),
|
||||
"streak_start_step": int(streak_start),
|
||||
"sample_every": int(sample_every),
|
||||
"particle_count_final": int(particles.shape[0]),
|
||||
"release_points_dense": int(release_points.shape[0]),
|
||||
"particle_count_final": int(streak.n_particles),
|
||||
"release_points_dense": int(base_release.shape[0]),
|
||||
"streak_png": str(png),
|
||||
"swap_action23_bodies": bool(vort.SWAP_ACTION23_BODIES),
|
||||
"render": render_info,
|
||||
}
|
||||
with (out_dir / f"summary_{case_id}_{slug}.json").open("w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
print(f" saved {png} particles={particles.shape[0]}")
|
||||
print(f" saved {png} particles={streak.n_particles}")
|
||||
return summary
|
||||
|
||||
|
||||
@ -188,7 +174,11 @@ def main() -> int:
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
selected = {c.strip() for c in args.cases.split(",") if c.strip()} if args.cases else None
|
||||
selected = (
|
||||
{c.strip() for c in args.cases.split(",") if c.strip()}
|
||||
if args.cases
|
||||
else None
|
||||
)
|
||||
|
||||
with vort.CONFIG_PATH.open("r", encoding="utf-8") as f:
|
||||
grid = json.load(f)["grid"]
|
||||
@ -1,7 +1,18 @@
|
||||
# CelerisLab/tests/run_exp_ctrl_matrix_vorticity.py
|
||||
# CelerisLab/tests/postproc/run_exp_ctrl_matrix_vorticity.py
|
||||
"""Batch vorticity images for three-cylinder control matrix (exp_ctrl_matrix.md).
|
||||
|
||||
Runs each control law to steady-like state, saves final vorticity PNG only (no streaklines).
|
||||
Usage::
|
||||
|
||||
# Single case
|
||||
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py \\
|
||||
--cases C0 --batch 10 --device-id 0
|
||||
|
||||
# All cases
|
||||
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py \\
|
||||
--batch 10 --device-id 0
|
||||
|
||||
# Full 100k steps, no batching (default)
|
||||
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -16,17 +27,16 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_REPO / "src"))
|
||||
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.common.streakline import (
|
||||
from CelerisLab.common.render import (
|
||||
compute_vorticity,
|
||||
cylinders_from_triangle_layout,
|
||||
render_vorticity_field,
|
||||
)
|
||||
from CelerisLab.common.preprocess import cylinders_from_triangle_layout
|
||||
|
||||
INLET_U_PHYS_M_S = 0.009028
|
||||
CYLINDER_DIAMETER_M = 0.010
|
||||
@ -224,9 +234,12 @@ def _triangle_layout(cfg) -> dict:
|
||||
|
||||
def _add_triangle_cylinders(sim: Simulation) -> dict:
|
||||
layout = _triangle_layout(sim.lbm_cfg)
|
||||
sim.add_cylinder(center=(layout["x_apex"], layout["y_center"]), radius=layout["radius_lb"])
|
||||
sim.add_cylinder(center=(layout["x_rear"], layout["y_lower"]), radius=layout["radius_lb"])
|
||||
sim.add_cylinder(center=(layout["x_rear"], layout["y_upper"]), radius=layout["radius_lb"])
|
||||
sim.add_body("circle", center=(layout["x_apex"], layout["y_center"]),
|
||||
radius=layout["radius_lb"])
|
||||
sim.add_body("circle", center=(layout["x_rear"], layout["y_lower"]),
|
||||
radius=layout["radius_lb"])
|
||||
sim.add_body("circle", center=(layout["x_rear"], layout["y_upper"]),
|
||||
radius=layout["radius_lb"])
|
||||
return layout
|
||||
|
||||
|
||||
@ -264,14 +277,10 @@ def _action_to_omega_lb(action_m_s: float, u_lb: float) -> float:
|
||||
|
||||
|
||||
def _set_body_omegas(sim: Simulation, omega0: float, omega1: float, omega2: float) -> None:
|
||||
bodies = sim.bodies
|
||||
dim = sim.lbm_cfg.dim
|
||||
slot = 3 * dim
|
||||
bodies.action.fill(0.0)
|
||||
bodies.action[(0 * slot) + slot - 1] = np.float32(omega0)
|
||||
bodies.action[(1 * slot) + slot - 1] = np.float32(omega1)
|
||||
bodies.action[(2 * slot) + slot - 1] = np.float32(omega2)
|
||||
cuda.memcpy_htod(bodies.action_gpu, bodies.action)
|
||||
"""Set all three body rotation speeds using new API (implicit GPU upload)."""
|
||||
sim.set_body(0, omega=omega0)
|
||||
sim.set_body(1, omega=omega1)
|
||||
sim.set_body(2, omega=omega2)
|
||||
|
||||
|
||||
def _default_steps(nx: int, u_lb: float, step_multiplier: float) -> int:
|
||||
@ -287,9 +296,11 @@ def run_case(
|
||||
out_dir: Path,
|
||||
steps: int,
|
||||
report_every: int,
|
||||
batch: int = 1,
|
||||
device_id: int = 0,
|
||||
) -> dict:
|
||||
compat = _ensure_compat_config(CONFIG_PATH)
|
||||
sim = Simulation(compat)
|
||||
sim = Simulation(compat, device_id=device_id)
|
||||
layout = _add_triangle_cylinders(sim)
|
||||
sim.initialize()
|
||||
u_lb = float(sim.lbm_cfg.velocity)
|
||||
@ -297,21 +308,43 @@ def run_case(
|
||||
dt_phys = dx_phys * (u_lb / INLET_U_PHYS_M_S)
|
||||
cylinders = cylinders_from_triangle_layout(layout)
|
||||
|
||||
print(f"--- {case_id} {slug} steps={steps} u_lb={u_lb} dt_phys={dt_phys} ---")
|
||||
for i in range(steps):
|
||||
t_phys = i * dt_phys
|
||||
a1, a2, a3 = _actions_at_time(t_phys, features)
|
||||
w1 = _action_to_omega_lb(a1, u_lb)
|
||||
w2 = _action_to_omega_lb(a2, u_lb)
|
||||
w3 = _action_to_omega_lb(a3, u_lb)
|
||||
if SWAP_ACTION23_BODIES:
|
||||
_set_body_omegas(sim, w1, w3, w2)
|
||||
else:
|
||||
_set_body_omegas(sim, w1, w2, w3)
|
||||
sim.run(1)
|
||||
if report_every > 0 and (i + 1) % report_every == 0:
|
||||
print(f" step {i+1}/{steps} a=({a1:+.5f},{a2:+.5f},{a3:+.5f})")
|
||||
print(f"--- {case_id} {slug} steps={steps} u_lb={u_lb} dt_phys={dt_phys} batch={batch} ---")
|
||||
stream = sim.stream
|
||||
batch_size = max(1, int(batch))
|
||||
|
||||
# Main loop: precompute actions, batch-step, read forces/sensors at intervals
|
||||
for batch_start in range(0, steps, batch_size):
|
||||
batch_end = min(batch_start + batch_size, steps)
|
||||
for j in range(batch_start, batch_end):
|
||||
t_phys = j * dt_phys
|
||||
a1, a2, a3 = _actions_at_time(t_phys, features)
|
||||
w1 = _action_to_omega_lb(a1, u_lb)
|
||||
w2 = _action_to_omega_lb(a2, u_lb)
|
||||
w3 = _action_to_omega_lb(a3, u_lb)
|
||||
if SWAP_ACTION23_BODIES:
|
||||
_set_body_omegas(sim, w1, w3, w2)
|
||||
else:
|
||||
_set_body_omegas(sim, w1, w2, w3)
|
||||
|
||||
n = batch_end - batch_start
|
||||
sim.stepper.step(
|
||||
n,
|
||||
action_gpu=sim.bodies.action_gpu,
|
||||
obs_gpu=sim.bodies.obs_gpu,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
if report_every > 0 and (batch_end % report_every == 0 or batch_end == steps):
|
||||
stream.synchronize()
|
||||
for bid in range(sim.bodies.count):
|
||||
fx = sim.bodies.read_force(bid)
|
||||
print(
|
||||
f" step={batch_end} body={bid}"
|
||||
f" fx={float(fx[0]):+.6f} fy={float(fx[1]):+.6f}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
stream.synchronize()
|
||||
macro = sim.get_macroscopic()
|
||||
vort = compute_vorticity(macro["ux"], macro["uy"])
|
||||
png = out_dir / f"vorticity_{case_id}_{slug}.png"
|
||||
@ -334,6 +367,7 @@ def run_case(
|
||||
"case_id": case_id,
|
||||
"slug": slug,
|
||||
"steps": int(steps),
|
||||
"batch": int(batch),
|
||||
"u_lb": u_lb,
|
||||
"dt_phys": dt_phys,
|
||||
"vort_png": str(png),
|
||||
@ -356,22 +390,36 @@ def main() -> int:
|
||||
"--step-multiplier",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help="Steps = multiplier * round(2*nx/(3*u_lb)); use 2.0 after halving nx/ny.",
|
||||
help=(
|
||||
"Steps = multiplier * round(2*nx/(3*u_lb)); "
|
||||
"use 2.0 after halving nx/ny."
|
||||
),
|
||||
)
|
||||
ap.add_argument(
|
||||
"--steps",
|
||||
type=int,
|
||||
default=FIXED_STEPS,
|
||||
help=f"Total LBM steps (default {FIXED_STEPS}, held fixed across grid tweaks).",
|
||||
help=f"Total LBM steps (default {FIXED_STEPS}).",
|
||||
)
|
||||
ap.add_argument("--report-every", type=int, default=20000)
|
||||
ap.add_argument("--cases", type=str, default="", help="Comma list e.g. C0,C1 or empty=all.")
|
||||
ap.add_argument("--cases", type=str, default="",
|
||||
help="Comma list e.g. C0,C1 or empty=all.")
|
||||
ap.add_argument(
|
||||
"--batch", type=int, default=1,
|
||||
help=(
|
||||
"Batch N steps between action uploads. Default 1 (each step). "
|
||||
"With --batch 10, actions are computed and uploaded every 10 steps. "
|
||||
"Saves kernel launch overhead at the cost of control-signal interpolation."
|
||||
),
|
||||
)
|
||||
ap.add_argument("--device-id", type=int, default=0, help="GPU device id.")
|
||||
args = ap.parse_args()
|
||||
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
selected = {c.strip() for c in args.cases.split(",") if c.strip()} if args.cases else None
|
||||
selected = {c.strip() for c in args.cases.split(",") if c.strip()} \
|
||||
if args.cases else None
|
||||
summaries = []
|
||||
|
||||
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
||||
@ -380,10 +428,12 @@ def main() -> int:
|
||||
ny = int(grid_cfg["ny"])
|
||||
u_lb = 0.04
|
||||
base_steps = int(round(2.0 * nx / (3.0 * u_lb)))
|
||||
steps = int(args.steps) if int(args.steps) > 0 else _default_steps(nx, u_lb, args.step_multiplier)
|
||||
steps = int(args.steps) if int(args.steps) > 0 \
|
||||
else _default_steps(nx, u_lb, args.step_multiplier)
|
||||
print(
|
||||
f"Output: {out_dir} | grid={nx}x{ny} | base_steps={base_steps} "
|
||||
f"x{args.step_multiplier} -> {steps} | vort [{VORT_VMIN}, {VORT_VMAX}]"
|
||||
f"x{args.step_multiplier} -> {steps} | batch={args.batch} | "
|
||||
f"device={args.device_id} | vort [{VORT_VMIN}, {VORT_VMAX}]"
|
||||
)
|
||||
|
||||
for case_id, slug, features in CONTROL_CASES:
|
||||
@ -397,6 +447,8 @@ def main() -> int:
|
||||
out_dir=out_dir,
|
||||
steps=steps,
|
||||
report_every=int(args.report_every),
|
||||
batch=int(args.batch),
|
||||
device_id=int(args.device_id),
|
||||
)
|
||||
)
|
||||
|
||||
@ -405,6 +457,8 @@ def main() -> int:
|
||||
"base_steps": base_steps,
|
||||
"step_multiplier": float(args.step_multiplier),
|
||||
"steps": steps,
|
||||
"batch": int(args.batch),
|
||||
"device_id": int(args.device_id),
|
||||
"vort_vmin": VORT_VMIN,
|
||||
"vort_vmax": VORT_VMAX,
|
||||
"swap_action23_bodies": bool(SWAP_ACTION23_BODIES),
|
||||
@ -1,9 +1,9 @@
|
||||
# CelerisLab/tests/run_kan99b_streakline.py
|
||||
"""Kan99b streakline demo using CelerisLab common streakline engine.
|
||||
# CelerisLab/tests/postproc/run_kan99b_streakline.py
|
||||
"""Kan99b streakline demo using the new Streakline class (online mode only).
|
||||
|
||||
Modes:
|
||||
- online: run CFD and compute streakline in memory (no velocity dump).
|
||||
- offline: load velocity snapshots and reconstruct streakline.
|
||||
Usage::
|
||||
|
||||
python tests/run_kan99b_streakline.py --domain M --re 100 --alpha 1.0
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -13,23 +13,14 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CelerisLab import Simulation
|
||||
from CelerisLab.common.streakline import (
|
||||
FlowFrame,
|
||||
IntegratorConfig,
|
||||
ReleaseConfig,
|
||||
build_release_points,
|
||||
estimate_sampling_plan,
|
||||
render_streakline_density,
|
||||
run_streakline_offline,
|
||||
run_streakline_online,
|
||||
)
|
||||
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
|
||||
|
||||
U_INF = 0.03
|
||||
@ -92,9 +83,13 @@ def _build_cfg(base_cfg: dict, *, nx: int, ny: int, re: float, inlet_scheme: str
|
||||
return cfg
|
||||
|
||||
|
||||
def _build_simulation(*, domain: DomainSpec, re: float, alpha: float, inlet_scheme: str) -> Simulation:
|
||||
def _build_simulation(
|
||||
*, domain: DomainSpec, re: float, alpha: float, inlet_scheme: str
|
||||
) -> Simulation:
|
||||
base_cfg = _load_json(_DEFAULT_LBM)
|
||||
cfg = _build_cfg(base_cfg, nx=domain.nx, ny=domain.ny, re=re, inlet_scheme=inlet_scheme)
|
||||
cfg = _build_cfg(
|
||||
base_cfg, nx=domain.nx, ny=domain.ny, re=re, inlet_scheme=inlet_scheme
|
||||
)
|
||||
body_doc = {
|
||||
"objects": [
|
||||
{
|
||||
@ -105,7 +100,7 @@ def _build_simulation(*, domain: DomainSpec, re: float, alpha: float, inlet_sche
|
||||
}
|
||||
]
|
||||
}
|
||||
tmpd = tempfile.mkdtemp(prefix="celeris_streakline_online_")
|
||||
tmpd = tempfile.mkdtemp(prefix="celeris_streakline_")
|
||||
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
|
||||
body_tmp = os.path.join(tmpd, "config_body.json")
|
||||
_write_json(lbm_tmp, cfg)
|
||||
@ -130,54 +125,46 @@ def _default_base_release_points(center: Tuple[float, float]) -> np.ndarray:
|
||||
)
|
||||
|
||||
|
||||
def _load_offline_frames(snapshot_dir: str) -> List[FlowFrame]:
|
||||
files = sorted(
|
||||
[
|
||||
os.path.join(snapshot_dir, name)
|
||||
for name in os.listdir(snapshot_dir)
|
||||
if name.endswith(".npz") and name.startswith("vel_step")
|
||||
]
|
||||
# ---- Sampling plan helper (kept as a local utility, not part of the library) ----
|
||||
def _estimate_sampling_plan(
|
||||
*,
|
||||
st_ref: float,
|
||||
diameter: float,
|
||||
u_ref: float,
|
||||
snapshots_per_period: float = 24.0,
|
||||
periods: int = 5,
|
||||
) -> dict:
|
||||
period_steps = float(diameter) / (float(st_ref) * float(u_ref))
|
||||
save_every = int(
|
||||
max(20, round(period_steps / snapshots_per_period / 10.0) * 10)
|
||||
)
|
||||
frames: List[FlowFrame] = []
|
||||
for path in files:
|
||||
data = np.load(path)
|
||||
step = int(np.asarray(data["step"]).reshape(-1)[0])
|
||||
frames.append(
|
||||
FlowFrame(
|
||||
step=step,
|
||||
ux=np.asarray(data["ux"], dtype=np.float64),
|
||||
uy=np.asarray(data["uy"], dtype=np.float64),
|
||||
)
|
||||
)
|
||||
return frames
|
||||
n_snapshots = int(max(20, round(float(periods) * float(snapshots_per_period))))
|
||||
return {
|
||||
"st_ref": float(st_ref),
|
||||
"period_steps_est": float(period_steps),
|
||||
"save_every_recommended": int(save_every),
|
||||
"snapshot_count_recommended": int(n_snapshots),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Kan99b streakline demo (online/offline)")
|
||||
ap.add_argument("--mode", default="online", choices=("online", "offline"))
|
||||
ap = argparse.ArgumentParser(description="Kan99b streakline demo")
|
||||
ap.add_argument("--domain", default="M", choices=("S", "M", "L"))
|
||||
ap.add_argument("--re", type=float, default=100.0)
|
||||
ap.add_argument("--alpha", type=float, default=1.0)
|
||||
ap.add_argument("--inlet-scheme", default="regularized", choices=("regularized", "zou_he_local"))
|
||||
ap.add_argument("--inlet-scheme", default="regularized",
|
||||
choices=("regularized", "zou_he_local"))
|
||||
ap.add_argument("--start-step", type=int, default=60_000)
|
||||
ap.add_argument("--sample-every", type=int, default=0, help="0 uses recommended value.")
|
||||
ap.add_argument("--n-snapshots", type=int, default=0, help="0 uses recommended value.")
|
||||
ap.add_argument("--snapshot-dir", type=str, default="", help="Required in offline mode.")
|
||||
ap.add_argument("--release-mode", default="strip", choices=("point", "line", "strip"))
|
||||
ap.add_argument("--sample-every", type=int, default=0,
|
||||
help="0 uses recommended value.")
|
||||
ap.add_argument("--n-snapshots", type=int, default=0,
|
||||
help="0 uses recommended value.")
|
||||
ap.add_argument("--release-mode", default="strip",
|
||||
choices=("point", "line", "strip"))
|
||||
ap.add_argument("--line-span", type=float, default=0.0)
|
||||
ap.add_argument("--line-count", type=int, default=1)
|
||||
ap.add_argument(
|
||||
"--downstream-count",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Particles per seed along +x (denser streak).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--downstream-spacing",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Lattice spacing between x-staggered release points.",
|
||||
)
|
||||
ap.add_argument("--downstream-count", type=int, default=5)
|
||||
ap.add_argument("--downstream-spacing", type=float, default=1.0)
|
||||
ap.add_argument("--inject-per-seed", type=int, default=2)
|
||||
ap.add_argument("--alpha-t", type=float, default=0.2)
|
||||
ap.add_argument("--alpha-x", type=float, default=0.4)
|
||||
@ -185,7 +172,9 @@ def main() -> int:
|
||||
ap.add_argument(
|
||||
"--out-dir",
|
||||
type=str,
|
||||
default=os.path.join(_REPO, "tests", "output", "streakline", "kan99b_k2_online"),
|
||||
default=os.path.join(
|
||||
_REPO, "tests", "output", "streakline", "kan99b_k2"
|
||||
),
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
@ -193,9 +182,19 @@ def main() -> int:
|
||||
out_dir = os.path.abspath(args.out_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
plan = estimate_sampling_plan(st_ref=KAN99B_ST_REF, diameter=D_LATTICE, u_ref=U_INF)
|
||||
sample_every = int(args.sample_every) if int(args.sample_every) > 0 else int(plan["save_every_recommended"])
|
||||
n_snapshots = int(args.n_snapshots) if int(args.n_snapshots) > 0 else int(plan["snapshot_count_recommended"])
|
||||
plan = _estimate_sampling_plan(
|
||||
st_ref=KAN99B_ST_REF, diameter=D_LATTICE, u_ref=U_INF
|
||||
)
|
||||
sample_every = (
|
||||
int(args.sample_every)
|
||||
if int(args.sample_every) > 0
|
||||
else int(plan["save_every_recommended"])
|
||||
)
|
||||
n_snapshots = (
|
||||
int(args.n_snapshots)
|
||||
if int(args.n_snapshots) > 0
|
||||
else int(plan["snapshot_count_recommended"])
|
||||
)
|
||||
|
||||
release_cfg = ReleaseConfig(
|
||||
mode=args.release_mode,
|
||||
@ -210,63 +209,52 @@ def main() -> int:
|
||||
alpha_x=float(args.alpha_x),
|
||||
diffusion_coeff=float(args.diffusion_coeff),
|
||||
)
|
||||
base_release_points = _default_base_release_points(domain.center)
|
||||
dense_release_points = build_release_points(base_release_points, release_cfg)
|
||||
base_release = _default_base_release_points(domain.center)
|
||||
|
||||
if args.mode == "online":
|
||||
sim = _build_simulation(
|
||||
domain=domain,
|
||||
re=float(args.re),
|
||||
alpha=float(args.alpha),
|
||||
inlet_scheme=args.inlet_scheme,
|
||||
)
|
||||
try:
|
||||
particles, ages, diag = run_streakline_online(
|
||||
sim,
|
||||
start_step=int(args.start_step),
|
||||
sample_every=int(sample_every),
|
||||
n_samples=int(n_snapshots),
|
||||
release_points=base_release_points,
|
||||
release_cfg=release_cfg,
|
||||
integrator_cfg=integrator_cfg,
|
||||
solid_center=domain.center,
|
||||
solid_radius=R_LATTICE,
|
||||
)
|
||||
finally:
|
||||
sim.close()
|
||||
frame_count = int(n_snapshots)
|
||||
frame_source = "in-memory online sampling"
|
||||
else:
|
||||
if not args.snapshot_dir:
|
||||
raise ValueError("--snapshot-dir is required in offline mode.")
|
||||
frames = _load_offline_frames(os.path.abspath(args.snapshot_dir))
|
||||
if len(frames) < 2:
|
||||
raise RuntimeError("Offline mode needs at least two velocity snapshots.")
|
||||
particles, ages, diag = run_streakline_offline(
|
||||
frames,
|
||||
nx=domain.nx,
|
||||
ny=domain.ny,
|
||||
release_points=base_release_points,
|
||||
release_cfg=release_cfg,
|
||||
integrator_cfg=integrator_cfg,
|
||||
solid_center=domain.center,
|
||||
solid_radius=R_LATTICE,
|
||||
)
|
||||
frame_count = len(frames)
|
||||
frame_source = os.path.abspath(args.snapshot_dir)
|
||||
|
||||
render_info = render_streakline_density(
|
||||
particles,
|
||||
ages,
|
||||
streak = Streakline(
|
||||
release_points=base_release,
|
||||
release_cfg=release_cfg,
|
||||
integrator_cfg=integrator_cfg,
|
||||
nx=domain.nx,
|
||||
ny=domain.ny,
|
||||
out_path=os.path.join(out_dir, "streakline.png"),
|
||||
release_points=dense_release_points,
|
||||
solid_center=domain.center,
|
||||
solid_radius=R_LATTICE,
|
||||
cylinders=[(domain.center, R_LATTICE)],
|
||||
)
|
||||
|
||||
sim = _build_simulation(
|
||||
domain=domain,
|
||||
re=float(args.re),
|
||||
alpha=float(args.alpha),
|
||||
inlet_scheme=args.inlet_scheme,
|
||||
)
|
||||
|
||||
# Burn-in phase: step the simulation but don't feed streakline
|
||||
print(f"Burning-in {args.start_step} steps ...")
|
||||
sim.run(int(args.start_step))
|
||||
|
||||
# Sampling phase: step and feed velocity frames to streakline
|
||||
target_last = int(args.start_step) + sample_every * (n_snapshots - 1)
|
||||
frames_collected = 0
|
||||
print(
|
||||
f"Sampling every {sample_every} steps for {n_snapshots} frames "
|
||||
f"(up to step {target_last})..."
|
||||
)
|
||||
while int(sim.stepper.step_count) < target_last:
|
||||
sim.step(1)
|
||||
step = int(sim.stepper.step_count)
|
||||
if (step - int(args.start_step)) % sample_every != 0:
|
||||
continue
|
||||
macro = sim.get_macroscopic()
|
||||
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
|
||||
frames_collected += 1
|
||||
if frames_collected >= n_snapshots:
|
||||
break
|
||||
|
||||
sim.close()
|
||||
|
||||
render_info = streak.render(
|
||||
os.path.join(out_dir, "streakline.png"),
|
||||
age_decay_steps=integrator_cfg.age_decay_steps,
|
||||
blur_sigma=1.2,
|
||||
title=f"Kan99b streakline ({args.mode}, {args.release_mode})",
|
||||
)
|
||||
|
||||
meta = {
|
||||
@ -277,30 +265,33 @@ def main() -> int:
|
||||
"inlet_scheme": args.inlet_scheme,
|
||||
"collision": "MRT",
|
||||
},
|
||||
"mode": args.mode,
|
||||
"sampling_estimate": plan,
|
||||
"sampling_used": {
|
||||
"start_step": int(args.start_step),
|
||||
"sample_every": int(sample_every),
|
||||
"n_snapshots": int(n_snapshots),
|
||||
"frame_source": frame_source,
|
||||
"frames_used": int(frame_count),
|
||||
"frames_collected": frames_collected,
|
||||
},
|
||||
"release": {
|
||||
"config": vars(args),
|
||||
"base_points": base_release_points.tolist(),
|
||||
"dense_point_count": int(dense_release_points.shape[0]),
|
||||
"dense_points_preview": dense_release_points[: min(12, dense_release_points.shape[0])].tolist(),
|
||||
"config": {
|
||||
"mode": args.release_mode,
|
||||
"line_span": float(args.line_span),
|
||||
"line_count": max(1, int(args.line_count)),
|
||||
"downstream_count": max(1, int(args.downstream_count)),
|
||||
"downstream_spacing": float(args.downstream_spacing),
|
||||
"inject_per_seed": max(1, int(args.inject_per_seed)),
|
||||
},
|
||||
"base_points": base_release.tolist(),
|
||||
},
|
||||
"diagnostics": diag,
|
||||
"diagnostics": {"n_particles_final": int(streak.n_particles)},
|
||||
"render": render_info,
|
||||
}
|
||||
_write_json(os.path.join(out_dir, "streakline_meta.json"), meta)
|
||||
|
||||
print(f"Recommended sample_every: {plan['save_every_recommended']} steps")
|
||||
print(f"Recommended snapshots: {plan['snapshot_count_recommended']}")
|
||||
print(f"Mode: {args.mode} | frames used: {frame_count}")
|
||||
print(f"Dense release points: {dense_release_points.shape[0]}")
|
||||
print(f"Frames collected: {frames_collected}")
|
||||
print(f"Final particles: {streak.n_particles}")
|
||||
print(f"Output image: {render_info['image_path']}")
|
||||
return 0
|
||||
|
||||
|
Before Width: | Height: | Size: 316 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
247
tests/specs/Kan99b_validation.md
Normal file
247
tests/specs/Kan99b_validation.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Rotating cylinder validation against [Kan99b]
|
||||
|
||||
## Goal
|
||||
|
||||
This validation should stay small, direct, and defensible.
|
||||
|
||||
The main design rules are:
|
||||
|
||||
- use the paper's direct numeric anchor at `Re = 100, alpha = 1.0` as the main hard benchmark
|
||||
- use a low-rotation case to test the lift trend
|
||||
- use suppression cases to test flow classification, not exact threshold fitting
|
||||
- do not treat values read from figures near `alpha_L` as tight amplitude targets
|
||||
|
||||
This keeps the matrix representative without overfitting to sensitive threshold points.
|
||||
|
||||
## Strong numeric anchors from [Kan99b]
|
||||
|
||||
The strongest exact benchmark in the paper is the convergence case at `Re = 100, alpha = 1.0`.
|
||||
|
||||
| Quantity | Reference value |
|
||||
|---|---:|
|
||||
| `St` | 0.1655 |
|
||||
| `mean C_L` | -2.4881 |
|
||||
| `mean C_D` | 1.1040 |
|
||||
| `C'_L` | 0.3631 |
|
||||
| `C'_D` | 0.0993 |
|
||||
|
||||
For low rotation at `Re = 100`, the paper also gives the mean lift trend
|
||||
|
||||
\[
|
||||
\overline{C_L} \approx -2.48\alpha
|
||||
\]
|
||||
|
||||
which is a good secondary benchmark for small `alpha`.
|
||||
|
||||
The suppression thresholds are given as trends:
|
||||
|
||||
| Reynolds number | Expected `alpha_L` |
|
||||
|---|---:|
|
||||
| 60 | about 1.4 |
|
||||
| 100 | about 1.8 |
|
||||
| 160 | about 1.9 |
|
||||
|
||||
These threshold values should be used as regime guides, not as tight one-point numeric targets. In the suppression curve from [Kan99b] shown above, the boundary is exactly the kind of place where a small solver difference can change the observed state.
|
||||
|
||||
## Fixed solver setup
|
||||
|
||||
| Item | Setting |
|
||||
|---|---|
|
||||
| Dimension | 2D |
|
||||
| Lattice | D2Q9 |
|
||||
| Streaming | double buffer |
|
||||
| Curved boundary | current Bouzidi moving wall implementation |
|
||||
| Inlet profile | uniform |
|
||||
| Top and bottom boundaries | free slip |
|
||||
| Outlet | neq extrapolation |
|
||||
| LES | off |
|
||||
| Precision | FP32 |
|
||||
| Cylinder diameter | `D = 30` lattice units |
|
||||
| Cylinder radius | `R = 15` lattice units |
|
||||
| Rotation input | update body omega only |
|
||||
|
||||
The baseline domain remains the current medium far field unless a later boundary sensitivity check shows otherwise.
|
||||
|
||||
## Inlet recommendation by collision model
|
||||
|
||||
Kan99b is an open-flow validation, not a confined-channel benchmark.
|
||||
|
||||
| Collision | Recommended inlet | Secondary choice | Avoid as primary |
|
||||
|---|---|---|---|
|
||||
| SRT | `equilibrium` | `regularized` | `zou_he_local` |
|
||||
| TRT | `regularized` | `equilibrium` | `zou_he_local` until the anchor is stable |
|
||||
| MRT | `regularized` or `zou_he_local` | `equilibrium` | `channel_stabilized` |
|
||||
|
||||
Keep one inlet family per collision model across the primary matrix.
|
||||
|
||||
## Lattice-unit mapping
|
||||
|
||||
Use
|
||||
|
||||
\[
|
||||
U_\infty = 0.03
|
||||
\]
|
||||
|
||||
With `D = 30`,
|
||||
|
||||
\[
|
||||
\nu = \frac{U_\infty D}{Re} = \frac{0.9}{Re}
|
||||
\]
|
||||
|
||||
| `Re` | `nu` | SRT equivalent `omega` |
|
||||
|---|---:|---:|
|
||||
| 60 | 0.015000 | 1.83486 |
|
||||
| 100 | 0.009000 | 1.89753 |
|
||||
| 160 | 0.005625 | 1.93470 |
|
||||
|
||||
The body rotation rate is
|
||||
|
||||
\[
|
||||
\omega_{body} = \frac{2 \alpha U_\infty}{D} = 0.002\alpha
|
||||
\]
|
||||
|
||||
| `alpha` | body omega |
|
||||
|---|---:|
|
||||
| 0.5 | 0.0010 |
|
||||
| 1.0 | 0.0020 |
|
||||
| 1.6 | 0.0032 |
|
||||
| 2.0 | 0.0040 |
|
||||
|
||||
## Primary matrix
|
||||
|
||||
This is the recommended main validation set.
|
||||
|
||||
| Case | `Re` | `alpha` | Role |
|
||||
|---|---:|---:|---|
|
||||
| K1 | 100 | 0.5 | low-rotation lift trend check |
|
||||
| K2 | 100 | 1.0 | strongest hard anchor |
|
||||
| K3 | 60 | 1.6 | low-Re suppression classification |
|
||||
| K4 | 100 | 2.0 | mid-Re suppression classification |
|
||||
| K5 | 160 | 2.0 | high-Re suppression classification |
|
||||
|
||||
Optional baseline if needed for debugging or plots:
|
||||
|
||||
| Case | `Re` | `alpha` | Status |
|
||||
|---|---:|---:|---|
|
||||
| K0 | 100 | 0.0 | optional |
|
||||
|
||||
This matrix covers:
|
||||
|
||||
- one periodic low-rotation trend point
|
||||
- one exact hard anchor with full force data
|
||||
- suppression behavior at low, medium, and high Reynolds number
|
||||
|
||||
## How to judge each case
|
||||
|
||||
### K1
|
||||
|
||||
Use K1 to check the low-rotation lift law.
|
||||
|
||||
Target:
|
||||
|
||||
\[
|
||||
\overline{C_L} \approx -2.48 \times 0.5 \approx -1.24
|
||||
\]
|
||||
|
||||
This is a trend check, not a strict fluctuation-amplitude benchmark.
|
||||
|
||||
### K2
|
||||
|
||||
Use K2 as the hard benchmark case.
|
||||
|
||||
Preferred agreement band:
|
||||
|
||||
| Quantity | Preferred band |
|
||||
|---|---:|
|
||||
| `St` | within 3 percent |
|
||||
| `mean C_L` | within 4 percent |
|
||||
| `mean C_D` | within 5 percent |
|
||||
| `C'_L` | within 8 percent |
|
||||
| `C'_D` | within 10 percent |
|
||||
|
||||
### K3 to K5
|
||||
|
||||
Use K3 to K5 as suppression classification cases.
|
||||
|
||||
Primary success signature:
|
||||
|
||||
- `C'_L` collapses toward zero in the final window
|
||||
- no sustained alternating wake remains
|
||||
- flow classification agrees with the expected suppressed regime
|
||||
|
||||
These are not exact threshold-fitting cases. Do not over-interpret a small residual fluctuation if the wake is otherwise clearly in the suppressed class.
|
||||
|
||||
## Optional threshold bracket check
|
||||
|
||||
If later you want a more explicit threshold study, use pairs around `alpha_L` rather than a single point on the boundary.
|
||||
|
||||
Recommended pairs:
|
||||
|
||||
| `Re` | Lower point | Upper point |
|
||||
|---|---:|---:|
|
||||
| 60 | 1.3 | 1.5 |
|
||||
| 100 | 1.7 | 1.9 |
|
||||
| 160 | 1.8 | 2.0 |
|
||||
|
||||
These should still be treated as regime-location checks, not hard force targets.
|
||||
|
||||
## Run policy
|
||||
|
||||
| Case type | Total steps | Warmup | Statistics |
|
||||
|---|---:|---:|---:|
|
||||
| K1 and K2 | 180000 to 220000 | first 40 percent | last 60 percent |
|
||||
| K3 to K5 | 220000 to 280000 | first 50 percent | last 50 percent |
|
||||
|
||||
The final statistics window should contain at least 20 shedding periods whenever the case remains periodic.
|
||||
|
||||
## TRT re-entry rule
|
||||
|
||||
Bring TRT back in this order:
|
||||
|
||||
1. K2 only
|
||||
2. if K2 is stable and credible, run K1
|
||||
3. only then run K3 to K5
|
||||
|
||||
This prevents TRT from expanding the matrix before the hard anchor is trustworthy.
|
||||
|
||||
## Deliverables
|
||||
|
||||
For each collision model, deliver:
|
||||
|
||||
- one table of run settings including collision, inlet scheme, wall type, `Re`, `alpha`, `nu`, and body omega
|
||||
- one CSV per run with force history
|
||||
- selected field images for wake classification
|
||||
- one summary table with `mean C_D`, `mean C_L`, `C'_D`, `C'_L`, and `St`
|
||||
- one short note stating whether suppression behavior matches [Kan99b]
|
||||
|
||||
## Recommended primary settings summary
|
||||
|
||||
| Collision | Wall | Inlet | Status |
|
||||
|---|---|---|---|
|
||||
| SRT | free slip | `equilibrium` | primary |
|
||||
| TRT | free slip | `regularized` | primary if K2 is stable |
|
||||
| MRT | free slip | `regularized` or `zou_he_local` | primary |
|
||||
|
||||
## MRT-only runner mapping
|
||||
|
||||
The current executable entrypoint is `tests/run_kan99b_rotating_cylinder.py`, and this round uses MRT-only scheduling:
|
||||
|
||||
- primary matrix is `K1-K5` with `MRT + regularized` inlet
|
||||
- one extra control run is added at K2 with `MRT + zou_he_local`
|
||||
- all runs keep `uniform` inlet profile, `free_slip` y-wall, `neq_extrap` outlet
|
||||
- output rows include `case_id`, `variant`, `collision`, `inlet_scheme`, `grid`, `steps`, `burn_in`, `St`, `St_error_pct` (for K2), and force metrics
|
||||
- K2 gate uses this document's per-metric tolerances for `St`, `mean C_L`, `mean C_D`, `C'_L`, `C'_D`
|
||||
|
||||
Example commands:
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py \
|
||||
--json-out tests/output/kan99b_validation/summary_runs.json
|
||||
|
||||
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py \
|
||||
--case K2 --save-vorticity
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
[Kan99b] S. Kang, H. Choi, and S. Lee, “Laminar flow past a rotating circular cylinder,” 1999.
|
||||
243
tests/specs/Sah04_validation.md
Normal file
243
tests/specs/Sah04_validation.md
Normal file
@ -0,0 +1,243 @@
|
||||
# Sah04 confined-cylinder validation against [Sah04]
|
||||
|
||||
## Goal
|
||||
|
||||
This validation should use a small set of direct periodic anchors from [Sah04], plus a small secondary block for near-onset flow-state checks.
|
||||
|
||||
The main design rules are:
|
||||
|
||||
- do not use interpolated values from figures as hard targets
|
||||
- do not use points on or extremely near critical curves as primary pass fail anchors
|
||||
- report realized blockage and realized Reynolds number, not just nominal inputs
|
||||
- use finer grids for high blockage so the narrow wall gaps are not under-resolved
|
||||
|
||||
This keeps the primary matrix small but still representative across moderate and high confinement.
|
||||
|
||||
## What counts as a hard benchmark from [Sah04]
|
||||
|
||||
The strongest periodic-flow anchors are the direct DNS values stated in the paper for developed unsteady states.
|
||||
|
||||
| Case | Blockage `beta` | Reynolds number | `St` target | Why it is a hard anchor |
|
||||
|---|---:|---:|---:|---|
|
||||
| S1 | 0.3 | 100 | 0.2115 | direct periodic DNS value |
|
||||
| S2 | 0.5 | 200 | 0.3513 | direct periodic DNS value |
|
||||
| S3 | 0.8 | 160 | `T ≈ 1.806`, so `St ≈ 0.5537` | direct case given in the paper |
|
||||
| S4 | 0.9 | 200 | 0.5314 | direct periodic DNS value |
|
||||
|
||||
These should be the primary Sah04 validation anchors.
|
||||
|
||||
## What should not be a hard target
|
||||
|
||||
The following are useful for qualitative or secondary checks, but not for the main validation gate:
|
||||
|
||||
- critical onset values from Table IV
|
||||
- any `St` or force value obtained by reading or interpolating a plot
|
||||
- cases chosen only to complete a rectangular parameter grid
|
||||
- points too close to the codimension two region or nearby neutral stability boundaries
|
||||
|
||||
This matters because the paper's stability map has several sensitive regions, especially at high blockage and near symmetry breaking. In the stability figure from [Sah04] shown above, those boundaries are exactly where a small setup difference can change the observed state.
|
||||
|
||||
## Geometry and blockage mapping
|
||||
|
||||
Keep the confined-channel layout and no-slip walls.
|
||||
|
||||
The validation table must always report both nominal and realized blockage.
|
||||
|
||||
With `D = 30` lattice units, the recommended realizations are:
|
||||
|
||||
| Case | `beta_nominal` | Suggested `H` | `beta_real` | Notes |
|
||||
|---|---:|---:|---:|---|
|
||||
| S1 | 0.3 | 100 | 0.3000 | exact |
|
||||
| S2 | 0.5 | 60 | 0.5000 | exact |
|
||||
| S3 | 0.8 | 38 or 37 | 0.7895 or 0.8108 | pick one and report it explicitly |
|
||||
| S4 | 0.9 | 33 | 0.9091 | use this, not `H = 35` |
|
||||
|
||||
Do not silently rename `beta_real` as the paper blockage.
|
||||
|
||||
## Grid density policy
|
||||
|
||||
High blockage cases need more wall-normal resolution than the base grid.
|
||||
|
||||
Minimum rule:
|
||||
|
||||
- for `beta < 0.8`, the baseline grid with `D = 30` is acceptable for the first pass
|
||||
- for `beta >= 0.8`, increase grid density by at least 2 times in each spatial direction before treating the result as validation quality
|
||||
|
||||
A practical way to do this is to keep geometry similarity while doubling the characteristic resolution:
|
||||
|
||||
- base cases: `D = 30`
|
||||
- high blockage validation cases: at least `D = 60`
|
||||
|
||||
Recommended high blockage realizations on the refined grid:
|
||||
|
||||
| Case | `beta_nominal` | Suggested refined `D` | Suggested refined `H` | `beta_real` |
|
||||
|---|---:|---:|---:|---:|
|
||||
| S3 | 0.8 | 60 | 75 | 0.8000 |
|
||||
| S4 | 0.9 | 60 | 67 | 0.8955 |
|
||||
|
||||
The point of this refinement is not only bulk accuracy. It is also to resolve the narrow cylinder wall gaps and reduce the risk that blockage effects are dominated by lattice geometry error.
|
||||
|
||||
## Primary matrix
|
||||
|
||||
This is the main Sah04 validation set.
|
||||
|
||||
| Case | `beta_nominal` | Primary target | Role |
|
||||
|---|---:|---|---|
|
||||
| S1 | 0.3 | `Re = 100`, `St = 0.2115` | moderate blockage periodic anchor |
|
||||
| S2 | 0.5 | `Re = 200`, `St = 0.3513` | medium blockage periodic anchor |
|
||||
| S3 | 0.8 | `Re = 160`, `St ≈ 0.5537` | high blockage periodic anchor |
|
||||
| S4 | 0.9 | `Re = 200`, `St = 0.5314` | very high blockage periodic anchor |
|
||||
|
||||
This matrix is smaller than the older grid, but it covers:
|
||||
|
||||
- moderate confinement
|
||||
- stronger confinement
|
||||
- high blockage periodic shedding
|
||||
- very high blockage periodic shedding
|
||||
|
||||
## Secondary onset block
|
||||
|
||||
These cases are recommended as flow-state checks, not hard `St` benchmarks.
|
||||
|
||||
| Case | `beta_nominal` | Suggested `Re` | Why it is useful | How to judge it |
|
||||
|---|---:|---:|---|---|
|
||||
| SO1 | 0.5 | about 130 | safely above first onset without sitting on the boundary | confirm sustained periodic state |
|
||||
| SO2 | 0.7 | about 120 | tests near-onset behavior in a more sensitive blockage range | confirm sustained periodic state |
|
||||
|
||||
These points exist to answer a different question from the primary matrix:
|
||||
|
||||
- does the solver enter and maintain the right flow regime once slightly above onset
|
||||
|
||||
For SO1 and SO2, judge by:
|
||||
|
||||
- persistent nonzero `C'_L`
|
||||
- a clean dominant spectral peak
|
||||
- repeatable periodic wake structure
|
||||
|
||||
Do not fail these runs because the measured `St` differs slightly from a value read from a nearby figure.
|
||||
|
||||
## Inlet and wall policy
|
||||
|
||||
Sah04 is a confined-channel benchmark, so inlet consistency matters more than inlet variety.
|
||||
|
||||
| Collision | Wall | Inlet | Status |
|
||||
|---|---|---|---|
|
||||
| SRT | no slip | `channel_stabilized` | primary |
|
||||
| TRT | no slip | `channel_stabilized` | primary |
|
||||
| MRT | no slip | `channel_stabilized` | primary |
|
||||
|
||||
Keep the inlet family fixed across collision models in the primary matrix.
|
||||
|
||||
Secondary inlet comparison, only after the primary set is working:
|
||||
|
||||
| Collision | Optional inlet | Status |
|
||||
|---|---|---|
|
||||
| MRT | `regularized` or `zou_he_local` | exploratory |
|
||||
| SRT or TRT | `equilibrium` or `regularized` | exploratory |
|
||||
|
||||
## Realized Reynolds number check
|
||||
|
||||
This is mandatory for Sah04.
|
||||
|
||||
For each run, record:
|
||||
|
||||
- nominal inlet definition
|
||||
- developed downstream velocity profile
|
||||
- measured `U_max,real`
|
||||
- measured bulk velocity if available
|
||||
- `beta_nominal`
|
||||
- `beta_real`
|
||||
- `Re_nominal`
|
||||
- `Re_real`
|
||||
|
||||
Use the paper-consistent label
|
||||
|
||||
\[
|
||||
Re_{real} = \frac{U_{max,real} D}{\nu}
|
||||
\]
|
||||
|
||||
for the final comparison table.
|
||||
|
||||
If `Re_real` drifts materially from the intended target, treat that as a setup problem before treating it as a Strouhal miss.
|
||||
|
||||
## Run policy
|
||||
|
||||
| Case block | Total steps | Burn | Statistics |
|
||||
|---|---:|---:|---:|
|
||||
| S1 and S2 | 100000 to 160000 | first 35 to 40 percent | last 60 to 65 percent |
|
||||
| S3 and S4 | 180000 to 260000 | first 45 percent | last 55 percent |
|
||||
| SO1 and SO2 | 140000 to 220000 | first 45 percent | last 55 percent |
|
||||
|
||||
For high blockage refined runs, prefer the longer end of the window.
|
||||
|
||||
## Evaluation rule
|
||||
|
||||
Use a two-layer rule.
|
||||
|
||||
### Primary periodic anchors
|
||||
|
||||
| Case | Hard target use |
|
||||
|---|---|
|
||||
| S1 to S4 | hard periodic benchmark anchors |
|
||||
|
||||
Preferred agreement band:
|
||||
|
||||
- within 5 percent when `Re_real` is close to target and the spectrum is clean
|
||||
- within 10 percent still acceptable if the run is clearly periodic and the residual mismatch is explainable by `Re_real` drift or geometry realization
|
||||
|
||||
### Secondary onset block
|
||||
|
||||
| Case | Hard target use |
|
||||
|---|---|
|
||||
| SO1 and SO2 | no hard `St` gate |
|
||||
|
||||
Success means:
|
||||
|
||||
- the flow is clearly unsteady and periodic
|
||||
- the dominant frequency is stable over long windows
|
||||
- the wake classification is consistent with being above onset
|
||||
|
||||
## Deliverables
|
||||
|
||||
For each run, deliver:
|
||||
|
||||
- one row with `beta_nominal`, `beta_real`, `Re_nominal`, `Re_real`, `nu`, collision, wall type, inlet scheme, and grid resolution
|
||||
- one downstream velocity-profile plot
|
||||
- one force-history CSV
|
||||
- one `St` estimate with the exact analysis window stated
|
||||
- selected wake images for flow classification
|
||||
|
||||
## Recommended minimum set
|
||||
|
||||
If compute budget is tight, run this order first:
|
||||
|
||||
| Priority | Runs |
|
||||
|---|---|
|
||||
| 1 | MRT on S1 to S4 |
|
||||
| 2 | SRT on S2 and S4 |
|
||||
| 3 | TRT on S2 and S4 |
|
||||
| 4 | SO1 and SO2 only after the primary anchors are behaving |
|
||||
|
||||
## MRT-only runner mapping
|
||||
|
||||
The current executable entrypoint is `tests/run_sah04_st_matrix.py`, and it is now aligned to this document's primary S1-S4 matrix:
|
||||
|
||||
- collision is fixed to `MRT`
|
||||
- inlet is fixed to `parabolic + channel_stabilized`
|
||||
- case set is `S1-S4` only
|
||||
- output rows include `case_id`, `collision`, `inlet_scheme`, `grid`, `steps`, `burn_in`, `St`, `St_error_pct`, `Re_real`, `beta_real`
|
||||
- default hard gate is 5% (`--gate-pct` can relax it to 10%)
|
||||
|
||||
Example commands:
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py \
|
||||
--json-out tests/output/sah04_mrt/summary.json
|
||||
|
||||
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py \
|
||||
--case S3 --gate-pct 10 --final-vorticity-dir tests/output/sah04_mrt/vorticity
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
[Sah04] M. Sahin and R. G. Owens, “A numerical investigation of wall effects up to high blockage ratios on two-dimensional flow past a confined circular cylinder,” 2004.
|
||||
181
tests/specs/exp_ctrl_matrix.md
Normal file
181
tests/specs/exp_ctrl_matrix.md
Normal file
@ -0,0 +1,181 @@
|
||||
229:无控制:
|
||||
```
|
||||
SIGNAL_FEATURES0 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0, 1.600),
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0, 2.099),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0, 1.639),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
234:隐身:
|
||||
```
|
||||
SIGNAL_FEATURES1 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0, 1.600),
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.01806,
|
||||
'components': [
|
||||
(0.1354, 0.0, 2.099),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.01806,
|
||||
'components': [
|
||||
(0.1354, 0.0, 1.639),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
欺骗:
|
||||
237 238 253:
|
||||
```
|
||||
SIGNAL_FEATURES2 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0026, 1.600), # 主频
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.008730,
|
||||
'components': [
|
||||
(0.1354, 0.0045, 2.099), # 主频
|
||||
(0.2708, 0.0010, 0.612),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.008730,
|
||||
'components': [
|
||||
(0.1354, 0.0045, 1.639), # 主频
|
||||
(0.2708, 0.0010, -2.962),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
257:
|
||||
```
|
||||
SIGNAL_FEATURES3 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.1354, 0.0029, -2.619), # 主频
|
||||
(0.2708, 0.0008, 2.856),
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.0140,
|
||||
'components': [
|
||||
(0.1354, 0.0050, -0.933), # 主频
|
||||
(0.2708, 0.0010, 0.801),
|
||||
(0.1806, 0.0003, 1.854),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.014,
|
||||
'components': [
|
||||
(0.1354, 0.0050, -1.398), # 主频
|
||||
(0.2708, 0.0010, 2.208),
|
||||
(0.1806, 0.0003, 1.810),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
260 (f*1.5):
|
||||
```
|
||||
SIGNAL_FEATURES4 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.2031, 0.0026, 1.600), # 主频
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.008730,
|
||||
'components': [
|
||||
(0.2031, 0.0045, 2.099), # 主频
|
||||
(0.4062, 0.0010, 0.612),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.008730,
|
||||
'components': [
|
||||
(0.2031, 0.0045, 1.639), # 主频
|
||||
(0.4062, 0.0010, -2.962),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
262: (f*1.5):
|
||||
```
|
||||
SIGNAL_FEATURES5 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.2031, 0.0029, -2.619), # 主频
|
||||
(0.4062, 0.0008, 2.856),
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.0140,
|
||||
'components': [
|
||||
(0.2031, 0.0050, -0.933), # 主频
|
||||
(0.4062, 0.0010, 0.801),
|
||||
(0.2709, 0.0003, 1.854),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.014,
|
||||
'components': [
|
||||
(0.2031, 0.0050, -1.398), # 主频
|
||||
(0.4062, 0.0010, 2.208),
|
||||
(0.2709, 0.0003, 1.810),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
270 (f*2):
|
||||
```
|
||||
SIGNAL_FEATURES6 = {
|
||||
'action1': {
|
||||
'mean': 0.0,
|
||||
'components': [
|
||||
(0.2708, 0.0044, -2.619), # 主频
|
||||
(0.8124, 0.0012, 2.856),
|
||||
]
|
||||
},
|
||||
'action2': {
|
||||
'mean': -0.014,
|
||||
'components': [
|
||||
(0.2708, 0.0075, -0.933), # 主频
|
||||
(0.8124, 0.0015, 0.801),
|
||||
(0.5418, 0.0005, 1.854),
|
||||
]
|
||||
},
|
||||
'action3': {
|
||||
'mean': 0.014,
|
||||
'components': [
|
||||
(0.2708, 0.0075, -1.398), # 主频
|
||||
(0.8124, 0.0015, 2.208),
|
||||
(0.5418, 0.0005, 1.810),
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
493
tests/specs/streakline_notes.md
Normal file
493
tests/specs/streakline_notes.md
Normal file
@ -0,0 +1,493 @@
|
||||
## Streakline postprocessing design
|
||||
|
||||
这份说明面向一个最小可实现的离线后处理程序。输入是按时间保存的二维速度场序列,输出是接近实验染色图的 streakline 图像。核心路线是连续释粒,因为 streakline 正是“固定位置持续释放染料后,在某个时刻所有已释放粒子的位置集合” [Lan96]。对非定常尾迹,这比单帧 streamline 更贴近实验图像 [Lan96]。
|
||||
|
||||
搜索和全文阅读给出的信息已经足够支撑第一版实现。最直接的方案是按保存时刻连续注入粒子,用时空插值后的速度场推进粒子,再将粒子云渲染为图像 [Lan96, Ken96]。如果纯粒子结果过于尖锐,再在推进后加入一个很小的随机扩散项,作为对真实染色液扩散的近似。更完整的被动标量法当然更物理,但实现成本更高,不适合作为第一版 [Kim04]。
|
||||
|
||||
## Core recommendation
|
||||
|
||||
| 方法 | 与实验染色图的对应 | 实现复杂度 | 适合作为第一版 |
|
||||
|---|---|---:|---|
|
||||
| 单帧 streamline | 弱 | 很低 | 不推荐 |
|
||||
| 连续释粒 streakline | 强 | 低 | 最推荐 |
|
||||
| streakline 加少量扩散 | 很强 | 低到中 | 推荐作为第二步 |
|
||||
| 被动标量对流扩散 | 最强 | 中到高 | 暂不作为第一版 |
|
||||
|
||||
最小可实现路线如下:
|
||||
|
||||
- 从 CelerisLab 导出一串二维速度场快照 `u_x(x,y,t_k), u_y(x,y,t_k)`
|
||||
- 选定一个固定释放点或一小段释放线
|
||||
- 在每个保存时刻都注入新粒子
|
||||
- 在两个相邻快照之间,用时间插值和空间插值计算粒子速度
|
||||
- 用二阶或四阶时间积分推进粒子
|
||||
- 删去出域粒子与进入固体的粒子
|
||||
- 将当前时刻存活粒子按位置和年龄渲染成图像
|
||||
|
||||
这个程序逻辑直接对应 [Lan96] 的 streakline 算法,插值与推进细节由 [Ken96] 和 [Dar96] 支撑。
|
||||
|
||||
## Data contract
|
||||
|
||||
默认输入是规则 Cartesian 网格上的二维时间序列速度场。对当前项目,最自然的输入约定是:
|
||||
|
||||
| 名称 | 含义 |
|
||||
|---|---|
|
||||
| `t_k` | 第 `k` 个保存时刻 |
|
||||
| `ux[k, j, i]` | 时刻 `t_k` 的 x 方向速度 |
|
||||
| `uy[k, j, i]` | 时刻 `t_k` 的 y 方向速度 |
|
||||
| `mask[j, i]` | 可选,固体与流体标记 |
|
||||
| `x_i, y_j` | 网格坐标 |
|
||||
|
||||
第一版最好满足下面三条:
|
||||
|
||||
- 时间快照间隔固定或至少已知
|
||||
- 网格坐标固定不动
|
||||
- 固体几何位置已知,至少能判断粒子是否进入圆柱内部
|
||||
|
||||
如果输出来自 CelerisLab,本质上只需要把每个保存时刻的 `ux, uy` 和对应时间写出来即可。算法本身是通用的,并不依赖 LBM 本身。
|
||||
|
||||
## Mathematical model for the base streakline
|
||||
|
||||
### Particle motion
|
||||
|
||||
粒子位置满足拉格朗日运动方程 [Lan96]:
|
||||
|
||||
\[
|
||||
\frac{d \boldsymbol{x}}{dt} = \boldsymbol{u}(\boldsymbol{x}, t)
|
||||
\]
|
||||
|
||||
其中
|
||||
|
||||
\[
|
||||
\boldsymbol{x} = (x, y), \qquad \boldsymbol{u} = (u_x, u_y)
|
||||
\]
|
||||
|
||||
积分形式为 [Lan96]:
|
||||
|
||||
\[
|
||||
\boldsymbol{x}(t + \Delta t) = \boldsymbol{x}(t) + \int_t^{t+\Delta t} \boldsymbol{u}(\boldsymbol{x}(\tau), \tau) \, d\tau
|
||||
\]
|
||||
|
||||
streakline 在观察时刻 \(t_n\) 的定义是:从固定释放位置 \(\boldsymbol{x}_s\) 在过去各时刻持续注入的粒子,在 \(t_n\) 时刻的全部位置集合 [Lan96]。
|
||||
|
||||
### Time interpolation
|
||||
|
||||
速度场只在离散时刻存储,所以必须做时间插值。对位于 \(t_k \le t \le t_{k+1}\) 的任意子步,最简单的做法是线性时间插值 [Ken96]:
|
||||
|
||||
\[
|
||||
\delta = \frac{t - t_k}{t_{k+1} - t_k}
|
||||
\]
|
||||
|
||||
\[
|
||||
\boldsymbol{u}(\boldsymbol{x}, t) = (1 - \delta) \, \boldsymbol{u}_k(\boldsymbol{x}) + \delta \, \boldsymbol{u}_{k+1}(\boldsymbol{x})
|
||||
\]
|
||||
|
||||
这里 \(\boldsymbol{u}_k(\boldsymbol{x})\) 和 \(\boldsymbol{u}_{k+1}(\boldsymbol{x})\) 仍需通过空间插值得到。
|
||||
|
||||
### Spatial interpolation
|
||||
|
||||
在规则网格上,第一版直接用双线性插值即可。虽然 [Ken96] 讨论的是非结构网格上的点定位与线性插值,但其一般原则完全适用于规则网格:
|
||||
|
||||
- 先定位粒子所在单元
|
||||
- 再用单元顶点速度插值得到粒子点速度 [Ken96]
|
||||
|
||||
若粒子位于单元局部坐标 \((\xi, \eta) \in [0,1]^2\),四个角点速度为 \(\boldsymbol{u}_{00}, \boldsymbol{u}_{10}, \boldsymbol{u}_{01}, \boldsymbol{u}_{11}\),则
|
||||
|
||||
\[
|
||||
\boldsymbol{u}(\xi, \eta) = (1-\xi)(1-\eta) \boldsymbol{u}_{00}
|
||||
+ \xi (1-\eta) \boldsymbol{u}_{10}
|
||||
+ (1-\xi)\eta \boldsymbol{u}_{01}
|
||||
+ \xi\eta \boldsymbol{u}_{11}
|
||||
\]
|
||||
|
||||
## Time integration choice
|
||||
|
||||
[Lan96] 和 [Ken96] 都直接使用四阶 Runge Kutta。对你的第一版程序,推荐两个选项:
|
||||
|
||||
| 积分器 | 优点 | 缺点 | 建议 |
|
||||
|---|---|---|---|
|
||||
| RK2 | 简洁,容易调试 | 精度一般 | 最小原型可用 |
|
||||
| RK4 | 文献最一致,精度更稳 | 每步插值次数更多 | 默认推荐 |
|
||||
|
||||
RK4 的更新式为 [Lan96, Ken96]:
|
||||
|
||||
\[
|
||||
\boldsymbol{x}_{n+1} = \boldsymbol{x}_n + \frac{1}{6}(\boldsymbol{a} + 2\boldsymbol{b} + 2\boldsymbol{c} + \boldsymbol{d})
|
||||
\]
|
||||
|
||||
其中
|
||||
|
||||
\[
|
||||
\boldsymbol{a} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n, t_n)
|
||||
\]
|
||||
|
||||
\[
|
||||
\boldsymbol{b} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \tfrac{1}{2}\boldsymbol{a}, t_n + \tfrac{1}{2}\Delta t)
|
||||
\]
|
||||
|
||||
\[
|
||||
\boldsymbol{c} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \tfrac{1}{2}\boldsymbol{b}, t_n + \tfrac{1}{2}\Delta t)
|
||||
\]
|
||||
|
||||
\[
|
||||
\boldsymbol{d} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \boldsymbol{c}, t_n + \Delta t)
|
||||
\]
|
||||
|
||||
[Ken96] 特别强调,RK4 的每一个子步都要重新做点定位与时空插值。这在规则网格上不难实现,只是意味着一次完整步进需要四次速度查询 [Ken96]。
|
||||
|
||||
## Practical timestep guidance
|
||||
|
||||
第一版不必上自适应步长,但步长不能随意设大。搜索里最重要的精度提醒来自 [Dar96]:
|
||||
|
||||
- 非定常粒子积分的误差常常由时间离散控制,而不是概念本身
|
||||
- 合理步长必须不大于流动主要非定常时间尺度的同量级 [Dar96]
|
||||
- 若时间步过大,粒子轨迹会在拓扑上都出错,而不仅是位置略偏 [Dar96]
|
||||
|
||||
对当前项目,最简单的工程准则是:
|
||||
|
||||
\[
|
||||
\Delta t_{trace} \le \min \bigl(\alpha_t \, \Delta t_{save},\; \alpha_x \, \frac{\Delta x}{\max |u|} \bigr)
|
||||
\]
|
||||
|
||||
其中 \(\Delta t_{save}\) 是两个保存快照之间的时间间隔,\(\Delta x\) 是网格尺度。第一版可取:
|
||||
|
||||
- \(\alpha_t = 0.1 \sim 0.25\)
|
||||
- \(\alpha_x = 0.25 \sim 0.5\)
|
||||
|
||||
这不是文献中的严格上界,而是结合 [Dar96] 的结论给出的实现准则:粒子推进子步既要显著小于保存间隔,也不要一子步跨过太多网格。
|
||||
|
||||
[Ken96] 还给了一个很实用的自适应思路:根据相邻速度方向夹角调节步长。如果速度方向变化太快就减半,变化很小就加倍 [Ken96]。这很适合后续增强版,但不属于第一版必需项。
|
||||
|
||||
## Base streakline algorithm
|
||||
|
||||
### Release strategy
|
||||
|
||||
最符合染色实验的是连续释粒 [Lan96]。第一版可用两种释放方式:
|
||||
|
||||
| 方式 | 适用场景 | 建议 |
|
||||
|---|---|---|
|
||||
| 单点释放 | 针头式染料入口 | 最简单 |
|
||||
| 短线段释放 | 更像细缝或细带注入 | 更稳健 |
|
||||
|
||||
如果实验是在圆柱上游某一点持续释放染色液,第一版就直接使用单点释放。
|
||||
|
||||
### Particle state
|
||||
|
||||
每个粒子只需保存:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `x, y` | 当前坐标 |
|
||||
| `t_birth` | 释放时刻 |
|
||||
| `age` | 当前年龄 |
|
||||
| `alive` | 是否仍在域内 |
|
||||
|
||||
第一版不需要保存整条历史轨迹。因为你要的是当前时刻的 streakline 图,而不是每个粒子的 pathline 曲线。
|
||||
|
||||
**常见误区(务必区分)**
|
||||
|
||||
| 概念 | 画什么 | 是否等于水洞染色迹线 |
|
||||
|---|---|---|
|
||||
| **Streakline(迹线)** | 固定源点**持续释粒**,在观察时刻 \(t_n\) 画出**所有仍存活粒子的当前位置**(可带年龄衰减权重) | 是 |
|
||||
| **Pathline(迹线/轨道)** | 单个粒子从释放到当前的**整条历史轨迹** | 否 |
|
||||
| **错误实现** | 每隔一段时间放一个粒子,再把**所有粒子走过的路径段**全部叠加到图上 | 否(这是 pathline 叠加,不是 streakline) |
|
||||
|
||||
水洞实验:针头在固定点连续注 dye → 某一时刻拍照 → 看到的是“此刻染料粒子在流场里的分布”,一条下游色带由**不同释放时刻、当前仍在本流场中的粒子**共同构成,而不是把每个粒子从出生到现在的轨迹都画出来。
|
||||
|
||||
### Main loop pseudocode
|
||||
|
||||
```text
|
||||
given velocity snapshots U[k] = {ux[k], uy[k]} at times t[k]
|
||||
given seeding point or seeding segment S
|
||||
initialize empty particle list P
|
||||
|
||||
for k = 0 to N-2:
|
||||
|
||||
inject new particles at source S at time t[k]
|
||||
|
||||
set t_local = t[k]
|
||||
while t_local < t[k+1]:
|
||||
dt = min(dt_trace, t[k+1] - t_local)
|
||||
|
||||
for each particle p in P with p.alive:
|
||||
v = interpolate_velocity(p.position, t_local)
|
||||
advance p by one integration step using RK2 or RK4
|
||||
update p.age
|
||||
if p leaves domain:
|
||||
p.alive = false
|
||||
if p enters solid body:
|
||||
p.alive = false
|
||||
|
||||
t_local = t_local + dt
|
||||
|
||||
optionally remove very old particles
|
||||
optionally render current particle cloud
|
||||
```
|
||||
|
||||
这个结构与 [Lan96] 的离散 streakline 算法一致,只是把“从 `t_k` 到 `t_{k+1}` 的一次推进”细化成多个更小的粒子子步,以满足 [Dar96] 对精度的要求。
|
||||
|
||||
### Velocity query pseudocode
|
||||
|
||||
```text
|
||||
function interpolate_velocity(position x, time t):
|
||||
find k such that t[k] <= t <= t[k+1]
|
||||
compute delta = (t - t[k]) / (t[k+1] - t[k])
|
||||
|
||||
u_k = bilinear_interpolation(U[k], x)
|
||||
u_k1 = bilinear_interpolation(U[k+1], x)
|
||||
|
||||
return (1 - delta) * u_k + delta * u_k1
|
||||
```
|
||||
|
||||
这是第一版最核心的数值部件。只要这部分实现正确,streakline 程序主体就很直接。
|
||||
|
||||
## Small diffusion particle model
|
||||
|
||||
当纯 streakline 太细、太锐利、不像染色液图像时,可以给每个粒子加一个很小的随机扩散项。这不是严格的被动标量求解,但能以很低代价增加染色带宽度。
|
||||
|
||||
### Guiding idea
|
||||
|
||||
[Kim04] 表明,染料图像更接近一个被动标量浓度场,其基本控制方程是对流扩散方程:
|
||||
|
||||
\[
|
||||
\frac{\partial c}{\partial t} + \boldsymbol{u} \cdot \nabla c = D \nabla^2 c
|
||||
\]
|
||||
|
||||
其中 \(c\) 是染料浓度,\(D\) 是扩散系数。对第一版粒子法,一个常见近似是将每个粒子的位置更新写成“对流加随机扩散”:
|
||||
|
||||
\[
|
||||
\boldsymbol{x}_{n+1} = \boldsymbol{x}_n + \Delta \boldsymbol{x}_{adv} + \Delta \boldsymbol{x}_{diff}
|
||||
\]
|
||||
|
||||
其中 \(\Delta \boldsymbol{x}_{adv}\) 由 RK2 或 RK4 给出,扩散项取二维各向同性随机增量:
|
||||
|
||||
\[
|
||||
\Delta \boldsymbol{x}_{diff} = \sqrt{2 D \Delta t}
|
||||
\begin{bmatrix}
|
||||
\eta_x \\
|
||||
\eta_y
|
||||
\end{bmatrix}
|
||||
\]
|
||||
|
||||
这里 \(\eta_x, \eta_y \sim \mathcal{N}(0,1)\)。
|
||||
|
||||
这个形式本身是对扩散过程的标准随机游走近似。搜索结果提醒,随机游走模型如果处理不当会产生假漂移与错误浓度偏置 [Mac92]。因此第一版的使用原则应当很克制:
|
||||
|
||||
- 只用于加入少量模糊和厚度
|
||||
- 不把粒子密度当成严格浓度
|
||||
- 不在强近壁统计上过度解读结果
|
||||
|
||||
### Diffusive particle update pseudocode
|
||||
|
||||
```text
|
||||
for each particle p in P with p.alive:
|
||||
x_adv = RK4_step(p.position, t_local, dt)
|
||||
|
||||
sigma = sqrt(2 * D * dt)
|
||||
dx_rand = sigma * normal(0, 1)
|
||||
dy_rand = sigma * normal(0, 1)
|
||||
|
||||
x_new = x_adv + [dx_rand, dy_rand]
|
||||
|
||||
if x_new leaves domain:
|
||||
p.alive = false
|
||||
else if x_new enters solid:
|
||||
p.alive = false
|
||||
else:
|
||||
p.position = x_new
|
||||
p.age += dt
|
||||
```
|
||||
|
||||
### Choosing the diffusion level
|
||||
|
||||
第一版不必试图从真实染料物性严格标定 \(D\)。更实用的做法是把 \(D\) 当作视觉匹配参数,并保持它足够小,使图像结构仍主要由对流控制。
|
||||
|
||||
[Kim04] 用 Schmidt 数控制扩散强弱。若已有参考速度 \(U\) 和长度尺度 \(L\),则
|
||||
|
||||
\[
|
||||
Sc = \frac{\nu}{D}
|
||||
\]
|
||||
|
||||
也可写成
|
||||
|
||||
\[
|
||||
D = \frac{\nu}{Sc}
|
||||
\]
|
||||
|
||||
对第一版,可以用下面的思路选扩散强度:
|
||||
|
||||
| 目标效果 | 建议 |
|
||||
|---|---|
|
||||
| 只想让线条略微变厚 | 取较大 `Sc`,即很小的 `D` |
|
||||
| 想模拟明显洗开和模糊 | 取较小 `Sc`,即较大的 `D` |
|
||||
| 不确定 | 先从几乎看不出的弱扩散开始 |
|
||||
|
||||
因为当前目标是“少量扩散”,所以推荐先把扩散当成弱修饰,而不是主导机制。
|
||||
|
||||
## Rendering logic
|
||||
|
||||
最终图像不需要把每个粒子的完整轨迹都画出来。更像实验染色图的做法是把当前存活粒子投影到图像网格上,生成粒子密度图或带年龄权重的强度图。
|
||||
|
||||
### Minimal rendering choices
|
||||
|
||||
| 方法 | 图像风格 | 实现难度 |
|
||||
|---|---|---:|
|
||||
| 直接散点 | 最简陋 | 很低 |
|
||||
| 网格计数直方图 | 像浓度图 | 低 |
|
||||
| 高斯核累积 | 更平滑 | 低到中 |
|
||||
|
||||
第一版推荐:
|
||||
|
||||
- 把每个粒子投到像素网格
|
||||
- 对像素做计数或加权累积
|
||||
- 最后做一次轻微 Gaussian blur
|
||||
|
||||
这与先做完整被动标量相比便宜很多,但视觉上已经会很接近实验染色图。
|
||||
|
||||
### Optional particle weighting
|
||||
|
||||
可以给粒子一个简单权重:
|
||||
|
||||
\[
|
||||
I = \sum_p w_p K(\boldsymbol{x} - \boldsymbol{x}_p)
|
||||
\]
|
||||
|
||||
其中 \(K\) 是像素核或 Gaussian 核。第一版里,权重 \(w_p\) 可直接取 1,也可对年龄做衰减,例如:
|
||||
|
||||
\[
|
||||
w_p = \exp\left(- \frac{\mathrm{age}_p}{\tau_f} \right)
|
||||
\]
|
||||
|
||||
这样旧粒子会逐渐淡出,图像不会无限堆积。
|
||||
|
||||
## Boundary handling
|
||||
|
||||
这部分先按最小原则处理即可。
|
||||
|
||||
| 情况 | 第一版处理 |
|
||||
|---|---|
|
||||
| 粒子出计算域 | 直接删除 |
|
||||
| 粒子进入圆柱内部 | 直接删除 |
|
||||
| 粒子贴近边界滑动 | 暂不专门处理 |
|
||||
| 扩散后跨入固体 | 直接删除 |
|
||||
|
||||
这样做的优点是简单稳妥。后续若发现近壁 streakline 误差明显,再考虑反射、投影或更物理的壁面处理。
|
||||
|
||||
## Minimal implementation plan
|
||||
|
||||
### Version 1
|
||||
|
||||
目标是尽快得到可信的 streakline 图:
|
||||
|
||||
- 读取 `ux, uy, t`
|
||||
- 单点连续释粒
|
||||
- 双线性空间插值
|
||||
- 线性时间插值
|
||||
- RK4 粒子推进
|
||||
- 出域删除与入固体删除
|
||||
- 粒子计数成图
|
||||
|
||||
### Version 2
|
||||
|
||||
在不改变主体架构的前提下增强视觉效果:
|
||||
|
||||
- 增加短线段释放
|
||||
- 增加年龄衰减
|
||||
- 增加弱随机扩散
|
||||
- 用 Gaussian 核代替简单计数
|
||||
|
||||
### Version 3
|
||||
|
||||
若后续发现实验图像明显受扩散与混合主导,再转向更重的模型:
|
||||
|
||||
- 被动标量对流扩散
|
||||
- 用 \(Sc\) 或 \(D\) 做参数标定 [Kim04]
|
||||
|
||||
## Design decisions that are already justified by the literature
|
||||
|
||||
下面这些设计已经有足够文献支撑,可以直接采用:
|
||||
|
||||
- 用 streakline 而不是 streamline 对应持续染色实验 [Lan96]
|
||||
- 用连续释放粒子离线重构 streakline [Lan96]
|
||||
- 用时空插值从离散速度快照查询粒子速度 [Ken96]
|
||||
- 用 RK4 作为默认推进器 [Lan96, Ken96]
|
||||
- 把步长取得明显小于保存时间间隔,并避免一次跨过太多网格 [Dar96]
|
||||
- 若需要更像染料图,可以加入弱扩散,或后续上升到被动标量 [Kim04]
|
||||
|
||||
## Recommended default choices
|
||||
|
||||
| 项目 | 默认选择 |
|
||||
|---|---|
|
||||
| 释放方式 | 单点连续释粒 |
|
||||
| 空间插值 | 双线性 |
|
||||
| 时间插值 | 线性 |
|
||||
| 积分器 | RK4 |
|
||||
| 粒子子步 | 保存步长的 0.1 到 0.25 |
|
||||
| 固体处理 | 入固体即删除 |
|
||||
| 输出图像 | 粒子密度图加轻微模糊 |
|
||||
| 扩散 | 默认关闭,作为第二步 |
|
||||
|
||||
## Bottom line
|
||||
|
||||
对当前项目,最简洁且足够准确的程序逻辑不是去解新的染料场,而是先做一个离线连续释粒 streakline 后处理器。它只依赖时间序列速度场,程序结构清楚,数值风险也集中在可控的几个环节:时空插值、积分步长和边界删除 [Lan96, Dar96, Ken96]。在此基础上,再增加一个弱随机扩散项,就能以很小代价把图像从“几何上正确的粒子线”推进到“更像实验染色照片”的粒子云 [Kim04]。
|
||||
|
||||
## CelerisLab implementation mapping
|
||||
|
||||
Current implementation lives in:
|
||||
|
||||
- library: `src/CelerisLab/common/streakline.py`
|
||||
- Kan99b demo CLI: `tests/run_kan99b_streakline.py`
|
||||
- experiment notebook: `tests/experiment.ipynb` (CLEAN 5/6)
|
||||
|
||||
Available runtime modes:
|
||||
|
||||
- `online`: sample `Simulation.get_macroscopic()` directly in memory (no velocity snapshot files).
|
||||
- `offline`: replay from an existing snapshot directory and run the same streakline integrator.
|
||||
|
||||
Dense release support:
|
||||
|
||||
- base upstream points are expanded by `release_mode` (`point|line|strip`).
|
||||
- `strip` mode densifies both cross-stream (`line_count`, `line_span`) and downstream (`downstream_count`, `downstream_spacing`) directions.
|
||||
|
||||
### Performance notes (why it can feel slow)
|
||||
|
||||
Streakline post-processing is **CPU-side** in the current stack:
|
||||
|
||||
1. **GPU→host velocity copies** dominate for large grids (`nx=6000, ny=1200`). Each `get_macroscopic()` pulls full `ux/uy` arrays.
|
||||
2. Particle RK4 + bilinear interpolation runs in NumPy on CPU (vectorized over particles, not GPU).
|
||||
3. Rendering accumulates polylines into an image (optionally multi-threaded via `ThreadPoolExecutor`).
|
||||
|
||||
Practical tuning:
|
||||
|
||||
| knob | effect |
|
||||
|---|---|
|
||||
| `sample_every` | larger → fewer host copies, faster, coarser streaks |
|
||||
| `max_particle_age` | `None` → no hard cutoff; trails survive until boundary/solid |
|
||||
| `num_threads` | `0` = auto; sets OpenBLAS/OMP threads for blur/host ops |
|
||||
| `blur_sigma` | `0` disables Gaussian blur pass |
|
||||
|
||||
For `experiment.ipynb` triangle case, use **`render_streakline_density(..., minimal_axes=True)`** on the **final alive particle cloud** (`positions`, `ages`). Do **not** accumulate and draw full path history (`render_snapshot_trails` is pathline-style debug only).
|
||||
|
||||
### Clean render style (experiment)
|
||||
|
||||
`render_snapshot_trails` defaults:
|
||||
|
||||
- white background
|
||||
- red streaks, brighter toward the downstream end (`fade_along_trail=True`)
|
||||
- black filled cylinders
|
||||
- no axes, labels, colorbar, or release-point markers
|
||||
|
||||
Example commands:
|
||||
|
||||
```bash
|
||||
# Online in-memory streakline (no snapshot write)
|
||||
conda run -n pycuda_3_10 python tests/run_kan99b_streakline.py \
|
||||
--mode online --domain M --re 100 --alpha 1.0 \
|
||||
--sample-every 300 --n-snapshots 20 \
|
||||
--release-mode strip --line-count 7 --downstream-count 6
|
||||
|
||||
# Offline replay from existing velocity snapshots
|
||||
conda run -n pycuda_3_10 python tests/run_kan99b_streakline.py \
|
||||
--mode offline \
|
||||
--snapshot-dir tests/output/final_validation_round/streakline_kan99b_k2/velocity_snapshots \
|
||||
--release-mode strip --line-count 7 --downstream-count 6
|
||||
```
|
||||
@ -1,7 +1,7 @@
|
||||
# CelerisLab/tests/run_kan99b_rotating_cylinder.py
|
||||
# CelerisLab/tests/validation/run_kan99b_rotating_cylinder.py
|
||||
"""Kan99b MRT-only rotating-cylinder validation runner.
|
||||
|
||||
This script follows ``tests/Kan99b_validation.md`` for the current round:
|
||||
This script follows ``docs/validation_specs/Kan99b_validation.md`` for the current round:
|
||||
|
||||
- Primary matrix: K1-K5 with collision fixed to MRT.
|
||||
- Primary inlet: regularized (uniform profile).
|
||||
@ -22,7 +22,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
|
||||
|
||||
U_INF = 0.03
|
||||
@ -26,7 +26,7 @@ from typing import Any, Dict, List
|
||||
|
||||
import pycuda.driver as cuda
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
# CelerisLab/tests/run_sah04_st_matrix.py
|
||||
# CelerisLab/tests/validation/run_sah04_st_matrix.py
|
||||
"""Sah04 MRT-only Strouhal validation on S1-S4 anchors.
|
||||
|
||||
This runner implements the current validation contract in ``tests/Sah04_validation.md``:
|
||||
@ -28,7 +28,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
|
||||
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json")
|
||||
|
||||
_BASE_D = 30.0
|
||||
124
tests/validation/test_sensor_accuracy.py
Normal file
124
tests/validation/test_sensor_accuracy.py
Normal file
@ -0,0 +1,124 @@
|
||||
# CelerisLab/tests/validation/test_sensor_accuracy.py
|
||||
"""Sensor accuracy validation: compare sensor readings to direct flow field averages.
|
||||
|
||||
This script validates that the GPU sensor kernel accumulation matches a
|
||||
CPU-side manual average of the macroscopic field over the same cell footprint.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python tests/validation/test_sensor_accuracy.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_REPO / "src"))
|
||||
|
||||
from CelerisLab import Simulation
|
||||
|
||||
|
||||
def test_sensor_accuracy() -> dict:
|
||||
"""Run sensor accuracy validation with multiple sensor positions."""
|
||||
cfg = json.loads(
|
||||
(Path(_REPO) / "src" / "CelerisLab" / "configs" / "config_lbm.json").read_text()
|
||||
)
|
||||
cfg["grid"]["nx"] = 256
|
||||
cfg["grid"]["ny"] = 128
|
||||
cfg["grid"]["nz"] = 1
|
||||
cfg["physics"]["viscosity"] = 0.009
|
||||
cfg["physics"]["velocity"] = 0.03
|
||||
cfg["method"]["collision"] = "MRT"
|
||||
cfg["method"]["inlet"]["scheme"] = "regularized"
|
||||
cfg["method"]["inlet"]["profile"] = "uniform"
|
||||
cfg["method"]["y_wall_bc"] = "free_slip"
|
||||
|
||||
tmpd = tempfile.mkdtemp(prefix="sensor_test_")
|
||||
lbm_path = os.path.join(tmpd, "config_lbm.json")
|
||||
with open(lbm_path, "w") as f:
|
||||
json.dump(cfg, f)
|
||||
|
||||
sim = Simulation(lbm_config_path=lbm_path)
|
||||
sim.add_body("circle", center=(80, 64), radius=15)
|
||||
|
||||
positions = [(120, 50), (120, 64), (120, 78), (150, 64)]
|
||||
sensor_ids = []
|
||||
for cx, cy in positions:
|
||||
sid = sim.add_body("sensor", center=(cx, cy), radius=10)
|
||||
sensor_ids.append(sid)
|
||||
|
||||
sim.initialize()
|
||||
print(f"Initialized: nx={cfg['grid']['nx']} ny={cfg['grid']['ny']} "
|
||||
f"n_curved={sim.field.n_curved} n_sensor={sim.field.n_sensor}")
|
||||
|
||||
# Step to develop wake
|
||||
for _ in range(50):
|
||||
sim.run(20)
|
||||
|
||||
# Get macroscopic field after one more step (with sensor accumulation)
|
||||
import pycuda.driver as cuda
|
||||
stream = cuda.Stream()
|
||||
sim.bodies.zero_sensor_segment_async(stream)
|
||||
sim.stepper.step(1, action_gpu=sim.bodies.action_gpu,
|
||||
obs_gpu=sim.bodies.obs_gpu, stream=stream)
|
||||
stream.synchronize()
|
||||
|
||||
macro = sim.get_macroscopic()
|
||||
ux = macro["ux"]
|
||||
uy = macro["uy"]
|
||||
|
||||
results = {}
|
||||
all_pass = True
|
||||
for sid in sensor_ids:
|
||||
cells_arr, _ = sim.bodies.get(sid).get_sensor_list(
|
||||
sim.lbm_cfg.nx, sim.lbm_cfg.ny
|
||||
)
|
||||
cell_idx = np.asarray(cells_arr, dtype=np.int64)
|
||||
ux_rav = ux.ravel().astype(np.float64)
|
||||
uy_rav = uy.ravel().astype(np.float64)
|
||||
|
||||
sensor_ux_mean = float(np.mean(ux_rav[cell_idx]))
|
||||
sensor_uy_mean = float(np.mean(uy_rav[cell_idx]))
|
||||
|
||||
sensor_reading = sim.read_sensor(sid)
|
||||
sensor_reading_x = float(sensor_reading[0])
|
||||
sensor_reading_y = float(sensor_reading[1])
|
||||
|
||||
diff_ux = abs(sensor_reading_x - sensor_ux_mean)
|
||||
diff_uy = abs(sensor_reading_y - sensor_uy_mean)
|
||||
passed = diff_ux < 1e-4 and diff_uy < 1e-4
|
||||
if not passed:
|
||||
all_pass = False
|
||||
|
||||
results[f"sensor_{sid}_pos{positions[i]}"] = {
|
||||
"sensor_reading": [sensor_reading_x, sensor_reading_y],
|
||||
"manual_average": [sensor_ux_mean, sensor_uy_mean],
|
||||
"diff": [float(diff_ux), float(diff_uy)],
|
||||
"n_cells": int(len(cells_arr)),
|
||||
"pass": bool(passed),
|
||||
}
|
||||
status = "PASS" if passed else "FAIL"
|
||||
print(
|
||||
f" Sensor {sid} @ {positions[sid]}: "
|
||||
f"reading=({sensor_reading_x:.8f},{sensor_reading_y:.8f}) "
|
||||
f"manual=({sensor_ux_mean:.8f},{sensor_uy_mean:.8f}) "
|
||||
f"diff=({diff_ux:.2e},{diff_uy:.2e}) "
|
||||
f"cells={len(cells_arr)} [{status}]"
|
||||
)
|
||||
|
||||
sim.close()
|
||||
summary = {"all_pass": bool(all_pass), "results": results}
|
||||
print(f"\nSensor accuracy: {'ALL PASS' if all_pass else 'SOME FAILED'}")
|
||||
return summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = test_sensor_accuracy()
|
||||
sys.exit(0 if result["all_pass"] else 1)
|
||||
130
tests/审计.md
130
tests/审计.md
@ -1,130 +0,0 @@
|
||||
## 审计结论
|
||||
|
||||
当前代码的问题不是单点误差,而是沿着主链路分布。最重的问题集中在三处:curved Bouzidi 的施加时机不对,Bouzidi 分支公式与文献不一致,以及若干运行时接口与内核实现脱节。这几类问题叠加后,足以让同一 case 在 SRT、TRT、MRT 之间出现远大于正常数值差异的结果。
|
||||
|
||||
需要单独说明的是,部分未来接口属于有意预留,不应被视为 bug。本审计只把已经进入当前执行契约、但实现与语义不一致的部分列为问题。
|
||||
|
||||
按当前项目约束,以下两类设计不再单列为缺陷:
|
||||
|
||||
- 运行时接口为后续能力预留,但当前未完全接通,只要注释明确即可
|
||||
- `obs` 采用跨多步累加,由调用者决定何时清零,这属于有意设计而非实现错误
|
||||
|
||||
## 状态说明
|
||||
|
||||
- `[已解决]` 已进入当前代码
|
||||
- `[待重构]` 不是单点修补能彻底解决,适合下一阶段重构
|
||||
- `[待验证]` 需要最小算例或单元测试确认
|
||||
- `[保留说明]` 当前不修,但需要在代码或文档中明确限制
|
||||
|
||||
## 当前总览
|
||||
|
||||
### 已解决
|
||||
|
||||
- [已解决] `lbm/__init__.py` 导出错误
|
||||
- [已解决] forcing 主链路未接通,以及 SRT TRT MRT forcing 预因子不一致
|
||||
- [已解决] TRT outlet NEQ 重构未补齐
|
||||
- [已解决] `add_vortex()` 把动量当速度
|
||||
- [已解决] Sensor 面积归一化缺失,已在 `ObjectManager` 层提供
|
||||
- [已解决] `sync_to_gpu()` 末尾重置非流体节点的架构错误
|
||||
- [已解决] curved donor 合法性未检查真实 domain flags
|
||||
- [已解决] curved Bouzidi 时序错误
|
||||
- [已解决] `q >= 0.5` 分支读错时间层
|
||||
- [已解决] moving wall 修正未按 q 分支实现
|
||||
- [已解决] 初始化链路与 object flag 叠加关系错误
|
||||
- [已解决] `config_body.json` 未进入实际初始化链路
|
||||
- [已解决] inlet `U0` 语义已补注释
|
||||
|
||||
### 仍需重构或继续处理
|
||||
|
||||
- [待重构] `body` 模块整体职责边界仍不清晰,几何描述、flag overlay、compact list 生成、动作状态、观测打包仍然耦合过重
|
||||
- [待重构] curved boundary 当前仍是“圆形简单几何特化 + Bouzidi kernel 特化”架构,不适合继续扩到离散几何与通用移动刚体
|
||||
- [待重构] 3D 刚体旋转契约仍是 z 轴占位实现
|
||||
- [待重构] plain linear Bouzidi 与 TRT 不相容问题已注明限制,但没有方法级替代方案
|
||||
- [待重构] `config.py` 与文档层对预留能力、当前能力、限制条件的边界还不够清楚
|
||||
- [待验证] MRT 路径仍需最小算例单独核对
|
||||
- [待验证] Esopull 邻壁处理仍需与 double-buffer 做一致性复核
|
||||
- [待验证] force 提取与 host 侧系数归一化仍需放到同一处核对
|
||||
|
||||
## 历史问题清单
|
||||
|
||||
|
||||
### 主执行链路
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `lbm/__init__.py` | [已解决] 导入了并不存在的 `add_lamb_oseen` 与 `add_taylor_green`。实际 `initializers.py` 只提供 `add_vortex`。 | `CelerisLab.lbm` 包导入会退化到空 `__all__`,对外 API 与代码不一致。 |
|
||||
| `field.py`、`config.py`、`inlet_outlet.cuh`、`init_flow.cu` | [已解决] `update_runtime_params()` 暴露了 `u_inlet` 与 `rho_ref` 一类接口,但入口、出口、初始化全部仍然读编译期宏 `U0` 与 `RHO`。如果这些接口继续保留在活跃 API 中,就会形成假功能。 | 运行时接口语义与真实执行路径不一致。 |
|
||||
| `helpers.cuh` 与 `forcing_guo.cuh` | [已解决] `collide_dispatch()` 每步都调用 `zero_forcing(Fin)`,没有任何地方根据 `d_params.fx fy fz` 生成 Guo forcing,也没有调用速度半步修正。 | 运行时体力项接口是死路径。任何依赖体力驱动的算例都会静默失效。 |
|
||||
| `collision_trt.cuh` 与 `collision_mrt.cuh` | [已解决] 在 forcing 真的接通之前,TRT 与 MRT 版本目前都是直接加 `Fin[i]`,SRT 则乘了 `(1-omega/2)`。 | forcing 一旦接通,三种碰撞模型会立刻表现出不一致的体力离散。 |
|
||||
|
||||
### Curved boundary 与几何预处理
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `one_step_double.cu`、`aux_kernels.cu`、`curved_boundary.cuh` | [已解决] Curved Bouzidi 是在 `OneStep` 完成 collision 与 store 之后,通过 `CurvedBoundaryKernel` 二次修补。 | 紧邻曲壁的 fluid 节点在本步 collision 时已经使用了从 solid 方向拉来的错误分布。后修补只能影响下一步,不能修正本步碰撞污染。 |
|
||||
| `curved_boundary.cuh` | [已解决] `q >= 0.5` 分支使用 `fi_in[k_f, dir_opp]`,也就是上一步缓冲区,而不是同一步、碰撞后传播前的分布函数。Bouzidi 线性插值两支公式都要求同一时间层的 post-collision 数据 [Bou01]。 | 该分支与文献公式不一致,会直接破坏 curved wall 的局部反射关系。 |
|
||||
| `curved_boundary.cuh` | [已解决] moving wall 修正统一写成 `+ 6 w_i (c_i · u_w)`,没有区分 `q < 0.5` 与 `q >= 0.5` 两支,也没有体现 Bouzidi moving boundary 中的分支依赖系数 [Bou01]。 | 对旋转圆柱或移动物体,壁面速度修正的量级与形式都不对。 |
|
||||
| `body/objects.py` | [已解决] `Cylinder.get_curved_list()` 的 donor 合法性只检查了“是否越界”和“是否落在圆柱内部”,没有检查 donor 是否落在上壁、下壁、入口、出口等非流体节点。 | `fallback_class` 的主机侧担保并不成立。靠近通道壁或边界时,`q < 0.5` 分支可能继续使用非法 donor。 |
|
||||
| `aux_kernels.cu` | [待重构] 3D curved body 的运行时角速度契约只读取一个标量 `omega`,并硬编码成 z 轴转动,代码里也明确写了 placeholder。 | 3D 旋转物体路径并未真正完成,但接口没有把这种限制暴露出来。 |
|
||||
|
||||
### 初始化、观测与工具函数
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `initializers.py` | [已解决] `add_vortex()` 里 `ux_old` 与 `uy_old` 用的是动量和,没有除以 `rho_old`。 | 任何基于该函数构造的初值都会把速度场放大为密度加权动量场。 |
|
||||
| `step/aux_kernels.cu` 与 `body/manager.py` | [已解决] `SensorKernel` 对传感器区域做的是逐格点求和,`ObjectManager` 也没有按面积归一化。 | 如果外部把传感器输出当成平均速度或平均观测量,结果会系统偏大并随探针面积变化。 |
|
||||
| `body/manager.py` | [已解决] `sync_to_gpu()` 最后调用 `_rest_nonfluid()`,会把所有非流体节点都重置成静止平衡态,而不仅是物体节点。 | 只要场中有物体,就会额外改写入口、出口、壁面节点的初始化状态。 |
|
||||
|
||||
### 文档与配置层
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `configs/CONFIG` | 文档写 `nx` 必须整除 `threads_per_block`,但实际 launch 使用的是 ceiling division。 | 配置说明与执行实现不一致。 |
|
||||
| `configs/CONFIG` | [已解决] 文档写 `neq_extrap` 下 SRT 与 TRT 都使用全分布 damped NEQ 重构,但代码里只有 SRT 走全分布分支,TRT 仍是少量未知方向重构。 | 算法说明与真实边界实现不一致。 |
|
||||
| `README` 与 `config.py` | 文档把 `FP16C` 当成可选存储精度,但 `LBMConfig.validate()` 会直接拒绝 `FP16C`。 | 对外能力声明与当前运行时不一致。 |
|
||||
|
||||
## 高风险问题
|
||||
|
||||
### 边界方法与碰撞模型耦合
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `curved_boundary.cuh` 与 `collision_trt.cuh` | [保留说明] 当前实现采用 plain linear Bouzidi 与 TRT 直接拼接。TRT 的黏性无关参数化只在边界离散满足相应条件时才成立,普通线性插值边界并不会自动保留该性质 [Gin08b]。 | 即使把显式 bug 全部修掉,TRT 与 SRT、MRT 的结果仍可能因边界离散而系统分叉。 |
|
||||
| `curved_boundary.cuh` | 当前 curved wall 没有任何质量守恒修正。对于高 Re、长时间、周期性 curved flow,文献已经报告 plain Bouzidi 会出现质量泄漏与力漂移 [San18]。 | 长时间拖算时,平均密度、阻力与升力可能持续漂移。 |
|
||||
| `collision_mrt.cuh` | D2Q9 MRT 为配合新方向排序手工重写了整套 moment transform 与 inverse transform,但代码里没有任何一致性校验或自测痕迹。 | 一旦某个符号、系数或方向映射有误,结果会直接体现在升阻力与稳定性上,而且不容易从表面现象定位。 |
|
||||
| `inlet_outlet.cuh` 与 `config.py` | 抛物入口把 `U0` 解释成截面平均速度,峰值自动变成 `1.5 * U0`。接口名字仍然叫 `velocity`,没有区分平均速度与最大速度。 | 只要用户按最大入口速度设参数,实际 Re 就会整体偏移。 |
|
||||
| `init_flow.cu` | 四个角点优先按 inlet 或 outlet 分类,而不是 wall。随后边界核又把非 `interior_y` 的角点落回 `bounce_back_swap()`。 | 角点的标记语义与实际处理语义不一致,容易在后续扩展中埋下隐患。 |
|
||||
|
||||
## 待验证问题
|
||||
|
||||
### 需要单元测试或最小算例复核的项
|
||||
|
||||
| 文件 | 问题 | 影响 |
|
||||
|---|---|---|
|
||||
| `one_step_esopull.cu` | Esopull 路径没有像 double-buffer 那样对 `y == 1` 和 `y == NY-2` 的流体邻壁行做显式半格 bounce-back 修正。 | bounded channel 在 esopull 与 double-buffer 之间可能存在额外差异,需要最小通道算例核对。 |
|
||||
| `collision_mrt.cuh` | [待验证] D2Q9 MRT 的新配对顺序、moment basis、inverse transform 是否与 `compute_rho_u()`、`compute_feq()` 完全一致,目前只能靠数值表现间接推断。 | 这条路径可能包含隐藏的符号错位,需要用均匀流、Poiseuille、衰减涡等简单算例单独验算。 |
|
||||
| `inlet_outlet.cuh` | SRT 出口在 `neq_extrap` 下重构了全体分布,不仅是未知方向。 | 该设计是否有利于稳定性、是否额外改变质量守恒,需要独立做 outlet 对比测试。 |
|
||||
| `curved_boundary.cuh` 与 `aux_kernels.cu` | [待验证] 当前力提取采用 `f_toward + f_reflected` 的链路级动量交换,但没有与 host 侧的阻力系数归一化和符号约定放在同一处核对。 | 即使流场正确,力的符号、量级和平均方式也仍可能在后处理阶段出错。 |
|
||||
|
||||
## 本轮已修复
|
||||
|
||||
- `lbm/__init__.py` 已改为导出真实存在的 `add_vortex`
|
||||
- 运行时参数接口已收紧到当前真实生效的 `omega` 与 forcing 相关项
|
||||
- Guo forcing 已接入主碰撞分发链路,SRT、TRT、MRT 的 forcing 预因子已统一
|
||||
- TRT 的 outlet NEQ 重构已补齐为与 SRT 一致的全分布 damped NEQ 路径
|
||||
- `add_vortex()` 已修正为用速度而不是动量直接叠加初值
|
||||
- 传感器读数已在 `ObjectManager` 层提供面积归一化接口
|
||||
- 物体同步后的静止平衡重置已收缩为只作用于 obstacle interior,而不再覆盖所有非流体节点
|
||||
- curved link donor 合法性检查已扩展到实际 domain flags,而不只检查是否在圆柱内部
|
||||
- curved Bouzidi 已从“步后修补流体节点”改为“步前写入 obstacle source slot”,并改掉了 `q >= 0.5` 分支对前一时间层 opposite population 的读取
|
||||
- curved moving-wall 修正已继续收紧为独立 helper,并把 `q < 0.5` 与 fallback 分支改回与 [Bou01] 一致的符号约定
|
||||
- 初始化链路已改为“先构建 clean channel flags,再叠加 object mask,再由 init kernel 保持 obstacle flag 并写入静止平衡态”,移除了 `sync_to_gpu()` 末尾对 obstacle interior 的二次主机侧重置
|
||||
- `Simulation` 已开始消费 `config_body.json` 中的简单物体定义,并在编译期同步 `N_OBJS` 契约
|
||||
- inlet 抛物入口已补充注释,明确 `U0` 在当前实现中表示截面平均速度,峰值为 `1.5 * U0`
|
||||
- curved boundary 文件已显式注释:plain linear Bouzidi 与 TRT 不具备 TRT-parametrized curved-wall 保证,当前实现保留该限制说明
|
||||
|
||||
## 交叉症状解释
|
||||
|
||||
当前现象和代码结构是吻合的。TRT 加 Bouzidi 直接发散,最符合 curved wall 时序错误与 `q >= 0.5` 分支读错时间层这两个问题叠加后的表现。SRT 与 MRT 加 Bouzidi 不发散但升阻力偏离,则更像是主链路虽然还能跑,但边界修正、观测累加、入口参数语义和 MRT 未校验实现共同把结果拖离了正常范围。
|
||||
|
||||
文献层面的两条提醒也和代码现状一致。第一,plain linear boundary 与 TRT 的参数化并不天然相容 [Gin08b]。第二,plain Bouzidi 在高 Re 周期 curved flow 中会出现质量泄漏与力漂移 [San18]。这两条不是当前代码里最先要解释的大偏差,但它们说明就算显式 bug 修完,边界策略本身仍然需要额外核验。
|
||||
Loading…
Reference in New Issue
Block a user