# Legacy 双时钟流场采样

## 目的

Legacy DRL 模型只应在固定控制周期边界接收 observation 并预测下一动作。例如：

- Illusion：每 600 lattice steps 决策一次；
- Kármán：每 800 lattice steps 决策一次。

流场分析通常需要另一套保存时钟，例如每 257 或 317 lattice steps 保存一次 DDF。这两个时钟不必整除，也不应相互改变。

错误做法是在每个流场保存点调用普通 `ff.run(segment_steps, action)`。Legacy `run()` 每次都会：

1. 从 `ff.action` 重新初始化 action smoothing；
2. 清空 observation 累计；
3. 将该段 observation 除以段长度并发布。

这样会把流场保存点错误地变成控制边界，改变闭环控制。

## 正确语义

一个 DRL 控制周期只执行一次以下生命周期：

```python
ff.begin_control_interval(sample_interval, action_target)
ff.run_control_segment(first_length)
ff.get_ddf()  # 只读流场
ff.run_control_segment(second_length)
ff.get_ddf()
# ...累计恰好 sample_interval steps...
obs = ff.end_control_interval()
```

其中：

- `begin_control_interval()` 只在真实控制边界调用一次；
- `run_control_segment()` 可以按任意正整数长度调用多次；
- segment 之间延续同一个 interval-local action smoothing；
- segment 之间延续同一个 raw observation 累计；
- `get_ddf()` 只将当前 GPU DDF 复制到 host，不更新 observation、FIFO、reward、action 或 policy；
- 只有累计推进完整控制周期后才能调用 `end_control_interval()`；
- `end_control_interval()` 发布完整控制周期的平均 observation；
- FIFO、observation normalization 和下一次 `model.predict()` 仍然只在控制边界执行一次。

普通 `ff.run(total_steps, action_target)` 保持兼容，它等价于 begin、一次完整 segment、end。

## 推荐复用接口

通用实现位于：

- `LegacyCelerisLab/driver.py`：control-interval session；
- `src/CCD_analysis/utils/dual_clock.py`：绝对 field-step 调度和 `ux/uy` 保存；
- `src/CCD_analysis/scripts/collect_illusion.py`、`collect_karman.py`：实际接入示例。

OID 若使用同一个 Legacy `FlowField`，应直接复用 `CCD_analysis.utils.dual_clock.DualClockCollector`，不要复制一套控制循环。

### 固定流场间隔

```python
from CCD_analysis.utils.dual_clock import (
    DualClockCollector,
    field_steps_from_interval,
)

control_interval = 600
control_count = 200
field_interval = 257

collector = DualClockCollector(
    ff,
    control_interval=control_interval,
    control_count=control_count,
    u0=u0,
    field_steps=field_steps_from_interval(
        control_interval * control_count,
        field_interval,
    ),
)

obs = initial_obs
for control_index in range(control_count):
    action, _ = model.predict(obs, deterministic=True)
    action_target = decode_action(action)

    # 此调用内部可能在一个控制周期内读取多次或零次 DDF，
    # 但始终只结束一个真实控制周期。
    raw_obs = collector.run_interval(control_index, action_target)

    fifo.append(select_observation(raw_obs))
    obs = normalize(select_observation(raw_obs))

collector.save("fields.npz")
```

### 任意、不均匀保存位置

`field_steps` 使用从 rollout 开始计数的绝对 lattice step，并且必须严格递增：

```python
collector = DualClockCollector(
    ff,
    control_interval=800,
    control_count=200,
    u0=u0,
    field_steps=[137, 800, 913, 1721, 2400],
)
```

位于控制边界的 field step 只保存一次，同时仍只产生一次完整控制 observation。

## 输出合同

`collector.save("fields.npz")` 保存：

- `ux`, `uy`：读取时刻的无量纲速度场；
- `lattice_steps`：每个场对应的绝对 lattice step；
- `control_indices`：所属的零基控制周期；
- `control_offsets`：在该控制周期内的位置，范围为 `1..control_interval`；
- `control_interval`：模型固定控制周期。

后续 CCD/OID 对齐必须使用 `lattice_steps`，不能通过数组下标猜测时间。

## 命令示例

所有 Legacy GPU 采集使用 `pycuda_3_10` 和 GPU 2：

```bash
PYTHONPATH=src:. conda run -n pycuda_3_10 python \
  src/CCD_analysis/scripts/collect_illusion.py \
  --scene illusion_1.0L --device 2 --steps 200 \
  --field-interval 257 --output-dir /path/to/run/illusion_1.0L
```

```bash
PYTHONPATH=src:. conda run -n pycuda_3_10 python \
  src/CCD_analysis/scripts/collect_karman.py \
  --scene karman_re100 --device 2 --steps 200 \
  --field-interval 317 --output-dir /path/to/run/karman_re100
```

不提供 `--field-interval` 时使用原来的单时钟 `ff.run()` 路径，不保存 `fields.npz`。

## 实际闭环验证

2026-07-22 在 `pycuda_3_10`、物理 GPU 2 上进行了 200 个控制周期的 A/B：baseline 不读取中间 DDF，实验组使用与控制周期不整除的流场间隔，因此读取位置持续在控制周期内漂移。

### Illusion 1.0L

- 控制周期：600；流场间隔：257；
- baseline 末 100 周期 DTW similarity：0.973265；
- DDF 读取组：0.976044；
- 差值：+0.002779；
- 平均 reward 差值：-0.001633。

### Kármán Re=100

- 控制周期：800；流场间隔：317；
- baseline DTW similarity：0.954562；
- DDF 读取组：0.953442；
- 差值：-0.001119（约 -0.12%）；
- 两组末 100 周期传感器序列经相位补偿后的相互 DTW similarity：0.965680。

最终受控流场保持相同类型的尾流结构，没有出现控制失效。因此对同一条连续在线闭环轨迹，可以认为控制期间独立读取 DDF 不会造成有意义的控制效果下降。

## 边界和注意事项

- 该方法解决的是同一条连续闭环轨迹中的独立流场采样。
- 它不保证不同 checkpoint restore 分支 bitwise 相同；Legacy CUDA kernel 本身存在数值非确定性。
- 不要把两个独立 restore 分支当成逐点 bitwise 配对数据。
- 不要在 active control interval 中调用普通 `ff.run()`。
- 不要提前调用 `end_control_interval()`。
- 不要在同一 control interval 内切换 action target。
- DDF 读取后必须继续同一个 session，直至原固定控制边界。
