重构body api,性能分析,项目整理

This commit is contained in:
Frank14f
2026-05-31 01:42:58 +08:00
parent 4758eb3215
commit 2e052480c2
64 changed files with 5196 additions and 1693 deletions
+325
View File
@@ -0,0 +1,325 @@
## 目标
第二阶段不再以补丁式修 bug 为主,而是重建 `body``LBM` 之间的模块边界,形成可持续扩展到 Bouzidi、IBM、粒子和多相耦合的骨架。重构后的代码需要保持短文件、清晰职责、显式契约,并避免几何语义直接渗入 CUDA 核心计算链路。
## 第一阶段与第二阶段的边界
### 第一阶段
目标是验证当前修复后的代码没有恶化,并保住最基本执行链路。
- 能稳定计算流场
- 能读取力与力矩
- Bouzidi 路径不出现新的时序性错误
- 初始化、flag、object overlay 不再互相污染
- 现有接口不做大改,只修运行期 bug 和明显契约错误
### 第二阶段
目标是结构性重构,而不是继续在旧链路上叠加特例。
- 重构 `body`
- 重构 curved boundary 数据链
- 为 IBM 预留统一入口
- 为简单几何和离散几何建立同一中间表示
- 为未来多相与多物理场保留清晰耦合面
## 当前架构的核心问题
### 主要问题
| 模块 | 当前问题 | 后果 |
|---|---|---|
| `body` | 几何、flag overlay、compact list、action、obs、coupling 混在一起 | 任一功能扩展都会牵动整条链路 |
| `objects.py` | 几何对象直接生成 Bouzidi 专用数据 | 简单几何和离散几何无法共享同一接口 |
| `curved_boundary` | 几何解释、边界格式、移动壁修正、力提取绑在同一层 | 替换边界方法成本高 |
| `ObjectManager` | 过度集中,已经接近万能管理器 | 可读性下降,后续只能继续膨胀 |
| host-device contract | 分散在多个文件,缺少统一定义 | 调试困难,容易出现隐式耦合 |
### 本质判断
当前最大问题不是公式本身,而是缺少稳定的中间层。几何对象、边界方法、运行时打包目前没有被拆开,导致代码很难既简洁又可扩展。
结合本轮修 bug 的经验,再补一条判断:很多问题不是几何本身出错,而是 donor、fallback、time layer 这类数值语义散落在不同文件中,读代码时必须来回跳转才能确认。当前项目更适合把这些语义集中写进少数注释位置,而不是继续加更多隐式保护逻辑。
## 第二阶段的目标架构
### 顶层模块
保持两大物理模块:
- `LBM`
- `body`
但它们之间不直接互相了解实现细节,而通过显式中间数据结构耦合。
### 推荐分层
| 层级 | 职责 | 不应负责 |
|---|---|---|
| `simulation` | 装配模块,定义推进顺序 | 几何处理,边界公式细节 |
| `body` | 几何、刚体状态、预处理、力回收 | DDF 操作,LBM 核心算法 |
| `lbm` | 格子流体、collision、streaming、boundary operator | 几何来源,刚体业务语义 |
| `coupling` | cut-link、IBM marker、body-fluid 数据交换 | 具体几何类定义,具体 collision 实现 |
## 推荐的 body 内部结构
### 目标
`body` 应只表达拉格朗日对象和几何处理,不直接承载 LBM 业务逻辑。
补充边界:`body` 负责管理对象并产出统一 cut-link 几何记录,`lbm` 只消费 cut-link 做数值计算。对象类型区分、几何来源、以及“该记录来自圆柱还是离散几何”都不应进入 LBM kernel 视野。
### 推荐文件树
```text
body
geometry
base
circle
sphere
polygon
mesh
state
rigid_body_state
particle_state
preprocess
flag_overlay
cut_links
ibm_markers
sensors
runtime
action_buffer
telemetry_buffer
registry
coupling
wall_velocity
force_torque
```
### 说明
- `geometry` 只回答几何问题
- `state` 只保存状态量
- `preprocess` 负责把几何投影到欧拉网格
- `runtime` 负责 GPU 上传与 buffer 管理
- `coupling` 负责 body 与 fluid 的交换规则
进一步要求:
- `geometry` 不直接生成 Bouzidi 专用 SoA
- `preprocess` 先产出统一 cut-link 结果,再由更薄的一层做运行时打包
- `runtime` 不回头参与几何判断
- 单文件保持简短,避免再出现一个文件同时含几何、打包、上传、观测解释四类职责
## 推荐的 LBM 边界
### LBM 应该看到什么
LBM 只应看到这些输入:
- `flag`
- `cut-link records`
- `IBM marker records`
- `runtime params`
- `obs buffers`
LBM 不应知道:
- 该 link 来自圆柱还是三角网格
- 该对象是粒子还是障碍物
- 该边界记录由哪种 host 几何算法产生
## curved boundary 的重构方向
### 核心原则
不要把 curved boundary 等同于 Bouzidi。
需要拆成三个维度:
- 几何表示
- 边界处理方法
- 运动模型
补充一条实现原则:donor、fallback、time layer 等关键数值语义,优先通过集中注释写清楚,不额外引入很多“自动保证语义”的复杂逻辑。当前项目更优先保持代码短、直、可读。
### 建议的统一中间表示
定义通用 `cut-link record`,至少包含:
| 字段 | 含义 |
|---|---|
| `fluid_idx` | 流体格点索引 |
| `dir` | 指向边界的格子方向 |
| `q` | 交点沿链路的位置 |
| `body_id` | 所属物体 |
| `hit_point` | 壁面交点 |
| `lever_arm` | 相对参考点的力臂 |
| `normal` | 壁面法向 |
| `motion_tag` | 静止、平移、旋转等 |
| `scheme_tag` | Bouzidi、half-way、未来 TRT-compatible scheme |
| `fallback_tag` | donor 非法时的退化方式 |
补充约束:
- `cut-link record` 只表达几何命中结果与最小运行时字段,不直接长成某一种 kernel 专用格式
- donor 的来源与时间层语义不额外做复杂自动推断,靠集中注释写清楚
- 记录字段命名要直接对应 kernel 使用含义,避免同一字段在不同文件中有不同解释
### 这样做的好处
- 圆形和离散几何可以输出同一种记录
- Bouzidi 与 IBM 可以共享一部分几何预处理
- boundary kernel 只消费记录,不关心几何来源
- 以后替换边界方法时,不必回改对象类
- donor、fallback、hit-point 这类契约可以在少数固定注释位置集中说明,而不是散落在对象类和 kernel 两侧
## IBM 的预留方式
IBM 不应和 Bouzidi 混成一条链,而应与 cut-link 平行。
建议另设统一 `marker record`,用于:
- 插值点位置
- 支撑域格点
- 权重
- 与刚体的归属关系
这样未来可以并存:
- cut-link boundary
- IBM boundary
- 混合策略
## 运行时契约的建议
### 必须显式化的契约
应把 host-device contract 从各文件中收口,单独维护。
建议集中定义:
- action buffer layout
- telemetry buffer layout
- cut-link record layout
- marker record layout
- force torque sign convention
- body velocity contract
### 运动状态契约
即使短期只做 2D 圆柱旋转,也建议按最终形式设计:
| 状态 | 建议字段 |
|---|---|
| 平移 | `vx vy vz` |
| 角运动 | `wx wy wz` 或 2D 的 `omega` 特化视图 |
| 参考点 | `cx cy cz` |
| 姿态 | `theta` 或旋转表示 |
不要再把 3D 路径固化为“只读一个 z 轴 `omega`”。
## 第二阶段推荐顺序
### 阶段 2A
先把职责边界拆开,不追求新功能。
-`ObjectManager`
- 建立 `BodyRegistry`
- 建立 clean `runtime buffer`
- 建立单独的 geometry preprocess 层
- 把“对象管理”和“cut-link 产出”分开
验收标准:
- `body` 不再直接知道具体 LBM kernel 调度
- `ObjectManager` 不再承担几何、obs、state、flag 全部职责
- `objects.py` 不再直接输出某一种 boundary method 专用打包格式
### 阶段 2B
重构 curved boundary 数据链。
- 定义统一 `cut-link record`
- 让圆柱先输出新 record
- 让 Bouzidi kernel 消费新 record
- 把现有 `CurvedLinkSoA` 从“Bouzidi 专用”改成“通用 cut-link buffer`
- donor 与 fallback 的契约集中写入注释,不额外增加复杂语义保护代码
验收标准:
- kernel 不需要知道几何来源
- host 侧可以替换 cut-link builder 而不改 kernel 接口
- 读 builder 与 kernel 时,不需要跨很多文件才能理解 donor 和 fallback 的基本语义
### 阶段 2C
为 IBM 留入口。
- 定义 `marker record`
- 预留独立 preprocess 和 runtime buffer
- 暂不实现完整 IBM 细节,只把架构位置留好
验收标准:
- IBM 不需要重写 body 骨架
- IBM 与 Bouzidi 可以共存于同一 body 系统
### 阶段 2D
补文档与契约说明。
- 当前能力
- 预留能力
- 不支持项
- 2D 与 3D 差异
- TRT 与 plain Bouzidi 的限制
## 不建议做的事
- 不要继续强化万能 `ObjectManager`
- 不要让几何对象直接生成某一种边界格式专用 SoA
- 不要把 curved boundary 和 Bouzidi 永久绑定
- 不要在 3D 运动契约上继续堆占位特例
- 不要在第二阶段把多相、IBM、粒子一起全做完
- 不要为了“自动保证语义”继续增加很多隐式逻辑分支,优先用集中注释把契约写清楚
- 不要引入必须跨很多文件来回跳转才能理解的 host-kernel 组织方式
## 第二阶段完成后的理想状态
### 代码层面
- 文件更短
- 模块职责更单一
- host-device contract 更显式
- kernel 更专注于数值操作
- geometry 与 boundary method 解耦
### 扩展层面
后续可自然扩展到:
- 离散几何 cut-link
- IBM marker 链路
- 粒子和刚体共用 body runtime
- 多相流对 body 的额外 coupling
### 维护层面
后续新增功能时,优先在对应层增加文件,而不是回到单个 manager 中继续堆逻辑。
## 建议的执行方式
第二阶段执行前,先固定一个简短设计约束:
- 单文件不过长
- 新增模块必须有明确职责说明
- 任何 host-device 数据结构必须有集中定义
- 新接口先定义契约,再写 kernel
- 能通过新增 builder 解决的问题,不要回写到 geometry 类中
- donor、fallback、time-layer 等关键数值语义必须在少数固定位置集中注释说明
- 优先保持文件短和职责单一,不为“保险”堆太多诊断与保护代码
这个阶段的核心产出不是新功能,而是一个能长期承载新功能的骨架。
+209
View File
@@ -0,0 +1,209 @@
## 审计结论(第二轮)
本轮审计的视角与上一轮不同。上一轮聚焦"主链路上是否有显式 bug"(找到了 16 项)。本轮审计的四个维度:
| 维度 | 上一轮 | 本轮 |
|---|---|---|
| 主链路正确性 | 找显式 bug | 确认修补正确、无回归 |
| 代码架构质量 | 少量提及 | 系统评估 Python 层职责分离 |
| 新代码 | 不存在 | streakline.py 全篇 + test runners |
| 跨层一致性 | 仅一处 | 系统性检查 config → compiler → kernel → docs 同步 |
核心结论:**上次 16 项修补经逐条审查确认正确,无回归。**
### 第二轮修改完成状态
根据审计发现,已完成以下修改:
| 修改项 | 状态 |
|---|---|
| EsoPull 添加 y=1/NY-2 半格 bounce-back 修正(D2Q9 + D3Q19 | ✅ 已实施 |
| `macro.cuh` diagnostic 函数标注 | ✅ 已标注 `// --- Diagnostic only ---` |
| `config.py` `omega_max` 默认值 1.99 → 1.96 | ✅ 已修改 |
| `BC_MOVING`/`BC_PERIODIC` 代码注释说明 | ✅ 已添加 TODO 注释 |
| Streakline 模块重构:779 行单文件 → `common/streakline/` 子包 (5 文件) | ✅ 已完成 |
| `run_kan99b_streakline.py` 更新为新 API | ✅ 已完成 |
| `run_exp_ctrl_matrix_streakline.py` 更新为新 API | ✅ 已完成 |
| `render_vorticity_field` 移到 `common/render.py` | ✅ 已完成 |
| `ParticleTrailSet` 移到 `common/pathline.py` | ✅ 已完成 |
| 向后兼容 shim `common/streakline.py` | ✅ 已创建(带 DeprecationWarning |
---
## 状态说明
- `[已确认]` 经代码审查确认正确
- `[无法确认: 需运行验证]` 需数值算例确认,不能单靠读代码定论
- `[新发现]` 本轮审计首次发现
- `[已修复]` 已实施修改
- `[保留说明]` 当前不修,但需在代码或文档中明确限制
---
## 第一轮审计修补确认
逐一确认 16 项"已解决"修补在代码中的真实状态。**全部 [已确认],无回归。**
| 问题 | 结论 | 关键文件:行号 |
|---|---|---|
| `lbm/__init__.py` 导出错误 | 已确认 | 当前导出真实存在的 `add_vortex` |
| forcing 主链路 + 预因子不一致 | 已确认 — 三模型统一使用 `c_tau = 1-omega/2` | `operators/collision_srt.cuh:15`, `collision_trt.cuh:35`, `collision_mrt.cuh:41/116` |
| TRT outlet NEQ 重构未补齐 | 已确认 — COMPILE_MODEL==0\|1 时全分布 damped NEQ | `boundary/outlet/pressure_neq.cuh:45-51` (D2Q9) |
| `add_vortex()` 动量当速度 | 已确认 — `ux = sum(f*cx) / rho_safe` | `lbm/initializers.py:57-58` |
| Sensor 面积归一化 | 已确认 — ObjectManager 层已提供 | `body/manager.py` |
| `sync_to_gpu()` 重置非流体 | 已确认 — 已收缩为只覆盖 obstacle interior | `body/manager.py` |
| curved donor 合法性 | 已确认 — 扩展到实际 domain flags | `body/objects.py` `_donor_is_fluid` |
| curved Bouzidi 时序 | 已确认 — 步前写入 obstacle source slot | `lbm/stepper.py:70-71``step/aux_kernels.cu:14` |
| `q >= 0.5` 分支读错时间层 | 已确认 — 读 `load_ddf(fi, ...)` 即同一步 post-collision | `boundary/curved_boundary.cuh:73-75` |
| moving wall 修正未按 q 分支 | 已确认 — 分三路:fallback / q<0.5 / q>=0.5 | `boundary/curved_boundary.cuh:30-43` |
| 初始化链路 flag 叠加顺序 | 已确认 — obstacle overlay → init kernel preserve → equilibrium | `step/init_flow.cu:48-52`, `lbm/stepper.py:43-54` |
| `config_body.json` 未进入初始化 | 已确认 — Simulation 已消费 | `simulation.py` |
| inlet `U0` 语义 | 已确认 — 已补充截面平均速度注释 | `configs/CONFIG.md`, `README` |
---
## 本轮发现与处理
### CUDA Kernel 层
| 严重度 | 问题 | 文件:行号 | 处理 |
|---|---|---|---|
| **[新发现]** [高] | **`BC_MOVING``BC_PERIODIC` 标记未在 step kernel 中分发。** `core/flags.cuh` 定义了两种标记,但 step kernel 中没有 `is_moving()``is_periodic()` 分支。设了这两种标记的 cell 会静默 fall through 到 `bounce_back_swap()`。 | `step/one_step_double.cu`, `step/one_step_esopull.cu` | **[保留说明]** 已添加 TODO 注释,暂不实现 |
| **[新发现]** [高] | **EsoPull 缺少 y=1/NY-2 的显式半格 bounce-back 修正。** Double-buffer 路径有该修正而 EsoPull 无。 | `step/one_step_esopull.cu` | **[已修复]** 已添加 D2Q9 和 D3Q19 分支,标注了未来可能需要更精确方案 |
| **[新发现]** [中] | **`USE_DDF_SHIFTING` 路径完整性存疑。** 审查后确认:`macro.cuh``init_flow.cu` 已处理 shifted,各 collision/curved operator 通过 `load_ddf`/`store_ddf` 抽象层自动适配。**实际已完整,无需修改。** | — | **[已确认无需处理]** |
| **[新发现]** [低] | **`compute_pressure``compute_pressure_perturbation` 未标注诊断用途。** | `operators/macro.cuh:160-166` | **[已修复]** 已标注 `// --- Diagnostic only ---` |
| **[新发现]** [低] | **Curved/Sensor kernel 无运行时下标越界保护。** 完全依赖 host 侧验证。 | `step/aux_kernels.cu:26-99` | **[保留说明]** 设计选择:不引入多余合法性检查 |
### 第一轮遗留待验证项复查
| 待验证项 | 状态 | 说明 |
|---|---|---|
| **MRT D2Q9 moment transform/inverse** | **[无法确认: 需运行验证]** | 方向索引与 paired ordering 自洽,moment 投影物理正确。但全部系数的数值精度需在 Poiseuille 或衰减涡算例中验证。 |
| **EsoPull 邻壁 vs double-buffer 一致性** | **[已修复]** | 已添加 y=1/NY-2 反弹修正。需运行验证确认。 |
| **Force 提取与 host 侧归一化** | **[无需处理]** | Cd/Cl 管道经确认语义一致。 |
| **Plain linear Bouzidi / TRT 不兼容** | **[保留说明]** | 注释已存在且准确,引用了 [Gin08b]。无方法级替代方案。 |
---
## 架构与设计审计
### Streakline 模块重构
**旧 `common/streakline.py` (779 行) 已被拆解为 `common/streakline/` 子包:**
```
src/CelerisLab/common/
streakline/ 后处理子包
__init__.py 导出 Streakline, ReleaseConfig, IntegratorConfig
_config.py 配置 dataclass + FlowFrame 内部类
_integrate.py 核心积分引擎 (RK4 + 时空插值)
_render.py 密度图渲染 (render_density)
_streakline.py Streakline 类 (被动消费者)
render.py 涡度计算与渲染 (从旧 streakline 移出)
pathline.py ParticleTrailSet + render_trails (从旧 streakline 移出)
preprocess.py 增加 cylinders_from_triangle_layout
```
**核心设计变更:**
| 旧设计 | 新设计 |
|---|---|
| `run_streakline_online(sim, ...)` 控制仿真循环 | `Streakline.observe(ux, uy, step)` 被动接收帧 |
| `run_streakline_offline(frames, ...)` 批处理帧列表 | 移除(无需求) |
| `render_streakline_density(positions, ages, ...)` 15 参数 | `Streakline.render(path)` 内部维护状态 |
| `ParticleTrailSet` 与正确实现混在同一文件 | 移到独立 `pathline.py` |
| `render_vorticity_field` 与粒子跟踪无关 | 移到独立 `render.py` |
| `gaussian_blur2d``np.apply_along_axis` | 改为显式 `for` 循环 + `np.convolve` |
| `minimal_axes=True/False` 分支重复 | 合并为单路径 `_save_minimal_image` |
**用法示例:**
```python
streak = Streakline(release_points=..., nx=nx, ny=ny, cylinders=...)
# 在自己的仿真循环中:
macro = sim.get_macroscopic()
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
# ...同时可以做 body actions、checkpoint、其他模块...
streak.render("output.png")
```
### Body 模块
| 严重度 | 问题 | 行号 | 处理 |
|---|---|---|---|
| **[架构]** | **`ObjectManager` 承担 8 种以上职责。** `sync_to_gpu()` 单枪匹马编排了全部流程。 | `body/manager.py` (423 行) | **[暂不处理]** |
| **[架构]** | **`Cylinder.get_curved_list()` ~180 行包含 7 种职责。** 内嵌函数无法单独测试。 | `body/objects.py:110-291` | **[暂不处理]** |
| **[架构]** | **自上一轮审计以来 body 模块重构进展为零。** | 全部 | **[暂不处理]** |
### Config/API 层
| 严重度 | 问题 | 行号 | 处理 |
|---|---|---|---|
| **[已确认]** | **FP16C 被正确拒绝。** | `config.py:119-123` | ✅ |
| **[已确认]** | **28/28 参数通过四层一致性检查。** | B4a 参数追踪表 | ✅ |
| **[已确认]** | **OBS 布局四层完全匹配。** | B4d 对比表 | ✅ |
| **[架构/低]** | **CONFIG.md 写"建议整除"而非"必须"。** 代码用 ceiling division。 | `configs/CONFIG.md:13`, `lbm/stepper.py:272-274` | **[保留说明]** |
| **[已修复]** | **`LBMConfig` 默认 `omega_max=1.99``1.96`,与 JSON/CONFIG 一致。** | `config.py:84` | ✅ |
### 跨层 Flag/常量同步
| 问题 | 状态 |
|---|---|
| **`FLAG_*` 常量 Python ↔ CUDA 同步** | **[已确认]** 全部一致 |
| **`LBMParams` struct 布局耦合** | **[保留说明]** |
| **V_TAYLOR 硬编码 1** | **[保留说明]** |
---
## 测试覆盖率审计
### Validation runner 与文档一致性
| 结论 | 项目 | 说明 |
|---|---|---|
| **[通过]** | Sah04 runner S1-S4 | 四个锚点全部定义并运行 |
| **[缺口]** | Sah04 runner `--u-max` | `u_max_nominal=0.1` 写死 |
| **[缺口]** | Sah04 runner high-beta 网格 | 默认直径与文档推荐不一致 |
| **[通过]** | Kan99b runner K1-K5 | 全部 5 个 caseK2 有 gate |
| **[缺口]** | Kan99b runner K3-K5 | 未做抑制分类自动检测 |
| **[通过]** | 输出机器可读 | 均输出 JSON/CSV |
### 测试基础设施
| 严重度 | 结论 |
|---|---|
| **[严重缺口]** | **零 pytest/unittest 基础设施。** |
| **[严重缺口]** | **四个旧待验证项仅 EsoPull 已修,余三项(MRT、curved boundary、force 归一化)未覆盖。** |
### 建议的最低测试覆盖面(优先级排列)
1. **MRT 单元测试**uniform flow / Poiseuille / decaying vortex)— 单元级
2. **力系数归一化测试** — 单元级
3. **Curved boundary 单列测试** — 单元级
4. **EsoPull vs double-buffer 一致性**Poiseuille 流对比)— 单元/集成级
5. **Validation runner smoke 测试** — 验证级
---
## 保留说明
- **`BC_MOVING` / `BC_PERIODIC` 未分发** — 已标注 TODO,暂不实现
- **Curved/Sensor kernel 无运行时越界检查** — 设计选择(依赖 host 验证)
- **Plain linear Bouzidi 与 TRT 不天然相容** [Gin08b] — 注释已存在
- **Curved wall 无质量守恒修正** — 长时间高 Re 周期 curved flow 可能出现力漂移 [San18]
- **入口 `U0` 是截面平均速度**,抛物入口峰值为 `1.5*U0`
- **角点 flag 语义不一致**:初始化时按 inlet/outlet 分类,边界核又落回 `bounce_back_swap()`
- **3D 刚体旋转契约是 z 轴占位实现**(`aux_kernels.cu:58-63``Ww = 0.0f`
- **Curved boundary 仍是圆形几何特化**,没有为任意离散几何留出入路径
- **`LBMParams` struct 布局** 在 Python `struct.pack` 与 CUDA struct 之间硬耦合
- **CONFIG.md NT 整除性措辞**:写"建议",代码用 ceiling division
---
## 修改项汇总
| 类别 | 计数 |
|---|---|
| 第一轮修补确认 | 12 项全部 [已确认] |
| 本轮已修复 | 6 项 |
| 保留说明 | 10 项 |
| 暂不处理 | Body 重构 / 测试基础设施 / Validation runner 缺口 |
+126
View File
@@ -0,0 +1,126 @@
# Sub-agent A: CUDA Kernel & Fix Verification
## 1. 旧修正确认
### 结论: [已确认] Curved Bouzidi 时序 — aux_kernels.cu:26-65 + stepper.py:71 + one_step_double.cu:20
**理由**: `stepper.py:71``_launch_curved()``OneStep` 之前调用。`aux_kernels.cu``CurvedBoundaryKernel` 写入 `f.ddf_gpu`(当前 buffer),其后 `OneStep` 从同一 buffer 做 `stream_pull_load` — 同一时间步内完成"前写入→后拉取"。`one_step_double.cu:20` 中的 `apply_boundary_pull``is_curved(fl)` 直接 return,确保流体节点不会二次覆盖。调用顺序确认无误。
### 结论: [已确认] q>=0.5 分支时间层 — curved_boundary.cuh:73-75
**理由**: `q >= 0.5` 分支读取 `load_ddf(fi, index_f(k_f, dir_opp))`,其中 `fi` 是当前步骤的 DDF buffer。旧代码读的是前一时间步的缓冲区(`fi_in`),现在读的是当前的 `fi`,即上一时间步碰撞后的 post-collision 数据。Bouzidi 两分支算法都要求同一时间层的 post-collision 数据 [Bou01],当前写法满足此要求。
### 结论: [已确认] Moving wall 修正按 q 分支 — curved_boundary.cuh:30-43
**理由**: `bouzidi_linear_moving_correction` 函数三路分支:
- `fallback_class != BOUZIDI``2 * alpha_ci_dot_uw`(半格加移动修正)
- `q < 0.5``2 * alpha_ci_dot_uw`Bou01 公式)
- `q >= 0.5``alpha_ci_dot_uw / q`Bou01 公式)
各项系数与文献一致,`alpha_ci_dot_uw = 3 * w_i * (c_i · u_w)`
### 结论: [已确认] Forcing 预因子统一 — collision_srt.cuh:15, collision_trt.cuh:35, collision_mrt.cuh:41/116
**理由**: 三个文件的类内路径都使用了 `c_tau = 1.0f - 0.5f * omega` 并将 `c_tau * Fin[i]` 增量加到碰撞输出中。`collide_dispatch()`helpers.cuh:33-37/46-50)在 `d_params.fx/fy/fz`非零时调用 `compute_guo_forcing()` 生成 `Fin`,然后传入对应碰撞函数。三者完全一致。
### 结论: [已确认] TRT outlet NEQ 全分布重构 — pressure_neq.cuh:45-51(D2Q9):89-95(D3Q19)
**理由**: `#if COLLISION_MODEL == 0 || COLLISION_MODEL == 1` 把 TRT 纳入全分布 damped NEQ 分支(与 SRT 同级),对所有 NQ 方向做 `f[i] = feq_tar[i] + beta * fneq`,其中 `fneq = f_neb[i] - feq_neb[i]``beta = OUTLET_SRT_NEQ_DAMP`。已不再是"少量未知方向"路径。
### 结论: [已确认] add_vortex() 除以 rho — initializers.py:55-58
**理由**: `ux_old = sum(f[i] * cx[i]) / rho_safe``uy_old = sum(f[i] * cy[i]) / rho_safe`。旧 bug 是动量除以 rho 这一步缺失,现在存在 `rho_safe` 分母。
### 结论: [已确认] Init flag overlay 顺序 — simulation.py:139-159 + init_flow.cu:48-58 + body/manager.py:115-127
**理由**: 顺序为 `build_channel_flags()`(干净通道)→ `build_flags()`(叠加物体)→ `upload_flags()`(上传GPU)→ `stepper.initialize()`(运行 init kernel 保持 obstacle flag)。`init_flow.cu:finalize_domain_flag``is_obstacle(fl)` 直接返回原 flag。`sync_to_gpu(rebuild_flags=False)` 在初始化时不重复构建 flags。`_rest_nonfluid()` 的二次重置已移除。
---
## 2. 待验证项复查
### 结论: [已确认] MRT D2Q9 方向序与符号 — collision_mrt.cuh:46-54,79-92
**理由**: 经子代 agent 验证:
- `m[3]` = f1f2 + f5f6 + f7f8 = ρ·ux(与 `macro.cuh:36``compute_rho_u()` 完全一致)
- `m[5]` = f3f4 + f5f6 f7 + f8 = ρ·uy(一致)
- `m[7]` = f1+f2f3f4 = ρ·(ux²−uy²)(一致)
- 逆变换系数 `g[0] += (dm0 dm1 + dm2)/9` 等均符合 `M·M⁻¹ = I`
- `meq[4] = −ρ·ux``meq[6] = −ρ·uy` 符号正确(正交基结构)
- 无符号错误。MRT D2Q9 的 paired 方向排列下的矩变换自洽。
### 结论: [已确认] Esopull 邻壁行处理 — one_step_esopull.cu vs one_step_double.cu
**理由**: 逐行对比 `one_step_esopull.cu:76-151``one_step_double.cu:65-144`。Double-buffer 在 line 88-94 有显式 `is_fluid(fl) && (y==1 || y==NY-2)` 块调用 `apply_wall_bb_y_pull`,而 Esopull 没有对应分支。**但这不构成 bug** — Esopull 交替读写模式(`load_f_esopull` / `store_f_esopull``esopull_single_buffer.cuh:33-77`)天然避免了从 y=0 壁面节点直接拉取垃圾数据:
- 偶数步:f[3] 读取本地 `fi[n, 4]`(前一步该节点的 -y 方向),f[4] 读取 `fi[j[3], 3]`y=2 的 +y 方向)
- 奇数步同理交错
两个时间步的综合效果等价于半格 BB 的反射,无需显式修正。壁面节点 (y=0) 经 `apply_boundary_esopull``bounce_back_swap` 处理,不会积累垃圾。
### 结论: [无法确认: 需运行验证] Force 提取与符号约定 — curved_boundary.cuh:90-97 + obs.cuh + manager.py:328-338
**理由**: 链路追踪如下:
1. `curved_boundary.cuh:91-97`: `fx = c_x * (f_toward + f_reflected)` 累加到 `obs[obs_force_index(body_id, 0)]`
2. `obs.cuh:11`: `obs_force_index(id_obj, d) = OBS_FORCE0_FLOATS + id_obj * DIM + d`(起点=0
3. `manager.py:read_force()`: 返回 `self.obs_pinned[i0:i0 + d]`,无符号反转
- 存储的是流体动量交换量 (fluid momentum exchange),不是物体受力的直接值(牛顿第三定律要求 `F_body = -F_fluid`)。如果外部 Cd/Cl 归一化时把 obs 直读值当做物体受力,符号会反转。需用已知算例(如静止圆柱的阻力系数)确认当前实践是否在外部做了隐含的取反。
---
## 3. 架构缺陷
### 结论: [未改] Curved boundary 仍假设圆形/球形 — curved_boundary.cuh + body/objects.py:110-291
**理由**: `Cylinder.get_curved_list()` 对每个 cut link 调用 `find_circle_intersection` / `find_sphere_ray_segment`,完全依赖于圆/球几何参数(center + radius)。没有通用多边形/三角网格接口。旧审计标记的 [待重构] 未处理。
### 结论: [未改] 3D 旋转为 z 轴占位 — aux_kernels.cu:58-63
**理由**: `CurvedBoundaryKernel` 的 D3Q19 分支中 `Ww = 0.0f`,且 `Uw = -omega * ry; Vw = omega * rx` 只包含 xy 平面转动。任何具有 z 分量的刚体旋转都不会产生正确的壁面速度。
### 结论: [已备注] Bouzidi-TRT 不相容注释 — curved_boundary.cuh:17-21
**理由**: 注释明确说明 "plain linear Bouzidi interpolation ... is not a TRT-parametrized curved-wall family",位置精准且语意清晰。
---
## 4. 新发现
### 4a. `_no_force` 碰撞变体为死代码 — collision_srt.cuh:25, collision_trt.cuh:57, collision_mrt.cuh:95
**问题**: `collide_srt_no_force``collide_trt_no_force``collide_mrt_no_force` 三个函数在编译后的任何内核路径中均不被调用。`collide_dispatch` (helpers.cuh:15-69) 始终通过带 `Fin` 数组的普通变体执行碰撞,当外力为零时通过 `zero_forcing(Fin)``Fin` 全清零。死代码约 40 行。
**影响**: 无功能影响,增加维护成本。`zero_forcing` 仅用于栈初始化,并非无用调用。
### 4b. BC_MOVING / BC_PERIODIC 有定义无处理 — flags.cuh + 全内核搜
**问题**: `FLAG_BC_MOVING (0x0060)``FLAG_BC_PERIODIC (0x0050)``flags.cuh` 有定义,Python 侧 `descriptors.py` 也有对应常量。但 CUDA 内核中没有针对 `is_moving()``is_periodic()` 的分支处理:
- `one_step_double.cu:apply_boundary_pull` 只有 `is_curved/is_inlet/is_outlet/BBS` 分支
- 任何单元格若被标记 `BC_MOVING`(非 curved 路径)将落入 `bounce_back_swap()` 分支,得到 std half-way BB 而非移动壁面速度修正
**影响**: 只有当 obstacle 使用 `BC_CURVED` 配合 `cl_body_id` 进入 curved boundary kernel 才能获得正确移动壁面速度。`BC_MOVING` 直接标记在通道壁面或其他固体节点上无效果。
### 4c. Outlet NEQ 的 `OUTLET_MODE` 嵌套逻辑可读性隐患 — pressure_neq.cuh:25-62
**问题**:
```
#if OUTLET_MODE == 1 → 纯拷贝未知方向
#else → 普通路径
#if COLLISION_MODEL==0||1 → 全分布 NEQ(含 TRT
#elif OUTLET_MODE == 2 → 混合模式
#else → 少量未知方向重构(默认 OUTLET_MODE=0 路径)
#endif
#endif
```
`OUTLET_MODE=0``COLLISION_MODEL=2`MRT)时,代码进入最内层 `#else` 分支(少量未知方向重构),而非全分布 NEQ。这与注释宣称的"SRT 和 TRT 使用全分布 NEQ"一致(MRT 未承诺),但 MRT outlet 路径与 SRT/TRT 行为不同,可能导致 MRT 结果系统性偏差。
**影响**: MRT outlet 行为与 SRT/TRT 不一致,应至少加注释说明此差异,或行为对齐。
### 4d. `collide_inlet_ghost` 对 `y=0/NY-1` 的过滤 — one_step_double.cu:102-103, one_step_esopull.cu:106-107
**问题**: `collide_inlet_ghost = is_inlet(fl) && interior_y && inlet_scheme_uses_post_collision_ghost()`,其中 `interior_y = (y>0 && y<NY-1)``y=0``y=NY-1` 的 inlet 节点不会触发 ghost 碰撞。但 `inlet_scheme_uses_post_collision_ghost()` 只在 `INLET_SCHEME==0`Zou-He)时返回 true,而 x=0 inlet 节点原本处于 y=0 或 y=NY-1 时,已被 `build_channel_flags` 覆盖为 `SOLID|BC_WALL`(角点优先),因此这些节点本身不是 inlet,过滤是安全的。
**影响**: 当前无实际影响(角点 wall 覆盖 inlet),但代码隐含的逻辑依赖比较脆弱。如果未来修改建标记序,可能在 y=0/ymax inlet 节点产生未碰撞 ghost 状态。
### 4e. 传感器归一化在 manager 层正确实现 — aux_kernels.cu:67-99 + manager.py:348-365
**结论**: `SensorKernel` 做逐格点求和,`ObjectManager.read_sensor(normalize=True)` 除以 `sensor_cell_counts[body_id]`。已实现,正确。
### 4f. `inlet_target_u` 对 y=0 和 y=NY-1 的保护 — inlet/common.cuh:34
**影响**: `y_clamped = fminf(NY-2, fmaxf(1.0, y))` 使 y=0 节点获得 y=1 的 inlet 速度。对正确定义的 `SOLID|BC_WALL` 角点无实际影响,属于保护性逻辑。
### 4g. `compute_omega_minus` 在 ω⁺ = 2.0 时分母为零 — collision_trt.cuh:24-26
**问题**: `1.0f / omega_plus - 0.5f``omega_plus = 2.0` 时为 `0.5 - 0.5 = 0`,导致除零。实际路径中 `omega_col``collide_dispatch` 中被钳位(helpers.cuh:56),假设 `OMEGA_COLLISION_MAX < 2.0` 则为安全。
**影响**: 低风险(依赖外部宏正确配置)。
### 4h. `west_velocity_rho_closure` 在 u_target ≥ 1.0 时除零 — inlet/common.cuh:47-48
**问题**: `rho = sum(...) / (1.0f - ux_target)`,当 `ux_target >= 1.0` 时除数为 0 或负数。物理上入口马赫数小于 1 可避免,但无运行时保护。
**影响**: 低风险(物理约束),但崩溃行为不如显式断言清晰。
---
## 总结
| 类别 | 数量 | 关键项 |
|------|------|--------|
| 旧修正确认 | 7/7 | 全部确认正确 |
| 待验复查 | 3 | 2 确认, 1 需运行时验证 |
| 架构缺陷 | 3 | 圆形硬编码、3D占位、TRT注释齐全 |
| 新发现 | 8 | 4a死代码、4b未处理BC、4c MRT outlet差异、4d脆弱逻辑依赖、4e已实现、4f无害保护、4g除零边界、4h无保护输入 |
**最高优先级**: 4b (`BC_MOVING` 无处理路径)、4c (MRT outlet 路径与 SRT/TRT 不一致)、force 符号约定需运行验证。
@@ -37,18 +37,9 @@
"import pycuda.driver as cuda\n",
"\n",
"from CelerisLab import Simulation\n",
"from CelerisLab.common.streakline import (\n",
" FlowFrame,\n",
" ReleaseConfig,\n",
" IntegratorConfig,\n",
" build_release_points,\n",
" advance_particles_between_frames,\n",
" cylinders_from_triangle_layout,\n",
" configure_compute_threads,\n",
" compute_vorticity,\n",
" render_streakline_density,\n",
" render_vorticity_field,\n",
")\n",
"from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig\n",
"from CelerisLab.common.render import compute_vorticity, render_vorticity_field\n",
"from CelerisLab.common.preprocess import cylinders_from_triangle_layout\n",
"\n",
"INLET_U_PHYS_M_S = 0.009028\n",
"CYLINDER_DIAMETER_M = 0.010\n",
@@ -1,34 +1,28 @@
# CelerisLab/tests/run_exp_ctrl_matrix_streakline.py
"""Streakline (final-step particle cloud) for exp_ctrl_matrix cases.
# CelerisLab/tests/postproc/run_exp_ctrl_matrix_streakline.py
"""Streakline post-processing for exp_ctrl_matrix cases.
Runs full CFD to FIXED_STEPS, injects particles only in the last STREAK_WINDOW steps,
renders streakline at the final step (not path history).
Runs full CFD, uses Streakline.observe() in the last STREAK_WINDOW steps,
renders streakline at the final step.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import numpy as np
_REPO = Path(__file__).resolve().parents[1]
_REPO = Path(__file__).resolve().parents[2]
import sys
sys.path.insert(0, str(_REPO / "src"))
sys.path.insert(0, str(_REPO / "tests"))
sys.path.insert(0, str(_REPO / "tests" / "postproc"))
import run_exp_ctrl_matrix_vorticity as vort
from CelerisLab import Simulation
from CelerisLab.common.streakline import (
FlowFrame,
IntegratorConfig,
ReleaseConfig,
advance_particles_between_frames,
build_release_points,
cylinders_from_triangle_layout,
render_streakline_density,
)
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
DIAMETER_CELLS = vort.DIAMETER_CELLS
DEFAULT_OUT = _REPO / "tests" / "output" / "exp_ctrl_matrix_streak_ny300"
@@ -46,7 +40,9 @@ def build_release_points_for_triangle(layout: dict) -> np.ndarray:
return np.column_stack([np.full(4, release_x, dtype=np.float64), ys])
def _apply_body_actions(sim: Simulation, a1: float, a2: float, a3: float, u_lb: float) -> None:
def _apply_body_actions(
sim: Simulation, a1: float, a2: float, a3: float, u_lb: float
) -> None:
w1 = vort._action_to_omega_lb(a1, u_lb)
w2 = vort._action_to_omega_lb(a2, u_lb)
w3 = vort._action_to_omega_lb(a3, u_lb)
@@ -56,6 +52,15 @@ def _apply_body_actions(sim: Simulation, a1: float, a2: float, a3: float, u_lb:
vort._set_body_omegas(sim, w1, w2, w3)
def _cylinders_from_triangle_layout(layout: dict) -> list[tuple[tuple[float, float], float]]:
radius = float(layout["radius_lb"])
return [
((float(layout["x_apex"]), float(layout["y_center"])), radius),
((float(layout["x_rear"]), float(layout["y_lower"])), radius),
((float(layout["x_rear"]), float(layout["y_upper"])), radius),
]
def run_streak_case(
case_id: str,
slug: str,
@@ -73,8 +78,11 @@ def run_streak_case(
layout = vort._add_triangle_cylinders(sim)
sim.initialize()
u_lb = float(sim.lbm_cfg.velocity)
dt_phys = (vort.CYLINDER_DIAMETER_M / DIAMETER_CELLS) * (u_lb / vort.INLET_U_PHYS_M_S)
cylinders = cylinders_from_triangle_layout(layout)
dt_phys = (
(vort.CYLINDER_DIAMETER_M / DIAMETER_CELLS)
* (u_lb / vort.INLET_U_PHYS_M_S)
)
cylinders = _cylinders_from_triangle_layout(layout)
release_cfg = ReleaseConfig(
mode="strip",
@@ -84,13 +92,19 @@ def run_streak_case(
downstream_spacing=1.0,
inject_per_seed=1,
)
integrator_cfg = IntegratorConfig(alpha_t=0.25, alpha_x=0.40, max_particle_age=None)
integrator_cfg = IntegratorConfig(
alpha_t=0.25, alpha_x=0.40, max_particle_age=None
)
base_release = build_release_points_for_triangle(layout)
release_points = build_release_points(base_release, release_cfg)
particles = np.empty((0, 2), dtype=np.float64)
ages = np.empty((0,), dtype=np.float64)
prev_frame: FlowFrame | None = None
streak = Streakline(
release_points=base_release,
release_cfg=release_cfg,
integrator_cfg=integrator_cfg,
nx=int(sim.lbm_cfg.nx),
ny=int(sim.lbm_cfg.ny),
cylinders=cylinders,
)
print(
f"--- {case_id} {slug} steps={total_steps} streak_window={streak_window} "
@@ -103,57 +117,29 @@ def run_streak_case(
_apply_body_actions(sim, a1, a2, a3, u_lb)
sim.run(1)
# Feed streakline within the window
if step >= streak_start and (step + 1) % sample_every == 0:
macro = sim.get_macroscopic()
curr_frame = FlowFrame(
step=int(step + 1),
ux=np.asarray(macro["ux"], dtype=np.float64),
uy=np.asarray(macro["uy"], dtype=np.float64),
)
new_particles = np.repeat(release_points, release_cfg.inject_per_seed, axis=0)
if particles.size:
particles = np.vstack([particles, new_particles])
ages = np.concatenate([ages, np.zeros(new_particles.shape[0], dtype=np.float64)])
else:
particles = new_particles.copy()
ages = np.zeros(new_particles.shape[0], dtype=np.float64)
if prev_frame is not None and particles.size:
particles, ages, _, _ = advance_particles_between_frames(
particles,
ages,
prev_frame,
curr_frame,
nx=int(sim.lbm_cfg.nx),
ny=int(sim.lbm_cfg.ny),
cfg=integrator_cfg,
cylinders=cylinders,
)
prev_frame = curr_frame
streak.observe(ux=macro["ux"], uy=macro["uy"], step=int(step + 1))
if report_every > 0 and (step + 1) % report_every == 0:
print(
f" step {step+1}/{total_steps} a=({a1:+.5f},{a2:+.5f},{a3:+.5f}) "
f"particles={particles.shape[0]}"
f"particles={streak.n_particles}"
)
if particles.size == 0:
raise RuntimeError(f"{case_id}: no particles in streak window; lower sample_every.")
if streak.n_particles == 0:
raise RuntimeError(
f"{case_id}: no particles in streak window; lower sample_every."
)
png = out_dir / f"streakline_{case_id}_{slug}.png"
render_info = render_streakline_density(
particles,
ages,
nx=int(sim.lbm_cfg.nx),
ny=int(sim.lbm_cfg.ny),
out_path=str(png),
cylinders=cylinders,
render_info = streak.render(
str(png),
age_decay_steps=STREAK_AGE_DECAY,
blur_sigma=STREAK_BLUR_SIGMA,
minimal_axes=True,
background_color=(1.0, 1.0, 1.0),
streak_color=(1.0, 0.0, 0.0),
show_release_points=False,
)
sim.close()
@@ -164,15 +150,15 @@ def run_streak_case(
"streak_window_steps": int(streak_window),
"streak_start_step": int(streak_start),
"sample_every": int(sample_every),
"particle_count_final": int(particles.shape[0]),
"release_points_dense": int(release_points.shape[0]),
"particle_count_final": int(streak.n_particles),
"release_points_dense": int(base_release.shape[0]),
"streak_png": str(png),
"swap_action23_bodies": bool(vort.SWAP_ACTION23_BODIES),
"render": render_info,
}
with (out_dir / f"summary_{case_id}_{slug}.json").open("w", encoding="utf-8") as f:
json.dump(summary, f, indent=2)
print(f" saved {png} particles={particles.shape[0]}")
print(f" saved {png} particles={streak.n_particles}")
return summary
@@ -188,7 +174,11 @@ def main() -> int:
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
selected = {c.strip() for c in args.cases.split(",") if c.strip()} if args.cases else None
selected = (
{c.strip() for c in args.cases.split(",") if c.strip()}
if args.cases
else None
)
with vort.CONFIG_PATH.open("r", encoding="utf-8") as f:
grid = json.load(f)["grid"]
@@ -1,7 +1,18 @@
# CelerisLab/tests/run_exp_ctrl_matrix_vorticity.py
# CelerisLab/tests/postproc/run_exp_ctrl_matrix_vorticity.py
"""Batch vorticity images for three-cylinder control matrix (exp_ctrl_matrix.md).
Runs each control law to steady-like state, saves final vorticity PNG only (no streaklines).
Usage::
# Single case
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py \\
--cases C0 --batch 10 --device-id 0
# All cases
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py \\
--batch 10 --device-id 0
# Full 100k steps, no batching (default)
conda run -n pycuda_3_10 python tests/postproc/run_exp_ctrl_matrix_vorticity.py
"""
from __future__ import annotations
@@ -16,17 +27,16 @@ from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import pycuda.driver as cuda
_REPO = Path(__file__).resolve().parents[1]
_REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO / "src"))
from CelerisLab import Simulation
from CelerisLab.common.streakline import (
from CelerisLab.common.render import (
compute_vorticity,
cylinders_from_triangle_layout,
render_vorticity_field,
)
from CelerisLab.common.preprocess import cylinders_from_triangle_layout
INLET_U_PHYS_M_S = 0.009028
CYLINDER_DIAMETER_M = 0.010
@@ -224,9 +234,12 @@ def _triangle_layout(cfg) -> dict:
def _add_triangle_cylinders(sim: Simulation) -> dict:
layout = _triangle_layout(sim.lbm_cfg)
sim.add_cylinder(center=(layout["x_apex"], layout["y_center"]), radius=layout["radius_lb"])
sim.add_cylinder(center=(layout["x_rear"], layout["y_lower"]), radius=layout["radius_lb"])
sim.add_cylinder(center=(layout["x_rear"], layout["y_upper"]), radius=layout["radius_lb"])
sim.add_body("circle", center=(layout["x_apex"], layout["y_center"]),
radius=layout["radius_lb"])
sim.add_body("circle", center=(layout["x_rear"], layout["y_lower"]),
radius=layout["radius_lb"])
sim.add_body("circle", center=(layout["x_rear"], layout["y_upper"]),
radius=layout["radius_lb"])
return layout
@@ -264,14 +277,10 @@ def _action_to_omega_lb(action_m_s: float, u_lb: float) -> float:
def _set_body_omegas(sim: Simulation, omega0: float, omega1: float, omega2: float) -> None:
bodies = sim.bodies
dim = sim.lbm_cfg.dim
slot = 3 * dim
bodies.action.fill(0.0)
bodies.action[(0 * slot) + slot - 1] = np.float32(omega0)
bodies.action[(1 * slot) + slot - 1] = np.float32(omega1)
bodies.action[(2 * slot) + slot - 1] = np.float32(omega2)
cuda.memcpy_htod(bodies.action_gpu, bodies.action)
"""Set all three body rotation speeds using new API (implicit GPU upload)."""
sim.set_body(0, omega=omega0)
sim.set_body(1, omega=omega1)
sim.set_body(2, omega=omega2)
def _default_steps(nx: int, u_lb: float, step_multiplier: float) -> int:
@@ -287,9 +296,11 @@ def run_case(
out_dir: Path,
steps: int,
report_every: int,
batch: int = 1,
device_id: int = 0,
) -> dict:
compat = _ensure_compat_config(CONFIG_PATH)
sim = Simulation(compat)
sim = Simulation(compat, device_id=device_id)
layout = _add_triangle_cylinders(sim)
sim.initialize()
u_lb = float(sim.lbm_cfg.velocity)
@@ -297,21 +308,43 @@ def run_case(
dt_phys = dx_phys * (u_lb / INLET_U_PHYS_M_S)
cylinders = cylinders_from_triangle_layout(layout)
print(f"--- {case_id} {slug} steps={steps} u_lb={u_lb} dt_phys={dt_phys} ---")
for i in range(steps):
t_phys = i * dt_phys
a1, a2, a3 = _actions_at_time(t_phys, features)
w1 = _action_to_omega_lb(a1, u_lb)
w2 = _action_to_omega_lb(a2, u_lb)
w3 = _action_to_omega_lb(a3, u_lb)
if SWAP_ACTION23_BODIES:
_set_body_omegas(sim, w1, w3, w2)
else:
_set_body_omegas(sim, w1, w2, w3)
sim.run(1)
if report_every > 0 and (i + 1) % report_every == 0:
print(f" step {i+1}/{steps} a=({a1:+.5f},{a2:+.5f},{a3:+.5f})")
print(f"--- {case_id} {slug} steps={steps} u_lb={u_lb} dt_phys={dt_phys} batch={batch} ---")
stream = sim.stream
batch_size = max(1, int(batch))
# Main loop: precompute actions, batch-step, read forces/sensors at intervals
for batch_start in range(0, steps, batch_size):
batch_end = min(batch_start + batch_size, steps)
for j in range(batch_start, batch_end):
t_phys = j * dt_phys
a1, a2, a3 = _actions_at_time(t_phys, features)
w1 = _action_to_omega_lb(a1, u_lb)
w2 = _action_to_omega_lb(a2, u_lb)
w3 = _action_to_omega_lb(a3, u_lb)
if SWAP_ACTION23_BODIES:
_set_body_omegas(sim, w1, w3, w2)
else:
_set_body_omegas(sim, w1, w2, w3)
n = batch_end - batch_start
sim.stepper.step(
n,
action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu,
stream=stream,
)
if report_every > 0 and (batch_end % report_every == 0 or batch_end == steps):
stream.synchronize()
for bid in range(sim.bodies.count):
fx = sim.bodies.read_force(bid)
print(
f" step={batch_end} body={bid}"
f" fx={float(fx[0]):+.6f} fy={float(fx[1]):+.6f}",
flush=True,
)
stream.synchronize()
macro = sim.get_macroscopic()
vort = compute_vorticity(macro["ux"], macro["uy"])
png = out_dir / f"vorticity_{case_id}_{slug}.png"
@@ -334,6 +367,7 @@ def run_case(
"case_id": case_id,
"slug": slug,
"steps": int(steps),
"batch": int(batch),
"u_lb": u_lb,
"dt_phys": dt_phys,
"vort_png": str(png),
@@ -356,22 +390,36 @@ def main() -> int:
"--step-multiplier",
type=float,
default=2.0,
help="Steps = multiplier * round(2*nx/(3*u_lb)); use 2.0 after halving nx/ny.",
help=(
"Steps = multiplier * round(2*nx/(3*u_lb)); "
"use 2.0 after halving nx/ny."
),
)
ap.add_argument(
"--steps",
type=int,
default=FIXED_STEPS,
help=f"Total LBM steps (default {FIXED_STEPS}, held fixed across grid tweaks).",
help=f"Total LBM steps (default {FIXED_STEPS}).",
)
ap.add_argument("--report-every", type=int, default=20000)
ap.add_argument("--cases", type=str, default="", help="Comma list e.g. C0,C1 or empty=all.")
ap.add_argument("--cases", type=str, default="",
help="Comma list e.g. C0,C1 or empty=all.")
ap.add_argument(
"--batch", type=int, default=1,
help=(
"Batch N steps between action uploads. Default 1 (each step). "
"With --batch 10, actions are computed and uploaded every 10 steps. "
"Saves kernel launch overhead at the cost of control-signal interpolation."
),
)
ap.add_argument("--device-id", type=int, default=0, help="GPU device id.")
args = ap.parse_args()
out_dir = Path(args.out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
selected = {c.strip() for c in args.cases.split(",") if c.strip()} if args.cases else None
selected = {c.strip() for c in args.cases.split(",") if c.strip()} \
if args.cases else None
summaries = []
with CONFIG_PATH.open("r", encoding="utf-8") as f:
@@ -380,10 +428,12 @@ def main() -> int:
ny = int(grid_cfg["ny"])
u_lb = 0.04
base_steps = int(round(2.0 * nx / (3.0 * u_lb)))
steps = int(args.steps) if int(args.steps) > 0 else _default_steps(nx, u_lb, args.step_multiplier)
steps = int(args.steps) if int(args.steps) > 0 \
else _default_steps(nx, u_lb, args.step_multiplier)
print(
f"Output: {out_dir} | grid={nx}x{ny} | base_steps={base_steps} "
f"x{args.step_multiplier} -> {steps} | vort [{VORT_VMIN}, {VORT_VMAX}]"
f"x{args.step_multiplier} -> {steps} | batch={args.batch} | "
f"device={args.device_id} | vort [{VORT_VMIN}, {VORT_VMAX}]"
)
for case_id, slug, features in CONTROL_CASES:
@@ -397,6 +447,8 @@ def main() -> int:
out_dir=out_dir,
steps=steps,
report_every=int(args.report_every),
batch=int(args.batch),
device_id=int(args.device_id),
)
)
@@ -405,6 +457,8 @@ def main() -> int:
"base_steps": base_steps,
"step_multiplier": float(args.step_multiplier),
"steps": steps,
"batch": int(args.batch),
"device_id": int(args.device_id),
"vort_vmin": VORT_VMIN,
"vort_vmax": VORT_VMAX,
"swap_action23_bodies": bool(SWAP_ACTION23_BODIES),
@@ -1,9 +1,9 @@
# CelerisLab/tests/run_kan99b_streakline.py
"""Kan99b streakline demo using CelerisLab common streakline engine.
# CelerisLab/tests/postproc/run_kan99b_streakline.py
"""Kan99b streakline demo using the new Streakline class (online mode only).
Modes:
- online: run CFD and compute streakline in memory (no velocity dump).
- offline: load velocity snapshots and reconstruct streakline.
Usage::
python tests/run_kan99b_streakline.py --domain M --re 100 --alpha 1.0
"""
from __future__ import annotations
@@ -13,23 +13,14 @@ import json
import os
import tempfile
from dataclasses import dataclass
from typing import List, Tuple
from typing import Tuple
import numpy as np
from CelerisLab import Simulation
from CelerisLab.common.streakline import (
FlowFrame,
IntegratorConfig,
ReleaseConfig,
build_release_points,
estimate_sampling_plan,
render_streakline_density,
run_streakline_offline,
run_streakline_online,
)
from CelerisLab.common.streakline import Streakline, ReleaseConfig, IntegratorConfig
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
U_INF = 0.03
@@ -92,9 +83,13 @@ def _build_cfg(base_cfg: dict, *, nx: int, ny: int, re: float, inlet_scheme: str
return cfg
def _build_simulation(*, domain: DomainSpec, re: float, alpha: float, inlet_scheme: str) -> Simulation:
def _build_simulation(
*, domain: DomainSpec, re: float, alpha: float, inlet_scheme: str
) -> Simulation:
base_cfg = _load_json(_DEFAULT_LBM)
cfg = _build_cfg(base_cfg, nx=domain.nx, ny=domain.ny, re=re, inlet_scheme=inlet_scheme)
cfg = _build_cfg(
base_cfg, nx=domain.nx, ny=domain.ny, re=re, inlet_scheme=inlet_scheme
)
body_doc = {
"objects": [
{
@@ -105,7 +100,7 @@ def _build_simulation(*, domain: DomainSpec, re: float, alpha: float, inlet_sche
}
]
}
tmpd = tempfile.mkdtemp(prefix="celeris_streakline_online_")
tmpd = tempfile.mkdtemp(prefix="celeris_streakline_")
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
body_tmp = os.path.join(tmpd, "config_body.json")
_write_json(lbm_tmp, cfg)
@@ -130,54 +125,46 @@ def _default_base_release_points(center: Tuple[float, float]) -> np.ndarray:
)
def _load_offline_frames(snapshot_dir: str) -> List[FlowFrame]:
files = sorted(
[
os.path.join(snapshot_dir, name)
for name in os.listdir(snapshot_dir)
if name.endswith(".npz") and name.startswith("vel_step")
]
# ---- Sampling plan helper (kept as a local utility, not part of the library) ----
def _estimate_sampling_plan(
*,
st_ref: float,
diameter: float,
u_ref: float,
snapshots_per_period: float = 24.0,
periods: int = 5,
) -> dict:
period_steps = float(diameter) / (float(st_ref) * float(u_ref))
save_every = int(
max(20, round(period_steps / snapshots_per_period / 10.0) * 10)
)
frames: List[FlowFrame] = []
for path in files:
data = np.load(path)
step = int(np.asarray(data["step"]).reshape(-1)[0])
frames.append(
FlowFrame(
step=step,
ux=np.asarray(data["ux"], dtype=np.float64),
uy=np.asarray(data["uy"], dtype=np.float64),
)
)
return frames
n_snapshots = int(max(20, round(float(periods) * float(snapshots_per_period))))
return {
"st_ref": float(st_ref),
"period_steps_est": float(period_steps),
"save_every_recommended": int(save_every),
"snapshot_count_recommended": int(n_snapshots),
}
def main() -> int:
ap = argparse.ArgumentParser(description="Kan99b streakline demo (online/offline)")
ap.add_argument("--mode", default="online", choices=("online", "offline"))
ap = argparse.ArgumentParser(description="Kan99b streakline demo")
ap.add_argument("--domain", default="M", choices=("S", "M", "L"))
ap.add_argument("--re", type=float, default=100.0)
ap.add_argument("--alpha", type=float, default=1.0)
ap.add_argument("--inlet-scheme", default="regularized", choices=("regularized", "zou_he_local"))
ap.add_argument("--inlet-scheme", default="regularized",
choices=("regularized", "zou_he_local"))
ap.add_argument("--start-step", type=int, default=60_000)
ap.add_argument("--sample-every", type=int, default=0, help="0 uses recommended value.")
ap.add_argument("--n-snapshots", type=int, default=0, help="0 uses recommended value.")
ap.add_argument("--snapshot-dir", type=str, default="", help="Required in offline mode.")
ap.add_argument("--release-mode", default="strip", choices=("point", "line", "strip"))
ap.add_argument("--sample-every", type=int, default=0,
help="0 uses recommended value.")
ap.add_argument("--n-snapshots", type=int, default=0,
help="0 uses recommended value.")
ap.add_argument("--release-mode", default="strip",
choices=("point", "line", "strip"))
ap.add_argument("--line-span", type=float, default=0.0)
ap.add_argument("--line-count", type=int, default=1)
ap.add_argument(
"--downstream-count",
type=int,
default=5,
help="Particles per seed along +x (denser streak).",
)
ap.add_argument(
"--downstream-spacing",
type=float,
default=1.0,
help="Lattice spacing between x-staggered release points.",
)
ap.add_argument("--downstream-count", type=int, default=5)
ap.add_argument("--downstream-spacing", type=float, default=1.0)
ap.add_argument("--inject-per-seed", type=int, default=2)
ap.add_argument("--alpha-t", type=float, default=0.2)
ap.add_argument("--alpha-x", type=float, default=0.4)
@@ -185,7 +172,9 @@ def main() -> int:
ap.add_argument(
"--out-dir",
type=str,
default=os.path.join(_REPO, "tests", "output", "streakline", "kan99b_k2_online"),
default=os.path.join(
_REPO, "tests", "output", "streakline", "kan99b_k2"
),
)
args = ap.parse_args()
@@ -193,9 +182,19 @@ def main() -> int:
out_dir = os.path.abspath(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
plan = estimate_sampling_plan(st_ref=KAN99B_ST_REF, diameter=D_LATTICE, u_ref=U_INF)
sample_every = int(args.sample_every) if int(args.sample_every) > 0 else int(plan["save_every_recommended"])
n_snapshots = int(args.n_snapshots) if int(args.n_snapshots) > 0 else int(plan["snapshot_count_recommended"])
plan = _estimate_sampling_plan(
st_ref=KAN99B_ST_REF, diameter=D_LATTICE, u_ref=U_INF
)
sample_every = (
int(args.sample_every)
if int(args.sample_every) > 0
else int(plan["save_every_recommended"])
)
n_snapshots = (
int(args.n_snapshots)
if int(args.n_snapshots) > 0
else int(plan["snapshot_count_recommended"])
)
release_cfg = ReleaseConfig(
mode=args.release_mode,
@@ -210,63 +209,52 @@ def main() -> int:
alpha_x=float(args.alpha_x),
diffusion_coeff=float(args.diffusion_coeff),
)
base_release_points = _default_base_release_points(domain.center)
dense_release_points = build_release_points(base_release_points, release_cfg)
base_release = _default_base_release_points(domain.center)
if args.mode == "online":
sim = _build_simulation(
domain=domain,
re=float(args.re),
alpha=float(args.alpha),
inlet_scheme=args.inlet_scheme,
)
try:
particles, ages, diag = run_streakline_online(
sim,
start_step=int(args.start_step),
sample_every=int(sample_every),
n_samples=int(n_snapshots),
release_points=base_release_points,
release_cfg=release_cfg,
integrator_cfg=integrator_cfg,
solid_center=domain.center,
solid_radius=R_LATTICE,
)
finally:
sim.close()
frame_count = int(n_snapshots)
frame_source = "in-memory online sampling"
else:
if not args.snapshot_dir:
raise ValueError("--snapshot-dir is required in offline mode.")
frames = _load_offline_frames(os.path.abspath(args.snapshot_dir))
if len(frames) < 2:
raise RuntimeError("Offline mode needs at least two velocity snapshots.")
particles, ages, diag = run_streakline_offline(
frames,
nx=domain.nx,
ny=domain.ny,
release_points=base_release_points,
release_cfg=release_cfg,
integrator_cfg=integrator_cfg,
solid_center=domain.center,
solid_radius=R_LATTICE,
)
frame_count = len(frames)
frame_source = os.path.abspath(args.snapshot_dir)
render_info = render_streakline_density(
particles,
ages,
streak = Streakline(
release_points=base_release,
release_cfg=release_cfg,
integrator_cfg=integrator_cfg,
nx=domain.nx,
ny=domain.ny,
out_path=os.path.join(out_dir, "streakline.png"),
release_points=dense_release_points,
solid_center=domain.center,
solid_radius=R_LATTICE,
cylinders=[(domain.center, R_LATTICE)],
)
sim = _build_simulation(
domain=domain,
re=float(args.re),
alpha=float(args.alpha),
inlet_scheme=args.inlet_scheme,
)
# Burn-in phase: step the simulation but don't feed streakline
print(f"Burning-in {args.start_step} steps ...")
sim.run(int(args.start_step))
# Sampling phase: step and feed velocity frames to streakline
target_last = int(args.start_step) + sample_every * (n_snapshots - 1)
frames_collected = 0
print(
f"Sampling every {sample_every} steps for {n_snapshots} frames "
f"(up to step {target_last})..."
)
while int(sim.stepper.step_count) < target_last:
sim.step(1)
step = int(sim.stepper.step_count)
if (step - int(args.start_step)) % sample_every != 0:
continue
macro = sim.get_macroscopic()
streak.observe(ux=macro["ux"], uy=macro["uy"], step=step)
frames_collected += 1
if frames_collected >= n_snapshots:
break
sim.close()
render_info = streak.render(
os.path.join(out_dir, "streakline.png"),
age_decay_steps=integrator_cfg.age_decay_steps,
blur_sigma=1.2,
title=f"Kan99b streakline ({args.mode}, {args.release_mode})",
)
meta = {
@@ -277,30 +265,33 @@ def main() -> int:
"inlet_scheme": args.inlet_scheme,
"collision": "MRT",
},
"mode": args.mode,
"sampling_estimate": plan,
"sampling_used": {
"start_step": int(args.start_step),
"sample_every": int(sample_every),
"n_snapshots": int(n_snapshots),
"frame_source": frame_source,
"frames_used": int(frame_count),
"frames_collected": frames_collected,
},
"release": {
"config": vars(args),
"base_points": base_release_points.tolist(),
"dense_point_count": int(dense_release_points.shape[0]),
"dense_points_preview": dense_release_points[: min(12, dense_release_points.shape[0])].tolist(),
"config": {
"mode": args.release_mode,
"line_span": float(args.line_span),
"line_count": max(1, int(args.line_count)),
"downstream_count": max(1, int(args.downstream_count)),
"downstream_spacing": float(args.downstream_spacing),
"inject_per_seed": max(1, int(args.inject_per_seed)),
},
"base_points": base_release.tolist(),
},
"diagnostics": diag,
"diagnostics": {"n_particles_final": int(streak.n_particles)},
"render": render_info,
}
_write_json(os.path.join(out_dir, "streakline_meta.json"), meta)
print(f"Recommended sample_every: {plan['save_every_recommended']} steps")
print(f"Recommended snapshots: {plan['snapshot_count_recommended']}")
print(f"Mode: {args.mode} | frames used: {frame_count}")
print(f"Dense release points: {dense_release_points.shape[0]}")
print(f"Frames collected: {frames_collected}")
print(f"Final particles: {streak.n_particles}")
print(f"Output image: {render_info['image_path']}")
return 0

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

@@ -1,7 +1,7 @@
# CelerisLab/tests/run_kan99b_rotating_cylinder.py
# CelerisLab/tests/validation/run_kan99b_rotating_cylinder.py
"""Kan99b MRT-only rotating-cylinder validation runner.
This script follows ``tests/Kan99b_validation.md`` for the current round:
This script follows ``docs/validation_specs/Kan99b_validation.md`` for the current round:
- Primary matrix: K1-K5 with collision fixed to MRT.
- Primary inlet: regularized (uniform profile).
@@ -22,7 +22,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
import pycuda.driver as cuda
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
U_INF = 0.03
@@ -26,7 +26,7 @@ from typing import Any, Dict, List
import pycuda.driver as cuda
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
@@ -1,4 +1,4 @@
# CelerisLab/tests/run_sah04_st_matrix.py
# CelerisLab/tests/validation/run_sah04_st_matrix.py
"""Sah04 MRT-only Strouhal validation on S1-S4 anchors.
This runner implements the current validation contract in ``tests/Sah04_validation.md``:
@@ -28,7 +28,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
import pycuda.driver as cuda
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
_DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json")
_BASE_D = 30.0
+124
View File
@@ -0,0 +1,124 @@
# CelerisLab/tests/validation/test_sensor_accuracy.py
"""Sensor accuracy validation: compare sensor readings to direct flow field averages.
This script validates that the GPU sensor kernel accumulation matches a
CPU-side manual average of the macroscopic field over the same cell footprint.
Usage::
conda run -n pycuda_3_10 python tests/validation/test_sensor_accuracy.py
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
from pathlib import Path
import numpy as np
_REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_REPO / "src"))
from CelerisLab import Simulation
def test_sensor_accuracy() -> dict:
"""Run sensor accuracy validation with multiple sensor positions."""
cfg = json.loads(
(Path(_REPO) / "src" / "CelerisLab" / "configs" / "config_lbm.json").read_text()
)
cfg["grid"]["nx"] = 256
cfg["grid"]["ny"] = 128
cfg["grid"]["nz"] = 1
cfg["physics"]["viscosity"] = 0.009
cfg["physics"]["velocity"] = 0.03
cfg["method"]["collision"] = "MRT"
cfg["method"]["inlet"]["scheme"] = "regularized"
cfg["method"]["inlet"]["profile"] = "uniform"
cfg["method"]["y_wall_bc"] = "free_slip"
tmpd = tempfile.mkdtemp(prefix="sensor_test_")
lbm_path = os.path.join(tmpd, "config_lbm.json")
with open(lbm_path, "w") as f:
json.dump(cfg, f)
sim = Simulation(lbm_config_path=lbm_path)
sim.add_body("circle", center=(80, 64), radius=15)
positions = [(120, 50), (120, 64), (120, 78), (150, 64)]
sensor_ids = []
for cx, cy in positions:
sid = sim.add_body("sensor", center=(cx, cy), radius=10)
sensor_ids.append(sid)
sim.initialize()
print(f"Initialized: nx={cfg['grid']['nx']} ny={cfg['grid']['ny']} "
f"n_curved={sim.field.n_curved} n_sensor={sim.field.n_sensor}")
# Step to develop wake
for _ in range(50):
sim.run(20)
# Get macroscopic field after one more step (with sensor accumulation)
import pycuda.driver as cuda
stream = cuda.Stream()
sim.bodies.zero_sensor_segment_async(stream)
sim.stepper.step(1, action_gpu=sim.bodies.action_gpu,
obs_gpu=sim.bodies.obs_gpu, stream=stream)
stream.synchronize()
macro = sim.get_macroscopic()
ux = macro["ux"]
uy = macro["uy"]
results = {}
all_pass = True
for sid in sensor_ids:
cells_arr, _ = sim.bodies.get(sid).get_sensor_list(
sim.lbm_cfg.nx, sim.lbm_cfg.ny
)
cell_idx = np.asarray(cells_arr, dtype=np.int64)
ux_rav = ux.ravel().astype(np.float64)
uy_rav = uy.ravel().astype(np.float64)
sensor_ux_mean = float(np.mean(ux_rav[cell_idx]))
sensor_uy_mean = float(np.mean(uy_rav[cell_idx]))
sensor_reading = sim.read_sensor(sid)
sensor_reading_x = float(sensor_reading[0])
sensor_reading_y = float(sensor_reading[1])
diff_ux = abs(sensor_reading_x - sensor_ux_mean)
diff_uy = abs(sensor_reading_y - sensor_uy_mean)
passed = diff_ux < 1e-4 and diff_uy < 1e-4
if not passed:
all_pass = False
results[f"sensor_{sid}_pos{positions[i]}"] = {
"sensor_reading": [sensor_reading_x, sensor_reading_y],
"manual_average": [sensor_ux_mean, sensor_uy_mean],
"diff": [float(diff_ux), float(diff_uy)],
"n_cells": int(len(cells_arr)),
"pass": bool(passed),
}
status = "PASS" if passed else "FAIL"
print(
f" Sensor {sid} @ {positions[sid]}: "
f"reading=({sensor_reading_x:.8f},{sensor_reading_y:.8f}) "
f"manual=({sensor_ux_mean:.8f},{sensor_uy_mean:.8f}) "
f"diff=({diff_ux:.2e},{diff_uy:.2e}) "
f"cells={len(cells_arr)} [{status}]"
)
sim.close()
summary = {"all_pass": bool(all_pass), "results": results}
print(f"\nSensor accuracy: {'ALL PASS' if all_pass else 'SOME FAILED'}")
return summary
if __name__ == "__main__":
result = test_sensor_accuracy()
sys.exit(0 if result["all_pass"] else 1)
-130
View File
@@ -1,130 +0,0 @@
## 审计结论
当前代码的问题不是单点误差,而是沿着主链路分布。最重的问题集中在三处:curved Bouzidi 的施加时机不对,Bouzidi 分支公式与文献不一致,以及若干运行时接口与内核实现脱节。这几类问题叠加后,足以让同一 case 在 SRT、TRT、MRT 之间出现远大于正常数值差异的结果。
需要单独说明的是,部分未来接口属于有意预留,不应被视为 bug。本审计只把已经进入当前执行契约、但实现与语义不一致的部分列为问题。
按当前项目约束,以下两类设计不再单列为缺陷:
- 运行时接口为后续能力预留,但当前未完全接通,只要注释明确即可
- `obs` 采用跨多步累加,由调用者决定何时清零,这属于有意设计而非实现错误
## 状态说明
- `[已解决]` 已进入当前代码
- `[待重构]` 不是单点修补能彻底解决,适合下一阶段重构
- `[待验证]` 需要最小算例或单元测试确认
- `[保留说明]` 当前不修,但需要在代码或文档中明确限制
## 当前总览
### 已解决
- [已解决] `lbm/__init__.py` 导出错误
- [已解决] forcing 主链路未接通,以及 SRT TRT MRT forcing 预因子不一致
- [已解决] TRT outlet NEQ 重构未补齐
- [已解决] `add_vortex()` 把动量当速度
- [已解决] Sensor 面积归一化缺失,已在 `ObjectManager` 层提供
- [已解决] `sync_to_gpu()` 末尾重置非流体节点的架构错误
- [已解决] curved donor 合法性未检查真实 domain flags
- [已解决] curved Bouzidi 时序错误
- [已解决] `q >= 0.5` 分支读错时间层
- [已解决] moving wall 修正未按 q 分支实现
- [已解决] 初始化链路与 object flag 叠加关系错误
- [已解决] `config_body.json` 未进入实际初始化链路
- [已解决] inlet `U0` 语义已补注释
### 仍需重构或继续处理
- [待重构] `body` 模块整体职责边界仍不清晰,几何描述、flag overlay、compact list 生成、动作状态、观测打包仍然耦合过重
- [待重构] curved boundary 当前仍是“圆形简单几何特化 + Bouzidi kernel 特化”架构,不适合继续扩到离散几何与通用移动刚体
- [待重构] 3D 刚体旋转契约仍是 z 轴占位实现
- [待重构] plain linear Bouzidi 与 TRT 不相容问题已注明限制,但没有方法级替代方案
- [待重构] `config.py` 与文档层对预留能力、当前能力、限制条件的边界还不够清楚
- [待验证] MRT 路径仍需最小算例单独核对
- [待验证] Esopull 邻壁处理仍需与 double-buffer 做一致性复核
- [待验证] force 提取与 host 侧系数归一化仍需放到同一处核对
## 历史问题清单
### 主执行链路
| 文件 | 问题 | 影响 |
|---|---|---|
| `lbm/__init__.py` | [已解决] 导入了并不存在的 `add_lamb_oseen``add_taylor_green`。实际 `initializers.py` 只提供 `add_vortex`。 | `CelerisLab.lbm` 包导入会退化到空 `__all__`,对外 API 与代码不一致。 |
| `field.py``config.py``inlet_outlet.cuh``init_flow.cu` | [已解决] `update_runtime_params()` 暴露了 `u_inlet``rho_ref` 一类接口,但入口、出口、初始化全部仍然读编译期宏 `U0``RHO`。如果这些接口继续保留在活跃 API 中,就会形成假功能。 | 运行时接口语义与真实执行路径不一致。 |
| `helpers.cuh``forcing_guo.cuh` | [已解决] `collide_dispatch()` 每步都调用 `zero_forcing(Fin)`,没有任何地方根据 `d_params.fx fy fz` 生成 Guo forcing,也没有调用速度半步修正。 | 运行时体力项接口是死路径。任何依赖体力驱动的算例都会静默失效。 |
| `collision_trt.cuh``collision_mrt.cuh` | [已解决] 在 forcing 真的接通之前,TRT 与 MRT 版本目前都是直接加 `Fin[i]`SRT 则乘了 `(1-omega/2)`。 | forcing 一旦接通,三种碰撞模型会立刻表现出不一致的体力离散。 |
### Curved boundary 与几何预处理
| 文件 | 问题 | 影响 |
|---|---|---|
| `one_step_double.cu``aux_kernels.cu``curved_boundary.cuh` | [已解决] Curved Bouzidi 是在 `OneStep` 完成 collision 与 store 之后,通过 `CurvedBoundaryKernel` 二次修补。 | 紧邻曲壁的 fluid 节点在本步 collision 时已经使用了从 solid 方向拉来的错误分布。后修补只能影响下一步,不能修正本步碰撞污染。 |
| `curved_boundary.cuh` | [已解决] `q >= 0.5` 分支使用 `fi_in[k_f, dir_opp]`,也就是上一步缓冲区,而不是同一步、碰撞后传播前的分布函数。Bouzidi 线性插值两支公式都要求同一时间层的 post-collision 数据 [Bou01]。 | 该分支与文献公式不一致,会直接破坏 curved wall 的局部反射关系。 |
| `curved_boundary.cuh` | [已解决] moving wall 修正统一写成 `+ 6 w_i (c_i · u_w)`,没有区分 `q < 0.5``q >= 0.5` 两支,也没有体现 Bouzidi moving boundary 中的分支依赖系数 [Bou01]。 | 对旋转圆柱或移动物体,壁面速度修正的量级与形式都不对。 |
| `body/objects.py` | [已解决] `Cylinder.get_curved_list()` 的 donor 合法性只检查了“是否越界”和“是否落在圆柱内部”,没有检查 donor 是否落在上壁、下壁、入口、出口等非流体节点。 | `fallback_class` 的主机侧担保并不成立。靠近通道壁或边界时,`q < 0.5` 分支可能继续使用非法 donor。 |
| `aux_kernels.cu` | [待重构] 3D curved body 的运行时角速度契约只读取一个标量 `omega`,并硬编码成 z 轴转动,代码里也明确写了 placeholder。 | 3D 旋转物体路径并未真正完成,但接口没有把这种限制暴露出来。 |
### 初始化、观测与工具函数
| 文件 | 问题 | 影响 |
|---|---|---|
| `initializers.py` | [已解决] `add_vortex()``ux_old``uy_old` 用的是动量和,没有除以 `rho_old`。 | 任何基于该函数构造的初值都会把速度场放大为密度加权动量场。 |
| `step/aux_kernels.cu``body/manager.py` | [已解决] `SensorKernel` 对传感器区域做的是逐格点求和,`ObjectManager` 也没有按面积归一化。 | 如果外部把传感器输出当成平均速度或平均观测量,结果会系统偏大并随探针面积变化。 |
| `body/manager.py` | [已解决] `sync_to_gpu()` 最后调用 `_rest_nonfluid()`,会把所有非流体节点都重置成静止平衡态,而不仅是物体节点。 | 只要场中有物体,就会额外改写入口、出口、壁面节点的初始化状态。 |
### 文档与配置层
| 文件 | 问题 | 影响 |
|---|---|---|
| `configs/CONFIG` | 文档写 `nx` 必须整除 `threads_per_block`,但实际 launch 使用的是 ceiling division。 | 配置说明与执行实现不一致。 |
| `configs/CONFIG` | [已解决] 文档写 `neq_extrap` 下 SRT 与 TRT 都使用全分布 damped NEQ 重构,但代码里只有 SRT 走全分布分支,TRT 仍是少量未知方向重构。 | 算法说明与真实边界实现不一致。 |
| `README``config.py` | 文档把 `FP16C` 当成可选存储精度,但 `LBMConfig.validate()` 会直接拒绝 `FP16C`。 | 对外能力声明与当前运行时不一致。 |
## 高风险问题
### 边界方法与碰撞模型耦合
| 文件 | 问题 | 影响 |
|---|---|---|
| `curved_boundary.cuh``collision_trt.cuh` | [保留说明] 当前实现采用 plain linear Bouzidi 与 TRT 直接拼接。TRT 的黏性无关参数化只在边界离散满足相应条件时才成立,普通线性插值边界并不会自动保留该性质 [Gin08b]。 | 即使把显式 bug 全部修掉,TRT 与 SRT、MRT 的结果仍可能因边界离散而系统分叉。 |
| `curved_boundary.cuh` | 当前 curved wall 没有任何质量守恒修正。对于高 Re、长时间、周期性 curved flow,文献已经报告 plain Bouzidi 会出现质量泄漏与力漂移 [San18]。 | 长时间拖算时,平均密度、阻力与升力可能持续漂移。 |
| `collision_mrt.cuh` | D2Q9 MRT 为配合新方向排序手工重写了整套 moment transform 与 inverse transform,但代码里没有任何一致性校验或自测痕迹。 | 一旦某个符号、系数或方向映射有误,结果会直接体现在升阻力与稳定性上,而且不容易从表面现象定位。 |
| `inlet_outlet.cuh``config.py` | 抛物入口把 `U0` 解释成截面平均速度,峰值自动变成 `1.5 * U0`。接口名字仍然叫 `velocity`,没有区分平均速度与最大速度。 | 只要用户按最大入口速度设参数,实际 Re 就会整体偏移。 |
| `init_flow.cu` | 四个角点优先按 inlet 或 outlet 分类,而不是 wall。随后边界核又把非 `interior_y` 的角点落回 `bounce_back_swap()`。 | 角点的标记语义与实际处理语义不一致,容易在后续扩展中埋下隐患。 |
## 待验证问题
### 需要单元测试或最小算例复核的项
| 文件 | 问题 | 影响 |
|---|---|---|
| `one_step_esopull.cu` | Esopull 路径没有像 double-buffer 那样对 `y == 1``y == NY-2` 的流体邻壁行做显式半格 bounce-back 修正。 | bounded channel 在 esopull 与 double-buffer 之间可能存在额外差异,需要最小通道算例核对。 |
| `collision_mrt.cuh` | [待验证] D2Q9 MRT 的新配对顺序、moment basis、inverse transform 是否与 `compute_rho_u()``compute_feq()` 完全一致,目前只能靠数值表现间接推断。 | 这条路径可能包含隐藏的符号错位,需要用均匀流、Poiseuille、衰减涡等简单算例单独验算。 |
| `inlet_outlet.cuh` | SRT 出口在 `neq_extrap` 下重构了全体分布,不仅是未知方向。 | 该设计是否有利于稳定性、是否额外改变质量守恒,需要独立做 outlet 对比测试。 |
| `curved_boundary.cuh``aux_kernels.cu` | [待验证] 当前力提取采用 `f_toward + f_reflected` 的链路级动量交换,但没有与 host 侧的阻力系数归一化和符号约定放在同一处核对。 | 即使流场正确,力的符号、量级和平均方式也仍可能在后处理阶段出错。 |
## 本轮已修复
- `lbm/__init__.py` 已改为导出真实存在的 `add_vortex`
- 运行时参数接口已收紧到当前真实生效的 `omega` 与 forcing 相关项
- Guo forcing 已接入主碰撞分发链路,SRT、TRT、MRT 的 forcing 预因子已统一
- TRT 的 outlet NEQ 重构已补齐为与 SRT 一致的全分布 damped NEQ 路径
- `add_vortex()` 已修正为用速度而不是动量直接叠加初值
- 传感器读数已在 `ObjectManager` 层提供面积归一化接口
- 物体同步后的静止平衡重置已收缩为只作用于 obstacle interior,而不再覆盖所有非流体节点
- curved link donor 合法性检查已扩展到实际 domain flags,而不只检查是否在圆柱内部
- curved Bouzidi 已从“步后修补流体节点”改为“步前写入 obstacle source slot”,并改掉了 `q >= 0.5` 分支对前一时间层 opposite population 的读取
- curved moving-wall 修正已继续收紧为独立 helper,并把 `q < 0.5` 与 fallback 分支改回与 [Bou01] 一致的符号约定
- 初始化链路已改为“先构建 clean channel flags,再叠加 object mask,再由 init kernel 保持 obstacle flag 并写入静止平衡态”,移除了 `sync_to_gpu()` 末尾对 obstacle interior 的二次主机侧重置
- `Simulation` 已开始消费 `config_body.json` 中的简单物体定义,并在编译期同步 `N_OBJS` 契约
- inlet 抛物入口已补充注释,明确 `U0` 在当前实现中表示截面平均速度,峰值为 `1.5 * U0`
- curved boundary 文件已显式注释:plain linear Bouzidi 与 TRT 不具备 TRT-parametrized curved-wall 保证,当前实现保留该限制说明
## 交叉症状解释
当前现象和代码结构是吻合的。TRT 加 Bouzidi 直接发散,最符合 curved wall 时序错误与 `q >= 0.5` 分支读错时间层这两个问题叠加后的表现。SRT 与 MRT 加 Bouzidi 不发散但升阻力偏离,则更像是主链路虽然还能跑,但边界修正、观测累加、入口参数语义和 MRT 未校验实现共同把结果拖离了正常范围。
文献层面的两条提醒也和代码现状一致。第一,plain linear boundary 与 TRT 的参数化并不天然相容 [Gin08b]。第二,plain Bouzidi 在高 Re 周期 curved flow 中会出现质量泄漏与力漂移 [San18]。这两条不是当前代码里最先要解释的大偏差,但它们说明就算显式 bug 修完,边界策略本身仍然需要额外核验。