## Streakline postprocessing design 这份说明面向一个最小可实现的离线后处理程序。输入是按时间保存的二维速度场序列,输出是接近实验染色图的 streakline 图像。核心路线是连续释粒,因为 streakline 正是“固定位置持续释放染料后,在某个时刻所有已释放粒子的位置集合” [Lan96]。对非定常尾迹,这比单帧 streamline 更贴近实验图像 [Lan96]。 搜索和全文阅读给出的信息已经足够支撑第一版实现。最直接的方案是按保存时刻连续注入粒子,用时空插值后的速度场推进粒子,再将粒子云渲染为图像 [Lan96, Ken96]。如果纯粒子结果过于尖锐,再在推进后加入一个很小的随机扩散项,作为对真实染色液扩散的近似。更完整的被动标量法当然更物理,但实现成本更高,不适合作为第一版 [Kim04]。 ## Core recommendation | 方法 | 与实验染色图的对应 | 实现复杂度 | 适合作为第一版 | |---|---|---:|---| | 单帧 streamline | 弱 | 很低 | 不推荐 | | 连续释粒 streakline | 强 | 低 | 最推荐 | | streakline 加少量扩散 | 很强 | 低到中 | 推荐作为第二步 | | 被动标量对流扩散 | 最强 | 中到高 | 暂不作为第一版 | 最小可实现路线如下: - 从 CelerisLab 导出一串二维速度场快照 `u_x(x,y,t_k), u_y(x,y,t_k)` - 选定一个固定释放点或一小段释放线 - 在每个保存时刻都注入新粒子 - 在两个相邻快照之间,用时间插值和空间插值计算粒子速度 - 用二阶或四阶时间积分推进粒子 - 删去出域粒子与进入固体的粒子 - 将当前时刻存活粒子按位置和年龄渲染成图像 这个程序逻辑直接对应 [Lan96] 的 streakline 算法,插值与推进细节由 [Ken96] 和 [Dar96] 支撑。 ## Data contract 默认输入是规则 Cartesian 网格上的二维时间序列速度场。对当前项目,最自然的输入约定是: | 名称 | 含义 | |---|---| | `t_k` | 第 `k` 个保存时刻 | | `ux[k, j, i]` | 时刻 `t_k` 的 x 方向速度 | | `uy[k, j, i]` | 时刻 `t_k` 的 y 方向速度 | | `mask[j, i]` | 可选,固体与流体标记 | | `x_i, y_j` | 网格坐标 | 第一版最好满足下面三条: - 时间快照间隔固定或至少已知 - 网格坐标固定不动 - 固体几何位置已知,至少能判断粒子是否进入圆柱内部 如果输出来自 CelerisLab,本质上只需要把每个保存时刻的 `ux, uy` 和对应时间写出来即可。算法本身是通用的,并不依赖 LBM 本身。 ## Mathematical model for the base streakline ### Particle motion 粒子位置满足拉格朗日运动方程 [Lan96]: \[ \frac{d \boldsymbol{x}}{dt} = \boldsymbol{u}(\boldsymbol{x}, t) \] 其中 \[ \boldsymbol{x} = (x, y), \qquad \boldsymbol{u} = (u_x, u_y) \] 积分形式为 [Lan96]: \[ \boldsymbol{x}(t + \Delta t) = \boldsymbol{x}(t) + \int_t^{t+\Delta t} \boldsymbol{u}(\boldsymbol{x}(\tau), \tau) \, d\tau \] streakline 在观察时刻 \(t_n\) 的定义是:从固定释放位置 \(\boldsymbol{x}_s\) 在过去各时刻持续注入的粒子,在 \(t_n\) 时刻的全部位置集合 [Lan96]。 ### Time interpolation 速度场只在离散时刻存储,所以必须做时间插值。对位于 \(t_k \le t \le t_{k+1}\) 的任意子步,最简单的做法是线性时间插值 [Ken96]: \[ \delta = \frac{t - t_k}{t_{k+1} - t_k} \] \[ \boldsymbol{u}(\boldsymbol{x}, t) = (1 - \delta) \, \boldsymbol{u}_k(\boldsymbol{x}) + \delta \, \boldsymbol{u}_{k+1}(\boldsymbol{x}) \] 这里 \(\boldsymbol{u}_k(\boldsymbol{x})\) 和 \(\boldsymbol{u}_{k+1}(\boldsymbol{x})\) 仍需通过空间插值得到。 ### Spatial interpolation 在规则网格上,第一版直接用双线性插值即可。虽然 [Ken96] 讨论的是非结构网格上的点定位与线性插值,但其一般原则完全适用于规则网格: - 先定位粒子所在单元 - 再用单元顶点速度插值得到粒子点速度 [Ken96] 若粒子位于单元局部坐标 \((\xi, \eta) \in [0,1]^2\),四个角点速度为 \(\boldsymbol{u}_{00}, \boldsymbol{u}_{10}, \boldsymbol{u}_{01}, \boldsymbol{u}_{11}\),则 \[ \boldsymbol{u}(\xi, \eta) = (1-\xi)(1-\eta) \boldsymbol{u}_{00} + \xi (1-\eta) \boldsymbol{u}_{10} + (1-\xi)\eta \boldsymbol{u}_{01} + \xi\eta \boldsymbol{u}_{11} \] ## Time integration choice [Lan96] 和 [Ken96] 都直接使用四阶 Runge Kutta。对你的第一版程序,推荐两个选项: | 积分器 | 优点 | 缺点 | 建议 | |---|---|---|---| | RK2 | 简洁,容易调试 | 精度一般 | 最小原型可用 | | RK4 | 文献最一致,精度更稳 | 每步插值次数更多 | 默认推荐 | RK4 的更新式为 [Lan96, Ken96]: \[ \boldsymbol{x}_{n+1} = \boldsymbol{x}_n + \frac{1}{6}(\boldsymbol{a} + 2\boldsymbol{b} + 2\boldsymbol{c} + \boldsymbol{d}) \] 其中 \[ \boldsymbol{a} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n, t_n) \] \[ \boldsymbol{b} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \tfrac{1}{2}\boldsymbol{a}, t_n + \tfrac{1}{2}\Delta t) \] \[ \boldsymbol{c} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \tfrac{1}{2}\boldsymbol{b}, t_n + \tfrac{1}{2}\Delta t) \] \[ \boldsymbol{d} = \Delta t \, \boldsymbol{u}(\boldsymbol{x}_n + \boldsymbol{c}, t_n + \Delta t) \] [Ken96] 特别强调,RK4 的每一个子步都要重新做点定位与时空插值。这在规则网格上不难实现,只是意味着一次完整步进需要四次速度查询 [Ken96]。 ## Practical timestep guidance 第一版不必上自适应步长,但步长不能随意设大。搜索里最重要的精度提醒来自 [Dar96]: - 非定常粒子积分的误差常常由时间离散控制,而不是概念本身 - 合理步长必须不大于流动主要非定常时间尺度的同量级 [Dar96] - 若时间步过大,粒子轨迹会在拓扑上都出错,而不仅是位置略偏 [Dar96] 对当前项目,最简单的工程准则是: \[ \Delta t_{trace} \le \min \bigl(\alpha_t \, \Delta t_{save},\; \alpha_x \, \frac{\Delta x}{\max |u|} \bigr) \] 其中 \(\Delta t_{save}\) 是两个保存快照之间的时间间隔,\(\Delta x\) 是网格尺度。第一版可取: - \(\alpha_t = 0.1 \sim 0.25\) - \(\alpha_x = 0.25 \sim 0.5\) 这不是文献中的严格上界,而是结合 [Dar96] 的结论给出的实现准则:粒子推进子步既要显著小于保存间隔,也不要一子步跨过太多网格。 [Ken96] 还给了一个很实用的自适应思路:根据相邻速度方向夹角调节步长。如果速度方向变化太快就减半,变化很小就加倍 [Ken96]。这很适合后续增强版,但不属于第一版必需项。 ## Base streakline algorithm ### Release strategy 最符合染色实验的是连续释粒 [Lan96]。第一版可用两种释放方式: | 方式 | 适用场景 | 建议 | |---|---|---| | 单点释放 | 针头式染料入口 | 最简单 | | 短线段释放 | 更像细缝或细带注入 | 更稳健 | 如果实验是在圆柱上游某一点持续释放染色液,第一版就直接使用单点释放。 ### Particle state 每个粒子只需保存: | 字段 | 含义 | |---|---| | `x, y` | 当前坐标 | | `t_birth` | 释放时刻 | | `age` | 当前年龄 | | `alive` | 是否仍在域内 | 第一版不需要保存整条历史轨迹。因为你要的是当前时刻的 streakline 图,而不是每个粒子的 pathline 曲线。 **常见误区(务必区分)** | 概念 | 画什么 | 是否等于水洞染色迹线 | |---|---|---| | **Streakline(迹线)** | 固定源点**持续释粒**,在观察时刻 \(t_n\) 画出**所有仍存活粒子的当前位置**(可带年龄衰减权重) | 是 | | **Pathline(迹线/轨道)** | 单个粒子从释放到当前的**整条历史轨迹** | 否 | | **错误实现** | 每隔一段时间放一个粒子,再把**所有粒子走过的路径段**全部叠加到图上 | 否(这是 pathline 叠加,不是 streakline) | 水洞实验:针头在固定点连续注 dye → 某一时刻拍照 → 看到的是“此刻染料粒子在流场里的分布”,一条下游色带由**不同释放时刻、当前仍在本流场中的粒子**共同构成,而不是把每个粒子从出生到现在的轨迹都画出来。 ### Main loop pseudocode ```text given velocity snapshots U[k] = {ux[k], uy[k]} at times t[k] given seeding point or seeding segment S initialize empty particle list P for k = 0 to N-2: inject new particles at source S at time t[k] set t_local = t[k] while t_local < t[k+1]: dt = min(dt_trace, t[k+1] - t_local) for each particle p in P with p.alive: v = interpolate_velocity(p.position, t_local) advance p by one integration step using RK2 or RK4 update p.age if p leaves domain: p.alive = false if p enters solid body: p.alive = false t_local = t_local + dt optionally remove very old particles optionally render current particle cloud ``` 这个结构与 [Lan96] 的离散 streakline 算法一致,只是把“从 `t_k` 到 `t_{k+1}` 的一次推进”细化成多个更小的粒子子步,以满足 [Dar96] 对精度的要求。 ### Velocity query pseudocode ```text function interpolate_velocity(position x, time t): find k such that t[k] <= t <= t[k+1] compute delta = (t - t[k]) / (t[k+1] - t[k]) u_k = bilinear_interpolation(U[k], x) u_k1 = bilinear_interpolation(U[k+1], x) return (1 - delta) * u_k + delta * u_k1 ``` 这是第一版最核心的数值部件。只要这部分实现正确,streakline 程序主体就很直接。 ## Small diffusion particle model 当纯 streakline 太细、太锐利、不像染色液图像时,可以给每个粒子加一个很小的随机扩散项。这不是严格的被动标量求解,但能以很低代价增加染色带宽度。 ### Guiding idea [Kim04] 表明,染料图像更接近一个被动标量浓度场,其基本控制方程是对流扩散方程: \[ \frac{\partial c}{\partial t} + \boldsymbol{u} \cdot \nabla c = D \nabla^2 c \] 其中 \(c\) 是染料浓度,\(D\) 是扩散系数。对第一版粒子法,一个常见近似是将每个粒子的位置更新写成“对流加随机扩散”: \[ \boldsymbol{x}_{n+1} = \boldsymbol{x}_n + \Delta \boldsymbol{x}_{adv} + \Delta \boldsymbol{x}_{diff} \] 其中 \(\Delta \boldsymbol{x}_{adv}\) 由 RK2 或 RK4 给出,扩散项取二维各向同性随机增量: \[ \Delta \boldsymbol{x}_{diff} = \sqrt{2 D \Delta t} \begin{bmatrix} \eta_x \\ \eta_y \end{bmatrix} \] 这里 \(\eta_x, \eta_y \sim \mathcal{N}(0,1)\)。 这个形式本身是对扩散过程的标准随机游走近似。搜索结果提醒,随机游走模型如果处理不当会产生假漂移与错误浓度偏置 [Mac92]。因此第一版的使用原则应当很克制: - 只用于加入少量模糊和厚度 - 不把粒子密度当成严格浓度 - 不在强近壁统计上过度解读结果 ### Diffusive particle update pseudocode ```text for each particle p in P with p.alive: x_adv = RK4_step(p.position, t_local, dt) sigma = sqrt(2 * D * dt) dx_rand = sigma * normal(0, 1) dy_rand = sigma * normal(0, 1) x_new = x_adv + [dx_rand, dy_rand] if x_new leaves domain: p.alive = false else if x_new enters solid: p.alive = false else: p.position = x_new p.age += dt ``` ### Choosing the diffusion level 第一版不必试图从真实染料物性严格标定 \(D\)。更实用的做法是把 \(D\) 当作视觉匹配参数,并保持它足够小,使图像结构仍主要由对流控制。 [Kim04] 用 Schmidt 数控制扩散强弱。若已有参考速度 \(U\) 和长度尺度 \(L\),则 \[ Sc = \frac{\nu}{D} \] 也可写成 \[ D = \frac{\nu}{Sc} \] 对第一版,可以用下面的思路选扩散强度: | 目标效果 | 建议 | |---|---| | 只想让线条略微变厚 | 取较大 `Sc`,即很小的 `D` | | 想模拟明显洗开和模糊 | 取较小 `Sc`,即较大的 `D` | | 不确定 | 先从几乎看不出的弱扩散开始 | 因为当前目标是“少量扩散”,所以推荐先把扩散当成弱修饰,而不是主导机制。 ## Rendering logic 最终图像不需要把每个粒子的完整轨迹都画出来。更像实验染色图的做法是把当前存活粒子投影到图像网格上,生成粒子密度图或带年龄权重的强度图。 ### Minimal rendering choices | 方法 | 图像风格 | 实现难度 | |---|---|---:| | 直接散点 | 最简陋 | 很低 | | 网格计数直方图 | 像浓度图 | 低 | | 高斯核累积 | 更平滑 | 低到中 | 第一版推荐: - 把每个粒子投到像素网格 - 对像素做计数或加权累积 - 最后做一次轻微 Gaussian blur 这与先做完整被动标量相比便宜很多,但视觉上已经会很接近实验染色图。 ### Optional particle weighting 可以给粒子一个简单权重: \[ I = \sum_p w_p K(\boldsymbol{x} - \boldsymbol{x}_p) \] 其中 \(K\) 是像素核或 Gaussian 核。第一版里,权重 \(w_p\) 可直接取 1,也可对年龄做衰减,例如: \[ w_p = \exp\left(- \frac{\mathrm{age}_p}{\tau_f} \right) \] 这样旧粒子会逐渐淡出,图像不会无限堆积。 ## Boundary handling 这部分先按最小原则处理即可。 | 情况 | 第一版处理 | |---|---| | 粒子出计算域 | 直接删除 | | 粒子进入圆柱内部 | 直接删除 | | 粒子贴近边界滑动 | 暂不专门处理 | | 扩散后跨入固体 | 直接删除 | 这样做的优点是简单稳妥。后续若发现近壁 streakline 误差明显,再考虑反射、投影或更物理的壁面处理。 ## Minimal implementation plan ### Version 1 目标是尽快得到可信的 streakline 图: - 读取 `ux, uy, t` - 单点连续释粒 - 双线性空间插值 - 线性时间插值 - RK4 粒子推进 - 出域删除与入固体删除 - 粒子计数成图 ### Version 2 在不改变主体架构的前提下增强视觉效果: - 增加短线段释放 - 增加年龄衰减 - 增加弱随机扩散 - 用 Gaussian 核代替简单计数 ### Version 3 若后续发现实验图像明显受扩散与混合主导,再转向更重的模型: - 被动标量对流扩散 - 用 \(Sc\) 或 \(D\) 做参数标定 [Kim04] ## Design decisions that are already justified by the literature 下面这些设计已经有足够文献支撑,可以直接采用: - 用 streakline 而不是 streamline 对应持续染色实验 [Lan96] - 用连续释放粒子离线重构 streakline [Lan96] - 用时空插值从离散速度快照查询粒子速度 [Ken96] - 用 RK4 作为默认推进器 [Lan96, Ken96] - 把步长取得明显小于保存时间间隔,并避免一次跨过太多网格 [Dar96] - 若需要更像染料图,可以加入弱扩散,或后续上升到被动标量 [Kim04] ## Recommended default choices | 项目 | 默认选择 | |---|---| | 释放方式 | 单点连续释粒 | | 空间插值 | 双线性 | | 时间插值 | 线性 | | 积分器 | RK4 | | 粒子子步 | 保存步长的 0.1 到 0.25 | | 固体处理 | 入固体即删除 | | 输出图像 | 粒子密度图加轻微模糊 | | 扩散 | 默认关闭,作为第二步 | ## Bottom line 对当前项目,最简洁且足够准确的程序逻辑不是去解新的染料场,而是先做一个离线连续释粒 streakline 后处理器。它只依赖时间序列速度场,程序结构清楚,数值风险也集中在可控的几个环节:时空插值、积分步长和边界删除 [Lan96, Dar96, Ken96]。在此基础上,再增加一个弱随机扩散项,就能以很小代价把图像从“几何上正确的粒子线”推进到“更像实验染色照片”的粒子云 [Kim04]。 ## CelerisLab implementation mapping Current implementation lives in: - library: `src/CelerisLab/common/streakline.py` - Kan99b demo CLI: `tests/run_kan99b_streakline.py` - experiment notebook: `tests/experiment.ipynb` (CLEAN 5/6) Available runtime modes: - `online`: sample `Simulation.get_macroscopic()` directly in memory (no velocity snapshot files). - `offline`: replay from an existing snapshot directory and run the same streakline integrator. Dense release support: - base upstream points are expanded by `release_mode` (`point|line|strip`). - `strip` mode densifies both cross-stream (`line_count`, `line_span`) and downstream (`downstream_count`, `downstream_spacing`) directions. ### Performance notes (why it can feel slow) Streakline post-processing is **CPU-side** in the current stack: 1. **GPU→host velocity copies** dominate for large grids (`nx=6000, ny=1200`). Each `get_macroscopic()` pulls full `ux/uy` arrays. 2. Particle RK4 + bilinear interpolation runs in NumPy on CPU (vectorized over particles, not GPU). 3. Rendering accumulates polylines into an image (optionally multi-threaded via `ThreadPoolExecutor`). Practical tuning: | knob | effect | |---|---| | `sample_every` | larger → fewer host copies, faster, coarser streaks | | `max_particle_age` | `None` → no hard cutoff; trails survive until boundary/solid | | `num_threads` | `0` = auto; sets OpenBLAS/OMP threads for blur/host ops | | `blur_sigma` | `0` disables Gaussian blur pass | For `experiment.ipynb` triangle case, use **`render_streakline_density(..., minimal_axes=True)`** on the **final alive particle cloud** (`positions`, `ages`). Do **not** accumulate and draw full path history (`render_snapshot_trails` is pathline-style debug only). ### Clean render style (experiment) `render_snapshot_trails` defaults: - white background - red streaks, brighter toward the downstream end (`fade_along_trail=True`) - black filled cylinders - no axes, labels, colorbar, or release-point markers Example commands: ```bash # Online in-memory streakline (no snapshot write) conda run -n pycuda_3_10 python tests/run_kan99b_streakline.py \ --mode online --domain M --re 100 --alpha 1.0 \ --sample-every 300 --n-snapshots 20 \ --release-mode strip --line-count 7 --downstream-count 6 # Offline replay from existing velocity snapshots conda run -n pycuda_3_10 python tests/run_kan99b_streakline.py \ --mode offline \ --snapshot-dir tests/output/final_validation_round/streakline_kan99b_k2/velocity_snapshots \ --release-mode strip --line-count 7 --downstream-count 6 ```