Undermind审计前,Cursor重构

This commit is contained in:
Frank14f 2026-05-12 19:08:49 +08:00
parent 654e3290a4
commit 925417abcb
55 changed files with 5511 additions and 3555 deletions

View File

@ -0,0 +1,56 @@
---
description: CUDA/C++ naming — macros, kernels, device helpers, module growth
globs: src/**/*.cu,src/**/*.cuh,src/**/*.h
---
# CUDA and GPU-side naming (CelerisLab)
## File banner and guards
- First line of each hand-written `.cu` / `.cuh`: `// CelerisLab <path_under_lbm/kernels/>` (en-dash ``), matching siblings (e.g. `operators/collision_srt.cuh`, `step/one_step_double.cu`).
- Include guards: `CELERIS_<PATH_UPPER>_<STEM>_CUH` (match existing pattern, e.g. `CELERIS_OPERATORS_COLLISION_SRT_CUH`).
- Auto-generated headers under `lbm/kernels/config/` must start with:
`// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY`
## Module domains (plan for growth)
Keep names readable across domains. Today most GPU code lives under `lbm/kernels/`; future areas may add parallel trees (e.g. particles, deformable solids). Use consistent **semantic** prefixes in identifiers when ambiguity is likely:
| Domain | Scope (conceptual) | Identifier hint |
|--------|-------------------|-----------------|
| Orchestration | Host-side launch, thin wrappers | Already mostly Python; CUDA entry files stay thin |
| Common | Shared numerics / helpers used by multiple domains | Prefer neutral names (`clamp01`, `dot3`) or `cel_*` only if truly project-global |
| LBM | Lattice, collision, streaming, macroscopic | `collide_*`, `stream_*`, `macro_*`, step drivers `one_step_*` — keep physics meaning in the name |
| Body / IBM | Immersed boundaries, rigid surfaces | `ibm_*`, `curved_*`, `cut_link_*` style names where they describe physics |
| Future: flexible solids | Not present yet | Reserve `flex_*` or a dedicated subdirectory prefix when added |
| Future: particles | Not present yet | Reserve `part_*` or `particle_*` under a dedicated subtree when added |
Do not rename working kernels for style alone in a mixed PR with behavior changes; batch renames in a dedicated change after audit.
## Config macros (`config_*.h`)
- Macros are compile-time switches and constants. Prefer **layer + clear token**:
- **Grid**: `NX`, `NY`, `NZ`, `DIM`, `NQ`, … (existing tier “Global/Grid”).
- **Physics**: `VIS`, `RHO`, `U0`, … (existing).
- **Method**: group by sub-area where possible — collision (`COLLISION_MODEL`, …), streaming (`STREAMING_MODEL`, …), LES (`USE_LES` / future `LES_*`), inlet/outlet (`INLET_*`, `OUTLET_*`), stability guards (`OMEGA_COLLISION_*`, `TRT_MAGIC_PARAM`, …).
- **Objects / case**: `N_OBJS`, …
- When **adding** macros, pick names that wont collide across layers and that encode the tier (document in `configs/CONFIG.md` and in compiler mapping).
- Full normalization of legacy macro names is a **separate refactor** (compiler + all `#ifdef` / macro uses).
## Device functions and kernels
- Device helpers: `__device__ __forceinline__`; prefer small verbs (`compute_feq`, `apply_bc_wall`).
- Pointer parameters that are not aliased: use `__restrict__` where appropriate.
- Kernel entry points (`__global__`): name should state **what step** and **which path** (e.g. `step_lbm_double_buffer`), not only `kernel_launch`.
## Flags and constants
- Cell flags live in `core/flags.cuh`: keep `FLAG_*`, `MASK_*`, and helpers (`is_fluid`, …). Do not change bit layout without a versioned migration plan.
- Python flag constants in `lbm/descriptors.py` must stay consistent with GPU flags; any change updates both sides and user docs.
## Section comments
- File-level title: `// ==== Title ====` or equivalent single banner.
- Subsections: `// --- Section ---` or aligned `// -----` blocks — pick one style **within a file** and match neighbors.

View File

@ -0,0 +1,47 @@
---
description: Layout, forbidden paths, doc sync across README / CONFIG / code
alwaysApply: true
---
# Project layout and documentation discipline (CelerisLab)
## Source tree (authoritative roles)
- [`src/CelerisLab/simulation.py`](src/CelerisLab/simulation.py): Top-level orchestration API (`Simulation`).
- [`src/CelerisLab/config.py`](src/CelerisLab/config.py): `LBMConfig` / `BodyConfig`, load/validate JSON.
- [`src/CelerisLab/configs/`](src/CelerisLab/configs/): JSON defaults and [`CONFIG.md`](src/CelerisLab/configs/CONFIG.md) (human-oriented parameter reference).
- [`src/CelerisLab/cuda/`](src/CelerisLab/cuda/): CUDA context, compile pipeline, **generation** of `lbm/kernels/config/*.h`.
- [`src/CelerisLab/lbm/`](src/CelerisLab/lbm/): Python field/stepper; [`lbm/kernels/`](src/CelerisLab/lbm/kernels/) holds `.cu` / `.cuh` implementation.
- [`src/CelerisLab/body/`](src/CelerisLab/body/): Rigid-style objects, `ObjectManager`, GPU sync for IBM/sensors.
- [`src/CelerisLab/common/`](src/CelerisLab/common/): Shared host utilities (checkpoint, preprocess, …).
Future domains (flexible solids, particles, etc.) should get **new packages** under `src/CelerisLab/` (e.g. `flex/`, `particle/`) and, if they need GPU code, sibling trees under a clear namespace — not ad-hoc files at repo root.
## Do not edit by hand
- `src/CelerisLab/lbm/kernels/config/*.h` — generated by the compiler from `LBMConfig`; change [`cuda/compiler_v2.py`](src/CelerisLab/cuda/compiler_v2.py) and JSON/schema instead.
- `**/*.ptx` — build artifact.
## Low-value context for agents
- [`legacy/`](legacy/): superseded; do not extend.
- [`ref/`](ref/): external reference trees; not part of the shipping package.
(Also listed in [`.cursorignore`](.cursorignore) to save indexing cost.)
## Configuration and documentation sync (critical)
Agents often update one layer and leave others stale. After changing **any** of the following, reconcile **all** that apply in the same task or an immediately following doc-only commit:
1. **JSON keys or shape** in `configs/config_lbm.json` / `config_body.json`
2. **Fields or validation** in `config.py`
3. **Generated macro names or tiers** in `compiler_v2.py` → `kernels/config/*.h`
4. **[`configs/CONFIG.md`](src/CelerisLab/configs/CONFIG.md)** — tables and “config → code” diagram
5. **[`README.md`](README.md)** — Quick Start JSON example, API bullets, Project Layout if paths change
6. **Docstrings** on `Simulation` and other public entry points if behavior or parameters change
Goal: one mental model — README example, CONFIG.md, and actual loader agree.
## Tests
- Behavioral changes should touch or add coverage under [`tests/`](tests/) when feasible.

View File

@ -0,0 +1,34 @@
---
description: Python style for CelerisLab — path header, docstrings, English comments
alwaysApply: true
---
# Python style (CelerisLab)
## File header
- Every `src/CelerisLab/**/*.py` file starts with a single-line path comment so agents can orient without reading the body:
- Format: `# CelerisLab/<path_under_package>` (e.g. `# CelerisLab/simulation.py`).
- Match the real path under `CelerisLab/` after edits or moves.
## Language
- All comments and docstrings in Python source are **English**. User-facing prose in `configs/CONFIG.md` may stay Chinese if that file is explicitly maintained for Chinese readers.
## Docstrings and structure
- Module docstring: one-line summary plus at least one structured block where useful (`Usage::`, `Responsibilities:`, `Design:`).
- Public classes and public functions/methods should have a docstring that states purpose; use Google-style sections (`Args:`, `Returns:`, `Raises:`) when parameters or return value are non-obvious.
- Section dividers in long files: `# -- Section name ---------------------------------------` (consistent with existing files like `simulation.py`).
## Types
- Prefer type hints on public APIs (`Simulation`, `ObjectManager`, `LBMField`, config loaders). Completeness can improve incrementally; do not block features on perfect annotation coverage.
## Imports
- Avoid obvious dead imports when touching a file, but **do not** run broad “import cleanup only” refactors as a standalone task unless asked.
## Placeholders
- Stub methods: `pass` plus a short docstring; mark intent with `(placeholder)` or `(future)` where appropriate.

10
.cursorignore Normal file
View File

@ -0,0 +1,10 @@
# Cursor indexing / context — keep noise out of agent view
# (Independent of .gitignore; ref/legacy are huge or obsolete.)
ref/
legacy/
output/*.pdf
**/*.ptx
**/__pycache__/
.git/

2
.gitignore vendored
View File

@ -71,3 +71,5 @@ venv.bak/
# reference:
ref/
output/
legacy/

View File

@ -44,6 +44,7 @@ pip install -e . # Installs from src/ directory
```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()
@ -67,41 +68,63 @@ with Simulation("configs/config_lbm.json") as sim:
## Configuration
### `configs/config_lbm.json`
### Where `config_lbm.json` is loaded from
`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`.
### `config_lbm.json` shape
The on-disk schema matches `src/CelerisLab/configs/config_lbm.json` (nested sections). Example fragment:
```json
{
"dim": 2,
"nq": 9,
"nx": 384,
"ny": 192,
"nz": 1,
"viscosity": 0.0005,
"velocity": 0.04,
"rho": 1.0,
"collision": "MRT",
"grid": {
"lattice_model": "D2Q9",
"nx": 512,
"ny": 256,
"nz": 1
},
"physics": {
"data_type": "FP32",
"viscosity": 0.0035,
"velocity": 0.03,
"rho": 1.0
},
"method": {
"collision": "SRT",
"streaming": "double_buffer",
"les_enabled": true,
"les_cs": 0.16,
"trt_magic_param": 0.001,
"omega_max": 1.90,
"inlet_profile": "parabolic",
"outlet_mode": "neq_extrap",
"compute_capability": "auto",
"threads_per_block": 256
"store_precision": "FP32",
"ddf_shifting": false,
"les": { "enabled": false, "cs": 0.16, "closed_form": true },
"trt": { "magic_param": 0.1875 },
"inlet": { "profile": "parabolic", "trt_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 }
},
"cuda": {
"threads_per_block": 256,
"compute_capability": "auto"
}
}
```
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.
### Parameter tiers
| Tier | Headers | Examples |
|---|---|---|
| Global/Grid | `config_grid.h` | DIM, NQ, NX, NY, NZ |
| 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` | N_OBJS |
| 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) |
Headers are auto-generated by the compiler from `LBMConfig`; do not edit manually.
Headers are auto-generated by `cuda/compiler_v2.py` from `LBMConfig`; do not edit manually.
## API Reference
@ -111,17 +134,28 @@ Headers are auto-generated by the compiler from `LBMConfig`; do not edit manuall
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()
sim.run(steps)
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=..., u_inlet=...)
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()
```
### `LBMStepper` (advanced)
```python
stepper.step(n=1, *, action_gpu, obs_gpu, stream=None)
```
Curved BC / sensor lists live on `field.curved` and `field.sensors` (`CurvedLinkSoA` / `SensorSoA`), filled by `ObjectManager.sync_to_gpu(field)`.
### Vortex initialization
```python
@ -137,7 +171,7 @@ add_vortex(sim.field, center=(50, 50), radius=10.0, strength=1.0, vortex_type="l
|---|---|
| Low Re (≤ 500) | SRT or TRT, LES off |
| Medium Re (5002000) | MRT or SRT+LES |
| High Re (20005000) | MRT+LES (most robust); SRT+LES; TRT+LES with `omega_max=1.90`, `trt_magic_param=0.001` |
| High Re (20005000) | 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
@ -149,12 +183,13 @@ src/CelerisLab/
compiler_v2.py Config header generation + nvcc + PTX load
context.py CUDA context lifecycle
lbm/
field.py GPU memory management
stepper.py Time-step driver
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 config headers
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
@ -162,12 +197,9 @@ src/CelerisLab/
step/ Step orchestration
body/
objects.py SimObject / Cylinder / Sensor
manager.py ObjectManager + GPU sync
manager.py ObjectManager; packed ``obs_gpu`` / ``obs_pinned``, B3 helpers
common/
preprocess.py Geometry utilities
tests/
test_stability_matrix.py 13-case stability matrix (Re × collision × LES × streaming)
test_high_re_validation.py High-Re directed validation (Re5000, 2D/3D, parameter sweep)
output/
CelerisLab_stage1_architecture.md Architecture specification (v3)
refactor_brief_stage1.md Refactoring brief

View File

@ -0,0 +1,63 @@
# CelerisLab/body/action_smoother.py
"""
ActionSmoother ramp object actions (velocity/force) to avoid
instantaneous jumps that cause lattice instability.
Modes:
NONE no smoothing (default)
LINEAR_RAMP linear ramp from 0 to target over [n_min, n_max] steps
EXPONENTIAL exponential approach: a(t) = target * (1 - exp(-t/tau))
Usage::
smoother = ActionSmoother(mode="LINEAR_RAMP", n_min=0, n_max=500)
for step in range(total):
scaled = smoother.scale(raw_action, step)
# pass scaled action to kernel
"""
import numpy as np
from dataclasses import dataclass
from enum import Enum
class SmoothMode(Enum):
NONE = "NONE"
LINEAR_RAMP = "LINEAR_RAMP"
EXPONENTIAL = "EXPONENTIAL"
@dataclass
class ActionSmoother:
"""Scale actions with a ramp profile."""
mode: str = "NONE"
n_min: int = 0 # step at which ramp begins
n_max: int = 500 # step at which ramp reaches 1.0
tau: float = 100.0 # exponential time constant (steps)
def scale(self, action: np.ndarray, step: int) -> np.ndarray:
"""Return action * alpha(step)."""
alpha = self._alpha(step)
if alpha >= 1.0:
return action
return action * np.float32(alpha)
def _alpha(self, step: int) -> float:
if self.mode == "NONE" or self.mode == SmoothMode.NONE.value:
return 1.0
if self.mode == "LINEAR_RAMP" or self.mode == SmoothMode.LINEAR_RAMP.value:
if step <= self.n_min:
return 0.0
if step >= self.n_max:
return 1.0
return float(step - self.n_min) / float(self.n_max - self.n_min)
if self.mode == "EXPONENTIAL" or self.mode == SmoothMode.EXPONENTIAL.value:
if step <= self.n_min:
return 0.0
t = float(step - self.n_min)
return 1.0 - float(np.exp(-t / max(self.tau, 1.0)))
return 1.0

View File

@ -4,39 +4,68 @@ ObjectManager — batch management of SimObjects.
Responsibilities:
- Add / remove / query objects
- Build merged flag, indx, delta arrays from all objects
- Allocate action / obs GPU buffers
- Sync geometry to GPU
- (future) detect collisions, exchange forces, update states
- Build merged flag masks and compact cut-link / sensor lists
- Allocate packed telemetry ``obs_gpu`` + pagelocked mirror ``obs_pinned``
- Sync geometry to :class:`~CelerisLab.lbm.field.LBMField`
Design::
Packed ``obs`` layout matches ``generate_config(..., n_objects=count)`` and
``config_obs.h`` macros. Host sizing uses :func:`~CelerisLab.cuda.compiler_v2.obs_layout`.
"""
from __future__ import annotations
import numpy as np
import pycuda.driver as cuda
from typing import Dict, List, Optional
from .objects import SimObject, FLUID, OBSTACLE, SENSOR_FLAG, INTERFACE, SOLID
from .objects import SimObject
from ..cuda.compiler_v2 import obs_layout
from ..cuda.obs_layout import ObsLayout
from ..lbm.descriptors import FLUID, D2Q9_EX, D2Q9_EY, D3Q19_EX, D3Q19_EY, D3Q19_EZ
class ObjectManager:
"""Central registry for all simulation objects."""
"""Central registry for all simulation objects.
def __init__(self, nx: int, ny: int, nq: int, dim: int = 2):
Frozen telemetry layout::
``obs`` packs force, torque, then sensor segments.
Segment offsets and sizes come from :func:`obs_layout(dim, count)`.
Device macros ``OBS_*`` in ``config_obs.h`` must match ``obs_layout(dim, count)``.
"""
# -- Construction and registration ---------------------------------------
def __init__(self, nx: int, ny: int, nz: int, nq: int, cfg=None):
self.nx = nx
self.ny = ny
self.nz = nz
self.nq = nq
self.dim = dim
self.cfg = cfg
self._objects: Dict[int, SimObject] = {}
self._next_id = 0
# GPU buffers (allocated on first sync)
self.action_gpu: Optional[cuda.DeviceAllocation] = None
self.obs_gpu: Optional[cuda.DeviceAllocation] = None
self._action_nbytes: int = 0
self._obs_alloc_nbytes: int = 0
# Host buffers
self.action = np.zeros(0, dtype=np.float32)
self.obs = np.zeros(0, dtype=np.float32)
# -- Object CRUD --------------------------------------------------------
self.obs_pinned: Optional[np.ndarray] = None
self.obs_force_view: Optional[np.ndarray] = None
self.obs_torque_view: Optional[np.ndarray] = None
self.obs_sensor_view: Optional[np.ndarray] = None
self.slot_stride_floats: int = 0
self.torque_stride_floats: int = 0
self.torque_components: int = 0
self.torque0_floats: int = 0
self.sensor0_floats: int = 0
self.obs_total_floats: int = 0
self.obs_nbytes: int = 0
self._telemetry_field: Optional[object] = None
def add(self, obj: SimObject) -> int:
"""Register an object and return its id."""
obj.obj_id = self._next_id
@ -60,84 +89,31 @@ class ObjectManager:
def count(self) -> int:
return len(self._objects)
# -- Buffer management ---------------------------------------------------
def _resize_buffers(self):
n = self.count
self.action = np.zeros(max(n, 1), dtype=np.float32)
self.obs = np.zeros(max(n * self.dim, 1), dtype=np.float32)
dim = self.cfg.dim if self.cfg else 2
action_slot_floats = 3 * dim
new_action = np.zeros(max(n * action_slot_floats, 1), dtype=np.float32)
if self.action.size > 0:
copy_n = min(self.action.size, new_action.size)
new_action[:copy_n] = self.action[:copy_n]
self.action = new_action
# -- Geometry and flag rasterisation -------------------------------------
# -- Build merged arrays -------------------------------------------------
def build_flags(self, base_flags: np.ndarray) -> np.ndarray:
"""Merge all object flag masks onto *base_flags* (modified in-place)."""
for obj in self._objects.values():
mask = obj.get_flag_mask(self.nx, self.ny)
# Set obstacle/sensor bits; also mark solid+interface for curved BC
for k in range(mask.size):
if mask[k] != 0:
if mask[k] == OBSTACLE:
base_flags[k] = SOLID | INTERFACE | OBSTACLE
else:
base_flags[k] |= mask[k]
mask = obj.get_flag_mask(self.nx, self.ny, self.nz)
nonzero = mask != 0
base_flags[nonzero] = mask[nonzero]
return base_flags
def build_indx(self, base_indx: np.ndarray) -> np.ndarray:
"""Merge per-cell object indices."""
for obj in self._objects.values():
indx = obj.get_indx_map(self.nx, self.ny)
nonzero = indx != 0
base_indx[nonzero] = indx[nonzero]
return base_indx
def build_delta(self) -> np.ndarray:
"""Concatenate curved-boundary data from all objects."""
parts = []
for obj in self._objects.values():
d = obj.get_delta_curve(self.nx, self.ny, self.nq)
if d.size > 0:
parts.append(d)
if parts:
return np.concatenate(parts)
return np.zeros(0, dtype=np.float32)
# -- GPU sync ------------------------------------------------------------
def sync_to_gpu(self, field):
"""Upload merged flags, indx, delta, and action/obs buffers."""
field.flag = self.build_flags(field.flag)
field.indx = self.build_indx(field.indx)
field.delta = self.build_delta()
field.upload_flags()
field.upload_delta()
field.update_params(n_objects=self.count)
# Alloc action/obs GPU
self._free_gpu()
self.action_gpu = cuda.mem_alloc(self.action.nbytes)
self.obs_gpu = cuda.mem_alloc(self.obs.nbytes)
cuda.memcpy_htod(self.action_gpu, self.action)
cuda.memcpy_htod(self.obs_gpu, self.obs)
# Impose rest equilibrium on newly non-fluid nodes
self._rest_nonfluid(field)
def _rest_nonfluid(self, field):
"""Set DDF of non-fluid nodes to rest equilibrium w_i * rho0."""
field.download_ddf()
nq = field.nq
nx, ny = field.nx, field.ny
n = nx * ny
# D2Q9 weights
if nq == 9:
w = np.array(
[4/9,1/9,1/9,1/9,1/9,1/36,1/36,1/36,1/36],
dtype=np.float32,
)
elif nq == 19:
w = np.array(
[1/3]+[1/18]*6+[1/36]*12, dtype=np.float32,
)
else:
return
w = field._read_lattice_weights()
f = field.ddf.reshape(nq, -1)
nonfluid = (field.flag & FLUID) == 0
for i in range(nq):
@ -145,15 +121,279 @@ class ObjectManager:
field.ddf = f.reshape(-1)
field.upload_ddf()
def _free_gpu(self):
# -- Compact list building -----------------------------------------------
def build_compact_lists(self):
"""Build cut-link SoA columns and sensor lists.
Returns:
cl_fluid_idx, cl_dir, cl_q, cl_body_id, cl_rx, cl_ry, cl_rz,
cl_fallback_class,
sensor_cells, sensor_obj_id
"""
cl_idx, cl_dir, cl_q, cl_bid = [], [], [], []
cl_rx, cl_ry, cl_rz, cl_fallback = [], [], [], []
s_cells, s_ids = [], []
ez = None
if self.cfg and self.cfg.is_d3q19:
ex, ey, ez = D3Q19_EX, D3Q19_EY, D3Q19_EZ
else:
ex, ey = D2Q9_EX, D2Q9_EY
for obj in self._objects.values():
if hasattr(obj, 'get_curved_list'):
(
fluid_idx, dirs, q_vals, body_ids,
rx_vals, ry_vals, rz_vals, fallback_vals
) = obj.get_curved_list(
self.nx, self.ny, self.nq,
nz=self.nz, ex=ex, ey=ey, ez=ez)
if len(fluid_idx) > 0:
cl_idx.append(fluid_idx)
cl_dir.append(dirs)
cl_q.append(q_vals)
cl_bid.append(body_ids)
cl_rx.append(rx_vals)
cl_ry.append(ry_vals)
cl_rz.append(rz_vals)
cl_fallback.append(fallback_vals)
if 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)
s_ids.append(ids)
r_idx = np.concatenate(cl_idx) if cl_idx else np.zeros(0, dtype=np.uint32)
r_dir = np.concatenate(cl_dir) if cl_dir else np.zeros(0, dtype=np.uint8)
r_q = np.concatenate(cl_q) if cl_q else np.zeros(0, dtype=np.float32)
r_bid = np.concatenate(cl_bid) if cl_bid else np.zeros(0, dtype=np.int32)
r_rx = np.concatenate(cl_rx) if cl_rx else np.zeros(0, dtype=np.float32)
r_ry = np.concatenate(cl_ry) if cl_ry else np.zeros(0, dtype=np.float32)
r_rz = np.concatenate(cl_rz) if cl_rz else np.zeros(0, dtype=np.float32)
r_fallback = (
np.concatenate(cl_fallback) if cl_fallback
else np.zeros(0, dtype=np.uint8)
)
sensor_cells = np.concatenate(s_cells) if s_cells else np.zeros(0, dtype=np.uint32)
sensor_obj_id = np.concatenate(s_ids) if s_ids else np.zeros(0, dtype=np.int32)
return (
r_idx, r_dir, r_q, r_bid,
r_rx, r_ry, r_rz, r_fallback,
sensor_cells, sensor_obj_id,
)
# -- GPU buffers (action + obs telemetry) --------------------------------
def sync_to_gpu(self, field):
"""Upload merged flags, compact lists, action buffer, and packed ``obs`` GPU buffer."""
self._telemetry_field = field
field.flag = self.build_flags(field.flag)
field.upload_flags()
(
cl_fluid_idx, cl_dir, cl_q, cl_body_id,
cl_rx, cl_ry, cl_rz, cl_fallback_class,
sensor_cells, sensor_obj_id,
) = self.build_compact_lists()
field.curved.assign_host(
cl_fluid_idx, cl_dir, cl_q, cl_body_id,
cl_rx, cl_ry, cl_rz, cl_fallback_class,
)
field.sensors.assign_host(sensor_cells, sensor_obj_id)
field.upload_compact_lists()
field.update_params(n_objects=self.count)
self._refresh_action_from_objects()
self._allocate_packed_telemetry()
assert self.obs_pinned is not None
self.obs_pinned.fill(0)
cuda.memcpy_htod(self.obs_gpu, self.obs_pinned)
self._rest_nonfluid(field)
def _allocate_packed_telemetry(self) -> None:
"""Resize ``action_gpu`` / ``obs_gpu`` / ``obs_pinned`` when layout changes."""
dim = self.cfg.dim if self.cfg else 2
lay = obs_layout(dim, self.count)
self._apply_obs_layout(lay)
action_nbytes = int(self.action.nbytes)
if self.action_gpu is None or self._action_nbytes != action_nbytes:
if self.action_gpu is not None:
self.action_gpu.free()
self.action_gpu = None
self.action_gpu = cuda.mem_alloc(action_nbytes)
self._action_nbytes = action_nbytes
cuda.memcpy_htod(self.action_gpu, self.action)
if self.obs_gpu is None or self._obs_alloc_nbytes != self.obs_nbytes:
if self.obs_gpu is not None:
self.obs_gpu.free()
self.obs_gpu = None
self.obs_gpu = cuda.mem_alloc(self.obs_nbytes)
self._obs_alloc_nbytes = self.obs_nbytes
# Pagelocked mirror (zeros); PyCUDA exposes pagelocked_empty, not pagelocked_zeros.
self.obs_pinned = cuda.pagelocked_empty(lay.total_floats, np.float32)
self.obs_pinned.fill(0)
n_slots = max(self.count, 1)
self.obs_force_view = self.obs_pinned[
lay.force0_floats: lay.force0_floats + lay.force_floats
].reshape(
n_slots, dim)
self.obs_torque_view = self.obs_pinned[
lay.torque0_floats: lay.torque0_floats + lay.torque_floats
].reshape(n_slots, lay.torque_components)
self.obs_sensor_view = self.obs_pinned[
lay.sensor0_floats: lay.sensor0_floats + lay.slot_stride_floats
].reshape(n_slots, dim)
def _apply_obs_layout(self, lay: ObsLayout) -> None:
self.slot_stride_floats = lay.slot_stride_floats
self.torque_stride_floats = lay.torque_floats
self.torque_components = lay.torque_components
self.torque0_floats = lay.torque0_floats
self.sensor0_floats = lay.sensor0_floats
self.obs_total_floats = lay.total_floats
self.obs_nbytes = lay.total_floats * 4
def ensure_packed_buffers(self) -> None:
"""Allocate ``action_gpu`` and packed ``obs`` when there are zero objects.
Call after ``generate_config(..., n_objects=0)`` so ``config_obs.h`` matches
:func:`obs_layout(dim, 0)`.
"""
if self.cfg is None:
return
self._allocate_packed_telemetry()
assert self.obs_pinned is not None
self.obs_pinned.fill(0)
cuda.memcpy_htod(self.obs_gpu, self.obs_pinned)
# -- B3 async telemetry rhythm -------------------------------------------
def zero_force_segment_async(self, stream: cuda.Stream):
"""Zero body telemetry (force + torque) of ``obs_gpu``."""
n_floats = self.sensor0_floats
cuda.memset_d32_async(self.obs_gpu, 0, n_floats, stream)
def zero_sensor_segment_async(self, stream: cuda.Stream):
"""Zero the sensor segment (second stride-sized block of floats).
This always issues a ``memset`` on the sensor sub-range. Call it only when
the sensor kernel runs (e.g. ``field.n_sensor > 0``); the runner decides.
"""
offset_bytes = self.sensor0_floats * 4
ptr = int(self.obs_gpu) + offset_bytes
cuda.memset_d32_async(ptr, 0, self.slot_stride_floats, stream)
def download_obs_full_async(self, stream: cuda.Stream):
"""Enqueue full DTOH copy ``obs_gpu`` → ``obs_pinned``."""
assert self.obs_pinned is not None
cuda.memcpy_dtoh_async(self.obs_pinned, self.obs_gpu, stream)
# -- Public introspection -------------------------------------------------
@property
def obs_n_slots(self) -> int:
"""At-least-one slot count ``max(count, 1)`` matching ``config_obs.h``."""
return max(self.count, 1)
@property
def obs_slot_stride_floats(self) -> int:
"""Floats per telemetry segment (``obs_n_slots * DIM``)."""
return self.slot_stride_floats
@property
def obs_sensor_base_floats(self) -> int:
"""Float index where the sensor segment begins."""
return self.sensor0_floats
@property
def obs_torque_base_floats(self) -> int:
"""Float index where the torque segment begins."""
return self.torque0_floats
def read_force(self, body_id: int) -> np.ndarray:
"""Return the DIM-vector force on body ``body_id`` from ``obs_pinned``.
Caller must have synchronised the CUDA stream before reading.
Args:
body_id: Object id assigned by :meth:`add`.
Returns:
Copy of length-``DIM`` float32 values from the force segment.
"""
self._validate_body_id(body_id)
assert self.cfg is not None
assert self.obs_pinned is not None
d = self.cfg.dim
# Force segment starts at float index 0 (``OBS_FORCE0_FLOATS``).
i0 = body_id * d
return np.array(self.obs_pinned[i0:i0 + d], dtype=np.float32)
def read_torque(self, body_id: int) -> np.ndarray:
"""Return torque vector for ``body_id`` from ``obs_pinned``."""
self._validate_body_id(body_id)
assert self.obs_pinned is not None
i0 = self.torque0_floats + body_id * self.torque_components
return np.array(
self.obs_pinned[i0:i0 + self.torque_components], dtype=np.float32)
# -- Runtime action state -------------------------------------------------
def set_body_state(self, body_id: int, omega: float = 0.0) -> None:
"""Set runtime body state used by kernels (currently only omega)."""
self._validate_body_id(body_id)
dim = self.cfg.dim if self.cfg else 2
slot = 3 * dim
base = body_id * slot
self.action[base + slot - 1] = np.float32(omega)
if self.action_gpu is not None:
cuda.memcpy_htod(self.action_gpu, self.action)
def _validate_body_id(self, body_id: int) -> None:
if body_id < 0 or body_id >= self.count:
raise IndexError(f"body_id {body_id} out of range for {self.count} objects")
def _refresh_action_from_objects(self) -> None:
"""Populate action slots from object state using the runtime contract."""
if self.count == 0:
return
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
if base + slot > self.action.size:
continue
self.action[base + slot - 1] = np.float32(getattr(obj.state, "omega", 0.0))
def fallback_link_count(self) -> int:
"""Return number of curved links marked for fallback bounce-back."""
if self._telemetry_field is None:
return 0
curved = self._telemetry_field.curved
if curved.count == 0:
return 0
return int(np.count_nonzero(curved.fallback_class[:curved.count] != 0))
def low_q_link_count(self, threshold: float = 0.5) -> int:
"""Return count of links where q is below ``threshold``."""
if self._telemetry_field is None:
return 0
curved = self._telemetry_field.curved
if curved.count == 0:
return 0
return int(np.count_nonzero(curved.q[:curved.count] < np.float32(threshold)))
# -- Rigid body kinematics (future) --------------------------------------
# -- Future placeholders -------------------------------------------------
def update_states(self, dt: float):
"""Integrate object motion (placeholder)."""
pass

View File

@ -4,23 +4,27 @@ Lightweight flat object model for immersed / rigid bodies and sensors.
Design:
- SimObject is a thin base with standard interface (state, control,
get_flag_mask, get_delta_curve).
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_delta_curve.
for custom shapes just implement get_flag_mask / get_curved_list.
"""
import math
import numpy as np
from dataclasses import dataclass, field
from typing import Optional, Tuple
from dataclasses import dataclass
from typing import Tuple
# Reuse flag constants (must match config/config_physics.h)
FLUID = 0x01
SOLID = 0x02
OBSTACLE = 0x20
INTERFACE = 0x08
SENSOR_FLAG = 0x10
# 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,
)
FALLBACK_CLASS_BOUZIDI = np.uint8(0)
FALLBACK_CLASS_HALFWAY_BOUNCEBACK = np.uint8(1)
# ---------------------------------------------------------------------------
@ -28,9 +32,10 @@ SENSOR_FLAG = 0x10
# ---------------------------------------------------------------------------
@dataclass
class ObjectState:
"""Position, velocity, orientation (2D for now)."""
"""Position, velocity, orientation (z used for 3D layouts)."""
x: float = 0.0
y: float = 0.0
z: float = 0.0
theta: float = 0.0
vx: float = 0.0
vy: float = 0.0
@ -59,20 +64,15 @@ class SimObject:
self.obj_id = obj_id
self.center = center
self.radius = radius
self.state = ObjectState(x=center[0], y=center[1])
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.control = ObjectControl()
def get_flag_mask(self, nx: int, ny: int) -> np.ndarray:
"""Return (n,) uint8 array with flag bits set for this object."""
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
def get_delta_curve(self, nx: int, ny: int, nq: int) -> np.ndarray:
"""Return flat float32 array of curved-boundary interpolation data."""
return np.zeros(0, dtype=np.float32)
def get_indx_map(self, nx: int, ny: int) -> np.ndarray:
"""Return (n,) int32 with per-cell object index or offset."""
return np.zeros(nx * ny, dtype=np.int32)
# ---------------------------------------------------------------------------
@ -81,109 +81,262 @@ class SimObject:
class Cylinder(SimObject):
obj_type = "cylinder"
def get_flag_mask(self, nx: int, ny: int) -> np.ndarray:
n = nx * ny
mask = np.zeros(n, dtype=np.uint8)
xc, yc = self.state.x, self.state.y
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**2:
k = x + y * nx
mask[k] = OBSTACLE
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_delta_curve(self, nx: int, ny: int, nq: int) -> np.ndarray:
"""Compute curved-boundary interpolation coefficients.
def get_curved_list(self, nx: int, ny: int, nq: int,
nz: int = 1, ex=None, ey=None, ez=None):
"""Return per-link curved boundary data (fluid-centric).
Uses raycircle intersection (Yu-Mei-Shyy 2003 scheme).
Returns packed array: [obj_id, delta_1..delta_NQ, Uw_x, Uw_y] per node.
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 fluidsolid link walk when ``nz > 1``.
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
"""
from ..common.preprocess import find_circle_intersection
xc, yc = self.state.x, self.state.y
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
# D2Q9 velocity vectors
ex = [0, 1, -1, 0, 0, 1, -1, 1, -1]
ey = [0, 0, 0, 1, -1, 1, -1, -1, 1]
idx_list = []
dir_list = []
q_list = []
rx_list = []
ry_list = []
rz_list = []
fallback_list = []
entries = []
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 _fallback_class(
q_val: float, donor_inside: bool, donor_in_domain: 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:
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
if donor_in_domain:
donor_inside = (
(xff - xc) ** 2 + (yff - yc) ** 2 + (zff - zc) ** 2
) < r_sq
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))
else:
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
if (x - xc)**2 + (y - yc)**2 >= r**2:
if (x - xc) ** 2 + (y - yc) ** 2 < r_sq:
continue
# This node is inside the obstacle
deltas = np.zeros(nq, dtype=np.float32)
has_fluid_nb = False
for i in range(nq):
for i in range(1, nq):
xn, yn = x + ex[i], y + ey[i]
if 0 <= xn < nx and 0 <= yn < ny:
if (xn - xc)**2 + (yn - yc)**2 >= r**2:
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 = xn - x
dy = yn - y
dist = math.sqrt(
(hit[0]-x)**2 + (hit[1]-y)**2
)
norm = math.sqrt(dx*dx + dy*dy)
deltas[i] = dist / norm if norm > 0 else 0.5
has_fluid_nb = True
if has_fluid_nb:
# Pack: [obj_id_as_float, delta_0..delta_NQ-1, Uw_x, Uw_y]
entry = np.zeros(1 + nq + 2, dtype=np.float32)
entry[0] = np.float32(self.obj_id).view(np.float32)
entry[1:1+nq] = deltas
entry[1+nq] = 0.0 # Uw_x (set by action at runtime)
entry[1+nq+1] = 0.0 # Uw_y
entries.append(entry)
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
if donor_in_domain:
donor_inside = ((xff - xc) ** 2 + (yff - yc) ** 2) < r_sq
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))
if entries:
return np.concatenate(entries)
return np.zeros(0, dtype=np.float32)
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) -> np.ndarray:
n = nx * ny
mask = np.zeros(n, dtype=np.uint8)
xc, yc = self.state.x, self.state.y
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**2:
mask[x + y * nx] = SENSOR_FLAG
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
def get_indx_map(self, nx: int, ny: int) -> np.ndarray:
indx = np.zeros(nx * ny, dtype=np.int32)
xc, yc = self.state.x, self.state.y
def get_sensor_list(self, nx: int, ny: int, nz: int = 1):
"""Return compact sensor list.
Returns:
cells: uint32 array of cell indices
obj_ids: int32 array of object ids
"""
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**2:
indx[x + y * nx] = self.obj_id
return indx
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)

View File

@ -0,0 +1,184 @@
# CelerisLab/common/checkpoint.py
"""HDF5 checkpoint save and load for CelerisLab simulations.
Responsibilities:
Serialize GPU distribution functions, cell flags, simulation step count,
and rigid-body states to HDF5; on load, validate lattice geometry and
storage precision before restoring device buffers.
Usage::
Call ``save_checkpoint`` / ``load_checkpoint`` from host-side code when GPU
work is finished; the active ``LBMConfig`` must match the checkpoint on
``lattice_model``, ``nx``, ``ny``, ``nz``, and ``store_precision``.
"""
import json
import os
import time
import numpy as np
import pycuda.driver as cuda
# File format version — bump on incompatible layout changes.
CKPT_VERSION = 1
_SUPPORTED_STORE_PRECISIONS = ("FP32", "FP16S")
def _require_supported_precision(store_precision: str, source: str) -> None:
supported = ", ".join(_SUPPORTED_STORE_PRECISIONS)
if store_precision not in _SUPPORTED_STORE_PRECISIONS:
raise RuntimeError(
f"Unsupported store_precision '{store_precision}' in {source}. "
f"Supported checkpoint/runtime precisions: {supported}."
)
def save_checkpoint(field, stepper, lbm_cfg, bodies, path=None):
"""Save full simulation state to an HDF5 file.
Args:
field: LBMField instance (GPU arrays).
stepper: LBMStepper instance (step count).
lbm_cfg: LBMConfig instance.
bodies: ObjectManager instance.
path: Output file path. Auto-generated if None.
Returns:
Path to the written checkpoint file.
"""
import h5py
if path is None:
os.makedirs("output", exist_ok=True)
path = f"output/checkpoint_{stepper.step_count:08d}.h5"
_require_supported_precision(lbm_cfg.store_precision, "save_checkpoint")
# Download GPU → host
dtype = np.float16 if field.store_bytes == 2 else np.float32
ddf_buf = np.empty(field.n * field.nq, dtype=dtype)
tmp_buf = np.empty(field.n * field.nq, dtype=dtype)
cuda.memcpy_dtoh(ddf_buf, field.ddf_gpu)
cuda.memcpy_dtoh(tmp_buf, field.temp_gpu)
flag_buf = np.empty(field.n, dtype=np.uint16)
cuda.memcpy_dtoh(flag_buf, field.flag_gpu)
with h5py.File(path, "w") as hf:
# Version and metadata
hf.attrs["ckpt_version"] = CKPT_VERSION
hf.attrs["celerislab_version"] = __import__(
"CelerisLab").__version__
hf.attrs["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S")
hf.attrs["step_count"] = stepper.step_count
# Config as JSON string (human-readable in HDF5 viewers)
cfg = lbm_cfg
config_dict = {
"lattice_model": cfg.lattice_model,
"nx": cfg.nx, "ny": cfg.ny, "nz": cfg.nz,
"data_type": cfg.data_type,
"viscosity": cfg.viscosity, "velocity": cfg.velocity,
"rho": cfg.rho,
"collision": cfg.collision, "streaming": cfg.streaming,
"store_precision": cfg.store_precision,
"ddf_shifting": cfg.ddf_shifting,
"les_enabled": cfg.les_enabled, "les_cs": cfg.les_cs,
"les_closed_form": cfg.les_closed_form,
"trt_magic_param": cfg.trt_magic_param,
"inlet_profile": cfg.inlet_profile,
"outlet_mode": cfg.outlet_mode,
"omega_min": cfg.omega_min, "omega_max": cfg.omega_max,
}
hf.attrs["config_json"] = json.dumps(config_dict)
# GPU arrays — gzip compression for portable, compact files
hf.create_dataset("ddf", data=ddf_buf, compression="gzip",
compression_opts=4)
hf.create_dataset("temp", data=tmp_buf, compression="gzip",
compression_opts=4)
hf.create_dataset("flag", data=flag_buf, compression="gzip",
compression_opts=4)
# Object states
obj_states = []
for obj in bodies.objects:
s = obj.state
obj_states.append({
"obj_id": obj.obj_id,
"type": obj.obj_type,
"x": s.x, "y": s.y, "z": s.z, "theta": s.theta,
"vx": s.vx, "vy": s.vy, "omega": s.omega,
"radius": obj.radius,
})
hf.attrs["objects_json"] = json.dumps(obj_states)
return path
def load_checkpoint(path, field, stepper, lbm_cfg, bodies):
"""Restore simulation state from an HDF5 checkpoint.
The current config's lattice_model, nx, ny, nz, and
store_precision must match the checkpoint. Other parameters
(collision, LES, ) may differ for parameter-study restarts.
Args:
path: HDF5 checkpoint file path.
field: LBMField instance.
stepper: LBMStepper instance.
lbm_cfg: LBMConfig instance.
bodies: ObjectManager instance.
"""
import h5py
_require_supported_precision(lbm_cfg.store_precision, "load_checkpoint")
with h5py.File(path, "r") as hf:
# Version check
ver = hf.attrs.get("ckpt_version", 0)
if ver != CKPT_VERSION:
raise ValueError(
f"Checkpoint version mismatch: file={ver}, "
f"expected={CKPT_VERSION}. "
f"Created by CelerisLab {hf.attrs.get('celerislab_version', '?')}")
# Config compatibility check
saved_cfg = json.loads(hf.attrs["config_json"])
for key in ("lattice_model", "nx", "ny", "nz", "store_precision"):
saved_val = saved_cfg.get(key)
curr_val = getattr(lbm_cfg, key)
if saved_val != curr_val:
raise ValueError(
f"Config mismatch on '{key}': "
f"checkpoint={saved_val}, current={curr_val}")
_require_supported_precision(
str(saved_cfg.get("store_precision", "unknown")),
"checkpoint file",
)
# Restore GPU arrays
ddf_buf = hf["ddf"][:]
tmp_buf = hf["temp"][:]
flag_buf = hf["flag"][:]
cuda.memcpy_htod(field.ddf_gpu, ddf_buf)
cuda.memcpy_htod(field.temp_gpu, tmp_buf)
cuda.memcpy_htod(field.flag_gpu, flag_buf)
field.flag = flag_buf.copy()
# Restore step count
stepper._step_count = int(hf.attrs["step_count"])
# Restore object states
obj_data = json.loads(hf.attrs.get("objects_json", "[]"))
for od in obj_data:
oid = od["obj_id"]
try:
obj = bodies.get(oid)
obj.state.x = od["x"]
obj.state.y = od["y"]
obj.state.z = float(od.get("z", 0.0))
obj.state.theta = od["theta"]
obj.state.vx = od["vx"]
obj.state.vy = od["vy"]
obj.state.omega = od["omega"]
except KeyError:
pass # object not in current sim — skip

View File

@ -1,17 +1,18 @@
# CelerisLab/preprocess.py
# CelerisLab/common/preprocess.py
"""Host-side geometric helpers for immersed-boundary preprocessing.
Responsibilities:
Circleray intersection along fluid-to-neighbour links and simple
discrete sensor-area counting used when building IBM masks and probes.
"""
import math
import numpy as np
from typing import Tuple
FLUID = 0b00000001
SOLID = 0b00000010
GAS = 0b00000100
INTERFACE = 0b00001000
SENSOR = 0b00010000
def find_circle_intersection(x, y, x_neb, y_neb, xc, yc, r0):
"""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
b = 2 * (dx * (x - xc) + dy * (y - yc))
@ -21,20 +22,45 @@ def find_circle_intersection(x, y, x_neb, y_neb, xc, yc, r0):
if det < 0:
return None
t1 = (-b + math.sqrt(det)) / (2 * a)
t2 = (-b - math.sqrt(det)) / (2 * a)
t1 = (-b - math.sqrt(det)) / (2 * a)
t2 = (-b + math.sqrt(det)) / (2 * a)
if 0 <= t1 <= 1:
return x + t1 * dx, y + t1 * dy
elif 0 <= t2 <= 1:
return x + t2 * dx, y + t2 * dy
else:
# Pick the smallest valid t (closest to fluid node).
for t in (t1, t2):
if 0 <= t <= 1:
return x + t * dx, y + t * dy
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(np.floor(-radius), np.ceil(radius)):
for j in range(np.floor(-radius), np.ceil(radius)):
for i in range(int(np.floor(-radius)), int(np.ceil(radius))):
for j in range(int(np.floor(-radius)), int(np.ceil(radius))):
if i ** 2 + j ** 2 <= radius ** 2:
area += 1
return area

View File

@ -7,7 +7,7 @@ Two JSON files:
config_body.json object list
LBMConfig / BodyConfig are plain dataclasses; they translate to
macro dicts consumed by compiler.py for header generation.
macro dicts consumed by `cuda/compiler_v2.py` for header generation.
"""
import json
@ -18,22 +18,34 @@ from typing import Any, Dict, List, Optional
# ---------------------------------------------------------------------------
# String ↔ integer mappings (used in JSON and macro generation)
# ---------------------------------------------------------------------------
LATTICE_MODEL_MAP = {
"D2Q9": {"dim": 2, "nq": 9, "id": 0},
"D3Q19": {"dim": 3, "nq": 19, "id": 1},
}
COLLISION_MAP = {"SRT": 0, "TRT": 1, "MRT": 2}
STREAMING_MAP = {"double_buffer": 0, "esopull": 1}
PRECISION_MAP = {"FP32": 0, "FP16S": 1, "FP16C": 2}
INLET_MAP = {"uniform": 0, "parabolic": 1}
OUTLET_MAP = {"neq_extrap": 0, "zero_gradient": 1, "blended": 2}
Y_WALL_BC_MAP = {"bounce_back": 0, "free_slip": 1}
DTYPE_MAP = {"FP32": "float", "FP64": "double"}
# ---------------------------------------------------------------------------
# LBM config
# ---------------------------------------------------------------------------
# ⚠ THIS IS NOT THE CONFIGURATION LOCATION.
# All parameters come from configs/config_lbm.json (the single source of truth).
# Use load_lbm_config() to construct; do not instantiate LBMConfig() with
# bare defaults. Tests may pass explicit kwargs for override only.
# See configs/CONFIG.md for parameter documentation.
# ---------------------------------------------------------------------------
@dataclass
class LBMConfig:
# Lattice model (canonical: determines dim and nq)
lattice_model: str = "D2Q9"
# Grid
dim: int = 2
nq: int = 9
nx: int = 512
ny: int = 256
nz: int = 1
@ -51,34 +63,76 @@ class LBMConfig:
ddf_shifting: bool = False
les_enabled: bool = False
les_cs: float = 0.16
les_closed_form: bool = True
trt_magic_param: float = 0.1875
inlet_profile: str = "parabolic"
outlet_mode: str = "neq_extrap"
outlet_blend_alpha: float = 0.7
outlet_backflow_clamp: bool = True
inlet_trt_neq_damp: float = 0.5
outlet_srt_neq_damp: float = 0.5
y_wall_bc: str = "bounce_back"
omega_min: float = 0.01
omega_max: float = 1.999
omega_max: float = 1.99
# CUDA
threads_per_block: int = 128
threads_per_block: int = 256
compute_capability: str = "auto"
# Derived (computed on validate)
# Derived (computed on validate — do not set manually)
dim: int = 0
nq: int = 0
omega: float = 0.0
@property
def is_d2q9(self) -> bool:
return self.lattice_model == "D2Q9"
@property
def is_d3q19(self) -> bool:
return self.lattice_model == "D3Q19"
def validate(self):
assert self.dim in (2, 3), f"dim must be 2 or 3, got {self.dim}"
assert self.nq in (9, 19), f"nq must be 9 or 19, got {self.nq}"
assert self.lattice_model in LATTICE_MODEL_MAP, (
f"Unknown lattice_model: {self.lattice_model}. "
f"Supported: {list(LATTICE_MODEL_MAP.keys())}")
lm = LATTICE_MODEL_MAP[self.lattice_model]
self.dim = lm["dim"]
self.nq = lm["nq"]
self.lattice_model_id = lm["id"]
assert self.collision in COLLISION_MAP, f"Unknown collision: {self.collision}"
assert self.streaming in STREAMING_MAP, f"Unknown streaming: {self.streaming}"
assert self.y_wall_bc in Y_WALL_BC_MAP, f"Unknown y_wall_bc: {self.y_wall_bc}"
assert self.data_type in DTYPE_MAP, f"Unknown data_type: {self.data_type}"
assert self.store_precision in PRECISION_MAP, (
f"Unknown store_precision: {self.store_precision}")
if self.store_precision == "FP16C":
raise ValueError(
"store_precision='FP16C' is not supported in the current runtime path. "
"Supported store_precision values: FP32, FP16S."
)
if self.data_type != "FP32":
raise ValueError(
"Only data_type='FP32' is supported: CUDA kernels and LBMField "
"paths use float32; LBtype is not wired through device code yet."
)
assert self.nx > 0 and self.ny > 0 and self.nz > 0
if self.dim == 2:
assert self.nz == 1, "nz must be 1 for 2D"
assert self.nq == 9, "D2Q9 required for dim=2"
else:
assert self.nq == 19, "D3Q19 required for dim=3"
if self.dim == 3 and self.y_wall_bc == "free_slip":
raise ValueError("y_wall_bc='free_slip' is currently supported for D2Q9 only.")
if self.omega_max >= 2.0:
raise ValueError("method.omega_guard.max must be < 2.0.")
if self.omega_min >= self.omega_max:
raise ValueError(
f"Invalid omega guard window: min={self.omega_min}, max={self.omega_max}."
)
self.omega = 1.0 / (3.0 * self.viscosity + 0.5)
if not (self.omega_min <= self.omega <= self.omega_max):
raise ValueError(
f"Computed omega={self.omega:.6f} outside guard "
f"[{self.omega_min:.6f}, {self.omega_max:.6f}]."
)
def to_macros(self) -> Dict[str, Any]:
"""Return flat dict of macro_name → value for header generation."""
@ -89,8 +143,7 @@ class LBMConfig:
"NX": self.nx,
"NY": self.ny,
"NZ": self.nz,
"DIM": self.dim,
"NQ": self.nq,
"LATTICE_MODEL": self.lattice_model_id,
"LBtype": DTYPE_MAP[self.data_type],
"VIS": f"{self.viscosity:.10f}",
"RHO": f"{self.rho:.1f}",
@ -101,13 +154,17 @@ class LBMConfig:
"USE_DDF_SHIFTING": int(self.ddf_shifting),
"USE_LES": int(self.les_enabled),
"LES_CS": f"{self.les_cs:.6f}f",
"LES_CLOSED_FORM": int(self.les_closed_form),
"INLET_PROFILE": INLET_MAP[self.inlet_profile],
"OUTLET_MODE": OUTLET_MAP[self.outlet_mode],
"OUTLET_BLEND_ALPHA": f"{self.outlet_blend_alpha:.3f}f",
"OUTLET_BACKFLOW_CLAMP": int(self.outlet_backflow_clamp),
"Y_WALL_BC": Y_WALL_BC_MAP[self.y_wall_bc],
"OMEGA_COLLISION_MIN": f"{self.omega_min:.2f}f",
"OMEGA_COLLISION_MAX": f"{self.omega_max:.3f}f",
"TRT_MAGIC_PARAM": f"{self.trt_magic_param:.6f}f",
"INLET_TRT_NEQ_DAMP": f"{self.inlet_trt_neq_damp:.4f}f",
"OUTLET_SRT_NEQ_DAMP": f"{self.outlet_srt_neq_damp:.4f}f",
}
@ -148,18 +205,23 @@ def load_lbm_config(path: Optional[str] = None) -> LBMConfig:
d = json.load(f)
g, p, m, c = d["grid"], d["physics"], d["method"], d["cuda"]
cfg = LBMConfig(
dim=g["dim"], nq=g["nq"], nx=g["nx"], ny=g["ny"], nz=g["nz"],
lattice_model=g["lattice_model"],
nx=g["nx"], ny=g["ny"], nz=g["nz"],
data_type=p["data_type"], viscosity=p["viscosity"],
velocity=p["velocity"], rho=p["rho"],
collision=m["collision"], streaming=m["streaming"],
store_precision=m["store_precision"],
ddf_shifting=m["ddf_shifting"],
les_enabled=m["les"]["enabled"], les_cs=m["les"]["cs"],
les_closed_form=m["les"].get("closed_form", True),
trt_magic_param=m["trt"]["magic_param"],
inlet_profile=m["inlet"]["profile"],
outlet_mode=m["outlet"]["mode"],
outlet_blend_alpha=m["outlet"]["blend_alpha"],
outlet_backflow_clamp=m["outlet"]["backflow_clamp"],
inlet_trt_neq_damp=m["inlet"].get("trt_neq_damp", 0.5),
outlet_srt_neq_damp=m["outlet"].get("srt_neq_damp", 0.5),
y_wall_bc=m.get("y_wall_bc", "bounce_back"),
omega_min=m["omega_guard"]["min"],
omega_max=m["omega_guard"]["max"],
threads_per_block=c["threads_per_block"],

View File

@ -0,0 +1,90 @@
# CelerisLab 配置说明
**唯一配置入口:`config_lbm.json`**
Python `config.py` 只负责读取和校验,不是配置位置。
---
## grid — 网格
| 字段 | 类型 | 默认 | 允许值 | 说明 |
|------|------|------|--------|------|
| `lattice_model` | string | `"D2Q9"` | `D2Q9`, `D3Q19` | 格子模型,决定维度和速度数量 |
| `nx` | int | 512 | >0 | x 方向格点数(流向),必须整除 `threads_per_block` |
| `ny` | int | 256 | >0 | y 方向格点数 |
| `nz` | int | 1 | >0 | z 方向格点数D2Q9 时必须为 1 |
## physics — 物理参数
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `data_type` | string | `"FP32"` | 计算精度:当前仅实现 **`FP32`**CUDA 核与 `LBMField` 主机缓冲为 float32`FP64` 会在校验阶段被拒绝) |
| `viscosity` | float | 0.0035 | 运动粘度(格子单位),ω = 1/(3ν + 0.5) |
| `velocity` | float | 0.03 | 入口速度(格子单位) |
| `rho` | float | 1.0 | 参考密度 |
## method — 算法
| 字段 | 类型 | 默认 | 允许值 | 说明 |
|------|------|------|--------|------|
| `collision` | string | `"SRT"` | `SRT`, `TRT`, `MRT` | 碰撞算子 |
| `streaming` | string | `"double_buffer"` | `double_buffer`, `esopull` | 流传输方式 |
| `store_precision` | string | `"FP32"` | `FP32`, `FP16S`, `FP16C` | GPU 存储精度FP16S 可 1.87× 加速 |
| `ddf_shifting` | bool | false | | 存储 fw 而非 f提升 FP16 精度 |
| `les.enabled` | bool | false | | LES Smagorinsky 子格模型 |
| `les.cs` | float | 0.16 | | Smagorinsky 常数 |
| `les.closed_form` | bool | true | | 闭合形式 τ_effvs 迭代) |
| `trt.magic_param` | float | 0.1875 | | TRT Λ 参数,高 Re 建议 0.001 |
| `inlet.profile` | string | `"parabolic"` | `uniform`, `parabolic` | 入口速度剖面 |
| `inlet.trt_neq_damp` | float | 0.5 | [0, 1] | TRT 入口 NEQ donor 阻尼;更小更平滑、精度略降 |
| `outlet.mode` | string | `"neq_extrap"` | `neq_extrap`, `zero_gradient`, `blended` | 出口条件 |
| `outlet.backflow_clamp` | bool | true | | 出口回流钳位 |
| `outlet.blend_alpha` | float | 0.7 | | blended 模式混合系数 |
| `outlet.srt_neq_damp` | float | 0.5 | [0, 1] | SRT 出口 NEQ donor 阻尼;更小可减轻棋盘噪声 |
| `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 |
## cuda — 编译
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `threads_per_block` | int | 256 | CUDA block 线程数V100 最优 256 |
| `compute_capability` | string | `"auto"` | 目标 SM 架构,`auto` 自动检测 |
---
## 配置 → 代码映射
```
**`lbm/kernels/config/*.h` 由编译器根据 `LBMConfig` 自动生成,请勿手改。**
仓库中可能检入过历史版本的 `config/*.h`,与当前默认 JSON **不一定一致**;若以 Python 入口运行仿真,`Simulation` / `compiler_v2.generate_config` 会在编译前重写这些头文件。若只用 `nvcc` 单独编译 `kernel_v2.cu` 且跳过 `generate_config`,则可能读到陈旧宏,属不受支持用法。
config_lbm.json
↓ load_lbm_config()
config.py:LBMConfig (读取 + 校验)
↓ to_macros()
cuda/compiler_v2.py (生成 config/*.h)
config_grid.h → NX, NY, NZ, NT, LATTICE_MODEL
DIM / NQ 由 LATTICE_MODEL 推导写入(与 grid.lattice_model 一致,非 JSON 顶层字段)
config_physics.h → LBtype, VIS, RHO, U0
config_method.h → COLLISION_MODEL, STORE_PRECISION, USE_LES, Y_WALL_BC, ...
config_objects.h → N_OBJS
config_obs.h → OBS_*打包遥测force/torque/sensor 三段的 offset/stride 宏;`OBS_N_SLOTS=max(N_OBJS,1)`;由 `generate_config(cfg, n_objects=K)` 写入;**无**单独 JSON 字段DIM 仍由格子模型推导)
#include
descriptors.cuh → d_cx[], d_cy[], d_w[] (CUDA __constant__)
step/one_step_*.cu → kernel 编排
```
### 旋转物体数据契约Rotating body contract
- 几何参数(如 `rx`, `ry`)在 `initialize()` 后应视为不变量;改变几何属于拓扑变更,需要重新构建物体并重新初始化。
- 转速 `omega` 属于运行时状态,可在步进期间更新;该更新不改变 `N_OBJS``OBS_*` 内存布局,因此**不需要**因 `omega` 变化触发重新编译。
- 力矩观测从 `obs` 的 torque segment 读取force / torque / sensor 段统一由 `config_obs.h` 宏描述。
### 稳定性默认窗口Stability defaults
- 默认 `method.omega_guard = {min: 0.01, max: 1.96}`,用于约束碰撞频率与 LES 有效粘度路径。
- 高 Re 调参建议将 `omega_guard.max` 保持在 `1.90-1.96` 区间;过高上界会增大接近 `omega -> 2` 的不稳定风险。

View File

@ -1,34 +1,61 @@
{
"_doc": "CelerisLab LBM configuration — single source of truth. See configs/CONFIG.md for full documentation.",
"grid": {
"dim": 2,
"nq": 9,
"lattice_model": "D2Q9",
"_lattice_model": "D2Q9 | D3Q19",
"nx": 512,
"ny": 256,
"nz": 1
},
"physics": {
"data_type": "FP32",
"viscosity": 0.002,
"_data_type": "FP32 only for now (FP64 rejected at validate — kernels/host float32)",
"viscosity": 0.0035,
"velocity": 0.03,
"rho": 1.0
},
"method": {
"collision": "SRT",
"_collision": "SRT | TRT | MRT",
"streaming": "double_buffer",
"_streaming": "double_buffer | esopull",
"store_precision": "FP32",
"_store_precision": "FP32 | FP16S | FP16C — GPU DDF storage",
"ddf_shifting": false,
"les": {"enabled": false, "cs": 0.16},
"trt": {"magic_param": 0.1875},
"inlet": {"profile": "parabolic"},
"les": {
"enabled": false,
"cs": 0.16,
"closed_form": true
},
"trt": {
"magic_param": 0.1875
},
"inlet": {
"profile": "parabolic",
"_profile": "uniform | parabolic",
"trt_neq_damp": 0.5,
"_trt_neq_damp": "TRT inlet NEQ donor damping [0,1]. Lower = smoother inlet, less accurate."
},
"outlet": {
"mode": "neq_extrap",
"_mode": "neq_extrap | zero_gradient | blended",
"backflow_clamp": true,
"blend_alpha": 0.7
"blend_alpha": 0.7,
"srt_neq_damp": 0.5,
"_srt_neq_damp": "SRT outlet NEQ donor damping [0,1]. Lower = less checkerboard noise."
},
"omega_guard": {"min": 0.01, "max": 1.999}
"y_wall_bc": "bounce_back",
"_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"
}
},
"cuda": {
"threads_per_block": 128,
"compute_capability": "auto"
"threads_per_block": 256,
"compute_capability": "auto",
"_compute_capability": "auto | e.g. 7.0"
}
}

View File

@ -0,0 +1,48 @@
{
"_doc": "Three rotating cylinders in confined channel (triangle layout, free-slip walls).",
"grid": {
"lattice_model": "D2Q9",
"nx": 6000,
"ny": 1200,
"nz": 1
},
"physics": {
"data_type": "FP32",
"viscosity": 0.004,
"velocity": 0.04,
"rho": 1.0
},
"method": {
"collision": "MRT",
"streaming": "double_buffer",
"store_precision": "FP32",
"ddf_shifting": false,
"les": {
"enabled": false,
"cs": 0.16,
"closed_form": true
},
"trt": {
"magic_param": 0.1875
},
"inlet": {
"profile": "uniform",
"trt_neq_damp": 0.5
},
"outlet": {
"mode": "neq_extrap",
"backflow_clamp": true,
"blend_alpha": 0.7,
"srt_neq_damp": 0.5
},
"y_wall_bc": "free_slip",
"omega_guard": {
"min": 0.01,
"max": 1.96
}
},
"cuda": {
"threads_per_block": 256,
"compute_capability": "auto"
}
}

View File

@ -1,4 +1,4 @@
# CelerisLab/cuda/compiler.py
# CelerisLab/cuda/compiler_v2.py
"""
Kernel configuration, compilation, and module loading.
@ -15,6 +15,7 @@ from typing import Optional
import pycuda.driver as cuda
from ..config import LBMConfig
from .obs_layout import obs_layout # re-exported for body.manager import path
# ---------------------------------------------------------------------------
# Paths
@ -57,8 +58,24 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0):
#define NX {m['NX']}
#define NY {m['NY']}
#define NZ {m['NZ']}
#define DIM {m['DIM']}
#define NQ {m['NQ']}
// ---- Lattice model (single source of truth) ----
// Model IDs: 0=D2Q9, 1=D3Q19 (extend here for future models like D2Q19)
#define LATTICE_D2Q9 0
#define LATTICE_D3Q19 1
#define LATTICE_MODEL {m['LATTICE_MODEL']}
// DIM and NQ are derived from LATTICE_MODEL do NOT set independently.
#if LATTICE_MODEL == LATTICE_D2Q9
#define DIM 2
#define NQ 9
#elif LATTICE_MODEL == LATTICE_D3Q19
#define DIM 3
#define NQ 19
#else
#error "Unknown LATTICE_MODEL. Supported: LATTICE_D2Q9 (0), LATTICE_D3Q19 (1)."
#endif
#endif
""")
@ -75,12 +92,8 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0):
#define PI 3.141592653589793238
#define FLUID 0x01
#define SOLID 0x02
#define GAS 0x04
#define INTERFACE 0x08
#define SENSOR 0x10
#define OBSTACLE 0x20
// Flag constants are defined in core/flags.cuh (uint16_t).
// Do NOT redefine them here.
#define V_TAYLOR 1
@ -99,16 +112,24 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0):
#define USE_LES {m['USE_LES']}
#define LES_CS {m['LES_CS']}
#define LES_CLOSED_FORM {m['LES_CLOSED_FORM']}
#define INLET_PROFILE {m['INLET_PROFILE']}
#define OUTLET_MODE {m['OUTLET_MODE']}
#define OUTLET_BLEND_ALPHA {m['OUTLET_BLEND_ALPHA']}
#define OUTLET_BACKFLOW_CLAMP {m['OUTLET_BACKFLOW_CLAMP']}
#define Y_WALL_BC {m['Y_WALL_BC']}
#define OMEGA_COLLISION_MIN {m['OMEGA_COLLISION_MIN']}
#define OMEGA_COLLISION_MAX {m['OMEGA_COLLISION_MAX']}
#define TRT_MAGIC_PARAM {m['TRT_MAGIC_PARAM']}
// NEQ damping coefficients for inlet/outlet BC reconstruction.
// TRT inlet: damps donor non-equilibrium to reduce inlet noise at high Re.
// SRT outlet: damps donor non-equilibrium to suppress checkerboard noise.
#define INLET_TRT_NEQ_DAMP {m['INLET_TRT_NEQ_DAMP']}
#define OUTLET_SRT_NEQ_DAMP {m['OUTLET_SRT_NEQ_DAMP']}
#endif
""")
@ -119,6 +140,38 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0):
#define N_OBJS {n_objects}
#endif
""")
_write(os.path.join(cdir, "config_obs.h"), f"""{_HEADER}\
// Layer: Case / Objects packed obs (force + torque + sensor), float indices not bytes
// Derived from N_OBJS (config_objects.h) and DIM (config_grid.h); include via config.h.
#ifndef CELERIS_CONFIG_OBS_H
#define CELERIS_CONFIG_OBS_H
#if (N_OBJS) >= 1
#define OBS_N_SLOTS (N_OBJS)
#else
#define OBS_N_SLOTS 1
#endif
#define OBS_SLOT_STRIDE_FLOATS ((OBS_N_SLOTS) * (DIM))
#define OBS_FORCE_FLOATS (OBS_SLOT_STRIDE_FLOATS)
#if DIM == 2
#define OBS_TORQUE_COMPONENTS 1
#else
#define OBS_TORQUE_COMPONENTS (DIM)
#endif
#define OBS_TORQUE_FLOATS ((OBS_N_SLOTS) * (OBS_TORQUE_COMPONENTS))
#define OBS_BODY_SLOT_FLOATS (3 * (DIM))
#define OBS_FORCE0_FLOATS 0
#define OBS_TORQUE0_FLOATS (OBS_FORCE0_FLOATS + OBS_FORCE_FLOATS)
#define OBS_SENSOR0_FLOATS (OBS_TORQUE0_FLOATS + OBS_TORQUE_FLOATS)
#define OBS_TOTAL_FLOATS (OBS_SENSOR0_FLOATS + OBS_SLOT_STRIDE_FLOATS)
#endif
""")
@ -129,13 +182,21 @@ def generate_config(cfg: LBMConfig, n_objects: int = 0):
def compile_kernel(arch: str = "sm_70",
source: str = "kernel_v2.cu",
output: Optional[str] = None) -> str:
"""Compile *source* to PTX. Returns path to the .ptx file."""
"""Compile *source* to PTX. Returns path to the .ptx file.
The translation unit includes ``config/*.h``. Those headers must match the
intended ``LBMConfig`` call :func:`generate_config` before compiling.
Invoking ``nvcc`` on ``kernel_v2.cu`` alone can pick up stale checked-in
headers from ``lbm/kernels/config/``.
"""
src = kernel_path(source)
if output is None:
output = source.replace(".cu", ".ptx")
dst = kernel_path(output)
import shutil
nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc"
subprocess.run(
["nvcc", "-ptx", f"-arch={arch}", src, "-o", dst],
[nvcc, "-ptx", f"-arch={arch}", "--use_fast_math", src, "-o", dst],
check=True,
)
return dst
@ -159,14 +220,3 @@ def compile_kernel_v2(arch: str = "sm_70") -> str:
"""Alias for compile_kernel() kept for test-script compatibility."""
return compile_kernel(arch=arch)
def read_lines(path: str):
"""Read a text file and return a list of lines (with newlines)."""
with open(path, "r") as f:
return f.readlines()
def write_lines(path: str, lines):
"""Write a list of lines back to a text file."""
with open(path, "w") as f:
f.writelines(lines)

View File

@ -0,0 +1,61 @@
# CelerisLab/cuda/obs_layout.py
"""Packed ``obs`` buffer layout for force / torque / sensor telemetry.
Usage::
Host code (e.g. ``ObjectManager``) and ``compiler_v2.generate_config`` must use
the same ``dim`` and ``n_objects`` so allocation sizes match device macros.
Design::
Segments are contiguous in this order:
1) force : ``max(n_objects, 1) * dim`` floats
2) torque : ``max(n_objects, 1) * (1 if dim==2 else dim)`` floats
3) sensor : ``max(n_objects, 1) * dim`` floats
"""
from typing import NamedTuple
class ObsLayout(NamedTuple):
"""Integer offsets and sizes in **float** elements (not bytes)."""
n_slots: int
torque_components: int
slot_stride_floats: int
force_floats: int
torque_floats: int
force0_floats: int
torque0_floats: int
sensor0_floats: int
total_floats: int
def obs_layout(dim: int, n_objects: int) -> ObsLayout:
"""Compute packed ``obs`` indices for force, torque, and sensor segments.
Args:
dim: Spatial dimension (2 or 3); must match ``LBMConfig.dim``.
n_objects: Number of registered objects; may be zero.
Returns:
:class:`ObsLayout` with segment sizes and float offsets.
"""
n_slots = max(int(n_objects), 1)
torque_components = 1 if int(dim) == 2 else int(dim)
slot_stride_floats = n_slots * dim
force_floats = slot_stride_floats
torque_floats = n_slots * torque_components
force0_floats = 0
torque0_floats = force0_floats + force_floats
sensor0_floats = torque0_floats + torque_floats
total_floats = sensor0_floats + slot_stride_floats
return ObsLayout(
n_slots=n_slots,
torque_components=torque_components,
slot_stride_floats=slot_stride_floats,
force_floats=force_floats,
torque_floats=torque_floats,
force0_floats=force0_floats,
torque0_floats=torque0_floats,
sensor0_floats=sensor0_floats,
total_floats=total_floats,
)

View File

@ -4,6 +4,7 @@ Lattice Boltzmann Method (LBM) module for fluid simulation.
"""
try:
from .descriptors import * # noqa: flags, lattice vectors
from .field import LBMField
from .stepper import LBMStepper
from .initializers import add_lamb_oseen, add_taylor_green
@ -13,5 +14,3 @@ except ImportError as e:
warnings.warn(f"LBM module not fully available: {e}", ImportWarning)
__all__ = []
# Legacy import — kept while test scripts still reference FlowField

View File

@ -0,0 +1,258 @@
# CelerisLab/lbm/curved_links.py
"""Structure-of-arrays helpers for curved Bouzidi links and sensor probe lists.
Responsibilities:
Hold host columns and lazy GPU mirrors for compact boundary kernels.
Design::
Device columns are reused by capacity to avoid reallocating every upload.
``count`` tracks active rows while ``capacity`` tracks allocated rows.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
import pycuda.driver as cuda
def _free_alloc(ptr: Optional[cuda.DeviceAllocation]) -> None:
if ptr is not None:
ptr.free()
@dataclass
class CurvedLinkSoA:
"""Cut-link columns on host with matching ``*_gpu`` device buffers."""
fluid_idx: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.uint32))
dir: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.uint8))
q: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.float32))
body_id: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.int32))
rx: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.float32))
ry: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.float32))
rz: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.float32))
fallback_class: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.uint8))
fluid_idx_gpu: Optional[cuda.DeviceAllocation] = None
dir_gpu: Optional[cuda.DeviceAllocation] = None
q_gpu: Optional[cuda.DeviceAllocation] = None
body_id_gpu: Optional[cuda.DeviceAllocation] = None
rx_gpu: Optional[cuda.DeviceAllocation] = None
ry_gpu: Optional[cuda.DeviceAllocation] = None
rz_gpu: Optional[cuda.DeviceAllocation] = None
fallback_class_gpu: Optional[cuda.DeviceAllocation] = None
count: int = 0
capacity: int = 0
def assign_host(
self,
fluid_idx: np.ndarray,
dir: np.ndarray,
q: np.ndarray,
body_id: np.ndarray,
rx: np.ndarray,
ry: np.ndarray,
rz: np.ndarray,
fallback_class: np.ndarray,
) -> None:
"""Store and validate host SoA columns for curved-link kernel inputs."""
expected_dtypes = {
"fluid_idx": np.uint32,
"dir": np.uint8,
"q": np.float32,
"body_id": np.int32,
"rx": np.float32,
"ry": np.float32,
"rz": np.float32,
"fallback_class": np.uint8,
}
raw_columns = {
"fluid_idx": fluid_idx,
"dir": dir,
"q": q,
"body_id": body_id,
"rx": rx,
"ry": ry,
"rz": rz,
"fallback_class": fallback_class,
}
columns = {}
for name, value in raw_columns.items():
arr = np.asarray(value)
if not np.can_cast(arr.dtype, expected_dtypes[name], casting="same_kind"):
raise TypeError(
f"CurvedLinkSoA column '{name}' dtype {arr.dtype} "
f"is incompatible with {np.dtype(expected_dtypes[name])}"
)
columns[name] = arr.astype(expected_dtypes[name], copy=False)
n = len(columns["fluid_idx"])
for name, col in columns.items():
if len(col) != n:
raise ValueError(
f"CurvedLinkSoA column '{name}' has length {len(col)} != {n}"
)
if not col.flags["C_CONTIGUOUS"]:
columns[name] = np.ascontiguousarray(col)
self.fluid_idx = columns["fluid_idx"]
self.dir = columns["dir"]
self.q = columns["q"]
self.body_id = columns["body_id"]
self.rx = columns["rx"]
self.ry = columns["ry"]
self.rz = columns["rz"]
self.fallback_class = columns["fallback_class"]
if self.q.size > 0:
if np.any((self.q <= 0.0) | (self.q > 1.0)):
raise ValueError("CurvedLinkSoA column 'q' must satisfy 0 < q <= 1")
if self.fallback_class.size > 0:
if not np.all(np.isin(self.fallback_class, (0, 1))):
raise ValueError(
"CurvedLinkSoA column 'fallback_class' must use classes {0, 1}"
)
def upload(self, stream: Optional[cuda.Stream] = None) -> None:
"""Upload host columns to GPU with capacity-based allocation reuse.
Args:
stream: If set, use async host-to-device copies on this stream.
"""
n = int(len(self.fluid_idx))
self.count = n
if n == 0:
return
if n > self.capacity or self.fluid_idx_gpu is None:
self.free_gpu_columns()
self.fluid_idx_gpu = cuda.mem_alloc(int(n * self.fluid_idx.dtype.itemsize))
self.dir_gpu = cuda.mem_alloc(int(n * self.dir.dtype.itemsize))
self.q_gpu = cuda.mem_alloc(int(n * self.q.dtype.itemsize))
self.body_id_gpu = cuda.mem_alloc(int(n * self.body_id.dtype.itemsize))
self.rx_gpu = cuda.mem_alloc(int(n * self.rx.dtype.itemsize))
self.ry_gpu = cuda.mem_alloc(int(n * self.ry.dtype.itemsize))
self.rz_gpu = cuda.mem_alloc(int(n * self.rz.dtype.itemsize))
self.fallback_class_gpu = cuda.mem_alloc(
int(n * self.fallback_class.dtype.itemsize)
)
self.capacity = n
assert self.fluid_idx_gpu is not None
assert self.dir_gpu is not None
assert self.q_gpu is not None
assert self.body_id_gpu is not None
assert self.rx_gpu is not None
assert self.ry_gpu is not None
assert self.rz_gpu is not None
assert self.fallback_class_gpu is not None
if stream is not None:
cuda.memcpy_htod_async(self.fluid_idx_gpu, self.fluid_idx, stream)
cuda.memcpy_htod_async(self.dir_gpu, self.dir, stream)
cuda.memcpy_htod_async(self.q_gpu, self.q, stream)
cuda.memcpy_htod_async(self.body_id_gpu, self.body_id, stream)
cuda.memcpy_htod_async(self.rx_gpu, self.rx, stream)
cuda.memcpy_htod_async(self.ry_gpu, self.ry, stream)
cuda.memcpy_htod_async(self.rz_gpu, self.rz, stream)
cuda.memcpy_htod_async(
self.fallback_class_gpu, self.fallback_class, stream)
else:
cuda.memcpy_htod(self.fluid_idx_gpu, self.fluid_idx)
cuda.memcpy_htod(self.dir_gpu, self.dir)
cuda.memcpy_htod(self.q_gpu, self.q)
cuda.memcpy_htod(self.body_id_gpu, self.body_id)
cuda.memcpy_htod(self.rx_gpu, self.rx)
cuda.memcpy_htod(self.ry_gpu, self.ry)
cuda.memcpy_htod(self.rz_gpu, self.rz)
cuda.memcpy_htod(self.fallback_class_gpu, self.fallback_class)
def free_gpu_columns(self) -> None:
"""Release device columns only (host arrays unchanged)."""
_free_alloc(self.fluid_idx_gpu)
_free_alloc(self.dir_gpu)
_free_alloc(self.q_gpu)
_free_alloc(self.body_id_gpu)
_free_alloc(self.rx_gpu)
_free_alloc(self.ry_gpu)
_free_alloc(self.rz_gpu)
_free_alloc(self.fallback_class_gpu)
self.fluid_idx_gpu = None
self.dir_gpu = None
self.q_gpu = None
self.body_id_gpu = None
self.rx_gpu = None
self.ry_gpu = None
self.rz_gpu = None
self.fallback_class_gpu = None
self.capacity = 0
def free(self) -> None:
"""Free GPU storage and reset ``count``."""
self.free_gpu_columns()
self.count = 0
@dataclass
class SensorSoA:
"""Sensor cell list with GPU mirrors for ``SensorKernel``."""
cells: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.uint32))
obj_id: np.ndarray = field(
default_factory=lambda: np.zeros(0, dtype=np.int32))
cells_gpu: Optional[cuda.DeviceAllocation] = None
obj_id_gpu: Optional[cuda.DeviceAllocation] = None
count: int = 0
def assign_host(self, cells: np.ndarray, obj_id: np.ndarray) -> None:
"""Store host sensor columns."""
self.cells = cells
self.obj_id = obj_id
def upload(self, stream: Optional[cuda.Stream] = None) -> None:
"""Reallocate GPU columns and upload.
Args:
stream: If set, use async host-to-device copies on this stream.
"""
self.free_gpu_columns()
n = int(len(self.cells))
self.count = n
if n == 0:
return
self.cells_gpu = cuda.mem_alloc(int(self.cells.nbytes))
self.obj_id_gpu = cuda.mem_alloc(int(self.obj_id.nbytes))
if stream is not None:
cuda.memcpy_htod_async(self.cells_gpu, self.cells, stream)
cuda.memcpy_htod_async(self.obj_id_gpu, self.obj_id, stream)
else:
cuda.memcpy_htod(self.cells_gpu, self.cells)
cuda.memcpy_htod(self.obj_id_gpu, self.obj_id)
def free_gpu_columns(self) -> None:
_free_alloc(self.cells_gpu)
_free_alloc(self.obj_id_gpu)
self.cells_gpu = None
self.obj_id_gpu = None
def free(self) -> None:
"""Free GPU storage and reset ``count``."""
self.free_gpu_columns()
self.count = 0

View File

@ -0,0 +1,49 @@
# CelerisLab/lbm/descriptors.py
"""
Python-side lattice descriptor constants.
This module is the **single authoritative source** for lattice velocity
vectors and flag constants on the Python side. The CUDA-side source of
truth lives in ``core/descriptors.cuh`` and ``core/flags.cuh``; the two
MUST be kept in sync (review on any change to either file).
Why here and not body/?
This is a multi-physics project flags and lattice vectors are shared
across all physics modules (fluid, thermal, species, ), not specific
to solid bodies. ``body/`` imports from here, not the other way around.
"""
# ===================================================================
# Cell flag constants (must match core/flags.cuh — uint16 layout)
# ===================================================================
# Cell type [3:0]
FLUID = 0x0001
SOLID = 0x0002
GAS = 0x0004
INTERFACE = 0x0008
# BC subtype [7:4]
BC_WALL = 0x0010
BC_INLET = 0x0020
BC_OUTLET = 0x0030
BC_CURVED = 0x0040
BC_PERIODIC = 0x0050
BC_MOVING = 0x0060
# Extension [15:8]
SENSOR_FLAG = 0x0100
OBSTACLE = 0x0200
# ===================================================================
# D2Q9 lattice velocity vectors
# Must match descriptors.cuh D2Q9 ordering.
# Used by host geometry routines that run *before* CUDA module load.
# After module load, prefer field._read_lattice_vectors().
# ===================================================================
D2Q9_EX = (0, 1, -1, 0, 0, 1, -1, 1, -1)
D2Q9_EY = (0, 0, 0, 1, -1, 1, -1, -1, 1)
# ===================================================================
# D3Q19 lattice velocity vectors
# ===================================================================
D3Q19_EX = (0, 1,-1, 0, 0, 0, 0, 1,-1, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0)
D3Q19_EY = (0, 0, 0, 1,-1, 0, 0, 1,-1, 0, 0, 1,-1, -1, 1, 0, 0, 1,-1)
D3Q19_EZ = (0, 0, 0, 0, 0, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0, -1, 1, -1, 1)

View File

@ -5,28 +5,34 @@ LBMField — GPU memory for distribution functions, flags, and macroscopic data.
Owns:
ddf_gpu / temp_gpu distribution-function double buffers
flag_gpu cell-type flags
indx_gpu per-cell object index (for curved BC / sensors)
delta_gpu curved-boundary interpolation data
curved / sensors SoA bundles for compact curved BC and sensor lists
Provides:
upload_params() push LBMParams to __constant__ memory (fixes d_params bug)
upload_params() push LBMParams to __constant__ memory
get_ddf() / set_ddf()
get_macroscopic() rho, ux, uy [, uz]
snapshot() / restore()
"""
import ctypes
import struct
from typing import Optional
import numpy as np
import pycuda.driver as cuda
from ..config import LBMConfig
from .curved_links import CurvedLinkSoA, SensorSoA
from .descriptors import FLUID
_SUPPORTED_STORE_PRECISIONS = ("FP32", "FP16S")
class LBMField:
"""Allocate and manage GPU arrays for one LBM domain."""
def __init__(self, cfg: LBMConfig, module: cuda.Module):
assert cfg.dim > 0 and cfg.nq > 0, (
"cfg.validate() must be called before constructing LBMField")
self.cfg = cfg
self.module = module
@ -34,20 +40,32 @@ class LBMField:
self.nq = cfg.nq
self.dim = cfg.dim
self.n = self.nx * self.ny * self.nz
self.dtype = np.float32 # extend when FP64 supported
self.dtype = np.float32 # compute precision always float32
# Host arrays
# Storage precision: current runtime path supports FP32 and FP16S only.
_prec = getattr(cfg, 'store_precision', 'FP32')
if _prec not in _SUPPORTED_STORE_PRECISIONS:
supported = ", ".join(_SUPPORTED_STORE_PRECISIONS)
raise RuntimeError(
f"Unsupported store_precision '{_prec}' in LBMField. "
f"Supported runtime precisions: {supported}."
)
self.store_bytes = 2 if _prec == 'FP16S' else 4
self.store_dtype = np.float16 if _prec == 'FP16S' else np.float32
# Host arrays (always float32 for convenience)
self.ddf = np.zeros(self.n * self.nq, dtype=self.dtype)
self.flag = np.ones(self.n, dtype=np.uint8) * 0x01 # FLUID
self.indx = np.zeros(self.n, dtype=np.int32)
self.delta = np.zeros(0, dtype=self.dtype)
self.flag = np.ones(self.n, dtype=np.uint16) * FLUID
# GPU allocations
self.ddf_gpu = cuda.mem_alloc(self.ddf.nbytes)
self.temp_gpu = cuda.mem_alloc(self.ddf.nbytes)
# Compact lists (filled by ObjectManager.sync_to_gpu)
self.curved = CurvedLinkSoA()
self.sensors = SensorSoA()
# GPU allocations sized by storage precision
_ddf_bytes = self.n * self.nq * self.store_bytes
self.ddf_gpu = cuda.mem_alloc(_ddf_bytes)
self.temp_gpu = cuda.mem_alloc(_ddf_bytes)
self.flag_gpu = cuda.mem_alloc(self.flag.nbytes)
self.indx_gpu = cuda.mem_alloc(self.indx.nbytes)
self.delta_gpu = cuda.mem_alloc(max(4, self.delta.nbytes))
# Snapshot
self._ddf_snap: np.ndarray | None = None
@ -55,18 +73,58 @@ class LBMField:
# Upload d_params immediately
self._upload_params()
@property
def n_curved(self) -> int:
return self.curved.count
@property
def n_sensor(self) -> int:
return self.sensors.count
# -- DDF download with precision decode ----------------------------------
def download_ddf(self):
"""Copy DDF from GPU to host self.ddf (float32), decoding FP16S if needed.
If DDF shifting is enabled, un-shifts by adding back lattice weights."""
if self.store_bytes == 2:
buf = np.empty(self.n * self.nq, dtype=self.store_dtype)
cuda.memcpy_dtoh(buf, self.ddf_gpu)
if self.store_dtype == np.float16:
# FP16S: stored as half(val * 32768) → decode by / 32768
self.ddf[:] = buf.astype(np.float32) * 3.0517578125e-5
else:
self.ddf[:] = buf.astype(np.float32)
else:
cuda.memcpy_dtoh(self.ddf, self.ddf_gpu)
# Un-shift: kernel stores (f_i - w_i), restore to f_i for host analysis
if getattr(self.cfg, 'ddf_shifting', False):
w = self._read_lattice_weights()
ddf_2d = self.ddf.reshape(self.nq, -1)
for i in range(self.nq):
ddf_2d[i] += w[i]
def _read_lattice_weights(self):
"""Read d_w[] from CUDA __constant__ memory (single source of truth in descriptors.cuh)."""
ptr, size = self.module.get_global("d_w")
w = np.empty(self.nq, dtype=np.float32)
cuda.memcpy_dtoh(w, ptr)
return w
# -- d_params upload -----------------------------------------------------
def _upload_params(self):
"""Pack LBMParams struct and upload to __constant__ d_params."""
"""Pack LBMParams struct and upload to __constant__ d_params.
Must match struct LBMParams in core/params.cuh layout:
float omega, omega_bulk;
float fx, fy, fz;
float rho_ref, u_inlet;
uint n_objects;
Total: 8 floats + 1 uint = 36 bytes (no alignment padding needed).
"""
cfg = self.cfg
# Must match struct LBMParams in core/params.cuh layout:
# uint Nx,Ny,Nz; ulong N; float omega,omega_bulk;
# float fx,fy,fz; float rho_ref,u_inlet; uint n_objects;
fmt = "IIILff fff ff I"
fmt = "=ff fff ff I"
data = struct.pack(
fmt,
cfg.nx, cfg.ny, cfg.nz,
self.n,
cfg.omega, 0.0, # omega, omega_bulk
0.0, 0.0, 0.0, # fx, fy, fz
cfg.rho, cfg.velocity, # rho_ref, u_inlet
@ -90,10 +148,9 @@ class LBMField:
u_inlet = kwargs.get("u_inlet", cfg.velocity)
n_objects = kwargs.get("n_objects", 0)
fmt = "IIILff fff ff I"
fmt = "=ff fff ff I"
data = struct.pack(
fmt,
cfg.nx, cfg.ny, cfg.nz, self.n,
omega, omega_bulk,
fx, fy, fz,
rho_ref, u_inlet,
@ -104,52 +161,71 @@ class LBMField:
# -- Host ↔ GPU transfers ------------------------------------------------
def upload_ddf(self):
if self.store_bytes == 2:
if self.store_dtype != np.float16:
raise RuntimeError(
"Invalid 2-byte DDF storage dtype; expected float16 for FP16S."
)
buf = (self.ddf * 32768.0).astype(self.store_dtype)
cuda.memcpy_htod(self.ddf_gpu, buf)
cuda.memcpy_htod(self.temp_gpu, buf)
else:
cuda.memcpy_htod(self.ddf_gpu, self.ddf)
cuda.memcpy_htod(self.temp_gpu, self.ddf)
def download_ddf(self):
cuda.memcpy_dtoh(self.ddf, self.ddf_gpu)
# The canonical download_ddf is defined above in the class body.
def upload_flags(self):
cuda.memcpy_htod(self.flag_gpu, self.flag)
cuda.memcpy_htod(self.indx_gpu, self.indx)
def upload_delta(self):
if self.delta.nbytes > 0:
self.delta_gpu.free()
self.delta_gpu = cuda.mem_alloc(self.delta.nbytes)
cuda.memcpy_htod(self.delta_gpu, self.delta)
def upload_compact_lists(self, stream: Optional[cuda.Stream] = None) -> None:
"""Upload cut-link and sensor compact lists to GPU."""
self.curved.upload(stream=stream)
self.sensors.upload(stream=stream)
# -- Read lattice descriptors from CUDA module ---------------------------
def _read_lattice_vectors(self):
"""Read d_cx, d_cy [, d_cz] from CUDA __constant__ memory."""
nq = self.nq
cx = np.empty(nq, dtype=np.int32)
cy = np.empty(nq, dtype=np.int32)
ptr_cx, _ = self.module.get_global("d_cx")
ptr_cy, _ = self.module.get_global("d_cy")
cuda.memcpy_dtoh(cx, ptr_cx)
cuda.memcpy_dtoh(cy, ptr_cy)
if self.cfg.is_d3q19:
cz = np.empty(nq, dtype=np.int32)
ptr_cz, _ = self.module.get_global("d_cz")
cuda.memcpy_dtoh(cz, ptr_cz)
return cx, cy, cz
return cx, cy
# -- Macroscopic field extraction ----------------------------------------
def get_macroscopic(self):
"""Download DDF and compute rho, ux, uy [, uz] on host."""
self.download_ddf()
nq, n = self.nq, self.n
shape = (self.nz, self.ny, self.nx) if self.dim == 3 else (self.ny, self.nx)
nq = self.nq
if nq == 9:
f = self.ddf.reshape(9, self.ny, self.nx)
if self.cfg.is_d2q9:
f = self.ddf.reshape(nq, self.ny, self.nx)
rho = np.sum(f, axis=0)
cx = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float32)
cy = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float32)
cx, cy = self._read_lattice_vectors()
rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0)
ux = sum(f[i] * cx[i] for i in range(9)) / rho_safe
uy = sum(f[i] * cy[i] for i in range(9)) / rho_safe
ux = sum(f[i] * cx[i] for i in range(nq)) / rho_safe
uy = sum(f[i] * cy[i] for i in range(nq)) / rho_safe
return {"rho": rho, "ux": ux, "uy": uy}
if nq == 19:
f = self.ddf.reshape(19, self.nz, self.ny, self.nx)
if self.cfg.is_d3q19:
f = self.ddf.reshape(nq, self.nz, self.ny, self.nx)
rho = np.sum(f, axis=0)
cx = np.array([0,1,-1,0,0,0,0,1,-1,1,-1,0,0,1,-1,1,-1,0,0])
cy = np.array([0,0,0,1,-1,0,0,1,-1,0,0,1,-1,-1,1,0,0,1,-1])
cz = np.array([0,0,0,0,0,1,-1,0,0,1,-1,1,-1,0,0,-1,1,-1,1])
cx, cy, cz = self._read_lattice_vectors()
rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0)
ux = sum(f[i]*cx[i] for i in range(19)) / rho_safe
uy = sum(f[i]*cy[i] for i in range(19)) / rho_safe
uz = sum(f[i]*cz[i] for i in range(19)) / rho_safe
ux = sum(f[i] * cx[i] for i in range(nq)) / rho_safe
uy = sum(f[i] * cy[i] for i in range(nq)) / rho_safe
uz = sum(f[i] * cz[i] for i in range(nq)) / rho_safe
return {"rho": rho, "ux": ux, "uy": uy, "uz": uz}
raise ValueError(f"Unsupported nq={nq}")
raise ValueError(f"Unsupported lattice_model: {self.cfg.lattice_model}")
# -- Snapshots -----------------------------------------------------------
def snapshot(self):

View File

@ -12,16 +12,6 @@ from typing import Tuple
import pycuda.driver as cuda
# D2Q9 velocity vectors and weights
_E9 = np.array(
[0, 0, 1, 0, 0, 1, -1, 0, 0, -1, 1, 1, -1, 1, -1, -1, 1, -1],
dtype=np.int32,
).reshape(9, 2)
_W9 = np.array(
[4/9, 1/9, 1/9, 1/9, 1/9, 1/36, 1/36, 1/36, 1/36],
dtype=np.float32,
)
def add_vortex(field, center: Tuple[float, float],
radius: float, strength: float,
@ -30,8 +20,8 @@ def add_vortex(field, center: Tuple[float, float],
Supported types: "lamb", "oseen", "taylor".
"""
if field.cfg.nq != 9 or field.cfg.dim != 2:
raise NotImplementedError("Vortex init only for 2D D2Q9")
if not field.cfg.is_d2q9:
raise NotImplementedError("Vortex init only for D2Q9")
if vortex_type not in ("lamb", "oseen", "taylor"):
raise ValueError(f"Unknown vortex type: {vortex_type}")
@ -54,12 +44,17 @@ def add_vortex(field, center: Tuple[float, float],
# Download current DDF
field.download_ddf()
f = field.ddf.reshape(9, ny, nx)
nq = field.nq
f = field.ddf.reshape(nq, ny, nx)
# Read lattice descriptors from CUDA module (single source of truth)
cx, cy = field._read_lattice_vectors()
w = field._read_lattice_weights()
# Compute current macroscopic from DDF
rho_old = np.sum(f, axis=0)
ux_old = (f[1]+f[5]+f[8]-f[3]-f[6]-f[7])
uy_old = (f[2]+f[5]+f[6]-f[4]-f[7]-f[8])
ux_old = sum(f[i] * cx[i] for i in range(nq))
uy_old = sum(f[i] * cy[i] for i in range(nq))
p_old = rho_old / 3.0
# Superimpose
@ -72,10 +67,10 @@ def add_vortex(field, center: Tuple[float, float],
for j in range(1, ny - 1):
for i in range(1, nx - 1):
u, v, p = float(u_new[j, i]), float(v_new[j, i]), float(p_new[j, i])
for e in range(9):
eu = _E9[e, 0] * u + _E9[e, 1] * v
for e in range(nq):
eu = cx[e] * u + cy[e] * v
u2 = u * u + v * v
f[e, j, i] = _W9[e] * (3*p + 3*eu + 4.5*eu*eu - 1.5*u2)
f[e, j, i] = w[e] * (3*p + 3*eu + 4.5*eu*eu - 1.5*u2)
field.ddf = f.reshape(-1)
field.upload_ddf()

View File

@ -1,48 +1,18 @@
// CelerisLab boundary/bounce_back.cuh
// Full-way bounce-back for grid-aligned solid walls.
// Half-way bounce-back for grid-aligned solid walls (pull-streaming variant).
//
// For nodes adjacent to a solid wall, the incoming population from the
// wall direction is reflected:
// f[i] = f[opp(i)] (+ wall velocity correction for moving walls)
// For wall-adjacent fluid nodes, the pull step loaded garbage from the wall.
// We replace those directions with the opposite-direction population at the
// SAME node from the INPUT buffer (half-way BB).
// ============================================================================
#ifndef CELERIS_BOUNDARY_BOUNCE_BACK_CUH
#define CELERIS_BOUNDARY_BOUNCE_BACK_CUH
// ---------------------------------------------------------------------------
// Simple bounce-back: replace f[i] with f[opp(i)] for solid neighbors
// This version checks the flag of each neighbor.
// D2Q9 y-wall half-way bounce-back for pull double-buffer streaming.
// ---------------------------------------------------------------------------
__device__ inline void apply_bounce_back(float* __restrict__ f,
const unsigned long* __restrict__ j,
const uint8_t* __restrict__ flags_arr)
{
// For each direction pair (i, i+1):
// If the neighbor in direction i is SOLID → f[i] should bounce
// from the opposite direction.
//
// With paired ordering, opp(i) = i+1 and opp(i+1) = i.
// In pull streaming, f[i] was loaded from j[opp(i)] = j[i+1].
// If j[i+1] is solid, we need bounce-back.
for (int i = 1; i < NQ; i += 2) {
// Direction i: check if source (j[i+1]) is solid
if (flags_arr[j[i + 1]] & LEGACY_SOLID) {
// Bounce: f[i] = f_post[opp(i)] at current node = pre-collision f[i+1]
// But in pull-streaming context, we already loaded from neighbor.
// For simple BB on a flat wall, just swap f[i] and f[i+1]:
float temp = f[i];
f[i] = f[i + 1];
f[i + 1] = temp;
}
}
}
// ---------------------------------------------------------------------------
// Top/bottom wall bounce-back (specialized for channel flow)
// For nodes at y=1 (adjacent to y=0 wall) or y=NY-2 (adjacent to y=NY-1 wall)
// ---------------------------------------------------------------------------
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
@ -62,29 +32,33 @@ __device__ inline void apply_wall_bb_d2q9_y_pull(unsigned int y,
}
}
__device__ inline void apply_wall_bb_d2q9(unsigned int y,
float* __restrict__ f)
// ---------------------------------------------------------------------------
// D2Q9 y-wall free-slip (specular reflection) for pull double-buffer streaming.
//
// Normal components use the same mapping as half-way BB at horizontal walls:
// (0,+1) <-> (0,-1)
// Tangential components mirror across wall normal:
// (+1,+1) <-> (+1,-1), (-1,+1) <-> (-1,-1)
// ---------------------------------------------------------------------------
__device__ inline void apply_wall_freeslip_d2q9_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
unsigned long k)
{
if (y == 1) {
float temp;
temp = f[3]; f[3] = f[4]; f[4] = temp; // ±y swap
temp = f[5]; f[5] = f[6]; f[6] = temp; // ±(x+y) swap
temp = f[7]; f[7] = f[8]; f[8] = temp; // ±(x-y) swap
f[3] = load_ddf(fi_in, index_f(k, 4u));
f[5] = load_ddf(fi_in, index_f(k, 7u));
f[8] = load_ddf(fi_in, index_f(k, 6u));
}
else if (y == NY - 2) {
float temp;
temp = f[3]; f[3] = f[4]; f[4] = temp;
temp = f[5]; f[5] = f[6]; f[6] = temp;
temp = f[7]; f[7] = f[8]; f[8] = temp;
else if (y == (unsigned int)(NY - 2)) {
f[4] = load_ddf(fi_in, index_f(k, 3u));
f[6] = load_ddf(fi_in, index_f(k, 8u));
f[7] = load_ddf(fi_in, index_f(k, 5u));
}
}
#endif
// ---------------------------------------------------------------------------
// D3Q19 wall bounce-back on y=0/NY-1 (channel walls)
// Pairs with non-zero cy: (3,4)±y, (7,8)±(x+y), (11,12)±(y+z),
// (13,14)±(x-y), (17,18)±(y-z)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// D3Q19 y-wall half-way bounce-back for pull double-buffer streaming.
//
@ -98,7 +72,7 @@ __device__ inline void apply_wall_bb_d2q9(unsigned int y,
//
// At y=NY-2 (wall at y=NY-1): directions from y=NY-1, cy_src = +1.
// ---------------------------------------------------------------------------
#if NQ == 19
#if LATTICE_MODEL == LATTICE_D3Q19
__device__ inline void apply_wall_bb_d3q19_y_pull(unsigned int y,
float* __restrict__ f,
const fpxx* __restrict__ fi_in,
@ -131,20 +105,6 @@ __device__ inline void apply_wall_bb_d3q19_y_pull(unsigned int y,
f[18] = load_ddf(fi_in, index_f(k, 17u));
}
}
// Keep the old swap version for backward compatibility
__device__ inline void apply_wall_bb_d3q19_y(unsigned int y,
float* __restrict__ f)
{
if (y == 1 || y == NY - 2) {
float temp;
temp = f[3]; f[3] = f[4]; f[4] = temp; // ±y
temp = f[7]; f[7] = f[8]; f[8] = temp; // ±(x+y)
temp = f[11]; f[11] = f[12]; f[12] = temp; // ±(y+z)
temp = f[13]; f[13] = f[14]; f[14] = temp; // ±(x-y)
temp = f[17]; f[17] = f[18]; f[18] = temp; // ±(y-z)
}
}
#endif
#endif // CELERIS_BOUNDARY_BOUNCE_BACK_CUH

View File

@ -1,111 +1,159 @@
// CelerisLab boundary/curved_boundary.cuh
// Interpolated curved-boundary bounce-back.
// Bouzidi linear interpolated bounce-back for curved surfaces.
//
// For curved solid surfaces that don't align with the grid, the
// reflected population is interpolated using the fractional distance q
// between the fluid node and the wall intersection.
// Per-link, fluid-centric formulation [Bouzidi 2001, Mei 1999].
// Each thread processes one cut link: a fluid node + direction that
// crosses the solid boundary. The kernel reads post-collision DDFs
// from fi_out (current step) and fi_in (previous step), computes the
// reflected population via Bouzidi's scheme, and stores it to fi_out.
//
// Migrated from existing kernel.cu + ref/main.cu.
//
// Delta pool layout per curved-boundary node (11 floats for D2Q9):
// delta[offset + 0] : encoded object id (bitcast int → float)
// delta[offset + 1..NQ-1] : q values for each direction (0 if no wall hit)
// delta[offset + NQ] : normal_y (wall normal y-component / R)
// delta[offset + NQ+1]: normal_x (wall normal x-component / R)
// References:
// [Bou01] Bouzidi, Firdaouss & Lallemand, Phys. Fluids 13(11), 2001
// [Mei99] Mei, Luo & Shyy, J. Comput. Phys. 155(2), 1999
// [Wen15] Wen, Zhang & Fang, Entropy 17(12), 2015 (MEA force)
// ============================================================================
#ifndef CELERIS_BOUNDARY_CURVED_BOUNDARY_CUH
#define CELERIS_BOUNDARY_CURVED_BOUNDARY_CUH
#if NQ == 9
constexpr unsigned char CURVED_FALLBACK_BOUZIDI = 0u;
constexpr unsigned char CURVED_FALLBACK_HALFWAY = 1u;
// ---------------------------------------------------------------------------
// apply_curved_boundary:
// Interpolated bounce-back with wall velocity correction.
// Operates on a SOLID+INTERFACE node's neighbors.
// apply_bouzidi_link single cut link, dimension-agnostic core
//
// Parameters:
// n current node (solid + interface)
// x, y coordinates of current node
// f_out the output DDF buffer (double-buffer scheme) being modified
// delta parameter pool
// id_off offset into delta for this node (= indx[n])
// Uw, Vw wall velocity at this node
// obs_fx, obs_fy accumulated force observation (atomicAdd targets)
// dir : direction FROM fluid TOWARD wall (α)
// q : fractional distance from fluid to wall intersection ∈ (0,1]
// k_f : linear index of the fluid node
// fi_out: post-collision output buffer (current step) — valid for dir α
// fi_in : post-collision input buffer (previous step) — used for dir ᾱ
// where fi_out[x_f, ᾱ] is garbage (pulled from solid)
// Uw,Vw : wall velocity components (Ww added for 3-D overload)
// rx,ry : link-arm components from body center to wall-intersection point
// fallback_class: 0=Bouzidi interpolation, nonzero=half-way bounce-back fallback
// obs : packed telemetry base pointer; force segment starts at OBS_FORCE0_FLOATS (0).
// body_id: ObjectManager object index; writes use obs_force_index/obs_torque_index.
// ---------------------------------------------------------------------------
__device__ inline void apply_curved_boundary(
unsigned long n, unsigned int x, unsigned int y,
fpxx* __restrict__ f_out,
const float* __restrict__ delta,
int id_off,
#if DIM == 2
__device__ inline void apply_bouzidi_link(
unsigned int dir, float q, unsigned long k_f,
fpxx* __restrict__ fi_out,
const fpxx* __restrict__ fi_in,
float Uw, float Vw,
float* __restrict__ obs_fx,
float* __restrict__ obs_fy)
float rx, float ry,
unsigned char fallback_class,
float* __restrict__ obs, int body_id)
{
// New paired direction ordering:
// cx = {0, 1,-1, 0, 0, 1,-1, 1,-1}
// cy = {0, 0, 0, 1,-1, 1,-1,-1, 1}
int dir_opp = opp_dir(dir);
for (int i = 1; i < NQ; i++) {
int x_neb = x + d_cx[i];
int y_neb = y + d_cy[i];
// Post-collision population heading toward wall (valid in fi_out)
float f_toward = load_ddf(fi_out, index_f(k_f, (unsigned int)dir));
// Check bounds (skip if neighbor is outside domain)
if (x_neb < 0 || x_neb >= NX || y_neb < 0 || y_neb >= NY) continue;
unsigned long k_neb = linear_index((unsigned int)x_neb, (unsigned int)y_neb);
// Only process if neighbor is FLUID
// (We read the flag from global memory this is a boundary kernel,
// called infrequently per node, so the extra read is acceptable.)
// The caller should ensure this node IS solid+interface.
float q = delta[id_off + i]; // fractional distance (0 if no wall hit)
if (q <= 0.0f) continue; // no wall intersection in this direction
int oi = opp_dir(i);
float ci_dot_uw = (float)d_cx[i] * Uw + (float)d_cy[i] * Vw;
float wall_term = 6.0f * d_w[i] * ci_dot_uw;
// Second neighbor for quadratic interpolation
int x_neb2 = x + 2 * d_cx[i];
int y_neb2 = y + 2 * d_cy[i];
// Clamp to domain (simple, could be improved)
x_neb2 = max(0, min(NX - 1, x_neb2));
y_neb2 = max(0, min(NY - 1, y_neb2));
unsigned long k_neb2 = linear_index((unsigned int)x_neb2, (unsigned int)y_neb2);
// Read current DDF values from output buffer
float f_self_opp = load_ddf(f_out, index_f(n, (unsigned int)oi));
float f_neb_opp = load_ddf(f_out, index_f(k_neb, (unsigned int)oi));
float f_neb2_fwd = load_ddf(f_out, index_f(k_neb2, (unsigned int)i));
// Interpolated bounce-back (Yu, Mei, Shyy, 2003)
float f_reflected = (q * f_self_opp + (1.0f - q) * f_neb_opp
+ q * f_neb2_fwd + wall_term) / (1.0f + q);
store_ddf(f_out, index_f(k_neb, (unsigned int)i), f_reflected);
// Force observation (momentum exchange)
float f_neb_fwd = load_ddf(f_out, index_f(k_neb, (unsigned int)i));
float f_self_fwd = load_ddf(f_out, index_f(n, (unsigned int)i));
float f_sum = f_neb_fwd + f_self_opp;
int x_back = x - d_cx[i];
int y_back = y - d_cy[i];
x_back = max(0, min(NX - 1, x_back));
y_back = max(0, min(NY - 1, y_back));
// Accumulate force
if (obs_fx != nullptr) {
atomicAdd(obs_fx, -f_sum * (float)d_cx[i] + f_self_fwd);
atomicAdd(obs_fy, -f_sum * (float)d_cy[i] +
load_ddf(f_out, index_f(linear_index((unsigned int)x_back, (unsigned int)y_back),
(unsigned int)i)));
float f_reflected;
if (fallback_class == CURVED_FALLBACK_BOUZIDI) {
if (q < 0.5f) {
// Fluid-dominant: interpolate with farther fluid neighbor.
// Donor validity is guaranteed by host-side fallback_class tagging.
unsigned int xf, yf;
coordinates(k_f, xf, yf);
int xff = (int)xf - d_cx[dir];
int yff = (int)yf - d_cy[dir];
unsigned long k_ff = linear_index((unsigned int)xff, (unsigned int)yff);
float f_toward_ff = load_ddf(fi_out, index_f(k_ff, (unsigned int)dir));
f_reflected = 2.0f * q * f_toward + (1.0f - 2.0f * q) * f_toward_ff;
} else {
// Wall-dominant: interpolate with same node's opposite direction
// fi_out[k_f, dir_opp] is garbage; use fi_in (previous step)
float f_opp_prev = load_ddf(fi_in, index_f(k_f, (unsigned int)dir_opp));
f_reflected = (1.0f / (2.0f * q)) * f_toward
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_prev;
}
} else if (fallback_class == CURVED_FALLBACK_HALFWAY) {
// Explicit fallback path: half-way moving bounce-back.
f_reflected = f_toward;
} else {
// Host contract guarantees classes {0,1}; fallback to conservative path.
f_reflected = f_toward;
}
// Wall velocity correction: +6 w_α (c_α · u_w) [Lallemand & Luo 2003]
float ci_dot_uw = (float)d_cx[dir] * Uw + (float)d_cy[dir] * Vw;
f_reflected += 6.0f * d_w[dir] * ci_dot_uw;
// Store corrected population in the opposite direction at the fluid node
store_ddf(fi_out, index_f(k_f, (unsigned int)dir_opp), f_reflected);
// Momentum-exchange force [Mei 1999, Wen 2015]
// F_link = c_α · (f_toward + f_reflected)
if (obs != nullptr) {
float fx = (float)d_cx[dir] * (f_toward + f_reflected);
float fy = (float)d_cy[dir] * (f_toward + f_reflected);
atomicAdd(&obs[obs_force_index(body_id, 0)], fx);
atomicAdd(&obs[obs_force_index(body_id, 1)], fy);
float tz = rx * fy - ry * fx;
atomicAdd(&obs[obs_torque_index(body_id, 0)], tz);
}
}
#endif // DIM == 2
#endif // NQ == 9
#if DIM == 3
__device__ inline void apply_bouzidi_link(
unsigned int dir, float q, unsigned long k_f,
fpxx* __restrict__ fi_out,
const fpxx* __restrict__ fi_in,
float Uw, float Vw, float Ww,
float rx, float ry, float rz,
unsigned char fallback_class,
float* __restrict__ obs, int body_id)
{
int dir_opp = opp_dir(dir);
float f_toward = load_ddf(fi_out, index_f(k_f, (unsigned int)dir));
float f_reflected;
if (fallback_class == CURVED_FALLBACK_BOUZIDI) {
if (q < 0.5f) {
unsigned int xf, yf, zf;
coordinates(k_f, xf, yf, zf);
int xff = (int)xf - d_cx[dir];
int yff = (int)yf - d_cy[dir];
int zff = (int)zf - d_cz[dir];
unsigned long k_ff = linear_index((unsigned int)xff, (unsigned int)yff, (unsigned int)zff);
float f_toward_ff = load_ddf(fi_out, index_f(k_ff, (unsigned int)dir));
f_reflected = 2.0f * q * f_toward + (1.0f - 2.0f * q) * f_toward_ff;
} else {
float f_opp_prev = load_ddf(fi_in, index_f(k_f, (unsigned int)dir_opp));
f_reflected = (1.0f / (2.0f * q)) * f_toward
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_prev;
}
} else if (fallback_class == CURVED_FALLBACK_HALFWAY) {
f_reflected = f_toward;
} else {
// Host contract guarantees classes {0,1}; fallback to conservative path.
f_reflected = f_toward;
}
float ci_dot_uw = (float)d_cx[dir] * Uw + (float)d_cy[dir] * Vw
+ (float)d_cz[dir] * Ww;
f_reflected += 6.0f * d_w[dir] * ci_dot_uw;
store_ddf(fi_out, index_f(k_f, (unsigned int)dir_opp), f_reflected);
if (obs != nullptr) {
float fx = (float)d_cx[dir] * (f_toward + f_reflected);
float fy = (float)d_cy[dir] * (f_toward + f_reflected);
float fz = (float)d_cz[dir] * (f_toward + f_reflected);
atomicAdd(&obs[obs_force_index(body_id, 0)], fx);
atomicAdd(&obs[obs_force_index(body_id, 1)], fy);
atomicAdd(&obs[obs_force_index(body_id, 2)], fz);
float tx = ry * fz - rz * fy;
float ty = rz * fx - rx * fz;
float tz = rx * fy - ry * fx;
atomicAdd(&obs[obs_torque_index(body_id, 0)], tx);
atomicAdd(&obs[obs_torque_index(body_id, 1)], ty);
atomicAdd(&obs[obs_torque_index(body_id, 2)], tz);
}
}
#endif // DIM == 3
#endif // CELERIS_BOUNDARY_CURVED_BOUNDARY_CUH

View File

@ -1,8 +1,19 @@
// CelerisLab boundary/ibm_kernels.cuh
// Immersed Boundary Method (IBM) kernels: Euler↔Lagrangian coupling.
// Migrated from ref/main.cu: Eul2Lag, Lag2Eul, Update_Lagrangian.
// Peskin IBM (Immersed Boundary Method) kernels: Euler↔Lagrangian coupling.
//
// Uses Peskin's 4-point delta function for interpolation/spreading.
// STATUS: FUTURE PATH — not yet wired into stepper.py or ObjectManager.
// The current production boundary path is Bouzidi curved BC (curved_boundary.cuh
// + CurvedBoundaryKernel). This file is kept for the planned multi-body /
// soft-body IBM extension where a continuous Lagrangian surface is preferred
// over discrete cut-links.
//
// To activate:
// 1. Add IBM body type to objects.py with get_lagrangian_list().
// 2. Wire update_lagrangian_control → euler_to_lagrangian → lagrangian_to_euler
// into stepper.py after the main step kernel.
// 3. Integrate forceE into the Guo forcing path (forcing_guo.cuh).
//
// Peskin 4-point delta function for interpolation/spreading [Peskin 2002].
//
// Data layout (per rigid body):
// RigidBodyState2D: {x, y, theta, vx, vy, omega}

View File

@ -31,6 +31,9 @@
#define OUTLET_BLEND_ALPHA 0.70f
#endif
// OUTLET_SRT_NEQ_DAMP and INLET_TRT_NEQ_DAMP are injected by config_method.h.
// These fallback defaults are only active if building outside the normal
// Python compile pipeline (e.g. standalone nvcc tests).
#ifndef OUTLET_SRT_NEQ_DAMP
#define OUTLET_SRT_NEQ_DAMP 0.50f
#endif
@ -53,7 +56,7 @@ __device__ __forceinline__ float inlet_target_u(float y_coord) {
#endif
}
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
// ---------------------------------------------------------------------------
// Parabolic inlet (x = 0, non-equilibrium extrapolation)
@ -77,7 +80,13 @@ __device__ inline void apply_parabolic_inlet(float* __restrict__ f,
// Target velocity (parabolic profile)
float u_target = inlet_target_u(y_coord);
float v_target = 0.0f;
const float rho_in = RHO;
// Zou-He mass balance: compute rho from known populations at x=0.
// Known (correctly streamed): f[0],f[2],f[3],f[4],f[6],f[8] (cx<=0)
// Unknown (being set): f[1],f[5],f[7] (cx>0)
// rho*(1 - u_target) = f[0]+f[3]+f[4] + 2*(f[2]+f[6]+f[8])
float rho_in = (f[0] + f[3] + f[4] + 2.0f*(f[2] + f[6] + f[8]))
/ (1.0f - u_target);
float feq_tar[9], feq_neb[9];
compute_feq(rho_in, u_target, v_target, feq_tar);
@ -152,7 +161,7 @@ __device__ inline void apply_pressure_outlet(float* __restrict__ f,
#endif
}
#endif // NQ == 9
#endif // LATTICE_D2Q9
// ============================================================================
// D3Q19 inlet / outlet (non-equilibrium extrapolation)
@ -162,7 +171,7 @@ __device__ inline void apply_pressure_outlet(float* __restrict__ f,
//
// Uses generic feq computation from macro.cuh to avoid hand-expanded formulas.
// ============================================================================
#if NQ == 19
#if LATTICE_MODEL == LATTICE_D3Q19
__device__ inline void apply_parabolic_inlet_3d(float* __restrict__ f,
const float* __restrict__ f_neb,
@ -174,7 +183,13 @@ __device__ inline void apply_parabolic_inlet_3d(float* __restrict__ f,
// Target velocity (parabolic in y, uniform in z)
float u_tar = inlet_target_u(y_coord);
const float rho_in = RHO;
// Zou-He mass balance: compute rho from known populations at x=0.
// cx>0 (unknown): i=1,7,9,13,15 cx<0 (known): i=2,8,10,14,16 cx=0 (known): rest
// rho*(1 - u_tar) = sum(cx=0 pops) + 2*sum(cx<0 pops)
float rho_in = (f[0] + f[3] + f[4] + f[5] + f[6] + f[11] + f[12] + f[17] + f[18]
+ 2.0f*(f[2] + f[8] + f[10] + f[14] + f[16]))
/ (1.0f - u_tar);
// feq arrays
float feq_tar[19], feq_neb[19];
@ -182,11 +197,21 @@ __device__ inline void apply_parabolic_inlet_3d(float* __restrict__ f,
compute_feq(rho_neb, un, vn, wn, feq_neb);
// Reconstruct cx>0 directions: i = 1, 7, 9, 13, 15
#if COLLISION_MODEL == 1
// TRT path: damped NEQ reconstruction to suppress inlet noise
const float beta = INLET_TRT_NEQ_DAMP;
f[1] = feq_tar[1] + beta * (f_neb[1] - feq_neb[1]);
f[7] = feq_tar[7] + beta * (f_neb[7] - feq_neb[7]);
f[9] = feq_tar[9] + beta * (f_neb[9] - feq_neb[9]);
f[13] = feq_tar[13] + beta * (f_neb[13] - feq_neb[13]);
f[15] = feq_tar[15] + beta * (f_neb[15] - feq_neb[15]);
#else
f[1] = f_neb[1] - feq_neb[1] + feq_tar[1];
f[7] = f_neb[7] - feq_neb[7] + feq_tar[7];
f[9] = f_neb[9] - feq_neb[9] + feq_tar[9];
f[13] = f_neb[13] - feq_neb[13] + feq_tar[13];
f[15] = f_neb[15] - feq_neb[15] + feq_tar[15];
#endif
}
__device__ inline void apply_pressure_outlet_3d(float* __restrict__ f,
@ -243,6 +268,6 @@ __device__ inline void apply_pressure_outlet_3d(float* __restrict__ f,
#endif
}
#endif // NQ == 19
#endif // LATTICE_D3Q19
#endif // CELERIS_BOUNDARY_INLET_OUTLET_CUH

View File

@ -14,5 +14,6 @@
#include "config/config_physics.h"
#include "config/config_method.h"
#include "config/config_objects.h"
#include "config/config_obs.h"
#endif // CELERIS_CONFIG_H

View File

@ -1,11 +1,31 @@
// AUTO-GENERATED by test_stability_matrix.py
// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY
// Layer: Global / Grid
#ifndef CELERIS_CONFIG_GRID_H
#define CELERIS_CONFIG_GRID_H
#define NT 128
#define NT 256
#define MULT_GPU 0
#define NX 384
#define NY 192
#define NX 2402
#define NY 102
#define NZ 1
#define DIM 2
#define NQ 9
// ---- Lattice model (single source of truth) ----
// Model IDs: 0=D2Q9, 1=D3Q19 (extend here for future models like D2Q19)
#define LATTICE_D2Q9 0
#define LATTICE_D3Q19 1
#define LATTICE_MODEL 0
// DIM and NQ are derived from LATTICE_MODEL — do NOT set independently.
#if LATTICE_MODEL == LATTICE_D2Q9
#define DIM 2
#define NQ 9
#elif LATTICE_MODEL == LATTICE_D3Q19
#define DIM 3
#define NQ 19
#else
#error "Unknown LATTICE_MODEL. Supported: LATTICE_D2Q9 (0), LATTICE_D3Q19 (1)."
#endif
#endif

View File

@ -1,17 +1,31 @@
// AUTO-GENERATED by test_stability_matrix.py
// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY
// Layer: Method
#ifndef CELERIS_CONFIG_METHOD_H
#define CELERIS_CONFIG_METHOD_H
#define COLLISION_MODEL 0
#define STREAMING_MODEL 1
#define STREAMING_MODEL 0
#define STORE_PRECISION 0
#define USE_DDF_SHIFTING 0
#define USE_LES 0
#define LES_CS 0.160000f
#define LES_CLOSED_FORM 1
#define INLET_PROFILE 1
#define OUTLET_MODE 0
#define OUTLET_BLEND_ALPHA 0.700f
#define OUTLET_BACKFLOW_CLAMP 1
#define Y_WALL_BC 0
#define OMEGA_COLLISION_MIN 0.01f
#define OMEGA_COLLISION_MAX 1.999f
#define OMEGA_COLLISION_MAX 1.960f
#define TRT_MAGIC_PARAM 0.187500f
// NEQ damping coefficients for inlet/outlet BC reconstruction.
// TRT inlet: damps donor non-equilibrium to reduce inlet noise at high Re.
// SRT outlet: damps donor non-equilibrium to suppress checkerboard noise.
#define INLET_TRT_NEQ_DAMP 0.5000f
#define OUTLET_SRT_NEQ_DAMP 0.5000f
#endif

View File

@ -1,5 +1,8 @@
// AUTO-GENERATED by test_stability_matrix.py
// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY
// Layer: Case / Objects
#ifndef CELERIS_CONFIG_OBJECTS_H
#define CELERIS_CONFIG_OBJECTS_H
#define N_OBJS 0
#define N_OBJS 1
#endif

View File

@ -0,0 +1,30 @@
// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY
// Layer: Case / Objects — packed obs (force + torque + sensor), float indices not bytes
// Derived from N_OBJS (config_objects.h) and DIM (config_grid.h); include via config.h.
#ifndef CELERIS_CONFIG_OBS_H
#define CELERIS_CONFIG_OBS_H
#if (N_OBJS) >= 1
#define OBS_N_SLOTS (N_OBJS)
#else
#define OBS_N_SLOTS 1
#endif
#define OBS_SLOT_STRIDE_FLOATS ((OBS_N_SLOTS) * (DIM))
#define OBS_FORCE_FLOATS (OBS_SLOT_STRIDE_FLOATS)
#if DIM == 2
#define OBS_TORQUE_COMPONENTS 1
#else
#define OBS_TORQUE_COMPONENTS (DIM)
#endif
#define OBS_TORQUE_FLOATS ((OBS_N_SLOTS) * (OBS_TORQUE_COMPONENTS))
#define OBS_BODY_SLOT_FLOATS (3 * (DIM))
#define OBS_FORCE0_FLOATS 0
#define OBS_TORQUE0_FLOATS (OBS_FORCE0_FLOATS + OBS_FORCE_FLOATS)
#define OBS_SENSOR0_FLOATS (OBS_TORQUE0_FLOATS + OBS_TORQUE_FLOATS)
#define OBS_TOTAL_FLOATS (OBS_SENSOR0_FLOATS + OBS_SLOT_STRIDE_FLOATS)
#endif

View File

@ -1,16 +1,18 @@
// AUTO-GENERATED by test_stability_matrix.py
// AUTO-GENERATED by CelerisLab compiler DO NOT EDIT MANUALLY
// Layer: Global / Physics
#ifndef CELERIS_CONFIG_PHYSICS_H
#define CELERIS_CONFIG_PHYSICS_H
#define LBtype float
#define VIS 0.0144000000
#define VIS 0.0125000000
#define RHO 1.0
#define U0 0.04
#define U0 0.0277777778
#define PI 3.141592653589793238
#define FLUID 0x01
#define SOLID 0x02
#define GAS 0x04
#define INTERFACE 0x08
#define SENSOR 0x10
#define OBSTACLE 0x20
// Flag constants are defined in core/flags.cuh (uint16_t).
// Do NOT redefine them here.
#define V_TAYLOR 1
#endif

View File

@ -14,12 +14,12 @@
#ifndef CELERIS_CORE_DESCRIPTORS_CUH
#define CELERIS_CORE_DESCRIPTORS_CUH
// NQ and DIM come from macros.h (included before this header)
// NQ, DIM, LATTICE_MODEL come from config_grid.h (included before this header)
// ---------------------------------------------------------------------------
// D2Q9
// ---------------------------------------------------------------------------
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__constant__ int d_cx[9] = { 0, 1, -1, 0, 0, 1, -1, 1, -1};
__constant__ int d_cy[9] = { 0, 0, 0, 1, -1, 1, -1, -1, 1};
@ -38,7 +38,7 @@ __constant__ float d_w[9] = {
// ---------------------------------------------------------------------------
// D3Q19
// ---------------------------------------------------------------------------
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
__constant__ int d_cx[19] = { 0, 1,-1, 0, 0, 0, 0, 1,-1, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0};
__constant__ int d_cy[19] = { 0, 0, 0, 1,-1, 0, 0, 1,-1, 0, 0, 1,-1, -1, 1, 0, 0, 1,-1};
@ -56,7 +56,7 @@ __constant__ float d_w[19] = {
#define WE_VAL (1.0f/36.0f)
#else
#error "Unsupported NQ. Use 9 (D2Q9) or 19 (D3Q19)."
#error "Unsupported LATTICE_MODEL. Check config_grid.h."
#endif
// ---------------------------------------------------------------------------

View File

@ -1,5 +1,12 @@
// CelerisLab core/flags.cuh
// Cell flag definitions (uint32_t, layered bytes) and helper functions.
// Cell flag definitions uint16_t, three-field layout.
//
// [15:8] Extension (SENSOR, OBSTACLE, HALO, …)
// [ 7:4] BC subtype (WALL, INLET, OUTLET, CURVED, PERIODIC, MOVING)
// [ 3:0] Cell type (FLUID, SOLID, GAS, INTERFACE)
//
// DIM and NQ are independent: a future D2Q5 or D3Q27 needs only new
// descriptors, not new flag bits.
// ============================================================================
#ifndef CELERIS_CORE_FLAGS_CUH
@ -8,80 +15,69 @@
#include <stdint.h>
// ---------------------------------------------------------------------------
// Byte 0 [0:7] basic cell type
// Cell type [3:0]
// ---------------------------------------------------------------------------
#define FLAG_FLUID 0x01u
#define FLAG_SOLID 0x02u
#define FLAG_GAS 0x04u
#define FLAG_INTERFACE 0x08u
#define FLAG_SENSOR 0x10u
#define FLAG_FLUID ((uint16_t)0x0001)
#define FLAG_SOLID ((uint16_t)0x0002)
#define FLAG_GAS ((uint16_t)0x0004)
#define FLAG_INTERFACE ((uint16_t)0x0008)
// ---------------------------------------------------------------------------
// Byte 1 [8:15] boundary condition type
// BC subtype [7:4]
// ---------------------------------------------------------------------------
#define FLAG_BB (0x01u << 8) // full-way bounce-back
#define FLAG_EQ_BC (0x02u << 8) // equilibrium (Dirichlet) boundary
#define FLAG_CURVED (0x04u << 8) // curved-boundary interpolation
#define FLAG_IBM (0x08u << 8) // immersed boundary marker
#define FLAG_MOVING (0x10u << 8) // moving-wall velocity
#define FLAG_BC_NONE ((uint16_t)0x0000)
#define FLAG_BC_WALL ((uint16_t)0x0010)
#define FLAG_BC_INLET ((uint16_t)0x0020)
#define FLAG_BC_OUTLET ((uint16_t)0x0030)
#define FLAG_BC_CURVED ((uint16_t)0x0040)
#define FLAG_BC_PERIODIC ((uint16_t)0x0050)
#define FLAG_BC_MOVING ((uint16_t)0x0060)
// ---------------------------------------------------------------------------
// Byte 2 [16:23] multi-GPU / AMR / ghost
// ---------------------------------------------------------------------------
#define FLAG_GHOST (0x01u << 16)
#define FLAG_HALO (0x02u << 16)
#define FLAG_AMR_FINE (0x04u << 16)
#define FLAG_AMR_COARSE (0x08u << 16)
// ---------------------------------------------------------------------------
// Byte 3 [24:31] reserved for extensions (phase-field, particles …)
// Extension [15:8]
// ---------------------------------------------------------------------------
#define FLAG_SENSOR ((uint16_t)0x0100)
#define FLAG_OBSTACLE ((uint16_t)0x0200)
#define FLAG_HALO ((uint16_t)0x0400)
// 0x0800..0x8000 reserved for multi-GPU / AMR / multi-phase
// ---------------------------------------------------------------------------
// Masks
// ---------------------------------------------------------------------------
#define MASK_CELL_TYPE 0x000000FFu
#define MASK_BC_TYPE 0x0000FF00u
#define MASK_MULTIGPU 0x00FF0000u
#define MASK_EXTENSION 0xFF000000u
#define MASK_CELL_TYPE ((uint16_t)0x000F)
#define MASK_BC_TYPE ((uint16_t)0x00F0)
#define MASK_EXTENSION ((uint16_t)0xFF00)
// ---------------------------------------------------------------------------
// Legacy compatibility (current driver.py uses uint8 with these bits)
// Migration target: uint16 flag field (see config/config_physics.h)
// Device helper: extract fields
// ---------------------------------------------------------------------------
#define LEGACY_FLUID 0x01
#define LEGACY_SOLID 0x02
#define LEGACY_GAS 0x04
#define LEGACY_OBSTACLE 0x20 // obstacle / immersed body (0x20 avoids GAS collision)
#define LEGACY_INTERFACE 0x08
#define LEGACY_SENSOR 0x10
__device__ __forceinline__ uint16_t cell_type(uint16_t fl) { return fl & MASK_CELL_TYPE; }
__device__ __forceinline__ uint16_t bc_type(uint16_t fl) { return fl & MASK_BC_TYPE; }
__device__ __forceinline__ uint16_t ext_bits(uint16_t fl) { return fl & MASK_EXTENSION; }
// ---------------------------------------------------------------------------
// Device helper functions
// Device helper: cell type queries
// ---------------------------------------------------------------------------
__device__ __forceinline__ bool is_fluid(uint32_t f) { return (f & FLAG_FLUID) != 0; }
__device__ __forceinline__ bool is_solid(uint32_t f) { return (f & FLAG_SOLID) != 0; }
__device__ __forceinline__ bool is_gas(uint32_t f) { return (f & FLAG_GAS) != 0; }
__device__ __forceinline__ bool is_interface(uint32_t f) { return (f & FLAG_INTERFACE) != 0; }
__device__ __forceinline__ bool is_sensor(uint32_t f) { return (f & FLAG_SENSOR) != 0; }
__device__ __forceinline__ bool is_bb(uint32_t f) { return (f & FLAG_BB) != 0; }
__device__ __forceinline__ bool is_curved(uint32_t f) { return (f & FLAG_CURVED) != 0; }
__device__ __forceinline__ bool is_ibm(uint32_t f) { return (f & FLAG_IBM) != 0; }
__device__ __forceinline__ bool is_moving(uint32_t f) { return (f & FLAG_MOVING) != 0; }
__device__ __forceinline__ bool is_eq_bc(uint32_t f) { return (f & FLAG_EQ_BC) != 0; }
__device__ __forceinline__ bool is_boundary(uint32_t f) { return (f & MASK_BC_TYPE) != 0; }
__device__ __forceinline__ bool is_wall(uint32_t f) { return (f & (FLAG_BB | FLAG_CURVED | FLAG_MOVING)) != 0; }
__device__ __forceinline__ bool is_fluid(uint16_t fl) { return (fl & FLAG_FLUID) != 0; }
__device__ __forceinline__ bool is_solid(uint16_t fl) { return (fl & FLAG_SOLID) != 0; }
__device__ __forceinline__ bool is_gas(uint16_t fl) { return (fl & FLAG_GAS) != 0; }
__device__ __forceinline__ bool is_interface(uint16_t fl) { return (fl & FLAG_INTERFACE) != 0; }
// ---------------------------------------------------------------------------
// Legacy flag promotion (uint8 flags → uint32 with inferred BC bits)
// Device helper: BC queries
// ---------------------------------------------------------------------------
__device__ __forceinline__ uint32_t promote_legacy_flag(uint8_t legacy) {
uint32_t f = (uint32_t)legacy & 0x1Fu; // copy low 5 bits
if ((f & FLAG_SOLID) && (f & FLAG_INTERFACE)) // SOLID + INTERFACE
f |= FLAG_CURVED; // → mark as curved BC
return f;
}
__device__ __forceinline__ bool is_wall(uint16_t fl) { return bc_type(fl) == FLAG_BC_WALL; }
__device__ __forceinline__ bool is_inlet(uint16_t fl) { return bc_type(fl) == FLAG_BC_INLET; }
__device__ __forceinline__ bool is_outlet(uint16_t fl) { return bc_type(fl) == FLAG_BC_OUTLET; }
__device__ __forceinline__ bool is_curved(uint16_t fl) { return bc_type(fl) == FLAG_BC_CURVED; }
__device__ __forceinline__ bool is_moving(uint16_t fl) { return bc_type(fl) == FLAG_BC_MOVING; }
__device__ __forceinline__ bool has_bc(uint16_t fl) { return (fl & MASK_BC_TYPE) != 0; }
// ---------------------------------------------------------------------------
// Device helper: extension queries
// ---------------------------------------------------------------------------
__device__ __forceinline__ bool is_sensor(uint16_t fl) { return (fl & FLAG_SENSOR) != 0; }
__device__ __forceinline__ bool is_obstacle(uint16_t fl) { return (fl & FLAG_OBSTACLE) != 0; }
__device__ __forceinline__ bool is_halo(uint16_t fl) { return (fl & FLAG_HALO) != 0; }
#endif // CELERIS_CORE_FLAGS_CUH

View File

@ -1,6 +1,6 @@
// CelerisLab core/layout.cuh
// SoA memory layout: index_f, coordinates, and neighbor computation.
// Depends on: NQ, NX, NY, NZ from macros.h; descriptors.cuh for d_cx/d_cy.
// Depends on: LATTICE_MODEL, NQ, NX, NY, NZ from config_grid.h; descriptors.cuh for d_cx/d_cy.
// ============================================================================
#ifndef CELERIS_CORE_LAYOUT_CUH
@ -68,7 +68,7 @@ __device__ inline void compute_neighbors(unsigned long n, unsigned long* j) {
// i=0: self
j[0] = n;
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
// paired: (1,2)±x (3,4)±y (5,6)±(x+y) (7,8)±(x-y)
j[1] = linear_index(xp, y ); // +x
j[2] = linear_index(xm, y ); // -x
@ -78,8 +78,31 @@ __device__ inline void compute_neighbors(unsigned long n, unsigned long* j) {
j[6] = linear_index(xm, ym); // -x -y
j[7] = linear_index(xp, ym); // +x -y
j[8] = linear_index(xm, yp); // -x +y
#elif NQ == 19
#error "D3Q19 requires DIM == 3. Set DIM=3 in macros.h."
#else
#error "2D compute_neighbors only supports LATTICE_D2Q9."
#endif
}
// Overload: skip coordinates() when x,y already known (saves ~40 cycles)
__device__ inline void compute_neighbors(unsigned int x, unsigned int y,
unsigned long* j) {
unsigned int xp = (x + 1u) % NX;
unsigned int xm = (x + NX - 1u) % NX;
unsigned int yp = (y + 1u) % NY;
unsigned int ym = (y + NY - 1u) % NY;
j[0] = linear_index(x, y);
#if LATTICE_MODEL == LATTICE_D2Q9
j[1] = linear_index(xp, y );
j[2] = linear_index(xm, y );
j[3] = linear_index(x , yp);
j[4] = linear_index(x , ym);
j[5] = linear_index(xp, yp);
j[6] = linear_index(xm, ym);
j[7] = linear_index(xp, ym);
j[8] = linear_index(xm, yp);
#else
#error "2D compute_neighbors only supports LATTICE_D2Q9."
#endif
}
@ -126,7 +149,7 @@ __device__ inline void compute_neighbors(unsigned long n, unsigned long* j) {
j[0] = n;
#if NQ == 19
#if LATTICE_MODEL == LATTICE_D3Q19
// ±x, ±y, ±z (straight)
j[1] = linear_index(xp, y, z ); // +x
j[2] = linear_index(xm, y, z ); // -x

View File

@ -0,0 +1,31 @@
// CelerisLab core/obs.cuh
// Compile-time packed ``obs`` layout: force + torque + sensor segments (config_obs.h).
// ============================================================================
#ifndef CELERIS_CORE_OBS_CUH
#define CELERIS_CORE_OBS_CUH
/// Flat index into *obs* for Bouzidi force accumulation for object *id_obj*, component *d*.
__device__ __forceinline__ int obs_force_index(int id_obj, int d)
{
return OBS_FORCE0_FLOATS + id_obj * DIM + d;
}
/// Flat index into *obs* for torque accumulation for object *id_obj*, component *d*.
__device__ __forceinline__ int obs_torque_index(int id_obj, int d)
{
#if DIM == 2
(void)d;
return OBS_TORQUE0_FLOATS + id_obj;
#elif DIM == 3
return OBS_TORQUE0_FLOATS + id_obj * DIM + d;
#endif
}
/// Flat index into *obs* for sensor readout for object *id_obj*, component *d*.
__device__ __forceinline__ int obs_sensor_index(int id_obj, int d)
{
return OBS_SENSOR0_FLOATS + id_obj * DIM + d;
}
#endif // CELERIS_CORE_OBS_CUH

View File

@ -10,20 +10,20 @@
// ============================================================================
// LBM runtime parameters (uploaded once per run or on parameter change)
//
// Grid topology (NX, NY, NZ, DIM, NQ) is compile-time via config_grid.h.
// Collision method & LES params are compile-time via config_method.h.
// Only runtime-mutable values live here.
// ============================================================================
struct LBMParams {
//--- grid ---
unsigned int Nx, Ny, Nz;
unsigned long N; // Nx * Ny * Nz
//--- relaxation ---
//--- relaxation (runtime-mutable for LES / adaptive omega) ---
float omega; // SRT/TRT主松弛率 w = 1/(3ν + 0.5)
float omega_bulk; // MRT bulk 松弛率 (s_e / s_eps)
//--- external body force ---
//--- external body force (runtime-mutable for forcing protocols) ---
float fx, fy, fz;
//--- reference quantities ---
//--- reference quantities (runtime-mutable for ramping) ---
float rho_ref; // 参考密度 (通常 1.0)
float u_inlet; // 入口参考速度

View File

@ -1,16 +1,16 @@
// CelerisLab kernel_v2.cu
// ============================================================================
// Modular compilation entry point (Stage 1 Architecture)
// Compilation entry point (Stage 2 Architecture).
//
// This file includes all modular headers and step kernels,
// and exports extern "C" functions for PyCUDA.
// This file only does #includes — no kernel logic lives here.
// All __global__ functions are in step/*.cu files.
//
// Controlled by macros.h:
// NQ = 9 (D2Q9) or 19 (D3Q19)
// COLLISION_MODEL = 0 (SRT) / 1 (TRT) / 2 (MRT)
// STREAMING_MODEL = 0 (double-buffer) / 1 (Esoteric-Pull)
// STORE_PRECISION = 0 (FP32) / 1 (FP16S) / 2 (FP16C)
// USE_DDF_SHIFTING= 0 / 1
// File layout (each <300 lines):
// operators/helpers.cuh — bounce_back_swap, collide_dispatch
// step/init_flow.cu — InitTubeFlow_v2, InitEsoPull
// step/one_step_double.cu — OneStep (pull double-buffer)
// step/one_step_esopull.cu — EsoPullStep (single-buffer)
// step/aux_kernels.cu — CurvedBoundaryKernel, SensorKernel
// ============================================================================
#include <stdio.h>
@ -30,6 +30,7 @@
#include "core/descriptors.cuh"
#include "core/layout.cuh"
#include "core/params.cuh"
#include "core/obs.cuh"
// ---------------------------------------------------------------------------
// Layer 2: Operators
@ -40,6 +41,7 @@
#include "operators/collision_mrt.cuh"
#include "operators/forcing_guo.cuh"
#include "operators/turbulence_smag.cuh"
#include "operators/helpers.cuh"
// ---------------------------------------------------------------------------
// Layer 3: Streaming
@ -53,530 +55,17 @@
#include "boundary/bounce_back.cuh"
#include "boundary/curved_boundary.cuh"
#include "boundary/inlet_outlet.cuh"
// ---------------------------------------------------------------------------
// Layer 5: Step kernels (selected by STREAMING_MODEL)
// ---------------------------------------------------------------------------
#include "step/one_step_double.cu"
#include "step/one_step_esopull.cu"
// ---------------------------------------------------------------------------
// Layer 6: IBM kernels (always available, launched separately)
// ---------------------------------------------------------------------------
#include "boundary/ibm_kernels.cuh"
// ============================================================================
// Extern "C" wrappers (for PyCUDA / ctypes)
//
// Naming:
// OneStep backward-compatible main step (double-buffer)
// InitTubeFlow_v2 channel initialization (new ordering)
// StreamCollide new API main step (double or esopull)
// CurvedBoundary post-step curved boundary correction
// UpdateFields recompute rho/u from DDF
// InitEsoPull Esoteric-Pull initialization
// IBM_UpdateLag IBM Lagrangian point update
// IBM_Eul2Lag Euler → Lagrangian interpolation
// IBM_Lag2Eul Lagrangian → Euler spreading
// Extern "C" — all __global__ kernels exported for PyCUDA
// ============================================================================
extern "C"
{
// ----- Backward-compatible init (uses new direction ordering) -----
__global__ void InitTubeFlow_v2(uint8_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
// Keep startup field consistent with runtime inlet configuration.
float u_init = inlet_target_u((float)y);
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++)
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
float val = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu - 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
val -= d_w[i];
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
// Keep startup field consistent with runtime inlet configuration.
float u_init = inlet_target_u((float)y);
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++)
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
float val = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu - 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
val -= d_w[i];
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
#endif
}
// ----- Main step (double-buffer) -----
// Signature compatible with driver.py: flag, fi_in, fi_out, indx, delta, action, obs
// TODO(Phase5b): Extract shared __device__ body to eliminate duplication with
// StreamCollideDouble in step/one_step_double.cu (~140 lines overlap).
__global__ void OneStep(
uint8_t* flag,
fpxx* fi_in,
fpxx* fi_out,
int32_t* indx,
float* delta,
float* action,
float* obs)
{
// Redirect to the modular StreamCollideDouble kernel body
// (We inline here to keep a single extern "C" entry point.)
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
#endif
uint8_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(k, j);
float f[NQ];
#if STREAMING_MODEL == 0
stream_pull_load(k, f, fi_in, j);
#else
// Esoteric-Pull for OneStep compat would need timestep — not available
// in legacy signature. Use double-buffer mode only for OneStep.
stream_pull_load(k, f, fi_in, j);
#endif
float rho_n, ux, uy;
#if NQ == 9
compute_rho_u(f, rho_n, ux, uy);
// Inlet / outlet / wall bounce-back
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_parabolic_inlet(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_pressure_outlet(f, f_neb, (float)y);
}
else {
// Wall / corner: full-way bounce-back (swap all pairs)
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
}
// Obstacle nodes: full-way bounce-back (same swap as wall nodes)
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
if ((fl & LEGACY_FLUID) && (y == 1u || y == (unsigned int)(NY - 2))) {
apply_wall_bb_d2q9_y_pull(y, f, fi_in, k);
}
// Collision
if (fl & LEGACY_FLUID) {
float feq[NQ], Fin[NQ];
compute_rho_u(f, rho_n, ux, uy);
compute_feq(rho_n, ux, uy, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, Fin, omega_col);
#endif
}
stream_pull_store(k, f, fi_out);
// Curved boundary processing
if ((fl & LEGACY_SOLID) && (fl & LEGACY_INTERFACE)) {
int id_off = indx[k];
int id_obj = *reinterpret_cast<int*>(&delta[id_off]);
float Uw = action[id_obj] * delta[id_off + NQ];
float Vw = action[id_obj] * delta[id_off + NQ + 1];
float* obs_fx = &obs[DIM * id_obj];
float* obs_fy = &obs[DIM * id_obj + 1];
apply_curved_boundary(k, x, y, fi_out, delta, id_off, Uw, Vw, obs_fx, obs_fy);
}
// Sensor
if (fl & LEGACY_SENSOR) {
int id_obj = indx[k];
atomicAdd(&obs[DIM * id_obj], ux);
atomicAdd(&obs[DIM * id_obj + 1], uy);
}
#elif NQ == 19
// ---- Macroscopic (computed for all nodes; only used for fluid collision) ----
float uz;
compute_rho_u(f, rho_n, ux, uy, uz);
// ---- Boundary conditions ----
if (fl & LEGACY_SOLID) {
// Interior of inlet/outlet planes (skip corners at y=0/NY-1)
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
// Parabolic velocity inlet
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y, z);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_parabolic_inlet_3d(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
// Pressure outlet
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y, z);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_pressure_outlet_3d(f, f_neb, (float)y);
}
else {
// Wall nodes and corners: full-way bounce-back.
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
}
// Obstacle nodes: full-way bounce-back (same swap)
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
if ((fl & LEGACY_FLUID) && (y == 1 || y == NY - 2)) {
apply_wall_bb_d3q19_y_pull(y, f, fi_in, k);
}
// ---- Collision (fluid only) ----
if (fl & LEGACY_FLUID) {
float feq[NQ], Fin[NQ];
compute_rho_u(f, rho_n, ux, uy, uz);
compute_feq(rho_n, ux, uy, uz, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, uz, Fin, omega_col);
#endif
}
stream_pull_store(k, f, fi_out);
#endif
}
// ---------------------------------------------------------------------------
// Esoteric-Pull wrappers (exported as extern "C" for PyCUDA)
// ---------------------------------------------------------------------------
// Thin forwarder: StreamCollideEsoPull is already __global__ in
// step/one_step_esopull.cu. We just need to ensure its symbol is
// inside this extern "C" block so PyCUDA can resolve it by name.
// Because it is already defined as __global__ in a header included above,
// we cannot redefine it here. Instead we provide a dedicated wrapper.
__global__ void EsoPullStep(
fpxx* fi,
uint8_t* flag,
int32_t* indx,
float* delta,
float* action,
float* obs,
unsigned long t)
{
// Delegate to StreamCollideEsoPull with NULL rho/u/force arrays
// (we don't need macroscopic output during stepping).
// We cannot call a __global__ from another __global__, so we inline
// the body directly. However since StreamCollideEsoPull is included
// as a full kernel above, we use a device-function extraction approach.
// For simplicity and correctness, we re-invoke the thread mapping here.
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
#endif
uint8_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(k, j);
float f[NQ];
load_f_esopull(k, f, fi, j, t);
// Solid nodes: BB + store, then return (essential for EsoPull correctness)
if ((fl & LEGACY_SOLID) && !(fl & LEGACY_INTERFACE) && !(fl & LEGACY_SENSOR)) {
if (x != 0 && x != (unsigned int)(NX - 1)) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float tmp = f[i]; f[i] = f[i+1]; f[i+1] = tmp;
}
store_f_esopull(k, f, fi, j, t);
return;
}
}
float rho_n, ux, uy;
#if NQ == 9
compute_rho_u(f, rho_n, ux, uy);
// Inlet / outlet
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
unsigned long k_neb = linear_index(x + 1u, y);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_parabolic_inlet(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
unsigned long k_neb = linear_index(x - 1u, y);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_pressure_outlet(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float tmp = f[i]; f[i] = f[i+1]; f[i+1] = tmp;
}
}
}
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float tmp = f[i]; f[i] = f[i+1]; f[i+1] = tmp;
}
}
// Collision (fluid only)
if (fl & LEGACY_FLUID) {
float feq[NQ], Fin[NQ];
compute_rho_u(f, rho_n, ux, uy);
compute_feq(rho_n, ux, uy, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, Fin, omega_col);
#endif
}
#elif NQ == 19
float uz;
compute_rho_u(f, rho_n, ux, uy, uz);
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
unsigned long k_neb = linear_index(x + 1u, y, z);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_parabolic_inlet_3d(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
unsigned long k_neb = linear_index(x - 1u, y, z);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_pressure_outlet_3d(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float tmp = f[i]; f[i] = f[i+1]; f[i+1] = tmp;
}
}
}
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float tmp = f[i]; f[i] = f[i+1]; f[i+1] = tmp;
}
}
if (fl & LEGACY_FLUID) {
float feq[NQ], Fin[NQ];
compute_rho_u(f, rho_n, ux, uy, uz);
compute_feq(rho_n, ux, uy, uz, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, uz, Fin, omega_col);
#endif
}
#endif
store_f_esopull(k, f, fi, j, t);
// Sensor
if (fl & LEGACY_SENSOR) {
int id_obj = indx[k];
atomicAdd(&obs[DIM * id_obj], ux);
atomicAdd(&obs[DIM * id_obj + 1], uy);
}
}
// ----- Esoteric-Pull initialization -----
// Writes equilibrium to ALL NQ slots directly (not through esoteric store).
// This ensures both even/odd read patterns get valid data on the first step.
// Reference: FluidX3D lbm.cpp — kernel_initialize writes all slots directly.
__global__ void InitEsoPull(uint8_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
float feq[NQ];
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++) {
feq[i] = d_w[i] * RHO;
#if USE_DDF_SHIFTING
feq[i] -= d_w[i];
#endif
}
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
float u_init = inlet_target_u((float)y);
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
feq[i] = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu - 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
feq[i] -= d_w[i];
#endif
}
}
// Direct store to ALL slots (not esoteric pattern)
for (int i = 0; i < NQ; i++) {
store_ddf(fi, index_f(k, (unsigned int)i), feq[i]);
}
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
float feq[NQ];
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++) {
feq[i] = d_w[i] * RHO;
#if USE_DDF_SHIFTING
feq[i] -= d_w[i];
#endif
}
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
float u_init = inlet_target_u((float)y);
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
feq[i] = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu - 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
feq[i] -= d_w[i];
#endif
}
}
for (int i = 0; i < NQ; i++) {
store_ddf(fi, index_f(k, (unsigned int)i), feq[i]);
}
#endif
}
#include "step/init_flow.cu"
#include "step/one_step_double.cu"
#include "step/one_step_esopull.cu"
#include "step/aux_kernels.cu"
} // extern "C"

View File

@ -20,7 +20,7 @@
#ifndef CELERIS_OPERATORS_COLLISION_MRT_CUH
#define CELERIS_OPERATORS_COLLISION_MRT_CUH
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
// ---------------------------------------------------------------------------
// D2Q9 MRT (fully expanded, no loops, no matrix storage)
@ -109,7 +109,7 @@ __device__ __forceinline__ void collide_mrt_no_force(float* __restrict__ g,
collide_mrt(g, rho, ux, uy, Fin, omega);
}
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
// ---------------------------------------------------------------------------
// D3Q19 MRT (tensor-projected multi-mode relaxation)

View File

@ -13,7 +13,7 @@
// ---------------------------------------------------------------------------
// D2Q9 Guo forcing terms
// ---------------------------------------------------------------------------
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ __forceinline__ void apply_guo_velocity_correction(
float& ux, float& uy,
@ -60,7 +60,7 @@ __device__ __forceinline__ void compute_guo_forcing(
Fin[8] = 9.0f * WE_VAL * fmaf(c8F, c8u + 0.33333334f, uF);
}
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
__device__ __forceinline__ void apply_guo_velocity_correction(
float& ux, float& uy, float& uz,
@ -89,7 +89,7 @@ __device__ __forceinline__ void compute_guo_forcing(
}
}
#endif // NQ
#endif // LATTICE_MODEL
// ---------------------------------------------------------------------------
// Zero forcing helper (when no external force)

View File

@ -0,0 +1,51 @@
// CelerisLab operators/helpers.cuh
// Shared device helpers used by all step kernels.
// ============================================================================
#ifndef CELERIS_OPERATORS_HELPERS_CUH
#define CELERIS_OPERATORS_HELPERS_CUH
// ---- Bounce-back: swap paired directions in-register ----
__device__ __forceinline__ void bounce_back_swap(float* f) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
// ---- Collision dispatch (selects SRT/TRT/MRT + optional LES) ----
__device__ __forceinline__ void collide_dispatch(
float* f, float rho_n,
#if DIM == 2
float ux, float uy
#elif DIM == 3
float ux, float uy, float uz
#endif
) {
float feq[NQ], Fin[NQ];
#if DIM == 2
compute_feq(rho_n, ux, uy, feq);
#elif DIM == 3
compute_feq(rho_n, ux, uy, uz, feq);
#endif
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
#if DIM == 2
collide_mrt(f, rho_n, ux, uy, Fin, omega_col);
#elif DIM == 3
collide_mrt(f, rho_n, ux, uy, uz, Fin, omega_col);
#endif
#endif
}
#endif // CELERIS_OPERATORS_HELPERS_CUH

View File

@ -17,7 +17,7 @@
// ---------------------------------------------------------------------------
// compute_rho_u: f[NQ] → (rho, ux, uy [, uz])
// ---------------------------------------------------------------------------
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ __forceinline__ void compute_rho_u(const float* __restrict__ f,
float& rho,
@ -38,7 +38,7 @@ __device__ __forceinline__ void compute_rho_u(const float* __restrict__ f,
uy = (f[3] - f[4] + f[5] - f[6] - f[7] + f[8]) * inv_rho;
}
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
__device__ __forceinline__ void compute_rho_u(const float* __restrict__ f,
float& rho,
@ -56,12 +56,12 @@ __device__ __forceinline__ void compute_rho_u(const float* __restrict__ f,
uz = (f[5]-f[6] + f[9]-f[10] + f[11]-f[12] + f[16]-f[15] + f[18]-f[17]) * inv_rho;
}
#endif // NQ
#endif // LATTICE_MODEL (compute_rho_u)
// ---------------------------------------------------------------------------
// compute_feq: (rho, u) → feq[NQ]
// ---------------------------------------------------------------------------
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ __forceinline__ void compute_feq(float rho, float ux, float uy,
float* __restrict__ feq)
@ -112,7 +112,7 @@ __device__ __forceinline__ void compute_feq(float rho, float ux, float uy,
#undef FEQ_CORE
}
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
__device__ __forceinline__ void compute_feq(float rho, float ux, float uy, float uz,
float* __restrict__ feq)
@ -152,7 +152,7 @@ __device__ __forceinline__ void compute_feq(float rho, float ux, float uy, float
#undef FEQ
}
#endif // NQ
#endif // LATTICE_MODEL (compute_feq)
// ---------------------------------------------------------------------------
// compute_pressure (diagnostic, from state equation p = cs² ρ)

View File

@ -19,6 +19,10 @@
#define LES_CS 0.16f
#endif
#ifndef LES_CLOSED_FORM
#define LES_CLOSED_FORM 1
#endif
#ifndef LES_FP_ITERS
#define LES_FP_ITERS 3
#endif
@ -36,7 +40,7 @@ __device__ __forceinline__ float clamp_omega(float w) {
return fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, w));
}
#if NQ == 9
#if LATTICE_MODEL == LATTICE_D2Q9
__device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
const float* __restrict__ feq,
@ -45,7 +49,6 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
{
const float rho_safe = fmaxf(rho, 1.0e-12f);
const float tau0 = fmaxf(1.0f / fmaxf(omega0, 1.0e-6f), 0.500001f);
const float nu0 = (tau0 - 0.5f) * (1.0f / 3.0f);
// Πneq = Σ (ci ci)(fi-feqi)
float pixx = 0.0f, piyy = 0.0f, pixy = 0.0f;
@ -60,8 +63,19 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
pixy += fneq * cx * cy;
}
// Self-consistent tau iteration for LES/TRT coupling.
// S is estimated from Pi_neq with current tau_eff, then nu_t updates tau_eff.
#if LES_CLOSED_FORM
// Closed-form Smagorinsky (no iteration):
// ω = 2 / (τ₀ + √(τ₀² + 18·Cs²·√Q / ρ))
// where Q = Πneq_ij · Πneq_ij (Frobenius norm squared)
const float Q = pixx*pixx + piyy*piyy + 2.0f*pixy*pixy;
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
const float smag_const_sq = LES_CS * LES_CS;
const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
#else
// Iterative fixed-point (original)
const float nu0 = (tau0 - 0.5f) * (1.0f / 3.0f);
float tau_eff = tau0;
const float tau_max = 1.0f / fmaxf(OMEGA_COLLISION_MIN, 1.0e-6f);
@ -90,9 +104,10 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
}
return clamp_omega(1.0f / tau_eff);
#endif // LES_CLOSED_FORM
}
#elif NQ == 19
#elif LATTICE_MODEL == LATTICE_D3Q19
__device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
const float* __restrict__ feq,
@ -101,7 +116,6 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
{
const float rho_safe = fmaxf(rho, 1.0e-12f);
const float tau0 = fmaxf(1.0f / fmaxf(omega0, 1.0e-6f), 0.500001f);
const float nu0 = (tau0 - 0.5f) * (1.0f / 3.0f);
// Πneq = Σ (ci ci)(fi-feqi)
float pixx = 0.0f, piyy = 0.0f, pizz = 0.0f;
@ -121,7 +135,17 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
piyz += fneq * cy * cz;
}
#if LES_CLOSED_FORM
const float Q = pixx*pixx + piyy*piyy + pizz*pizz
+ 2.0f*(pixy*pixy + pixz*pixz + piyz*piyz);
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
const float smag_const_sq = LES_CS * LES_CS;
const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
#else
// Self-consistent tau iteration for LES/TRT coupling.
const float nu0 = (tau0 - 0.5f) * (1.0f / 3.0f);
float tau_eff = tau0;
const float tau_max = 1.0f / fmaxf(OMEGA_COLLISION_MIN, 1.0e-6f);
@ -157,6 +181,7 @@ __device__ __forceinline__ float compute_omega_smag(const float* __restrict__ f,
}
return clamp_omega(1.0f / tau_eff);
#endif // LES_CLOSED_FORM
}
#endif // NQ

View File

@ -0,0 +1,107 @@
// CelerisLab step/aux_kernels.cu
// Auxiliary kernels: CurvedBoundaryKernel, SensorKernel.
// Launched on compact lists (n_curved / n_sensor threads).
// Included by kernel_v2.cu. No standalone compilation.
//
// CurvedBoundaryKernel: per-link geometry is passed as cl_rx/y/z and runtime
// angular state is read from action[body_id * OBS_BODY_SLOT_FLOATS + ...].
// For DIM==2, wall velocity uses rigid-body rotation: Uw=-omega*ry, Vw=omega*rx.
// ============================================================================
#ifndef CELERIS_STEP_AUX_KERNELS_CU
#define CELERIS_STEP_AUX_KERNELS_CU
// ============================================================================
// CurvedBoundaryKernel — per-link Bouzidi curved BC (n_curved threads)
//
// One thread per cut link. Reads from fi_out (current step, post-collision)
// and fi_in (previous step) to compute Bouzidi linear interpolation.
// ============================================================================
__device__ __forceinline__ float action_omega(const float* action, int body_id)
{
if (action == nullptr || body_id < 0) return 0.0f;
unsigned int base = (unsigned int)body_id * (unsigned int)OBS_BODY_SLOT_FLOATS;
return action[base + (unsigned int)(OBS_BODY_SLOT_FLOATS - 1)];
}
__global__ void CurvedBoundaryKernel(
fpxx* fi_out,
const fpxx* fi_in,
const unsigned int* cl_fluid_idx,
const unsigned char* cl_dir,
const float* cl_q,
const float* cl_rx,
const float* cl_ry,
const float* cl_rz,
const unsigned char* cl_fallback_class,
const int* cl_body_id,
const float* action,
float* obs,
unsigned int n_curved)
{
unsigned int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid >= n_curved) return;
unsigned long k_f = (unsigned long)cl_fluid_idx[tid];
unsigned int dir = (unsigned int)cl_dir[tid];
float q = cl_q[tid];
int bid = cl_body_id[tid];
float rx = cl_rx[tid];
float ry = cl_ry[tid];
unsigned char fallback_class = cl_fallback_class[tid];
float omega = action_omega(action, bid);
#if DIM == 2
float Uw = -omega * ry;
float Vw = omega * rx;
(void)cl_rz;
apply_bouzidi_link(dir, q, k_f, fi_out, fi_in, Uw, Vw, rx, ry, fallback_class, obs, bid);
#elif DIM == 3
float rz = cl_rz[tid];
// Placeholder 3D angular contract: read scalar omega_z from action tail.
float Uw = -omega * ry;
float Vw = omega * rx;
float Ww = 0.0f;
apply_bouzidi_link(dir, q, k_f, fi_out, fi_in, Uw, Vw, Ww,
rx, ry, rz, fallback_class, obs, bid);
#endif
}
// ============================================================================
// SensorKernel — compact-list sensor readout (n_sensor threads)
// ============================================================================
__global__ void SensorKernel(
const fpxx* fi,
const unsigned short* flag,
const unsigned int* sensor_cells,
const int* sensor_obj_id,
float* obs,
unsigned int n_sensor)
{
unsigned int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid >= n_sensor) return;
unsigned long k = (unsigned long)sensor_cells[tid];
int id_obj = sensor_obj_id[tid];
float f[NQ];
for (int i = 0; i < NQ; i++)
f[i] = load_ddf(fi, index_f(k, (unsigned int)i));
#if DIM == 2
float rho_n, ux, uy;
compute_rho_u(f, rho_n, ux, uy);
atomicAdd(&obs[obs_sensor_index(id_obj, 0)], ux);
atomicAdd(&obs[obs_sensor_index(id_obj, 1)], uy);
#elif DIM == 3
float rho_n, ux, uy, uz;
compute_rho_u(f, rho_n, ux, uy, uz);
atomicAdd(&obs[obs_sensor_index(id_obj, 0)], ux);
atomicAdd(&obs[obs_sensor_index(id_obj, 1)], uy);
atomicAdd(&obs[obs_sensor_index(id_obj, 2)], uz);
#endif
(void)flag;
}
#endif // CELERIS_STEP_AUX_KERNELS_CU

View File

@ -0,0 +1,112 @@
// CelerisLab step/init_flow.cu
// Channel-flow initialization kernels (double-buffer and EsoPull).
// Included by kernel_v2.cu after all headers. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_INIT_FLOW_CU
#define CELERIS_STEP_INIT_FLOW_CU
// ---- Helper: write equilibrium for a given velocity ----
__device__ __forceinline__ void write_equilibrium(
fpxx* fi, unsigned long k, float u_init)
{
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
float val = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu
- 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
val -= d_w[i];
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
__device__ __forceinline__ void write_rest_equilibrium(
fpxx* fi, unsigned long k)
{
for (int i = 0; i < NQ; i++) {
float val = d_w[i] * RHO;
#if USE_DDF_SHIFTING
val -= d_w[i];
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
__device__ __forceinline__ uint16_t classify_boundary(
unsigned int x, unsigned int y)
{
uint16_t fl = FLAG_SOLID;
if (x == 0) fl |= FLAG_BC_INLET;
else if (x == (unsigned int)(NX - 1)) fl |= FLAG_BC_OUTLET;
else fl |= FLAG_BC_WALL;
return fl;
}
// ============================================================================
// InitTubeFlow_v2 — double-buffer channel initialization
// ============================================================================
__global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = classify_boundary(x, y);
for (int i = 0; i < NQ; i++)
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#elif DIM == 3
unsigned int x, y, z; unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = classify_boundary(x, y);
for (int i = 0; i < NQ; i++)
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#endif
}
// ============================================================================
// InitEsoPull — EsoPull single-buffer channel initialization
// ============================================================================
__global__ void InitEsoPull(uint16_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = classify_boundary(x, y);
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#elif DIM == 3
unsigned int x, y, z; unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = classify_boundary(x, y);
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#endif
}
#endif // CELERIS_STEP_INIT_FLOW_CU

View File

@ -1,368 +1,150 @@
// CelerisLab step/one_step_double.cu
// Main LBM step kernel using double-buffer PULL streaming.
//
// Workflow per node:
// 1. Pull-stream: gather DDF from neighbors → local f[NQ]
// 2. Compute macroscopic quantities (ρ, u)
// 3. Apply boundary conditions (inlet/outlet/wall)
// 4. Compute equilibrium + forcing
// 5. Collision (SRT / TRT / MRT)
// 6. Write to output buffer
//
// Kernel signature maintains backward compatibility with driver.py:
// flag* cell flags (uint8 for legacy, uint32 for new)
// fi_in* input DDF array (fpxx)
// fi_out* output DDF array (fpxx)
// indx* per-cell offset into delta pool
// delta* curved boundary parameter pool
// action* per-object control input
// obs* per-object observation output (force / velocity)
// Double-buffer PULL streaming step kernel.
// Included by kernel_v2.cu. No standalone compilation.
// ============================================================================
#ifndef CELERIS_STEP_ONE_STEP_DOUBLE_CU
#define CELERIS_STEP_ONE_STEP_DOUBLE_CU
__global__ void StreamCollideDouble(
const uint8_t* __restrict__ flag,
const fpxx* __restrict__ fi_in,
fpxx* __restrict__ fi_out,
const int32_t* __restrict__ indx,
const float* __restrict__ delta,
const float* __restrict__ action,
float* __restrict__ obs,
float* __restrict__ rho_arr, // macro output (can be NULL)
float* __restrict__ u_arr) // macro output (can be NULL)
{
// ----- Thread → node mapping (2D) -----
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
#ifndef Y_WALL_BC
#define Y_WALL_BC 0
#endif
// ----- Read flag -----
uint8_t fl = flag[k];
// ---- Helper: apply inlet/outlet/wall BC in pull-double mode ----
#if DIM == 2
__device__ __forceinline__ void apply_boundary_pull(
float* f, uint16_t fl, unsigned int x, unsigned int y,
const fpxx* fi_in, unsigned long k)
{
// Curved-BC nodes are handled by CurvedBoundaryKernel after the step.
if (is_curved(fl)) return;
// ----- Neighbor indices -----
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (is_inlet(fl) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_parabolic_inlet(f, f_neb, (float)y);
}
else if (is_outlet(fl) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_pressure_outlet(f, f_neb, (float)y);
}
else {
bounce_back_swap(f);
}
}
#endif // DIM == 2
#if DIM == 3
__device__ __forceinline__ void apply_boundary_pull_3d(
float* f, uint16_t fl, unsigned int x, unsigned int y, unsigned int z,
const fpxx* fi_in, unsigned long k)
{
// Curved-BC nodes are handled by CurvedBoundaryKernel after the step.
if (is_curved(fl)) return;
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (is_inlet(fl) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y, z);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_parabolic_inlet_3d(f, f_neb, (float)y);
}
else if (is_outlet(fl) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y, z);
for (int i = 0; i < NQ; i++)
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
apply_pressure_outlet_3d(f, f_neb, (float)y);
}
else {
bounce_back_swap(f);
}
}
#endif
// ============================================================================
// OneStep — main pull double-buffer step kernel
// ============================================================================
__global__ __launch_bounds__(NT, 2)
void OneStep(
uint16_t* flag, fpxx* fi_in, fpxx* fi_out)
{
#if DIM == 2
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
uint16_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(k, j);
compute_neighbors(x, y, j);
// ----- Pull-stream: load DDF from neighbors -----
float f[NQ];
stream_pull_load(k, f, fi_in, j);
// ----- Compute macroscopic quantities -----
float rho_n, ux, uy;
#if NQ == 9
compute_rho_u(f, rho_n, ux, uy);
#elif NQ == 19
float uz;
compute_rho_u(f, rho_n, ux, uy, uz);
#endif
// Boundary handling
// 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.
if (is_solid(fl) && has_bc(fl))
apply_boundary_pull(f, fl, x, y, fi_in, k);
// ----- Boundary conditions (inlet/outlet/wall) -----
#if NQ == 9
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y);
for (int i = 0; i < NQ; i++) {
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
}
apply_parabolic_inlet(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y);
for (int i = 0; i < NQ; i++) {
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
}
apply_pressure_outlet(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
}
if (is_obstacle(fl) && !is_curved(fl))
bounce_back_swap(f);
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
#elif NQ == 19
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x + 1u, y, z);
for (int i = 0; i < NQ; i++) {
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
}
apply_parabolic_inlet_3d(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
float f_neb[NQ];
unsigned long k_neb = linear_index(x - 1u, y, z);
for (int i = 0; i < NQ; i++) {
f_neb[i] = load_ddf(fi_in, index_f(k_neb, (unsigned int)i));
}
apply_pressure_outlet_3d(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float t = f[i]; f[i] = f[i+1]; f[i+1] = t;
}
}
}
#endif
// ----- Wall bounce-back (top/bottom walls) -----
#if NQ == 9
if ((fl & LEGACY_FLUID) && (y == 1u || y == (unsigned int)(NY - 2))) {
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_in, k);
#else
apply_wall_bb_d2q9_y_pull(y, f, fi_in, k);
}
#elif NQ == 19
if ((fl & LEGACY_FLUID) && (y == 1u || y == (unsigned int)(NY - 2))) {
apply_wall_bb_d3q19_y_pull(y, f, fi_in, k);
}
#endif
}
// ----- Compute equilibrium -----
if (fl & LEGACY_FLUID) {
float feq[NQ];
float Fin[NQ];
#if NQ == 9
// Collision (fluid only)
if (is_fluid(fl)) {
float rho_n, ux, uy;
compute_rho_u(f, rho_n, ux, uy);
compute_feq(rho_n, ux, uy, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
// ----- Collision -----
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, Fin, omega_col);
#endif
#elif NQ == 19
compute_rho_u(f, rho_n, ux, uy, uz);
compute_feq(rho_n, ux, uy, uz, feq);
zero_forcing(Fin);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, uz, Fin, omega_col);
#endif
#endif
collide_dispatch(f, rho_n, ux, uy);
}
// ----- Write to output buffer -----
stream_pull_store(k, f, fi_out);
// ----- Write macroscopic fields (optional) -----
if (rho_arr != nullptr) {
rho_arr[k] = rho_n;
}
if (u_arr != nullptr) {
#if DIM == 2
u_arr[k] = ux;
u_arr[TOTAL_CELLS + k] = uy;
#elif DIM == 3
u_arr[k] = ux;
u_arr[TOTAL_CELLS + k] = uy;
u_arr[2 * TOTAL_CELLS + k] = uz;
#endif
}
// ----- Sensor observation -----
if (fl & LEGACY_SENSOR) {
int id_obj = indx[k];
atomicAdd(&obs[DIM * id_obj], ux);
atomicAdd(&obs[DIM * id_obj + 1], uy);
}
}
// ---------------------------------------------------------------------------
// Curved boundary post-processing kernel (separate launch)
// Processes SOLID+INTERFACE nodes → modifies fi_out at neighbors.
// ---------------------------------------------------------------------------
__global__ void CurvedBoundaryPost(
const uint8_t* __restrict__ flag,
fpxx* __restrict__ fi_out,
const int32_t* __restrict__ indx,
const float* __restrict__ delta,
const float* __restrict__ action,
float* __restrict__ obs)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
#endif
uint8_t fl = flag[k];
if ((fl & LEGACY_SOLID) && (fl & LEGACY_INTERFACE)) {
#if DIM == 2
int id_off = indx[k];
int id_obj = *reinterpret_cast<const int*>(&delta[id_off]);
// Wall velocity from action + normal
float Uw = action[id_obj] * delta[id_off + NQ];
float Vw = action[id_obj] * delta[id_off + NQ + 1];
float* obs_fx = (obs != nullptr) ? &obs[DIM * id_obj] : nullptr;
float* obs_fy = (obs != nullptr) ? &obs[DIM * id_obj + 1] : nullptr;
apply_curved_boundary(k, x, y, fi_out, delta, id_off, Uw, Vw, obs_fx, obs_fy);
#endif // DIM == 2 curved BC
}
}
// ---------------------------------------------------------------------------
// InitTubeFlow: Initialize parabolic tube flow (backward compatible)
// ---------------------------------------------------------------------------
__global__ void InitTubeFlow(uint8_t* flag, fpxx* fi)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
float u_init = U0 * 1.5f * (1.0f - 4.0f * ((float)y - 0.5f * (NY - 1))
* ((float)y - 0.5f * (NY - 1))
/ ((float)(NY - 2) * (float)(NY - 2)));
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++) {
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
}
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
// feq with rho=RHO, u=(u_init, 0)
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init; // cy=0 for parabolic flow
float val = d_w[i] * RHO * (1.0f + 3.0f * cu + 4.5f * cu * cu - 1.5f * u_init * u_init);
#if USE_DDF_SHIFTING
val -= d_w[i]; // store f_tilde = f - w
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
unsigned int x, y, z; unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
float u_init = U0 * 1.5f * (1.0f - 4.0f * ((float)y - 0.5f * (NY - 1))
* ((float)y - 0.5f * (NY - 1))
/ ((float)(NY - 2) * (float)(NY - 2)));
// Walls: y=0, y=NY-1, x=0, x=NX-1; z is periodic
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++) {
store_ddf(fi, index_f(k, (unsigned int)i), 0.0f);
}
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
float val = d_w[i] * RHO * (1.0f + 3.0f * cu + 4.5f * cu * cu - 1.5f * u_init * u_init);
#if USE_DDF_SHIFTING
val -= d_w[i];
#endif
store_ddf(fi, index_f(k, (unsigned int)i), val);
}
}
#endif
}
// ---------------------------------------------------------------------------
// UpdateMacro: Recompute rho/u from DDF (for diagnostics/output)
// ---------------------------------------------------------------------------
__global__ void UpdateMacro(
const fpxx* __restrict__ fi,
float* __restrict__ rho_arr,
float* __restrict__ u_arr,
const uint8_t* __restrict__ flag)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
if (flag[k] & LEGACY_SOLID) return;
uint16_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(k, j);
float f[NQ];
// For double-buffer, just read from the current buffer (no streaming)
for (int i = 0; i < NQ; i++) {
f[i] = load_ddf(fi, index_f(k, (unsigned int)i));
}
stream_pull_load(k, f, fi_in, j);
float rho_n, ux, uy;
compute_rho_u(f, rho_n, ux, uy);
if (is_solid(fl) && has_bc(fl))
apply_boundary_pull_3d(f, fl, x, y, z, fi_in, k);
rho_arr[k] = rho_n;
u_arr[k] = ux;
u_arr[TOTAL_CELLS + k] = uy;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
if (is_obstacle(fl) && !is_curved(fl))
bounce_back_swap(f);
if (flag[k] & LEGACY_SOLID) return;
float f[NQ];
for (int i = 0; i < NQ; i++) {
f[i] = load_ddf(fi, index_f(k, (unsigned int)i));
}
if (is_fluid(fl) && (y == 1u || y == (unsigned int)(NY - 2)))
apply_wall_bb_d3q19_y_pull(y, f, fi_in, k);
if (is_fluid(fl)) {
float rho_n, ux, uy, uz;
compute_rho_u(f, rho_n, ux, uy, uz);
collide_dispatch(f, rho_n, ux, uy, uz);
}
rho_arr[k] = rho_n;
u_arr[k] = ux;
u_arr[TOTAL_CELLS + k] = uy;
u_arr[2 * TOTAL_CELLS + k] = uz;
stream_pull_store(k, f, fi_out);
#endif
}

View File

@ -1,125 +1,65 @@
// CelerisLab step/one_step_esopull.cu
// Main LBM step kernel using Esoteric-Pull single-buffer streaming.
// Esoteric-Pull single-buffer step kernel.
// Included by kernel_v2.cu. No standalone compilation.
//
// Workflow per node:
// 1. load_f_esopull: read DDF (streaming second half)
// 2. Compute macroscopic quantities (ρ, u)
// 3. Boundary conditions
// 4. Equilibrium + forcing + collision
// 5. store_f_esopull: write DDF (streaming first half)
// CURVED BC LIMITATION: EsoPull maintains only ONE DDF buffer, so there is
// no "previous-step" fi_in buffer for Bouzidi interpolation (curved_boundary.cuh).
// Curved obstacle nodes therefore receive no boundary correction here — the
// apply_boundary_esopull helper returns early on is_curved().
// CurvedBoundaryKernel is SKIPPED by stepper.py when streaming==esopull.
//
// Compared to double-buffer:
// - Uses HALF the memory (single fi array)
// - Timestep counter t is required
// - fi_alt pointer is unused (can be NULL)
// TODO(future): Support curved BC with EsoPull by maintaining a per-link
// shadow buffer (previous-step DDF for curved nodes only) or switching to
// a half-step strategy that reuses the in-place slot before overwrite.
// ============================================================================
#ifndef CELERIS_STEP_ONE_STEP_ESOPULL_CU
#define CELERIS_STEP_ONE_STEP_ESOPULL_CU
__global__ void StreamCollideEsoPull(
fpxx* __restrict__ fi, // single DDF buffer (read/write)
const uint8_t* __restrict__ flag,
const int32_t* __restrict__ indx,
const float* __restrict__ delta,
const float* __restrict__ action,
float* __restrict__ obs,
float* __restrict__ rho_arr,
float* __restrict__ u_arr,
const float* __restrict__ force_field, // Euler force field [DIM*N], or NULL
unsigned long t) // current timestep
{
// ----- Thread → node mapping -----
// ---- Helper: apply inlet/outlet BC in esopull mode ----
#if DIM == 2
unsigned int x, y;
unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
#elif DIM == 3
unsigned int x, y, z;
unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
#endif
__device__ __forceinline__ void apply_boundary_esopull(
float* f, uint16_t fl, unsigned int x, unsigned int y,
const fpxx* fi, unsigned long t)
{
// Curved-BC nodes are handled by CurvedBoundaryKernel after the step.
if (is_curved(fl)) return;
uint8_t fl = flag[k];
// ----- Neighbor indices -----
unsigned long j[NQ];
compute_neighbors(k, j);
// ----- Esoteric-Pull: load DDF (streaming second half) -----
float f[NQ];
load_f_esopull(k, f, fi, j, t);
// ----- Solid / wall nodes: bounce-back then store (essential for EsoPull) -----
// Unlike double-buffer, EsoPull requires solid nodes to participate in
// store_f so that adjacent fluid nodes pull the correct reflected DDF.
// Reference: FluidX3D kernel.cpp apply_moving_boundaries()
if ((fl & LEGACY_SOLID) && !(fl & LEGACY_INTERFACE) && !(fl & LEGACY_SENSOR)) {
if (x != 0 && x != (unsigned int)(NX - 1)) {
// Pure wall / interior solid: bounce-back (swap pairs) then store
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float ttmp = f[i]; f[i] = f[i+1]; f[i+1] = ttmp;
}
store_f_esopull(k, f, fi, j, t);
return;
}
}
// ----- Compute macroscopic quantities -----
float rho_n, ux, uy;
#if NQ == 9
compute_rho_u(f, rho_n, ux, uy);
#elif NQ == 19
float uz;
compute_rho_u(f, rho_n, ux, uy, uz);
#endif
// ----- Boundary conditions -----
#if NQ == 9
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
float f_neb[NQ];
if (is_inlet(fl) && interior_y) {
unsigned long k_neb = linear_index(x + 1u, y);
load_f_esopull(k_neb, f_neb, fi, j, t); // approximate: reuse j
// More accurate: compute j_neb separately
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
compute_neighbors(x + 1u, y, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_parabolic_inlet(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
else if (is_outlet(fl) && interior_y) {
unsigned long k_neb = linear_index(x - 1u, y);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
compute_neighbors(x - 1u, y, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_pressure_outlet(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float ttmp = f[i]; f[i] = f[i+1]; f[i+1] = ttmp;
}
}
else {
bounce_back_swap(f);
}
}
#endif // DIM == 2
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float ttmp = f[i]; f[i] = f[i+1]; f[i+1] = ttmp;
}
}
#if DIM == 3
__device__ __forceinline__ void apply_boundary_esopull_3d(
float* f, uint16_t fl, unsigned int x, unsigned int y, unsigned int z,
const fpxx* fi, unsigned long t)
{
// Curved-BC nodes are handled by CurvedBoundaryKernel after the step.
if (is_curved(fl)) return;
// Wall BB at y=1/NY-2 is NOT needed for EsoPull: solid wall nodes
// now do BB+store, so fluid nodes pull correctly reflected DDFs.
#elif NQ == 19
if (fl & LEGACY_SOLID) {
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
if (x == 0 && interior_y) {
if (is_inlet(fl) && interior_y) {
unsigned long k_neb = linear_index(x + 1u, y, z);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
@ -127,144 +67,95 @@ __global__ void StreamCollideEsoPull(
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_parabolic_inlet_3d(f, f_neb, (float)y);
}
else if (x == (unsigned int)(NX - 1) && interior_y) {
else if (is_outlet(fl) && interior_y) {
unsigned long k_neb = linear_index(x - 1u, y, z);
unsigned long j_neb[NQ];
compute_neighbors(k_neb, j_neb);
float f_neb[NQ];
load_f_esopull(k_neb, f_neb, fi, j_neb, t);
apply_pressure_outlet_3d(f, f_neb, (float)y);
} else {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float ttmp = f[i]; f[i] = f[i+1]; f[i+1] = ttmp;
}
}
}
if (fl & LEGACY_OBSTACLE) {
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
float ttmp = f[i]; f[i] = f[i+1]; f[i+1] = ttmp;
}
}
// Wall BB at y=1/NY-2 NOT needed for EsoPull (solid nodes do BB+store).
#endif
// ----- Forcing -----
float Fin[NQ];
float fxn = d_params.fx, fyn = d_params.fy;
if (force_field != nullptr) {
fxn += force_field[k];
fyn += force_field[TOTAL_CELLS + k];
}
if (fxn != 0.0f || fyn != 0.0f) {
#if NQ == 9
apply_guo_velocity_correction(ux, uy, fxn, fyn, rho_n);
compute_guo_forcing(ux, uy, fxn, fyn, Fin);
#endif
} else {
zero_forcing(Fin);
}
// ----- Clamp velocity for stability -----
const float CS_LIMIT = 0.57735027f; // 1/sqrt(3)
ux = fminf(fmaxf(ux, -CS_LIMIT), CS_LIMIT);
uy = fminf(fmaxf(uy, -CS_LIMIT), CS_LIMIT);
// ----- Collision -----
if (fl & LEGACY_FLUID) {
float feq[NQ];
#if NQ == 9
compute_feq(rho_n, ux, uy, feq);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, Fin, omega_col);
#endif
#elif NQ == 19
float fzn = d_params.fz;
if (force_field != nullptr) fzn += force_field[2*TOTAL_CELLS + k];
apply_guo_velocity_correction(ux, uy, uz, fxn, fyn, fzn, rho_n);
compute_feq(rho_n, ux, uy, uz, feq);
float omega_col = d_params.omega;
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif
omega_col = fminf(OMEGA_COLLISION_MAX, fmaxf(OMEGA_COLLISION_MIN, omega_col));
#if COLLISION_MODEL == 0
collide_srt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 1
collide_trt(f, feq, Fin, omega_col);
#elif COLLISION_MODEL == 2
collide_mrt(f, rho_n, ux, uy, uz, Fin, omega_col);
#endif
#endif
}
// ----- Esoteric-Pull: write DDF (streaming first half) -----
store_f_esopull(k, f, fi, j, t);
// ----- Write macroscopic fields -----
if (rho_arr != nullptr) rho_arr[k] = rho_n;
if (u_arr != nullptr) {
u_arr[k] = ux;
u_arr[TOTAL_CELLS + k] = uy;
}
// ----- Sensor observation -----
if (fl & LEGACY_SENSOR) {
int id_obj = indx[k];
atomicAdd(&obs[DIM * id_obj], ux);
atomicAdd(&obs[DIM * id_obj + 1], uy);
else {
bounce_back_swap(f);
}
}
#endif
// ---------------------------------------------------------------------------
// InitializeEsoPull: Initialize DDF via Esoteric-Pull store (t=1)
// ---------------------------------------------------------------------------
__global__ void InitializeEsoPull(uint8_t* flag, fpxx* fi)
// ============================================================================
// EsoPullStep — Esoteric-Pull single-buffer step kernel
// ============================================================================
__global__ __launch_bounds__(NT, 2)
void EsoPullStep(
fpxx* fi, uint16_t* flag,
float* action, float* obs,
unsigned long t)
{
#if DIM == 2
unsigned int x, y;
unsigned long k;
unsigned int x, y; unsigned long k;
index_from_thread(x, y, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY) return;
uint16_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(x, y, j);
float f[NQ];
load_f_esopull(k, f, fi, j, t);
// Pure solid early exit
if (is_solid(fl) && !has_bc(fl)) {
bounce_back_swap(f);
store_f_esopull(k, f, fi, j, t);
return;
}
// Boundary nodes
if (is_solid(fl))
apply_boundary_esopull(f, fl, x, y, fi, t);
if (is_obstacle(fl) && !is_curved(fl))
bounce_back_swap(f);
// Collision (fluid only)
if (is_fluid(fl)) {
float rho_n, ux, uy;
compute_rho_u(f, rho_n, ux, uy);
collide_dispatch(f, rho_n, ux, uy);
}
store_f_esopull(k, f, fi, j, t);
#elif DIM == 3
unsigned int x, y, z; unsigned long k;
index_from_thread(x, y, z, k);
if (x >= (unsigned int)NX || y >= (unsigned int)NY || z >= (unsigned int)NZ) return;
uint16_t fl = flag[k];
unsigned long j[NQ];
compute_neighbors(k, j);
float feq[NQ];
if (y == 0 || y == NY - 1 || x == 0 || x == NX - 1) {
flag[k] = LEGACY_SOLID;
for (int i = 0; i < NQ; i++) feq[i] = 0.0f;
} else {
flag[k] = (uint8_t)LEGACY_FLUID;
float u_init = U0 * 1.5f * (1.0f - 4.0f * ((float)y - 0.5f*(NY-1))
* ((float)y - 0.5f*(NY-1))
/ ((float)(NY-2) * (float)(NY-2)));
for (int i = 0; i < NQ; i++) {
float cu = (float)d_cx[i] * u_init;
feq[i] = d_w[i] * RHO * (1.0f + 3.0f*cu + 4.5f*cu*cu - 1.5f*u_init*u_init);
#if USE_DDF_SHIFTING
feq[i] -= d_w[i];
#endif
float f[NQ];
load_f_esopull(k, f, fi, j, t);
if (is_solid(fl) && !has_bc(fl)) {
bounce_back_swap(f);
store_f_esopull(k, f, fi, j, t);
return;
}
if (is_solid(fl))
apply_boundary_esopull_3d(f, fl, x, y, z, fi, t);
if (is_obstacle(fl) && !is_curved(fl))
bounce_back_swap(f);
if (is_fluid(fl)) {
float rho_n, ux, uy, uz;
compute_rho_u(f, rho_n, ux, uy, uz);
collide_dispatch(f, rho_n, ux, uy, uz);
}
// Write via Esoteric-Pull store at t=1 (odd step) for correct alignment
store_f_esopull(k, feq, fi, j, 1ul);
store_f_esopull(k, f, fi, j, t);
#endif
}

View File

@ -19,17 +19,28 @@ class LBMStepper:
self.module = module
self.cfg = cfg
# Streaming mode
self._esopull = (cfg.streaming == "esopull")
# Kernel handles
if self._esopull:
self.step_fn = module.get_function("EsoPullStep")
self.init_fn = module.get_function("InitEsoPull")
else:
self.step_fn = module.get_function("OneStep")
self.init_fn = module.get_function("InitTubeFlow_v2")
# Launch geometry
# Aux kernels (always present; launched only when compact lists are non-empty)
self.curved_fn = module.get_function("CurvedBoundaryKernel")
self.sensor_fn = module.get_function("SensorKernel")
# Launch geometry — ceiling division to cover all cells
tpb = cfg.threads_per_block
self.block = (tpb, 1, 1)
if cfg.dim == 2:
self.grid = (cfg.nx // tpb, cfg.ny, 1)
if cfg.is_d2q9:
self.grid = ((cfg.nx + tpb - 1) // tpb, cfg.ny, 1)
else:
self.grid = (cfg.nx // tpb, cfg.ny, cfg.nz)
self.grid = ((cfg.nx + tpb - 1) // tpb, cfg.ny, cfg.nz)
self._step_count = 0
@ -41,38 +52,112 @@ class LBMStepper:
f.flag_gpu, f.ddf_gpu,
block=self.block, grid=self.grid,
)
# Copy init state to both buffers
cuda.memcpy_dtod(f.temp_gpu, f.ddf_gpu, f.ddf.nbytes)
if not self._esopull:
# Double-buffer: copy init state to both buffers
_gpu_bytes = f.n * f.nq * f.store_bytes
cuda.memcpy_dtod(f.temp_gpu, f.ddf_gpu, _gpu_bytes)
# Sync host
cuda.memcpy_dtoh(f.flag, f.flag_gpu)
cuda.memcpy_dtoh(f.ddf, f.ddf_gpu)
f.download_ddf()
# -- Stepping ------------------------------------------------------------
def step(self, n: int = 1, action_gpu=None, obs_gpu=None):
def step(
self,
n: int = 1,
*,
action_gpu: cuda.DeviceAllocation,
obs_gpu: cuda.DeviceAllocation,
stream: cuda.Stream | None = None,
):
"""Advance *n* time steps.
Optional action_gpu / obs_gpu are raw device pointers for
object interaction (passed through to the kernel).
Args:
n: Number of substeps.
action_gpu: Device buffer for per-object runtime control (packed floats).
obs_gpu: Packed telemetry buffer (force + torque + sensor segments); aux kernels
index via compile-time ``OBS_*`` macros host does not pass offsets.
stream: Optional CUDA stream for kernel launches (required by runners).
"""
f = self.field
# Provide dummy pointers if no objects
dummy = cuda.mem_alloc(4) if action_gpu is None else None
act = action_gpu or dummy
ob = obs_gpu or dummy
launch_kw = {}
if stream is not None:
launch_kw["stream"] = stream
for _ in range(n):
if self._esopull:
self.step_fn(
f.ddf_gpu, f.flag_gpu,
action_gpu, obs_gpu,
np.uint64(self._step_count),
block=self.block, grid=self.grid,
**launch_kw,
)
else:
self.step_fn(
f.flag_gpu, f.ddf_gpu, f.temp_gpu,
f.indx_gpu, f.delta_gpu,
act, ob,
block=self.block, grid=self.grid,
**launch_kw,
)
# Swap buffers
f.ddf_gpu, f.temp_gpu = f.temp_gpu, f.ddf_gpu
self._launch_curved(action_gpu, obs_gpu, **launch_kw)
self._launch_sensor(obs_gpu, **launch_kw)
self._step_count += 1
if dummy is not None:
dummy.free()
# -- Aux kernel launchers ------------------------------------------------
def _launch_curved(self, action_gpu, obs_gpu, **launch_kw):
f = self.field
if f.n_curved == 0:
return
if self._esopull:
# Curved BC (Bouzidi) requires a previous-step fi_in buffer, which
# EsoPull does not maintain (single-buffer). Curved BC is therefore
# unsupported with EsoPull until a shadow-buffer strategy is added.
# See one_step_esopull.cu for the implementation plan.
return
tpb = self.cfg.threads_per_block
grid_c = ((f.n_curved + tpb - 1) // tpb, 1, 1)
fi_out = f.ddf_gpu
fi_in = f.temp_gpu
cl = f.curved
assert cl.fluid_idx_gpu is not None
assert cl.dir_gpu is not None
assert cl.q_gpu is not None
assert cl.rx_gpu is not None
assert cl.ry_gpu is not None
assert cl.rz_gpu is not None
assert cl.fallback_class_gpu is not None
assert cl.body_id_gpu is not None
self.curved_fn(
fi_out, fi_in,
cl.fluid_idx_gpu, cl.dir_gpu, cl.q_gpu,
cl.rx_gpu, cl.ry_gpu, cl.rz_gpu, cl.fallback_class_gpu, cl.body_id_gpu,
action_gpu, obs_gpu,
np.uint32(f.n_curved),
block=(tpb, 1, 1), grid=grid_c,
**launch_kw,
)
def _launch_sensor(self, obs_gpu, **launch_kw):
f = self.field
if f.n_sensor == 0:
return
tpb = self.cfg.threads_per_block
grid_s = ((f.n_sensor + tpb - 1) // tpb, 1, 1)
fi_in = f.ddf_gpu
se = f.sensors
assert se.cells_gpu is not None and se.obj_id_gpu is not None
self.sensor_fn(
fi_in, f.flag_gpu,
se.cells_gpu, se.obj_id_gpu,
obs_gpu,
np.uint32(f.n_sensor),
block=(tpb, 1, 1), grid=grid_s,
**launch_kw,
)
@property
def step_count(self) -> int:

View File

@ -14,6 +14,8 @@ Usage::
from typing import Dict, Optional, Tuple, Any
import os
import numpy as np
import pycuda.driver as cuda
@ -43,9 +45,7 @@ class Simulation:
# CUDA context
self.ctx = CudaContext(device_id)
arch = self.ctx.sm_arch
if self.lbm_cfg.compute_capability != "auto":
arch = f"sm_{''.join(self.lbm_cfg.compute_capability.split('.'))}"
arch = self._resolve_compile_arch()
# Compile kernel
compiler.generate_config(self.lbm_cfg, n_objects=0)
@ -58,19 +58,20 @@ class Simulation:
# Body manager
self.bodies = ObjectManager(
self.lbm_cfg.nx, self.lbm_cfg.ny,
self.lbm_cfg.nq, self.lbm_cfg.dim,
self.lbm_cfg.nx, self.lbm_cfg.ny, self.lbm_cfg.nz,
self.lbm_cfg.nq, self.lbm_cfg,
)
self._initialized = False
self._assert_object_count_contract(expected_count=0)
# -- Object management ---------------------------------------------------
def add_cylinder(self, center: Tuple[float, float],
def add_cylinder(self, center: Tuple[float, ...],
radius: float) -> int:
obj = Cylinder(obj_id=-1, center=center, radius=radius)
return self.bodies.add(obj)
def add_sensor(self, center: Tuple[float, float],
def add_sensor(self, center: Tuple[float, ...],
radius: float) -> int:
obj = Sensor(obj_id=-1, center=center, radius=radius)
return self.bodies.add(obj)
@ -84,16 +85,23 @@ class Simulation:
Call after changing compile-time parameters (collision model, etc.).
"""
arch = self.ctx.sm_arch
if self._initialized:
raise RuntimeError(
"recompile() must not be called after initialize(); "
"rebuild Simulation instead.")
arch = self._resolve_compile_arch()
compiler.generate_config(self.lbm_cfg, n_objects=self.bodies.count)
self._ptx_path = compiler.compile_kernel(arch=arch)
self._module = compiler.load_module(self._ptx_path)
# Reconnect field and stepper to new module
self.field.module = self._module
self.field._upload_params()
_prev_step_count = self.stepper._step_count
self.stepper = LBMStepper(
self.field, self._module, self.lbm_cfg,
)
self.stepper._step_count = _prev_step_count
self._assert_object_count_contract(expected_count=self.bodies.count)
# -- Initialization ------------------------------------------------------
def initialize(self):
@ -104,13 +112,39 @@ class Simulation:
self.stepper.initialize()
if self.bodies.count > 0:
self.bodies.sync_to_gpu(self.field)
else:
self.bodies.ensure_packed_buffers()
self._assert_runtime_contracts()
if self.lbm_cfg.streaming == "esopull" and self.field.n_curved > 0:
raise RuntimeError(
"Unsupported configuration: streaming='esopull' with curved links. "
"Use streaming='double_buffer' or remove curved-link objects."
)
self._initialized = True
# -- Stepping ------------------------------------------------------------
def run(self, steps: int):
"""Advance simulation by *steps* time steps."""
def run(self, steps: int, checkpoint_interval: int = 0):
"""Advance simulation by *steps* time steps.
Args:
checkpoint_interval: If >0, save checkpoint every N steps.
"""
if not self._initialized:
raise RuntimeError("Call initialize() first")
self._assert_runtime_contracts()
if checkpoint_interval > 0:
done = 0
while done < steps:
batch = min(checkpoint_interval, steps - done)
self.stepper.step(
batch,
action_gpu=self.bodies.action_gpu,
obs_gpu=self.bodies.obs_gpu,
)
done += batch
if done < steps or done % checkpoint_interval == 0:
self.save_checkpoint()
else:
self.stepper.step(
steps,
action_gpu=self.bodies.action_gpu,
@ -148,6 +182,20 @@ class Simulation:
def restore(self):
self.field.restore()
# -- Checkpoint / Restore (HDF5) ----------------------------------------
def save_checkpoint(self, path: str = None):
"""Save full simulation state to an HDF5 file."""
from .common.checkpoint import save_checkpoint
return save_checkpoint(self.field, self.stepper, self.lbm_cfg,
self.bodies, path)
def load_checkpoint(self, path: str):
"""Restore simulation state from an HDF5 checkpoint."""
from .common.checkpoint import load_checkpoint
load_checkpoint(path, self.field, self.stepper, self.lbm_cfg,
self.bodies)
self._initialized = True
# -- Cleanup -------------------------------------------------------------
def close(self):
self.ctx.close()
@ -163,3 +211,53 @@ class Simulation:
self.close()
except Exception:
pass
def _resolve_compile_arch(self) -> str:
"""Return nvcc arch, respecting explicit compute_capability override."""
if self.lbm_cfg.compute_capability == "auto":
return self.ctx.sm_arch
return f"sm_{''.join(self.lbm_cfg.compute_capability.split('.'))}"
def _read_generated_n_objects(self) -> int:
"""Read N_OBJS from generated config_objects.h for safety checks."""
cfg_path = compiler.kernel_path("config/config_objects.h")
with open(cfg_path, "r", encoding="utf-8") as f:
for line in f:
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}")
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)
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."
)
def _assert_obs_layout_contract(self) -> None:
"""Ensure host obs layout matches runtime body count and generated 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}."
)
if (
mgr.slot_stride_floats != lay.slot_stride_floats
or mgr.torque0_floats != lay.torque0_floats
or mgr.sensor0_floats != lay.sensor0_floats
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}."
)
def _assert_runtime_contracts(self) -> None:
"""Validate object-count and packed-observation contracts before stepping."""
self._assert_object_count_contract()
self._assert_obs_layout_contract()

View File

@ -0,0 +1,174 @@
# Cylinder benchmark targets for solver validation
##### [**Undermind**](https://undermind.ai)
---
当前阶段不再追求一次覆盖全部圆柱文献,而是先把工作集压缩到两个最有诊断价值的 case一个固定圆柱文献硬对标一个旋转圆柱内部回归。这样做的目的是先回答两个更基础的问题求解器在当前边界能力下是否可靠以及 `curved_boundary` 这条代码路径是否自洽。
结合当前代码能力,最合适的固定圆柱主 benchmark 是 \[Sah04\] 的 2D 受限通道圆柱。该 family 与你现有的 `parabolic` 入口和上下 no-slip wall 最一致,且 \[Sah04\] 明确给出了 \\\beta=0.3\\ 下的临界 Reynolds 数和 \\Re=100\\ 时的 Strouhal 数。\[Sah04\] 旋转圆柱则先不做开放来流文献硬对标,因为南北 free-stream 与更匹配的外边界 family 尚未实现;当前最合理的做法,是沿用同一受限通道几何做内部旋转回归,并把 \[Kan99b\] 作为下一阶段开放来流 benchmark 的目标参考。\[Kan99b\]
在 \[Sah04\] 的表格中,\\\beta=0.30\\ 的第一临界 Reynolds 数和临界 Strouhal 已经给出;下面展示的表格就是当前最适合锁定的文献锚点。上图中的拖曳曲线则说明了为什么 \\\beta=0.30, Re=100\\ 是一个好用的周期态检查点。\[Sah04\]
## 当前锁定的两个 case
| ID | 角色 | family | 是否做文献硬评分 | 主要回答的问题 |
|:---|:---|:---|:---|:---|
| A1 | 固定圆柱主 benchmark | \[Sah04\] confined cylinder | 是 | 现在的 solver 在当前边界能力下能否对标稳定周期 shedding |
| B1 | 旋转圆柱内部回归 | internal rotating confined cylinder | 否 | `curved_boundary` 中 moving wall 项MEA 力torque符号约定是否自洽 |
## A1 固定圆柱主 benchmark
A1 固定采用 \[Sah04\] 的受限通道 family并且只选一个最实用的工作点
- 阻塞比 \\\beta = D/H = 0.30\\
- Reynolds 数 \\Re = 100\\
- 入口为 fully developed parabolic inflow \\u=(1-x_2^2,0)\\
- 上下边界为 no-slip wall
- 圆柱表面为 no-slip curved wall
- 出口先用当前最稳的 `neq_extrap`,再用 `zero_gradient` 做敏感性对比
- 运行模式统一为 `double_buffer + LES off`
选择这个点有四个原因。
- 它和你当前代码能力最匹配 \[Sah04\]
- 它已经越过一阶 Hopf 临界点,能同时检查 \\C_d\\、\\C_l\\ 和 \\St\\
- \\\beta=0.30\\ 的壁效应足够明显,能放大曲壁与受力统计误差
- 文献中给出了明确的 \\Re\_{crit}\\ 和 \\St\\,比只靠图上读趋势更稳 \[Sah04\]
### A1 的文献定义
| 项目 | 设定 | 说明 |
|:---|:---|:---|
| family | `sah04_confined` | 固定圆柱受限通道 |
| Reynolds 数定义 | \\Re = U\_{max} D / \nu\\ | 用最大入口速度归一化 \[Sah04\] |
| 阻塞比定义 | \\\beta = D/H\\ | \\H\\ 是通道高度 \[Sah04\] |
| 入口 | parabolic | \\u=(1-x_2^2,0)\\ \[Sah04\] |
| 上下边界 | no-slip wall | 与当前代码能力一致 |
| 圆柱边界 | no-slip curved wall | 当前主排错对象 |
| 出口 | 先 `neq_extrap` | 文献是二阶导数型出口,你的实现只能近似 |
| 上游长度 | 40D | \[Sah04\] |
| 下游长度 | 40D | \[Sah04\] |
### A1 的具体网格方案
你提到圆柱直径至少要 20 个格点,这个判断是对的。对 \\\beta=0.30\\ 来说,若希望阻塞比在格点上精确,最方便的直径应取 3 的倍数。这样 \\H=D/\beta\\ 才是整数。基于这个原则,当前推荐如下。
| 方案 | 直径 D | 半径 r | 流体高度 H | 当前代码中的 NY | 流体长度 Lx | 当前代码中的 NX | 圆心 |
|:---|---:|---:|---:|---:|---:|---:|:---|
| A1 base | 24 | 12.0 | 80 | 82 | 1920 | 1922 | \\(960.5, 40.5)\\ |
| A1 confirm | 30 | 15.0 | 100 | 102 | 2400 | 2402 | \\(1200.5, 50.5)\\ |
这里的换算采用你当前代码的 wall 约定:
- 流体带高度取 `NY - 2`
- 上下边界各占一层边界行
- 因此 `NY = H + 2`
- 同理,若左右边界节点各占一列,则 `NX = Lx + 2`
推荐先用 `D=24` 做主排错,因为它已经超过 20 格点,同时保持 \\\beta=0.30\\ 精确。若 A1 结果基本合理,再用 `D=30` 做一次确认,判断误差是边界主导还是分辨率主导。
### A1 的对标物理量
A1 不要求所有量都同等对待。当前建议分成三级。
| 优先级 | 物理量 | 对标方式 | 备注 |
|:---|:---|:---|:---|
| P1 | \\St\\ | 点目标 | \[Sah04\] 明确给出 \\St=0.2115\\ for \\\beta=0.30, Re=100\\ |
| P1 | \\Re\_{crit}\\ | 点目标 | \[Sah04\] 给出 \\Re\_{crit}\approx 94.4\\ for \\\beta=0.30\\ |
| P2 | 平均阻力 \\\overline{C_d}\\ | 带宽目标 | 图上可读约 1.8 到 2.0,当前先作为 band target |
| P3 | 升力振幅或 RMS | 内部比较 | 文献未在该 case 明确列表,当前先记录,不做硬阈值 |
| P3 | 质量漂移 | 诊断量 | 当前不纳入文献分数,但必须长期记录 |
这意味着A1 现在最硬的文献锚点其实是 \\Re\_{crit}\\ 和 \\St\\,不是 \\C_l\\ 振幅。平均阻力可以作为第二层目标,但更适合作为带宽检查,而不是一个小数点后三位的硬分数。
### A1 的通过标准
| 量 | 通过标准 | 解释 |
|:---|:---|:---|
| \\St\\ | 相对误差 3 percent 内 | 这是当前最重要的周期态 benchmark |
| \\Re\_{crit}\\ | 相对误差 5 percent 内 | 用于确认整体 shedding 触发位置 |
| \\\overline{C_d}\\ | 落在 1.8 到 2.0 带内 | 当前只做 band target |
| 质量漂移 | 单位 shedding 周期内接近零趋势 | 不要求绝对零,但不能持续单调失控 |
## B1 旋转圆柱内部回归
B1 不拿来和 \[Kan99b\] 做硬评分,因为 \[Kan99b\] 用的是开放来流 O-grid 外场与 convective outlet\[Kan99b\] 而你当前还没有相同 family。B1 的意义不是“验证旋转圆柱文献是否复现”,而是“验证 moving wall 代码路径是否自洽”。
B1 直接继承 A1 的几何和网格,只改变圆柱壁面速度。这样做能最大限度隔离变量。
### B1 的统一定义
| 项目 | 设定 |
|:---------------|:------------------------------------|
| family | `internal_rotating_confined` |
| 几何 | 与 A1 完全相同 |
| 参考速度 | 暂统一用 \\U\_{max}\\ |
| 自旋比 | \\\alpha = \Omega D / (2U\_{max})\\ |
| 入口和上下边界 | 与 A1 完全相同 |
| 文献硬评分 | 不做 |
### B1 的具体工作点
| 子工况 | 自旋比 \\\alpha\\ | 用途 |
|:-------|------------------:|:------------------------------------|
| B1-0 | 0.0 | 必须退化回 A1 固定圆柱 |
| B1+ | +0.5 | 检查正转时的 moving wall 与受力响应 |
| B1- | -0.5 | 检查反转时 lift 和 torque 是否反号 |
只要 B1+ 和 B1- 不是镜像关系,就先不要扩大到更高 \\\alpha\\ 扫描。
### B1 的目标物理量
| 优先级 | 量 | 目标 |
|:---|:---|:---|
| P1 | \\\overline{C_l}\\ 符号 | 正反转必须反号 |
| P1 | torque 符号 | 正反转必须反号 |
| P1 | B1-0 与 A1 的一致性 | \\\alpha=0\\ 必须退化成固定圆柱结果 |
| P2 | \\\overline{C_d}\\ 随 \\\alpha\\ 的变化 | 作为趋势检查 |
| P2 | 质量漂移变化 | 看 moving wall 是否显著放大 leakage |
## 是否纳入 MRT
你提到 MRT 可以直接纳入我同意。MRT 不会改变 benchmark family本身不会让设置更混乱。当前更合理的做法不是把 MRT 排除,而是把它放在同一 case 的第三个运行层级中。
| 顺序 | collision | 角色 |
|:-----|:----------|:------------------------------------|
| 1 | SRT | 最简单可解释基线 |
| 2 | TRT | 最直接的边界敏感性对比 |
| 3 | MRT | 观察多松弛是否改变 force 和 leakage |
但解释顺序仍应保持不变:若 SRT 已经明显错,先不要拿 MRT 的较好结果掩盖基础边界问题。
## 推荐的首轮运行序列
| 顺序 | run | 目的 |
|:-----|:--------------------------------|:--------------------------------------|
| 1 | A1 base + SRT + `neq_extrap` | 建立固定圆柱主 benchmark |
| 2 | A1 base + TRT + `neq_extrap` | 检查曲壁与碰撞敏感性 |
| 3 | A1 base + MRT + `neq_extrap` | 记录 collision family 差异 |
| 4 | A1 base + SRT + `zero_gradient` | 判断 outlet 对 \\St\\ 与 force 的影响 |
| 5 | A1 confirm + TRT + `neq_extrap` | 做一次分辨率确认 |
| 6 | B1-0 + TRT | 检查是否退化回 A1 |
| 7 | B1+ + TRT | 检查正转 |
| 8 | B1- + TRT | 检查反转 |
| 9 | B1+ + MRT | 看 moving wall 下 MRT 是否改变趋势 |
## 当前阶段的判读重点
- 若 A1 的 \\St\\ 比 \\\overline{C_d}\\ 更先失真,先查 outlet 和曲壁时序。
- 若 A1 的 \\\overline{C_d}\\ 偏差更明显,先查 MEA 力定义、系数归一化和 `q` 分布。
- 若 B1 正反转不反号,优先查 `6 w_i (c_i\cdot u_w)` 的符号和 torque 杠杆臂。
- 若 B1-0 不能退化回 A1说明 rotating 路径在 \\\alpha=0\\ 时仍残留额外改动。
## 结论
当前最合理的工作集就是 A1 和 B1。A1 负责文献硬对标,且具体锁定在 \[Sah04\] 的 \\\beta=0.30, Re=100\\;网格优先用 `D=24`,再用 `D=30` 做确认。\[Sah04\] B1 负责 rotating path 的内部回归,优先看正反转镜像关系与 torque 符号,而不是先追开放来流文献值。\[Kan99b\] 这样可以在最少的 case 上同时推进“求解器可靠性”和“代码缺陷排查”两条线。
---
## References
\[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,” Apr. 02, 2004. doi: [10.1063/1.1668285](https://doi.org/10.1063/1.1668285).
\[Kan99b\] S. Kang, H. Choi, and S. Lee, “Laminar flow past a rotating circular cylinder,” Oct. 07, 1999. doi: [10.1063/1.870190](https://doi.org/10.1063/1.870190).

258
tests/boundary_features.md Normal file
View File

@ -0,0 +1,258 @@
# Boundary features still needed for cylinder validation
##### [**Undermind**](https://undermind.ai)
---
当前代码已经具备做 2D 通道类圆柱验证的主体能力D2Q9 与 D3Q19SRT 与 TRT 与 MRT`double_buffer` 与 `esopull`,可开关 LES`uniform` 与 `parabolic` 入口,和若干开放出口模式。真正阻塞下一阶段 benchmark 的,不是碰撞模型数量不够,而是少数几个边界功能还没有补齐。它们分别是:南北 free-stream 边界time-dependent 入口幅值,开放来流 family 的更匹配外边界,以及 z 方向 periodic 边界。\[Qu13, Kan99b, Jia21\]
这份说明只回答一个问题:这些功能应如何实现,物理上在做什么,数值上应放在什么位置,以及如何兼容你当前的 SRT 与 TRT 与 MRT、`double_buffer` 与 `esopull`、LES 开关。
## 当前最值得补的功能顺序
| 顺序 | 功能 | 直接解锁的 benchmark | 为什么先做 |
|:---|:---|:---|:---|
| 1 | 南北 free-stream 边界 | \[Qu13\] 2D 开放来流 | 数学最简单,直接解锁 2D 恒定来流主 benchmark |
| 2 | time-dependent inlet amplitude | \[Joh04\] 全定义复现 | 实现代价低,可完善实现检查 |
| 3 | 开放来流 family 的远场与 outlet | \[Kan99b\] 2D 旋转开放来流 | 旋转 benchmark 的关键 |
| 4 | z periodic 边界 | \[Jia21\] 3D 固定圆柱 | 一旦实现即可解锁 3D 主 benchmark |
如果只能先做一个功能,应优先做南北 free-stream 边界。因为它不仅解锁 \[Qu13\],也会让开放来流中的静止圆柱和旋转圆柱不再被 fixed wall 人为污染。\[Qu13, Kan99b\]
## 南北 free-stream 边界
### 物理含义
\[Qu13\] 的上下边界不是物理壁面,而是远场近似。其假设是:圆柱诱导扰动在足够远的横向距离上已经衰减,因此边界上的流动回到自由来流状态。\[Qu13\]
对 2D 开放来流圆柱,这意味着在上边界和下边界施加目标宏观状态
``` math
\mathbf{u}_b = (U_{\infty}, 0)
```
``` math
\rho_b = \rho_0
```
最实用的实现不是发明一套新的边界家族,而是把你已有的 aligned velocity boundary 泛化到 y 方向边界。
### 推荐数值形式
最稳妥的第一版做法是 non-equilibrium extrapolation \[Guo02\]。它的写法是
``` math
f_i(x_b,t+\Delta t)=f_i^{eq}(\rho_b,\mathbf{u}_b)+\left[f_i(x_f,t+\Delta t)-f_i^{eq}(\rho_f,\mathbf{u}_f)\right]
```
这里
- $`x_b`$ 是边界节点
- $`x_f`$ 是相邻内侧流体 donor 节点
- 平衡态由目标自由流状态给出
- 非平衡部分由 donor 节点外推
这个形式有几个好处。
- 与你现有入口边界在代码结构上最接近
- 对 SRT 与 TRT 与 MRT 都能共用同一宏观目标状态
- 比单纯代数型 local closure 更稳,特别适合工程首版 \[Guo02, Lat08\]
### 在当前模式中的兼容方式
| 模块 | 兼容原则 |
|:---|:---|
| SRT | 直接用当前平衡态与 donor nonequilibrium |
| TRT | 同样重构分布,必要时保留现有 `trt_neq_damp` 作为 donor damping |
| MRT | 边界先在分布空间重构,再交给常规 MRT bulk 更新 |
| LES | 层流 benchmark 默认关。若开启边界仍使用相同宏观目标LES 只影响 bulk 局部黏性 |
### 在 `double_buffer``esopull` 中的放置位置
不论 streaming 路径如何,逻辑上都应保持一致:先得到边界节点的 post-stream 已知分布,再重构未知分布。区别只在索引来源。
| streaming | 实现原则 |
|:---|:---|
| `double_buffer` | 在 pull streaming 之后,对边界节点用 donor 节点和目标状态重构未知分布 |
| `esopull` | 在得到本地 post-stream 视图后,用同一重构公式回填未知方向 |
因此最好把边界实现写成“给定当前节点、方向集合、目标宏观状态、donor 节点”的统一接口,而不要把公式写死在某一种 streaming 路径中。
### 工程建议
第一版不必追求 characteristic far-field。只需先实现 north 与 south 的统一 velocity boundary支持 `uniform` 目标状态,并把 `boundary_type``velocity_profile` 解耦。这样同一套 y 边界内核就能同时服务:
- 开放来流的 free-stream 侧边界
- 通道中的移动壁之外的其他速度边界
## time-dependent inlet amplitude
### 物理含义
\[Joh04\] 需要的不是新的空间 profile而是已有 parabolic profile 的时间幅值调制。\[Joh04\] 因此它是一个很低成本但高收益的功能。
若把入口 profile 写成模板
``` math
\mathbf{u}_{in}(y,t)=A(t)\,\mathbf{u}_{shape}(y)
```
那么当前已支持的两种 profile 都能自然兼容。
- `uniform` 对应 $`\mathbf{u}_{shape}=(1,0)`$
- `parabolic` 对应 $`\mathbf{u}_{shape}(y)`$ 为固定抛物线形状
\[Joh04\] 的本质就是给 $`A(t)`$ 一个时间函数。\[Joh04\]
### 推荐实现
最简单的工程形式是给 inlet boundary 增加一个可选回调或 schedule。
| 字段 | 含义 |
|:---------------------|:---------------------------|
| `profile` | `uniform``parabolic` |
| `amplitude_mode` | `constant``scheduled` |
| `amplitude_value` | 常数幅值 |
| `amplitude_schedule` | 按时间步返回幅值的函数或表 |
这样不需要新边界核,只需要在每一步把目标边界速度更新后交给现有入口重构。
### 与模式的关系
它与 SRT 与 TRT 与 MRT、`double_buffer` 与 `esopull`、LES 都无直接耦合。因为变的是目标宏观状态,不是边界算法本身。
## 开放来流 family 的远场与 outlet
### 物理含义
\[Qu13\] 的出口使用 convective boundary。
``` math
\frac{\partial u_i}{\partial t}+C\frac{\partial u_i}{\partial x}=0
```
并取 $`C=1`$。\[Qu13\]
\[Kan99b\] 也采用了开放外场,并把外边界分成 inflow 半边和 outflow 半边。\[Kan99b\] 对旋转圆柱而言,只修正圆柱壁面速度是不够的;若外场 family 仍不对,升阻力和频率会被远场反射和壁效应同时污染。
### 对当前工程的建议
当前已有 `neq_extrap`、`zero_gradient` 和 `blended` 三种 outlet。对于通道类验证这已经足够先用。但若要更严格地逼近 \[Qu13\] 或 \[Kan99b\],下一步应考虑增加更接近 convective outlet 的 family。
最简单的第一版不是 characteristic outlet而是直接实现一个宏观量层面的 convective update。之后若高 Re 稳定性成为问题,再考虑 characteristic 或 regularized characteristic family \[Izq08, Wis17\]。
### 推荐路线
| 阶段 | 功能 |
|:-------|:---------------------------------------------|
| 第一版 | north 与 south free-stream velocity boundary |
| 第二版 | x 出口增加 convective family |
| 第三版 | 若高 Re 仍敏感,再加 characteristic outlet |
### 与模式的关系
出口 family 应尽量与 collision model 解耦。也就是说:
- SRT 与 TRT 与 MRT 共享同一个宏观 outlet 目标
- 仅 donor nonequilibrium 的处理上保留与现有 config 一致的 damping 参数
- LES 不应改变 outlet 类型,只改变内域的有效黏性与非平衡强度
## z 方向 periodic 边界
### 物理含义
\[Jia21\] 的 3D 圆柱 benchmark 之所以能被稳定比较,是因为 spanwise 方向采用 periodic从而模拟无限长圆柱的一段重复单元。\[Jia21\] periodic 不代表“边界上再做一次边界重构”,而代表拓扑上把两个 z 面缝合成一个连续方向。
其物理条件是
``` math
\phi(x,y,0,t)=\phi(x,y,L_z,t)
```
对分布函数而言,就是所有跨越 z 边界的 streaming 都直接 wrap 到另一侧。
### 实现原则
periodic 边界不属于 bounce-back也不属于 velocity outlet。它本质上是索引映射。
对任何具有 $`c_{i,z}=+1`$ 的离散方向,若 pull source 超出上边界,就从另一端取值;反之亦然。
### 在两种 streaming 路径中的处理
| streaming | 实现原则 |
|:---|:---|
| `double_buffer` | pull source 的 z 索引越界时做 modulo wrap |
| `esopull` | 针对所有 $`c_{i,z}\neq 0`$ 的方向,读取或写回时做相同 wrap |
这说明 periodic 的最稳实现位置不在边界核,而在 streaming 索引层。只要 streaming 层完成 wrapcollision、curved boundary、sensor、LES 都不需要知道 periodic 的存在。
### 与模式的关系
| 模块 | 关系 |
|:------------------|:-------------------------------------------|
| SRT 与 TRT 与 MRT | 完全无关collision 不变 |
| LES | 完全无关,局部 SGS 仍按正常 stencil 取邻域 |
| 曲壁边界 | 完全无关,圆柱表面仍按现有 kernel 处理 |
因此 z periodic 是一个高价值、低物理风险的功能。它的难点主要是索引实现,而不是物理公式。
## 对当前代码架构的建议
### 先按 boundary family 分层,不要按 case 临时加分支
建议把缺失功能拆成三层接口。
| 层 | 作用 |
|:---|:---|
| `macro target` 层 | 给出边界目标状态,如 $`\rho_b`$ 与 $`\mathbf{u}_b`$ |
| `reconstruction` 层 | 用 Zou-He 或 NEQ extrapolation 等方法重构未知分布 |
| `streaming topology` 层 | 处理 periodic wrap 或 pull source 定位 |
这样做的好处是:
- north 与 south free-stream 只改 `macro target``reconstruction`
- time-dependent inlet 只改 `macro target`
- z periodic 只改 `streaming topology`
### 模式扩展顺序
建议所有新增功能都按同一顺序落地。
1. 先在 `double_buffer` 上实现
2. 先支持 SRT 基线
3. 再验证 TRT 和 MRT
4. 最后再移植到 `esopull`
5. 层流 benchmark 上默认 LES off
这是最省调试成本的路线,因为 `double_buffer + SRT + LES off` 最容易隔离边界错误。
## 最后建议
如果你的目标是尽快把圆柱验证从当前阶段推进到真正可对标的开放来流和 3D family那么最值得优先补的不是新的碰撞模型也不是新的曲壁公式而是
- north 与 south free-stream boundary
- inlet amplitude schedule
- convective 或更匹配的远场 outlet family
- z periodic
其中第一项和第四项收益最高。第一项直接解锁 \[Qu13\],第四项直接解锁 \[Jia21\]。\[Qu13, Jia21\] 对旋转圆柱而言,只有在第一项和第三项具备之后,\[Kan99b\] 才能从 internal regression 升级成真正的 benchmark。\[Kan99b\]
---
## References
\[Qu13\] L. Qu, C. Norberg, L. Davidson, S.-H. Peng, and F. Wang, “Quantitative numerical analysis of flow past a circular cylinder at Reynolds number between 50 and 200,” May 01, 2013. doi: [10.1016/J.JFLUIDSTRUCTS.2013.02.007](https://doi.org/10.1016/J.JFLUIDSTRUCTS.2013.02.007).
\[Kan99b\] S. Kang, H. Choi, and S. Lee, “Laminar flow past a rotating circular cylinder,” Oct. 07, 1999. doi: [10.1063/1.870190](https://doi.org/10.1063/1.870190).
\[Jia21\] H. Jiang and L. Cheng, “Large-eddy simulation of flow past a circular cylinder for Reynolds numbers 400 to 3900,” Mar. 19, 2021. doi: [10.1063/5.0041168](https://doi.org/10.1063/5.0041168).
\[Joh04\] V. John, “Reference values for drag and lift of a twodimensional timedependent flow around a cylinder,” Mar. 10, 2004. doi: [10.1002/FLD.679](https://doi.org/10.1002/FLD.679).
\[Guo02\] Z. Guo, C. Zheng, and B. Shi, “Non-equilibrium extrapolation method for velocity and pressure boundary conditions in the lattice Boltzmann method,” Apr. 01, 2002. doi: [10.1088/1009-1963/11/4/310](https://doi.org/10.1088/1009-1963/11/4/310).
\[Lat08\] J. Latt, B. Chopard, O. Malaspinas, M. Deville, and A. Michler, “Straight velocity boundaries in the lattice Boltzmann method.” *Physical review. E, Statistical, nonlinear, and soft matter physics*, vol. 77 5 Pt 2, pp. 056703, May 2008, doi: [10.1103/physreve.77.056703](https://doi.org/10.1103/physreve.77.056703).
\[Izq08\] S. Izquierdo and N. Fueyo, “Characteristic nonreflecting boundary conditions for open boundaries in lattice Boltzmann methods.” *Physical review. E, Statistical, nonlinear, and soft matter physics*, vol. 78 4 Pt 2, pp. 046707, Oct. 2008, doi: [10.1103/PHYSREVE.78.046707](https://doi.org/10.1103/PHYSREVE.78.046707).
\[Wis17\] G. Wissocq, N. Gourdain, O. Malaspinas, and A. Eyssartier, “Regularized characteristic boundary conditions for the Lattice-Boltzmann methods at high Reynolds number flows,” *J. Comput. Phys.*, vol. 331, pp. 118, Jan. 2017, doi: [10.1016/j.jcp.2016.11.037](https://doi.org/10.1016/j.jcp.2016.11.037).

1317
tests/experiment.ipynb Normal file

File diff suppressed because one or more lines are too long

628
tests/sah04.md Normal file
View File

@ -0,0 +1,628 @@
Physics of Fluids
[Non-Text]
AIP Publishing
09 May 2026 02:44:09
RESEARCH ARTICLE | MAY 01 2004
# A numerical investigation of wall effects up to high blockage ratios on two-dimensional flow past a confined circular cylinder 
Mehmet Sahin; Robert G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/04f2ac91f19136625792cc7885c26ff309cd2af40953795f507cf0174f894240.jpg)
Check for updates
Physics of Fluids 16, 13051320 (2004)
https://doi.org/10.1063/1.1668285
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/72090bcfbb4012675d2c7b1cc9edb72bb6bd8c31af67159969daccf60b89cd9d.jpg)
View Online
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/0326ffba2e567900d98bef3ee37f76b67e7bfcf58bdad88efcf0e941c04df2ba.jpg)
Export Citation
# Articles You May Be Interested In
Shear-induced autorotation of freely rotatable cylinder in a channel flow at moderate Reynolds number
Physics of Fluids (April 2018)
Frequency lock-in mechanism in the presence of blockage effects
Physics of Fluids (July 2024)
The blockage and erosion characteristics of woody debris flow on an erodible gully bed: Insight from a small-scale model experiment
Physics of Fluids (December 2024)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/b23da9e50021a50f3728dda7a3e6e5fcffe481041870de3da4f3d5b7fa3621d5.jpg)
# AIP Advances
# Why Publish With Us?
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/0ea6c476b7d8bccc0a577f6d9a9c6900559d74f3c9d252d371339e35c78918a7.jpg)
21DAYS average time to1st decision
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/624899fbfa71eedc7107d8121d7251768bb035aad000f4bb8266ea3b34e0e663.jpg)
OVER 4 MILLION views in the last year
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/68712f3d7b56205622c906d76b1d087ece8564459f1a49411af458bf06262dda.jpg)
INCLUSIVE scope
Learn More
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/2ca83425fe3d559d59c7763e626ce2f555a4206119d376c4e6c2cd4bb62b3b0a.jpg)
AIP Publishing
09 May 2026 02:44:09
HTmL AB:STRACT * LINKS
PHYSICS OF FLUIDS
VOLUME 16, NUMBER 5
MAY 2004
# A numerical investigation of wall effects up to high blockage ratios on two-dimensional flow past a confined circular cylinder
Mehmet Sahin and Robert G. Owensa)
LMF-ISE-FSTI, Ecole Polytechnique Fe´de´rale de Lausanne, CH 1015 Lausanne, Switzerland
~Received 25 September 2003; accepted 20 January 2004; published online 2 April 2004!
A finite volume method based on a velocity-only formulation is used to solve the flow field around a confined circular cylinder in a channel in order to investigate lateral wall proximity effects on stability, Strouhal number, hydrodynamic forces and wake structure behind the cylinder for a wide range of blockage ratios (0.1,b<0.9) and Reynolds numbers $( 0 < R e \leqslant 2 8 0 )$ . For blockage ratios less than approximately 0.85 a first critical Reynolds number is identified at which a supercritical Hopf bifurcation of the symmetric solution occurs. For blockage ratios greater than about 0.687 and at Reynolds numbers exceeding the first critical Reynolds number a second curve of neutral stability is seen, representing a pitchfork bifurcation of the steady symmetric solution to one of two possible steady asymmetric solutions. Either side of the neutral stability curve for the pitchfork bifurcation our linear stability analysis and direct numerical simulations demonstrate that although the flow is linearly stable it is unstable to finite two-dimensional perturbations. At blockage ratios larger than about 0.82 the steady asymmetric solutions also become unstable through a Hopf bifurcation. In contrast with the first Hopf bifurcation of the symmetric solution at lower Reynolds numbers numerical calculations of the lift coefficient reveal that the oscillations are no longer symmetric in the rising and falling parts of each cycle. Very strong vortices shed from the cylinder and the wall cause drastic increases in the amplitudes of the lift and drag coefficients. A co-dimension 2 point where pitchfork and Hopf bifurcations occur simultaneously has been located in parameter space. Altogether, four distinct regions in the parameter space $( \beta , R e ) \in ( 0 , 0 . 9 ] \times ( 0 , 2 8 0 ]$ have been identified, each corresponding to a different class of flow: ~i! Steady symmetric flow, ~ii! symmetric vortex shedding, ~iii! steady asymmetric flow, and ~iv! asymmetric vortex shedding, where a periodic-in-time flow is classed as symmetric or asymmetric depending on whether the time-average over one cycle of the lift coefficient is zero or not. Numerical solutions are computed on meshes having up to 1.8 million degrees of freedom. Extensive comparisons are made with the results available in the literature. © 2004 American Institute of Physics. @DOI: 10.1063/1.1668285#
# I. INTRODUCTION
It is no exaggeration to say that an enormous ~and still rapidly growing! corpus of literature on the subject of bluff body wakes has developed since the pioneering work of von Ka´rma´n early last century. This fact is an attestation to both the difficulty in understanding and adequately describing the flow bifurcations that occur at various values of the Reynolds number in viscous flows and the interest in doing so. Flows having particularly simple setups such as those past a sphere or cylinder have succeeded in drawing experimentalists, theoreticians and computational fluid dynamicists into the fray that has gone on through the decades and only very recently are consensuses emerging.
Details of recent theoretical, experimental and computational developments for unbounded flow past a cylinder may be found in the review paper of Williamson,1 where particular attention is paid to the vortex dynamics in the cylinder wake. Our interest in this paper is a careful analysis of lateral wall effects on viscous flow past a confined cylinder. What we have in mind is depicted in Fig. 1. In this figure an infinitely long cylinder of diameter D is placed symmetrically between parallel lateral walls a distance H apart. The parameter $\beta { \equiv } D / H$ is usually termed the blockage ratio. In stark contrast to the wealth of insight and commentary available on vortex dynamics in the wake of an unbounded cylinder we find ourselves with only a handful of papers offering a serious treatment of the blockage ratio effects present in the confined cylinder problem. This paucity of scientific literature should not be interpreted as implying that the problem is an unimportant one, however. On the contrary, even for unbounded flow past a cylinder the ~infinite! flow domain has to be replaced with ~or mapped onto! a finite one, thus introducing numerical or experimental blockage effects that may have considerable influence over the determined values of the flow parameters.2,3 Many of the blockage ratio effects described in the literature are more or less evident:
~1! In the steady flow regime, bringing the walls closer to the cylinder results in the appearance of the twin vortices in the cylinder wake at higher Reynolds numbers.
~2! At any given modest (&50) Reynolds number and for
a! Author to whom correspondence should be addressed. Electronic mail: robert.owens@epfl.ch
1070-6631/2004/16(5)/1305/16/$22.00
1305
© 2004 American Institute of Physics
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/e689dae01d67b8c954588a8c7ac9096932bc150f9447192a7132674811fd46be.jpg)
FIG. 1. Schematic of a cylinder placed symmetrically in a plane channel. The cylinder diameter is D and the channel height H.
$\beta { \leqslant } 0 . 2$ the length of the closed vortex bubble decreases with wall proximity, while remaining a linear function of R e . 2 4 $R e . ^ { 2 - 4 }$
~3! For increasing blockage ratios $\beta$ up to 0.5 the steady two-dimensional base flow is stabilized with respect to infinitesimal perturbations due to constraint by the confining walls of the separating shear layer that exists between the cylinder wake and the wall boundary layer vorticity.3,4
~4! Once the critical Reynolds number for the primary instability has been exceeded the frequency with which periodic two-dimensional vortex shedding takes place at a given Reynolds number is an increasing function of $\bar { \boldsymbol { \beta } } . ^ { 2 , 4 - 6 }$ ~Note that the spurious result obtained by Stansby and Slaouti6 for $\beta = 0 . 5$ is thought to be due to neglect of the boundary layers in their numerical simulation using random vortex methods.!
~5! At $R e = O ( 1 0 0 )$ both the mean drag coefficient $C _ { d }$ and the separation angle of the vortex bubble increase as the walls approach the cylinder.2,3,5,6
In addition to the obvious interest of wall blockage effects and as observed by Chen $e t a l . , { ^ 4 }$ the choice of a bounded domain allows a more definitive specification of the flow ~both numerically and experimentally! than is possible in the unbounded case, whilst conserving the essential features of the latter.
A problem bearing some similarities to that of flow past a confined cylinder is that of flow around a cylinder placed at various heights above a plane boundary. A recent literature survey of experimental investigations into this problem may be found in the paper of Lei et al.7 These studies have sought to address the issue of how forces on the cylinder and vortex shedding frequency depend on the ratio $g / D$ of the gap between the cylinder and the wall, g, and the cylinder diameter, D. They have also been concerned with understanding the effect on these quantities of the boundary layer thickness and the velocity gradient. Most of the experiments have been conducted at Reynolds numbers in the sub-critical regime $\left[ R e = O ( 1 \times 1 0 ^ { 4 } ) \right]$ in which the boundary layer is still laminar. Lei et $a l . ^ { 7 }$ found that the drag coefficient $C _ { d }$ increased with increasing gap ratio because of the reduction in the base pressure. The same trend in base pressure dependence had been observed by Bearman and Zdravkovich.8 The latter authors further found that the Strouhal number for $g / D { \gtrsim } 0 . 3$ was more or less constant in their experiments at a Reynolds number of $4 . 8 \times 1 0 ^ { 4 }$ . Lei et $a l . ^ { 7 }$ also noted only slight fluctuations in a Strouhal number computed from the free-stream velocity for a similar range of gap to diameter ratios. However, for gaps less than 0.3 cylinder diameters8 or $0 . 2 \mathrm { - } 0 . 3$ diameters ~depending on the boundary layer thickness7 !, vortex shedding was suppressed. Differences in the quantification of the vortex shedding suppression gap ratio were due possibly to differences in the boundary layer thicknesses generated by the experimentalists and also to the manner in which the critical gap ratio was identified: Bearman and $\mathrm { Z d r a v k o v i c h } ^ { 8 }$ using a spectral analysis of hot-wire signals in the cylinder wake whereas the method of Lei $e t a l . ^ { 7 }$ was based on observation of the spectrum of the lift coefficient. Suppression of vortices for a sufficiently small gap ratio was also confirmed by Zovatto and Pedrizzetti,9 who used a finite element method based on a vorticity-streamfunction formulation to analyze flow around a cylinder positioned eccentrically between two lateral walls. For very small gap ratios Zovatto and Pedrizzetti9 found a recirculating bubble on the wall downstream of the cylinder. A separation bubble on the wall had also been seen earlier by Bearman and Zdravkovich8 for gap ratios smaller than the critical value for vortex suppression. We will return to some of these flow phenomena in our discussion of our numerical results in Sec. IV for large blockage ratios.
The motivation for the present study is twofold. First, the rich fluid dynamics in the wake and near the lateral walls deserves to be investigated with greater numerical accuracy than has been possible with the computational resources available to other researchers at the time at which they prepared their manuscripts. Computations on meshes allowing for only tens of thousands of degrees of freedom have been typical ~for example, Refs. 4 and 10!. In the present study a novel finite volume method $\begin{array} { r l } { { 1 1 - 1 3 } } & { { } } \\ { . } \end{array}$ is used in a parallel implementation, permitting up to 1.8 million degrees of freedom and thus a higher resolution of the wake and boundary layer structures. Second, the only previous numerical linear stability analysis of flow past a confined cylinder available to $\mathrm { u s } ^ { 4 }$ went no further than a blockage ratio of $\beta { = } 0 . 7$ . From the results of this publication the trend seemed to be one of decreasing linear stability of the two-dimensional flow for $\beta { > } 0 . 5$ . Stability was always lost over the range of blockage ratios considered through a symmetry-breaking supercritical Hopf bifurcation. We wish in this paper to investigate the effect on the critical Reynolds number of choosing $\beta { > } 0 . 7$ and to identify the nature of the flow instabilities by means of an Arnoldi method.
The outline of the present paper is as follows: In Sec. II we describe the problem to be solved and furnish the reader with a brief description of the numerical method used to analyze flow past a confined cylinder at Reynolds numbers up to 280. Section III is dedicated to validation of our numerical scheme for the classical problem of unbounded twodimensional flow past a circular cylinder. Extensive comparison with other results in the literature is made. In particular we find excellent agreement with previously obtained values for the drag coefficients, first critical Reynolds numbers $R e _ { \mathrm { c r i t 1 } }$ and the corresponding critical Strouhal numbers. In
1306
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
Sec. IV we are concerned with a detailed description of wake dynamics and interactions of the wake and wall boundary layers for blockage ratios up to 0.9. For blockage ratios below approximately 0.85 the locus of a supercritical Hopf bifurcation may be traced out in parameter space. At higher Reynolds numbers and for blockage ratios sufficiently large there is a pitchfork bifurcation of the steady symmetric state to one of two asymmetric steady states. Either side of the curve of neutral stability for the pitchfork bifurcation the steady solutions are linearly stable but appear on the basis of direct numerical simulations to be unstable to finite twodimensional perturbations. For yet larger Reynolds numbers and $\beta { \gtrsim } 0 . 8 2$ a Hopf bifurcation of the asymmetric state occurs. The oscillations are now quite different from those associated with the first symmetry-breaking instability, the amplitude of the drag and lift coefficients being much stronger and the oscillations are now asymmetric in time. Finally, we draw some conclusions.
# II. MATHEMATICAL PROBLEM AND NUMERICAL SCHEME
An infinitely long cylinder of diameter D is placed midway between two parallel planes which are a distance H apart, as shown in Fig. 1. Let us denote by $U _ { \mathrm { m a x } }$ the maximum inlet fluid speed. The incompressible unsteady Navier Stokes equations may be written in dimensionless form as
$$
\frac {\partial \mathbf {u}}{\partial t} + (\mathbf {u} \cdot \nabla) \mathbf {u} = - \nabla p + \frac {1}{R e} \nabla^ {2} \mathbf {u}, \tag {1}
$$
$$
\nabla \cdot \mathbf {u} = 0, \tag {2}
$$
where, in the usual notation, $\mathbf { u } { = } ( u _ { 1 } , u _ { 2 } )$ denotes the velocity field, p the pressure and Re is a Reynolds number. In the present work the Reynolds number is defined as Re $= U _ { \mathrm { m a x } } D / v$ where v is the kinematic viscosity. In the presentation of results in Secs. III and IV for those flows exhibiting periodic vortex shedding, a Strouhal number St is defined by $S t { = } D / ( T U _ { \operatorname* { m a x } } ) .$ , where T is the period of vortex shedding. We denote by $( \mathbf { x } , t ) { = } ( ( x _ { 1 } , x _ { 2 } ) , t )$ a generic point in space and time.
In Sec. III we approximate the unbounded cylinder geometry by choosing $\beta = 0 . 0 1$ and the following boundary conditions:
$\mathrm { C y l i n d e r ~ s u r f a c e : } ~ { \bf u } { = } ( 0 , 0 ) ,$ ~3!
$\mathrm { L a t e r a l ~ w a l l s } \colon \mathrm { ~ } \mathbf { u } = ( 1 , 0 ) ,$ ~4!
$\mathrm { I n f l o w : } \qquad \mathbf { u } = ( 1 , 0 ) ,$ ~5!
$\mathrm { O u t f l o w : } \qquad { \frac { \partial ^ { 2 } u _ { 1 } } { \partial x _ { 1 } ^ { 2 } } } = 0 , \ { \frac { \partial u _ { 2 } } { \partial x _ { 1 } } } = 0 .$ 9u1 du2 ~6! x1
For the confined cylinder problem ~see Sec. IV! Eqs. ~1! and ~2! are solved subject to the following boundary conditions on the components of velocity:
$\mathrm { C y l i n d e r ~ s u r f a c e : } ~ { \bf u } { = } ( 0 , 0 ) ,$ ~7!
$\mathrm { L a t e r a l ~ w a l l s } \colon \mathrm { ~ } \mathbf { u } = ( 0 , 0 ) ,$ ~8!
$\mathrm { I n f l o w : } \qquad \mathbf { u } = ( 1 - x _ { 2 } ^ { 2 } , 0 ) ,$ ~9!
$$
\text { Outflow: } \quad \frac {\partial^ {2} u _ {1}}{\partial x _ {1} ^ {2}} = 0, \frac {\partial u _ {2}}{\partial x _ {1}} = 0. \tag {10}
$$
Some care needs to be taken with how the second normal derivative outflow condition is imposed, due to possible linear dependence in the discrete equation set of this condition with the discrete form of the continuity equation. More precisely stated, the second normal derivative outflow condition will be automatically satisfied at $x _ { 2 } = 0$ upon imposition of the continuity equation ~2! within each finite volume. For all the results presented in Sec. III the dimensionless upstream and downstream channel lengths were set equal to 400 D. In Sec. IV these lengths were chosen to both be equal to 40 D. The choice of outflow boundary conditions ~6! and ~10! was motivated by the fact that our numerical method uses a velocity-only formulation so that the usual traction-free conditions could not easily be implemented. The free boundary layer type of conditions ~6! and ~10! were used successfully by Kourta et al.14 in finite volume simulations of a twodimensional plane mixing layer. Although Jin and Braza15 later developed a nonreflecting outlet condition that greatly reduced feedback noise when compared with the outlet condition of Kourta et al.,14 the outlet length used for the calculations in the present paper are considered sufficiently great that the difference between the influence of the one set of exit conditions and the other on drag, linear stability and Strouhal number would be negligible. The more complicated exit conditions of Jin and Braza15 are therefore not implemented.
Let n denote a unit outward pointing normal vector to the boundary ]V of a finite volume V. Then integration of ~2! over V and taking the vector product of ~1! with n, followed by integration around ]V leads, respectively, to
$$
\oint_ {\partial \Omega} \mathbf {n} \cdot \mathbf {u} d s = 0 \tag {11}
$$
and
$$
\oint_ {\partial \Omega} \mathbf {n} \times \left[ \frac {\partial \mathbf {u}}{\partial t} + (\nabla \times \mathbf {u}) \times \mathbf {u} + \frac {1}{R e} \nabla \times (\nabla \times \mathbf {u}) \right] d s = \mathbf {0}. \tag {12}
$$
In our numerical scheme the continuity equation ~11! is satisfied within each finite volume while ~12! is applied to each finite volume except the finite volumes next to the wall. Therefore, vorticity creation is allowed within these finite volumes in order to satisfy the no-slip boundary conditions. Equations ~11! and ~12! with no-slip boundary conditions are enough to solve the problem in a simply connected domain ~such as that found in the lid-driven cavity problem, for $\mathrm { e x a m p l e } ^ { 1 2 } )$ . However, if the domain is not simply connected there is a need for additional equations. This is because there is a potential problem in our velocity-only formulation with multi-valuedness of the pressure field, even though the pressure does not appear explicitly as a dependent variable in our formulation. To rectify this a Kutta-type condition
$$
\oint_ {\Gamma} \mathbf {n} \times \left[ \frac {\partial \mathbf {u}}{\partial t} + (\nabla \times \mathbf {u}) \times \mathbf {u} + \frac {1}{R e} \nabla \times (\nabla \times \mathbf {u}) \right] d s = \mathbf {0}, \tag {13}
$$
1307
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
TABLE I. Values of grid parameters $i _ { \mathrm { m a x } } , k _ { \mathrm { m a x } } , k _ { \mathrm { w a l l } }$ , and N .
<table><tr><td rowspan="2"><eq>\beta</eq></td><td colspan="4">M1</td><td colspan="4">M2</td><td colspan="4">M3</td></tr><tr><td><eq>i_{\text{max}}</eq></td><td><eq>k_{\text{max}}</eq></td><td><eq>k_{\text{wall}}</eq></td><td>N</td><td><eq>i_{\text{max}}</eq></td><td><eq>k_{\text{max}}</eq></td><td><eq>k_{\text{wall}}</eq></td><td>N</td><td><eq>i_{\text{max}}</eq></td><td><eq>k_{\text{max}}</eq></td><td><eq>k_{\text{wall}}</eq></td><td>N</td></tr><tr><td>0.01</td><td>181</td><td>301</td><td>137</td><td>89 336</td><td>361</td><td>601</td><td>273</td><td>355 312</td><td>721</td><td>1201</td><td>545</td><td>1 417 184</td></tr><tr><td>0.1</td><td>181</td><td>441</td><td>77</td><td>116 536</td><td>361</td><td>881</td><td>153</td><td>462 512</td><td>721</td><td>1761</td><td>305</td><td>1 842 784</td></tr><tr><td>0.2</td><td>181</td><td>421</td><td>57</td><td>109 336</td><td>361</td><td>841</td><td>113</td><td>433 712</td><td>721</td><td>1681</td><td>225</td><td>1 727 584</td></tr><tr><td>0.3</td><td>181</td><td>411</td><td>47</td><td>105 736</td><td>361</td><td>821</td><td>93</td><td>419 312</td><td>721</td><td>1641</td><td>185</td><td>1 669 984</td></tr><tr><td>0.5</td><td>181</td><td>401</td><td>37</td><td>102 136</td><td>361</td><td>801</td><td>73</td><td>404 912</td><td>721</td><td>1601</td><td>145</td><td>1 612 384</td></tr><tr><td>0.7</td><td>181</td><td>391</td><td>27</td><td>98 536</td><td>361</td><td>781</td><td>53</td><td>390 512</td><td>721</td><td>1561</td><td>105</td><td>1 554 784</td></tr><tr><td>0.9</td><td>181</td><td>381</td><td>17</td><td>94 936</td><td>361</td><td>761</td><td>33</td><td>376 112</td><td>721</td><td>1521</td><td>65</td><td>1 497 184</td></tr></table>
is imposed around the closed path G formed from the union of the outer edges of the finite volumes on the cylinder surface. The condition ~13! guarantees that
$$
\oint_ {\Gamma} \mathbf {n} \times \nabla p d s = \mathbf {k} [ p ] = \mathbf {0}, \tag {14}
$$
where k is a unit vector normal to the plane of the flow, and @ p# denotes the jump in the pressure on passing once around G. Since ~12! is satisfied in every interior finite volume, satisfaction of ~13! ensures that $p$ is single-valued at every interior finite volume vertex. The pressure can be obtained by integrating the two components of the pressure gradient appearing in the equations of linear momentum in a manner analogous to that used in finding a streamfunction from a given velocity. The values of p on the domain boundaries, when required, are determined by first computing ]p/]n from ~1!.
A fully implicit second-order cell-vertex finite volume method based on a velocity-only formulation is used for the discretization of ~1! and ~2!. Discretization of the integrals appearing in ~11! and ~12! is effected by using the mid-point rule on cell faces. Full details of the method are supplied in two recent papers by the present authors11,12 and, in the interests of brevity, will not be reproduced here. For the timedependent computations presented in Secs. III and IV we discretize in time using an Euler implicit method and for computing steady-state base flows a Newton method is employed.
A major part of the present paper is concerned with the linear stability of two-dimensional flow at various different blockage ratios. Consider the perturbed flow
$$
\mathbf {u} (\mathbf {x}, t) = \mathbf {U} (\mathbf {x}) + \mathbf {v} (\mathbf {x}) \exp (\sigma t), \tag {15}
$$
where U(x) is the ~numerically determined! steady base flow at a given Reynolds number. Then discretizing the dimensionless NavierStokes equations as described above leads to an algebraic system of equations
$$
\mathbf {A} \mathbf {x} = \sigma \mathbf {M} \mathbf {x}, \tag {16}
$$
for the nodal values of the perturbation velocity v. The matrices A and M in ~16! are block quad-diagonal and block bi-diagonal, respectively. The GEVP ~16! may be solved by applying Arnoldis method16,17 to the equivalent system
$$
\mathbf {C} \mathbf {x} = \mu \mathbf {x}, \tag {17}
$$
where $\mathbf { C } = ( \mathbf { A } - \lambda \mathbf { M } ) ^ { - 1 } \mathbf { M }$ and $\mu { = } \left( \sigma { - } \lambda \right) ^ { - 1 }$ . Application of the Arnoldi method results in the construction of an upper Hessenberg matrix whose eigenvalues are approximations to a subset of the eigenvalues $\mu$ of C. From the properties of Arnoldis method and in the absence of a shift $\lambda , ^ { 1 8 }$ best resolution of the s-spectrum is expected to be near the origin.
The coefficient matrix A in ~16! is almost identical ~by construction! to that which arises in the computations of the steady base flow using Newtons method. Solutions to all the discrete algebraic equations that arise in the steady, unsteady or eigenvalue problems of this paper have been obtained by implementing the MUltifrontal Massively Parallel Solver ~MUMPS! of Amestoy et al.19,20 The multifrontal method used is a direct method based on LU decomposition for the solution of sparse systems of linear equations with optimum fill in. The algorithms employed by MUMPS use a dynamic distributed task scheduling technique that permits numerical pivoting and the transfer of computational tasks to lightly loaded processors. The calculations have been performed on an SGI Origin 3800 parallel machine with 124 processors and on a Linux cluster with 22 processors.
Three different finite volume grids (M 1 M 3) have been used for each value of the blockage ratio considered in this paper. Each of the meshes has been generated algebraically and then smoothed by solving elliptic partial differential equations for the spatial variables $x _ { 1 }$ and $x _ { 2 }$ where derivatives are with respect to mapped variables in a space in which the mesh appears rectangular.21 For the present problem the physical grid is cut along the line $x _ { 2 } = 0$ from the rear stagnation point to the outlet before being mapped. The method of Steger and Sorenson21 allows both grid cell sizes and grid cell skewness to be controlled at the inner and outer boundaries. Meshes M 1 to M 3 are characterized by $i _ { \mathrm { m a x } }$ : The number of nodes on the surface of the cylinder, $k _ { \mathrm { m a x } } \colon$ The number of nodes along the line $x _ { 2 } = 0$ from the rear stagnation point on the cylinder to the outflow boundary and $k _ { \mathrm { w a l l } }$ : The number of nodes in the gap between the cylinder and a lateral wall. The values of $i _ { \mathrm { m a x } } , k _ { \mathrm { m a x } } , k _ { \mathrm { w a l l } }$ , and N ~the number of degrees of freedom! for the three meshes are supplied in Table I for different blockage ratios.
# III. FLOW PAST AN UNBOUNDED CIRCULAR CYLINDER
Flow around an unbounded circular cylinder is a classical benchmark problem for which a large number of numerical and experimental results exists. In this problem, and in approximation to the case of an unbounded flow domain, a circular cylinder of diameter D51.00 is placed symmetrically in a channel with blockage ratio $\beta = 0 . 0 1$ . For the numerical linear stability analysis the three meshes M 1 to M 3 were used, with $i _ { \mathrm { m a x } } , \ : k _ { \mathrm { m a x } }$ , and $k _ { \mathrm { w a l l } }$ as given in Table I. However, for unsteady time-dependent simulations we were only able to afford to use M1 and M2, the unsteady calculations on M3 proving to be prohibitively expensive. On the boundaries of the computational domain the conditions ~3! ~6! were imposed.
1308
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
TABLE II. Unbounded flow past a cylinder. Comparison of critical Reynolds numbers computed on M1M3 with others in the literature.
<table><tr><td></td><td>M1</td><td>M2</td><td>M3</td><td>Extrapolated</td><td>Jackson</td><td>Ding and Kawahara</td><td>Noack and Eckelmann</td><td>Chen et al.</td></tr><tr><td><eq>Re_{\text{crit1}}</eq></td><td>47.08</td><td>46.82</td><td>46.76</td><td>46.74</td><td>46.184</td><td>46.389</td><td>50</td><td>47.9</td></tr><tr><td><eq>St_{\text{crit1}}</eq></td><td>0.1163</td><td>0.1166</td><td>0.1167</td><td>0.1167</td><td>0.13804</td><td>0.12619</td><td>0.132</td><td>0.138</td></tr></table>
The linear stability analysis predictions of the critical Reynolds and Strouhal numbers corresponding to the onset of the first flow instability are supplied in Table II, as computed on meshes M 1 M 3. Also shown are the values of these quantities when extrapolated to zero mesh size. The extrapolated critical Reynolds number is found to be $R e _ { \mathrm { c r i t 1 } }$ 546.74 with a corresponding Strouhal number of $S t _ { \mathrm { c r i t 1 } }$ 50.1167. These values are compared with others in the literature in the same table. Although we find good agreement for the critical Reynolds number with the result of Ding and Kawahara22 $( R e _ { \mathrm { c r i t 1 } } = 4 6 . 3 8 9 )$ , and Jackson10 $( R e _ { \mathrm { c r i t 1 } }$ 546.184), the critical Strouhal number manifests wider scatter in the cited references. Issues such as the blockage ratios chosen, distances from the cylinder of the upstream and downstream boundaries, boundary conditions, mesh resolution and number of eigenvalues determined may be amongst the reasons for discrepancies in the numerical results. In addition to our mesh convergence study, we present a convergence study of the leading eigenvalues on mesh M 1 with the Krylov subspace dimension m and shift parameter l in Table III in order to show that our leading eigenvalue is essentially independent of both m and l for sufficiently large values of these two parameters. Although the leading eigenvalue converges very rapidly with a suitably chosen complex shift around the leading eigenvalue, it requires complex arithmetic. A real shift also dramatically improves the convergence of the leading eigenvalue while avoiding complex arithmetic which significantly increases the memory requirements during LU factorization. Our calculations show that a Krylov subspace dimension as low as 250 can be enough to compute the leading eigenvalue with l50.5060.00i while with no shift a Krylov subspace dimension larger than 1000 may be required.
The computed eigenspectrum on mesh M 3 at the critical Reynolds number is given in Fig. 2. Although we present the first 250 computed eigenvalues, calculations with higher Krylov subspace dimensions showed that only the leading eigenvalues and the eigenvalues around the origin were properly converged. As may be seen, the most dangerous eigenvalue pair is well separated from the rest of the spectrum, unlike the eigenspectrum for the two-dimensional liddriven cavity problem, for example.13 This is likely to be the reason for well-developed periodic flow observed far beyond the critical Reynolds number. Our critical Strouhal number ~0.1167! compares very well with the Strouhal number St 50.1179 computed at the same Reynolds number ~46.74! from a curve fit of the two-dimensional experimental data of Williamson.23 In addition, our critical Strouhal number agrees quite well with the Strouhal number $( S t = 0 . 1 1 8 3 4 )$ of the direct numerical simulation of Posdziech and Grundmann,24 even though their critical Strouhal number and ours were computed at two slightly different Reynolds numbers $( R e = 4 7 . 5 0$ and $R e = 4 6 . 7 4 ,$ , respectively!.
In Fig. 3 we present comparisons of the Strouhal number versus Reynolds number and in Fig. 4 comparisons of the drag coefficient $C _ { d } { = } F _ { x } / 0 . 5 U _ { \mathrm { m a x } } ^ { 2 } D$ versus Reynolds number, in further verification of our numerical scheme. Our Strouhal numbers are seen to be in very good agreement with those from the experimental work of Williamson23 for Reynolds numbers up to 200. Beyond this point the flow becomes three-dimensional and we do not expect to have agreement with the experimental results. Good agreement for St and Cd with results from the two-dimensional numerical simulations of Henderson25 and Posdziech and Grundmann24 may also be seen from Figs. 3 and 4. Our computed lift coefficients $C _ { l }$ at Reynolds numbers of 100 and 200 are 60.3333 and 60.6861 and these are in satisfactory agreement with Posdziech and Grundmanns values of 60.321 04 and 60.673 15, respectively. Although both Henderson25 and Posdziech and Grundmann24 used high-order spectral elements the differences between their two sets of results are due to the use of different blockage ratios in their calculations. However, as the Reynolds number increases the difference in their computed results becomes smaller. An interesting convergence study on the extension of the computational domain boundary is given by Posdziech and Grundmann24 at $R e = 2 0 0 . 0 0$ . The authors concluded that the lateral boundaries should be set at a distance of at least 70 diameters away in order to obtain a Strouhal number independent of yet smaller blockage ratios. At lower Reynolds number the effect of the lateral boundaries becomes more severe ~see Fornberg,26 for example!. In addition, Zisis and Mitsoulis27 showed that the convergence of the total drag at $R e { = } 0 . 0 0$ may be very poor as $\beta$ goes to zero.
TABLE III. Unbounded flow past a cylinder. Convergence of the leading eigenvalue at Re547.08 on M1 with the Krylov space dimension m and shift parameter l.
<table><tr><td>m</td><td><eq>\lambda = 0.00 \pm 0.00i</eq></td><td><eq>\lambda = 0.25 \pm 0.00i</eq></td><td><eq>\lambda = 0.50 \pm 0.00i</eq></td></tr><tr><td>250</td><td><eq>-2.499\ 530 \times 10^{-3} \pm 0.716\ 816</eq></td><td><eq>-2.362\ 255 \times 10^{-6} \pm 0.730\ 913</eq></td><td><eq>-2.666\ 849 \times 10^{-6} \pm 0.730\ 912</eq></td></tr><tr><td>500</td><td><eq>+3.029\ 965 \times 10^{-3} \pm 0.726\ 480</eq></td><td><eq>-2.668\ 530 \times 10^{-6} \pm 0.730\ 912</eq></td><td><eq>-2.668\ 473 \times 10^{-6} \pm 0.730\ 912</eq></td></tr><tr><td>1000</td><td><eq>+2.230\ 634 \times 10^{-4} \pm 0.729\ 683</eq></td><td><eq>-2.668\ 474 \times 10^{-6} \pm 0.730\ 912</eq></td><td><eq>-2.668\ 473 \times 10^{-6} \pm 0.730\ 912</eq></td></tr></table>
1309
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/2ebfe63f66b4b8ce81ece88d7582351efe95e77bfe7961743028e968b2f0baab.jpg)
FIG. 2. Reciprocal Ritz values for unbounded flow around a circular cylinder at Re546.76 computed on mesh M3 with Krylov space dimension m 5250 and shift parameter l50.5060.00i (b50.01).
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/8142e640c2a49c62f699d40ee8de33ff49c1ba9822687fe291c53f620349ae09.jpg)
FIG. 3. Comparison of Strouhal number versus Reynolds number for unbounded flow around a circular cylinder with other results in the literature: $( - ) .$ experimental work of Williamson ~Ref. 23!; ~¯!, numerical results of Henderson ~Ref. 25!; ~s!, numerical results of Posdziech and Grundmann ~Ref. 24!; ~h!, present ( b50.01, mesh M2!.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/a658e52f6cedb92a9f87c95d53663963190d92305c08c1bd7017c95c50292cc3.jpg)
FIG. 4. Comparison of drag coefficient versus Reynolds number for unbounded flow around a circular cylinder with other results in the literature: $( - ) ,$ , numerical results of Henderson ~Ref. 25!; ~s!, numerical results of Posdziech and Grundmann ~Ref. 24!; ~h!, present (b50.01, mesh M2!.
# IV. FLOW PAST A CONFINED CIRCULAR CYLINDER „0.1ËbË0.9…
Flow around a confined circular cylinder ~as opposed to the unbounded case! is an attractive benchmark problem in numerical simulation since it does not suffer from any of the difficulties associated with far-field boundary conditions ~particularly at very low Reynolds numbers! and permits the use of grid points more efficiently in smaller computational domains. Somewhat surprising, therefore, is that the only numerical linear stability analysis of Newtonian flow past a confined cylinder available in the literature would seem to be that of Chen $e t a l . ^ { 4 }$ These authors went no further than identifying the curve of neutral stability for the supercritical Hopf bifurcation at blockage ratios up to $\beta { = } 0 . 7$ . This is regrettable, because as we shall see in the paragraphs to follow, the linear stability properties of the flow become rich and therefore interesting at higher blockage ratios and Reynolds numbers than those considered by Chen et al. In the present study we consider two-dimensional flow at Reynolds numbers up to 280 and for blockage ratios in the range 0.10.9.
# A. Linear stability analysis
The curves of neutral stability computed from the GEVP with a Krylov subspace dimension $m = 2 5 0$ on mesh M2 for $\beta \in \left[ 0 . 1 , 0 . 9 \right]$ and $R e < 2 8 0$ are presented in Fig.5. Our discussion of these curves will focus on the five distinct curve sections labeled AB, BC, CD, CE, and $F G$ in the same
1310
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/7143dec957189df04b69e5cf9e774f69213002910deed08e6e9428081f7c6fee.jpg
FIG. 5. Change of critical Reynolds number corresponding to both Hopf and pitchfork bifurcations with blockage ratio $\beta ,$ computed on $M 2 . A C \mathrm { : }$ Curve of neutral stability for Hopf bifurcations about symmetric solution; CD: Transition curve from asymmetric vortex shedding ~smaller $\beta )$ to a steady asymmetric solution ~larger $\beta ) ; C E \colon$ : Neutral stability curve for pitchfork bifurcation of steady symmetric solution ~smaller $\beta )$ to a steady asymmetric state ~larger $\beta ) ; F G ;$ Hopf bifurcation of an asymmetric solution ~smaller $\beta )$ to asymmetric vortex shedding ~larger $\beta ) . ~ C$ is a co-dimension 2 point where Hopf and pitchfork bifurcations occur simultaneously.
TABLE IV. Convergence of critical Reynolds number for different blockage ratios with $\lambda = 0 . 0 0 \pm 0 . 0 0 i$ .
<table><tr><td rowspan="2">Curve section(see Fig. 5)</td><td rowspan="2"><eq>\beta</eq></td><td rowspan="2">m</td><td colspan="2">M1</td><td colspan="2">M2</td><td colspan="2">M3</td><td colspan="2">Chen et al.</td></tr><tr><td><eq>Re_{\text{crit}}</eq></td><td><eq>St_{\text{crit}}</eq></td><td><eq>Re_{\text{crit}}</eq></td><td><eq>St_{\text{crit}}</eq></td><td><eq>Re_{\text{crit}}</eq></td><td><eq>St_{\text{crit}}</eq></td><td><eq>Re_{\text{crit}}</eq></td><td><eq>St_{\text{crit}}</eq></td></tr><tr><td rowspan="12">AC</td><td>0.10</td><td>500</td><td>51.00</td><td>0.1206</td><td>50.81</td><td>0.1210</td><td>50.75</td><td>0.1211</td><td>51.77</td><td>0.1116</td></tr><tr><td>0.20</td><td>250</td><td>69.86</td><td>0.1559</td><td>69.43</td><td>0.1566</td><td>69.34</td><td>0.1567</td><td>69.93</td><td>0.1559</td></tr><tr><td>0.30</td><td>250</td><td>95.24</td><td>0.2079</td><td>94.56</td><td>0.2090</td><td>94.40</td><td>0.2093</td><td>94.85</td><td>0.2085</td></tr><tr><td>0.50</td><td>250</td><td>125.23</td><td>0.3369</td><td>124.09</td><td>0.3393</td><td>123.75</td><td>0.3399</td><td>124.58</td><td>0.3382</td></tr><tr><td>0.70</td><td>250</td><td>111.32</td><td>0.4714</td><td>110.29</td><td>0.4752</td><td>110.04</td><td>0.4762</td><td>111.04</td><td>0.4744</td></tr><tr><td>0.80</td><td>250</td><td>111.45</td><td>0.5324</td><td>110.24</td><td>0.5363</td><td>109.98</td><td>0.5374</td><td></td><td></td></tr><tr><td>0.84</td><td>250</td><td>114.44</td><td>0.5530</td><td>113.69</td><td>0.5568</td><td></td><td></td><td></td><td></td></tr><tr><td>0.84</td><td>250</td><td>130.92</td><td>0.5510</td><td>126.64</td><td>0.5557</td><td></td><td></td><td></td><td></td></tr><tr><td>0.80</td><td>250</td><td>148.24</td><td>0.5324</td><td>144.19</td><td>0.5383</td><td>143.29</td><td>0.5398</td><td></td><td></td></tr><tr><td>0.76</td><td>250</td><td>169.75</td><td>0.5115</td><td>165.49</td><td>0.5186</td><td></td><td></td><td></td><td></td></tr><tr><td>0.72</td><td>250</td><td>198.94</td><td>0.4872</td><td>193.25</td><td>0.4955</td><td></td><td></td><td></td><td></td></tr><tr><td>0.70</td><td>250</td><td>218.03</td><td>0.4737</td><td>211.01</td><td>0.4827</td><td>209.40</td><td>0.4851</td><td></td><td></td></tr><tr><td rowspan="7">CE</td><td>0.70</td><td>250</td><td>221.87</td><td></td><td>216.75</td><td></td><td>215.53</td><td></td><td></td><td></td></tr><tr><td>0.72</td><td>250</td><td>210.17</td><td></td><td>205.95</td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>0.76</td><td>250</td><td>190.65</td><td></td><td>187.01</td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>0.80</td><td>250</td><td>173.97</td><td></td><td>169.49</td><td></td><td>168.29</td><td></td><td></td><td></td></tr><tr><td>0.84</td><td>250</td><td>161.57</td><td></td><td>158.15</td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>0.88</td><td>250</td><td>152.93</td><td></td><td>149.84</td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>0.90</td><td>250</td><td>147.78</td><td></td><td>145.27</td><td></td><td>144.70</td><td></td><td></td><td></td></tr><tr><td rowspan="4">CD</td><td>0.68</td><td>250</td><td>237.33</td><td>0.4596</td><td>231.06</td><td>0.4695</td><td></td><td></td><td></td><td></td></tr><tr><td>0.66</td><td>250</td><td>259.55</td><td>0.4477</td><td>253.08</td><td>0.4566</td><td></td><td></td><td></td><td></td></tr><tr><td>0.64</td><td>250</td><td>284.56</td><td>0.4351</td><td>278.01</td><td>0.4441</td><td></td><td></td><td></td><td></td></tr><tr><td>0.62</td><td>250</td><td>312.66</td><td>0.4235</td><td>306.27</td><td>0.4326</td><td></td><td></td><td></td><td></td></tr><tr><td rowspan="6">FG</td><td>0.82</td><td>250</td><td></td><td></td><td>319.80</td><td>0.4664</td><td></td><td></td><td></td><td></td></tr><tr><td>0.82</td><td>250</td><td></td><td></td><td>227.44</td><td>0.4719</td><td></td><td></td><td></td><td></td></tr><tr><td>0.84</td><td>250</td><td>331.02</td><td>0.4954</td><td></td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>0.84</td><td>250</td><td>214.00</td><td>0.4794</td><td>194.30</td><td>0.4979</td><td></td><td></td><td></td><td></td></tr><tr><td>0.88</td><td>250</td><td>180.43</td><td>0.5097</td><td>171.28</td><td>0.5234</td><td></td><td></td><td></td><td></td></tr><tr><td>0.90</td><td>250</td><td>169.44</td><td>0.5146</td><td>162.82</td><td>0.5202</td><td>160.50</td><td>0.5212</td><td></td><td></td></tr></table>
1311
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/287adf6266b667377c0fd8a7130dd15e250b88a5f1e1cc4d15a74fec125c4bd7.jpg)
FIG. 6. Change of base flow with critical Reynolds number and blockage ratio b, computed on M 2.
figure. The critical Reynolds numbers and corresponding Strouhal numbers ~where appropriate! for points on each curve section and computed on meshes M 1 M 3 are supplied in Table IV.
# 1. Curve section AB
Validation of our numerical stability analysis and direct numerical simulations for flow past an unbounded cylinder $\beta { \approx } 0$ has been described in Sec. II. For the confined cylinder problem we have been able to compare our critical Reynolds and Strouhal numbers for the bifurcation for the symmetric state with the values for these quantities computed by Chen et $a l . ^ { 4 }$ The available results $( 0 . 1 { \leqslant } \beta { \leqslant } 0 . 7 )$ of the critical Reynolds number calculations of Chen et al. are plotted in Fig. 5 and agreement between our results and theirs over this limited section of the curve AB is excellent. Similarly excellent agreement in the computed Strouhal numbers was seen over the same range of blockage ratios, both our results and those of Chen et al. revealing a monotonic increase in the critical Strouhal number with the blockage ratio ~see Table IV!.
Up to a blockage ratio b50.5 Table IV and the neutral stability curve AB of Fig. 5 indicate that the flow becomes more stable to two-dimensional infinitesimal disturbances as the blockage ratio increases. All along AB the flow loses stability to a Hopf bifurcation and the Strouhal number over this range of blockage ratios is increasing. Between $\beta$ 50.75 and 0.85 it may be seen from Table IV and section AB of Fig. 5 that the flow restabilizes slightly leading up to point B $( \beta = \beta _ { B } \approx 0 . 8 5 5 )$ .
In Fig. 6 we show the streamlines of the steady base flow at seven points on the neutral stability curves. Those corresponding to point 1 are typical of those at points on and below curve AB in Fig. 5 where the steady solution is symmetric and the only recirculatory region observed is the vortex pair immediately in the wake of the cylinder itself. That is, for solutions corresponding to parameter space on and below AB in Fig. 5 no flow separation on the walls is observed.
# 2. Curve sections BC and CE
In Fig. 5 section BC represents the part of the critical $\beta - R e$ curve on which the time-dependent state (symmetric periodic oscillations!, passed into by crossing AB in the direction of increasing Reynolds number, restabilizes to a symmetric steady state once more. Further increases in the Reynolds number for blockage ratios in the range $\beta _ { C }$ to $\beta _ { B }$ or for choices of $\beta$ greater than $\beta _ { B }$ may result in the steady symmetric solution becoming unstable to two-dimensional perturbations via a pitchfork bifurcation into one of two asymmetric states. The curve of neutral stability for this transition is labeled C E in Fig. 5.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/75629c20cd5f0d463a1b12aa369432073b000bcf448ce0e7db574e93ffa94d12.jpg)
FIG. 7. Streamlines of unstable symmetric and stable asymmetric solutions at $R e = 1 5 0 . 0 0$ for $\beta = 0 . 9$ computed on M3.
1312
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/05f2d969d57502c4b4e17ff616bcfdd0cda0ffb78cdca54fbd0f0793f9a1307d.jpg)
(a)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/00c091674382b75e5a9f5e8480ecbc291a8fecf5933f72c4f5ca52290daf75a3.jpg)
(b)
FIG. 8. Streamlines for the disturbance velocity corresponding to ~a! the first and ~b! the second leading eigenvectors at $R e = 1 4 4 . 7 0$ for b50.9 computed on M3.
The point C is a co-dimension 2 point where Hopf and pitchfork bifurcations occur simultaneously. We are able to estimate the coordinates $( \beta _ { C } , R e _ { C } )$ of this point by considering it to be the point of intersection of two straight lines drawn through the pairs of points on AC, CD, and EC that correspond to $\beta = 0 . 6 8$ and 0.7. Since b50.68 is outside the range of blockage ratios corresponding to EC the ordinate for this value of $\beta$ is computed to be that at which the leading real eigenvalue in the spectrum of linear perturbations about the ~linearly unstable! steady symmetric solution is at the origin. The critical Reynolds numbers at b50.68 and 0.7 computed on curves AC, CD, and EC are detailed in Table IV and lead to the estimate $( \beta _ { C } , R e _ { C } ) = ( 0 . 6 8 7 , 2 2 4 . 1 4 2 )$ .
The occurrence of the transition from a symmetric steady state to an asymmetric one on CE is preceded ~in Reynolds number! by the appearance in the streamlines of a pair of downstream separation bubbles on the walls. For example, in the eigenspectrum we observed that for b50.9 and at Reynolds numbers increasing up to approximately 110 the complex conjugate pair of leading eigenvalues moved in the direction of the positive real part of the spectrum. At a Reynolds number of around 110 separation bubbles appeared on the walls and with the appearance of the separation bubbles the leading eigenpair now started to move in the opposite direction while the eigenvalue on the real axis having largest real part moved right towards the origin. In Fig. 6 we plot the streamlines on BC at points 2 and 3 to demonstrate how the size of these recirculatory regions as well as their attachment distance downstream of the cylinder increase as C is approached along the curve BC. In the context of a circular cylinder near a plane boundary such downstream separation bubbles have been observed both experimentally8 and numerically9 for cylinders sufficiently close to the boundary.
To gain further insight into the flow transition from steady symmetric flow ~between BC and CE and for $\beta$ $\geqslant _ { \beta _ { B } ) }$ via a pitchfork bifurcation to steady asymmetric flow ~between DE and FG) we plot in Fig. 7 the streamlines of two solutions at a Reynolds number of 150 and blockage ratio of 0.9. It may be seen from Fig. 5 that this point lies between E $( R e = 1 4 4 . 7 , \beta = \beta _ { E } = 0 . 9 )$ and $\textit { F } ( R e = 1 6 0 . 5 , \beta$ $= \beta _ { F } = 0 . 9 )$ . Thus, the symmetric solution in the upper plot in Fig. 7 is linearly unstable and the lower plot represents the streamlines of one of the stable asymmetric solutions. The disturbance velocity v in equation ~II! is, of course, solenoidal. In Fig. 8~a! we plot the streamlines associated with the disturbance field and corresponding to the dominant eigenvalue at E. The addition of a multiple of the eigenvector shown in Fig. 8~a! to the symmetric steady base flow leads to one or other of the two asymmetric steady flows, the choice dependent on the direction of circulation around the symmetric streamlines in Fig. 8~a!. An anti-clockwise direction leads to reinforcement of the lower recirculation region and reduction in the size of the upper bubble. A clockwise direction has the opposite effect. The drag coefficient associated with the steady asymmetric solution shown in Fig. 7 is slightly larger than that of the corresponding unstable symmetric one.
1313
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
TABLE V. Comparison with the results of Zisis and Mitsoulis ~Ref. 27! and Liu et al. ~Ref. 35! of computed total drag at Re50.0 for different blockage ratios b.
<table><tr><td><eq>\beta</eq></td><td>M1</td><td>M2</td><td>M3</td><td>Extrapolated</td><td>Zisis and Mitsoulis</td><td>Liu et al.</td></tr><tr><td>0.1</td><td>8.9125</td><td>8.9100</td><td>8.9089</td><td>8.9080</td><td>8.912</td><td>8.9067</td></tr><tr><td>0.2</td><td><eq>1.6221 \times 10^{1}</eq></td><td><eq>1.6215 \times 10^{1}</eq></td><td><eq>1.6211 \times 10^{1}</eq></td><td><eq>1.6200 \times 10^{1}</eq></td><td>...</td><td>...</td></tr><tr><td>0.3</td><td><eq>2.7923 \times 10^{1}</eq></td><td><eq>2.7910 \times 10^{1}</eq></td><td><eq>2.7902 \times 10^{1}</eq></td><td><eq>2.7886 \times 10^{1}</eq></td><td>...</td><td>...</td></tr><tr><td>0.5</td><td><eq>8.8354 \times 10^{1}</eq></td><td><eq>8.8294 \times 10^{1}</eq></td><td><eq>8.8263 \times 10^{1}</eq></td><td><eq>8.8227 \times 10^{1}</eq></td><td><eq>8.8207 \times 10^{1}</eq></td><td><eq>8.8227 \times 10^{1}</eq></td></tr><tr><td>0.7</td><td><eq>4.0347 \times 10^{2}</eq></td><td><eq>4.0318 \times 10^{2}</eq></td><td><eq>4.0299 \times 10^{2}</eq></td><td><eq>4.0257 \times 10^{2}</eq></td><td>...</td><td>...</td></tr><tr><td>0.9</td><td><eq>7.7057 \times 10^{3}</eq></td><td><eq>7.6988 \times 10^{3}</eq></td><td><eq>7.6959 \times 10^{3}</eq></td><td><eq>7.6941 \times 10^{3}</eq></td><td>...</td><td>...</td></tr></table>
# 3. Curve section CD
The section of the neutral stability curves labeled CD in Fig. 5 represents a transition curve with increasing b from periodic vortex shedding to the left of this curve ~smaller b! to a steady asymmetric state ~larger b!. At point C the steady solution is symmetric but moving along the curve CD towards D causes the growth of one of the recirculatory wall regions relative to the other.
Since all curves of neutral stability in Fig. 5 have been determined using a linear stability analysis about a steady flow as described in Sec. II we have been unable to plot the precise boundaries of the transition region that must exist from symmetric oscillations to asymmetric oscillations as the curve CD is approached in parameter space from the left ~smaller b!.
# 4. Curve section FG
Finally, the steady asymmetric solution of the region between curves DE and FG can become unstable via a Hopf bifurcation to asymmetric vortex shedding ~see discussion in Sec. IV B!. The transition curve is plotted as FG and the streamlines of two steady base flows at points 6 and 7 of this curve are shown in Fig. 6. In Fig. 8~b! we plot the streamlines of the disturbance velocity corresponding to the leading complex eigenvalue pair in the spectrum at E. It is the addition of a mode similar in form to this ~but at a higher Reynolds number! that leads to vortex shedding about the asymmetric state. If the Reynolds number is further increased on the curve FG additional separation bubbles appear on the wall further downstream. We also remark that on the curve FG the separation bubble just behind the cylinder is generally shorter and more rounded than that computed on the curves BC and CD.
A strong parallel is thus seen in the present results with those of numerous other authors ~see, for example, those of Battaglia et al., 28 Drikakis,29 Fearn et al., 30 Hawa and Rusak,31 Mishra and Jayaraman,32 and Oliveira33! for flows through both two-dimensional and three-dimensional symmetric expansions. All the cited authors report that steady flow with symmetric recirculatory regions through an expansion geometry encounters a supercritical pitchfork bifurcation at a certain Reynolds number ~dependent, of course, on the channel geometry! and becomes asymmetric. The difference in the streamwise attachment length of the two recirculatory regions ~still in the steady regime! becomes larger as the Reynolds number is further increased from the critical value. In the two-dimensional case, increasing the expansion ratio decreases the critical Reynolds number.28 In the threedimensional case, Schrek and Scha¨fer34 found that fixing the expansion ratio at 1:3 and decreasing the width of the channel relative to the downstream channel height from ` ~twodimensional flow! through 5 to 2 resulted in a stabilization of the flow.
It will be noted from Table IV that for the choices of β=0.82 and 0.84 critical Reynolds numbers of 319.8 and 331.02, respectively, are added to those that are shown in Fig. 5. This is to indicate how the curve FG would continue if the range of Reynolds numbers were to be extended in Fig. 5, although at these higher Reynolds numbers it is highly unlikely that the flow would in reality remain twodimensional.
# B. Direct numerical simulations
A few verifications were performed on the results of our direct numerical simulations in order to establish their reliability. First, Strouhal numbers near the critical Reynolds numbers corresponding to the onset of periodic vortex shedding and computed with direct numerical simulation were found to be in good agreement in a couple of cases with those predicted on the basis of the eigenvalue analysis of
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/d233f703836ce940995bdda3c8a82c47e1ff833a385141d00b282e58fd86bc06.jpg)
FIG. 9. Computed drag coefficient versus Reynolds number at blockage ratios b5 0.1, 0.3, 0.5, 0.7, and 0.9.
1314
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/12341d37bdf5380be588cb865d5ef03c4f9b19a1b980368307fd6ba2863b74f2.jpg)
FIG. 10. Change of time-dependent flow with Reynolds number and blockage ratio b, computed on M 2.
Sec. IV A. For example, at a blockage ratio of $\beta = 0 . 3$ and at $R e = 1 0 0$ the corresponding Strouhal number was computed from the lift coefficient data over extended time intervals and found to be equal to 0.2115. This compares well with the value of 0.2090 supplied in Table IV and computed at $R e _ { \mathrm { c r i t } } { = } 9 4 . 5 6$ . Second, we present in Table V results of computations using the three meshes M 1 to M 3 of the drag on the cylinder for various blockage ratios. These are compared with the recent numerical data of Zisis and Mitsoulis27 and Liu $e t a l . ^ { 3 5 }$ and the agreement is convincing. It should be further added that the drag result of Liu et al. for $\beta { = } 0 . 1$ is within 0.007% of the theoretically predicted value of Faxe´n.36 The drag coefficient versus Reynolds number is given in Fig. 9 for several blockage ratios. At all the blockage ratios considered here the drag coefficient behaves like $1 / R e$ at low Reynolds numbers. As the blockage ratio increases the range of values of Re over which this remains true gets smaller.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/b31895fa9a9e0b82a39e0418e3e3135fb32c8f0852074544f2d8ce28feebbf67.jpg)
(a)t=0
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/262f5e179db0fbf8d4870af077f0626ec8e6168bd5599c0a337dceb33eb3be82.jpg)
(b)t=T/3
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/ebdc9e3ddd6ea5026c354e96991056ed6d1bec23bcb3c2ad94c5b576092c4125.jpg)
(c)t=2T/3
FIG. 11. Vorticity contours of the periodic flow at $R e = 2 0 0 . 0 0$ and b50.5 ~point 1 in Fig. 10! computed on M2. t50 corresponds to the solution having minimum lift coefficient and the period T'2.85.
1315
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/622e2044b5a845dfa57c24fbc927115b9b46d78d67a7ec37c7fb69fd4ecf2d04.jpg)
(a)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/9801b6a1c33530c50f10cd592247241a75f911760359ed95fae9f382d1109ea5.jpg)
(b)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/b6b583a99360c4b1b31048d0a9ce72f2ef778a6123096cdc6685bc886930154c.jpg)
c
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/2f9cd0d17f273a51ccd9b9b38e545ae60577c7f5e455880d89c09347a8f55688.jpg)
(d
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/aa0934f80e02e83e94d04b40a92ef195365bc4147a95504a53717935ecbb1d8b.jpg)
(e
FIG. 12. Phase space plots of lift and drag coefficients parametrized with nondimensional time $( t U _ { \mathrm { m a x } } / D )$ , computed on M2. ~a! $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 5$ ~point 1 in Fig. 10!, ~b! $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 7$ ~point 2 in Fig. 10!, ~c! $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 9$ ~point 3 in Fig. 10!, ~d! $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 8$ ~point 4 in Fig. 10!, ~e! $R e = 1 6 0 . 0 0$ and $\beta { = } 0 . 8$ ~point 5 in Fig. 10!.
1316
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/972de0ff38c7199d2570ec69fe1cca797a8cf1bb45cb040ed1e180ade1cbe744.jpg)
(a)t=0
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/c3fb2874046d3d5bf9aa21970e57ab550fc5a1f64f51a91df68fae3ced395ec7.jpg)
(b) $t = T / 3$
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/a98e733df397f34c4928336a9cd78c6d2cceca710667a03e7eb8cf964967e9ad.jpg)
$( \mathrm { c } ) \ t = 2 T / 3$
FIG. 13. Vorticity contours of the periodic flow at $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 7$ ~point 2 in Fig. 10! computed on $M 2 . \ t = 0$ corresponds to the solution having minimum lift coefficient and the period T'2.05.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/13c02ba53cc45572192a87422b139e2af874d39210315fdfaad2a5ca00d405e9.jpg)
(a)t=0
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/ceb7a2030bd393ae3d8f31ef2fd72448b2ff2b752a4fb059497eb755ca59096e.jpg)
$( \mathbf { b } ) \ t = T / 3$
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/e56b98879b35815a3fda98b8d57ca23380a02dfeb8c789678fe23588d247ccd5.jpg)
$( \mathrm { c } ) \ t = 2 T / 3$
FIG. 14. Vorticity contours of the periodic solution at $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 9$ ~point 3 in Fig. 10! computed on $M 2 , t = 0$ corresponds to the solution having minimum lift coefficient and the period T'1.88.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/4dc64efe900b5af5a25bce3e060c5c16b4c6f699a9df37999d9e220cd4d8b0c1.jpg)
(a $t = 0$
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/e35ad26dec380aa8fa4266acca99b64c55a17870550ab959a65edf80867e2638.jpg)
(b) $t = T / 3$
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/163b2314375abbc1b96879dc5cfcee23a059a8fd33182934b777a164f47e687f.jpg)
$( \mathrm { c } ) \ t = 2 T / 3$
FIG. 15. Vorticity contours of the periodic solution at $R e = 2 0 0 . 0 0$ and $\beta { = } 0 . 8$ ~point 4 in Fig. 10! computed on $M 2 , t = 0$ corresponds to the solution having minimum lift coefficient and the period $T { \approx } 1 . 8 1 5 .$ .
1317
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/c25426e8a1d9471b41be10b9e7ebaeaf9a59bd02d0bc40348e02fb98367bdef6.jpg)
(at=0
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/68db3e9f4b1ce4c61efa243540ef557d41cee94ae3031b3a3b0c5ed15bb0628c.jpg)
(b)t=T/3
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-11/e29ea199-8ede-444f-8a3a-062a2f820b92/4d5bbdbee86dc15076403fb4d042a4efaf373eab07a11bb054b9bd4ae148a195.jpg)
(c)t=2T/3
FIG. 16. Vorticity contours of the periodic solution at $R e = 1 6 0 . 0 0$ and b50.8 ~point 5 in Fig. 10! computed on M2. t50 corresponds to the solution having minimum lift coefficient and the period T'1.806.
In order to elucidate the variation in the critical Reynolds number with blockage ratio observed from the eigenvalue analysis of Sec. IV A we used direct numerical simulation to investigate the wake structure at five different locations in the $\beta - R e$ parameter space and labeled 15 in Fig. 10. As an aside, and before discussing our results in detail, we simply note that at blockage ratios $\beta { < } 0 . 5$ direct numerical simulations revealed that the vortex shedding over the cylinder was quite similar to that of the unbounded case, although the vortex street is shorter due to shear in the free stream. We also note that although blockage effects are expected to delay transition of the cylinder wake to threedimensional flow, it is possible that at some of the points labeled 14 in Fig. 10 the local velocity is so high that a three-dimensional transition occurs for the highly decelerated, separated boundary layers on the channel walls. Verification of this will have to await fully three-dimensional simulations, however.
Time-dependent solutions are presented in Fig. 11 for $\beta = 0 . 5$ at a Reynolds number of 200 (point 1 of Fig.10).At this Reynolds number the flow has lost its stability to twodimensional disturbances and has become time-periodic with ${ S t = 0 . 3 5 1 3 }$ , which is higher than for unbounded flow around a circular cylinder $( S t = 0 . 1 9 7 7 )$ . The sequences of three snapshots in Fig. 11 are taken at times t50, T/3 and 2T/3, the nondimensional period T being approximately equal to 2.85 and determined from the lift coefficient data over long time periods. t50 corresponds to a minimum in the lift coefficient once fully periodic vortex shedding is established. It may be seen from Figs. 11~a!11~c! that vortex shedding occurs both from the cylinder and the channel walls. As these vortices move downstream the trajectories of clockwise vortices shed from the upper part of the cylinder cross those having opposite sign ~and shed from the lower part of the cylinder! so that wall proximity effects are seen to give rise to a reverse von Ka´rma´n street. The same phenomenon has been documented by other authors.9 In Fig. 12~a! we show the $C _ { d } { - } C _ { l }$ phase space plot at point 1 of Fig. 10, once fully periodic conditions have been established. The average lift is zero and $C _ { d }$ and $C _ { l }$ are both symmetric in the rising and falling parts of each cycle.
At a blockage ratio of 0.7 the flow is periodic at a Reynolds number of 200.00 ~point 2 of Fig. 10! with St 50.4881. The vorticity contours at times t50, T/3 and 2T/3 are shown in Figs. 13~a!13~c!, with the period T'2.05. Unlike in the case of $\beta = 0 . 5$ described in the paragraph above, vortex shedding from the cylinder seems to be almost suppressed at this Reynolds number, due to the proximity of point 2 to the curve of neutral stability BC. However, there are very weak vortices shed from both upper and lower lateral walls. These are well separated from each other and their interaction is weak. The phase space plot of the lift and drag coefficients at this blockage ratio are shown in Fig. 12~b!. Although the time-averaged value of the drag coefficient $C _ { d }$ has increased it is notable that the amplitude of the $C _ { d }$ oscillations is an order of magnitude less than that seen at $\beta$ 50.5.
At a blockage ratio of 0.9 and $R e > 1 6 0 . 5$ the flow is unsteady and very strong vortices are shed from both the cylinder and the walls. The computed streamlines and vorticity contours at $R e = 2 0 0 . 0 0$ ~point 3 of Fig. 10! are shown in Fig. 14, each separated from the previous in the series by a third of a period T/3. At this Reynolds number the flow is periodic with St50.5314 (T'1.88) and this compares reasonably well ~see Table IV! with the Strouhal number of 0.5202 computed from the GEVP on mesh M 2 at the critical Reynolds number $R e _ { \mathrm { c r i t } } { = } 1 6 2 . 8 2$ . Vortices having the same sign merge just behind the cylinder and are then transported downstream. However, the vortex street formed behind the cylinder is quite different from the well-known von Ka´rma´n street in that very strong opposite-sign vortices with smaller structure move downstream and interact with the wall, creating strong vortices there. Although the streamlines of the eigenvector shown in Fig. 8~b! will be slightly modified by the time the Reynolds number reaches 200, the size of the cellular structures in the wake of the eigenvector are close to what is seen in the direct numerical solution for the vorticity in Fig. 14. Oscillations are now about an asymmetric state and lead to drastic increases in both the lift and drag coefficient values. Their variation over a cycle with dimensionless time is shown in Fig. 12~c!. In this figure it may be seen that not only is the time-averaged value of $C _ { d }$ greater than for the two previous blockage ratios considered, but the amplitudes of oscillation of both coefficients has dramatically increased after a tendency observed up to $\beta { \approx } 0 . 7 5$ of successively diminishing amplitudes. We also note for the first time that the lift coefficient is no longer symmetric in the rising and falling parts of each cycle ~the figure of $8 ^ { \circ }$ is distorted!, although the average value of $C _ { l }$ over one cycle is zero.
1318
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
A numerical investigation of wall effects
The flow behavior in the parametric region between curves BD and FG is particularly interesting since although the steady base flow solutions are linearly stable they appear to be finitely unstable. Although our primary concern in this paper is the study of wall effects on the linear stability of flow past a confined cylinder, in Fig. 10 we plot the instantaneous streamlines of two pairs of different solutions, both pairs being for a blockage ratio $\beta { = } 0 . 8$ but corresponding to two different values of $R e \colon$ at point 4 $R e = 2 0 0$ whereas at point 5 $R e = 1 6 0$ . Shown in the uppermost plots at points 4 and 5 are the steady and linearly stable solutions, but these may be sent into permanently unsteady states by running, for example, the time-dependent code with the geometrically rescaled steady base flow of point $2 \ ( \beta = 0 . 7 , R e = 2 0 0 )$ as the initial guess. Snapshots of unsteady flow at times t $= 0 , T / 3$ , and 2T/3 at point 4 are presented in Figs. 15~a! 15~c!, with the period $T { \approx } 1 . 8 1 5$ . Unlike the other unsteady cases considered so far in this paper, the recirculation regions on the upper wall are much larger than those on the lower wall and they can move far downstream. Additionally, the recirculatory region at the lower part of the cylinder is larger than that at the upper part. This asymmetry may be seen from the phase space plot in Fig. 12~d!. The time average of the lift coefficient is no longer zero and the lift curve is asymmetric in the rising and falling part of each cycle. However, if the Reynolds number is chosen equal to 160 ~point 5 in Fig. 10! the flow becomes symmetric again since point 5 is below the curve CE. The computed vorticity contours are given in Fig. 16 with T'1.806. At this point the flow structure is quite similar to that of point 2 with vortex shedding from the upper and lower walls. The lift and drag coefficients which are supplied in the phase space plot in Fig. 12~e! are also similar to those of point 2 with zero time average of the lift coefficient.
Numerical experiments at a blockage ratio of 0.9 and a Reynolds number of 500 indicated that the flow had become chaotic. However, it seems unlikely that the flow is still twodimensional at this Reynolds number and presentation of our results will have to await a fully three-dimensional analysis.
# V. CONCLUSIONS
In this paper we have computed with greater accuracy and over a larger range of blockage ratios than has proved possible in the past the effects on the drag and linear stability of lateral wall proximity for flow past a cylinder at Reynolds numbers up to 280.
Some of the rich and complex dynamics of the system for sufficiently high Reynolds numbers and blockage ratios have been uncovered and discussed. In particular, we have found that for $R e \leqslant 2 8 0$ and $\beta { \leqslant } 0 . 9$ there are ~at least! three separate curves of neutral stability: ~a! Hopf bifurcation of a symmetric state, ~b! pitchfork bifurcation of a symmetric state to one of two asymmetric states, and ~c! Hopf bifurcation of an asymmetric state leading to asymmetric oscillations thereafter. In addition, we have drawn attention to a transition region from symmetric vortex shedding to asymmetric vortex shedding with increasing blockage ratio. Further increases in the blockage ratio ~crossing CD in Fig. 10! leads to restabilization to a steady asymmetric solution.
A co-dimension 2 point where pitchfork and Hopf bifurcations occur simultaneously has been identified and a region in parameter space ~either side of the locus of the pitchfork bifurcation! seems to exist where the steady solution is linearly stable but unstable to finite two-dimensional perturbations.
# ACKNOWLEDGMENTS
The authors wish to thank Peter Monkewitz for sharing with them interesting and illuminating insights into the primary and secondary instability mechanisms. The work of the first author was supported by the Swiss National Science Foundation, Grant No. 21-61865.00.
1C. H. K. Williamson, Vortex dynamics in the cylinder wake, Annu. Rev. Fluid Mech. 28, 477 ~1996!.
2P. Anagnostopoulos, G. Iliadis, and S. Richardson, Numerical study of the blockage effect on viscous flow past a circular cylinder, Int. J. Numer. Methods Fluids 22, 1061 ~1996!.
3M. Coutanceau and R. Bouard, Experimental determination of the main features of the viscous flow in the wake of a circular cylinder in uniform translation. Part 1. Steady flow, J. Fluid Mech. 79, 231 ~1977!.
4J.-H. Chen, W. G. Pritchard, and S. J. Tavener, Bifurcation for flow past a cylinder between parallel planes, J. Fluid Mech. 284, 23 ~1995!.
5M. Behr, S. Hastreiter, S. Mittal, and T. E. Tezduyar, Incompressible flow past a circular cylinder: Dependence of the computed flow field on the location of the lateral boundaries, Comput. Methods Appl. Mech. Eng. 123, 309 ~1995!.
6P. K. Stansby and A. Slaouti, Simulation of vortex shedding including blockage by the random-vortex and other methods, Int. J. Numer. Methods Fluids 17, 1003 ~1993!.
7C. Lei, L. Cheng, and K. Kavanagh, Re-examination of the effect of a plane boundary on force and vortex shedding of a circular cylinder, J. Wind. Eng. Ind. Aerodyn. 80, 263 ~1999!.
8P. W. Bearman and M. M. Zdravkovich, Flow around a circular cylinder near a plane boundary, J. Fluid Mech. 89, 33 ~1978!.
9L. Zovatto and G. Pedrizzetti, Flow around a circular cylinder between parallel walls, J. Fluid Mech. 440, 1 ~2001!.
10C. P. Jackson, A finite-element study of the onset of vortex shedding in flow past variously shaped bodies, J. Fluid Mech. 182, 23 ~1987!.
11M. Sahin, Solution of the incompressible unsteady NavierStokes equations only in terms of the velocity components, Int. J. Comput. Fluid Dyn. 17, 199 ~2003!.
12M. Sahin and R. G. Owens, A novel fully-implicit finite volume method applied to the lid-driven cavity problem. Part I. High Reynolds number
1319
09 May 2026 02:44:09
Phys. Fluids, Vol. 16, No. 5, May 2004
M. Sahin and R. G. Owens
flow calculations, Int. J. Numer. Methods Fluids 42, 57 ~2003!.
13M. Sahin and R. G. Owens, A novel fully-implicit finite volume method applied to the lid-driven cavity problem. Part II. Linear stability analysis, Int. J. Numer. Methods Fluids 42, 79 ~2003!.
14A. Kourta, M. Braza, P. Chassaing, and H. Haminh, Numerical analysis of a natural and excited two-dimensional mixing layer, AIAA J. 25, 279 ~1987!.
15G. Jin and M. Braza, A nonreflecting outlet boundary condition for incompressible unsteady NavierStokes calculations, J. Comput. Phys. 107, 239 ~1993!.
16W. E. Arnoldi, The principle of minimized iterations in the solution of the matrix eigenvalue problem, Q. Appl. Math. 9, 17 ~1951!.
17Y. Saad, Variations on Arnoldis method for computing eigen elements of large unsymmetric matrices, Linear Algebr. Appl. 34, 269 ~1980!.
18R. Natarajan, An Arnoldi-based iterative scheme for nonsymmetric matrix pencils arising in finite element stability problems, J. Comput. Phys. 100, 128 ~1992!.
19P. R. Amestoy, I. S. Duff, and J.-Y. LExcellent, Multifrontal parallel distributed symmetric and unsymmetric solvers, Comput. Methods Appl. Mech. Eng. 184, 501 ~2000!.
20P. R. Amestoy, I. S. Duff, J. Koster, and J.-Y. LExcellent, A fully asynchronous multifrontal solver using distributed dynamic scheduling, SIAM J. Matrix Anal. Appl. 23, 15 ~2001!.
21J. L. Steger and R. L. Sorenson, Automatic mesh-point clustering near a boundary in grid generation with elliptic partial differential equations, J. Comput. Phys. 33, 405 ~1979!.
22Y. Ding and M. Kawahara, Three-dimensional linear stability analysis of incompressible viscous flows using the finite element method, Int. J. Numer. Methods Fluids 31, 451 ~1999!.
23C. H. K. Williamson, Oblique and parallel modes of vortex shedding in the wake of a circular cylinder at low Reynolds numbers, J. Fluid Mech. 206, 579 ~1989!.
24O. Posdziech and R. Grundmann, Numerical simulation of the flow around an infinitely long circular cylinder in the transition regime, Theor. Comput. Fluid Dyn. 15, 121 ~2001!.
25R. D. Henderson, Detail of the drag curve near the onset of vortex shedding, Phys. Fluids 7, 2102 ~1995!.
26B. Fornberg, A numerical study of steady viscous flow past a circular cylinder, J. Fluid Mech. 98, 819 ~1980!.
27Th. Zisis and E. Mitsoulis, Viscoplastic flow around a cylinder kept between parallel plates, J. Non-Newtonian Fluid Mech. 105, 1 ~2002!.
28F. Battaglia, S. J. Tavener, A. K. Kulkarni, and C. L. Merkle, Bifurcation of low Reynolds number flows in symmetric channels, AIAA J. 35, 99 ~1997!.
29D. Drikakis, Bifurcation phenomena in incompressible sudden expansion flows, Phys. Fluids 9, 76 ~1997!.
30R. M. Fearn, T. Mullin, and K. A. Cliffe, Nonlinear flow phenomena in a symmetric sudden expansion, J. Fluid Mech. 211, 595 ~1990!.
31T. Hawa and Z. Rusak, The dynamics of a laminar flow in a symmetric channel with a sudden expansion, J. Fluid Mech. 436, 283 ~2001!.
32S. Mishra and K. Jayaraman, Asymmetric flows in planar symmetric channels with large expansion ratio, Int. J. Numer. Methods Fluids 38, 945 ~2002!.
33P. J. Oliveira, Asymmetric flows of viscoelastic fluids in symmetric planar expansion geometries, J. Non-Newtonian Fluid Mech. 114, 33 ~2003!.
34E. Schreck and M. Scha¨fer, Numerical study of bifurcation in threedimensional sudden channel expansions, Comput. Fluids 29, 583 ~2000!.
35A. W. Liu, D. E. Bornside, R. C. Armstrong, and R. A. Brown, Viscoelastic flow of polymer solutions around a periodic, linear array of cylinders: comparisons of predictions for microstructure and flow fields, J. Non-Newtonian Fluid Mech. 77, 153 ~1998!.
36O. H. Faxe´n, Forces exerted on a rigid cylinder in a viscous fluid between two parallel fixed planes, R. Swed. Acad. Eng. Sci. 187, 1 ~1946!.
1320

File diff suppressed because it is too large Load Diff

View File

@ -1,755 +0,0 @@
#!/usr/bin/env python3
"""
Stability Matrix Test
=====================
Tests three collision models (SRT/TRT/MRT) at low and high Re (with/without LES),
plus Esoteric-Pull streaming at low Re with SRT.
Outputs:
- Flow-field images (velocity, vorticity, streamlines) for each case
- Diagnostic JSON with stability metrics
- EsoPull vs double-buffer comparison plots
Usage:
python3 tests/test_stability_matrix.py [--device 0] [--steps 2000]
"""
import argparse
import json
import math
import os
import struct
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pycuda.driver as cuda
from CelerisLab.cuda import compiler
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
FLUID = 0x01
SOLID = 0x02
OBSTACLE = 0x20 # fixed: was 0x04
COLLISION_NAMES = {0: "SRT", 1: "TRT", 2: "MRT"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def compute_vis_omega(re, diameter, u0):
vis = u0 * diameter / re
omega = 1.0 / (3.0 * vis + 0.5)
return vis, omega
def lattice_weights(nq):
if nq == 9:
return np.array([4/9] + [1/9]*4 + [1/36]*4, dtype=np.float32)
if nq == 19:
return np.array([1/3] + [1/18]*6 + [1/36]*12, dtype=np.float32)
raise ValueError(f"nq={nq}")
def build_flags_2d(nx, ny, cx, cy, radius):
flag = np.ones(nx * ny, dtype=np.uint8) * FLUID
for y in range(ny):
for x in range(nx):
k = y * nx + x
if y == 0 or y == ny - 1 or x == 0 or x == nx - 1:
flag[k] = SOLID
elif (x - cx)**2 + (y - cy)**2 < radius**2:
flag[k] = OBSTACLE
return flag
def set_macros(nx, ny, dim, nq, vis, u0, collision_model, use_les, streaming_model,
omega_collision_max=1.999, inlet_profile=1, trt_magic_param=0.1875,
les_cs=0.16):
"""Write config/*.h files used by kernel_v2.cu."""
cfg_dir = os.path.join(os.path.dirname(compiler.kernel_path("config.h")), "config")
# config_grid.h
with open(os.path.join(cfg_dir, "config_grid.h"), "w") as f:
f.write(f"""\
// AUTO-GENERATED by test_stability_matrix.py
#ifndef CELERIS_CONFIG_GRID_H
#define CELERIS_CONFIG_GRID_H
#define NT 128
#define MULT_GPU 0
#define NX {nx}
#define NY {ny}
#define NZ 1
#define DIM {dim}
#define NQ {nq}
#endif
""")
# config_physics.h
with open(os.path.join(cfg_dir, "config_physics.h"), "w") as f:
f.write(f"""\
// AUTO-GENERATED by test_stability_matrix.py
#ifndef CELERIS_CONFIG_PHYSICS_H
#define CELERIS_CONFIG_PHYSICS_H
#define LBtype float
#define VIS {vis:.10f}
#define RHO 1.0
#define U0 {u0}
#define PI 3.141592653589793238
#define FLUID 0x01
#define SOLID 0x02
#define GAS 0x04
#define INTERFACE 0x08
#define SENSOR 0x10
#define OBSTACLE 0x20
#define V_TAYLOR 1
#endif
""")
# config_method.h
with open(os.path.join(cfg_dir, "config_method.h"), "w") as f:
f.write(f"""\
// AUTO-GENERATED by test_stability_matrix.py
#ifndef CELERIS_CONFIG_METHOD_H
#define CELERIS_CONFIG_METHOD_H
#define COLLISION_MODEL {collision_model}
#define STREAMING_MODEL {streaming_model}
#define STORE_PRECISION 0
#define USE_DDF_SHIFTING 0
#define USE_LES {int(use_les)}
#define LES_CS {les_cs:.6f}f
#define INLET_PROFILE {int(inlet_profile)}
#define OUTLET_MODE 0
#define OUTLET_BLEND_ALPHA 0.700f
#define OUTLET_BACKFLOW_CLAMP 1
#define OMEGA_COLLISION_MIN 0.01f
#define OMEGA_COLLISION_MAX {float(omega_collision_max):.3f}f
#define TRT_MAGIC_PARAM {float(trt_magic_param):.6f}f
#endif
""")
# config_objects.h
with open(os.path.join(cfg_dir, "config_objects.h"), "w") as f:
f.write("""\
// AUTO-GENERATED by test_stability_matrix.py
#ifndef CELERIS_CONFIG_OBJECTS_H
#define CELERIS_CONFIG_OBJECTS_H
#define N_OBJS 0
#endif
""")
def pack_d_params(nx, ny, omega, u0):
"""Pack LBMParams struct for __constant__ memory upload."""
return struct.pack(
"IIIQfffffffI",
nx, ny, 1, # Nx, Ny, Nz
nx * ny, # N
omega, # omega
1.1, # omega_bulk
0.0, 0.0, 0.0, # fx, fy, fz
1.0, # rho_ref
u0, # u_inlet
0, # n_objects
)
def impose_rest_on_nonfluid(flag, host_ddf, nq, nx, ny):
w = lattice_weights(nq)
f = host_ddf.reshape(nq, ny, nx)
nonfluid = flag.reshape(ny, nx) != FLUID
for i in range(nq):
f[i, nonfluid] = w[i]
return host_ddf
def compute_macros_2d(host_ddf, nq, nx, ny, flag):
"""Compute rho, ux, uy from DDF."""
cx9 = [0, 1, -1, 0, 0, 1, -1, 1, -1]
cy9 = [0, 0, 0, 1, -1, 1, -1, -1, 1]
f = host_ddf.reshape(nq, ny, nx)
rho = np.sum(f, axis=0)
ux = np.zeros_like(rho)
uy = np.zeros_like(rho)
for i in range(nq):
ux += cx9[i] * f[i]
uy += cy9[i] * f[i]
rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0)
ux /= rho_safe
uy /= rho_safe
return rho, ux, uy
def diagnose(rho, ux, uy, flag, nx, ny):
"""Compute stability diagnostics."""
fluid = flag.reshape(ny, nx) == FLUID
nan_count = int(np.isnan(rho).sum())
rho_min = float(np.nanmin(rho))
rho_max = float(np.nanmax(rho))
mass = float(np.nansum(rho[fluid]))
vel = np.sqrt(ux**2 + uy**2)
# Ma check
ma_max = float(np.nanmax(vel[fluid])) * math.sqrt(3.0) if np.any(fluid) else 0.0
# Vorticity RMS in wake region
vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
wake_mask = fluid & (np.arange(nx)[None, :] > nx // 3)
vort_rms = float(np.sqrt(np.nanmean(vort[wake_mask]**2))) if np.any(wake_mask) else 0.0
stable = nan_count == 0 and rho_min > 0.0 and rho_max < 2.0
return {
"nan_count": nan_count,
"rho_min": rho_min,
"rho_max": rho_max,
"mass": mass,
"ma_max": ma_max,
"vort_rms": vort_rms,
"stable": stable,
}
def plot_flow(rho, ux, uy, flag, nx, ny, title, out_path):
"""Plot velocity magnitude, vorticity, and streamlines."""
fluid_mask = flag.reshape(ny, nx) != FLUID
vel = np.sqrt(ux**2 + uy**2)
vel_m = np.ma.array(vel, mask=fluid_mask)
vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
vort_m = np.ma.array(vort, mask=fluid_mask)
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# Velocity magnitude
im0 = axes[0].imshow(vel_m, origin="lower", aspect="auto", cmap="turbo")
plt.colorbar(im0, ax=axes[0], label="|u|")
axes[0].set_title("Velocity Magnitude")
# Vorticity
vals = vort[~fluid_mask]
if vals.size > 0:
vmax = max(float(np.percentile(np.abs(vals), 99)), 1e-8)
else:
vmax = 1e-6
im1 = axes[1].imshow(vort_m, origin="lower", aspect="auto", cmap="RdBu_r",
vmin=-vmax, vmax=vmax)
plt.colorbar(im1, ax=axes[1], label="vorticity")
axes[1].set_title("Vorticity")
# Streamlines
X, Y = np.meshgrid(np.arange(nx), np.arange(ny))
ux_s = np.ma.array(ux, mask=fluid_mask)
uy_s = np.ma.array(uy, mask=fluid_mask)
speed = np.ma.sqrt(ux_s**2 + uy_s**2)
axes[2].streamplot(X, Y, ux_s, uy_s, color=speed, cmap="viridis",
density=2.0, linewidth=0.7)
axes[2].set_xlim(0, nx)
axes[2].set_ylim(0, ny)
axes[2].set_title("Streamlines")
fig.suptitle(title, fontsize=13)
fig.tight_layout()
fig.savefig(out_path, dpi=150)
plt.close(fig)
return out_path
# ---------------------------------------------------------------------------
# Case runner: double-buffer
# ---------------------------------------------------------------------------
def run_double_buffer(device_id, cfg, out_dir):
"""Run a case with standard double-buffer streaming."""
nx, ny = cfg["nx"], cfg["ny"]
nq = cfg["nq"]
n = nx * ny
set_macros(nx, ny, cfg["dim"], nq, cfg["vis"], cfg["u0"],
cfg["collision_model"], cfg["use_les"], streaming_model=0,
omega_collision_max=cfg.get("omega_max", 1.999),
trt_magic_param=cfg.get("trt_magic", 0.1875))
compiler.compile_kernel_v2()
cuda.init()
dev = cuda.Device(device_id)
ctx = dev.make_context()
try:
mod = cuda.module_from_file(compiler.kernel_path("kernel_v2.ptx"))
init_fn = mod.get_function("InitTubeFlow_v2")
step_fn = mod.get_function("OneStep")
# Upload d_params
params_ptr, params_size = mod.get_global("d_params")
params_data = pack_d_params(nx, ny, cfg["omega"], cfg["u0"])
if len(params_data) < params_size:
params_data += b"\x00" * (params_size - len(params_data))
cuda.memcpy_htod(params_ptr, params_data)
fsize = n * nq * 4
d_fi = cuda.mem_alloc(fsize)
d_fi2 = cuda.mem_alloc(fsize)
d_flag = cuda.mem_alloc(n)
d_indx = cuda.mem_alloc(n * 4)
d_delta = cuda.mem_alloc(4)
d_action = cuda.mem_alloc(4)
d_obs = cuda.mem_alloc(4)
cuda.memset_d32(d_indx, 0, n)
cuda.memset_d32(d_delta, 0, 1)
cuda.memset_d32(d_action, 0, 1)
cuda.memset_d32(d_obs, 0, 1)
block = (128, 1, 1)
grid = ((nx + 127) // 128, ny, 1)
init_fn(d_flag, d_fi, block=block, grid=grid)
cuda.memcpy_htod(d_flag, cfg["flag"])
host0 = np.empty(n * nq, dtype=np.float32)
cuda.memcpy_dtoh(host0, d_fi)
host0 = impose_rest_on_nonfluid(cfg["flag"], host0, nq, nx, ny)
cuda.memcpy_htod(d_fi, host0)
cuda.memcpy_htod(d_fi2, host0)
steps = cfg["steps"]
report = max(steps // 5, 1)
t0 = time.time()
diverged_step = None
for s in range(steps):
step_fn(d_flag, d_fi, d_fi2, d_indx, d_delta, d_action, d_obs,
block=block, grid=grid)
d_fi, d_fi2 = d_fi2, d_fi
if (s + 1) % report == 0:
cuda.Context.synchronize()
h = np.empty(n * nq, dtype=np.float32)
cuda.memcpy_dtoh(h, d_fi)
rho_c = h.reshape(nq, ny, nx).sum(axis=0)
nc = int(np.isnan(rho_c).sum())
center = float(rho_c[ny // 2, nx // 2])
print(f" step {s+1:6d}: rho_center={center:.6f} nan={nc}")
if nc > 0:
diverged_step = s + 1
break
cuda.Context.synchronize()
elapsed = time.time() - t0
host = np.empty(n * nq, dtype=np.float32)
cuda.memcpy_dtoh(host, d_fi)
rho, ux, uy = compute_macros_2d(host, nq, nx, ny, cfg["flag"])
diag = diagnose(rho, ux, uy, cfg["flag"], nx, ny)
diag["elapsed"] = elapsed
diag["mlups"] = n * steps / elapsed / 1e6 if elapsed > 0 else 0
diag["diverged_step"] = diverged_step
tag = cfg["tag"]
plot_path = plot_flow(rho, ux, uy, cfg["flag"], nx, ny, tag,
os.path.join(out_dir, f"{tag}.png"))
diag["plot"] = plot_path
return diag
finally:
ctx.pop()
# ---------------------------------------------------------------------------
# Case runner: Esoteric-Pull (single buffer)
# ---------------------------------------------------------------------------
def run_esopull(device_id, cfg, out_dir):
"""Run a case with Esoteric-Pull single-buffer streaming."""
nx, ny = cfg["nx"], cfg["ny"]
nq = cfg["nq"]
n = nx * ny
set_macros(nx, ny, cfg["dim"], nq, cfg["vis"], cfg["u0"],
cfg["collision_model"], cfg["use_les"], streaming_model=1,
omega_collision_max=cfg.get("omega_max", 1.999),
trt_magic_param=cfg.get("trt_magic", 0.1875))
compiler.compile_kernel_v2()
cuda.init()
dev = cuda.Device(device_id)
ctx = dev.make_context()
try:
mod = cuda.module_from_file(compiler.kernel_path("kernel_v2.ptx"))
init_fn = mod.get_function("InitEsoPull")
step_fn = mod.get_function("EsoPullStep")
# Upload d_params
params_ptr, params_size = mod.get_global("d_params")
params_data = pack_d_params(nx, ny, cfg["omega"], cfg["u0"])
if len(params_data) < params_size:
params_data += b"\x00" * (params_size - len(params_data))
cuda.memcpy_htod(params_ptr, params_data)
fsize = n * nq * 4
d_fi = cuda.mem_alloc(fsize)
d_flag = cuda.mem_alloc(n)
d_indx = cuda.mem_alloc(n * 4)
d_delta = cuda.mem_alloc(4)
d_action = cuda.mem_alloc(4)
d_obs = cuda.mem_alloc(4)
cuda.memset_d32(d_indx, 0, n)
cuda.memset_d32(d_delta, 0, 1)
cuda.memset_d32(d_action, 0, 1)
cuda.memset_d32(d_obs, 0, 1)
block = (128, 1, 1)
grid = ((nx + 127) // 128, ny, 1)
init_fn(d_flag, d_fi, block=block, grid=grid)
cuda.memcpy_htod(d_flag, cfg["flag"])
# Note: for EsoPull, we don't impose_rest_on_nonfluid on the raw
# DDF because the data is stored in esoteric layout. InitEsoPull
# already stores rest equilibrium for solid nodes.
steps = cfg["steps"]
report = max(steps // 5, 1)
t0 = time.time()
diverged_step = None
for s in range(steps):
t_val = np.uint64(s) # timestep counter for load/store parity
step_fn(d_fi, d_flag, d_indx, d_delta, d_action, d_obs,
t_val, block=block, grid=grid)
if (s + 1) % report == 0:
cuda.Context.synchronize()
# For diagnostics, download raw DDF and decode from esopull layout
h = np.empty(n * nq, dtype=np.float32)
cuda.memcpy_dtoh(h, d_fi)
# Esoteric layout: at this point the DDF is in post-store layout
# for timestep s. To compute macros we need to "undo" the esoteric
# read pattern. A simpler approach: compute rho = sum(fi) per node.
# Because sum is invariant under slot permutation, rho is correct.
# But ux/uy need correct direction assignment.
# For diagnostic, use a simple sum-based stability check.
f_arr = h.reshape(nq, ny, nx)
rho_c = f_arr.sum(axis=0)
nc = int(np.isnan(rho_c).sum())
center = float(rho_c[ny // 2, nx // 2])
print(f" step {s+1:6d}: rho_center={center:.6f} nan={nc}")
if nc > 0:
diverged_step = s + 1
break
cuda.Context.synchronize()
elapsed = time.time() - t0
# For final macros, do one more step that also writes to rho/u arrays.
# But we don't have UpdateMacro for EsoPull yet. Instead, use the
# approach: run a "read-only" macro computation from the esoteric layout.
# For correctness, we load from the proper esoteric positions on host.
h = np.empty(n * nq, dtype=np.float32)
cuda.memcpy_dtoh(h, d_fi)
rho, ux, uy = _decode_esopull_macros(h, nq, nx, ny, cfg["flag"], steps)
diag = diagnose(rho, ux, uy, cfg["flag"], nx, ny)
diag["elapsed"] = elapsed
diag["mlups"] = n * steps / elapsed / 1e6 if elapsed > 0 else 0
diag["diverged_step"] = diverged_step
tag = cfg["tag"]
plot_path = plot_flow(rho, ux, uy, cfg["flag"], nx, ny, tag,
os.path.join(out_dir, f"{tag}.png"))
diag["plot"] = plot_path
return diag
finally:
ctx.pop()
def _decode_esopull_macros(host_ddf, nq, nx, ny, flag, last_t):
"""Decode macroscopic quantities from esoteric-pull layout on host.
After step t (0-based), the store was done at parity t.
The next load would use parity t+1. To read correct DDFs we mimic
load_f_esopull at t_read = last_t (the parity of the *next* step to execute).
"""
fi = host_ddf.reshape(nq, ny * nx) # fi[direction, node]
t_read = last_t # parity for the load that would happen next
cx9 = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float32)
cy9 = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float32)
# Compute neighbor table once
j_table = np.zeros((nq, ny * nx), dtype=np.int64)
for y in range(ny):
for x in range(nx):
k = y * nx + x
xp = (x + 1) % nx
xm = (x - 1) % nx
yp = (y + 1) % ny
ym = (y - 1) % ny
j_table[0, k] = k
j_table[1, k] = yp * nx + xp if nq > 1 else k # placeholder
j_table[2, k] = ym * nx + xm if nq > 2 else k
# D2Q9 neighbors: j[i] = neighbor in direction c_i
if nq == 9:
j_table[1, k] = y * nx + xp # +x
j_table[2, k] = y * nx + xm # -x
j_table[3, k] = yp * nx + x # +y
j_table[4, k] = ym * nx + x # -y
j_table[5, k] = yp * nx + xp # +x+y
j_table[6, k] = ym * nx + xm # -x-y
j_table[7, k] = ym * nx + xp # +x-y
j_table[8, k] = yp * nx + xm # -x+y
n = nx * ny
f_decoded = np.zeros((nq, n), dtype=np.float32)
f_decoded[0] = fi[0]
for i in range(1, nq, 2):
if t_read & 1:
# Odd: f[i] from fi[n, i], f[i+1] from fi[j[i], i+1]
f_decoded[i] = fi[i]
f_decoded[i + 1] = fi[i + 1, j_table[i]]
else:
# Even: f[i] from fi[n, i+1], f[i+1] from fi[j[i], i]
f_decoded[i] = fi[i + 1]
f_decoded[i + 1] = fi[i, j_table[i]]
f_decoded = f_decoded.reshape(nq, ny, nx)
rho = f_decoded.sum(axis=0)
rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0)
ux = np.zeros_like(rho)
uy = np.zeros_like(rho)
for i in range(nq):
ux += cx9[i] * f_decoded[i]
uy += cy9[i] * f_decoded[i]
ux /= rho_safe
uy /= rho_safe
return rho, ux, uy
# ---------------------------------------------------------------------------
# Case builders
# ---------------------------------------------------------------------------
def build_cases(steps_low, steps_high):
"""Build the full test matrix."""
# Grid params (moderate size for fast testing)
nx, ny = 384, 192
cx_ob, cy_ob, radius = 96.0, 96.0, 18.0
u0 = 0.04
cases = []
for re_val, re_label, n_steps, use_les in [
(100.0, "Re100", steps_low, False),
(100.0, "Re100", steps_low, True),
(3000.0, "Re3000", steps_high, False),
(3000.0, "Re3000", steps_high, True),
]:
for cm in (0, 1, 2):
vis, omega = compute_vis_omega(re_val, 2.0 * radius, u0)
les_tag = "LES" if use_les else "noLES"
cm_name = COLLISION_NAMES[cm]
tag = f"DB_{re_label}_{cm_name}_{les_tag}"
cases.append({
"tag": tag,
"nx": nx, "ny": ny,
"dim": 2, "nq": 9,
"cx": cx_ob, "cy": cy_ob, "radius": radius,
"flag": build_flags_2d(nx, ny, cx_ob, cy_ob, radius),
"u0": u0,
"vis": vis,
"omega": omega,
"collision_model": cm,
"use_les": use_les,
"steps": n_steps,
"streaming": "double_buffer",
"omega_max": 1.999,
"trt_magic": 0.1875,
})
# EsoPull case: low Re, SRT only
re_eso = 100.0
vis_eso, omega_eso = compute_vis_omega(re_eso, 2.0 * radius, u0)
cases.append({
"tag": "EsoPull_Re100_SRT_noLES",
"nx": nx, "ny": ny,
"dim": 2, "nq": 9,
"cx": cx_ob, "cy": cy_ob, "radius": radius,
"flag": build_flags_2d(nx, ny, cx_ob, cy_ob, radius),
"u0": u0,
"vis": vis_eso,
"omega": omega_eso,
"collision_model": 0,
"use_les": False,
"steps": steps_low,
"streaming": "esopull",
"omega_max": 1.999,
"trt_magic": 0.1875,
})
return cases
# ---------------------------------------------------------------------------
# Comparison plot: EsoPull vs DoubleBuffer
# ---------------------------------------------------------------------------
def plot_comparison(results, out_dir):
"""Compare EsoPull and DoubleBuffer at matching Re/collision settings."""
eso_key = "EsoPull_Re100_SRT_noLES"
db_key = "DB_Re100_SRT_noLES"
eso = results.get(eso_key)
db = results.get(db_key)
if eso is None or db is None:
return None
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
fig.suptitle("EsoPull vs DoubleBuffer — Re100 SRT noLES", fontsize=14)
labels = ["DoubleBuffer", "EsoPull"]
for row, (r, label) in enumerate([(db, labels[0]), (eso, labels[1])]):
vel_img = plt.imread(r["plot"]) if os.path.exists(r["plot"]) else None
if vel_img is not None:
axes[row, 0].imshow(vel_img)
axes[row, 0].set_title(f"{label}: flow field")
axes[row, 0].axis("off")
else:
axes[row, 0].text(0.5, 0.5, f"No image for {label}",
ha="center", va="center", transform=axes[row, 0].transAxes)
axes[row, 0].set_title(label)
# Metrics bar chart
metrics = {
"rho_min": r.get("rho_min", 0),
"rho_max": r.get("rho_max", 0),
"ma_max": r.get("ma_max", 0),
"vort_rms": r.get("vort_rms", 0),
}
bars = list(metrics.keys())
vals = [float(metrics[b]) for b in bars]
axes[row, 1].barh(bars, vals, color=["steelblue", "salmon", "green", "purple"])
axes[row, 1].set_title(f"{label}: diagnostics")
# Stability text
text_lines = [
f"stable: {r.get('stable', '?')}",
f"nan_count: {r.get('nan_count', '?')}",
f"mass: {r.get('mass', 0):.2f}",
f"MLUPS: {r.get('mlups', 0):.1f}",
f"diverged_step: {r.get('diverged_step', 'None')}",
]
axes[row, 2].text(0.1, 0.5, "\n".join(text_lines), fontsize=12,
family="monospace", va="center",
transform=axes[row, 2].transAxes)
axes[row, 2].set_title(f"{label}: summary")
axes[row, 2].axis("off")
fig.tight_layout()
cmp_path = os.path.join(out_dir, "esopull_vs_doublebuffer.png")
fig.savefig(cmp_path, dpi=150)
plt.close(fig)
return cmp_path
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(description="Stability matrix test")
parser.add_argument("--device", type=int, default=0)
parser.add_argument("--steps-low", type=int, default=3000,
help="Steps for low-Re cases")
parser.add_argument("--steps-high", type=int, default=6000,
help="Steps for high-Re cases")
parser.add_argument("--only-esopull", action="store_true",
help="Only run the EsoPull test")
args = parser.parse_args()
# Backup config/*.h files (kernel_v2.cu uses config.h, NOT macros.h)
cfg_dir = os.path.join(os.path.dirname(compiler.kernel_path("config.h")), "config")
config_files = ["config_grid.h", "config_physics.h", "config_method.h", "config_objects.h"]
config_backups = {}
for cf in config_files:
path = os.path.join(cfg_dir, cf)
with open(path, "r") as f:
config_backups[path] = f.read()
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "output", "stability_matrix")
os.makedirs(out_dir, exist_ok=True)
cases = build_cases(args.steps_low, args.steps_high)
if args.only_esopull:
cases = [c for c in cases if c["streaming"] == "esopull"]
results = {}
try:
for i, cfg in enumerate(cases):
tag = cfg["tag"]
streaming = cfg["streaming"]
print(f"\n[{i+1}/{len(cases)}] {tag}")
print(f" Re={cfg['u0']*2*cfg['radius']/cfg['vis']:.0f}, "
f"omega={cfg['omega']:.4f}, "
f"collision={COLLISION_NAMES[cfg['collision_model']]}, "
f"LES={cfg['use_les']}, streaming={streaming}")
if streaming == "esopull":
diag = run_esopull(args.device, cfg, out_dir)
else:
diag = run_double_buffer(args.device, cfg, out_dir)
diag["tag"] = tag
diag["streaming"] = streaming
diag["collision"] = COLLISION_NAMES[cfg["collision_model"]]
diag["use_les"] = cfg["use_les"]
diag["re"] = cfg["u0"] * 2 * cfg["radius"] / cfg["vis"]
results[tag] = diag
status = "PASS" if diag["stable"] else "FAIL"
print(f" => {status}: rho=[{diag['rho_min']:.4f}, {diag['rho_max']:.4f}], "
f"nan={diag['nan_count']}, ma_max={diag['ma_max']:.4f}, "
f"MLUPS={diag['mlups']:.1f}")
# Comparison plot
cmp_path = plot_comparison(results, out_dir)
if cmp_path:
print(f"\nComparison plot: {cmp_path}")
# Summary table
print("\n" + "=" * 100)
print(f"{'Tag':<35s} {'Stream':<8s} {'Col':<5s} {'LES':<5s} "
f"{'Re':>6s} {'Stable':>7s} {'rho_min':>9s} {'rho_max':>9s} "
f"{'Ma_max':>8s} {'MLUPS':>7s}")
print("-" * 100)
for tag, r in results.items():
print(f"{tag:<35s} {r['streaming']:<8s} {r['collision']:<5s} "
f"{'Y' if r['use_les'] else 'N':<5s} "
f"{r['re']:6.0f} {'PASS' if r['stable'] else 'FAIL':>7s} "
f"{r['rho_min']:9.5f} {r['rho_max']:9.5f} "
f"{r['ma_max']:8.5f} {r['mlups']:7.1f}")
print("=" * 100)
# Save JSON
json_path = os.path.join(out_dir, "stability_matrix_results.json")
json_results = {}
for k, v in results.items():
jr = {}
for rk, rv in v.items():
if isinstance(rv, (np.integer, np.floating)):
jr[rk] = float(rv)
elif isinstance(rv, np.bool_):
jr[rk] = bool(rv)
else:
jr[rk] = rv
json_results[k] = jr
with open(json_path, "w") as f:
json.dump(json_results, f, indent=2)
print(f"\nResults saved: {json_path}")
finally:
for path, content in config_backups.items():
with open(path, "w") as f:
f.write(content)
if __name__ == "__main__":
main()