Sah04验证部分通过,临时修复部分NEQ inlet解决高阻塞流动。kan99b验证初期。

This commit is contained in:
Frank14f 2026-05-16 21:52:06 +08:00
parent 925417abcb
commit ce492f2794
32 changed files with 3939 additions and 846 deletions

View File

@ -141,7 +141,7 @@ 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.update_runtime_params(omega=..., fx=..., fy=...)
sim.snapshot() / sim.restore()
sim.save_checkpoint(path=None) -> str # HDF5; default path if omitted
sim.load_checkpoint(path) # restores field, step count, bodies

View File

@ -22,7 +22,13 @@ from typing import Dict, List, Optional
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
from ..lbm.descriptors import (
D2Q9_EX,
D2Q9_EY,
D3Q19_EX,
D3Q19_EY,
D3Q19_EZ,
)
class ObjectManager:
@ -34,8 +40,6 @@ class ObjectManager:
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
@ -51,11 +55,11 @@ class ObjectManager:
self._obs_alloc_nbytes: int = 0
self.action = np.zeros(0, dtype=np.float32)
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.sensor_cell_counts = np.zeros(0, dtype=np.int32)
self.slot_stride_floats: int = 0
self.torque_stride_floats: int = 0
self.torque_components: int = 0
@ -65,6 +69,9 @@ class ObjectManager:
self.obs_nbytes: int = 0
self._telemetry_field: Optional[object] = None
# Ensure host action buffer is non-empty so CUDA alloc never sees 0 bytes
# when bodies.count == 0 (matches _resize_buffers sizing for n=0).
self._resize_buffers()
def add(self, obj: SimObject) -> int:
"""Register an object and return its id."""
@ -99,31 +106,27 @@ class ObjectManager:
new_action[:copy_n] = self.action[:copy_n]
self.action = new_action
# -- Geometry and flag rasterisation -------------------------------------
new_sensor_counts = np.zeros(max(n, 1), dtype=np.int32)
if self.sensor_cell_counts.size > 0:
copy_n = min(self.sensor_cell_counts.size, new_sensor_counts.size)
new_sensor_counts[:copy_n] = self.sensor_cell_counts[:copy_n]
self.sensor_cell_counts = new_sensor_counts
def build_flags(self, base_flags: np.ndarray) -> np.ndarray:
"""Merge all object flag masks onto *base_flags* (modified in-place)."""
"""Merge object flag masks onto a clean domain base.
Callers should pass a fresh channel-layout flag array. This keeps object
overlays stateless and prevents stale obstacle bits from surviving after
geometry edits or future body motion.
"""
merged = np.array(base_flags, copy=True)
for obj in self._objects.values():
mask = obj.get_flag_mask(self.nx, self.ny, self.nz)
nonzero = mask != 0
base_flags[nonzero] = mask[nonzero]
return base_flags
merged[nonzero] = mask[nonzero]
return merged
def _rest_nonfluid(self, field):
"""Set DDF of non-fluid nodes to rest equilibrium w_i * rho0."""
field.download_ddf()
nq = field.nq
w = field._read_lattice_weights()
f = field.ddf.reshape(nq, -1)
nonfluid = (field.flag & FLUID) == 0
for i in range(nq):
f[i, nonfluid] = w[i] * field.cfg.rho
field.ddf = f.reshape(-1)
field.upload_ddf()
# -- Compact list building -----------------------------------------------
def build_compact_lists(self):
def build_compact_lists(self, domain_flags: np.ndarray | None = None):
"""Build cut-link SoA columns and sensor lists.
Returns:
@ -134,6 +137,7 @@ class ObjectManager:
cl_idx, cl_dir, cl_q, cl_bid = [], [], [], []
cl_rx, cl_ry, cl_rz, cl_fallback = [], [], [], []
s_cells, s_ids = [], []
self.sensor_cell_counts.fill(0)
ez = None
if self.cfg and self.cfg.is_d3q19:
@ -148,7 +152,9 @@ class ObjectManager:
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)
nz=self.nz, ex=ex, ey=ey, ez=ez,
domain_flags=domain_flags,
)
if len(fluid_idx) > 0:
cl_idx.append(fluid_idx)
cl_dir.append(dirs)
@ -163,6 +169,8 @@ class ObjectManager:
if len(cells) > 0:
s_cells.append(cells)
s_ids.append(ids)
if 0 <= obj.obj_id < self.sensor_cell_counts.size:
self.sensor_cell_counts[obj.obj_id] = int(len(cells))
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)
@ -184,20 +192,26 @@ class ObjectManager:
sensor_cells, sensor_obj_id,
)
# -- GPU buffers (action + obs telemetry) --------------------------------
def sync_to_gpu(self, field, *, rebuild_flags: bool = True):
"""Upload compact lists, action buffer, and packed ``obs`` GPU buffer.
def sync_to_gpu(self, field):
"""Upload merged flags, compact lists, action buffer, and packed ``obs`` GPU buffer."""
Args:
rebuild_flags: Rebuild ``field.flag`` from the clean channel base and
upload it before constructing compact lists. Pass ``False`` when
the caller has already prepared and uploaded the final flag field
for this geometry, such as during initialization.
"""
self._telemetry_field = field
field.flag = self.build_flags(field.flag)
field.upload_flags()
if rebuild_flags:
field.flag = self.build_flags(field.build_channel_flags())
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()
) = self.build_compact_lists(domain_flags=field.flag)
field.curved.assign_host(
cl_fluid_idx, cl_dir, cl_q, cl_body_id,
@ -214,8 +228,6 @@ class ObjectManager:
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
@ -237,7 +249,6 @@ class ObjectManager:
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)
@ -274,8 +285,6 @@ class ObjectManager:
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
@ -296,8 +305,6 @@ class ObjectManager:
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``."""
@ -322,18 +329,11 @@ class ObjectManager:
"""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)
@ -345,7 +345,24 @@ class ObjectManager:
return np.array(
self.obs_pinned[i0:i0 + self.torque_components], dtype=np.float32)
# -- Runtime action state -------------------------------------------------
def read_sensor(self, body_id: int, *, normalize: bool = True) -> np.ndarray:
"""Return sensor accumulation for ``body_id``.
By default this returns the area-averaged value over the sensor footprint.
Set ``normalize=False`` to get the raw sum accumulated by ``SensorKernel``.
"""
self._validate_body_id(body_id)
assert self.obs_pinned is not None
assert self.cfg is not None
d = self.cfg.dim
i0 = self.sensor0_floats + body_id * d
values = np.array(self.obs_pinned[i0:i0 + d], dtype=np.float32)
if not normalize:
return values
count = int(self.sensor_cell_counts[body_id]) if body_id < self.sensor_cell_counts.size else 0
if count <= 0:
return values
return values / np.float32(count)
def set_body_state(self, body_id: int, omega: float = 0.0) -> None:
"""Set runtime body state used by kernels (currently only omega)."""
@ -392,8 +409,6 @@ class ObjectManager:
return 0
return int(np.count_nonzero(curved.q[:curved.count] < np.float32(threshold)))
# -- Rigid body kinematics (future) --------------------------------------
def update_states(self, dt: float):
"""Integrate object motion (placeholder)."""
pass

View File

@ -108,7 +108,8 @@ class Cylinder(SimObject):
return mask
def get_curved_list(self, nx: int, ny: int, nq: int,
nz: int = 1, ex=None, ey=None, ez=None):
nz: int = 1, ex=None, ey=None, ez=None,
domain_flags=None):
"""Return per-link curved boundary data (fluid-centric).
Each record represents one cut link: a fluid node with a lattice
@ -159,15 +160,24 @@ class Cylinder(SimObject):
r_sq = r * r
use_3d = ez is not None
def _donor_is_fluid(xd: int, yd: int, zd: int = 0) -> bool:
if domain_flags is None:
return True
if nz > 1:
idx = xd + yd * nx + zd * nx * ny
else:
idx = xd + yd * nx
return (int(domain_flags[idx]) & FLUID) != 0
def _fallback_class(
q_val: float, donor_inside: bool, donor_in_domain: bool
q_val: float, donor_inside: bool, donor_in_domain: bool, donor_is_fluid: bool
) -> np.uint8:
"""Classify donor legality for Bouzidi interpolation fallback."""
if not (0.0 < q_val <= 1.0):
raise ValueError(f"q must satisfy 0 < q <= 1, got {q_val}")
if q_val >= 0.5:
return FALLBACK_CLASS_BOUZIDI
if not donor_in_domain or donor_inside:
if (not donor_in_domain) or donor_inside or (not donor_is_fluid):
return FALLBACK_CLASS_HALFWAY_BOUNCEBACK
return FALLBACK_CLASS_BOUZIDI
@ -207,10 +217,12 @@ class Cylinder(SimObject):
zff = z - ez[i]
donor_in_domain = (0 <= xff < nx and 0 <= yff < ny and 0 <= zff < nz)
donor_inside = False
donor_is_fluid = False
if donor_in_domain:
donor_inside = (
(xff - xc) ** 2 + (yff - yc) ** 2 + (zff - zc) ** 2
) < r_sq
donor_is_fluid = _donor_is_fluid(xff, yff, zff)
idx_list.append(
np.uint32(x + y * nx + z * nx * ny))
dir_list.append(np.uint8(i))
@ -219,7 +231,7 @@ class Cylinder(SimObject):
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))
_fallback_class(q, donor_inside, donor_in_domain, donor_is_fluid))
else:
for x in range(x0, x1 + 1):
for y in range(y0, y1 + 1):
@ -246,8 +258,10 @@ class Cylinder(SimObject):
yff = y - ey[i]
donor_in_domain = (0 <= xff < nx and 0 <= yff < ny)
donor_inside = False
donor_is_fluid = False
if donor_in_domain:
donor_inside = ((xff - xc) ** 2 + (yff - yc) ** 2) < r_sq
donor_is_fluid = _donor_is_fluid(xff, yff)
idx_list.append(np.uint32(x + y * nx))
dir_list.append(np.uint8(i))
q_list.append(np.float32(q))
@ -255,7 +269,7 @@ class Cylinder(SimObject):
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))
_fallback_class(q, donor_inside, donor_in_domain, donor_is_fluid))
n = len(idx_list)
if n > 0:

View File

@ -10,7 +10,7 @@ Python `config.py` 只负责读取和校验,不是配置位置。
| 字段 | 类型 | 默认 | 允许值 | 说明 |
|------|------|------|--------|------|
| `lattice_model` | string | `"D2Q9"` | `D2Q9`, `D3Q19` | 格子模型,决定维度和速度数量 |
| `nx` | int | 512 | >0 | x 方向格点数(流向),必须整除 `threads_per_block` |
| `nx` | int | 512 | >0 | x 方向格点数(流向),建议整除 `threads_per_block` 以减少尾块浪费 |
| `ny` | int | 256 | >0 | y 方向格点数 |
| `nz` | int | 1 | >0 | z 方向格点数D2Q9 时必须为 1 |
@ -29,7 +29,7 @@ Python `config.py` 只负责读取和校验,不是配置位置。
|------|------|------|--------|------|
| `collision` | string | `"SRT"` | `SRT`, `TRT`, `MRT` | 碰撞算子 |
| `streaming` | string | `"double_buffer"` | `double_buffer`, `esopull` | 流传输方式 |
| `store_precision` | string | `"FP32"` | `FP32`, `FP16S`, `FP16C` | GPU 存储精度FP16S 可 1.87× 加速 |
| `store_precision` | string | `"FP32"` | `FP32`, `FP16S`, `FP16C` | GPU 存储精度。当前运行时已实现 `FP32``FP16S``FP16C` 仍为保留选项 |
| `ddf_shifting` | bool | false | | 存储 fw 而非 f提升 FP16 精度 |
| `les.enabled` | bool | false | | LES Smagorinsky 子格模型 |
| `les.cs` | float | 0.16 | | Smagorinsky 常数 |
@ -39,8 +39,8 @@ Python `config.py` 只负责读取和校验,不是配置位置。
| `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 阻尼;更小可减轻棋盘噪声 |
| `outlet.blend_alpha` | float | 0.7 | | `blended` 下对未知入域方向的混合系数(**所有碰撞模型**共用同一路径) |
| `outlet.srt_neq_damp` | float | 0.5 | [0, 1] | 仅当 `outlet.mode`**`neq_extrap`** 且碰撞为 **SRT/TRT** 时:出口全分布 damped NEQ 的阻尼系数。`outlet.mode` 为 **`blended`** 时 SRT/TRT 走混合未知方向分支不使用本键。MRT + `neq_extrap` 仍为少量方向的 NEQ 外推 |
| `y_wall_bc` | string | `"bounce_back"` | `bounce_back`, `free_slip` | y 向上下壁面边界条件(仅壁面相邻流体行生效) |
| `omega_guard.min` | float | 0.01 | | ω 下界LES τ_eff 保护) |
| `omega_guard.max` | float | 1.96 | | ω 上界,高 Re 建议 1.90-1.96 |

View File

@ -1,16 +1,16 @@
# CelerisLab/lbm/__init__.py
"""
Lattice Boltzmann Method (LBM) module for fluid simulation.
Lattice Boltzmann Method 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
__all__ = ["LBMField", "LBMStepper", "add_lamb_oseen", "add_taylor_green"]
from .initializers import add_vortex
__all__ = ["LBMField", "LBMStepper", "add_vortex"]
except ImportError as e:
import warnings
warnings.warn(f"LBM module not fully available: {e}", ImportWarning)
__all__ = []

View File

@ -22,7 +22,7 @@ import pycuda.driver as cuda
from ..config import LBMConfig
from .curved_links import CurvedLinkSoA, SensorSoA
from .descriptors import FLUID
from .descriptors import FLUID, SOLID, BC_WALL, BC_INLET, BC_OUTLET
_SUPPORTED_STORE_PRECISIONS = ("FP32", "FP16S")
@ -55,7 +55,7 @@ class LBMField:
# 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.uint16) * FLUID
self.flag = self.build_channel_flags()
# Compact lists (filled by ObjectManager.sync_to_gpu)
self.curved = CurvedLinkSoA()
@ -70,9 +70,50 @@ class LBMField:
# Snapshot
self._ddf_snap: np.ndarray | None = None
# Host mirror of runtime parameters so partial updates preserve state.
self._runtime_params = {
"omega": float(cfg.omega),
"fx": 0.0,
"fy": 0.0,
"fz": 0.0,
"n_objects": 0,
}
# Upload d_params immediately
self._upload_params()
def build_channel_flags(self) -> np.ndarray:
"""Return a fresh host flag array for the base channel domain.
The returned array contains only the static channel topology:
inlet, outlet, y-walls, and interior fluid. Object overlays are applied
later by ObjectManager on top of this clean base, which avoids stale
obstacle bits when geometry is rebuilt or moved.
"""
flag = np.ones(self.n, dtype=np.uint16) * FLUID
if self.cfg.is_d2q9:
flag[0:self.nx] = SOLID | BC_WALL
flag[(self.ny - 1) * self.nx:self.ny * self.nx] = SOLID | BC_WALL
flag[::self.nx] = SOLID | BC_INLET
flag[self.nx - 1::self.nx] = SOLID | BC_OUTLET
flag[0] = SOLID | BC_WALL
flag[self.nx - 1] = SOLID | BC_WALL
flag[(self.ny - 1) * self.nx] = SOLID | BC_WALL
flag[self.ny * self.nx - 1] = SOLID | BC_WALL
return flag
flag = flag.reshape(self.nz, self.ny, self.nx)
flag[:, 0, :] = SOLID | BC_WALL
flag[:, self.ny - 1, :] = SOLID | BC_WALL
flag[:, :, 0] = SOLID | BC_INLET
flag[:, :, self.nx - 1] = SOLID | BC_OUTLET
flag[:, 0, 0] = SOLID | BC_WALL
flag[:, 0, self.nx - 1] = SOLID | BC_WALL
flag[:, self.ny - 1, 0] = SOLID | BC_WALL
flag[:, self.ny - 1, self.nx - 1] = SOLID | BC_WALL
return flag.reshape(-1)
@property
def n_curved(self) -> int:
return self.curved.count
@ -115,20 +156,18 @@ class LBMField:
"""Pack LBMParams struct and upload to __constant__ d_params.
Must match struct LBMParams in core/params.cuh layout:
float omega, omega_bulk;
float omega;
float fx, fy, fz;
float rho_ref, u_inlet;
uint n_objects;
Total: 8 floats + 1 uint = 36 bytes (no alignment padding needed).
Total: 5 floats/uint words = 20 bytes.
"""
cfg = self.cfg
fmt = "=ff fff ff I"
p = self._runtime_params
fmt = "=f fff I"
data = struct.pack(
fmt,
cfg.omega, 0.0, # omega, omega_bulk
0.0, 0.0, 0.0, # fx, fy, fz
cfg.rho, cfg.velocity, # rho_ref, u_inlet
0, # n_objects (updated later)
p["omega"],
p["fx"], p["fy"], p["fz"],
p["n_objects"],
)
ptr, size = self.module.get_global("d_params")
cuda.memcpy_htod(ptr, data)
@ -136,25 +175,23 @@ class LBMField:
def update_params(self, **kwargs):
"""Re-upload d_params after changing runtime-adjustable values.
Accepted keys: omega, omega_bulk, fx, fy, fz, rho_ref, u_inlet, n_objects.
Accepted keys: omega, fx, fy, fz, n_objects.
"""
cfg = self.cfg
omega = kwargs.get("omega", cfg.omega)
omega_bulk = kwargs.get("omega_bulk", 0.0)
fx = kwargs.get("fx", 0.0)
fy = kwargs.get("fy", 0.0)
fz = kwargs.get("fz", 0.0)
rho_ref = kwargs.get("rho_ref", cfg.rho)
u_inlet = kwargs.get("u_inlet", cfg.velocity)
n_objects = kwargs.get("n_objects", 0)
self._runtime_params.update({
"omega": float(kwargs.get("omega", self._runtime_params["omega"])),
"fx": float(kwargs.get("fx", self._runtime_params["fx"])),
"fy": float(kwargs.get("fy", self._runtime_params["fy"])),
"fz": float(kwargs.get("fz", self._runtime_params["fz"])),
"n_objects": int(kwargs.get("n_objects", self._runtime_params["n_objects"])),
})
fmt = "=ff fff ff I"
p = self._runtime_params
fmt = "=f fff I"
data = struct.pack(
fmt,
omega, omega_bulk,
fx, fy, fz,
rho_ref, u_inlet,
n_objects,
p["omega"],
p["fx"], p["fy"], p["fz"],
p["n_objects"],
)
ptr, _ = self.module.get_global("d_params")
cuda.memcpy_htod(ptr, data)

View File

@ -53,8 +53,9 @@ def add_vortex(field, center: Tuple[float, float],
# Compute current macroscopic from DDF
rho_old = np.sum(f, axis=0)
ux_old = sum(f[i] * cx[i] for i in range(nq))
uy_old = sum(f[i] * cy[i] for i in range(nq))
rho_safe = np.where(np.abs(rho_old) > 1e-12, rho_old, 1.0)
ux_old = sum(f[i] * cx[i] for i in range(nq)) / rho_safe
uy_old = sum(f[i] * cy[i] for i in range(nq)) / rho_safe
p_old = rho_old / 3.0
# Superimpose

View File

@ -1,16 +1,24 @@
// CelerisLab boundary/curved_boundary.cuh
// Bouzidi linear interpolated bounce-back for curved surfaces.
//
// 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.
// Pull-streaming contract used here:
// before the main pull step, each cut-link thread computes the reflected
// population and writes it into the adjacent solid source node. The pull load
// from the neighboring solid node then receives the corrected value in the
// same time step. This avoids colliding fluid nodes with garbage populations
// pulled from obstacle interiors.
//
// References:
// [Bou01] Bouzidi, Firdaouss & Lallemand, Phys. Fluids 13(11), 2001
// [Mei99] Mei, Luo & Shyy, J. Comput. Phys. 155(2), 1999
// [Gin08b] Ginzburg, Verhaeghe & d'Humieres, Commun. Comput. Phys. 2008
// [Wen15] Wen, Zhang & Fang, Entropy 17(12), 2015 (MEA force)
//
// Note on TRT:
// Plain linear Bouzidi interpolation is kept here because it is simple and
// useful for SRT and MRT baselines and for moving simple geometries.
// However, it is not a TRT-parametrized curved-wall family. Expect TRT to be
// more sensitive here than with CLI, MGLI, or other TRT-consistent schemes.
// ============================================================================
#ifndef CELERIS_BOUNDARY_CURVED_BOUNDARY_CUH
@ -19,78 +27,72 @@
constexpr unsigned char CURVED_FALLBACK_BOUZIDI = 0u;
constexpr unsigned char CURVED_FALLBACK_HALFWAY = 1u;
// ---------------------------------------------------------------------------
// apply_bouzidi_link single cut link, dimension-agnostic core
//
// 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__ __forceinline__ float bouzidi_linear_moving_correction(
float q, unsigned char fallback_class, float alpha_ci_dot_uw)
{
if (fallback_class != CURVED_FALLBACK_BOUZIDI) {
// Fallback replaces the q < 1/2 branch when its donor is illegal.
// For D2Q9, alpha_i = 3 w_i, so the moving-wall addend is
// +2 * alpha_i * (c_i · u_w) in the q < 1/2 branch.
return 2.0f * alpha_ci_dot_uw;
}
if (q < 0.5f) {
return 2.0f * alpha_ci_dot_uw;
}
return (alpha_ci_dot_uw / q);
}
#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,
fpxx* __restrict__ fi,
float Uw, float Vw,
float rx, float ry,
unsigned char fallback_class,
float* __restrict__ obs, int body_id)
{
int dir_opp = opp_dir(dir);
const unsigned int dir_opp = (unsigned int)opp_dir((int)dir);
// Post-collision population heading toward wall (valid in fi_out)
float f_toward = load_ddf(fi_out, index_f(k_f, (unsigned int)dir));
unsigned int xf, yf;
coordinates(k_f, xf, yf);
const int xs = (int)xf + d_cx[dir];
const int ys = (int)yf + d_cy[dir];
const unsigned long k_s = linear_index((unsigned int)xs, (unsigned int)ys);
const float f_toward = load_ddf(fi, index_f(k_f, dir));
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));
const int xff = (int)xf - d_cx[dir];
const int yff = (int)yf - d_cy[dir];
const unsigned long k_ff = linear_index((unsigned int)xff, (unsigned int)yff);
const float f_toward_ff = load_ddf(fi, index_f(k_ff, 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));
const float f_opp_same = load_ddf(fi, index_f(k_f, dir_opp));
f_reflected = (1.0f / (2.0f * q)) * f_toward
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_prev;
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_same;
}
} 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;
const float ci_dot_uw = (float)d_cx[dir] * Uw + (float)d_cy[dir] * Vw;
const float alpha_ci_dot_uw = 3.0f * d_w[dir] * ci_dot_uw;
f_reflected += bouzidi_linear_moving_correction(
q, fallback_class, alpha_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);
// Write to the solid source node so the subsequent pull step loads the
// corrected incoming population into the adjacent fluid node.
store_ddf(fi, index_f(k_s, 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);
const float fx = (float)d_cx[dir] * (f_toward + f_reflected);
const 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;
const float tz = rx * fy - ry * fx;
atomicAdd(&obs[obs_torque_index(body_id, 0)], tz);
}
}
@ -99,56 +101,59 @@ __device__ inline void apply_bouzidi_link(
#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,
fpxx* __restrict__ fi,
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);
const unsigned int dir_opp = (unsigned int)opp_dir((int)dir);
float f_toward = load_ddf(fi_out, index_f(k_f, (unsigned int)dir));
unsigned int xf, yf, zf;
coordinates(k_f, xf, yf, zf);
const int xs = (int)xf + d_cx[dir];
const int ys = (int)yf + d_cy[dir];
const int zs = (int)zf + d_cz[dir];
const unsigned long k_s = linear_index((unsigned int)xs, (unsigned int)ys, (unsigned int)zs);
const float f_toward = load_ddf(fi, index_f(k_f, 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));
const int xff = (int)xf - d_cx[dir];
const int yff = (int)yf - d_cy[dir];
const int zff = (int)zf - d_cz[dir];
const unsigned long k_ff = linear_index((unsigned int)xff, (unsigned int)yff, (unsigned int)zff);
const float f_toward_ff = load_ddf(fi, index_f(k_ff, 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));
const float f_opp_same = load_ddf(fi, index_f(k_f, dir_opp));
f_reflected = (1.0f / (2.0f * q)) * f_toward
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_prev;
+ (1.0f - 1.0f / (2.0f * q)) * f_opp_same;
}
} 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;
const float ci_dot_uw = (float)d_cx[dir] * Uw + (float)d_cy[dir] * Vw
+ (float)d_cz[dir] * Ww;
const float alpha_ci_dot_uw = 3.0f * d_w[dir] * ci_dot_uw;
f_reflected += bouzidi_linear_moving_correction(
q, fallback_class, alpha_ci_dot_uw);
store_ddf(fi_out, index_f(k_f, (unsigned int)dir_opp), f_reflected);
store_ddf(fi, index_f(k_s, 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);
const float fx = (float)d_cx[dir] * (f_toward + f_reflected);
const float fy = (float)d_cy[dir] * (f_toward + f_reflected);
const 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;
const float tx = ry * fz - rz * fy;
const float ty = rz * fx - rx * fz;
const 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);

View File

@ -44,13 +44,19 @@
__device__ __forceinline__ float inlet_target_u(float y_coord) {
#if INLET_PROFILE == 0
// Uniform profile: U0 is the imposed streamwise velocity everywhere on the
// inlet fluid band.
return U0;
#else
// Define profile on fluid-node band y in [1, NY-2] and clamp to non-negative
// to avoid near-corner backflow injection.
// Parabolic profile on the fluid-node band y in [1, NY-2].
//
// U0 is treated here as the mean streamwise inlet velocity. The returned
// peak centerline velocity is 1.5 * U0, matching the discrete Poiseuille
// profile used throughout initialization and boundary reconstruction.
// Keep this convention aligned with case setup and validation scripts.
const float y_clamped = fminf((float)(NY - 2), fmaxf(1.0f, y_coord));
const float H = fmaxf((float)(NY - 2), 1.0f);
const float eta = (y_clamped - 0.5f) / H; // maps first/last fluid rows near 0/1
const float eta = (y_clamped - 0.5f) / H; // first and last fluid rows map near 0 and 1
const float shape = fmaxf(0.0f, 4.0f * eta * (1.0f - eta));
return U0 * 1.5f * shape;
#endif
@ -66,44 +72,88 @@ __device__ __forceinline__ float inlet_target_u(float y_coord) {
// f_neb = populations at the interior neighbor (x=1)
// y = y-coordinate of the boundary node
//
// Reconstructs f[1], f[5], f[7] (cx > 0 directions in new ordering)
// using: f_bc[i] = f_neb[i] - feq(rho_neb, u_neb)[i] + feq(rho_neb, u_target)[i]
// Reconstructs f[1], f[5], f[7] (cx > 0 directions in new ordering).
//
// Velocity convention:
// uniform -> U0 is the imposed inlet velocity
// parabolic -> U0 is the mean inlet velocity, so inlet_target_u() returns a
// centerline peak of 1.5 * U0
//
// Reconstruction keeps the Zou-He mass closure but does not copy all three
// unknown-direction NEQ parts from the donor.
//
// Why this split form:
// - In narrow high-blockage channels, the donor diagonals f_neb[5], f_neb[7]
// are strongly contaminated by near-wall shear and tend to inject a spurious
// negative shift into the first interior column. Empirically this is not just
// a corner-node artifact: the whole x=1 profile can be biased low when the
// channel becomes very narrow.
// - Removing NEQ entirely hurts stability, so the streamwise unknown f[1] keeps
// donor NEQ information.
// - The diagonal unknowns are instead reconstructed from local density and
// transverse-velocity constraints. A positivity limiter is applied only if
// the local constraints are mutually inconsistent, preferring exact rho and
// streamwise flux over exact v_target at that node.
// ---------------------------------------------------------------------------
__device__ inline void apply_parabolic_inlet(float* __restrict__ f,
const float* __restrict__ f_neb,
float y_coord)
{
// Neighbor macros
// Donor macros from the first interior fluid column.
float rho_neb, u_neb, v_neb;
compute_rho_u(f_neb, rho_neb, u_neb, v_neb);
// Target velocity (parabolic profile)
float u_target = inlet_target_u(y_coord);
float v_target = 0.0f;
// Target inlet velocity.
const float u_target = inlet_target_u(y_coord);
const float v_target = 0.0f;
// 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);
// Zou-He mass closure at the west boundary.
// Known (after pull and any wall pre-repair): f[0],f[2],f[3],f[4],f[6],f[8]
// Unknown to reconstruct: f[1],f[5],f[7]
const 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);
compute_feq(rho_neb, u_neb, v_neb, feq_neb);
#if COLLISION_MODEL == 1
// TRT path: reconstruct unknown incoming populations only (cx>0 at west inlet).
const float beta = INLET_TRT_NEQ_DAMP;
f[1] = feq_tar[1] + beta * (f_neb[1] - feq_neb[1]);
f[5] = feq_tar[5] + beta * (f_neb[5] - feq_neb[5]);
f[7] = feq_tar[7] + beta * (f_neb[7] - feq_neb[7]);
const float beta_n = INLET_TRT_NEQ_DAMP;
#else
const float beta = 1.0f;
f[1] = feq_tar[1] + beta * (f_neb[1] - feq_neb[1]);
f[5] = feq_tar[5] + beta * (f_neb[5] - feq_neb[5]);
f[7] = feq_tar[7] + beta * (f_neb[7] - feq_neb[7]);
const float beta_n = 1.0f;
#endif
// Keep donor NEQ only for the streamwise incoming population. This retains
// the stabilizing normal-flux information without feeding both diagonal
// donor modes back into the inlet every step.
const float f1_try = feq_tar[1] + beta_n * (f_neb[1] - feq_neb[1]);
// Known-part density contribution.
const float known_sum = f[0] + f[2] + f[3] + f[4] + f[6] + f[8];
// From uy = (f3 - f4 + f5 - f6 - f7 + f8) / rho, so with v_target = 0:
// f5 - f7 = rho*v_target + (f4 - f3) + (f6 - f8)
float pair_diff = rho_in * v_target + (f[4] - f[3]) + (f[6] - f[8]);
// Density fixes f5 + f7 once f1 is chosen:
// f5 + f7 = rho - known_sum - f1
// To keep both diagonals non-negative we need pair_sum >= |pair_diff|,
// hence f1 <= rho - known_sum - |pair_diff|.
const float f1_hi = fmaxf(0.0f, rho_in - known_sum - fabsf(pair_diff));
const float f1 = fminf(fmaxf(f1_try, 0.0f), f1_hi);
float pair_sum = rho_in - known_sum - f1;
// If the local constraints are still inconsistent because of roundoff or an
// extremely distorted incoming state, clip the transverse difference rather
// than emitting negative diagonal populations.
if (fabsf(pair_diff) > pair_sum) {
pair_diff = copysignf(pair_sum, pair_diff);
}
f[1] = f1;
f[5] = 0.5f * (pair_sum + pair_diff);
f[7] = 0.5f * (pair_sum - pair_diff);
}
// ---------------------------------------------------------------------------
@ -138,10 +188,9 @@ __device__ inline void apply_pressure_outlet(float* __restrict__ f,
compute_feq(rho_out, u_neb, v_neb, feq_tar);
compute_feq(rho_neb, u_neb, v_neb, feq_neb);
#if COLLISION_MODEL == 0
// SRT path: use full-population damped NEQ reconstruction at outlet to
// suppress checkerboard/grid noise from high-frequency donor content.
// Reference: high-Re outlet regularization rationale and NEQ decomposition.
#if COLLISION_MODEL == 0 || COLLISION_MODEL == 1
// SRT and TRT path: use full-population damped NEQ reconstruction at
// outlet to suppress checkerboard and boundary-source noise.
const float beta = OUTLET_SRT_NEQ_DAMP;
#pragma unroll
for (int i = 0; i < 9; i++) {
@ -177,41 +226,78 @@ __device__ inline void apply_parabolic_inlet_3d(float* __restrict__ f,
const float* __restrict__ f_neb,
float y_coord)
{
// Neighbor macros
// Donor macros from the first interior fluid column.
float rho_neb, un, vn, wn;
compute_rho_u(f_neb, rho_neb, un, vn, wn);
// Target velocity (parabolic in y, uniform in z)
float u_tar = inlet_target_u(y_coord);
// Target velocity: parabolic in y, uniform in z.
const float u_tar = inlet_target_u(y_coord);
const float v_tar = 0.0f;
const float w_tar = 0.0f;
// 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);
// Zou-He mass balance at the west boundary.
// Unknown cx>0 populations are i = 1, 7, 9, 13, 15.
const 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];
compute_feq(rho_in, u_tar, 0.0f, 0.0f, feq_tar);
compute_feq(rho_in, u_tar, v_tar, w_tar, feq_tar);
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]);
const float beta_n = INLET_TRT_NEQ_DAMP;
#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];
const float beta_n = 1.0f;
#endif
// D3Q19 counterpart of the D2Q9 split strategy:
// - keep donor NEQ on the pure streamwise incoming population f[1]
// - retain the x-z pair only through its total incoming mass, because the
// present channel setup has y-walls but no z-wall BC path
// - reconstruct the y-coupled diagonals from local rho/uy constraints to
// avoid feeding wall-shear contamination back into the inlet every step
const float f1_try = feq_tar[1] + beta_n * (f_neb[1] - feq_neb[1]);
const float zsum_try = (feq_tar[9] + feq_tar[15])
+ beta_n * ((f_neb[9] - feq_neb[9])
+ (f_neb[15] - feq_neb[15]));
const float known_sum = f[0] + f[2] + f[3] + f[4] + f[5] + f[6]
+ f[8] + f[10] + f[11] + f[12] + f[14]
+ f[16] + f[17] + f[18];
const float rem_total = rho_in - known_sum;
// uy constraint:
// uy = (f3 - f4 + f7 - f8 + f11 - f12 + f14 - f13 + f17 - f18) / rho
float y_diff = rho_in * v_tar
- (f[3] - f[4] - f[8] + f[11] - f[12] + f[14] + f[17] - f[18]);
// uz constraint:
// uz = (f5 - f6 + f9 - f10 + f11 - f12 + f16 - f15 + f18 - f17) / rho
float z_diff = rho_in * w_tar
- (f[5] - f[6] - f[10] + f[11] - f[12] + f[16] + f[18] - f[17]);
// Reserve enough total mass for the y-diagonal pair to satisfy positivity.
const float f1_hi = fmaxf(0.0f, rem_total - fabsf(y_diff));
const float f1 = fminf(fmaxf(f1_try, 0.0f), f1_hi);
const float zsum_hi = fmaxf(0.0f, rem_total - f1 - fabsf(y_diff));
const float z_sum = fminf(fmaxf(zsum_try, 0.0f), zsum_hi);
if (fabsf(z_diff) > z_sum) {
z_diff = copysignf(z_sum, z_diff);
}
float y_sum = rem_total - f1 - z_sum;
y_sum = fmaxf(y_sum, 0.0f);
if (fabsf(y_diff) > y_sum) {
y_diff = copysignf(y_sum, y_diff);
}
f[1] = f1;
f[9] = 0.5f * (z_sum + z_diff);
f[15] = 0.5f * (z_sum - z_diff);
f[7] = 0.5f * (y_sum + y_diff);
f[13] = 0.5f * (y_sum - y_diff);
}
__device__ inline void apply_pressure_outlet_3d(float* __restrict__ f,
@ -244,7 +330,7 @@ __device__ inline void apply_pressure_outlet_3d(float* __restrict__ f,
compute_feq(rho_neb, un, vn, wn, feq_neb);
// Reconstruct cx<0 directions: i = 2, 8, 10, 14, 16
#if COLLISION_MODEL == 0
#if COLLISION_MODEL == 0 || COLLISION_MODEL == 1
const float beta = OUTLET_SRT_NEQ_DAMP;
#pragma unroll
for (int i = 0; i < 19; i++) {

View File

@ -6,8 +6,8 @@
#define NT 256
#define MULT_GPU 0
#define NX 2402
#define NY 102
#define NX 1351
#define NY 601
#define NZ 1
// ---- Lattice model (single source of truth) ----

View File

@ -3,7 +3,7 @@
#ifndef CELERIS_CONFIG_METHOD_H
#define CELERIS_CONFIG_METHOD_H
#define COLLISION_MODEL 0
#define COLLISION_MODEL 2
#define STREAMING_MODEL 0
#define STORE_PRECISION 0
#define USE_DDF_SHIFTING 0
@ -12,11 +12,11 @@
#define LES_CS 0.160000f
#define LES_CLOSED_FORM 1
#define INLET_PROFILE 1
#define INLET_PROFILE 0
#define OUTLET_MODE 0
#define OUTLET_BLEND_ALPHA 0.700f
#define OUTLET_BACKFLOW_CLAMP 1
#define Y_WALL_BC 0
#define Y_WALL_BC 1
#define OMEGA_COLLISION_MIN 0.01f
#define OMEGA_COLLISION_MAX 1.960f

View File

@ -4,9 +4,9 @@
#define CELERIS_CONFIG_PHYSICS_H
#define LBtype float
#define VIS 0.0125000000
#define VIS 0.0056250000
#define RHO 1.0
#define U0 0.0277777778
#define U0 0.03
#define PI 3.141592653589793238

View File

@ -1,6 +1,11 @@
// CelerisLab core/params.cuh
// Runtime parameter structures transported via __constant__ memory.
// Includes: LBMParams, RigidBodyState2D, RigidBodyControl2D.
//
// Design note:
// Only parameters that are truly consumed by current kernels should live in
// LBMParams. Future-facing interface placeholders belong in host-side API
// comments or TODOs, not in the active device contract.
// ============================================================================
#ifndef CELERIS_CORE_PARAMS_CUH
@ -9,26 +14,18 @@
#include <stdint.h>
// ============================================================================
// LBM runtime parameters (uploaded once per run or on parameter change)
// LBM runtime parameters
//
// 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.
// Grid topology and collision family are compile-time via generated headers.
// Runtime state is intentionally kept minimal:
// - omega : effective relaxation rate used by SRT/TRT/MRT
// - fx,fy,fz : external body force density for Guo forcing
// - n_objects : packed telemetry contract
// ============================================================================
struct LBMParams {
//--- 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 (runtime-mutable for forcing protocols) ---
float omega;
float fx, fy, fz;
//--- reference quantities (runtime-mutable for ramping) ---
float rho_ref; // 参考密度 (通常 1.0)
float u_inlet; // 入口参考速度
//--- diagnostics ---
unsigned int n_objects; // 观测对象数量
unsigned int n_objects;
};
__constant__ LBMParams d_params;
@ -37,14 +34,14 @@ __constant__ LBMParams d_params;
// Rigid-body state / control (2D, per architecture §17.3)
// ============================================================================
struct RigidBodyState2D {
float x, y, theta; // 位姿 (position + orientation)
float vx, vy, omega; // 速度 (translational + rotational)
float x, y, theta;
float vx, vy, omega;
};
struct RigidBodyControl2D {
float ax_cmd, ay_cmd; // 目标加速度(或目标速度/位移, 取决于 mode
float alpha_cmd; // 目标角加速度 / 角速度
int control_mode; // 0=力控, 1=速度控, 2=位移控
float ax_cmd, ay_cmd;
float alpha_cmd;
int control_mode; // 0=force, 1=velocity, 2=displacement
};
// ============================================================================
@ -53,7 +50,7 @@ struct RigidBodyControl2D {
struct HaloPlan {
int face_id;
int peer_gpu;
int level; // AMR level (0 = base)
int level;
size_t count;
int* send_idx;
int* recv_idx;

View File

@ -15,6 +15,10 @@
// m[6] = qy (energy flux y, s₆ = s_q)
// m[7] = pxx (stress, s₇ = s_nu = ω = 1/(3ν + 0.5))
// m[8] = pxy (stress, s₈ = s_nu)
//
// Forcing contract:
// Fin[i] is the raw Guo source term without the (1 - ω/2) prefactor.
// This operator applies the prefactor internally, matching collide_srt().
// ============================================================================
#ifndef CELERIS_OPERATORS_COLLISION_MRT_CUH
@ -22,30 +26,22 @@
#if LATTICE_MODEL == LATTICE_D2Q9
// ---------------------------------------------------------------------------
// D2Q9 MRT (fully expanded, no loops, no matrix storage)
// ---------------------------------------------------------------------------
__device__ __forceinline__ void collide_mrt(float* __restrict__ g,
float rho, float ux, float uy,
const float* __restrict__ Fin,
float omega)
{
// ----- Relaxation rates -----
// s_rho = s_jx = s_jy = 0.0 (strictly conserved in collision)
// s_e = s_eps = s_q = 1.2 (kinetic transport)
// s_nu = omega (viscosity-related)
const float s_rho = 0.0f; // conserved moments
const float s_rho = 0.0f;
const float s_e = 1.2f;
const float s_eps = 1.2f;
const float s_jx = 0.0f;
const float s_q = 1.2f;
const float s_jy = 0.0f;
const float s_nu = omega;
const float c_tau = 1.0f - 0.5f * omega;
// ----- Pressure from local density -----
const float p = rho * (1.0f / 3.0f);
// ----- Forward transform: m = M * g (new paired ordering) -----
float m[9];
m[0] = g[0] + g[1] + g[2] + g[3] + g[4] + g[5] + g[6] + g[7] + g[8];
m[1] = -4*g[0] - g[1] - g[2] - g[3] - g[4] + 2*g[5] + 2*g[6] + 2*g[7] + 2*g[8];
@ -57,20 +53,18 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
m[7] = g[1] + g[2] - g[3] - g[4];
m[8] = g[5] + g[6] - g[7] - g[8];
// ----- Equilibrium moments -----
const float u2 = ux * ux + uy * uy;
float meq[9];
meq[0] = 3.0f * p; // ρ
meq[1] = -6.0f * p + 3.0f * rho * u2; // e
meq[2] = 3.0f * p - 3.0f * rho * u2; // ε
meq[3] = rho * ux; // jx
meq[4] = -rho * ux; // qx
meq[5] = rho * uy; // jy
meq[6] = -rho * uy; // qy
meq[7] = rho * (ux * ux - uy * uy); // pxx
meq[8] = rho * ux * uy; // pxy
meq[0] = 3.0f * p;
meq[1] = -6.0f * p + 3.0f * rho * u2;
meq[2] = 3.0f * p - 3.0f * rho * u2;
meq[3] = rho * ux;
meq[4] = -rho * ux;
meq[5] = rho * uy;
meq[6] = -rho * uy;
meq[7] = rho * (ux * ux - uy * uy);
meq[8] = rho * ux * uy;
// ----- Relaxation: delta_m[i] = s_i * (meq[i] - m[i]) -----
float dm[9];
dm[0] = s_rho * (meq[0] - m[0]);
dm[1] = s_e * (meq[1] - m[1]);
@ -82,7 +76,6 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
dm[7] = s_nu * (meq[7] - m[7]);
dm[8] = s_nu * (meq[8] - m[8]);
// ----- Inverse transform: g += M⁻¹ * dm (new paired ordering) -----
g[0] += ( dm[0] - dm[1] + dm[2] ) / 9.0f;
g[1] += (4 * dm[0] - dm[1] - 2* dm[2] + 6* dm[3] - 6* dm[4] + 9*dm[7]) / 36.0f;
g[2] += (4 * dm[0] - dm[1] - 2* dm[2] - 6* dm[3] + 6* dm[4] + 9*dm[7]) / 36.0f;
@ -93,14 +86,12 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
g[7] += (4 * dm[0] + 2* dm[1] + dm[2] + 6* dm[3] + 3* dm[4] - 6*dm[5] - 3*dm[6] - 9*dm[8]) / 36.0f;
g[8] += (4 * dm[0] + 2* dm[1] + dm[2] - 6* dm[3] - 3* dm[4] + 6*dm[5] + 3*dm[6] - 9*dm[8]) / 36.0f;
// ----- Add forcing (if present) -----
#pragma unroll
for (int i = 0; i < 9; i++) {
g[i] += Fin[i];
g[i] += c_tau * Fin[i];
}
}
// Convenience wrapper: no external forcing
__device__ __forceinline__ void collide_mrt_no_force(float* __restrict__ g,
float rho, float ux, float uy,
float omega)
@ -111,18 +102,6 @@ __device__ __forceinline__ void collide_mrt_no_force(float* __restrict__ g,
#elif LATTICE_MODEL == LATTICE_D3Q19
// ---------------------------------------------------------------------------
// D3Q19 MRT (tensor-projected multi-mode relaxation)
//
// This implementation performs a true multi-relaxation update by splitting
// non-equilibrium content into three subspaces:
// 1) deviatoric 2nd-order stress modes -> s_nu (shear, controlled by omega)
// 2) isotropic 2nd-order stress mode -> s_bulk (bulk, controlled by omega_bulk)
// 3) higher-order residual modes -> s_high (kinetic damping)
//
// It is formulated in Hermite/tensor space, avoiding explicit 19x19 matrices
// while still relaxing different mode families at different rates.
// ---------------------------------------------------------------------------
__device__ __forceinline__ void collide_mrt(float* __restrict__ g,
float rho, float ux, float uy, float uz,
const float* __restrict__ Fin,
@ -131,23 +110,21 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
float feq[19];
compute_feq(rho, ux, uy, uz, feq);
// Relaxation rates
const float s_nu = omega;
const float s_bulk = (d_params.omega_bulk > 0.0f) ? d_params.omega_bulk : 1.2f;
const float s_bulk = 1.2f;
const float s_high = 1.4f;
const float c_tau = 1.0f - 0.5f * omega;
const float one_minus_s_nu = 1.0f - s_nu;
const float one_minus_s_bulk = 1.0f - s_bulk;
const float one_minus_s_high = 1.0f - s_high;
// Non-equilibrium populations
float neq[19];
#pragma unroll
for (int i = 0; i < 19; i++) {
neq[i] = g[i] - feq[i];
}
// Build non-equilibrium 2nd-order tensor Πneq = Σ (ci ci) (fi-feqi)
float pixx = 0.0f, piyy = 0.0f, pizz = 0.0f;
float pixy = 0.0f, pixz = 0.0f, piyz = 0.0f;
@ -165,7 +142,6 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
piyz += fneq * ci_y * ci_z;
}
// Isotropic / deviatoric split of Πneq
const float tr_pi = pixx + piyy + pizz;
const float tr_pi_thrd = tr_pi * (1.0f / 3.0f);
@ -176,10 +152,7 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
const float dev_xz = pixz;
const float dev_yz = piyz;
// cs^2 = 1/3 => 2*cs^4 = 2/9, inverse = 4.5
// Project neq onto second-order Hermite basis and relax mode families.
const float proj_pref = 4.5f;
const float c_tau = 1.0f - 0.5f * omega;
#pragma unroll
for (int i = 0; i < 19; i++) {
@ -187,7 +160,6 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
const float ci_y = (float)d_cy[i];
const float ci_z = (float)d_cz[i];
// Q = ci ci - cs^2 I
const float q_xx = ci_x * ci_x - CS2;
const float q_yy = ci_y * ci_y - CS2;
const float q_zz = ci_z * ci_z - CS2;
@ -195,12 +167,11 @@ __device__ __forceinline__ void collide_mrt(float* __restrict__ g,
const float q_xz = ci_x * ci_z;
const float q_yz = ci_y * ci_z;
// Contractions Q:Πdev and Q:Πiso
const float q_dot_dev =
q_xx * dev_xx + q_yy * dev_yy + q_zz * dev_zz
+ 2.0f * (q_xy * dev_xy + q_xz * dev_xz + q_yz * dev_yz);
const float q_trace = q_xx + q_yy + q_zz; // = |ci|^2 - 1
const float q_trace = q_xx + q_yy + q_zz;
const float q_dot_iso = q_trace * tr_pi_thrd;
const float neq_dev = d_w[i] * proj_pref * q_dot_dev;

View File

@ -8,13 +8,15 @@
// Paired direction ordering makes TRT natural:
// f_s[i] = 0.5*(f[i] + f[opp(i)]) symmetric
// f_a[i] = 0.5*(f[i] - f[opp(i)]) antisymmetric
//
// Forcing contract:
// Fin[i] is the raw Guo source term without the (1 - ω/2) prefactor.
// This operator applies the prefactor internally, matching collide_srt().
// ============================================================================
#ifndef CELERIS_OPERATORS_COLLISION_TRT_CUH
#define CELERIS_OPERATORS_COLLISION_TRT_CUH
// Magic parameter Λ for TRT wall/collision coupling.
// Default keeps previous behavior; can be overridden in macros.h / tests.
#ifndef TRT_MAGIC_PARAM
#define TRT_MAGIC_PARAM (0.1875f)
#endif
@ -30,34 +32,28 @@ __device__ __forceinline__ void collide_trt(float* __restrict__ f,
{
const float wp = omega;
const float wm = compute_omega_minus(wp);
const float c_tau = 1.0f - 0.5f * wp;
// Direction 0: rest particle treat as symmetric-only
f[0] = f[0] - wp * (f[0] - feq[0]) + Fin[0];
f[0] = f[0] - wp * (f[0] - feq[0]) + c_tau * Fin[0];
// Direction pairs (i, i+1) for i = 1, 3, 5, …
#pragma unroll
for (int i = 1; i < NQ; i += 2) {
const int ib = i + 1; // opposite (paired layout)
const int ib = i + 1;
// Current and opposite
const float fi = f[i];
const float fb = f[ib];
// Equilibrium
const float feqi = feq[i];
const float feqb = feq[ib];
// Symmetric / antisymmetric non-equilibrium
const float delta_s = 0.5f * ((fi - feqi) + (fb - feqb));
const float delta_a = 0.5f * ((fi - feqi) - (fb - feqb));
// Relax + forcing
f[i] = fi - wp * delta_s - wm * delta_a + Fin[i];
f[ib] = fb - wp * delta_s + wm * delta_a + Fin[ib];
f[i] = fi - wp * delta_s - wm * delta_a + c_tau * Fin[i];
f[ib] = fb - wp * delta_s + wm * delta_a + c_tau * Fin[ib];
}
}
// Variant without forcing (Fin = 0)
__device__ __forceinline__ void collide_trt_no_force(float* __restrict__ f,
const float* __restrict__ feq,
float omega)

View File

@ -5,7 +5,6 @@
#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) {
@ -13,7 +12,6 @@ __device__ __forceinline__ void bounce_back_swap(float* f) {
}
}
// ---- Collision dispatch (selects SRT/TRT/MRT + optional LES) ----
__device__ __forceinline__ void collide_dispatch(
float* f, float rho_n,
#if DIM == 2
@ -23,13 +21,35 @@ __device__ __forceinline__ void collide_dispatch(
#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 DIM == 2
const float fx = d_params.fx;
const float fy = d_params.fy;
if (fx != 0.0f || fy != 0.0f) {
apply_guo_velocity_correction(ux, uy, fx, fy, rho_n);
}
compute_feq(rho_n, ux, uy, feq);
if (fx != 0.0f || fy != 0.0f) {
compute_guo_forcing(ux, uy, fx, fy, Fin);
} else {
zero_forcing(Fin);
}
#elif DIM == 3
const float fx = d_params.fx;
const float fy = d_params.fy;
const float fz = d_params.fz;
if (fx != 0.0f || fy != 0.0f || fz != 0.0f) {
apply_guo_velocity_correction(ux, uy, uz, fx, fy, fz, rho_n);
}
compute_feq(rho_n, ux, uy, uz, feq);
if (fx != 0.0f || fy != 0.0f || fz != 0.0f) {
compute_guo_forcing(ux, uy, uz, fx, fy, fz, Fin);
} else {
zero_forcing(Fin);
}
#endif
#if USE_LES
omega_col = compute_omega_smag(f, feq, rho_n, omega_col);
#endif

View File

@ -6,17 +6,16 @@
// 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.
//
// Important timing:
// CurvedBoundaryKernel runs BEFORE the main pull step. It writes corrected
// source populations into obstacle nodes so the pull loader receives valid
// incoming distributions on the same step.
// ============================================================================
#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;
@ -25,8 +24,7 @@ __device__ __forceinline__ float action_omega(const float* action, int body_id)
}
__global__ void CurvedBoundaryKernel(
fpxx* fi_out,
const fpxx* fi_in,
fpxx* fi,
const unsigned int* cl_fluid_idx,
const unsigned char* cl_dir,
const float* cl_q,
@ -42,34 +40,30 @@ __global__ void CurvedBoundaryKernel(
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);
const unsigned long k_f = (unsigned long)cl_fluid_idx[tid];
const unsigned int dir = (unsigned int)cl_dir[tid];
const float q = cl_q[tid];
const int bid = cl_body_id[tid];
const float rx = cl_rx[tid];
const float ry = cl_ry[tid];
const unsigned char fallback_class = cl_fallback_class[tid];
const float omega = action_omega(action, bid);
#if DIM == 2
float Uw = -omega * ry;
float Vw = omega * rx;
const float Uw = -omega * ry;
const 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);
apply_bouzidi_link(dir, q, k_f, fi, 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,
const float rz = cl_rz[tid];
const float Uw = -omega * ry;
const float Vw = omega * rx;
const float Ww = 0.0f;
apply_bouzidi_link(dir, q, k_f, fi, 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,

View File

@ -33,14 +33,28 @@ __device__ __forceinline__ void write_rest_equilibrium(
}
}
__device__ __forceinline__ uint16_t classify_boundary(
__device__ __forceinline__ uint16_t channel_flag_from_coords(
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;
if (y == 0u || y == (unsigned int)(NY - 1))
return (uint16_t)(FLAG_SOLID | FLAG_BC_WALL);
if (x == 0u)
return (uint16_t)(FLAG_SOLID | FLAG_BC_INLET);
if (x == (unsigned int)(NX - 1))
return (uint16_t)(FLAG_SOLID | FLAG_BC_OUTLET);
return FLAG_FLUID;
}
__device__ __forceinline__ uint16_t finalize_domain_flag(
uint16_t fl, unsigned int x, unsigned int y)
{
if (is_obstacle(fl))
return fl;
const uint16_t base = channel_flag_from_coords(x, y);
if (is_sensor(fl) && base == FLAG_FLUID)
return (uint16_t)(base | FLAG_SENSOR);
return base;
}
// ============================================================================
@ -53,12 +67,11 @@ __global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
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);
const uint16_t fl = finalize_domain_flag(flag[k], x, y);
flag[k] = fl;
if (is_solid(fl)) {
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#elif DIM == 3
@ -66,12 +79,11 @@ __global__ void InitTubeFlow_v2(uint16_t* flag, fpxx* fi)
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);
const uint16_t fl = finalize_domain_flag(flag[k], x, y);
flag[k] = fl;
if (is_solid(fl)) {
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#endif
@ -87,11 +99,11 @@ __global__ void InitEsoPull(uint16_t* flag, fpxx* fi)
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);
const uint16_t fl = finalize_domain_flag(flag[k], x, y);
flag[k] = fl;
if (is_solid(fl)) {
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#elif DIM == 3
@ -99,11 +111,11 @@ __global__ void InitEsoPull(uint16_t* flag, fpxx* fi)
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);
const uint16_t fl = finalize_domain_flag(flag[k], x, y);
flag[k] = fl;
if (is_solid(fl)) {
write_rest_equilibrium(fi, k);
} else {
flag[k] = FLAG_FLUID;
write_equilibrium(fi, k, inlet_target_u((float)y));
}
#endif

View File

@ -16,7 +16,7 @@ __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.
// Curved-BC nodes are handled by CurvedBoundaryKernel before OneStep (see stepper).
if (is_curved(fl)) return;
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
@ -46,7 +46,7 @@ __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.
// Curved-BC nodes are handled by CurvedBoundaryKernel before OneStep (see stepper).
if (is_curved(fl)) return;
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));

View File

@ -22,7 +22,8 @@ __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.
// Curved-BC nodes are handled by CurvedBoundaryKernel before the main step when
// streaming is double_buffer; EsoPull skips curved (see stepper).
if (is_curved(fl)) return;
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));
@ -54,7 +55,8 @@ __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.
// Curved-BC nodes are handled by CurvedBoundaryKernel before the main step when
// streaming is double_buffer; EsoPull skips curved (see stepper).
if (is_curved(fl)) return;
bool interior_y = (y > 0u) && (y < (unsigned int)(NY - 1));

View File

@ -19,10 +19,8 @@ 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")
@ -30,11 +28,9 @@ class LBMStepper:
self.step_fn = module.get_function("OneStep")
self.init_fn = module.get_function("InitTubeFlow_v2")
# 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.is_d2q9:
@ -44,7 +40,6 @@ class LBMStepper:
self._step_count = 0
# -- Initialization ------------------------------------------------------
def initialize(self):
"""Run the init kernel to set up channel flow + flags."""
f = self.field
@ -53,14 +48,11 @@ class LBMStepper:
block=self.block, grid=self.grid,
)
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)
f.download_ddf()
# -- Stepping ------------------------------------------------------------
def step(
self,
n: int = 1,
@ -69,22 +61,15 @@ class LBMStepper:
obs_gpu: cuda.DeviceAllocation,
stream: cuda.Stream | None = None,
):
"""Advance *n* time steps.
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).
"""
"""Advance *n* time steps."""
f = self.field
launch_kw = {}
if stream is not None:
launch_kw["stream"] = stream
for _ in range(n):
self._launch_curved(action_gpu, obs_gpu, **launch_kw)
if self._esopull:
self.step_fn(
f.ddf_gpu, f.flag_gpu,
@ -99,29 +84,17 @@ class LBMStepper:
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
# -- 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.
if f.n_curved == 0 or self._esopull:
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
@ -132,7 +105,7 @@ class LBMStepper:
assert cl.fallback_class_gpu is not None
assert cl.body_id_gpu is not None
self.curved_fn(
fi_out, fi_in,
f.ddf_gpu,
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,

View File

@ -63,7 +63,10 @@ class Simulation:
)
self._initialized = False
self._assert_object_count_contract(expected_count=0)
self._load_objects_from_body_config()
if self.bodies.count > 0:
self.recompile()
self._assert_object_count_contract(expected_count=self.bodies.count)
# -- Object management ---------------------------------------------------
def add_cylinder(self, center: Tuple[float, ...],
@ -79,6 +82,33 @@ class Simulation:
def add_object(self, obj: SimObject) -> int:
return self.bodies.add(obj)
def _load_objects_from_body_config(self) -> None:
"""Instantiate simple objects declared in ``config_body.json``."""
for spec in self.body_cfg.objects:
obj_type = str(spec.get("type", "")).strip().lower()
center = tuple(spec.get("center", ()))
radius = float(spec.get("radius", 0.0))
omega = spec.get("omega", None)
if obj_type == "":
raise ValueError("Body config object is missing required key 'type'.")
if len(center) not in (2, 3):
raise ValueError(
f"Body config object '{obj_type}' requires center with 2 or 3 entries."
)
if obj_type == "cylinder":
body_id = self.add_cylinder(center=center, radius=radius)
if omega is not None:
self.bodies.get(body_id).state.omega = float(omega)
elif obj_type == "sensor":
self.add_sensor(center=center, radius=radius)
else:
raise ValueError(
f"Unsupported body config object type '{obj_type}'. "
"Supported types: cylinder, sensor."
)
# -- Compilation ---------------------------------------------------------
def recompile(self):
"""Re-generate config headers and recompile kernel.
@ -106,12 +136,18 @@ class Simulation:
# -- Initialization ------------------------------------------------------
def initialize(self):
"""Initialize flow field and sync objects to GPU."""
# Recompile if objects were added after construction
if self.bodies.count > 0:
# Recompile if objects were added after construction.
if self.bodies.count > 0 and self._read_generated_n_objects() != self.bodies.count:
self.recompile()
self.field.flag = self.field.build_channel_flags()
if self.bodies.count > 0:
self.field.flag = self.bodies.build_flags(self.field.flag)
self.field.upload_flags()
self.stepper.initialize()
if self.bodies.count > 0:
self.bodies.sync_to_gpu(self.field)
self.bodies.sync_to_gpu(self.field, rebuild_flags=False)
else:
self.bodies.ensure_packed_buffers()
self._assert_runtime_contracts()
@ -171,7 +207,7 @@ class Simulation:
def update_runtime_params(self, **kwargs):
"""Update __constant__ d_params without recompiling.
Accepted: omega, omega_bulk, fx, fy, fz, rho_ref, u_inlet, n_objects.
Accepted: omega, fx, fy, fz, n_objects.
"""
self.field.update_params(**kwargs)

View File

@ -1,174 +0,0 @@
# 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).

View File

@ -0,0 +1,353 @@
# Rotating cylinder validation plan
##### [**Undermind**](https://undermind.ai)
---
## Rotating cylinder validation against \[Kan99b\]
This plan defines a practical validation campaign for 2D flow past a rotating circular cylinder using the current CelerisLab solver. The reference is the rotating cylinder study of Kang, Choi, and Lee \[Kan99b\]. The goal is not to reproduce their O type far field mesh exactly. The goal is to show that the present rectangular domain with uniform inflow, slip top and bottom boundaries, curved moving wall treatment, and open outlet can recover the same force, shedding, and suppression trends with controlled boundary sensitivity.
The validation focuses on three questions:
- whether the moving curved wall treatment gives the correct mean force trend under rotation
- whether the wake frequency and fluctuation amplitudes are credible before shedding is suppressed
- whether the predicted critical rotation rate for shedding suppression is close to the literature value
## Reference targets from \[Kan99b\]
The paper defines the Reynolds number and spin ratio as
``` math
Re = \frac{U_\infty D}{\nu}, \qquad \alpha = \frac{\omega_{body} D}{2 U_\infty}
```
and uses the standard force coefficients
``` math
C_D = \frac{2 F_x}{\rho U_\infty^2 D}, \qquad C_L = \frac{2 F_y}{\rho U_\infty^2 D}
```
The fluctuation amplitudes are half peak to peak values over one shedding period, not RMS values \[Kan99b\].
The most useful exact anchor point is the tabulated convergence case at $`Re=100`$ and $`\alpha=1.0`$ \[Kan99b\].
| Quantity | Reference value |
|:-------------------|----------------:|
| $`St`$ | 0.1655 |
| $`\overline{C_L}`$ | -2.4881 |
| $`\overline{C_D}`$ | 1.1040 |
| $`C'_L`$ | 0.3631 |
| $`C'_D`$ | 0.0993 |
The same work gives the most important trend targets for the broader campaign \[Kan99b\].
| Reynolds number | Expected trend | Practical target |
|:---|:---|---:|
| 60 | shedding suppressed near $`\alpha_L`$ | about 1.4 |
| 100 | shedding suppressed near $`\alpha_L`$ | about 1.8 |
| 160 | shedding suppressed near $`\alpha_L`$ | about 1.9 |
| 60 | low rotation mean lift slope | $`\overline{C_L} \approx -2.50\alpha`$ |
| 100 | low rotation mean lift slope | $`\overline{C_L} \approx -2.48\alpha`$ |
| 160 | low rotation mean lift slope | $`\overline{C_L} \approx -2.46\alpha`$ |
The paper also reports that its outer boundary sensitivity is already very small at far field radius $`R_D=50`$, and that increasing the radius to $`R_D=100`$ changes the main outputs by less than about half a percent for the anchor case \[Kan99b\]. That result motivates an explicit boundary independence check in the present rectangular setup.
## Solver setup for this campaign
The campaign assumes the present code path and keeps all nonessential choices fixed.
| Item | Setting |
|:---|:---|
| Dimension | 2D |
| Lattice | D2Q9 |
| Streaming | double buffer |
| Curved boundary | current Bouzidi moving wall implementation |
| Inlet profile | uniform |
| Top and bottom boundaries | free slip |
| Outlet | neq extrapolation |
| LES | off |
| Precision | FP32 |
| Cylinder diameter | $`D=30`$ lattice units |
| Cylinder radius | $`R=15`$ lattice units |
| Rotation input | update body omega only |
| Output cadence for force history | every 100 steps |
| Sensors | none |
| Flow diagnosis | saved flow fields and derived images |
This campaign evaluates SRT, TRT, and MRT separately. The same geometry, flow scales, and post processing rules should be used for all three collision models.
## Parameter mapping in lattice units
The recommended inflow speed is
``` math
U_\infty = 0.03
```
This keeps the Mach number low while leaving enough dynamic range for the force signals.
With $`D=30`$, the required viscosity for each Reynolds number is
``` math
\nu = \frac{U_\infty D}{Re} = \frac{0.9}{Re}
```
which gives the following values.
| $`Re`$ | $`\nu`$ | SRT equivalent $`\omega = 1 \mathbin{/} (3\nu + 0.5)`$ |
|:-------|---------:|-------------------------------------------------------:|
| 60 | 0.015000 | 1.83486 |
| 100 | 0.009000 | 1.89753 |
| 160 | 0.005625 | 1.93470 |
The body rotation rate in lattice units is
``` math
\omega_{body} = \frac{2 \alpha U_\infty}{D} = 0.002\alpha
```
which gives the following direct conversion table.
| $`\alpha`$ | body omega |
|:-----------|-----------:|
| 0.0 | 0.0000 |
| 0.5 | 0.0010 |
| 1.0 | 0.0020 |
| 1.4 | 0.0028 |
| 1.8 | 0.0036 |
| 1.9 | 0.0038 |
| 2.0 | 0.0040 |
## Computational domain family
Because the current solver uses a rectangular box rather than the O type far field mesh of \[Kan99b\], boundary sensitivity must be checked explicitly. With free slip top and bottom boundaries, the lateral confinement error should be much weaker than with no slip walls, so a moderate family of domain sizes is sufficient for the first pass.
The recommended domain family is defined in cylinder diameters.
| Domain | Upstream distance | Downstream distance | Height | Integer grid for $`D=30`$ |
|:---|---:|---:|---:|---:|
| S | 12D | 24D | 16D | 1081 by 481 |
| M | 15D | 30D | 20D | 1351 by 601 |
| L | 20D | 40D | 24D | 1801 by 721 |
The corresponding cylinder centers are
| Domain | Cylinder center |
|:-------|:----------------|
| S | 360, 240 |
| M | 450, 300 |
| L | 600, 360 |
These sizes are measured from the cylinder center to the inlet, outlet, and slip boundaries. The baseline domain should be M. Domain S and L are used only to establish boundary independence.
## Test phases
## Phase A
The first phase chooses a domain that is large enough for the rest of the campaign.
Run only the anchor case
``` math
Re = 100, \qquad \alpha = 1.0
```
with MRT on domains S, M, and L.
For each run, compute
- $`\overline{C_D}`$
- $`\overline{C_L}`$
- $`C'_D`$
- $`C'_L`$
- $`St`$
Pick the smallest domain for which the change from the next larger domain is small enough.
| Quantity | Domain independence target |
|:-------------------|---------------------------:|
| $`St`$ | less than 1 percent |
| $`\overline{C_L}`$ | less than 2 percent |
| $`\overline{C_D}`$ | less than 2 percent |
| $`C'_L`$ | less than 3 percent |
| $`C'_D`$ | less than 3 percent |
If M satisfies these limits against L, the rest of the campaign should use M. If not, use L.
## Phase B
Once the domain is fixed, run the same anchor case with all three collision models.
| Collision model | Required run |
|:----------------|:-----------------------|
| SRT | $`Re=100, \alpha=1.0`$ |
| TRT | $`Re=100, \alpha=1.0`$ |
| MRT | $`Re=100, \alpha=1.0`$ |
Compare each run to the exact anchor values from \[Kan99b\].
| Quantity | Preferred agreement band |
|:-------------------|-------------------------:|
| $`St`$ | within 3 percent |
| $`\overline{C_L}`$ | within 4 percent |
| $`\overline{C_D}`$ | within 5 percent |
| $`C'_L`$ | within 8 percent |
| $`C'_D`$ | within 10 percent |
This phase also checks collision model consistency. SRT, TRT, and MRT should not disagree strongly if the moving wall and force extraction are implemented correctly.
## Phase C
After the anchor case is established, run the main rotation scan at $`Re=100`$ for all three collision models.
| Case | $`\alpha`$ | body omega |
|:-----|-----------:|-----------:|
| A0 | 0.0 | 0.0000 |
| A1 | 0.5 | 0.0010 |
| A2 | 1.0 | 0.0020 |
| A3 | 1.5 | 0.0030 |
| A4 | 1.7 | 0.0034 |
| A5 | 1.8 | 0.0036 |
| A6 | 1.9 | 0.0038 |
| A7 | 2.0 | 0.0040 |
The expected signatures from the paper are \[Kan99b\]:
- mean lift becomes more negative roughly linearly at low $`\alpha`$
- mean drag decreases as $`\alpha`$ increases
- Strouhal number stays nearly constant while periodic shedding exists
- shedding disappears near $`\alpha \approx 1.8`$
The low rotation linear fit gives a strong consistency check.
``` math
\overline{C_L} \approx -2.48\alpha \qquad Re=100
```
## Phase D
The final phase checks whether the code recovers the Reynolds number dependence of the suppression threshold.
Run the following reduced sets for all three collision models.
| Reynolds number | $`\alpha`$ values |
|:----------------|:----------------------------------|
| 60 | 0.0, 0.5, 1.0, 1.2, 1.4, 1.6 |
| 160 | 0.0, 0.5, 1.0, 1.5, 1.8, 1.9, 2.0 |
The expected signatures are \[Kan99b\]:
- $`Re=60`$ should lose periodic shedding near $`\alpha \approx 1.4`$
- $`Re=160`$ should lose periodic shedding near $`\alpha \approx 1.9`$
- the low rotation lift slopes should be about $`-2.50\alpha`$ at $`Re=60`$ and about $`-2.46\alpha`$ at $`Re=160`$
## Run length and output policy
The lattice time step is very small, so dense output is unnecessary. Integrated quantities should be written every 100 steps. Full field dumps should be much less frequent.
The recommended policy is
| Output type | Cadence |
|:---|:---|
| force and torque history | every 100 steps |
| full field dump during warmup | every 2000 steps |
| full field dump during statistics window | every 1000 steps |
| final image export for report | selected representative times only |
The minimum full field content is
- density
- $`u_x`$
- $`u_y`$
Derived images should be generated in post processing.
| Image | Purpose |
|:---|:---|
| vorticity | identify alternating vortex shedding and its suppression |
| velocity magnitude | show wake width and accelerated side of the rotating cylinder |
| streamwise velocity | show wake recovery and reverse flow region |
No sensors are required for this campaign. Flow images plus the integrated force history are sufficient.
## Statistics window
The force history should be split into a warmup window and a statistics window. Near the suppression threshold, transients decay slowly, so longer runs are needed.
| Case type | Total steps | Warmup | Statistics |
|:---|---:|---:|---:|
| anchor case | 180000 to 220000 | first 40 percent | last 60 percent |
| near critical $`\alpha`$ | 220000 to 280000 | first 50 percent | last 50 percent |
| clearly periodic or clearly steady case | 140000 to 180000 | first 40 percent | last 60 percent |
The final statistics window should contain at least 20 shedding periods whenever the case is periodic.
## Post processing rules
Compute the force coefficients from the saved force history using
``` math
C_D = \frac{2 F_x}{\rho U_\infty^2 D}, \qquad C_L = \frac{2 F_y}{\rho U_\infty^2 D}
```
For periodic cases, compute
- $`\overline{C_D}`$ and $`\overline{C_L}`$ as time averages over the statistics window
- $`C'_D`$ and $`C'_L`$ as half peak to peak values over each period, then average those amplitudes over the statistics window
- $`St`$ from the dominant frequency of $`C_L`$
The preferred frequency estimate is FFT of $`C_L`$. A zero crossing estimate may be used as a cross check.
A case should be classified as steady when both conditions hold:
- the last part of the $`C_L`$ signal no longer shows a sustained periodic component above noise level
- the vorticity images no longer show an alternating Karman street
A practical threshold for suppression is that the measured $`C'_L`$ in the final window falls below 5 percent of the $`\alpha=0`$ value at the same Reynolds number and collision model.
## Deliverables for the coder
The coder should deliver the following for each completed phase.
- one configuration table listing domain, collision model, Reynolds number, viscosity, $`\alpha`$, body omega, and total step count
- one CSV file per run with force history sampled every 100 steps
- one folder of selected field snapshots
- one post processing table with $`\overline{C_D}`$, $`\overline{C_L}`$, $`C'_D`$, $`C'_L`$, and $`St`$
- one summary plot of $`\overline{C_L}`$ versus $`\alpha`$
- one summary plot of $`\overline{C_D}`$ versus $`\alpha`$
- one summary plot of $`C'_L`$ versus $`\alpha`$
- one summary plot of $`St`$ versus $`\alpha`$
- one domain sensitivity comparison for the anchor case
- one short note stating whether shedding suppression occurs near the expected $`\alpha_L`$
Executable driver for this deliverable set:
- [tests/run_kan99b_rotating_cylinder.py](tests/run_kan99b_rotating_cylinder.py)
Example commands:
```bash
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase a
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase b --domain M
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase all --minimal --domain M
```
## Minimum run set
If compute budget is tight, the minimum useful run set is the following.
| Phase | Runs |
|:---|:---|
| domain choice | MRT on S, M, L for $`Re=100, \alpha=1.0`$ |
| anchor comparison | SRT, TRT, MRT on the chosen domain for $`Re=100, \alpha=1.0`$ |
| main trend check | SRT, TRT, MRT on the chosen domain for $`Re=100`$ with $`\alpha = 0.0, 1.0, 1.5, 1.8, 2.0`$ |
| threshold check | SRT, TRT, MRT on the chosen domain for $`Re=60, \alpha=1.4`$ and $`Re=160, \alpha=1.9`$ |
This reduced set is enough to answer the main validation question. A larger scan can be added only after the anchor and threshold cases behave correctly.
---
## References
\[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).

View File

@ -0,0 +1,147 @@
# Sah04 St validation matrix
##### [**Undermind**](https://undermind.ai)
---
## Sah04 St validation matrix
A Strouhal based matrix should lean on cases where \[Sah04\] gives an explicit frequency target, or a target that can be derived directly from a stated shedding period. With cylinder diameter fixed at D = 30, that rules out a strict low to mid to high blockage Cartesian grid if every cell is meant to carry a hard 5 percent St gate. The paper simply does not publish a dense 3 by 3 table of supercritical confined flow frequencies. The most defensible 27 run plan is therefore a nine case matrix chosen to maximize paper anchored St targets, then run with SRT, TRT, and MRT on each case.
## Fixed setup
- Geometry follows \[Sah04\]
- Upstream length is 40D
- Downstream length is 40D
- Total fluid length is 80D
- With D = 30, use nx = 80D + 2 = 2402
- Cylinder radius is 15
- Cylinder x center is 40D + 0.5 = 1200.5
- Inlet profile is parabolic
- Reynolds number is defined with U_max and D
- Strouhal number is defined as St = fD U_max^-1
- For all cases, keep U_max fixed and set viscosity from Re = U_max D nu^-1
## Blockage levels
The table below gives the three blockage levels to use with D fixed at 30.
| Blockage tier | Nominal beta | H | ny | Realized beta | Cylinder center |
|:--------------|-------------:|----:|----:|--------------:|:----------------|
| Low | 0.5 | 60 | 62 | 0.5000 | 1200.5, 30.5 |
| Mid | 0.8 | 38 | 40 | 0.7895 | 1200.5, 19.5 |
| High | 0.9 | 33 | 35 | 0.9091 | 1200.5, 17.0 |
The low blockage tier is exact on the D = 30 grid. The two higher blockage tiers use the nearest integer channel heights. This keeps the matrix practical while staying close to the \[Sah04\] confined flow regime where supercritical St values are actually reported.
## Nine Sah04 cases
Run every row below with SRT, TRT, and MRT. That gives 27 total runs.
| Case | Blockage tier | Re | Target St | Target class |
|:-----|:--------------|-------:|----------:|:-----------------------------------------|
| 1 | Low beta 0.5 | 124.09 | 0.3393 | Direct from Table IV |
| 2 | Low beta 0.5 | 160 | 0.3450 | Interpolated between Table IV and Re 200 |
| 3 | Low beta 0.5 | 200 | 0.3513 | Direct from Section IV.B |
| 4 | Mid beta 0.8 | 110.24 | 0.5363 | Direct from Table IV |
| 5 | Mid beta 0.8 | 160 | 0.5537 | Derived from stated period T ≈ 1.806 |
| 6 | Mid beta 0.8 | 200 | 0.5510 | Derived from stated period T ≈ 1.815 |
| 7 | High beta 0.9 | 162.82 | 0.5202 | Direct from Table IV |
| 8 | High beta 0.9 | 180 | 0.5254 | Interpolated between Table IV and Re 200 |
| 9 | High beta 0.9 | 200 | 0.5314 | Direct from Section IV.B |
## How to use the targets
The nine cases are not equal in evidential weight.
- Hard pass fail cases
- Case 1
- Case 3
- Case 4
- Case 5
- Case 6
- Case 7
- Case 9
These have targets stated directly in \[Sah04\], or obtained directly from a shedding period stated in the paper. A 5 percent St gate is reasonable here.
- Soft trend cases
- Case 2
- Case 8
These are useful to see whether each collision model follows the same St trend between two paper anchored points. They should not carry the same weight as the hard cases.
## Recommended run settings
Use one sampling and averaging policy across the whole matrix so the comparisons stay clean.
- D = 30 for all runs
- record_every = 5
- double buffer streaming
- same inlet formulation and force extraction for all collisions
- same FFT windowing and same St extraction routine for all runs
The automated runner ``tests/run_sah04_st_matrix.py`` uses a band around the
paper target shedding frequency ``f0 = St_target * U_max / D`` and reports
**two** Strouhal numbers: **raw** (plain band-limited dominant peak) and
**guided** (same band with a mild Gaussian weight toward ``f0`` to reduce
harmonic ambiguity in narrow channels). The **5% / 10%** hard-case rules
apply to **guided** ``St``; **raw** is for diagnosing pick ambiguity (e.g.
supercritical mid/high cases).
For the lower Re onset cases, the frequency estimate is more sensitive to burn in and window length. A conservative default is:
- Cases 1, 4, 7
- 80k total steps
- 30k burn
- Cases 2, 3, 5, 6, 8, 9
- 60k total steps
- 20k burn
If TRT is visibly noisier on the onset cases, extend TRT alone to the longer window rather than changing the matrix.
**Runner (repo):** ``tests/run_sah04_st_matrix.py``
- Full MRT sweep: ``conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --collision MRT``
- All collisions (27 runs): ``... --collision all``
- Quick wiring check: add ``--smoke`` (short steps; St only qualitative)
- Single case: ``--case 4`` ; JSON: ``--json-out path.json``
- Override length: ``--steps`` / ``--burn`` (ignored with ``--smoke``)
- Long archive: ``--dump-npz-dir DIR`` writes ``case{id}_{COLL}.npz`` (lift, drag samples, LBM step index per sample, post-burn ``freqs_hz`` / power, band mask, guided Gaussian weight) plus ``.meta.json``
## Long runs (cases 6 and 9)
For spectrum stability checks (not the 9-case matrix gate), a **minimal** set is six runs: MRT/TRT/SRT × case 6 and case 9. Suggested first budget: **steps 200000**, **burn 80000**, ``record_every=5`` (same as matrix), with ``--dump-npz-dir`` so raw vs guided can be recomputed offline from ``power_post_burn`` and the saved band weights.
## Evaluation rule
Judge each collision model on the hard cases first.
- Primary rule
- At least 5 of the 7 hard cases within 5 percent in St
- No hard case worse than 10 percent
- Secondary rule
- Cases 2 and 8 should lie between their neighboring hard target values in the correct order
- SRT, TRT, and MRT should preserve the same beta and Re trend even when their absolute errors differ slightly
## Why this matrix and not a strict Cartesian 3 by 3
\[Sah04\] gives systematic confined flow St targets at the onset of periodic shedding in Table IV, plus a small number of direct supercritical DNS values in Section IV.B. It does not provide a full Cartesian table of supercritical St values for low, mid, and high blockage at three common Reynolds numbers. The matrix above is therefore built to maximize direct paper anchors rather than to force a mathematically neat but weakly supported grid.
## Exact beta alternative
If exact blockage ratios on the D = 30 grid matter more than supercritical St anchor density, use beta = 0.1, 0.3, and 0.5 instead. That gives exact channel heights H = 300, 100, and 60. The tradeoff is that \[Sah04\] supplies far fewer direct supercritical St targets in that lower blockage band, so the resulting matrix is less suitable for a clean 5 percent St gate.
---
## 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).

View File

@ -1,258 +0,0 @@
# 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).

555
tests/kan99b.md Normal file
View File

@ -0,0 +1,555 @@
RESEARCH ARTICLE | NOVEMBER 01 1999
# Laminar flow past a rotating circular cylinder
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/32898014976d73eb93d0663896822077ae6fdff7f7afc0394359a57e6f66a430.jpg)
Sangmo Kang; Haecheon Choi; Sangsan Lee
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/5f0858cc36b3cd2ea375e968f2a4573a7e021515024943a9b5f96f7c9951041b.jpg)
Check for updates
Physics of Fluids 11, 33123321 (1999)
https://doi.org/10.1063/1.870190
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/56f85fdf958a7663502f3498a6702f6ab87ce0088d6caadbb136d297cd403006.jpg)
View Online
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/79dc4f785cdedfbc4f60c9c3a5debff497e1ac383377895c5cb223ba01a4402a.jpg)
Export Citation
# Articles You May Be Interested In
Lagrangian analysis of the laminar flat plate boundary layer
Physics of Fluids (October 2016)
Laminar flow instability in a rectangular channel with a cylindrical core
Physics of Fluids (April 2006)
Characteristics of laminar flow past a sphere in uniform shear
Physics of Fluids (October 2005)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/0b72ea6a3951956e1b4053131b378ce540da04064f8a4e3842d504ead6fd0e12.jpg)
# AIP Advances
# Why Publish With Us?
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/4ae229b78ba474fc71eedfd6facfb6b572a949c817b48a641c21e1e9fedd499b.jpg)
21DAYS average time to1st decision
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/a9b9f50bda43ea8669b26f5f30cb37825007046730ae06c4f94ed6ad5913c692.jpg)
OVER 4 MILLION views in the last year
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/74a2998b471473305428ed5a153c460fa0855f0426a61f5361e09b14eb4f0fe3.jpg)
INCLUSIVE scope
Learn More
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/7950151c72385430008b42e9f9fc13f33f7be37df41c89618838a9e18fa9d325.jpg)
AIP Publishing
# Laminar flow past a rotating circular cylinder
Sangmo Kanga) and Haecheon Choib)
National CRI Center for Turbulence and Flow Control Research, Institute of Advanced Machinery and Design, Seoul National University, Seoul 151-742, Korea
Sangsan Lee
ETRI Supercomputer Center, P.O. Box 1, Yusong, Taejeon 305-600, Korea
~Received 7 October 1998; accepted 20 July 1999!
The present study numerically investigates two-dimensional laminar flow past a circular cylinder rotating with a constant angular velocity, for the purpose of controlling vortex shedding and understanding the underlying flow mechanism. Numerical simulations are performed for flows with Re560, 100, and 160 in the range of $0 { \leqslant } \alpha { \leqslant } 2 . 5 ,$ , where is the circumferential speed at the cylinder surface normalized by the free-stream velocity. Results show that the rotation of a cylinder can suppress vortex shedding effectively. Vortex shedding exists at low rotational speeds and completely disappears at $\alpha > \alpha _ { L }$ , where $\alpha _ { L }$ is the critical rotational speed which shows a logarithmic dependence on Re. The Strouhal number remains nearly constant regardless of while vortex shedding exists. With increasing , the mean lift increases linearly and the mean drag decreases, which differ significantly from those predicted by the potential flow theory. On the other hand, the amplitude of lift fluctuation stays nearly constant with increasing $( < \alpha _ { L } )$ , while that of drag fluctuation increases. Further studies from the instantaneous flow fields demonstrate again that the rotation of a cylinder makes a substantial effect on the flow pattern. © 1999 American Institute of Physics. @S1070-6631~99!01211-8#
# I. INTRODUCTION
Flow past a circular cylinder has been accepted as a building-block problem for understanding the vortex dynamics in bluff body wakes. At a low Reynolds number ~Re ,47!, the wake behind a ~nonrotating! cylinder comprises a steady recirculation region with two vortices symmetrically attached to the cylinder, whose size grows with increasing Reynolds number. Here, the Reynolds number Re is defined as $\mathrm { R e } { = } U _ { \infty } d / \nu ,$ where $U _ { \infty }$ is the free-stream velocity, d the diameter of the cylinder, and the kinematic viscosity. At a Reynolds number of 47<Re,200, vortex shedding occurs in the near wake behind a cylinder due to the flow instability accompanying a large fluctuating pressure and, thus, a periodically oscillating lift force. At a higher Reynolds number ~Re>200!, the flow becomes three-dimensional and turbulent, and vortex shedding also occurs but in more complicated patterns. Such fluctuating forces induced from vortex shedding may cause structural vibrations, acoustic noise or resonance, which in some cases can trigger structure failure or enhance mixing in the wake.1 Therefore, controlling vortex shedding appropriately is critical in practical engineering environments. Many attempts have been made for controlling the wake behind a circular cylinder in recent years, especially for the purpose of suppressing vortex shedding, using passive or ~nonfeedback or feedback! active controls.
The rotation of a cylinder in a viscous uniform flow is expected to modify wake flow pattern and vortex shedding configuration, which may reduce a flow-induced oscillation or augment a lift force. The basic rationale behind the rotation effect is that as a cylinder rotates, the flow is accelerated on one side of the cylinder and decelerated on the other side. Hence, the pressure on the accelerated side becomes smaller than that on the decelerated side, resulting in a mean lift force. Such a phenomenon is referred to as the Magnus effect. As a result, the rotation significantly alters the flow pattern, and probably has an effect on a flow-induced oscillation. Most of such researches performed to date are classified largely into two categories, a rotary oscillation and a unidirectional rotation. Flow past a circular cylinder performing a rotary oscillation has been investigated by many researchers; for example, experimentally by Taneda,2 Tokumaru and Dimotakis,3 and Filler et al.4 and numerically by Wu et al.5 and Baek and Sung.6
There have also been numerous investigations of flow past a circular cylinder rotating with a constant angular velocity, which is the main concern of the present study. The flow past a circular cylinder started impulsively into rotation and translation from rest becomes transient in an early time, and then reaches either a steady or unsteady ~time-periodic! state depending on two parameters, Re and . Here, the rotational speed is defined as $\alpha { = } \dot { \theta } d / ( 2 U _ { \infty } )$ , where ˙ is the angular velocity of the cylinder. Ingham,7 Badr et $a l . , ^ { 8 }$ and Ingham and Tang9 numerically investigated the rotatingcylinder flow in the laminar steady regime ~Re,47! for relatively small rotational speeds ~ <3!. They found that, although vortex shedding does not occur in the wake, the rotation delays and even inhibits the boundary-layer separation. The flow in the laminar vortex shedding regime $( 4 7 { \leqslant } \mathrm { R e } < 2 0 0 )$ has also been investigated in the literature. Badr $e t a l . ^ { 8 }$ numerically investigated unsteady flow at Re 560, 100, and 200, focusing on the flow pattern during an early time period after the impulsive rotation and translation of a cylinder. Subsequently, Tang and Ingham10 examined steady flow at $\mathrm { R e } { = } 6 0$ and 100 in the range of $0 { \leqslant } \alpha { \leqslant } 1$ by solving time-independent governing equations, and presented the changes in the flow variables. To the best of our knowledge, there is no literature that investigates the rotation effect on the laminar flow in the fully developed stage involving vortex shedding. This has mainly motivated the present numerical simulation of flow past a constantly rotating cylinder in the laminar vortex shedding regime.
Flow at a higher Reynolds number $( \mathrm { R e } { \geqslant } 2 0 0 )$ is more difficult to numerically simulate because of its intrinsic three-dimensionality and complexity. Tokumaru and Dimotakis11 experimentally investigated flow past a circular cylinder with both net steady rotation and forced oscillations at $\mathrm { R e } { \approx } 1 0 ^ { 3 }$ by suggesting the so-called virtual vortex method. They found that a higher cylinder aspect ratio yields a higher maximum lift coefficient in the case of steady rotation and the addition of forced rotary oscillations to the steady rotation increases or decreases the lift coefficient. Most investigations of unsteady flow at a high Reynolds number are limited to an early transient period because the flow at a later time becomes too complex to analyze. Badr and $\mathrm { D e n n i s } ^ { 1 2 }$ numerically investigated unsteady flow for $\mathbf { R e } { \geqslant } 2 0 0$ , while Coutanceau and $\mathrm { M e n a r d } ^ { 1 3 }$ experimentally investigated the same flow ~ 50.5 and 1! for comparison. Subsequently, Badr et al.14 carried out a comparative study of both the time evolutions of two-dimensional unsteady flow obtained numerically and experimentally for $1 0 ^ { 3 } { \leqslant } \mathrm { R e } { \leqslant } 1 0 ^ { 4 }$ and $0 . 5 { \leqslant } \alpha { \leqslant } 3$ . They found that vortex shedding completely disappears for $\alpha > \alpha _ { L }$ , where the critical value $\alpha _ { L }$ is almost independent of Re and is about 2. Chen et al.15 investigated the same unsteady flow at $ { \mathrm { R e } } { = } 2 0 0$ for $\alpha { \leqslant } 3 . 2 5$ , and reported that shedding of more than one vortex occurs even for $\alpha { = } 3 . 2 5$ , which is in contrast to the previous result of Badr et al.14 In company with these conflicting observations, there have been a few systematic researches about $\alpha _ { L }$ and its dependence on Re when a cylinder rotates at a constant angular velocity. Chang and Chern16 and Chen et al.15 suggested different basic modes of vortex shedding depending on Re and , respectively, by identifying the behavior of stagnation point and by characterizing the asymptotic stability of the flow. Recently, Hu et al.17 systematically studied the Hopf bifurcation describing the transition from a steady to timeperiodic solution through a Galerkin method and a system theory. They obtained the transition curve on a plane of Re and and reported that the rotation may delay the onset of vortex shedding and decrease the vortex-shedding frequency.
The objectives of the present study are to numerically investigate the effect of the rotation on flow past a constantly rotating circular cylinder in the laminar vortex shedding regime $( 4 7 { \leqslant } \mathrm { R e } < 2 0 0 )$ and to understand the underlying mechanism of vortex shedding suppression. This is accomplished by presenting the Strouhal number, mean flow quantities at the cylinder surface, lift and drag coefficients, and instantaneous flow fields in the fully developed stage, at various Re and . The two-dimensional unsteady NavierStokes equations are solved using a fully-implicit, fractional-step method in time18 and a second-order central difference scheme in space. The numerical simulations are performed for flows at Re540, 60, 100, and 160 in the range of $0 { \leqslant } \alpha { \leqslant } 2 . 5$ . Note that, in case of a stationary cylinder, the flow is steady at $\mathrm { R e } { = } 4 0$ and unsteady ~time-periodic! at Re 560, 100, and 160. All the numerical simulations are continued until the flow reaches a fully developed state, where all the flow characteristics are analyzed. The paper is organized as follows: Sec. II explains details of the numerical method. Subsequently, in Sec. III, we discuss the results from the simulations. Finally, a summary is presented in Sec. IV.
# II. NUMERICAL METHOD
The two-dimensional unsteady governing equations for an incompressible flow can be written as
$$
\frac {\partial u _ {i}}{\partial t} + \frac {\partial (u _ {i} u _ {j})}{\partial x _ {j}} = - \frac {\partial p}{\partial x _ {i}} + \frac {1}{\mathrm{Re}} \frac {\partial^ {2} u _ {i}}{\partial x _ {j} \partial x _ {j}}, \tag {1}
$$
$$
\frac {\partial u _ {i}}{\partial x _ {i}} = 0, \tag {2}
$$
where $x _ { i }$ are the Cartesian coordinates and $u _ { i }$ are the corresponding velocity components. All variables are nondimensionalized by the cylinder diameter d and the free-stream velocity $U _ { \infty }$ . Notice that the notation sets $( u , v )$ and $( x , y )$ are used interchangeably with $( u _ { 1 } , u _ { 2 } )$ and $( x _ { 1 } , x _ { 2 } )$ , respectively, in this paper.
Equations ~1! and ~2! are transformed to, by introducing generalized coordinates $\eta ^ { i }$ and volume fluxes $q ^ { i }$ across the faces of the cell,
$$
\frac {\partial q ^ {i}}{\partial t} + N ^ {i} (\mathbf {q}) = - G ^ {i} (p) + L _ {1} ^ {i} (\mathbf {q}) + L _ {2} ^ {i} (\mathbf {q}), \tag {3}
$$
$$
D ^ {i} q ^ {i} = \frac {1}{\mathbf {J}} \left(\frac {\partial q ^ {1}}{\partial \eta^ {1}} + \frac {\partial q ^ {2}}{\partial \eta^ {2}}\right) = 0, \tag {4}
$$
where $ { \mathbf { q } } = ( q ^ { 1 } , q ^ { 2 } )$ , Ni is the convection term, $G ^ { i } ( p )$ is the pressure gradient term, $L _ { 1 } ^ { i }$ and $L _ { 2 } ^ { i }$ are the diffusion terms without and with cross-derivatives, respectively, and $D ^ { i }$ is the divergence operator. More details are described in Choi et $a l . ^ { 1 8 }$ The transformed equations ~3! and ~4! are integrated in time using a fully-implicit, fractional-step method,18 composed of four-step time splitting. All terms in ~3! including cross-derivative diffusion terms are advanced with the CrankNicolson method in time and are resolved with a second-order central difference scheme in space. A Newton method is used to solve the discretized nonlinear equation.
The flow geometry and coordinate system along with boundary conditions are shown in Fig. 1. Here, notation sets $( r , \theta )$ and $( u _ { r } , u _ { \theta } )$ are also introduced to address, respectively, the radial and azimuthal directions and their corresponding velocity components. In this study, we use an O-type mesh, rather than a C-type mesh which may not be appropriate in the case of ~oscillatory! rotation of a cylinder.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/ea1c900021313cb41dd9d5f6a3b3cf021b3c9489daad2af05300a3a488c93d80.jpg)
FIG. 1. Flow geometry and coordinate system along with boundary conditions.
In the C-type mesh in case of rotation, the nonzero circumferential velocity of a cylinder, unless particularly considered, makes the solution singular at the base point, causing a fatal spurious effect on the flow field. The O-type mesh used in this study is created following Beaudan and $\mathrm { M o i n } . ^ { 1 9 }$ For the computational domain with $0 . 5 { \leqslant } r { < } R _ { D }$ and $0 ^ { \circ } { \leqslant } \theta$ $< 3 6 0 ^ { \circ }$ , the grid points $r _ { m } ~ ( m { = } 1 , 2 , . . . , M )$ in the radial direction are
$$
r _ {m} = 0. 5 + (R _ {D} - 0. 5) \frac {1 - s ^ {(m - 1)}}{1 - s ^ {M - 1}}, \tag {5}
$$
where $R _ { D }$ is the radius of the computational domain, s the stretching factor and M the number of the radial grid points. The grid points $\theta _ { n } \ ( n { = } 1 , 2 , . . . , N )$ in the azimuthal direction are more clustered in the wake region than in the potential region, where N is the number of the azimuthal grid points. The grid boundary, or envelope, of the wake region $( x _ { b } ( h ) , y _ { b } ( h ) )$ is specified analytically as
$$
\left\{ \begin{array}{c} x _ {b} (h) = \left(R _ {D} \cos \theta_ {0} - \cos \theta_ {i} + G\right) h ^ {2} - G h + \cos \theta_ {i}, \\ y _ {b} (h) = \left(R _ {D} \sin \theta_ {0} - \sin \theta_ {i} + G \tan \theta_ {i}\right) \frac {e ^ {b h} - 1 - b h}{e ^ {b} - 1 - b} \\ - G h \tan \theta_ {i} + \sin \theta_ {i}, \end{array} \right. \tag {6}
$$
where h is a continuous parameter and $\theta _ { i }$ and $\theta _ { 0 }$ are the azimuthal positions of the grid boundary at $r = 0 . 5$ and $R _ { D }$ , respectively. Within the wake region, the azimuthal grid points are equispaced. In this study, the grid points $( r _ { m } , \theta _ { n } )$ are generated with $s = 1 . 0 1 6 , G = - 5 , b = 1 0 , \theta _ { i } = 8 5 ^ { \circ }$ , and $\theta _ { 0 } = 3 0 ^ { \circ }$ .
The condition of a constant circumferential velocity, $u _ { r }$ ${ } = 0$ and $u _ { \theta } { = } \alpha$ , is imposed on the cylinder surface. The farfield boundary is divided into inflow and outflow boundaries, that is $x { \leqslant } 0$ and $x { > } 0$ , respectively. The former boundary has a Dirichlet boundary condition, $u = 1$ and $\scriptstyle { V = 0 }$ , while the latter boundary has a convective outflow condition, $\partial u _ { i } / \partial t$ $+ c \partial u _ { i } / \partial x = 0 , ^ { 2 0 }$ where c is the space-averaged streamwise ~x-direction! exit velocity.
In the present study, a parameter set of $R _ { D } { = } 5 0 , M { \times } N$ $= 2 4 1 \times 2 4 1$ and $\Delta t = 0 . 0 2$ has been chosen to solve the governing equations on the O-type mesh. The computational time step $\Delta t = 0 . 0 2$ corresponds to a maximum $\mathrm { C F L } { = } 1 . 5 { - }$ 4.5. To confirm the chosen parameter values, parametric studies at Re5100 and 51 have been performed and the typical results are presented in Table I. The studies are accomplished by successively varying one parameter while keeping the others unchanged. The relative errors in the table show that the results obtained with the chosen parameter values are well converged with respect to the domain size, and spatial and temporal resolutions. The appropriateness of the boundary condition $u = 1$ and $\scriptstyle { V = 0 }$ imposed on the farfield boundary $( x { \leqslant } 0 )$ has also been examined because there may be an induced flow due to the cylinder rotation even at large distances away from the cylinder. This is accomplished by replacing the uniform flow on the far-field boundary (x $\leqslant 0 )$ with the potential flow involving the rotation effect as follows:
$$
\left\{ \begin{array}{l} u = 1 - \frac {\cos 2 \theta}{4 R _ {D} ^ {2}} - \frac {\alpha \sin \theta}{2 R _ {D}}, \\ v = - \frac {\sin 2 \theta}{4 R _ {D} ^ {2}} + \frac {\alpha \cos \theta}{2 R _ {D}}. \end{array} \right. \tag {7}
$$
In the case of $\alpha { = } 2 . 5$ ~the largest investigated in this paper! at Re5100, the inclusion of the rotation effect in the far-field boundary condition changed the lift coefficient, surface vorticity and surface pressure by less than 0.5%. This proves the applicability of the uniform inflow boundary condition at sufficiently large distances from the rotating cylinder.
To further validate the choice of numerical method and mesh, numerical simulations at the identical conditions used in previous publications have also been carried out and the results are presented in Figs. 2 and 3. Figure 2~a! shows the variation of St with Re at $\scriptstyle { \alpha = 0 } .$ , compared with the results of Park et $a l . ^ { 2 1 }$ and Williamsons correlations,22 while Fig. 2~b! shows the variation of $C _ { L }$ with at $ { \mathrm { R e } } { = } 2 0 .$ , compared with the results of Badr et $a l . ^ { 8 }$ and Ingham and $\mathrm { T a n g . } ^ { 9 }$ Here, the Strouhal number St is defined as ${ \mathrm { S t } } { = } f d / U _ { \infty }$ , where f is the vortex-shedding frequency. The lift and drag coefficients, $C _ { L }$ and $C _ { D }$ , are defined respectively as $C _ { L } { = } 2 L / ( \rho U _ { \infty } ^ { 2 } d )$ and $C _ { D } { = } 2 D / ( \rho U _ { \infty } ^ { 2 } d )$ , where L and D are the lift and drag forces per unit cylinder length, respectively. It is clearly shown that the present results are in excellent agreement with the previous ones. Figure 3 shows time developments of velocity profiles in an early time after the impulsive rotation and translation of a cylinder at Re5200 and $\alpha { = } 0 . 5 ,$ compared with the experimental measurements of Coutanceau and Me´nard.13 The results, v-velocity along $\theta { = } 0 ^ { \circ }$ and u-velocity along $\theta { = } 9 0 ^ { \circ }$ , are not only in excellent agreement with the experimental measurements, but also with the computational results of Badr and $\mathrm { D e n n i s } ^ { 1 2 }$ ~not shown here!. The comparisons made in Figs. 2 and 3 indicate the appropriateness of the numerical method and mesh used in this study.
TABLE I. Parametric studies at $\mathbf { R e } { = } 1 0 0$ and $\alpha { = } 1 . 0$ . Here, the relative errors ~%! with respect to the result from $R _ { D } = 5 0 , M \times N = 2 4 1 \times 2 4 1$ and $\Delta t = 0 . 0 2$ are parenthesized. $\Delta r _ { b }$ and $\Delta \theta _ { b }$ denote the relative magnitudes of grid-spacings at the base point. St is the Strouhal number, and $C _ { L }$ and $C _ { D }$ are the lift and drag coefficients, respectively. The overbar denotes the time averaging and the prime indicates the amplitude of fluctuation @see Eq. ~12!#.
<table><tr><td><eq>R_D</eq>M×N</td><td><eq>\Delta r_b</eq>Δ <eq>\theta_b</eq></td><td><eq>\Delta t</eq></td><td>St</td><td><eq>\bar{C}_L</eq></td><td><eq>\bar{C}_D</eq></td><td><eq>C&#x27;_L</eq></td><td><eq>C&#x27;_D</eq></td></tr><tr><td>50</td><td>1</td><td>0.02</td><td>0.1655</td><td>-2.4881</td><td>1.1040</td><td>0.3631</td><td>0.0993</td></tr><tr><td>241×241</td><td>1</td><td></td><td></td><td></td><td></td><td></td><td></td></tr><tr><td>100</td><td>0.99</td><td>0.02</td><td>0.1650</td><td>-2.4833</td><td>1.0979</td><td>0.3603</td><td>0.0988</td></tr><tr><td>285×241</td><td>1</td><td></td><td>(0.30)</td><td>(0.19)</td><td>(0.55)</td><td>(0.77)</td><td>(0.50)</td></tr><tr><td>50</td><td>0.38</td><td>0.02</td><td>0.1656</td><td>-2.5027</td><td>1.0993</td><td>0.3576</td><td>0.0980</td></tr><tr><td>301×301</td><td>0.80</td><td></td><td>(0.06)</td><td>(0.59)</td><td>(0.43)</td><td>(1.51)</td><td>(1.31)</td></tr><tr><td>50</td><td>1</td><td>0.01</td><td>0.1656</td><td>-2.4881</td><td>1.1039</td><td>0.3630</td><td>0.0991</td></tr><tr><td>241×241</td><td>1</td><td></td><td>(0.06)</td><td>(0.00)</td><td>(0.01)</td><td>(0.03)</td><td>(0.20)</td></tr></table>
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/f3c6bf9b7fe03266526f1391768a6f6ff18520361100a0d7a31c6bfa33d2f55e.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/98aa60d53c5aa75d04e4251ad6b2b2695f816f875130866e1b12af8f1e5a8235.jpg)
FIG. 2. ~a! St vs Re at $\alpha { = } 0 ; \bigcirc ,$ the present study; L, Park et al. ~Ref. 21! ~6413241, C grid!; ¯, Williamsons correlations ~Ref. 22!. ~b! $C _ { L }$ vs at $\ R e { = } 2 0 \colon { \mathcal { O } } ,$ the present study; • h• , Badr et al. ~Ref. $8 ) \ - \textcircled { < } -$ , Ingham and Tang ~Ref. 9!.
After verifying the numerical method, we have conducted numerical simulations of flow past a constantly rotating cylinder at Re540, 60, 100, and 160 by successively increasing from 0 to 2.5. Since the fully developed flow is independent of initial conditions ~Fig. 4!, all the simulations may be started with arbitrary initial conditions only if the fully developed flow fields are to be analyzed.
# III. RESULTS
# A. Strouhal number
In the case of no rotation ~ 50!, the flow at $\mathrm { R e } { = } 4 0$ is steady, while the flows at Re560, 100, and 160 are timeperiodic on account of vortex shedding ~it was shown by Park et $a l . ^ { 2 1 }$ and Fey et $a l . ^ { 2 3 }$ that vortex shedding occurs at Re>47!. Simulations show that vortex shedding exists at low rotational speeds and then completely disappears at $> \alpha _ { L }$ , where the critical rotational speed $\alpha _ { L }$ depends on the Reynolds number, for example, $\alpha _ { L } { \approx } 1 . 4$ , 1.8, and 1.9 for
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/ed4b044c4a5f491bade7fa2e19b62babd0d57b5af6817111fbb042e6cfd00744.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/1d97e6ae9b62f2dd8157c02b049c5679484f2c1b135876f788b81c3cc5ade3e9.jpg)
FIG. 3. Time developments of velocity profiles at Re5200 and 50.5. ~a! v-velocity along 50°; ~b! u-velocity along 590°. —, the present study; s, h, n, and L, Coutanceau and Me´nard ~Ref. 13!.
Re560, 100, and 160, respectively. No vortex shedding develops regardless of in the case of Re540. Variation of the Strouhal number with respect to while vortex shedding exists is shown in Fig. 5. Rotation of a cylinder does not significantly alter St in the range of $\alpha { < } \alpha _ { L }$ . More specifically, the Strouhal number stays nearly constant at low $\alpha ^ { \prime } \mathrm { s } ,$ , decreases slightly as approaches $\alpha _ { L }$ , and sharply reduces to zero at $\alpha > \alpha _ { L }$ . The present result supports the assumption of Badr et al.8 that St is more or less independent of ~St '0.14 and 0.16, respectively, for $\mathrm { R e } { = } 6 0$ and 100, consistent with the present result!, but contradicts the result of Hu et $a l . ^ { 1 7 }$ that St decreases steadily with increasing as shown in Fig. 5. However, the result of Hu et al. seems to be inaccurate, which shows a poor agreement with those of Park $e t a l . ^ { 2 \dot { 1 } }$ and Williamson22 at Re560 and 50.
# B. Mean flow quantities
The mean pressure coefficient and vorticity around the cylinder surface for cases with various combinations of the rotational speed and Reynolds number are shown in Figs. 6 and 7. In these figures, the effect of on the mean flow quantities, denoted by the solid line, is examined by fixing Re at $\mathbf { R e } { = } 1 0 0$ , while the effect of Re, denoted by the dashed line, is done by fixing at 51.0. The pressure distributions obtained from the potential flow theory are also plotted for¯ comparison. The mean flow quantity, denoted by $\overline { { ( ) } }$ , is computed by taking the temporal average of the flow quantities over one complete time cycle after the flow becomes fully developed.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/18cb257fbeab2de424fbd7b16e19f8596ed46d678c90147e9b0c2dc838cf9d85.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/04aaf178e09e0f085a543c7772c665f5570aa850469568df47f1ed9ec31bd649.jpg)
FIG. 4. Variation of $C _ { L }$ according to the initial condition at $t = 0 \ \mathrm { ( R e = } 1 0 0$ and $\alpha { = } 1 . 0 ) ; ~ ( { \mathrm { a } } ) ~ - 2 0 { \leqslant } t { \leqslant } 7 0$ and ~b! $7 0 { \leqslant } t { \leqslant } 1 0 0 . -$ , started impulsively into rotation and translation from rest; –•–, started from steady flow at $\alpha { = } 2 . 0 ; \mathrm { ~ -- ~ }$ , started from time-periodic flow at $\scriptstyle \alpha = 0 .$ . This figure shows that the flow becomes fully time-periodic after a long enough time, irrespective of the initial conditions.
Figure 6 shows the mean pressure coefficients, defined as $\overline { { C } } _ { p } = 2 ( \bar { p } - p _ { \infty } ) / \rho U _ { \infty } ^ { 2 }$ , where $p _ { \infty }$ is the free-stream pressure, around the cylinder surface with increasing in the range of $0 { \leqslant } \alpha { \leqslant } 2 . 5$ at $\mathrm { R e } { = } 1 0 0 .$ . As expected, the mean pressure for $\scriptstyle { \alpha = 0 }$ is symmetric about $\theta { = } 1 8 0 ^ { \circ }$ ~stagnation point!, leading to a zero mean lift. As increases, the flow becomes asymmetric and at the same time the pressure on the lower ~or the accelerated flow! side of the cylinder ~ '270°! decreases, resulting in a negative ~downward! mean lift. In addition, the stagnation point adhered to the cylinder surface moves in the reverse direction of rotation with increasing and then departs from the cylinder surface when $\alpha { \gtrsim } 0 . 5$ . Such observations are also found for all the other Reynolds numbers $\mathrm { R e } { = } 4 0 , 6 0$ , and 160. In Fig. 6 the results are also compared with those from the potential flow theory, given as
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/fe251d80087baa242bb44f1cde815d8a64c28cc9ac5b0e3c5875b02ae5b7291b.jpg)
FIG. 5. Variation of St according to at Re560, 100, and 160: $\mathrm { O , R e = 1 6 0 ; }$ $\scriptstyle \prod , \mathrm { R e } = 1 0 0 ; \Delta , \mathrm { R e } = 6 0 ; \emptyset$ , Hu et al. ~Ref. 17! ~Re560!.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/14880d6670ead06d60886299edd5a9797ec27c6ada5ae63dc955d791e67a7cf9.jpg)
FIG. 6. Mean pressure coefficients around the cylinder surface at $\alpha { = } 0 , 0 . 5 ,$ , 1.0, 1.5, 2.0, and 2.5 for Re5100, compared with those from the potential flow theory for $\scriptstyle { \alpha = 0 }$ and $1 . 0 ; -$ , the present study; $- \cdot - .$ the potential theory. Also shown are the mean pressure coefficients at $\mathrm { R e } { = } 6 0$ and 160 for $\alpha { = } 1 . 0 ,$ , denoted by .
$$
\bar {C} _ {p} = 1 - 4 \sin^ {2} \theta + 4 \alpha \sin \theta - \alpha^ {2}, \tag {8}
$$
for cases with $\scriptstyle { \alpha = 0 }$ and 1.0. The comparison reveals that there is a big discrepancy between the potential and real viscous flows. The mean pressure coefficients at $\mathrm { R e } { = } 6 0$ and 160 for $\alpha { = } 1 . 0$ are also shown in Fig. 6. The results indicate that a change of the Reynolds number has a negligible effect on the pressure coefficient at the cylinder surface.
Figure 7 shows the mean vorticities around the cylinder surface for the same conditions as in Fig. 6. In this study, the mean surface vorticity is computed from $\overline { { \omega } } = 2 \alpha + \partial \overline { { u } } _ { \theta } / \partial r$ in the $( r , \theta )$ coordinate. In the case of $\alpha { = } 0 ,$ , the vorticity has the positive and negative peak values, respectively, at $\theta { \approx } 2 3 0 ^ { \circ }$ and $\theta \approx 1 3 0 ^ { \circ }$ , while it is nearly zero in the wake. With increasing , the vorticity increases in its magnitude, significantly deviating from zero in the wake region. The local peak in the wake region $( 3 0 0 ^ { \circ } < \theta < 3 6 0 ^ { \circ } )$ also increases with the peak point moving in the direction of rotation as increases, and then becomes of a maximum ~negative! value when *1.5. On the other hand, increasing Re involves an overall growth of the vorticity magnitude around the cylinder surface. The shear stress ~or friction coefficient! around the cylinder surface, defined as $\overline { { \tau } } _ { r \theta } = ( - 2 \alpha + \partial \overline { { u } } _ { \theta } / \partial r ) / \mathrm { R e }$ , can be explained from the surface vorticity shown in Fig. 7. Now that ${ \partial \overline { { u } } _ { \theta } } / { \partial r }$ is much larger than 2 for all the rotational speeds investigated, the distribution of the surface shear stress is very similar to that of the surface vorticity.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/f4f317b8e8321a2efad4c6b13666add3acd358c7457437b5f1ad42b756b4112f.jpg)
FIG. 7. Mean vorticities around the cylinder surface at $\alpha { = } 0 , 0 . 5 , 1 . 0 , 1 . 5 ,$ 2.0, and 2.5 for Re5100. Also shown are the mean vorticities at Re560 and 160 for $\alpha { = } 1 . 0 ,$ , denoted by .
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/51a8b2c6565d6164d9df78f74964e2d449952975239fae9abd9eb2fd3b3f35d0.jpg)
FIG. 8. Mean lift and drag coefficients according to the rotational speed; ~a! $\overline { { C } } _ { L } ; ( { \bf b } ) \ : \overline { { C } } _ { D } . \mathrm { ~ O , R e = 1 6 0 ; } \bigstar \bigstar \mathrm { , ~ } \forall \mathrm { e = 1 0 0 ; } \bigtriangleup , \mathrm { R e = 6 0 ; } \bigtriangledown , \mathrm { R e = 4 0 ; } \bigtriangleup$ , potential flow theory. Solid symbols ~j! are obtained from Badr et al. ~Ref. 8! at $\mathbf { R e } { = } 1 0 0$ . Also shown are the contributions of the pressure and friction forces; —, total; –•–, pressure; ¯, friction.
# C. Lift and drag coefficients
The characteristics of lift and drag forces exerted on the cylinder rotating steadily in a viscous uniform flow is investigated in terms of the lift and drag coefficients, $C _ { L }$ and $C _ { D }$ . The ~total! drag and lift forces are composed of both the pressure and friction forces as follows:
$$
C _ {L} = C _ {L p} + C _ {L f}, \quad C _ {D} = C _ {D p} + C _ {D f}. \tag {9}
$$
Variations of the lift and drag coefficients for cases with various combinations of the rotational speed and Reynolds number are shown in Figs. 810.
Figure 8 shows the mean lift and drag coefficients for all the cases investigated, compared with those computed from the potential flow theory,
$$
C _ {L} = - 2 \pi \alpha , \quad C _ {D} = 0. \tag {10}
$$
Also shown in the same figure are the contributions of the pressure and friction forces. First, the mean lift coefficients, $\bar { C } _ { L } , \bar { C } _ { L p }$ , and $\bar { C } _ { L f }$ , are shown in Fig. 8~a!. In the figure, all the ~negative! lift coefficients increase linearly in proportion to for low rotational speeds and the least-square fit provides ~for <1.0!
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/eec81ea7eb9f52d54756740f4bee3cb14c815c406d3bae728a6f6c9155f9907d.jpg)
FIG. 9. Amplitudes of the lift and drag fluctuations according to the rotational speed; ~a! $C _ { L } ^ { \prime } ; ( { \bf b } ) \ C _ { D } ^ { \prime } . \bigcirc , \mathrm { R e } = 1 6 0 ; \bigcirc , \mathrm { R e } = 1 0 0 ; \bigtriangleup , \mathrm { R e } = 6 0$ . Solid symbols ~j! are obtained from Badr et al. ~Ref. 8! at Re5100.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/ae96fccd33140b96a5ddd0807e0ae923d01fc69d721042c3a8d3bdc38332aae3.jpg)
FIG. 10. Phase diagrams of $C _ { L }$ with $C _ { D }$ for various values of ; ~a! Re 5100; ~b! Re5160. Time advances in the clockwise direction ~on the upper branch in the case of 50!.
$$
\bar {C} _ {L p} \sim - 2. 3 3 \alpha , \quad \bar {C} _ {L f} \sim - 0. 1 3 \alpha ,
$$
$$
\bar {C} _ {L} \sim - 2. 4 6 \alpha , \quad \text { for } \mathrm{Re} = 1 6 0,
$$
$$
\bar {C} _ {L p} \sim - 2. 3 1 \alpha , \quad \bar {C} _ {L f} \sim - 0. 1 7 \alpha ,
$$
$$
\bar {C} _ {L} \sim - 2. 4 8 \alpha , \quad \text { for } \mathrm{Re} = 1 0 0,
$$
$$
\bar {C} _ {L p} \sim - 2. 2 9 \alpha , \quad \bar {C} _ {L f} \sim - 0. 2 1 \alpha , \tag {11}
$$
$$
\bar {C} _ {L} \sim - 2. 5 0 \alpha , \quad \text { for } \mathrm{Re} = 6 0,
$$
$$
\bar {C} _ {L p} \sim - 2. 3 1 \alpha , \quad \bar {C} _ {L f} \sim - 0. 2 6 \alpha ,
$$
$$
\bar {C} _ {L} \sim - 2. 5 7 \alpha , \quad \text { for } \mathrm{Re} = 4 0.
$$
The present result is in good agreement with the prediction of Badr et al. $\mathrm { , } \ \bar { C } _ { L } { \sim } - 2 . 5 5 \alpha$ for $\mathrm { R e } { = } 6 0$ , but in relatively poor agreement with the predictions of Tang and Ingham,10 $\bar { C } _ { L } { \sim } - 2 . 1 7 \alpha$ and 21.86 for $ { \mathrm { R e } } { = } 6 0$ and 100, respectively. Such discrepancies are due to the fact that Badr et al. investigated unsteady flow by solving time-dependent governing equations as in the present study, while Tang and Ingham investigated steady flow by solving time-independent governing equations. As shown in Eq. ~11!, the lift force mostly comes from the pressure force and its contribution increases with increasing Re, for example 92%, 93%, and 95%, respectively, for $\mathrm { R e } { = } 6 0 , 1 0 0$ , and 160. Since a change in the Reynolds number mostly affects the friction, as implied in Figs. 6 and 7, which has a negligible contribution to the total lift force, the total lift coefficient is nearly independent of Re. It is also observed that the lift force differs significantly from the result obtained from the potential flow theory @see Fig. 8~a!#.
The mean drag coefficients, $\bar { C } _ { D } , \ \bar { C } _ { D p }$ , and $\bar { C } _ { D f }$ , are shown in Fig. 8~b!. The drag force seems to be more complicated than the lift force. It is seen that the friction drag is of the same order of magnitude as the pressure drag and thus the total drag has a relatively large dependence on Re. As Re increases, the friction and total drag coefficients decrease. As increases, the pressure drag decreases with the increase in the friction drag, resulting in the net decrease in the total drag force. Even though the total drag forces at $\mathbf { R e } { = } 1 0 0$ and 160 are similar, their drag decompositions are different. It is also worth noting that the pressure drag becomes negative when *2.
In view of controlling vortex shedding, the lift and drag fluctuations are of more practical importance in engineering environments. Here, the amplitudes in the fluctuation are defined, respectively, as
$$
C _ {L} ^ {\prime} = \frac {C _ {L , \max} - C _ {L , \min}}{2}, \quad C _ {D} ^ {\prime} = \frac {C _ {D , \max} - C _ {D , \min}}{2}, \tag {12}
$$
where the subscripts min and max denote the minimum and maximum values, respectively, in a period. The variations of $C _ { L } ^ { \prime }$ and $C _ { D } ^ { \prime }$ with at $R e = 6 0$ , 100 and 160 are shown in Fig. 9, together with those of Badr et $a l . ^ { 8 }$ Excellent agreement is seen between the present and Badr et al.s studies. The figure implies again that vortex shedding appears at low rotational speeds and then completrange of vortex shedding, disappears at stays nearly c $\alpha > \alpha _ { L }$ . In and $C _ { L } ^ { \prime }$ $C _ { D } ^ { \prime }$ increases linearly with increasing . On the other hand, the two fluctuation amplitudes, $C _ { L } ^ { \prime }$ and $C _ { D } ^ { \prime }$ , increase with increasing Re.
Behaviors of the lift and drag forces presented in Figs. 8 and 9 can be represented more clearly in the form of the phase diagram, by plotting $C _ { L }$ as a function of $C _ { D }$ at Re $= 1 0 0$ and 160 as shown in Fig. 10. The closed phase diagram indicates that the flow becomes fully time-periodic. As evident in the figure, the position of a phase diagram denotes the mean lift and drag and the size denotes the corresponding amplitudes of fluctuation. The phase diagrams confirm again that the mean lift and drag forces increase and decrease, respectively, with increasing . At the same time, the amplitude of the lift fluctuation stays almost constant and that of the drag fluctuation increases in the range of vortex shedding. It is also clear that the phase diagram collapses to a point ~denoted by the asterisk! at $\alpha > \alpha _ { L }$ , for example $\alpha { = } 1 . 9$ for $\mathbf { R e } { = } 1 0 0$ and $\alpha { = } 2 . 0$ for $\mathrm { R e } { = } 1 6 0$ . At 50, vortex shedding occurs at the frequency of $C _ { L }$ being half that of $C _ { D }$ , resulting in a symmetry and a crossline in the phase diagram. When the cylinder rotates, however, the flow becomes asymmetric due to the unidirectional rotation. Consequently, $C _ { D }$ has the same frequency as $C _ { L }$ when $\alpha { > } \alpha _ { s }$ at occurrence of vortex shedding, for example $\alpha _ { s } \approx 0 . 1$ at Re $= 1 0 0$ . The value $\alpha _ { s }$ tends to increase with increasing Re.
# D. Instantaneous flow fields
Instantaneous flow fields are investigated to identify the underlying mechanism of the suppression of vortex shedding due to the rotation. Figure 11 shows the vorticity contours at two different times, $t / T { = } 1 / 2$ and 1 in one complete time cycle, with increasing at $\mathbf { R e } { = } 1 0 0$ , where T is the nondimensional period or a reciprocal of the Strouhal number. Here, $t / T { = } 0$ is the time corresponding to the maximum lift, which does not necessarily mean that $t / T { = } 1 / 2$ corresponds to the minimum lift. As expected, it is clearly shown that vortex shedding which develops at $\scriptstyle { \alpha = 0 }$ also occurs at low values of $\alpha ,$ for example $\alpha { < } 2 . 0$ for $\mathrm { R e } { = } 1 0 0$ . While vortex shedding exists, the vorticity contours away from the cylinder surface are similar in overall shape, which indicates that the rotation effect is confined to the flow in the vicinity of the cylinder surface. In the near-surface flow, with increasing , the negative vorticity on the upper side of the cylinder becomes more dominant than the positive vorticity on the lower side. Subsequently, for $\alpha { \geq } 2 . 0$ , vortex shedding completely disappears and the flow has two stationary vorticity bubbles attached to the cylinder. As increases further, the bubbles become thinner and more inclined in the direction of rotation. In the range of $\alpha { \gtrsim } 1$ , the negative isovorticity lines in front of the cylinder form an acute curved tail, which grows around the cylinder in the direction of rotation with increasing .
The corresponding streamlines are shown in Fig. 12 for the same conditions. Overall, the figure shows that the rotation of a cylinder makes a substantial effect on the flow pattern. When the cylinder rotates at low rotational speeds ~ ,2.0!, two vortices are alternately shed on each side of the cylinder but the vortex shedding configuration varies according to . As increases, the vortex on the upper side in the wake becomes stronger, while that on the lower side becomes weaker. Finally, the lower vortex completely disappears when >2.0, resulting in no vortex shedding. In addition, it is valuable to compare the flow pattern to the corresponding potential flow, especially at a high rotational speed. The potential flow theory24 gives that closed streamlines surrounding the cylinder exist only when .2.0, where the stagnation point is detached from the cylinder surface. The same phenomenon can be observed in the viscous flow but for different conditions. In the present study, the flow has the closed streamlines surrounding the cylinder when $\alpha { \gtrsim } 0 . 5$ , where the separation point departs from the cylinder surface. The flow pattern for 52.5 is very similar to that of the potential flow.24
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/bea5bfc3fcb905bf90783dce9e7bdcd767d9439f9c90f4e01f5576666e1b0feb.jpg)
FIG. 11. Vorticity contours at t/T51/2 ~left-hand side! and 1 ~right-hand side! with increasing at Re5100. 5~a! 0.0, ~b! 0.5, ~c! 1.0, ~d! 1.5, ~e! 2.0, and ~f! 2.5. Contour levels are from 23 to 3 in increments of 0.2. Negative values are shown as dashed.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/e8ff140fe333248f973e6a8cb475d5f8851adb76798c45bc899e0d3f1a9134df.jpg)
FIG. 12. Streamlines at t/T51/2 ~left-hand side! and 1 ~right-hand side! with increasing at Re5100. 5~a! 0.0, ~b! 0.5, ~c! 1.0, ~d! 1.5, ~e! 2.0, and ~f! 2.5. Contour levels are from 20.3 to 0.3 in increments of 0.02 and from 60.3 to 61 in increments of 60.05.
Figure 13 shows the time evolution of the streamlines at Re5100 and $\alpha { = } 1 . 0$ . This figure indicates the way how the shed vortices are formed and convected downstream. It is seen that there are two alternate vortices over one complete time cycle with one vortex $[ ^ { 6 } \mathrm { \AA } ^ { , 3 }$ vortex in ~e!# stronger than the other $( ^ { 6 } \mathrm { \Delta ^ { 6 } B ^ { \prime } } )$ vortex! because of the unidirectional rotation, and the stronger vortex lasts longer than the other. Such an observation becomes more pronounced as the rotational speed increases.
# E. Stability and bifurcation
Results have shown repeatedly that in the laminar vortex shedding regime ~47<Re,200! vortex shedding exists at low rotational speeds and completely disappears at $\alpha > \alpha _ { L }$ . This indicates that there should be a bifurcation curve between the existence of vortex shedding and its disappearance on a two-dimensional plane of Re and . Here, such a bifurcation curve based on the onset of vortex shedding is a typical result of flow stability problem. Figure 14 shows the bifurcation curve, or borderline, between steady and unsteady ~time-periodic! regimes obtained with varying by an increment of 0.1 at several chosen Reynolds numbers. Notice that the real bifurcation curve should reside in between open circle and cross symbols in the figure. The figure shows that the $\alpha _ { L }$ ~denoted by open circles! increases logarithmically as Re increases. The observation was also found qualitatively in Badr and Dennis,12 Coutanceau and Me´nard,13 and Badr et al., 14 who all investigated unsteady flow at high Reynolds numbers $( \mathrm { R e } { = } 2 0 0 { - } 1 0 ^ { 4 } )$ and found the disappearance of vortex shedding at $\alpha > \alpha _ { L }$ . Then, they claimed that $\alpha _ { L }$ is nearly independent of Re and is about 2, which is apparently inconsistent with the present result but may be valid at high Reynolds numbers. Recently, Hu et al.17 found analytically the logarithmic dependence of $\alpha _ { L }$ on Re in the range of $4 5 { \leqslant } \mathrm { R e } { \leqslant } 5 0$ and <1.0 using a Galerkin method and a system theory, as also depicted in Fig. 14. Agreement between the results of the present study and Hu et al. is clearly seen. It can be concluded from Fig. 14 that the Reynolds number tends to destabilize flow past a circular cylinder, while the rotation of the cylinder tends to stabilize it.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/a0d3f75f1871a8b0d558fbdd2cec08c878da10f3f046a03f3dbaf5aa36f1a430.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/70e2d61eb4b69c55ca458f02c6bdd50b083551898a0654d8f44c5f82c683b7a5.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/caa2e6c21da6273342b37b76d1a9f9dc7f949e6b1bb3b2d4c2adeb361ba2c9c8.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/cc23f8e1607730be38c7bd5c5147654a03760f8a9a7033fa73202d84cbdaf4a6.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/9a489e54aa52e82eec4d35a36047c8bddca60a05c798fb92066bb58390d8bc55.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/f0f8463129fc7b53e7392424c705b99de688b0b7c024269a38fbb6e9bcdeb9f5.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/8b60abfa992cf745e5fe5fab6144783a989bd899998fbd463ce5325821be3f6e.jpg)
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/3615a16604c0ae644ebb2d214b4709df2211613dcf883f24d478d91090df08a2.jpg)
FIG. 13. Time evolution of the streamlines at Re5100 and $\alpha { = } 1 . 0 ; t / T { = } ( \mathrm { a } )$ 1/8, ~b! 2/8, ~c! 3/8, ~d! 4/8, ~e! 5/8, ~f! 6/8, ~g! 7/8, and ~h! 1. Contour levels are from 20.3 to 0.3 in increments of 0.02 and from 60.3 to 61 in increments of 60.05.
# IV. SUMMARY
In this paper, we have performed a numerical study of fully developed two-dimensional laminar flow past a circular cylinder rotating with a constant angular velocity for the purpose of controlling vortex shedding and understanding the underlying flow mechanism. The rotation of a circular cylinder in a viscous uniform flow may significantly modify flow patterns and, thus, reduce a flow-induced oscillation from vortex shedding. Numerical simulations were performed for flows with Re540, 60, 100, and 160 in the range of $0 { \leqslant } \alpha { \leqslant } 2 . 5$ . In the case of no rotation, the flow at $\mathrm { R e } { = } 4 0$ is steady, while the flows at Re560, 100, and 160 are timeperiodic with vortex shedding.
Results showed that vortex shedding exists, resulting in a time-periodic flow, at low rotational speeds and completely disappears at $\alpha > \alpha _ { L }$ , where the critical rotational speed $\alpha _ { L }$ depends on the Reynolds number, for example $\alpha _ { L } { \approx } 1 . 4 ,$ , 1.8, and 1.9 for $\mathrm { R e } { = } 6 0 .$ , 100, and 160, respectively. In other words, the value of $\alpha _ { L }$ increases logarithmically as Re increases. When vortex shedding exists in the range of $\leqslant \alpha _ { L }$ , notable changes in the flow pattern are observed. First, the Strouhal number is nearly independent of the rotational speed, but strongly dependent on the Reynolds number. Second, as the rotational speed increases, the mean lift force increases almost linearly with and the mean drag force decreases. At the same time, the amplitude of the lift fluctuation stays nearly constant and that of the drag fluctuation increases linearly with . The drag and lift fluctuations also vanish at $\alpha > \alpha _ { L }$ . Further studies from the instantaneous flow fields demonstrate again that the rotation of a cylinder makes a substantial effect on the flow pattern.
![image](https://cdn-mineru.openxlab.org.cn/result/2026-05-15/dcdb8d42-2df5-48cc-ad76-88d30c977dbf/33774b8e7095fa892f28e79bf87b6b541258de4973558eff79a15e6e9b981dff.jpg)
FIG. 14. Bifurcation curve dividing into the steady and time-periodic regimes on a plane of Re and , compared to Hu et al. ~Ref. 17!. ~L!: s, vortex shedding; 3, no vortex shedding.
# ACKNOWLEDGMENTS
This work was supported by National Creative Research Initiatives of the Korean Ministry of Science and Technology. The computations were performed on the CRAY YMP C90 at the Electronics and Telecommunications Research Institute. The supports are gratefully acknowledged.
1C. H. K. Williamson, Vortex dynamics in the cylinder wake, Annu. Rev. Fluid Mech. 28, 477 ~1996!.
2S. Taneda, Visual observations of the flow past a circular cylinder performing a rotary oscillation, J. Phys. Soc. Jpn. 45, 1038 ~1978!.
3P. T. Tokumaru and P. E. Dimotakis, Rotary oscillation control of a cylinder wake, J. Fluid Mech. 224, 77 ~1991!.
4J. R. Filler, P. L. Marston, and W. C. Mih, Response of the shear layers separating from a circular cylinder to small-amplitude rotational oscillations, J. Fluid Mech. 231, 481 ~1991!.
5J. M. Wu, J. D. Mo, and A. D. Vakili, On the wake of cylinder with rotational oscillations, AIAA Paper No. AIAA-89-1024, 1989.
6S. Baek and H. J. Sung, Numerical simulation of the flow behind a rotary oscillating circular cylinder, Phys. Fluids 10, 869 ~1998!.
7D. B. Ingham, Steady flow past a rotating cylinder, Comput. Fluids 11, 351 ~1983!.
8H. M. Badr, S. C. R. Dennis, and P. J. S. Young, Steady and unsteady flow past a rotating circular cylinder at low Reynolds numbers, Comput. Fluids 17, 579 ~1989!.
9D. B. Ingham and T. Tang, A numerical investigation into the steady flow past a rotating circular cylinder at low and intermediate Reynolds numbers, J. Comput. Phys. 87, 91 ~1990!.
10T. Tang and D. B. Ingham, On steady flow past a rotating circular cylinder at Reynolds numbers 60 and 100, Comput. Fluids 19, 217 ~1991!.
11P. T. Tokumaru and P. E. Dimotakis, The lift of a cylinder executing rotary motions in a uniform flow, J. Fluid Mech. 255, 1 ~1993!.
12H. M. Badr and S. C. R. Dennis, Time-dependent viscous flow past an impulsively started rotating and translating circular cylinder, J. Fluid Mech. 158, 447 ~1985!.
13M. Coutanceau and C. Me´nard, Influence of rotation on the near-wake development behind an impulsively started circular cylinder, J. Fluid Mech. 158, 399 ~1985!.
14H. M. Badr, M. Coutanceau, S. C. R. Dennis, and C. Me´nard, Unsteady flow past a rotating circular cylinder at Reynolds numbers 103 and 104, J. Fluid Mech. 220, 459 ~1990!.
15Y. Chen, Y. Ou, and A. J. Pearlstein, Development of the wake behind a circular cylinder impulsively started into rotary and rectilinear motion, J. Fluid Mech. 253, 449 ~1993!.
16C. Chang and R. Chern, Vortex shedding from an impulsively started rotating and translating circular cylinder, J. Fluid Mech. 233, 265 ~1991!.
17G. Hu, D. Sun, X. Yin, and B. Tong, Hopf bifurcation in wakes behind a rotating and translating circular cylinder, Phys. Fluids 8, 1972 ~1996!.
18H. Choi, P. Moin, and J. Kim, Direct numerical simulation of turbulent flow over riblets, J. Fluid Mech. 255, 503 ~1993!.
19P. Beaudan and P. Moin, Numerical experiments on the flow past a
circular cylinder at subcritical Reynolds number, Report No. TF-62, Department of Mechanical Engineering, Stanford University, Stanford, CA, 1994.
20L. L. Pauley, P. Moin, and W. C. Reynolds, The structure of twodimensional separation, J. Fluid Mech. 220, 397 ~1990!.
21J. Park, K. Kwon, and H. Choi, Numerical solutions of flow past a circular cylinder at Reynolds numbers up to 160, KSME Int. J. 12, 1200 ~1998!.
22C. 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!.
23U. Fey, M. Ko¨nig, and H. Eckelmann, A new StrouhalReynoldsnumber relationship for the circular cylinder in the range 47,Re,2 3105, Phys. Fluids 10, 1547 ~1998!.
24F. M. White, Fluid Mechanics ~McGrawHill, New York, 1994!.

View File

@ -0,0 +1,801 @@
# CelerisLab/tests/run_kan99b_rotating_cylinder.py
"""Kan99b rotating-cylinder validation driver.
This script executes the rotating-cylinder campaign in
``tests/Rotating_cylinder_validation_plan.md`` against Kan99b anchors.
Core lattice mapping (fixed by campaign contract):
- D = 30, R = 15
- U_inf = 0.03
- nu = U_inf * D / Re = 0.9 / Re
- omega_body = 2 * alpha * U_inf / D = 0.002 * alpha
- inlet.profile = uniform
- y_wall_bc = free_slip
- outlet.mode = neq_extrap
- streaming = double_buffer
Phases:
- A: domain independence at Re=100, alpha=1.0 (MRT, domains S/M/L)
- B: anchor collision sweep at Re=100, alpha=1.0 (SRT/TRT/MRT)
- C: Re=100 alpha scan
- D: Re=60 and Re=160 threshold scan
Usage examples::
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase a
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase b --domain M
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase c --minimal
conda run -n pycuda_3_10 python tests/run_kan99b_rotating_cylinder.py --phase all --minimal
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
import numpy as np
import pycuda.driver as cuda
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
U_INF = 0.03
D_LATTICE = 30.0
R_LATTICE = 15.0
# Kan99b Table I anchor (Re=100, alpha=1.0).
KAN99B_ANCHOR = {
"St": 0.1655,
"mean_cl": -2.4881,
"mean_cd": 1.1040,
"amp_cl": 0.3631,
"amp_cd": 0.0993,
}
# Preferred agreement bands from the validation plan (fractional errors).
ANCHOR_BANDS = {
"St": 0.03,
"mean_cl": 0.04,
"mean_cd": 0.05,
"amp_cl": 0.08,
"amp_cd": 0.10,
}
# Domain sensitivity thresholds vs domain L (fractional errors).
DOMAIN_THRESH = {
"St": 0.01,
"mean_cl": 0.02,
"mean_cd": 0.02,
"amp_cl": 0.03,
"amp_cd": 0.03,
}
@dataclass(frozen=True)
class DomainSpec:
"""Rectangular domain defined in lattice units."""
key: str
nx: int
ny: int
center: Tuple[float, float]
@dataclass(frozen=True)
class RunSpec:
"""One executable run specification."""
phase: str
collision: str
domain: str
re: float
alpha: float
steps: int
burn: int
def _load_json(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: str, payload: dict) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
def _domain_specs() -> Dict[str, DomainSpec]:
return {
"S": DomainSpec("S", 1081, 481, (360.0, 240.0)),
"M": DomainSpec("M", 1351, 601, (450.0, 300.0)),
"L": DomainSpec("L", 1801, 721, (600.0, 360.0)),
}
def _nu_from_re(re: float) -> float:
return U_INF * D_LATTICE / float(re)
def _omega_body(alpha: float) -> float:
return 2.0 * float(alpha) * U_INF / D_LATTICE
def _run_id(spec: RunSpec) -> str:
a = f"{spec.alpha:.3f}".replace(".", "p")
return f"phase{spec.phase}_dom{spec.domain}_re{int(spec.re)}_a{a}_{spec.collision.lower()}"
def _build_cfg(base_cfg: dict, *, nx: int, ny: int, collision: str, re: float) -> dict:
cfg = json.loads(json.dumps(base_cfg))
cfg["grid"]["nx"] = int(nx)
cfg["grid"]["ny"] = int(ny)
cfg["grid"]["nz"] = 1
cfg["physics"]["velocity"] = float(U_INF)
cfg["physics"]["viscosity"] = float(_nu_from_re(re))
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = str(collision).upper()
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["store_precision"] = "FP32"
cfg["method"]["ddf_shifting"] = False
cfg["method"]["les"]["enabled"] = False
cfg["method"]["inlet"]["profile"] = "uniform"
cfg["method"]["outlet"]["mode"] = "neq_extrap"
cfg["method"]["y_wall_bc"] = "free_slip"
return cfg
def _body_doc(center: Tuple[float, float], *, alpha: float) -> dict:
return {
"objects": [
{
"type": "cylinder",
"center": [float(center[0]), float(center[1])],
"radius": float(R_LATTICE),
"omega": float(_omega_body(alpha)),
}
]
}
def _rfft_spectrum(x: np.ndarray, sample_dt: float) -> Tuple[np.ndarray, np.ndarray]:
v = np.asarray(x, dtype=np.float64)
if v.size < 64:
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
v = v - np.mean(v)
win = np.hanning(v.size)
spec = np.abs(np.fft.rfft(v * win)) ** 2
freqs = np.fft.rfftfreq(v.size, d=float(sample_dt))
return freqs.astype(np.float64), spec.astype(np.float64)
def _peak_freq_parabolic(freqs: np.ndarray, spec: np.ndarray, idx: int) -> float:
i = int(np.clip(idx, 0, spec.size - 1))
if i <= 0 or i + 1 >= spec.size:
return float(freqs[i])
y0 = np.log(spec[i - 1] + 1e-30)
y1 = np.log(spec[i] + 1e-30)
y2 = np.log(spec[i + 1] + 1e-30)
den = y0 - 2.0 * y1 + y2
if abs(den) < 1e-20:
return float(freqs[i])
delta = 0.5 * (y0 - y2) / den
delta = float(np.clip(delta, -1.0, 1.0))
df = float(freqs[i + 1] - freqs[i])
return float(freqs[i]) + delta * df
def _st_from_lift(lift: np.ndarray, sample_dt: float) -> float:
freqs, spec = _rfft_spectrum(lift, sample_dt=sample_dt)
if freqs.size <= 1:
return float("nan")
# Ignore DC bin.
idx = int(np.argmax(spec[1:])) + 1
f_peak = _peak_freq_parabolic(freqs, spec, idx)
return float(f_peak * D_LATTICE / U_INF)
def _cycle_half_p2p(y: np.ndarray) -> float:
"""Mean half peak-to-peak amplitude over cycles of demeaned signal."""
s = np.asarray(y, dtype=np.float64)
if s.size < 8:
return float("nan")
d = s - np.mean(s)
crossing = np.where((d[:-1] <= 0.0) & (d[1:] > 0.0))[0]
if crossing.size >= 2:
amps: List[float] = []
for i in range(crossing.size - 1):
seg = s[crossing[i] + 1 : crossing[i + 1] + 1]
if seg.size < 3:
continue
amps.append(0.5 * (float(np.max(seg)) - float(np.min(seg))))
if amps:
return float(np.mean(amps))
return 0.5 * (float(np.max(s)) - float(np.min(s)))
def _vorticity_z(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
ux = np.asarray(ux, dtype=np.float64)
uy = np.asarray(uy, dtype=np.float64)
return np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
def _save_vorticity_png(path: str, ux: np.ndarray, uy: np.ndarray, title: str) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
omega = _vorticity_z(ux, uy)
abs_o = np.abs(omega[np.isfinite(omega)])
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size else 1.0
if vmax <= 0.0:
vmax = 1.0
ny, nx = omega.shape
fig, ax = plt.subplots(figsize=(min(18.0, max(8.0, nx / 100.0)), min(12.0, max(3.0, ny / 40.0))))
im = ax.imshow(
omega,
origin="lower",
aspect="equal",
cmap="RdBu_r",
vmin=-vmax,
vmax=vmax,
extent=(0, nx - 1, 0, ny - 1),
)
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="omega_z")
fig.tight_layout()
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _run_one(
spec: RunSpec,
*,
domain: DomainSpec,
base_cfg: dict,
out_dir: str,
record_every: int,
field_every: int,
save_vorticity: bool,
) -> Dict[str, Any]:
cfg = _build_cfg(base_cfg, nx=domain.nx, ny=domain.ny, collision=spec.collision, re=spec.re)
bdoc = _body_doc(domain.center, alpha=spec.alpha)
tmpd = tempfile.mkdtemp(prefix="celeris_kan99b_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
_write_json(lbm_tmp, cfg)
_write_json(body_tmp, bdoc)
from CelerisLab import Simulation # noqa: WPS433
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
# Contract: body omega is host-side runtime state from alpha conversion.
if sim.bodies.count < 1:
sim.close()
raise RuntimeError("Expected one cylinder in body config.")
sim.bodies.get(0).state.omega = np.float32(_omega_body(spec.alpha))
sim.initialize()
stream = cuda.Stream()
rec = max(1, int(record_every))
total = int(spec.burn) + int(spec.steps)
if total < 1:
sim.close()
raise ValueError("burn + steps must be >= 1")
steps: List[int] = []
fx_hist: List[float] = []
fy_hist: List[float] = []
field_snapshots: List[str] = []
run_id = _run_id(spec)
snap_dir = os.path.join(out_dir, "fields", run_id)
if field_every > 0:
os.makedirs(snap_dir, exist_ok=True)
for step in range(1, total + 1):
sim.bodies.zero_force_segment_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
if step % rec == 0 or step == total:
stream.synchronize()
sim.bodies.download_obs_full_async(stream)
stream.synchronize()
fvec = sim.bodies.read_force(0)
fx = float(fvec[0])
fy = float(fvec[1])
steps.append(step)
fx_hist.append(fx)
fy_hist.append(fy)
if not np.isfinite(fx) or not np.isfinite(fy):
sim.close()
raise RuntimeError(f"NaN/Inf force at step {step}")
if field_every > 0 and (step % int(field_every) == 0 or step == total):
stream.synchronize()
macro = sim.get_macroscopic()
save_p = os.path.join(snap_dir, f"macro_step{step:08d}.npz")
np.savez_compressed(
save_p,
rho=np.asarray(macro["rho"], dtype=np.float32),
ux=np.asarray(macro["ux"], dtype=np.float32),
uy=np.asarray(macro["uy"], dtype=np.float32),
)
field_snapshots.append(save_p)
stream.synchronize()
macro_last = sim.get_macroscopic()
ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(domain.ny, domain.nx)
uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(domain.ny, domain.nx)
rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(domain.ny, domain.nx)
sim.close()
step_arr = np.asarray(steps, dtype=np.int64)
fx_arr = np.asarray(fx_hist, dtype=np.float64)
fy_arr = np.asarray(fy_hist, dtype=np.float64)
burn_mask = step_arr >= int(spec.burn)
if not np.any(burn_mask):
burn_mask = np.ones_like(step_arr, dtype=bool)
cl = 2.0 * fy_arr / (1.0 * (U_INF ** 2) * D_LATTICE)
cd = 2.0 * fx_arr / (1.0 * (U_INF ** 2) * D_LATTICE)
cl_tail = cl[burn_mask]
cd_tail = cd[burn_mask]
st = _st_from_lift(cl_tail, sample_dt=float(rec))
amp_cl = _cycle_half_p2p(cl_tail)
amp_cd = _cycle_half_p2p(cd_tail)
csv_dir = os.path.join(out_dir, "force_csv")
os.makedirs(csv_dir, exist_ok=True)
csv_path = os.path.join(csv_dir, f"{run_id}.csv")
with open(csv_path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["step", "fx", "fy", "cd", "cl"])
for i, s in enumerate(step_arr.tolist()):
w.writerow([s, fx_arr[i], fy_arr[i], cd[i], cl[i]])
if save_vorticity:
vdir = os.path.join(out_dir, "vorticity")
os.makedirs(vdir, exist_ok=True)
_save_vorticity_png(
os.path.join(vdir, f"{run_id}.png"),
ux_last,
uy_last,
title=(
f"Kan99b {spec.phase.upper()} {spec.collision} dom={spec.domain} "
f"Re={spec.re:.0f} alpha={spec.alpha:.3f}"
),
)
return {
"run_id": run_id,
"phase": spec.phase,
"collision": spec.collision,
"domain": spec.domain,
"re": float(spec.re),
"alpha": float(spec.alpha),
"omega_body": float(_omega_body(spec.alpha)),
"nu": float(_nu_from_re(spec.re)),
"steps": int(spec.steps),
"burn": int(spec.burn),
"total_steps": int(total),
"record_every": int(rec),
"n_samples": int(step_arr.size),
"mean_cd": float(np.mean(cd_tail)),
"mean_cl": float(np.mean(cl_tail)),
"amp_cd": float(amp_cd),
"amp_cl": float(amp_cl),
"st": float(st),
"rho_min_final": float(np.min(rho_last)),
"rho_max_final": float(np.max(rho_last)),
"force_csv": csv_path,
"field_snapshots": field_snapshots,
}
def _alpha_list_from_str(text: str) -> List[float]:
vals: List[float] = []
for t in text.split(","):
t = t.strip()
if t:
vals.append(float(t))
return vals
def _phase_runs(
phase: str,
*,
minimal: bool,
domain_key: str,
collisions: Sequence[str],
alpha_override: Optional[List[float]],
steps: int,
burn: int,
) -> List[RunSpec]:
runs: List[RunSpec] = []
def add_many(
p: str,
ds: Iterable[str],
cs: Iterable[str],
res: Iterable[float],
alphas: Iterable[float],
*,
phase_steps: Optional[int] = None,
phase_burn: Optional[int] = None,
) -> None:
for d in ds:
for c in cs:
for re in res:
for a in alphas:
runs.append(
RunSpec(
phase=p,
collision=str(c).upper(),
domain=d,
re=float(re),
alpha=float(a),
steps=int(phase_steps if phase_steps is not None else steps),
burn=int(phase_burn if phase_burn is not None else burn),
)
)
# Plan-driven defaults.
alpha_c = [0.0, 0.5, 1.0, 1.5, 1.7, 1.8, 1.9, 2.0]
alpha_c_min = [0.0, 1.0, 1.5, 1.8, 2.0]
alpha_d_60 = [0.0, 0.5, 1.0, 1.2, 1.4, 1.6]
alpha_d_160 = [0.0, 0.5, 1.0, 1.5, 1.8, 1.9, 2.0]
alpha_d_min = {60.0: [1.4], 160.0: [1.9]}
anchor_steps = 200_000
anchor_burn = 80_000
near_steps = 240_000
near_burn = 120_000
periodic_steps = 160_000
periodic_burn = 64_000
if phase in ("a", "all"):
add_many("a", ["S", "M", "L"], ["MRT"], [100.0], [1.0], phase_steps=anchor_steps, phase_burn=anchor_burn)
if phase in ("anchor", "b", "all"):
add_many("b", [domain_key], collisions, [100.0], [1.0], phase_steps=anchor_steps, phase_burn=anchor_burn)
if phase in ("c", "all"):
alphas = alpha_override if alpha_override is not None else (alpha_c_min if minimal else alpha_c)
# Near-critical values need longer windows.
for a in alphas:
ps = near_steps if abs(a - 1.8) < 0.11 else periodic_steps
pb = near_burn if abs(a - 1.8) < 0.11 else periodic_burn
add_many("c", [domain_key], collisions, [100.0], [a], phase_steps=ps, phase_burn=pb)
if phase in ("d", "all"):
if minimal:
for re, alphas in alpha_d_min.items():
add_many("d", [domain_key], collisions, [re], alphas, phase_steps=near_steps, phase_burn=near_burn)
else:
add_many("d", [domain_key], collisions, [60.0], alpha_d_60, phase_steps=periodic_steps, phase_burn=periodic_burn)
add_many("d", [domain_key], collisions, [160.0], alpha_d_160, phase_steps=periodic_steps, phase_burn=periodic_burn)
# CLI override for quick tests.
if steps > 0:
for i in range(len(runs)):
runs[i] = RunSpec(
phase=runs[i].phase,
collision=runs[i].collision,
domain=runs[i].domain,
re=runs[i].re,
alpha=runs[i].alpha,
steps=steps,
burn=burn,
)
return runs
def _rel_err(meas: float, ref: float) -> Optional[float]:
if not np.isfinite(meas) or ref == 0:
return None
return abs(float(meas) - float(ref)) / abs(float(ref))
def _phase_a_gate(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
dom = {r["domain"]: r for r in rows}
out: Dict[str, Any] = {"phase": "a", "pass": False}
if not all(k in dom for k in ("S", "M", "L")):
out["error"] = "Phase A needs S, M, L rows."
return out
l = dom["L"]
compare: Dict[str, Any] = {}
for k in ("S", "M"):
r = dom[k]
compare[k] = {
"St": _rel_err(r["st"], l["st"]),
"mean_cl": _rel_err(r["mean_cl"], l["mean_cl"]),
"mean_cd": _rel_err(r["mean_cd"], l["mean_cd"]),
"amp_cl": _rel_err(r["amp_cl"], l["amp_cl"]),
"amp_cd": _rel_err(r["amp_cd"], l["amp_cd"]),
}
choose = "L"
m_ok = all(
(compare["M"][metric] is not None and compare["M"][metric] <= DOMAIN_THRESH[metric])
for metric in DOMAIN_THRESH
)
if m_ok:
choose = "M"
else:
s_ok = all(
(compare["S"][metric] is not None and compare["S"][metric] <= DOMAIN_THRESH[metric])
for metric in DOMAIN_THRESH
)
if s_ok:
choose = "S"
out["compare_vs_L"] = compare
out["recommended_domain"] = choose
out["pass"] = True
return out
def _phase_b_anchor_eval(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
by_coll = {r["collision"]: r for r in rows}
out: Dict[str, Any] = {"phase": "b", "rows": {}}
for coll in ("SRT", "TRT", "MRT"):
row = by_coll.get(coll)
if row is None:
continue
metrics = {
"St": _rel_err(row["st"], KAN99B_ANCHOR["St"]),
"mean_cl": _rel_err(row["mean_cl"], KAN99B_ANCHOR["mean_cl"]),
"mean_cd": _rel_err(row["mean_cd"], KAN99B_ANCHOR["mean_cd"]),
"amp_cl": _rel_err(row["amp_cl"], KAN99B_ANCHOR["amp_cl"]),
"amp_cd": _rel_err(row["amp_cd"], KAN99B_ANCHOR["amp_cd"]),
}
out["rows"][coll] = {
"rel_err": metrics,
"pass_bands": {
m: (metrics[m] is not None and metrics[m] <= ANCHOR_BANDS[m]) for m in ANCHOR_BANDS
},
}
return out
def _save_summary_plots(rows: List[Dict[str, Any]], out_dir: str) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
summary_dir = os.path.join(out_dir, "summary_plots")
os.makedirs(summary_dir, exist_ok=True)
def plot_metric(metric: str, ylabel: str, filename: str) -> None:
fig, ax = plt.subplots(figsize=(8, 5))
for coll in ("SRT", "TRT", "MRT"):
coll_rows = [r for r in rows if r["collision"] == coll]
if not coll_rows:
continue
# Use Re=100 sweep first if present, else all points sorted by alpha.
target = [r for r in coll_rows if abs(r["re"] - 100.0) < 1e-9]
data = target if target else coll_rows
data = sorted(data, key=lambda r: (r["re"], r["alpha"]))
ax.plot(
[r["alpha"] for r in data],
[r[metric] for r in data],
marker="o",
linewidth=1.4,
label=coll,
)
ax.set_xlabel("alpha")
ax.set_ylabel(ylabel)
ax.set_title(f"{ylabel} vs alpha")
ax.grid(True, alpha=0.3)
ax.legend(loc="best")
fig.tight_layout()
fig.savefig(os.path.join(summary_dir, filename), dpi=150, bbox_inches="tight")
plt.close(fig)
plot_metric("mean_cl", "mean C_L", "mean_cl_vs_alpha.png")
plot_metric("mean_cd", "mean C_D", "mean_cd_vs_alpha.png")
plot_metric("amp_cl", "C'_L (half peak-to-peak)", "amp_cl_vs_alpha.png")
plot_metric("st", "St", "st_vs_alpha.png")
def main() -> int:
ap = argparse.ArgumentParser(description="Kan99b rotating-cylinder validation driver")
ap.add_argument("--phase", default="all", choices=("anchor", "a", "b", "c", "d", "all"))
ap.add_argument("--minimal", action="store_true", help="Run reduced minimum set from the plan.")
ap.add_argument("--domain", default="M", choices=("S", "M", "L"), help="Default domain for phases B/C/D.")
ap.add_argument("--collision", default="all", choices=("SRT", "TRT", "MRT", "all"))
ap.add_argument("--alpha", type=float, default=None, help="Single alpha override (for c/d phases).")
ap.add_argument("--alpha-list", type=str, default="", help="Comma-separated alpha list override.")
ap.add_argument("--steps", type=int, default=0, help="Override run steps for all selected runs.")
ap.add_argument("--burn", type=int, default=0, help="Override burn steps for all selected runs.")
ap.add_argument("--record-every", type=int, default=100)
ap.add_argument("--field-every", type=int, default=0, help="Dump macro field .npz every N steps (0 disables).")
ap.add_argument("--out-dir", type=str, default=os.path.join(_REPO, "tests", "output", "kan99b_validation"))
ap.add_argument("--smoke", action="store_true", help="Very short run for wiring checks.")
ap.add_argument("--save-vorticity", action="store_true", help="Save final vorticity PNG per run.")
ap.add_argument("--json-out", type=str, default="", help="Optional explicit summary JSON output path.")
args = ap.parse_args()
if not os.path.isfile(_DEFAULT_LBM):
print(f"Missing base config: {_DEFAULT_LBM}", file=sys.stderr)
return 2
base_cfg = _load_json(_DEFAULT_LBM)
out_dir = os.path.abspath(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
collisions = ["SRT", "TRT", "MRT"] if args.collision == "all" else [str(args.collision).upper()]
alpha_override: Optional[List[float]] = None
if args.alpha is not None:
alpha_override = [float(args.alpha)]
elif args.alpha_list.strip():
alpha_override = _alpha_list_from_str(args.alpha_list)
if args.smoke:
o_steps = 2000
o_burn = 800
else:
o_steps = max(0, int(args.steps))
o_burn = max(0, int(args.burn))
runs = _phase_runs(
args.phase,
minimal=bool(args.minimal),
domain_key=args.domain,
collisions=collisions,
alpha_override=alpha_override,
steps=o_steps,
burn=o_burn,
)
if not runs:
print("No runs selected.", file=sys.stderr)
return 2
domains = _domain_specs()
rows: List[Dict[str, Any]] = []
contract = {
"U_inf": U_INF,
"D_lattice": D_LATTICE,
"R_lattice": R_LATTICE,
"nu_formula": "nu = U_inf * D / Re = 0.9 / Re",
"omega_formula": "omega_body = 2 * alpha * U_inf / D = 0.002 * alpha",
"method_contract": {
"inlet_profile": "uniform",
"y_wall_bc": "free_slip",
"outlet_mode": "neq_extrap",
"streaming": "double_buffer",
"store_precision": "FP32",
"les_enabled": False,
},
}
for spec in runs:
dspec = domains[spec.domain]
print(
f"--- {spec.phase.upper()} {spec.collision} dom={spec.domain} Re={spec.re:.0f} "
f"alpha={spec.alpha:.3f} burn={spec.burn} steps={spec.steps} ---",
flush=True,
)
try:
row = _run_one(
spec,
domain=dspec,
base_cfg=base_cfg,
out_dir=out_dir,
record_every=max(1, int(args.record_every)),
field_every=max(0, int(args.field_every)),
save_vorticity=bool(args.save_vorticity),
)
except Exception as e: # noqa: BLE001
rows.append(
{
"run_id": _run_id(spec),
"phase": spec.phase,
"collision": spec.collision,
"domain": spec.domain,
"re": float(spec.re),
"alpha": float(spec.alpha),
"error": str(e),
}
)
print(f"FAILED: {e}", flush=True)
continue
rows.append(row)
print(
" "
f"St={row['st']:.5f} mean_CL={row['mean_cl']:.4f} mean_CD={row['mean_cd']:.4f} "
f"C'L={row['amp_cl']:.4f} C'D={row['amp_cd']:.4f}",
flush=True,
)
# Summary table outputs
summary_csv = os.path.join(out_dir, "summary_runs.csv")
csv_keys = [
"run_id",
"phase",
"collision",
"domain",
"re",
"alpha",
"omega_body",
"nu",
"burn",
"steps",
"total_steps",
"record_every",
"n_samples",
"st",
"mean_cl",
"mean_cd",
"amp_cl",
"amp_cd",
"rho_min_final",
"rho_max_final",
"force_csv",
"error",
]
with open(summary_csv, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=csv_keys)
w.writeheader()
for r in rows:
w.writerow({k: r.get(k, "") for k in csv_keys})
phase_reports: Dict[str, Any] = {}
phase_a_rows = [r for r in rows if r.get("phase") == "a" and "error" not in r]
if phase_a_rows:
phase_reports["a"] = _phase_a_gate(phase_a_rows)
phase_b_rows = [r for r in rows if r.get("phase") == "b" and "error" not in r]
if phase_b_rows:
phase_reports["b"] = _phase_b_anchor_eval(phase_b_rows)
_save_summary_plots([r for r in rows if "error" not in r], out_dir)
summary = {
"contract": contract,
"requested": {
"phase": args.phase,
"minimal": bool(args.minimal),
"domain": args.domain,
"collision": args.collision,
"steps_override": int(o_steps),
"burn_override": int(o_burn),
"record_every": int(args.record_every),
"field_every": int(args.field_every),
"save_vorticity": bool(args.save_vorticity),
},
"counts": {
"requested_runs": len(runs),
"completed_runs": sum(1 for r in rows if "error" not in r),
"failed_runs": sum(1 for r in rows if "error" in r),
},
"phase_reports": phase_reports,
"rows": rows,
}
json_out = (
os.path.abspath(args.json_out)
if args.json_out.strip()
else os.path.join(out_dir, "summary_runs.json")
)
_write_json(json_out, summary)
print(f"Wrote: {summary_csv}", flush=True)
print(f"Wrote: {json_out}", flush=True)
print(f"Wrote: {os.path.join(out_dir, 'summary_plots')}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,238 @@
# tests/run_sah04_case9_flow_diagnostics.py
"""Sah04 matrix case 9: velocity profiles at several x and full-field vorticity.
Loads geometry and setup from ``tests/tests/run_sah04_st_matrix.py``, runs one
simulation to the last LBM step, then writes PNG + JSON under
``tests/output/sah04_case9_flow_diag/`` (default).
Usage::
conda run -n pycuda_3_10 python tests/run_sah04_case9_flow_diagnostics.py
conda run -n pycuda_3_10 python tests/run_sah04_case9_flow_diagnostics.py --steps 60000 --burn 20000
Design::
Intended for inlet / channel diagnostics after boundary fixes; not part of
the St matrix gate.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import shutil
import sys
import tempfile
from typing import Any, Dict, List, Sequence
import numpy as np
import pycuda.driver as cuda
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_mtx_path = os.path.join(_REPO, "tests", "tests", "run_sah04_st_matrix.py")
_spec = importlib.util.spec_from_file_location("sah04_mtx", _mtx_path)
if _spec is None or _spec.loader is None:
raise RuntimeError(f"Cannot load {_mtx_path}")
sah = importlib.util.module_from_spec(_spec)
sys.modules["sah04_mtx"] = sah
_spec.loader.exec_module(sah)
def _inlet_parabolic_ref_u(y: np.ndarray, ny: int, u0_mean: float) -> np.ndarray:
"""Match ``inlet_target_u`` in ``inlet_outlet.cuh`` (parabolic, mean ``u0_mean``)."""
h = max(float(ny - 2), 1.0)
yc = np.clip(y.astype(np.float64), 1.0, float(ny - 2))
eta = (yc - 0.5) / h
shape = np.maximum(0.0, 4.0 * eta * (1.0 - eta))
return u0_mean * 1.5 * shape
def _default_x_indices(nx: int) -> List[int]:
raw = [1, 2, 3, 5, 10, 20, 40, 80, 160, 320, 640, 960, 1200, 1600, 2000, 2200]
out: List[int] = []
for x in raw:
if 1 <= x < nx - 1:
out.append(int(x))
mid = max(1, (nx - 2) // 2)
if mid not in out:
out.append(mid)
out = sorted(set(out))
return out
def _plot_profiles(
out_png: str,
*,
ux: np.ndarray,
uy: np.ndarray,
x_indices: Sequence[int],
ny: int,
u0_mean: float,
title: str,
) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
y = np.arange(ny, dtype=np.float64)
u_ref = _inlet_parabolic_ref_u(y, ny, u0_mean)
fig, axes = plt.subplots(1, 2, figsize=(12, 6), sharey=True)
for xi in x_indices:
axes[0].plot(ux[:, xi], y, label=f"x={xi}", linewidth=1.2)
axes[1].plot(uy[:, xi], y, label=f"x={xi}", linewidth=1.2)
axes[0].plot(u_ref, y, "k--", linewidth=1.5, label="inlet parabolic ref")
axes[0].set_xlabel("u_x")
axes[0].set_ylabel("y (lattice)")
axes[0].set_title("u_x (y) at selected x")
axes[0].legend(loc="best", fontsize=7)
axes[0].grid(True, alpha=0.3)
axes[1].set_xlabel("u_y")
axes[1].set_title("u_y (y) at selected x")
axes[1].legend(loc="best", fontsize=7)
axes[1].grid(True, alpha=0.3)
fig.suptitle(title)
fig.tight_layout()
os.makedirs(os.path.dirname(os.path.abspath(out_png)) or ".", exist_ok=True)
fig.savefig(out_png, dpi=150, bbox_inches="tight")
plt.close(fig)
def main() -> int:
ap = argparse.ArgumentParser(description="Sah04 case 9 flow: profiles + vorticity")
ap.add_argument("--collision", default="MRT", help="SRT, TRT, or MRT")
ap.add_argument("--steps", type=int, default=60_000)
ap.add_argument("--burn", type=int, default=20_000)
ap.add_argument("--record-every", type=int, default=5)
ap.add_argument("--outlet", default="neq_extrap")
ap.add_argument(
"--out-dir",
default=os.path.join(_REPO, "tests", "output", "sah04_case9_flow_diag"),
help="Output directory for PNG + JSON",
)
ap.add_argument(
"--x-list",
type=str,
default="",
help='Comma-separated x indices (default: built-in list); e.g. "1,2,5,40,200"',
)
args = ap.parse_args()
mc = next(c for c in sah.MATRIX if c.case_id == 9)
tier = sah._TIERS[mc.tier]
ny = int(tier["ny"])
nx = int(sah._NX)
center = (sah._CX, float(tier["center_y"]))
u_max = 0.1
u0_mean = u_max / 1.5
nu = u_max * float(sah._D) / float(mc.re)
if args.x_list.strip():
x_indices = [int(s.strip()) for s in args.x_list.split(",") if s.strip()]
x_indices = [x for x in x_indices if 1 <= x < nx - 1]
else:
x_indices = _default_x_indices(nx)
lbm_path = sah._DEFAULT_LBM
cfg = sah._load_json(lbm_path)
cfg["grid"]["nx"] = nx
cfg["grid"]["ny"] = ny
cfg["grid"]["nz"] = 1
cfg["physics"]["viscosity"] = float(nu)
cfg["physics"]["velocity"] = float(u0_mean)
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = str(args.collision).upper()
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["les"]["enabled"] = False
cfg["method"]["outlet"]["mode"] = args.outlet
body_doc = {
"objects": [
{
"type": "cylinder",
"center": list(center),
"radius": float(sah._R_CYL),
}
]
}
tmpd = tempfile.mkdtemp(prefix="celeris_sah09_diag_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
sah._write_json(lbm_tmp, cfg)
sah._write_json(body_tmp, body_doc)
os.makedirs(args.out_dir, exist_ok=True)
png_profiles = os.path.join(args.out_dir, "case9_profiles_ux_uy.png")
png_vort = os.path.join(args.out_dir, "case9_omega_z_full.png")
json_path = os.path.join(args.out_dir, "case9_profiles.json")
try:
from CelerisLab import Simulation # noqa: WPS433
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
sim.initialize()
stream = cuda.Stream()
rec_every = max(1, int(args.record_every))
steps = int(args.steps)
for step in range(1, steps + 1):
sim.bodies.zero_force_segment_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
stream.synchronize()
macro = sim.get_macroscopic()
ux = np.asarray(macro["ux"], dtype=np.float64)
uy = np.asarray(macro["uy"], dtype=np.float64)
sim.close()
y = np.arange(ny, dtype=np.float64)
u_ref = _inlet_parabolic_ref_u(y, ny, u0_mean)
title = (
f"Sah04 case 9 {args.collision} Re={mc.re} ny={ny} "
f"steps={steps} (last step)"
)
_plot_profiles(png_profiles, ux=ux, uy=uy, x_indices=x_indices, ny=ny, u0_mean=u0_mean, title=title)
sah.save_final_vorticity_png(png_vort, macro["ux"], macro["uy"], title=title + " omega_z")
profiles: Dict[str, Any] = {
"case_id": 9,
"collision": str(args.collision).upper(),
"Re": mc.re,
"nx": nx,
"ny": ny,
"steps": steps,
"burn_requested": int(args.burn),
"u_max": u_max,
"u0_mean_parabolic": u0_mean,
"x_indices": list(x_indices),
"y_lattice": y.tolist(),
"inlet_parabolic_ref_ux": u_ref.tolist(),
"profiles": {},
}
for xi in x_indices:
profiles["profiles"][str(xi)] = {
"ux": ux[:, xi].tolist(),
"uy": uy[:, xi].tolist(),
}
with open(json_path, "w", encoding="utf-8") as f:
json.dump(profiles, f, indent=2)
print("Wrote:", png_profiles, flush=True)
print("Wrote:", png_vort, flush=True)
print("Wrote:", json_path, flush=True)
finally:
shutil.rmtree(tmpd, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,452 @@
# tests/run_sah04_case9_grid_blockage_compare.py
"""Sah04 matrix case 9: compare inlet-near flow vs grid refinement vs blockage.
Runs three D2Q9 setups anchored to case 9 (high tier, Re=200, ``u_max``=0.1):
1. **baseline** matrix geometry: ``D=30``, ``nx=80*D+2``, ``ny=35``,
cylinder ``(40*D+0.5, 17)``, ``r=D/2``.
2. **grid_2x** double lattice resolution with the same **blockage** ``D/H``
and the same ``Lx/D=80`` convention: ``D=60``, ``nx=80*D+2``, ``ny=68``,
``center_y`` doubled with the channel, ``r=D/2``, ``Re`` still based on ``D``.
3. **radius_half** same channel height as baseline but **cylinder diameter
halved** in lattice units: ``D=15``, ``nx=80*D+2``, ``ny=35``, same
``center_y``, ``r=D/2``, ``Re`` based on the smaller ``D`` (lower blockage).
Outputs under ``tests/output/sah04_case9_compare/`` by default:
- ``compare_meta.json`` per-variant grid, ``nu``, estimated ``beta=D/H`` (no
full arrays; see ``compare_fields.npz``).
- ``compare_fields.npz`` full ``ux`` / ``uy`` per variant.
- ``ux_vs_y_inlet.png`` ``u_x`` vs normalized wall-normal ``eta`` at several
``x`` indices (all variants overlaid per panel).
- ``omega_z_<variant>.png`` final-step vorticity ``omega_z = d u_y/dx - d u_x/dy``
for each variant.
Forward schedule: ``burn`` warm-up steps (may be ``0``), then ``steps`` more
steps; the reported field is the state **after** ``burn + steps`` LBM steps.
Usage::
conda run -n pycuda_3_10 python tests/run_sah04_case9_grid_blockage_compare.py --smoke
conda run -n pycuda_3_10 python tests/run_sah04_case9_grid_blockage_compare.py \\
--steps 20000 --burn 0 --collision MRT
Design::
Isolates whether an inlet-channel artifact scales with **mesh** (grid_2x)
or with **blockage** (radius_half) while keeping the Sah04-style confined
channel layout. Requires **matplotlib** for PNG output.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple
import numpy as np
import pycuda.driver as cuda
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_DEFAULT_LBM = os.path.join(
_REPO, "src", "CelerisLab", "configs", "config_lbm.json",
)
def _load_json(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: str, d: dict) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(d, f, indent=2)
@dataclass(frozen=True)
class Variant:
"""One lattice-resolved Sah04-like case (case 9 Re / u_max convention)."""
key: str
nx: int
ny: int
center: Tuple[float, float]
d_lattice: float
r_cyl: float
re: float
note: str
def _variants() -> Tuple[Variant, Variant, Variant]:
d0 = 30
ny0 = 35
cy0 = 17.0
v0 = Variant(
key="baseline",
nx=80 * d0 + 2,
ny=ny0,
center=(40.0 * d0 + 0.5, cy0),
d_lattice=float(d0),
r_cyl=0.5 * d0,
re=200.0,
note="Matrix case 9 (high tier): D=30, ny=35.",
)
d2 = 60
ny2 = 2 * ny0 - 2
v1 = Variant(
key="grid_2x",
nx=80 * d2 + 2,
ny=ny2,
center=(40.0 * d2 + 0.5, 2.0 * cy0),
d_lattice=float(d2),
r_cyl=0.5 * d2,
re=200.0,
note="Double D and ny-2; Lx/D=80 unchanged; blockage D/H ~ baseline.",
)
dh = 15
v2 = Variant(
key="radius_half",
nx=80 * dh + 2,
ny=ny0,
center=(40.0 * dh + 0.5, cy0),
d_lattice=float(dh),
r_cyl=0.5 * dh,
re=200.0,
note="Half cylinder diameter in lattice units; same ny as baseline.",
)
return v0, v1, v2
def _vorticity_z_from_velocity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
"""``omega_z = d u_y/dx - d u_x/dy`` on a 2D ``(ny, nx)`` slice (unit lattice spacing)."""
ux = np.asarray(ux, dtype=np.float64)
uy = np.asarray(uy, dtype=np.float64)
duy_dx = np.gradient(uy, axis=1)
dux_dy = np.gradient(ux, axis=0)
return duy_dx - dux_dy
def _save_vorticity_png(
path: str,
ux: np.ndarray,
uy: np.ndarray,
*,
title: str,
) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
omega = _vorticity_z_from_velocity(ux, uy)
abs_o = np.abs(omega[np.isfinite(omega)])
if abs_o.size:
vmax = float(np.percentile(abs_o, 99.5))
if vmax <= 0.0:
vmax = float(np.max(abs_o)) or 1.0
else:
vmax = 1.0
ny, nx = omega.shape
fw = min(18.0, max(8.0, nx / 100.0))
fh = min(12.0, max(3.0, ny / 40.0))
fig, ax = plt.subplots(figsize=(fw, fh))
im = ax.imshow(
omega,
origin="lower",
aspect="equal",
cmap="RdBu_r",
vmin=-vmax,
vmax=vmax,
extent=(0, nx - 1, 0, ny - 1),
)
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="omega_z")
fig.tight_layout()
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _run_variant(
v: Variant,
*,
collision: str,
outlet: str,
u_max: float,
burn: int,
steps: int,
) -> Dict[str, Any]:
u0_mean = u_max / 1.5
nu = u_max * float(v.d_lattice) / float(v.re)
if not os.path.isfile(_DEFAULT_LBM):
raise FileNotFoundError(_DEFAULT_LBM)
cfg = _load_json(_DEFAULT_LBM)
cfg["grid"]["nx"] = int(v.nx)
cfg["grid"]["ny"] = int(v.ny)
cfg["grid"]["nz"] = 1
cfg["physics"]["viscosity"] = float(nu)
cfg["physics"]["velocity"] = float(u0_mean)
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = collision
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["les"]["enabled"] = False
cfg["method"]["outlet"]["mode"] = outlet
body_doc = {
"objects": [
{
"type": "cylinder",
"center": list(v.center),
"radius": float(v.r_cyl),
}
]
}
tmpd = tempfile.mkdtemp(prefix="celeris_sah09cmp_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
_write_json(lbm_tmp, cfg)
_write_json(body_tmp, body_doc)
from CelerisLab import Simulation # noqa: WPS433
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
sim.initialize()
stream = cuda.Stream()
total = int(burn) + int(steps)
if total < 1:
sim.close()
raise ValueError("burn + steps must be >= 1")
for _step in range(1, total + 1):
sim.bodies.zero_force_segment_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
stream.synchronize()
macro = sim.get_macroscopic()
ux = np.asarray(macro["ux"], dtype=np.float64).reshape(v.ny, v.nx)
uy = np.asarray(macro["uy"], dtype=np.float64).reshape(v.ny, v.nx)
rho = np.asarray(macro["rho"], dtype=np.float64).reshape(v.ny, v.nx)
sim.close()
h_fluid = max(int(v.ny) - 2, 1)
beta_est = float(v.d_lattice) / float(h_fluid)
return {
"key": v.key,
"nx": int(v.nx),
"ny": int(v.ny),
"center": [float(v.center[0]), float(v.center[1])],
"d_lattice": float(v.d_lattice),
"r_cyl": float(v.r_cyl),
"re": float(v.re),
"nu": float(nu),
"u_max": float(u_max),
"u0_mean": float(u0_mean),
"beta_est": beta_est,
"note": v.note,
"burn": int(burn),
"steps": int(steps),
"total_lbm_steps": int(total),
"ux_shape": list(ux.shape),
"rho_min": float(np.min(rho)),
"rho_max": float(np.max(rho)),
"ux": ux.tolist(),
"uy": uy.tolist(),
}
def _eta_coords(ny: int) -> np.ndarray:
"""Wall-normal fractional coordinate in (0,1) for fluid rows y=1..ny-2."""
y = np.arange(ny, dtype=np.float64)
h = max(float(ny - 2), 1.0)
out = np.zeros_like(y)
for yi in range(1, max(ny - 1, 1)):
out[yi] = (float(yi) - 0.5) / h
return out
def _plot_ux_vs_y_inlet(
path: str,
results: List[Dict[str, Any]],
x_indices: List[int],
) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
n_x = len(x_indices)
fig, axes = plt.subplots(1, n_x, figsize=(4.2 * n_x, 5.0), sharey=True)
if n_x == 1:
axes = [axes]
colors = {"baseline": "C0", "grid_2x": "C1", "radius_half": "C2"}
for ax, xi in zip(axes, x_indices):
for r in results:
nx = int(r["nx"])
if xi < 1 or xi >= nx - 1:
continue
ux = np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"])
eta = _eta_coords(int(r["ny"]))
y = np.arange(int(r["ny"]))
ax.plot(ux[:, xi], eta, label=r["key"], color=colors.get(r["key"], "k"))
ax.set_title(f"x = {xi}")
ax.set_xlabel("u_x")
ax.grid(True, alpha=0.3)
axes[0].set_ylabel("eta = (y-0.5)/H (fluid band)")
fig.suptitle("u_x vs wall-normal coordinate near inlet (several x)")
axes[0].set_ylim(0.0, 1.0)
axes[0].legend(loc="best", fontsize=8)
fig.tight_layout()
os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def main() -> int:
ap = argparse.ArgumentParser(
description="Case 9 baseline vs finer grid vs half-radius: inlet-near u_x",
)
ap.add_argument("--collision", default="MRT", help="SRT, TRT, or MRT")
ap.add_argument("--outlet", default="neq_extrap")
ap.add_argument("--u-max", type=float, default=0.1, dest="u_max")
ap.add_argument(
"--burn",
type=int,
default=0,
help="Warm-up LBM steps before the --steps production segment (0 is valid).",
)
ap.add_argument(
"--steps",
type=int,
default=20_000,
help="LBM steps after burn-in; final field is after burn+steps.",
)
ap.add_argument(
"--smoke",
action="store_true",
help="Short run: burn=0, steps=2500 (overrides --burn and --steps)",
)
ap.add_argument(
"--out-dir",
default=os.path.join(_REPO, "tests", "output", "sah04_case9_compare"),
)
ap.add_argument(
"--inlet-x",
type=str,
default="1,2,3,5,8",
help="Comma-separated x indices for u_x(y) panels (must exist for all nx)",
)
args = ap.parse_args()
if args.smoke:
burn = 0
steps = 2500
else:
burn = max(0, int(args.burn))
steps = max(1, int(args.steps))
out_dir = os.path.abspath(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
v0, v1, v2 = _variants()
variants = (v0, v1, v2)
x_list = [int(s.strip()) for s in args.inlet_x.split(",") if s.strip()]
min_nx = min(v.nx for v in variants)
x_list = [x for x in x_list if 1 <= x < min_nx - 1]
if not x_list:
print("No valid --inlet-x indices common to all variants.", file=sys.stderr)
return 2
results: List[Dict[str, Any]] = []
for v in variants:
print(f"--- {v.key}: nx={v.nx} ny={v.ny} D={v.d_lattice} r={v.r_cyl} ---", flush=True)
row = _run_variant(
v,
collision=str(args.collision).upper(),
outlet=str(args.outlet),
u_max=float(args.u_max),
burn=burn,
steps=steps,
)
results.append(row)
meta_path = os.path.join(out_dir, "compare_meta.json")
slim = []
for r in results:
slim.append({k: v for k, v in r.items() if k not in ("ux", "uy")})
_write_json(
meta_path,
{
"variants_summary": slim,
"inlet_x_used": x_list,
"burn": burn,
"steps": steps,
"total_lbm_steps": burn + steps,
"collision": str(args.collision).upper(),
},
)
try:
_plot_ux_vs_y_inlet(
os.path.join(out_dir, "ux_vs_y_inlet.png"),
results,
x_list,
)
for r in results:
key = str(r["key"])
ux = np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"])
uy = np.asarray(r["uy"], dtype=np.float64).reshape(r["ny"], r["nx"])
_save_vorticity_png(
os.path.join(out_dir, f"omega_z_{key}.png"),
ux,
uy,
title=f"omega_z final ({key}) nx={r['nx']} ny={r['ny']} "
f"D={r['d_lattice']} burn={r['burn']} steps={r['steps']}",
)
except ImportError:
print(
"matplotlib not installed; skipped PNG. Install matplotlib for figures.",
file=sys.stderr,
)
full_npz = os.path.join(out_dir, "compare_fields.npz")
np.savez_compressed(
full_npz,
keys=np.array([r["key"] for r in results]),
**{f"ux_{r['key']}": np.asarray(r["ux"], dtype=np.float64) for r in results},
**{f"uy_{r['key']}": np.asarray(r["uy"], dtype=np.float64) for r in results},
**{
f"omega_z_{r['key']}": _vorticity_z_from_velocity(
np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"]),
np.asarray(r["uy"], dtype=np.float64).reshape(r["ny"], r["nx"]),
)
for r in results
},
)
print(f"Wrote: {meta_path}", flush=True)
print(f"Wrote: {full_npz}", flush=True)
print(f"Wrote: {os.path.join(out_dir, 'ux_vs_y_inlet.png')}", flush=True)
for r in results:
oz_path = os.path.join(out_dir, f"omega_z_{r['key']}.png")
print(f"Wrote: {oz_path}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,690 @@
# CelerisLab/tests/run_sah04_st_matrix.py
"""Sah04 St validation matrix from tests/Sah04_St_validation_matrix.md.
Runs the nine paper-anchored (Re, beta tier) cases with fixed D=30 channel
geometry, optional SRT/TRT/MRT sweep, and evaluates St against targets.
Each case reports **two** Strouhal estimates from the same lift window:
- **raw**: dominant rFFT peak in the paper-frequency band (no target prior).
- **guided**: same band with a Gaussian weight toward ``f0`` from the paper
``St_target`` (reduces harmonic confusion in narrow channels).
The matrix **5% / 10% hard-case rules** apply to **guided** ``St`` by default;
``St_raw`` is for diagnosing whether the guide helps or hides a spectrum issue.
Usage::
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --collision MRT
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --collision all --json-out sah04_matrix.json
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --smoke --case 3 --collision SRT
Long diagnostic (lift + spectrum + **final-step vorticity** under ``tests/output/``)::
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --collision MRT --case all \\
--steps 200000 --burn 80000 \\
--dump-npz-dir tests/output/sah04_long/npz \\
--final-vorticity-dir tests/output/sah04_long/vorticity \\
--json-out tests/output/sah04_long/matrix_mrt.json
Requires **matplotlib** for ``--final-vorticity-dir`` (not a core package dependency).
Design::
Hard cases use a 5% relative St gate on **guided** St; no hard case worse
than 10%. Soft cases (2, 8) only check ordering vs neighbors in printed summary.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
import pycuda.driver as cuda
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json")
# D=30 fixed; Lx_fluid = 80D per Sah04 confined setup
_D = 30
_NX = 80 * _D + 2
_CX = 40.0 * _D + 0.5
_R_CYL = 0.5 * _D
# Blockage tiers: fluid height H = ny - 2; cylinder center (1200.5, center_y)
_TIERS: Dict[str, Dict[str, float]] = {
"low": {"ny": 62.0, "center_y": 30.5, "beta_nom": 0.5},
"mid": {"ny": 40.0, "center_y": 19.5, "beta_nom": 0.8},
"high": {"ny": 35.0, "center_y": 17.0, "beta_nom": 0.9},
}
@dataclass(frozen=True)
class MatrixCase:
case_id: int
tier: str
re: float
target_st: float
hard: bool
steps: int
burn: int
# Table from Sah04_St_validation_matrix.md
MATRIX: Tuple[MatrixCase, ...] = (
MatrixCase(1, "low", 124.09, 0.3393, True, 80_000, 30_000),
MatrixCase(2, "low", 160.0, 0.3450, False, 60_000, 20_000),
MatrixCase(3, "low", 200.0, 0.3513, True, 60_000, 20_000),
MatrixCase(4, "mid", 110.24, 0.5363, True, 80_000, 30_000),
MatrixCase(5, "mid", 160.0, 0.5537, True, 60_000, 20_000),
MatrixCase(6, "mid", 200.0, 0.5510, True, 60_000, 20_000),
MatrixCase(7, "high", 162.82, 0.5202, True, 80_000, 30_000),
MatrixCase(8, "high", 180.0, 0.5254, False, 60_000, 20_000),
MatrixCase(9, "high", 200.0, 0.5314, True, 60_000, 20_000),
)
def _load_json(path: str) -> dict:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: str, d: dict) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(d, f, indent=2)
def vorticity_z_from_velocity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
"""Z-component vorticity ``ωz = ∂uy/∂x ∂ux/∂y`` on a 2D ``(ny, nx)`` slice.
Lattice spacing is taken as 1 in ``np.gradient`` (LBM cell units).
"""
ux = np.asarray(ux, dtype=np.float64)
uy = np.asarray(uy, dtype=np.float64)
duy_dx = np.gradient(uy, axis=1)
dux_dy = np.gradient(ux, axis=0)
return duy_dx - dux_dy
def save_final_vorticity_png(
path: str,
ux: np.ndarray,
uy: np.ndarray,
*,
title: str,
) -> None:
"""Write ``ωz`` heatmap from last-step ``ux``/``uy`` to ``path`` (PNG)."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as e:
raise RuntimeError(
"save_final_vorticity_png requires matplotlib (e.g. pip install matplotlib)."
) from e
omega = vorticity_z_from_velocity(ux, uy)
abs_o = np.abs(omega[np.isfinite(omega)])
if abs_o.size:
vmax = float(np.percentile(abs_o, 99.5))
if vmax <= 0.0:
vmax = float(np.max(abs_o)) or 1.0
else:
vmax = 1.0
ny, nx = omega.shape
fw = min(18.0, max(8.0, nx / 100.0))
fh = min(12.0, max(3.0, ny / 40.0))
fig, ax = plt.subplots(figsize=(fw, fh))
im = ax.imshow(
omega,
origin="lower",
aspect="equal",
cmap="RdBu_r",
vmin=-vmax,
vmax=vmax,
extent=(0, nx - 1, 0, ny - 1),
)
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="omega_z")
fig.tight_layout()
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def rfft_power_spectrum(
lift: np.ndarray,
*,
sample_dt: float,
) -> Tuple[np.ndarray, np.ndarray]:
"""Mean-subtracted Hanning-windowed lift → ``(freqs_hz, power)`` for positive rFFT bins."""
x = np.asarray(lift, dtype=np.float64)
x = x - np.mean(x)
n = x.size
if n < 64:
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
win = np.hanning(n)
xw = x * win
spec = np.abs(np.fft.rfft(xw)) ** 2
freqs = np.fft.rfftfreq(n, d=float(sample_dt))
return freqs.astype(np.float64), spec.astype(np.float64)
def _parabolic_peak_freq(freqs: np.ndarray, spec: np.ndarray, idx: int) -> float:
"""Refine discrete FFT peak index ``idx`` with log-power parabolic fit."""
idx = int(np.clip(idx, 0, spec.size - 1))
if idx <= 0 or idx + 1 >= spec.size:
return float(freqs[idx])
i0, i1, i2 = idx - 1, idx, idx + 1
y0, y1, y2 = (
np.log(spec[i0] + 1e-30),
np.log(spec[i1] + 1e-30),
np.log(spec[i2] + 1e-30),
)
denom = y0 - 2.0 * y1 + y2
if abs(denom) < 1e-20:
return float(freqs[i1])
delta = 0.5 * (y0 - y2) / denom
delta = float(np.clip(delta, -1.0, 1.0))
df = float(freqs[i2] - freqs[i1])
return float(freqs[i1]) + delta * df
def dual_strouhal_from_lift(
lift: np.ndarray,
*,
diameter: float,
u_max: float,
sample_dt: float,
f_hz_min: float,
f_hz_max: float,
) -> Dict[str, float]:
"""Return raw and guided ``St`` and underlying ``f_peak`` (cycles per LBM step)."""
nan = {"St_raw": float("nan"), "f_raw": float("nan"), "St_guided": float("nan"), "f_guided": float("nan")}
freqs, spec = rfft_power_spectrum(lift, sample_dt=sample_dt)
if freqs.size == 0:
return nan
mask = (freqs >= float(f_hz_min)) & (freqs <= float(f_hz_max))
if not np.any(mask):
return nan
m = mask.astype(float)
idx_raw = int(np.argmax(spec * m))
f0 = 0.5 * (float(f_hz_min) + float(f_hz_max))
sigma = max(1e-12, 0.18 * f0)
weight = np.exp(-((freqs - f0) / sigma) ** 2)
idx_g = int(np.argmax(spec * m * weight))
f_raw = _parabolic_peak_freq(freqs, spec, idx_raw)
f_g = _parabolic_peak_freq(freqs, spec, idx_g)
return {
"St_raw": float(f_raw * diameter / u_max),
"f_raw": float(f_raw),
"St_guided": float(f_g * diameter / u_max),
"f_guided": float(f_g),
}
def dominant_strouhal_from_lift(
lift: np.ndarray,
*,
diameter: float,
u_max: float,
sample_dt: float = 1.0,
f_hz_min: float,
f_hz_max: float,
) -> Tuple[float, float]:
"""Backward-compatible: returns guided ``(St, f_peak)``."""
d = dual_strouhal_from_lift(
lift,
diameter=diameter,
u_max=u_max,
sample_dt=sample_dt,
f_hz_min=f_hz_min,
f_hz_max=f_hz_max,
)
return d["St_guided"], d["f_guided"]
def shedding_freq_band_hz(
target_st: float, u_max: float, d_lattice: float, *, half_width: float = 0.42
) -> Tuple[float, float]:
"""Cycles per LBM step around ``f0 = St*Umax/D`` so sparse rFFT bins still get coverage."""
f0 = float(target_st) * float(u_max) / float(d_lattice)
lo = max(1e-8, f0 * (1.0 - half_width))
hi = f0 * (1.0 + half_width)
return lo, hi
def run_one_simulation(
*,
collision: str,
outlet: str,
nx: int,
ny: int,
center: Tuple[float, float],
re: float,
d_lattice: float,
r_cyl: float,
u_max: float,
steps: int,
burn: int,
record_every: int,
f_hz_min: float,
f_hz_max: float,
dump_npz_path: Optional[str] = None,
final_vorticity_png_path: Optional[str] = None,
flow_figure_title: str = "Sah04 vorticity (final LBM step)",
) -> Dict[str, Any]:
"""Build configs, run steps, return St, rough Cd, curved stats."""
u0_mean = u_max / 1.5
nu = u_max * d_lattice / re
lbm_path = _DEFAULT_LBM
if not os.path.isfile(lbm_path):
raise FileNotFoundError(lbm_path)
cfg = _load_json(lbm_path)
cfg["grid"]["nx"] = nx
cfg["grid"]["ny"] = ny
cfg["grid"]["nz"] = 1
cfg["physics"]["viscosity"] = float(nu)
cfg["physics"]["velocity"] = float(u0_mean)
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = collision
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["les"]["enabled"] = False
cfg["method"]["outlet"]["mode"] = outlet
body_doc = {
"objects": [
{
"type": "cylinder",
"center": list(center),
"radius": float(r_cyl),
}
]
}
tmpd = tempfile.mkdtemp(prefix="celeris_sah04_mtx_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
_write_json(lbm_tmp, cfg)
_write_json(body_tmp, body_doc)
from CelerisLab import Simulation # noqa: WPS433
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
sim.initialize()
stream = cuda.Stream()
lift_hist: List[float] = []
fx_hist: List[float] = []
step_hist: List[int] = []
rec_every = max(1, int(record_every))
rho_every = max(2000, rec_every)
rho_snap_step: List[int] = []
rho_snap_min: List[float] = []
rho_snap_max: List[float] = []
n_curved = int(sim.field.n_curved)
fb = int(sim.bodies.fallback_link_count())
lq = int(sim.bodies.low_q_link_count())
for step in range(1, int(steps) + 1):
sim.bodies.zero_force_segment_async(stream)
sim.stepper.step(
1,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
if step % rec_every == 0 or step == int(steps):
stream.synchronize()
sim.bodies.download_obs_full_async(stream)
stream.synchronize()
fvec = sim.bodies.read_force(0)
lift_hist.append(float(fvec[1]))
fx_hist.append(float(fvec[0]))
step_hist.append(int(step))
if not np.isfinite(lift_hist[-1]):
sim.close()
raise RuntimeError(f"NaN/Inf lift at step {step}")
if step % rho_every == 0 or step == int(steps):
stream.synchronize()
macro = sim.get_macroscopic()
rho_snap_step.append(step)
rho_snap_min.append(float(np.min(macro["rho"])))
rho_snap_max.append(float(np.max(macro["rho"])))
if final_vorticity_png_path:
stream.synchronize()
macro_last = sim.get_macroscopic()
_vdir = os.path.dirname(os.path.abspath(final_vorticity_png_path))
if _vdir:
os.makedirs(_vdir, exist_ok=True)
save_final_vorticity_png(
final_vorticity_png_path,
macro_last["ux"],
macro_last["uy"],
title=flow_figure_title,
)
sim.close()
lift_arr = np.array(lift_hist, dtype=np.float64)
fx_arr = np.array(fx_hist, dtype=np.float64)
step_arr = np.array(step_hist, dtype=np.int64)
burn_samp = min(int(burn) // rec_every, max(0, lift_arr.size - 16))
tail = lift_arr[burn_samp:]
dual = dual_strouhal_from_lift(
tail,
diameter=d_lattice,
u_max=u_max,
sample_dt=float(rec_every),
f_hz_min=f_hz_min,
f_hz_max=f_hz_max,
)
st_guided = dual["St_guided"]
f_guided = dual["f_guided"]
mean_cd = float(np.mean(fx_arr[burn_samp:])) * 2.0 / (u_max ** 2 * d_lattice) if fx_arr.size else float("nan")
if rho_snap_min:
i0 = next((i for i, s in enumerate(rho_snap_step) if s >= burn), 0)
rho_rng = (min(rho_snap_min[i0:]), max(rho_snap_max[i0:]))
else:
rho_rng = (float("nan"), float("nan"))
if dump_npz_path:
freqs_pb, spec_pb = rfft_power_spectrum(tail, sample_dt=float(rec_every))
f0_hz = 0.5 * (float(f_hz_min) + float(f_hz_max))
sigma = max(1e-12, 0.18 * f0_hz)
w_guided = (
np.exp(-((freqs_pb - f0_hz) / sigma) ** 2) if freqs_pb.size else np.zeros(0)
)
band = (
((freqs_pb >= float(f_hz_min)) & (freqs_pb <= float(f_hz_max))).astype(np.float64)
if freqs_pb.size
else np.zeros(0)
)
_dir = os.path.dirname(os.path.abspath(dump_npz_path))
if _dir:
os.makedirs(_dir, exist_ok=True)
np.savez_compressed(
dump_npz_path,
lift_samples=lift_arr,
fx_samples=fx_arr,
sample_lbm_step=step_arr,
burn_index_samples=int(burn_samp),
record_every_lbm_steps=int(rec_every),
freqs_hz_post_burn=freqs_pb,
power_post_burn=spec_pb,
band_mask=band,
guided_gaussian_weight=w_guided,
f_hz_min=np.array([float(f_hz_min)], dtype=np.float64),
f_hz_max=np.array([float(f_hz_max)], dtype=np.float64),
f0_hz_band_mid=np.array([f0_hz], dtype=np.float64),
St_raw=np.array([float(dual["St_raw"])], dtype=np.float64),
St_guided=np.array([float(dual["St_guided"])], dtype=np.float64),
u_max=np.array([float(u_max)], dtype=np.float64),
diameter_lattice=np.array([float(d_lattice)], dtype=np.float64),
)
return {
"St": float(st_guided),
"St_guided": float(st_guided),
"St_raw": float(dual["St_raw"]),
"f_peak_per_step": float(f_guided),
"f_peak_raw_per_step": float(dual["f_raw"]),
"f_peak_guided_per_step": float(f_guided),
"mean_Cd": float(mean_cd),
"n_curved": n_curved,
"fallback_links": fb,
"low_q_links": lq,
"rho_min_post_burn": rho_rng[0],
"rho_max_post_burn": rho_rng[1],
"n_lift_samples": int(lift_arr.size),
}
def relative_st_error(st_meas: float, st_target: float) -> float:
if not np.isfinite(st_meas) or st_target <= 0:
return float("inf")
return abs(st_meas - st_target) / st_target
def evaluate_hard_cases(rows: Sequence[Dict[str, Any]], collision: str) -> Dict[str, Any]:
hard = [
r
for r in rows
if r.get("hard")
and r.get("collision") == collision
and "St" in r
and np.isfinite(r["St"])
]
errs = [relative_st_error(r["St"], r["target_st"]) for r in hard]
errs_raw = [
relative_st_error(r["St_raw"], r["target_st"])
for r in hard
if "St_raw" in r and np.isfinite(r["St_raw"])
]
finite = [e for e in errs if np.isfinite(e)]
within5 = sum(1 for e in finite if e <= 0.05)
worse10 = sum(1 for e in finite if e > 0.10)
fr = [e for e in errs_raw if np.isfinite(e)]
return {
"collision": collision,
"hard_count": len(hard),
"hard_within_5pct": int(within5),
"hard_worse_than_10pct": int(worse10),
"pass_primary_rule": bool(within5 >= 5 and worse10 == 0),
"hard_median_abs_rel_err_raw": float(np.median(fr)) if fr else None,
}
def main() -> int:
ap = argparse.ArgumentParser(description="Sah04 St validation matrix (9 cases × collisions)")
ap.add_argument("--collision", default="MRT", help="SRT, TRT, MRT, or all")
ap.add_argument("--outlet", default="neq_extrap", choices=("neq_extrap", "zero_gradient", "blended"))
ap.add_argument("--record-every", type=int, default=5)
ap.add_argument("--case", default="all", help='Case id 1-9 or "all"')
ap.add_argument("--smoke", action="store_true", help="Short steps/burn for wiring checks")
ap.add_argument("--json-out", type=str, default=None, help="Write full result rows to JSON")
ap.add_argument(
"--steps",
type=int,
default=None,
help="Override matrix LBM steps for each case (ignored with --smoke)",
)
ap.add_argument(
"--burn",
type=int,
default=None,
help="Override matrix burn in LBM steps (ignored with --smoke)",
)
ap.add_argument(
"--dump-npz-dir",
type=str,
default=None,
help="Directory: write case{id}_{COLL}.npz (+ .meta.json) with lift, fx, sample steps, post-burn spectrum",
)
ap.add_argument(
"--final-vorticity-dir",
type=str,
default=None,
help="Directory: write case{id}_{COLL}_laststep.png (omega_z from final macroscopic slice; needs matplotlib)",
)
args = ap.parse_args()
u_max = 0.1
collisions: List[str]
if str(args.collision).lower() == "all":
collisions = ["SRT", "TRT", "MRT"]
else:
collisions = [str(args.collision).upper()]
if collisions[0] not in ("SRT", "TRT", "MRT"):
print("--collision must be SRT, TRT, MRT, or all", file=sys.stderr)
return 2
case_filter: Optional[int] = None
if str(args.case).lower() != "all":
case_filter = int(args.case)
if case_filter < 1 or case_filter > 9:
print("--case must be 1-9 or all", file=sys.stderr)
return 2
cases_to_run = [c for c in MATRIX if case_filter is None or c.case_id == case_filter]
if args.dump_npz_dir:
os.makedirs(args.dump_npz_dir, exist_ok=True)
if args.final_vorticity_dir:
os.makedirs(args.final_vorticity_dir, exist_ok=True)
rows: List[Dict[str, Any]] = []
for coll in collisions:
for mc in cases_to_run:
tier = _TIERS[mc.tier]
ny = int(tier["ny"])
center = (_CX, float(tier["center_y"]))
if args.smoke:
steps = 6000
burn = 1500
else:
steps = int(args.steps) if args.steps is not None else mc.steps
burn = int(args.burn) if args.burn is not None else mc.burn
flo, fhi = shedding_freq_band_hz(mc.target_st, u_max, float(_D))
dump_path: Optional[str] = None
if args.dump_npz_dir:
dump_path = os.path.join(args.dump_npz_dir, f"case{mc.case_id}_{coll}.npz")
vort_path: Optional[str] = None
vort_title = ""
if args.final_vorticity_dir:
vort_path = os.path.join(
args.final_vorticity_dir,
f"case{mc.case_id}_{coll}_laststep.png",
)
vort_title = (
f"Sah04 case {mc.case_id} tier={mc.tier} {coll} Re={mc.re} "
f"nx={_NX} ny={ny} last LBM step={steps} (omega_z)"
)
print(
f"--- case {mc.case_id} tier={mc.tier} beta~{tier['beta_nom']} "
f"Re={mc.re} target_St={mc.target_st} {coll} steps={steps} burn={burn} ---",
flush=True,
)
try:
out = run_one_simulation(
collision=coll,
outlet=args.outlet,
nx=_NX,
ny=ny,
center=center,
re=float(mc.re),
d_lattice=float(_D),
r_cyl=_R_CYL,
u_max=u_max,
steps=steps,
burn=burn,
record_every=int(args.record_every),
f_hz_min=flo,
f_hz_max=fhi,
dump_npz_path=dump_path,
final_vorticity_png_path=vort_path,
flow_figure_title=vort_title or "Sah04 vorticity (final LBM step)",
)
except Exception as e:
print(f"FAILED: {e}", file=sys.stderr)
rows.append(
{
"case_id": mc.case_id,
"tier": mc.tier,
"collision": coll,
"Re": mc.re,
"target_st": mc.target_st,
"hard": mc.hard,
"error": str(e),
}
)
continue
rel_err = relative_st_error(out["St"], mc.target_st)
rel_err_raw = relative_st_error(out["St_raw"], mc.target_st)
row = {
"case_id": mc.case_id,
"tier": mc.tier,
"beta_nominal": tier["beta_nom"],
"collision": coll,
"Re": mc.re,
"target_st": mc.target_st,
"hard": mc.hard,
"St": out["St"],
"St_raw": out["St_raw"],
"St_guided": out["St_guided"],
"rel_err_st": float(rel_err) if np.isfinite(rel_err) else None,
"rel_err_st_raw": float(rel_err_raw) if np.isfinite(rel_err_raw) else None,
"within_5pct": bool(np.isfinite(rel_err) and rel_err <= 0.05),
"worse_10pct": bool(np.isfinite(rel_err) and rel_err > 0.10),
"mean_Cd": out["mean_Cd"],
"n_curved": out["n_curved"],
"fallback_links": out["fallback_links"],
"low_q_links": out["low_q_links"],
"steps": steps,
"burn": burn,
}
rows.append(row)
if dump_path:
meta_path = os.path.splitext(dump_path)[0] + ".meta.json"
_write_json(
meta_path,
{
"case_id": mc.case_id,
"tier": mc.tier,
"collision": coll,
"Re": mc.re,
"target_st": mc.target_st,
"hard": mc.hard,
"steps": steps,
"burn": burn,
"record_every": int(args.record_every),
"f_hz_min": flo,
"f_hz_max": fhi,
"npz_basename": os.path.basename(dump_path),
"St_raw": out["St_raw"],
"St_guided": out["St_guided"],
"mean_Cd": out["mean_Cd"],
},
)
flag = "OK" if row.get("within_5pct") else ("SOFT" if not mc.hard else "CHECK")
print(
f" St_raw={out['St_raw']:.5f} St_guided={out['St_guided']:.5f} "
f"rel(guided)={100.0 * rel_err:.2f}% rel(raw)={100.0 * rel_err_raw:.2f}% "
f"Cd~{out['mean_Cd']:.4f} [{flag}]",
flush=True,
)
if not args.smoke and case_filter is None and str(args.case).lower() == "all":
print("\n=== Hard-case summary per collision (5% gate; need 5/7 & none >10%) ===")
for coll in collisions:
ev = evaluate_hard_cases(rows, coll)
print(json.dumps(ev, indent=2))
if args.json_out:
ev_all = {
coll: evaluate_hard_cases(rows, coll)
for coll in {r.get("collision") for r in rows if r.get("collision")}
if coll
}
_write_json(args.json_out, {"rows": rows, "evaluation_by_collision": ev_all})
return 0
if __name__ == "__main__":
raise SystemExit(main())

130
tests/审计.md Normal file
View File

@ -0,0 +1,130 @@
## 审计结论
当前代码的问题不是单点误差而是沿着主链路分布。最重的问题集中在三处curved Bouzidi 的施加时机不对Bouzidi 分支公式与文献不一致,以及若干运行时接口与内核实现脱节。这几类问题叠加后,足以让同一 case 在 SRT、TRT、MRT 之间出现远大于正常数值差异的结果。
需要单独说明的是,部分未来接口属于有意预留,不应被视为 bug。本审计只把已经进入当前执行契约、但实现与语义不一致的部分列为问题。
按当前项目约束,以下两类设计不再单列为缺陷:
- 运行时接口为后续能力预留,但当前未完全接通,只要注释明确即可
- `obs` 采用跨多步累加,由调用者决定何时清零,这属于有意设计而非实现错误
## 状态说明
- `[已解决]` 已进入当前代码
- `[待重构]` 不是单点修补能彻底解决,适合下一阶段重构
- `[待验证]` 需要最小算例或单元测试确认
- `[保留说明]` 当前不修,但需要在代码或文档中明确限制
## 当前总览
### 已解决
- [已解决] `lbm/__init__.py` 导出错误
- [已解决] forcing 主链路未接通,以及 SRT TRT MRT forcing 预因子不一致
- [已解决] TRT outlet NEQ 重构未补齐
- [已解决] `add_vortex()` 把动量当速度
- [已解决] Sensor 面积归一化缺失,已在 `ObjectManager` 层提供
- [已解决] `sync_to_gpu()` 末尾重置非流体节点的架构错误
- [已解决] curved donor 合法性未检查真实 domain flags
- [已解决] curved Bouzidi 时序错误
- [已解决] `q >= 0.5` 分支读错时间层
- [已解决] moving wall 修正未按 q 分支实现
- [已解决] 初始化链路与 object flag 叠加关系错误
- [已解决] `config_body.json` 未进入实际初始化链路
- [已解决] inlet `U0` 语义已补注释
### 仍需重构或继续处理
- [待重构] `body` 模块整体职责边界仍不清晰几何描述、flag overlay、compact list 生成、动作状态、观测打包仍然耦合过重
- [待重构] curved boundary 当前仍是“圆形简单几何特化 + Bouzidi kernel 特化”架构,不适合继续扩到离散几何与通用移动刚体
- [待重构] 3D 刚体旋转契约仍是 z 轴占位实现
- [待重构] plain linear Bouzidi 与 TRT 不相容问题已注明限制,但没有方法级替代方案
- [待重构] `config.py` 与文档层对预留能力、当前能力、限制条件的边界还不够清楚
- [待验证] MRT 路径仍需最小算例单独核对
- [待验证] Esopull 邻壁处理仍需与 double-buffer 做一致性复核
- [待验证] force 提取与 host 侧系数归一化仍需放到同一处核对
## 历史问题清单
### 主执行链路
| 文件 | 问题 | 影响 |
|---|---|---|
| `lbm/__init__.py` | [已解决] 导入了并不存在的 `add_lamb_oseen``add_taylor_green`。实际 `initializers.py` 只提供 `add_vortex`。 | `CelerisLab.lbm` 包导入会退化到空 `__all__`,对外 API 与代码不一致。 |
| `field.py`、`config.py`、`inlet_outlet.cuh`、`init_flow.cu` | [已解决] `update_runtime_params()` 暴露了 `u_inlet``rho_ref` 一类接口,但入口、出口、初始化全部仍然读编译期宏 `U0``RHO`。如果这些接口继续保留在活跃 API 中,就会形成假功能。 | 运行时接口语义与真实执行路径不一致。 |
| `helpers.cuh``forcing_guo.cuh` | [已解决] `collide_dispatch()` 每步都调用 `zero_forcing(Fin)`,没有任何地方根据 `d_params.fx fy fz` 生成 Guo forcing也没有调用速度半步修正。 | 运行时体力项接口是死路径。任何依赖体力驱动的算例都会静默失效。 |
| `collision_trt.cuh``collision_mrt.cuh` | [已解决] 在 forcing 真的接通之前TRT 与 MRT 版本目前都是直接加 `Fin[i]`SRT 则乘了 `(1-omega/2)`。 | forcing 一旦接通,三种碰撞模型会立刻表现出不一致的体力离散。 |
### Curved boundary 与几何预处理
| 文件 | 问题 | 影响 |
|---|---|---|
| `one_step_double.cu`、`aux_kernels.cu`、`curved_boundary.cuh` | [已解决] Curved Bouzidi 是在 `OneStep` 完成 collision 与 store 之后,通过 `CurvedBoundaryKernel` 二次修补。 | 紧邻曲壁的 fluid 节点在本步 collision 时已经使用了从 solid 方向拉来的错误分布。后修补只能影响下一步,不能修正本步碰撞污染。 |
| `curved_boundary.cuh` | [已解决] `q >= 0.5` 分支使用 `fi_in[k_f, dir_opp]`也就是上一步缓冲区而不是同一步、碰撞后传播前的分布函数。Bouzidi 线性插值两支公式都要求同一时间层的 post-collision 数据 [Bou01]。 | 该分支与文献公式不一致,会直接破坏 curved wall 的局部反射关系。 |
| `curved_boundary.cuh` | [已解决] moving wall 修正统一写成 `+ 6 w_i (c_i · u_w)`,没有区分 `q < 0.5``q >= 0.5` 两支,也没有体现 Bouzidi moving boundary 中的分支依赖系数 [Bou01]。 | 对旋转圆柱或移动物体,壁面速度修正的量级与形式都不对。 |
| `body/objects.py` | [已解决] `Cylinder.get_curved_list()` 的 donor 合法性只检查了“是否越界”和“是否落在圆柱内部”,没有检查 donor 是否落在上壁、下壁、入口、出口等非流体节点。 | `fallback_class` 的主机侧担保并不成立。靠近通道壁或边界时,`q < 0.5` 分支可能继续使用非法 donor |
| `aux_kernels.cu` | [待重构] 3D curved body 的运行时角速度契约只读取一个标量 `omega`,并硬编码成 z 轴转动,代码里也明确写了 placeholder。 | 3D 旋转物体路径并未真正完成,但接口没有把这种限制暴露出来。 |
### 初始化、观测与工具函数
| 文件 | 问题 | 影响 |
|---|---|---|
| `initializers.py` | [已解决] `add_vortex()``ux_old``uy_old` 用的是动量和,没有除以 `rho_old`。 | 任何基于该函数构造的初值都会把速度场放大为密度加权动量场。 |
| `step/aux_kernels.cu``body/manager.py` | [已解决] `SensorKernel` 对传感器区域做的是逐格点求和,`ObjectManager` 也没有按面积归一化。 | 如果外部把传感器输出当成平均速度或平均观测量,结果会系统偏大并随探针面积变化。 |
| `body/manager.py` | [已解决] `sync_to_gpu()` 最后调用 `_rest_nonfluid()`,会把所有非流体节点都重置成静止平衡态,而不仅是物体节点。 | 只要场中有物体,就会额外改写入口、出口、壁面节点的初始化状态。 |
### 文档与配置层
| 文件 | 问题 | 影响 |
|---|---|---|
| `configs/CONFIG` | 文档写 `nx` 必须整除 `threads_per_block`,但实际 launch 使用的是 ceiling division。 | 配置说明与执行实现不一致。 |
| `configs/CONFIG` | [已解决] 文档写 `neq_extrap` 下 SRT 与 TRT 都使用全分布 damped NEQ 重构,但代码里只有 SRT 走全分布分支TRT 仍是少量未知方向重构。 | 算法说明与真实边界实现不一致。 |
| `README``config.py` | 文档把 `FP16C` 当成可选存储精度,但 `LBMConfig.validate()` 会直接拒绝 `FP16C`。 | 对外能力声明与当前运行时不一致。 |
## 高风险问题
### 边界方法与碰撞模型耦合
| 文件 | 问题 | 影响 |
|---|---|---|
| `curved_boundary.cuh``collision_trt.cuh` | [保留说明] 当前实现采用 plain linear Bouzidi 与 TRT 直接拼接。TRT 的黏性无关参数化只在边界离散满足相应条件时才成立,普通线性插值边界并不会自动保留该性质 [Gin08b]。 | 即使把显式 bug 全部修掉TRT 与 SRT、MRT 的结果仍可能因边界离散而系统分叉。 |
| `curved_boundary.cuh` | 当前 curved wall 没有任何质量守恒修正。对于高 Re、长时间、周期性 curved flow文献已经报告 plain Bouzidi 会出现质量泄漏与力漂移 [San18]。 | 长时间拖算时,平均密度、阻力与升力可能持续漂移。 |
| `collision_mrt.cuh` | D2Q9 MRT 为配合新方向排序手工重写了整套 moment transform 与 inverse transform但代码里没有任何一致性校验或自测痕迹。 | 一旦某个符号、系数或方向映射有误,结果会直接体现在升阻力与稳定性上,而且不容易从表面现象定位。 |
| `inlet_outlet.cuh``config.py` | 抛物入口把 `U0` 解释成截面平均速度,峰值自动变成 `1.5 * U0`。接口名字仍然叫 `velocity`,没有区分平均速度与最大速度。 | 只要用户按最大入口速度设参数,实际 Re 就会整体偏移。 |
| `init_flow.cu` | 四个角点优先按 inlet 或 outlet 分类,而不是 wall。随后边界核又把非 `interior_y` 的角点落回 `bounce_back_swap()`。 | 角点的标记语义与实际处理语义不一致,容易在后续扩展中埋下隐患。 |
## 待验证问题
### 需要单元测试或最小算例复核的项
| 文件 | 问题 | 影响 |
|---|---|---|
| `one_step_esopull.cu` | Esopull 路径没有像 double-buffer 那样对 `y == 1``y == NY-2` 的流体邻壁行做显式半格 bounce-back 修正。 | bounded channel 在 esopull 与 double-buffer 之间可能存在额外差异,需要最小通道算例核对。 |
| `collision_mrt.cuh` | [待验证] D2Q9 MRT 的新配对顺序、moment basis、inverse transform 是否与 `compute_rho_u()`、`compute_feq()` 完全一致,目前只能靠数值表现间接推断。 | 这条路径可能包含隐藏的符号错位需要用均匀流、Poiseuille、衰减涡等简单算例单独验算。 |
| `inlet_outlet.cuh` | SRT 出口在 `neq_extrap` 下重构了全体分布,不仅是未知方向。 | 该设计是否有利于稳定性、是否额外改变质量守恒,需要独立做 outlet 对比测试。 |
| `curved_boundary.cuh``aux_kernels.cu` | [待验证] 当前力提取采用 `f_toward + f_reflected` 的链路级动量交换,但没有与 host 侧的阻力系数归一化和符号约定放在同一处核对。 | 即使流场正确,力的符号、量级和平均方式也仍可能在后处理阶段出错。 |
## 本轮已修复
- `lbm/__init__.py` 已改为导出真实存在的 `add_vortex`
- 运行时参数接口已收紧到当前真实生效的 `omega` 与 forcing 相关项
- Guo forcing 已接入主碰撞分发链路SRT、TRT、MRT 的 forcing 预因子已统一
- TRT 的 outlet NEQ 重构已补齐为与 SRT 一致的全分布 damped NEQ 路径
- `add_vortex()` 已修正为用速度而不是动量直接叠加初值
- 传感器读数已在 `ObjectManager` 层提供面积归一化接口
- 物体同步后的静止平衡重置已收缩为只作用于 obstacle interior而不再覆盖所有非流体节点
- curved link donor 合法性检查已扩展到实际 domain flags而不只检查是否在圆柱内部
- curved Bouzidi 已从“步后修补流体节点”改为“步前写入 obstacle source slot”并改掉了 `q >= 0.5` 分支对前一时间层 opposite population 的读取
- curved moving-wall 修正已继续收紧为独立 helper并把 `q < 0.5` 与 fallback 分支改回与 [Bou01] 一致的符号约定
- 初始化链路已改为“先构建 clean channel flags再叠加 object mask再由 init kernel 保持 obstacle flag 并写入静止平衡态”,移除了 `sync_to_gpu()` 末尾对 obstacle interior 的二次主机侧重置
- `Simulation` 已开始消费 `config_body.json` 中的简单物体定义,并在编译期同步 `N_OBJS` 契约
- inlet 抛物入口已补充注释,明确 `U0` 在当前实现中表示截面平均速度,峰值为 `1.5 * U0`
- curved boundary 文件已显式注释plain linear Bouzidi 与 TRT 不具备 TRT-parametrized curved-wall 保证,当前实现保留该限制说明
## 交叉症状解释
当前现象和代码结构是吻合的。TRT 加 Bouzidi 直接发散,最符合 curved wall 时序错误与 `q >= 0.5` 分支读错时间层这两个问题叠加后的表现。SRT 与 MRT 加 Bouzidi 不发散但升阻力偏离,则更像是主链路虽然还能跑,但边界修正、观测累加、入口参数语义和 MRT 未校验实现共同把结果拖离了正常范围。
文献层面的两条提醒也和代码现状一致。第一plain linear boundary 与 TRT 的参数化并不天然相容 [Gin08b]。第二plain Bouzidi 在高 Re 周期 curved flow 中会出现质量泄漏与力漂移 [San18]。这两条不是当前代码里最先要解释的大偏差,但它们说明就算显式 bug 修完,边界策略本身仍然需要额外核验。