## 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
```