feat: eval benchmark + cloud-trained models replace train/

- eval/: 统一推理评估框架 (infer_train.py, infer_reproduce.py, viz_flow.py,
  viz_signals.py, generate_report.py, run_all.sh, scene_manifest.py)
- train/: 替换为云端正式训练产出,覆盖 11 个场景
  - Karman: Re100 (5 seeds), Re60/200/400 transfer
  - VarDist: d075/d15/d2 scratch
  - Illusion: 0.75L/1.0L/1.5L/2.0L scratch
- train_karman.py: 新增 --resume-from 断点续训支持
- calibrations: 17 组标定数据 (kar/ill 命名规范)
- scripts/: 7 个自动化训练脚本
- 清理旧 calibrations/shell scripts/visualize

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f
2026-07-12 23:56:02 +08:00
co-authored by Cursor
parent f2f88c2442
commit 4feac49f1c
52 changed files with 4053 additions and 1341 deletions
+206
View File
@@ -0,0 +1,206 @@
# Eval Benchmark — 推理评估与流场分析
> 对 V5 Train 模型和 Legacy 旧模型进行全面推理采样和可视化对比。
> 复用 train env 保证与训练流程完全一致,不使用 skeleton injection。
## 文件结构
```
src/drl_pinball/eval/
README.md # 本文件 (自我审查清单 + 复习流程)
__init__.py
scene_manifest.py # 所有 scene 的配置单点真理源
infer_train.py # V5 Train 模型推理引擎 (GPU 0)
infer_reproduce.py # Reproduce 模型推理引擎 (GPU 1)
viz_flow.py # 涡量图跨场景拼图 (统一 colormap)
viz_signals.py # obs/action/DTW/FFT 时序可视化
generate_report.py # 汇总表格 + 柱状图
run_all.sh # 主启动器 (GPU 调度)
output/
train/{scene}/ # signals.npz, vorticity_*.png, metrics.json, all_seeds.json
reproduce/{scene}/ # 同上
reports/
comparison_table.md # 汇总对比表
summary_dtw_barchart.png # DTW 柱状图
vorticity_panels/ # 跨场景拼图
signal_plots/ # 诊断信号图
```
## Quick Start
```bash
conda activate pycuda_3_10
cd src/drl_pinball/eval
# === 全量跑 (双 GPU) ===
bash run_all.sh
# === 单场景测试 ===
python infer_train.py --scene re100 --device-id 0
python infer_train.py --scene illusion_1L --device-id 0
# === 仅跑报告生成 (已有 output 数据) ===
python generate_report.py
python viz_signals.py --side train
python viz_flow.py
```
## GPU 调度策略
```
时间轴:
GPU0: [re100] [ill_1L] [ill_15L] [ill_075L] --120s--> [re60] --120s--> [re200] --120s--> [re400]
(config A: karman_2000x600, nu=0.004) (config B: re60) (config C: re200) (config D: re400)
GPU1: --120s--> [karman_cloak] [steady_cloak] [ill_075L] [ill_1L] [ill_15L] [vortex_lamb] [vortex_taylor]
(全部 config_pinball.json, 无额外编译)
```
- GPU 1 比 GPU 0 晚 120 秒启动,避免两个不同 config 同时触发编译
- 同 config 内 scene 连续运行,无 recompilation 开销
- 每个 scene 约 5-8 分钟(warmup + FIFO + 360 步推理)
## 场景覆盖率
### Train pipeline (GPU 0)
| Scene | Config | Seeds |
|-------|--------|-------|
| re100_karman | karman_2000x600 (Re=100) | 41-45, 539439 |
| transfer_re60 | karman_2000x600_re60 | 41, 43 |
| transfer_re200 | karman_2000x600_re200 | 41, 43, 45 |
| transfer_re400 | karman_2000x600_re400 | 43 |
| illusion_075L | karman_2000x600 | 41 |
| illusion_1L | karman_2000x600 | 41, 43 |
| illusion_15L | karman_2000x600 | 41, 43 |
### Reproduce pipeline (GPU 1)
| Scene | Config | 模型 |
|-------|--------|------|
| karman_cloak | pinball (1280x512) | d1a3o12_re100 |
| steady_cloak | pinball (1280x512) | open-loop [0,-5.1,5.1] |
| illusion_075L | pinball (1280x512) | d1a3o14_250525_imit_075L_2U_400S |
| illusion_1L | pinball (1280x512) | d1a3o14_250525_imit_1L_2U_600S |
| illusion_15L | pinball (1280x512) | d1a3o14_250525_imit_15L_2U |
| vortex_lamb | pinball (1280x512) | vortex_lamb |
| vortex_taylor | pinball (1280x512) | vortex_taylor |
## 核心设计原则
1. **复用 train env 而非重构 CFD** — KarmanCloakEnv / IllusionCloakEnv 已封装 norm、calibration、DTW、snapshot/restore
2. **PPO.load() + VecNormalize.load()** — SB3 标准 API,不使用 skeleton injection
3. **统一涡量 [-0.03, 0.03] colormap** — 所有场景跨可比
4. **每步全量时序** — sensors(6) + forces(6) + actions(3) + rewards 存入 signals.npz
---
## 自我审查清单
### 启动前
- [ ] `conda activate pycuda_3_10`
- [ ] 所有 `best_model.zip``vec_normalize.pkl` 路径存在 (见 scene_manifest.py)
- [ ] 所有 `calibration.json``target.npy` 存在 (train/calibrations/)
- [ ] `nvidia-smi` 显示两个 GPU 空闲
- [ ] 无僵尸 pycuda 进程 (`pkill -f pycuda` 后等待 3 分钟)
### Phase 1 — Train 推理
- [ ] `python infer_train.py --scene re100 --device-id 0` 单场景测试通过
- [ ] 输出的 `signals.npz` 形状: (360,6) sensors, (360,6) forces, (360,3) actions
- [ ] rewards 非 NaNDTW sim 在 [0,1] 内
- [ ] Zero-action baseline 的 DTW 明显低于 controlled
### Phase 2 — Reproduce 推理
- [ ] `python infer_reproduce.py --scene karman_cloak --device-id 1` 测试通过
- [ ] 旧模型 PPO.load() 不报错
- [ ] Vortex 场景 add_vortex 正确初始化(check target.npz 非全零)
### Phase 3 — 涡量图
- [ ] `python viz_flow.py` 产出全部拼图
- [ ] vorticity range 统一 [-0.03, 0.03]
- [ ] 圆柱边界清晰可见
- [ ] target vs controlled vs zero 三栏对比正常
### Phase 4 — 时序图
- [ ] `python viz_signals.py --side train` 产出全部诊断图
- [ ] FFT 频谱确认 controlled 与 target Strouhal 匹配
- [ ] Action 时序 policy 收敛到稳态 (非振荡)
### Phase 5 — 汇总报告
- [ ] `python generate_report.py` 产出 comparison_table.md
- [ ] Train DTW >= Reproduce DTW (或合理的 gap 原因)
### 最终验收
- [ ] 7 train + 7 reproduce 目录完整
- [ ] 每个目录: signals.npz, vorticity_controlled.png, vorticity_target.png, vorticity_zero.png, metrics.json, all_seeds.json
- [ ] reports/ 下: comparison_table.md, summary_dtw_barchart.png, vorticity_panels/, signal_plots/
---
## 复习流程 (中断后快速恢复)
按顺序读取以下文件重建上下文:
1. **本文件** — 确认当前进度和 GPU 调度图
2. **`eval/scene_manifest.py`** — 所有 scene 配置
```bash
python -c "from drl_pinball.eval.scene_manifest import TRAIN_SCENES; \
[print(s['scene_id'], s['si']) for s in TRAIN_SCENES]"
```
3. **`train/TRAIN_KNOWLEDGE.md`** sections 1-3 — V5 训练管线回顾
4. **验证环境可用**:
```bash
ls eval/output/train/*/metrics.json 2>/dev/null # 已完成的 scene
ls eval/output/reproduce/*/metrics.json 2>/dev/null
```
5. **从断点继续**:
```bash
# 单 scene:
python infer_train.py --scene re200 --device-id 0
# 或 Kill pycuda 进程后重新启动显卡:
pkill -f pycuda; sleep 180; bash run_all.sh
```
### 常见问题排查
| 问题 | 解决方案 |
|------|---------|
| `cuInit failed` | GPU 被占用。`pkill -f pycuda; sleep 180` 后重试 |
| `best_model.zip not found` | 训练未完成。查看 `train/output/{case}/models/` |
| `target.npy not found` | calibration 缺失。重新运行 `calibrate.py` |
| DTW sim = -1.0 (reproduce) | 正常。Reproduce 不计算 DTW,由 report 统一处理 |
| 涡量图空白 | 检查 `render_vorticity_field` 的 `vmin/vmax` 或 cylinders 坐标 |
| VecNormalize.pkl 不存在 | 训练时未保存。用 `best_model.zip` 同目录的 `vec_normalize.pkl` |
---
## 代码关键点
### infer_train.py 的关键差异点
- 每个 seed 独立 `PPO.load()` + `VecNormalize.load()`,不是共用 skeleton
- 推理时 `SymmetryAugmentWrapper(prob=0.0)`deterministic mode
- Raw obs 从 `env._read_obs()` 提取,通过 wrapper chain 穿透
- Target vorticity 单独创建 Simulation 生成(dist cylinder only
### infer_reproduce.py 的关键差异点
- 1280x512 网格,parabolic inletbounce-back walls
- Legacy norm 从 `SR_analysis/data/*/norm.json` 加载
- 旧动作缩放: `norm * scale + bias` (各 scene 不同)
- Illusion 模型 obs_dim=14(含 target_cd, target_cl,通过 harmonics 重构)
- Vortex 用 `add_vortex()` 在不同 x 位置添加涡量
### 输出文件命名
| 文件 | 内容 |
|------|------|
| `signals.npz` | sensors (N,6), forces (N,6), actions (N,3), rewards (N,) |
| `vorticity_controlled.png` | DRL 控制后的 final 时刻涡量 |
| `vorticity_target.png` | 目标状态 (disturbance only / target cylinder) |
| `vorticity_zero.png` | 零动作 baseline (无控制) |
| `metrics.json` | DTW sim, reward, action stats |
| `all_seeds.json` | 每个 seed 的详细评分 |
+1
View File
@@ -0,0 +1 @@
# eval package
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Generate master comparison report from eval outputs.
Reads metrics.json from all output/{train,reproduce}/ directories,
produces:
1. Master comparison table (markdown)
2. Summary bar chart (train vs reproduce DTW similarity)
3. Action statistics summary
Usage:
conda run -n pycuda_3_10 python generate_report.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
_OUT_BASE = Path(__file__).resolve().parent / "output"
_REPORT_DIR = _OUT_BASE / "reports"
def log(msg: str) -> None:
print(f"[report] {msg}", flush=True)
def load_metrics(scene_dir: Path) -> Dict[str, Any]:
p = scene_dir / "metrics.json"
if p.exists():
with open(p) as f:
return json.load(f)
return {}
def load_all_seeds(scene_dir: Path) -> List[Dict]:
p = scene_dir / "all_seeds.json"
if p.exists():
with open(p) as f:
return json.load(f)
return []
# ---------------------------------------------------------------------------
# Tables
# ---------------------------------------------------------------------------
def generate_comparison_table() -> str:
"""Generate markdown comparison table for overlapping scenes."""
overlap = [
("re100_karman", "karman_cloak", "Karman Cloak Re100"),
("illusion_075L", "illusion_075L", "Illusion 0.75L"),
("illusion_1L", "illusion_1L", "Illusion 1.0L"),
("illusion_15L", "illusion_15L", "Illusion 1.5L"),
]
lines = []
lines.append("# Master Comparison Table")
lines.append("")
lines.append("| Scene | Side | Best Seed | DTW sim | Reward | "
"aF mean | aB mean | aT mean | r_cd | r_cl | r_sim |")
lines.append("|-------|------|-----------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|")
for trn_key, rep_key, name in overlap:
m_trn = load_metrics(_OUT_BASE / "train" / trn_key)
m_rep = load_metrics(_OUT_BASE / "reproduce" / rep_key)
dtw_t = _fmt(m_trn.get("dtw_sim_v5") or m_trn.get("sim_raw_mean"))
dtw_r = _fmt(m_rep.get("dtw_sim_v5") or m_rep.get("sim_raw_mean"))
# Train row
lines.append(
f"| {name} | Train | {m_trn.get('best_seed', '-')} | "
f"{dtw_t} | {_fmt(m_trn.get('reward_mean'))} | "
f"{_fmt(m_trn.get('aF_mean'))} | {_fmt(m_trn.get('aB_mean'))} | "
f"{_fmt(m_trn.get('aT_mean'))} | "
f"{_fmt(m_trn.get('r_cd_mean'))} | {_fmt(m_trn.get('r_cl_mean'))} | "
f"{_fmt(m_trn.get('r_sim_mean'))} |"
)
# Reproduce row
lines.append(
f"| {name} | Reproduce | - | "
f"{dtw_r} | - | "
f"- | - | - | - | - | - |"
)
# Train-only scenes
train_only = ["transfer_re60", "transfer_re200", "transfer_re400"]
lines.append("")
lines.append("## Train Only (Cross-Re Transfer)")
lines.append("| Scene | Best Seed | Reward | "
"r_cd | r_cl | r_sim | sim_raw |")
lines.append("|-------|-----------|---:|-----:|-----:|-----:|---:|")
for trn_key in train_only:
m = load_metrics(_OUT_BASE / "train" / trn_key)
lines.append(
f"| {trn_key} | {m.get('best_seed', '-')} | "
f"{_fmt(m.get('reward_mean'))} | "
f"{_fmt(m.get('r_cd_mean'))} | {_fmt(m.get('r_cl_mean'))} | "
f"{_fmt(m.get('r_sim_mean'))} | {_fmt(m.get('sim_raw_mean'))} |"
)
# Reproduce-only
repro_only = ["steady_cloak", "vortex_lamb", "vortex_taylor"]
lines.append("")
lines.append("## Reproduce Only")
lines.append("| Scene | Notes |")
lines.append("|-------|-------|")
for r_key in repro_only:
lines.append(f"| {r_key} | open-loop / vortex |")
# Per-scene all-seeds detail
lines.append("")
lines.append("## Per-Scene Seed Details (Train)")
for trn_key in ["re100_karman", "transfer_re60", "transfer_re200",
"transfer_re400", "illusion_075L", "illusion_1L", "illusion_15L"]:
seeds = load_all_seeds(_OUT_BASE / "train" / trn_key)
if not seeds:
continue
lines.append(f"\n### {trn_key}")
lines.append("| Seed | Reward | r_cd | r_cl | r_sim | sim_raw | Time (s) |")
lines.append("|------|--------|------|------|-------|---------|---------:|")
for s in sorted(seeds, key=lambda x: -x.get("reward", -float("inf"))):
lines.append(
f"| {s['seed']} | {_fmt(s.get('reward'))} | "
f"{_fmt(s.get('r_cd'))} | {_fmt(s.get('r_cl'))} | "
f"{_fmt(s.get('r_sim'))} | {_fmt(s.get('sim_raw'))} | "
f"{_fmt_int(s.get('dt_sec'))} |"
)
return "\n".join(lines)
def _fmt(v, precision=4):
if v is None or v == -1.0:
return "-"
if isinstance(v, float):
return f"{v:.{precision}f}"
return str(v)
def _fmt_int(v):
if v is None:
return "-"
return f"{int(v)}"
# ---------------------------------------------------------------------------
# Bar chart
# ---------------------------------------------------------------------------
def generate_dtw_barchart() -> None:
overlap = [
("re100_karman", "karman_cloak", "Karman Cloak"),
("illusion_075L", "illusion_075L", "Illusion 0.75L"),
("illusion_1L", "illusion_1L", "Illusion 1L"),
("illusion_15L", "illusion_15L", "Illusion 1.5L"),
]
names = []
trn_vals = []
rep_vals = []
for trn_key, rep_key, name in overlap:
m_trn = load_metrics(_OUT_BASE / "train" / trn_key)
m_rep = load_metrics(_OUT_BASE / "reproduce" / rep_key)
t = m_trn.get("dtw_sim_v5") or m_trn.get("sim_raw_mean", 0)
r = m_rep.get("dtw_sim_v5") or m_rep.get("sim_raw_mean", 0)
names.append(name)
trn_vals.append(t if t and t > 0 else 0)
rep_vals.append(r if r and r > 0 else 0)
fig, ax = plt.subplots(figsize=(10, 5))
x = np.arange(len(names))
w = 0.35
ax.bar(x - w / 2, rep_vals, w, label="Reproduce (legacy)", color="#d62728")
ax.bar(x + w / 2, trn_vals, w, label="Train (V5)", color="#1f77b4")
ax.set_xticks(x); ax.set_xticklabels(names, rotation=15, ha="right")
ax.set_ylabel("DTW Similarity"); ax.set_ylim(0, 1.05)
ax.set_title("Train vs Reproduce - DTW Similarity")
ax.legend(); ax.grid(axis="y", alpha=0.3)
for i, (tv, rv) in enumerate(zip(trn_vals, rep_vals)):
if tv > 0: ax.text(i + w / 2, tv + 0.01, f"{tv:.3f}", ha="center", fontsize=9)
if rv > 0: ax.text(i - w / 2, rv + 0.01, f"{rv:.3f}", ha="center", fontsize=9)
fig.tight_layout()
fig.savefig(_REPORT_DIR / "summary_dtw_barchart.png", dpi=150,
bbox_inches="tight")
plt.close(fig)
log(" -> summary_dtw_barchart.png")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
_REPORT_DIR.mkdir(parents=True, exist_ok=True)
log(f"Train dir: {_OUT_BASE / 'train'}")
log(f"Reproduce dir: {_OUT_BASE / 'reproduce'}")
log(f"Reports dir: {_REPORT_DIR}")
table = generate_comparison_table()
(_REPORT_DIR / "comparison_table.md").write_text(table)
log(" -> comparison_table.md")
generate_dtw_barchart()
log("All reports generated.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+610
View File
@@ -0,0 +1,610 @@
#!/usr/bin/env python3
"""Reproduce pipeline inference: legacy models on 1280x512 config.
Loads pre-trained PPO models from models/old/ and models/250525/,
runs deterministic inference on config_lbm_pinball.json (parabolic, bounce-back).
Key differences from train pipeline:
- 1280x512 grid (not 2000x600)
- Legacy norm (force_norm_fact + sens_deviation + sens_norm_fact)
- Old action scaling with biases (match legacy env)
- No VecNormalize (old models use manual norm)
- DummyEnv for PPO.load()
Usage:
conda run -n pycuda_3_10 python -u infer_reproduce.py --device-id 1
conda run -n pycuda_3_10 python -u infer_reproduce.py --device-id 1 --scene karman
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import pycuda.driver as cuda; cuda.init()
_REPO = str(Path(__file__).resolve().parents[3])
_SRC = Path(_REPO) / "src"
for p in [_REPO, str(_SRC)]:
if p not in sys.path:
sys.path.insert(0, p)
import torch
from torch.nn import Module as TorchModule
from stable_baselines3 import PPO
from CelerisLab import Simulation
from CelerisLab.common.render import compute_vorticity, render_vorticity_field
from CelerisLab.lbm.initializers import add_vortex
from drl_pinball.reproduce.configs.model_inventory import ModelInventory
from drl_pinball.eval.scene_manifest import REPRODUCE_SCENES
L0 = 20.0
U0 = 0.01
NX = 1280
NY = 512
CENTER_Y = float(NY - 1) / 2.0
RADIUS = L0 / 2.0
FIFO_LEN = 150
WARMUP = int(4.0 * NX / U0)
# Karman/Vortex/Steady geometry (1280x512)
DIST_X = 10.0 * L0 # 200
PB_FRONT_X = 30.0 * L0 # 600
PB_REAR_X = 31.3 * L0 # 626
SENSOR_X = 40.0 * L0 # 800
# Illusion geometry (shifted left)
ILL_PB_FRONT_X = 19.0 * L0 # 380
ILL_PB_REAR_X = 20.3 * L0 # 406
ILL_SENSOR_X = 30.0 * L0 # 600
ILL_TARGET_X = 20.0 * L0 # 400
VORTEX_RADIUS = 2.0 * L0
SR_DATA = _SRC / "SR_analysis" / "data"
_OUT_BASE = Path(__file__).resolve().parent / "output" / "reproduce"
class Sin(TorchModule):
def __init__(self): super().__init__()
def forward(self, x): return torch.sin(x)
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ---------------------------------------------------------------------------
# Legacy helpers
# ---------------------------------------------------------------------------
class ActionSmoother:
def __init__(self, weight=0.1):
self.weight = weight; self._state = None
def __call__(self, target):
t = np.asarray(target, dtype=np.float32)
if self._state is None:
self._state = t.copy()
else:
self._state = (1.0 - self.weight) * self._state + self.weight * t
return self._state.copy()
def reset(self, value=None):
self._state = np.asarray(value, dtype=np.float32).copy() if value is not None else None
def get_cc(sim, sid):
nx, ny = sim.lbm_cfg.nx, sim.lbm_cfg.ny
cells_arr, _ = sim.bodies.get(sid).get_sensor_list(nx, ny)
return float(len(cells_arr))
def action_to_omega_legacy(action_norm, scale=8.0, bias=(0.0, -4.0, 4.0)):
b = np.array(bias, dtype=np.float32)
sv = (np.asarray(action_norm, dtype=np.float32) * scale + b) * U0
return -sv / RADIUS
def load_legacy_norm(ref_dir: str) -> Dict[str, Any]:
with open(os.path.join(ref_dir, "norm.json")) as f:
d = json.load(f)
return {
"force_norm_fact": np.float32(d["force_norm_fact"]),
"sens_deviation": np.array(d["sens_deviation"], dtype=np.float32),
"sens_norm_fact": np.array(d["sens_norm_fact"], dtype=np.float32),
}
def normalize_obs(obs_slice, norm):
forces = obs_slice[6:12] / norm["force_norm_fact"]
sens = (obs_slice[0:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
return np.clip(np.hstack([forces, sens]), -1.0, 1.0).astype(np.float32)
def save_vorticity(sim, out_path, cylinders, nx=NX, ny=NY):
macro = sim.get_macroscopic()
vort = compute_vorticity(macro["ux"], macro["uy"])
render_vorticity_field(vort, nx=nx, ny=ny, out_path=str(out_path),
cylinders=cylinders, vmin=-0.03, vmax=0.03)
# ---------------------------------------------------------------------------
# Obs readers (legacy ordering)
# ---------------------------------------------------------------------------
def read_obs_karman(sim, dist_id, sensor_ids, pinball_ids, cc):
"""dist_fx, dist_fy, s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy,
front_fx, front_fy, top_fx, top_fy, bot_fx, bot_fy"""
obs = list(sim.read_force(dist_id, normalize=True))
for sid in sensor_ids:
s = sim.read_sensor(sid, normalize=True)
obs.extend([float(s[0]) * cc, float(s[1]) * cc])
for pid in pinball_ids:
obs.extend(sim.read_force(pid, normalize=True))
return np.array(obs, dtype=np.float32)
def read_obs_6obj(sim, sensor_ids, pinball_ids, cc):
obs = []
for sid in sensor_ids:
s = sim.read_sensor(sid, normalize=True)
obs.extend([float(s[0]) * cc, float(s[1]) * cc])
for pid in pinball_ids:
obs.extend(sim.read_force(pid, normalize=True))
return np.array(obs, dtype=np.float32)
# ---------------------------------------------------------------------------
# Karman Cloak
# ---------------------------------------------------------------------------
def run_karman_reproduce(scene: Dict[str, Any], device_id: int, out_dir: Path) -> None:
log(f"=== Reproduce: Karman Cloak ===")
out_dir.mkdir(parents=True, exist_ok=True)
SI = scene["si"]
num_steps = scene["num_steps"]
model_name = scene["model_name"]
scale = scene["action_scale"]
bias = scene["action_bias"]
# Phase 1: Disturbance + sensors, record target
log(" Phase 1: Recording target...")
sim = Simulation(lbm_config_path=scene["config_path"], device_id=device_id)
dist_id = sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
sensor_ids = [
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0),
]
sim.initialize()
sim.run(WARMUP, zero_obs=True)
cc = get_cc(sim, sensor_ids[0])
log(f" Sensor cell count: {cc}")
target_states = np.zeros((FIFO_LEN, 6), dtype=np.float32)
for i in range(FIFO_LEN):
sim.run(SI, zero_obs=True)
obs = read_obs_karman(sim, dist_id, sensor_ids, [], cc)
target_states[i] = obs[2:8]
np.savez_compressed(out_dir / "target.npz", target_states=target_states)
# Save target vorticity
save_vorticity(sim, out_dir / "vorticity_target.png",
[((DIST_X, CENTER_Y), 1.0 * L0),
((SENSOR_X, CENTER_Y + 40.0), 5.0),
((SENSOR_X, CENTER_Y), 5.0),
((SENSOR_X, CENTER_Y - 40.0), 5.0)])
# Phase 2: Add pinball
log(" Phase 2: Adding pinball...")
n0 = sim.bodies.count
sim.add_body("circle", center=(PB_FRONT_X, CENTER_Y, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS)
sim.sync_bodies()
fid, tid, bid = list(range(n0, n0 + 3))
sim.run(WARMUP, zero_obs=True)
# Load legacy norm
ref_dir = str(SR_DATA / "karman" / "karman_re100")
norm = load_legacy_norm(ref_dir)
# Bias FIFO
bias_norm = np.array([0.0, -1.0, 1.0])
bias_omega = action_to_omega_legacy(bias_norm, scale=scale, bias=bias)
ema = ActionSmoother(weight=0.1); ema.reset(np.zeros(3, dtype=np.float32))
for _ in range(FIFO_LEN):
s = ema(bias_omega)
sim.set_body(fid, omega=s[0]); sim.set_body(tid, omega=s[1]); sim.set_body(bid, omega=s[2])
sim.run(SI, zero_obs=True)
sim.snapshot()
# DRL inference
log(" Phase 3: DRL inference...")
sim.restore()
ema.reset(bias_omega.copy())
model = ModelInventory().load(model_name, device="cpu")
log(f" Model: {model_name} on CPU")
obs_init = read_obs_karman(sim, dist_id, sensor_ids, [fid, tid, bid], cc)
obs_norm = normalize_obs(obs_init[2:14], norm)
sig_s = np.zeros((num_steps, 6), dtype=np.float32)
sig_f = np.zeros((num_steps, 6), dtype=np.float32)
sig_a = np.zeros((num_steps, 3), dtype=np.float32)
fifo = deque(maxlen=FIFO_LEN)
[fifo.append(target_states[i, :].copy()) for i in range(FIFO_LEN)]
for step in range(num_steps):
action, _ = model.predict(obs_norm, deterministic=True)
action = np.asarray(action, dtype=np.float32).flatten()
target_omega = action_to_omega_legacy(action, scale=scale, bias=bias)
smoothed = ema(target_omega)
sim.set_body(fid, omega=smoothed[0]); sim.set_body(tid, omega=smoothed[1]); sim.set_body(bid, omega=smoothed[2])
sim.run(SI, zero_obs=True)
obs = read_obs_karman(sim, dist_id, sensor_ids, [fid, tid, bid], cc)
sl = obs[2:14]
fifo.append(sl[0:6].copy())
sig_s[step] = sl[0:6]
sig_f[step] = sl[6:12]
sig_a[step] = action
obs_norm = normalize_obs(sl, norm)
save_vorticity(sim, out_dir / "vorticity_controlled.png", [
((DIST_X, CENTER_Y), 1.0 * L0),
((PB_FRONT_X, CENTER_Y), RADIUS),
((PB_REAR_X, CENTER_Y + 15.0), RADIUS),
((PB_REAR_X, CENTER_Y - 15.0), RADIUS),
])
sim.close()
np.savez_compressed(out_dir / "signals.npz",
sensors=sig_s, forces=sig_f, actions=sig_a)
metrics = {"dtw_sim_v5": -1.0} # DTW computed by generate_report later
with open(out_dir / "metrics.json", "w") as f:
json.dump(metrics, f, indent=2)
log(" Done.")
# ---------------------------------------------------------------------------
# Steady Cloak (open-loop)
# ---------------------------------------------------------------------------
def run_steady_reproduce(scene: Dict[str, Any], device_id: int, out_dir: Path) -> None:
log(f"=== Reproduce: Steady Cloak ===")
out_dir.mkdir(parents=True, exist_ok=True)
SI = scene["si"]
num_steps = scene["num_steps"]
surf_vel = scene.get("open_loop_surf_vel", (0.0, -5.1, 5.1))
bias_surf = np.array(surf_vel, dtype=np.float32) * U0
sim = Simulation(lbm_config_path=scene["config_path"], device_id=device_id)
sensor_ids = [
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0),
]
sim.add_body("circle", center=(PB_FRONT_X, CENTER_Y, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS)
sim.initialize()
sim.run(WARMUP, zero_obs=True)
cc = get_cc(sim, sensor_ids[0])
save_vorticity(sim, out_dir / "vorticity_zero.png", [
((PB_FRONT_X, CENTER_Y), RADIUS),
((PB_REAR_X, CENTER_Y + 15.0), RADIUS),
((PB_REAR_X, CENTER_Y - 15.0), RADIUS),
])
bias_omega = -bias_surf / RADIUS
ema = ActionSmoother(weight=0.1); ema.reset(np.zeros(3, dtype=np.float32))
for _ in range(FIFO_LEN):
s = ema(bias_omega)
sim.set_body(3, omega=s[0]); sim.set_body(4, omega=s[1]); sim.set_body(5, omega=s[2])
sim.run(SI, zero_obs=True)
sim.snapshot()
sim.restore()
ema.reset(bias_omega.copy())
sig_s = np.zeros((num_steps, 6), dtype=np.float32)
sig_f = np.zeros((num_steps, 6), dtype=np.float32)
for step in range(num_steps):
smoothed = ema(bias_omega)
sim.set_body(3, omega=smoothed[0]); sim.set_body(4, omega=smoothed[1]); sim.set_body(5, omega=smoothed[2])
sim.run(SI, zero_obs=True)
obs = read_obs_6obj(sim, sensor_ids, [3, 4, 5], cc)
sig_s[step] = obs[0:6]
sig_f[step] = obs[6:12]
save_vorticity(sim, out_dir / "vorticity_controlled.png", [
((PB_FRONT_X, CENTER_Y), RADIUS),
((PB_REAR_X, CENTER_Y + 15.0), RADIUS),
((PB_REAR_X, CENTER_Y - 15.0), RADIUS),
])
sim.close()
np.savez_compressed(out_dir / "signals.npz", sensors=sig_s, forces=sig_f,
actions=np.zeros((num_steps, 3), dtype=np.float32))
with open(out_dir / "metrics.json", "w") as f:
json.dump({"dtw_sim_v5": -1.0}, f, indent=2)
log(" Done.")
# ---------------------------------------------------------------------------
# Illusion
# ---------------------------------------------------------------------------
def run_illusion_reproduce(scene: Dict[str, Any], device_id: int, out_dir: Path) -> None:
scene_id = scene["scene_id"]
log(f"=== Reproduce: Illusion {scene_id} ===")
out_dir.mkdir(parents=True, exist_ok=True)
SI = scene["si"]
num_steps = scene["num_steps"]
model_name = scene["model_name"]
scale = scene["action_scale"]
bias = scene["action_bias"]
target_diam = scene.get("target_diam", 1.0)
# Load legacy data
ref_dir = str(SR_DATA / "illusion" / scene_id)
norm = load_legacy_norm(ref_dir)
with open(os.path.join(ref_dir, "target_harmonics.json")) as f:
target_harmonics = json.load(f)
legacy_target = np.load(os.path.join(ref_dir, "target.npz"))["target_states"]
def gen_target_at(t):
D = len(target_harmonics)
result = np.zeros(D, dtype=np.float32)
for d, h in enumerate(target_harmonics):
val = np.float32(h["dc"])
for amp, freq, phase in zip(h["amps"], h["freqs"], h["phases"]):
val += amp * np.cos(2.0 * np.pi * freq * t + phase)
result[d] = val
return result
sim = Simulation(lbm_config_path=scene["config_path"], device_id=device_id)
sensor_ids = [
sim.add_body("sensor", center=(ILL_SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
sim.add_body("sensor", center=(ILL_SENSOR_X, CENTER_Y, 0.0), radius=5.0),
sim.add_body("sensor", center=(ILL_SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0),
]
sim.add_body("circle", center=(ILL_PB_FRONT_X, CENTER_Y, 0.0), radius=RADIUS)
sim.add_body("circle", center=(ILL_PB_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS)
sim.add_body("circle", center=(ILL_PB_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS)
sim.initialize()
sim.run(WARMUP, zero_obs=True)
cc = get_cc(sim, sensor_ids[0])
fid, tid, bid = 3, 4, 5
# Bias FIFO
bias_surf = np.array([0.0, -1.0, 1.0], dtype=np.float32) * U0
bias_omega = -bias_surf / RADIUS
ema_b = ActionSmoother(weight=0.1); ema_b.reset(np.zeros(3, dtype=np.float32))
for _ in range(FIFO_LEN):
s = ema_b(bias_omega)
sim.set_body(fid, omega=s[0]); sim.set_body(tid, omega=s[1]); sim.set_body(bid, omega=s[2])
sim.run(SI, zero_obs=True)
sim.snapshot()
# DRL inference
sim.restore()
ema = ActionSmoother(weight=0.1); ema.reset(bias_omega.copy())
model = ModelInventory().load(model_name, device="cpu")
log(f" Model: {model_name} on CPU")
obs_init = read_obs_6obj(sim, sensor_ids, [fid, tid, bid], cc)
obs_norm_base = normalize_obs(obs_init, norm)
t0 = gen_target_at(0)
target_cd = np.float32(t0[0] / norm["force_norm_fact"])
target_cl = np.float32(t0[1] / norm["force_norm_fact"])
obs_norm = np.clip(np.hstack([obs_norm_base, [target_cd, target_cl]]), -1.0, 1.0).astype(np.float32)
sig_s = np.zeros((num_steps, 6), dtype=np.float32)
sig_f = np.zeros((num_steps, 6), dtype=np.float32)
sig_a = np.zeros((num_steps, 3), dtype=np.float32)
for step in range(num_steps):
action, _ = model.predict(obs_norm, deterministic=True)
action = np.asarray(action, dtype=np.float32).flatten()
target_omega = action_to_omega_legacy(action, scale=scale, bias=bias)
smoothed = ema(target_omega)
sim.set_body(fid, omega=smoothed[0]); sim.set_body(tid, omega=smoothed[1]); sim.set_body(bid, omega=smoothed[2])
sim.run(SI, zero_obs=True)
obs = read_obs_6obj(sim, sensor_ids, [fid, tid, bid], cc)
sig_s[step] = obs[0:6]
sig_f[step] = obs[6:12]
sig_a[step] = action
forces_n = obs[6:12] / norm["force_norm_fact"]
sens_n = (obs[0:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
t_h = gen_target_at(step)
target_cd = np.float32(t_h[0] / norm["force_norm_fact"])
target_cl = np.float32(t_h[1] / norm["force_norm_fact"])
obs_norm = np.clip(np.hstack([forces_n, sens_n, [target_cd, target_cl]]), -1.0, 1.0).astype(np.float32)
save_vorticity(sim, out_dir / "vorticity_controlled.png", [
((ILL_PB_FRONT_X, CENTER_Y), RADIUS),
((ILL_PB_REAR_X, CENTER_Y + 15.0), RADIUS),
((ILL_PB_REAR_X, CENTER_Y - 15.0), RADIUS),
])
sim.close()
np.savez_compressed(out_dir / "signals.npz",
sensors=sig_s, forces=sig_f, actions=sig_a)
with open(out_dir / "metrics.json", "w") as f:
json.dump({"dtw_sim_v5": -1.0}, f, indent=2)
log(" Done.")
# ---------------------------------------------------------------------------
# Vortex
# ---------------------------------------------------------------------------
def run_vortex_reproduce(scene: Dict[str, Any], device_id: int, out_dir: Path) -> None:
scene_id = scene["scene_id"]
vortex_type = scene["vortex_type"]
model_name = scene["model_name"]
SI = scene["si"]
num_steps = scene["num_steps"]
scale = scene["action_scale"]
bias = scene["action_bias"]
vortex_strength = scene["vortex_strength_factor"] * U0
log(f"=== Reproduce: Vortex {vortex_type} ===")
out_dir.mkdir(parents=True, exist_ok=True)
ref_dir = str(SR_DATA / "vortex" / scene_id)
norm = load_legacy_norm(ref_dir)
# Stage 1: Sensors only, record target with vortex at x=10
log(" Stage 1: Recording target with vortex...")
sim = Simulation(lbm_config_path=scene["config_path"], device_id=device_id)
sensor_ids = [
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0),
]
sim.initialize()
sim.run(WARMUP, zero_obs=True)
cc = get_cc(sim, sensor_ids[0])
sim.snapshot()
add_vortex(sim.field, center=(10.0 * L0, CENTER_Y), radius=VORTEX_RADIUS,
strength=vortex_strength, vortex_type=vortex_type)
target_states = np.zeros((FIFO_LEN, 6), dtype=np.float32)
for i in range(FIFO_LEN):
sim.run(SI, zero_obs=True)
obs = read_obs_6obj(sim, sensor_ids, [], cc)
target_states[i] = obs
np.savez_compressed(out_dir / "target.npz", target_states=target_states)
# Stage 2: Add pinball + vortex at x=15
log(" Stage 2: Adding pinball + vortex at x=15...")
sim.restore()
n0 = sim.bodies.count
sim.add_body("circle", center=(PB_FRONT_X, CENTER_Y, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS)
sim.add_body("circle", center=(PB_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS)
sim.sync_bodies()
fid, tid, bid = list(range(n0, n0 + 3))
bias_surf = np.array([0.0, -4.0, 4.0], dtype=np.float32) * U0
bias_omega = -bias_surf / RADIUS
ema_init = ActionSmoother(weight=0.1); ema_init.reset(np.zeros(3, dtype=np.float32))
for _ in range(100):
s = ema_init(bias_omega)
sim.set_body(fid, omega=s[0]); sim.set_body(tid, omega=s[1]); sim.set_body(bid, omega=s[2])
sim.run(SI, zero_obs=True)
add_vortex(sim.field, center=(15.0 * L0, CENTER_Y), radius=VORTEX_RADIUS,
strength=vortex_strength, vortex_type=vortex_type)
sim.snapshot()
# Bias FIFO
sim.restore()
ema_b = ActionSmoother(weight=0.1); ema_b.reset(np.zeros(3, dtype=np.float32))
for _ in range(FIFO_LEN):
s = ema_b(bias_omega)
sim.set_body(fid, omega=s[0]); sim.set_body(tid, omega=s[1]); sim.set_body(bid, omega=s[2])
sim.run(SI, zero_obs=True)
sim.snapshot()
# DRL inference
log(" Stage 3: DRL inference...")
sim.restore()
ema = ActionSmoother(weight=0.1); ema.reset(bias_omega.copy())
model = ModelInventory().load(model_name, device="cpu")
log(f" Model: {model_name} on CPU")
obs_init = read_obs_6obj(sim, sensor_ids, [fid, tid, bid], cc)
obs_norm = normalize_obs(obs_init, norm)
sig_s = np.zeros((num_steps, 6), dtype=np.float32)
sig_f = np.zeros((num_steps, 6), dtype=np.float32)
sig_a = np.zeros((num_steps, 3), dtype=np.float32)
for step in range(num_steps):
action, _ = model.predict(obs_norm, deterministic=True)
action = np.asarray(action, dtype=np.float32).flatten()
target_omega = action_to_omega_legacy(action, scale=scale, bias=bias)
smoothed = ema(target_omega)
sim.set_body(fid, omega=smoothed[0]); sim.set_body(tid, omega=smoothed[1]); sim.set_body(bid, omega=smoothed[2])
sim.run(SI, zero_obs=True)
obs = read_obs_6obj(sim, sensor_ids, [fid, tid, bid], cc)
sig_s[step] = obs[0:6]
sig_f[step] = obs[6:12]
sig_a[step] = action
forces_n = obs[6:12] / norm["force_norm_fact"]
sens_n = (obs[0:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
obs_norm = np.clip(np.hstack([forces_n, sens_n]), -1.0, 1.0).astype(np.float32)
save_vorticity(sim, out_dir / "vorticity_controlled.png", [
((PB_FRONT_X, CENTER_Y), RADIUS),
((PB_REAR_X, CENTER_Y + 15.0), RADIUS),
((PB_REAR_X, CENTER_Y - 15.0), RADIUS),
])
sim.close()
np.savez_compressed(out_dir / "signals.npz",
sensors=sig_s, forces=sig_f, actions=sig_a)
with open(out_dir / "metrics.json", "w") as f:
json.dump({"dtw_sim_v5": -1.0}, f, indent=2)
log(" Done.")
# ---------------------------------------------------------------------------
# Scene dispatcher
# ---------------------------------------------------------------------------
SCENE_RUNNERS = {
"karman_cloak": run_karman_reproduce,
"steady_cloak": run_steady_reproduce,
"illusion_075L": run_illusion_reproduce,
"illusion_1L": run_illusion_reproduce,
"illusion_15L": run_illusion_reproduce,
"vortex_lamb": run_vortex_reproduce,
"vortex_taylor": run_vortex_reproduce,
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Evaluate reproduce models")
parser.add_argument("--device-id", type=int, default=1)
parser.add_argument("--scene", type=str, default=None,
help="Run single scene (e.g. karman_cloak, illusion_1L)")
args = parser.parse_args()
log(f"GPU: {args.device_id}")
scenes = REPRODUCE_SCENES
if args.scene:
key = args.scene
scenes = [s for s in REPRODUCE_SCENES if s["scene_id"] == key]
if not scenes:
log(f"ERROR: No scene matching '{args.scene}'")
return 1
for scene in scenes:
scene_id = scene["scene_id"]
out_dir = _OUT_BASE / scene_id
runner = SCENE_RUNNERS.get(scene_id)
if runner is None:
log(f"WARNING: No runner for scene {scene_id}, skipping.")
continue
runner(scene, args.device_id, out_dir)
log("All reproduce scenes complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+472
View File
@@ -0,0 +1,472 @@
#!/usr/bin/env python3
"""V5 Train model inference engine — skeleton injection with per-seed VecNormalize.
For each scene in TRAIN_SCENES:
1. Create V5 CFD env (KarmanCloakEnv / IllusionCloakEnv)
2. For every seed: fresh skeleton + VecNormalize.load(seed.pkl) + weight injection
3. 360-step deterministic rollout, pick best by tail-180 reward
4. Save signals.npz + vorticity PNGs (controlled, target, zero)
Uses skeleton injection per-seed (one skeleton per seed, one VecNormalize load per seed)
to avoid cloudpickle/numpy PPO.load() deserialization issues while still using
trained VecNormalize stats for proper observation normalization.
Usage:
conda run -n pycuda_3_10 python -u infer_train.py --device-id 0
conda run -n pycuda_3_10 python -u infer_train.py --device-id 0 --scene re100
"""
from __future__ import annotations
import argparse
import io
import json
import os
import sys
import time
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Tuple
import numpy as np
import pycuda.driver as cuda; cuda.init()
_REPO = str(Path(__file__).resolve().parents[3])
_SRC = Path(_REPO) / "src"
for p in [_REPO, str(_SRC)]:
if p not in sys.path:
sys.path.insert(0, p)
import torch
from torch.nn import Module as TorchModule
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from CelerisLab.common.render import compute_vorticity, render_vorticity_field
_TRAIN_DIR = _SRC / "drl_pinball" / "train"
sys.path.insert(0, str(_TRAIN_DIR))
from symmetry_wrapper import SymmetryAugmentWrapper
from drl_pinball.eval.scene_manifest import TRAIN_SCENES
_OUT_BASE = Path(__file__).resolve().parent / "output" / "train"
class Sin(TorchModule):
def __init__(self): super().__init__()
def forward(self, x): return torch.sin(x)
_device = None # set at startup
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
# ---------------------------------------------------------------------------
# Env creation
# ---------------------------------------------------------------------------
def _create_env(scene: Dict[str, Any], device_id: int):
scene_type = scene["scene_type"]
config_path = Path(scene["config_path"]).resolve()
cal_path = Path(scene["calibration_path"]).resolve()
si = scene["si"]
with open(cal_path) as f:
cal = json.load(f)
cal["SI"] = si
cal_dir = cal_path.parent
target_path = cal_dir / "target.npy"
if not target_path.exists():
raise FileNotFoundError(f"target.npy not found at {target_path}")
target_states = np.load(str(target_path))
if scene_type == "karman":
from env_karman import KarmanCloakEnv
log(f" Creating KarmanCloakEnv (config={config_path.name}, SI={si})...")
t0 = time.perf_counter()
env = KarmanCloakEnv(
device_id=device_id, seed=41,
calibration=cal, config_path=str(config_path),
target_states=target_states,
)
log(f" Env ready in {time.perf_counter() - t0:.0f}s")
return env
elif scene_type == "illusion":
from env_illusion import IllusionCloakEnv
target_harmonics_path = cal_dir / "target_harmonics.json"
if not target_harmonics_path.exists():
raise FileNotFoundError(f"target_harmonics.json not found at {target_harmonics_path}")
with open(target_harmonics_path) as f:
target_harmonics = json.load(f)
target_diam = float(scene.get("target_diam", 1.0))
cal["target_diam"] = target_diam
log(f" Creating IllusionCloakEnv (config={config_path.name}, diam={target_diam}L, SI={si})...")
t0 = time.perf_counter()
env = IllusionCloakEnv(
device_id=device_id, seed=41,
calibration=cal, config_path=str(config_path),
target_states=target_states,
target_harmonics=target_harmonics,
target_diam=target_diam,
)
log(f" Env ready in {time.perf_counter() - t0:.0f}s")
return env
else:
raise ValueError(f"Unknown scene_type: {scene_type}")
# ---------------------------------------------------------------------------
# Extract policy weights from SB3 .zip
# ---------------------------------------------------------------------------
def _extract_state_dict(zip_path: str) -> dict:
with zipfile.ZipFile(zip_path, "r") as zf:
with zf.open("policy.pth") as f:
return torch.load(io.BytesIO(f.read()), map_location="cpu",
weights_only=False)
# ---------------------------------------------------------------------------
# Raw env extraction (walk wrapper chain)
# ---------------------------------------------------------------------------
def _get_raw_env(vec_env):
dummy = vec_env.venv
inner = dummy.envs[0]
if hasattr(inner, 'prob'):
return inner.env
return inner
# ---------------------------------------------------------------------------
# Saving
# ---------------------------------------------------------------------------
def _save_vorticity(env, out_path: Path, scene: Dict[str, Any]) -> None:
sim = env.sim
macro = sim.get_macroscopic()
vort = compute_vorticity(macro["ux"], macro["uy"])
nx = int(sim.lbm_cfg.nx)
ny = int(sim.lbm_cfg.ny)
cylinders = []
for body_id in range(sim.bodies.count):
body = sim.bodies.get(body_id)
if hasattr(body, 'center'):
c = body.center
r = body.radius if hasattr(body, 'radius') else 10.0
cylinders.append(((c[0], c[1]), r))
render_vorticity_field(vort, nx=nx, ny=ny, out_path=str(out_path),
cylinders=cylinders, vmin=-0.03, vmax=0.03)
# ---------------------------------------------------------------------------
# Per-seed eval — correct pattern: PPO.load(no env) + VecNormalize(env, training=False)
# ---------------------------------------------------------------------------
def _eval_one_seed(env, seed_label: str, seed_dir_str: str,
num_steps: int) -> Dict[str, Any]:
"""Load model WITHOUT env, wrap env with VecNormalize.load (single layer, frozen)."""
seed_dir = Path(seed_dir_str)
model_path = seed_dir / "best_model.zip"
norm_path = seed_dir.parent / "vec_normalize.pkl"
if not model_path.exists():
log(f" [seed {seed_label}] SKIP: no best_model.zip")
return {"seed": seed_label, "avg_reward": -float("inf"), "skip": True}
if not norm_path.exists():
log(f" [seed {seed_label}] SKIP: no vec_normalize.pkl")
return {"seed": seed_label, "avg_reward": -float("inf"), "skip": True}
log(f" [seed {seed_label}] loading...")
# All seeds: PPO.load fails (numpy._core.numeric cloudpickle issue).
# Use skeleton injection directly — avoids GPU pollution from failed load.
wrapped = SymmetryAugmentWrapper(env, prob=0.0, seed=41, rollout_len=2048)
vec_env = DummyVecEnv([lambda: wrapped])
vec_env = VecNormalize.load(str(norm_path), vec_env)
vec_env.norm_reward = False
skeleton = PPO(
"MlpPolicy",
policy_kwargs={"activation_fn": Sin, "net_arch": [64, 64]},
env=vec_env, device=_device,
n_steps=2048, batch_size=64, n_epochs=10,
learning_rate=3e-4, gamma=0.995, verbose=0,
)
sd = _extract_state_dict(str(model_path))
skeleton.policy.load_state_dict(sd, strict=False)
raw_env = _get_raw_env(vec_env)
sens_cc = float(raw_env._cal.get("SENSOR_CC", 78.0))
return _rollout(skeleton, vec_env, raw_env, sens_cc, seed_label, num_steps)
def _rollout(model, vec_env, raw_env, sens_cc, seed_label, num_steps):
"""Deterministic rollout, model.predict receives normalized obs from vec_env."""
ep_rew, ep_r_cd, ep_r_cl, ep_r_sim, ep_sim_raw = [], [], [], [], []
sig_s, sig_f, sig_a = [], [], []
obs = vec_env.reset()
for step in range(num_steps):
action, _ = model.predict(obs, deterministic=True)
obs, reward, done, info = vec_env.step(action)
inf = info[0] if isinstance(info, list) else info
ep_rew.append(float(reward[0]))
ep_r_cd.append(float(inf.get("r_cd", 0)))
ep_r_cl.append(float(inf.get("r_cl", 0)))
ep_r_sim.append(float(inf.get("r_sim", 0)))
ep_sim_raw.append(float(inf.get("sim", 0)))
try:
raw = raw_env._read_obs()
if "Karman" in type(raw_env).__name__:
sl = raw[2:14]
else:
sl = raw[:]
sig_s.append(sl[0:6] * sens_cc)
sig_f.append(sl[6:12])
except Exception:
sig_s.append(np.zeros(6, dtype=np.float32))
sig_f.append(np.zeros(6, dtype=np.float32))
sig_a.append(np.asarray(action, dtype=np.float32).flatten())
if done[0]:
break
tail = 180
sl = slice(-tail, None) if len(ep_rew) >= tail else slice(None)
avg_r = float(np.mean(ep_rew[sl]))
log(f" reward={avg_r:.4f} r_cd={float(np.mean(ep_r_cd[sl])):.3f} "
f"r_cl={float(np.mean(ep_r_cl[sl])):.3f} "
f"r_sim={float(np.mean(ep_r_sim[sl])):.3f} "
f"sim_raw={float(np.mean(ep_sim_raw[sl])):.3f}")
return {
"seed": seed_label,
"avg_reward": avg_r,
"r_cd": float(np.mean(ep_r_cd[sl])), "r_cl": float(np.mean(ep_r_cl[sl])),
"r_sim": float(np.mean(ep_r_sim[sl])),
"sim_raw": float(np.mean(ep_sim_raw[sl])),
"sensors": np.array(sig_s, dtype=np.float32),
"forces": np.array(sig_f, dtype=np.float32),
"actions": np.array(sig_a, dtype=np.float32),
"rewards": np.array(ep_rew, dtype=np.float32),
"skip": False,
}
# ---------------------------------------------------------------------------
# Per-scene pipeline
# ---------------------------------------------------------------------------
def evaluate_train_scene(scene: Dict[str, Any], device_id: int) -> None:
scene_id = scene["scene_id"]
num_steps = scene["num_steps"]
out_dir = _OUT_BASE / scene_id
out_dir.mkdir(parents=True, exist_ok=True)
log(f"\n{'='*70}")
log(f" Train eval: {scene_id} (SI={scene['si']}, steps={num_steps})")
log(f"{'='*70}")
# Create CFD env once, reused across seeds
env = _create_env(scene, device_id)
# Evaluate each seed (independent skeleton + VecNormalize per seed)
all_results = []
for seed_label, seed_dir in scene["seeds"]:
t0 = time.perf_counter()
result = _eval_one_seed(env, seed_label, seed_dir, num_steps)
result["_dt"] = time.perf_counter() - t0
all_results.append(result)
# Pick best seed
valid = [r for r in all_results if not r.get("skip", False)]
if not valid:
log(" WARNING: No valid seeds found, skipping scene.")
env.close()
return
best = max(valid, key=lambda r: r["avg_reward"])
log(f" Best: seed={best['seed']}, reward={best['avg_reward']:.4f}")
# Save all-seeds summary
summary = [{"seed": r["seed"], "reward": r["avg_reward"],
"r_cd": r.get("r_cd", 0), "r_cl": r.get("r_cl", 0),
"r_sim": r.get("r_sim", 0), "sim_raw": r.get("sim_raw", 0),
"dt_sec": r.get("_dt", 0)} for r in all_results]
with open(out_dir / "all_seeds.json", "w") as f:
json.dump(summary, f, indent=2)
# Save best seed's full signals
np.savez_compressed(out_dir / "signals.npz",
sensors=best["sensors"], forces=best["forces"],
actions=best["actions"], rewards=best["rewards"])
# Vorticity: re-create env, load best seed skeleton, run, capture
log(" Generating vorticity for best seed...")
env2 = _create_env(scene, device_id)
seed_dir = Path(next(s[1] for s in scene["seeds"] if s[0] == best["seed"]))
norm_path = seed_dir.parent / "vec_normalize.pkl"
model_path = seed_dir / "best_model.zip"
wrapped2 = SymmetryAugmentWrapper(env2, prob=0.0, seed=41, rollout_len=2048)
vec_env2 = DummyVecEnv([lambda: wrapped2])
vec_env2 = VecNormalize.load(str(norm_path), vec_env2)
vec_env2.norm_reward = False
skeleton2 = PPO(
"MlpPolicy",
policy_kwargs={"activation_fn": Sin, "net_arch": [64, 64]},
env=vec_env2, device=_device,
n_steps=2048, batch_size=64, n_epochs=10,
learning_rate=3e-4, gamma=0.995, verbose=0,
)
sd = _extract_state_dict(str(model_path))
skeleton2.policy.load_state_dict(sd, strict=False)
obs = vec_env2.reset()
for _ in range(num_steps):
action, _ = skeleton2.predict(obs, deterministic=True)
obs, reward, done, info = vec_env2.step(action)
if done[0]:
break
_save_vorticity(env2, out_dir / "vorticity_controlled.png", scene)
env2.close()
# Target vorticity
log(" Generating target vorticity...")
_generate_target_vorticity(scene, device_id, out_dir)
# Zero-action baseline
log(" Generating zero-action baseline...")
env3 = _create_env(scene, device_id)
zero_omega = env3._action_to_omega(np.zeros(3, dtype=np.float32))
for _ in range(num_steps):
smoothed = env3.smoother(zero_omega)
env3._set_omega(smoothed)
env3._gpu_block(lambda: env3.sim.run(scene["si"], zero_obs=True))
_save_vorticity(env3, out_dir / "vorticity_zero.png", scene)
env3.close()
# Metrics
metrics = {
"best_seed": best["seed"],
"dtw_sim_v5": float(best.get("sim_raw", 0)),
"reward_mean": float(best["avg_reward"]),
"r_cd_mean": float(best.get("r_cd", 0)),
"r_cl_mean": float(best.get("r_cl", 0)),
"r_sim_mean": float(best.get("r_sim", 0)),
"sim_raw_mean": float(best.get("sim_raw", 0)),
"aF_mean": float(np.mean(best["actions"][:, 0])),
"aB_mean": float(np.mean(best["actions"][:, 1])),
"aT_mean": float(np.mean(best["actions"][:, 2])),
}
with open(out_dir / "metrics.json", "w") as f:
json.dump(metrics, f, indent=2)
env.close()
log(f" {scene_id} complete.")
def _generate_target_vorticity(scene: Dict[str, Any], device_id: int,
out_dir: Path) -> None:
from CelerisLab import Simulation
scene_type = scene["scene_type"]
config_path = Path(scene["config_path"]).resolve()
si = scene["si"]
warmup = int(4.0 * 2000 / 0.01)
L0 = 20.0
U0 = 0.01
sim = Simulation(lbm_config_path=str(config_path), device_id=device_id)
if scene_type == "karman":
with open(scene["calibration_path"]) as f:
cal = json.load(f)
dist_radius = float(cal.get("dist_radius", 1.0)) * L0
nx_cfg = int(cal.get("grid", {}).get("nx", 2000))
ny_cfg = int(cal.get("grid", {}).get("ny", 600))
CENTER_Y = float(ny_cfg - 1) / 2.0
DIST_X = 600.0
SENS_X = 1200.0
sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=dist_radius)
sim.add_body("sensor", center=(SENS_X, CENTER_Y + 40.0, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENS_X, CENTER_Y, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENS_X, CENTER_Y - 40.0, 0.0), radius=5.0)
elif scene_type == "illusion":
with open(scene["calibration_path"]) as f:
cal = json.load(f)
target_diam = float(scene.get("target_diam", 1.0))
nx_cfg = int(cal.get("grid", {}).get("nx", 2000))
ny_cfg = int(cal.get("grid", {}).get("ny", 600))
CENTER_Y = float(ny_cfg - 1) / 2.0
TARGET_X = 400.0
SENS_X = 600.0
sim.add_body("circle", center=(TARGET_X, CENTER_Y, 0.0), radius=target_diam * L0)
sim.add_body("sensor", center=(SENS_X, CENTER_Y + 40.0, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENS_X, CENTER_Y, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENS_X, CENTER_Y - 40.0, 0.0), radius=5.0)
else:
sim.close()
return
sim.initialize()
sim.run(warmup, zero_obs=True)
for _ in range(30):
sim.run(si, zero_obs=True)
macro = sim.get_macroscopic()
vort = compute_vorticity(macro["ux"], macro["uy"])
cylinders = [
((ctr[0], ctr[1]), rad)
for body in [sim.bodies.get(i) for i in range(sim.bodies.count)]
if hasattr(body, 'center')
for ctr, rad in [((body.center[0], body.center[1]),
body.radius if hasattr(body, 'radius') else 10.0)]
]
render_vorticity_field(vort, nx=nx_cfg, ny=ny_cfg,
out_path=str(out_dir / "vorticity_target.png"),
cylinders=cylinders, vmin=-0.03, vmax=0.03)
sim.close()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
global _device
parser = argparse.ArgumentParser(description="Evaluate V5 train models")
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--scene", type=str, default=None)
args = parser.parse_args()
_device = torch.device(f"cuda:{args.device_id}")
log(f"GPU: {args.device_id}, torch device: {_device}")
scenes = TRAIN_SCENES
if args.scene:
key = args.scene
scenes = [s for s in TRAIN_SCENES if s["scene_id"] == key
or s["scene_id"].startswith(key)]
if not scenes:
log(f"ERROR: No scene matching '{args.scene}'")
return 1
for i, scene in enumerate(scenes):
if i > 0:
prev_cfg = scenes[i - 1]["config_path"]
curr_cfg = scene["config_path"]
if prev_cfg != curr_cfg:
log(f"Waiting 120s before config switch...")
time.sleep(120)
evaluate_train_scene(scene, args.device_id)
log("All train scenes complete.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+126
View File
@@ -0,0 +1,126 @@
#!/bin/bash
# run_all.sh — Master launcher for eval benchmark.
#
# GPU 0: Train pipeline (7 scenes)
# GPU 1: Reproduce pipeline (7 scenes, 120s stagger)
#
# Each pipeline saves outputs to eval/output/{train,reproduce}/.
# After both complete, run generate_report.py and viz_*.py.
#
# Usage:
# conda activate pycuda_3_10
# bash run_all.sh
#
# Single scene mode:
# bash run_all.sh --train-scene re100 --gpu 0
# bash run_all.sh --repro-scene karman_cloak --gpu 1
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TRAIN_SCRIPT="$SCRIPT_DIR/infer_train.py"
REPRO_SCRIPT="$SCRIPT_DIR/infer_reproduce.py"
CONDA_ENV="pycuda_3_10"
TRAIN_GPU=0
REPRO_GPU=1
TRAIN_SCENE=""
REPRO_SCENE=""
usage() {
echo "Usage: $0 [--train-scene SCENE] [--repro-scene SCENE] [--gpu N]"
echo " --train-scene Run single train scene (e.g. re100, illusion_1L)"
echo " --repro-scene Run single reproduce scene (e.g. karman_cloak)"
echo " --gpu GPU device ID"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--train-scene) TRAIN_SCENE="$2"; shift 2 ;;
--repro-scene) REPRO_SCENE="$2"; shift 2 ;;
--gpu) TRAIN_GPU="$2"; REPRO_GPU="$2"; shift 2 ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# Create output dirs
mkdir -p "$SCRIPT_DIR/output/train"
mkdir -p "$SCRIPT_DIR/output/reproduce"
echo "=== Eval Benchmark ==="
echo " Train GPU: $TRAIN_GPU"
echo " Reproduce GPU: $REPRO_GPU"
echo " Conda env: $CONDA_ENV"
echo " Script dir: $SCRIPT_DIR"
echo ""
# ---------------------------------------------------------------------------
# Launch Reproduce pipeline (GPU 1, staggered)
# ---------------------------------------------------------------------------
launch_reproduce() {
echo "[$(date '+%H:%M:%S')] Launching Reproduce pipeline on GPU $REPRO_GPU..."
local arg=""
if [[ -n "$REPRO_SCENE" ]]; then
arg="--scene $REPRO_SCENE"
fi
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$REPRO_SCRIPT" --device-id "$REPRO_GPU" $arg \
2>&1 | tee "$SCRIPT_DIR/output/reproduce/run.log"
echo "[$(date '+%H:%M:%S')] Reproduce pipeline complete."
}
# Start reproduce after 120s delay (avoids kernel compilation race with GPU 0)
if [[ -z "$TRAIN_SCENE" ]] && [[ -z "$REPRO_SCENE" ]]; then
# Full run: launch reproduce in background with delay
(sleep 120 && launch_reproduce) &
REPRO_PID=$!
echo " Reproduce scheduled in 120s (PID=$REPRO_PID)"
else
# Single scene mode: just run the requested pipeline
if [[ -n "$REPRO_SCENE" ]]; then
launch_reproduce
exit 0
fi
fi
# ---------------------------------------------------------------------------
# Train pipeline (GPU 0)
# ---------------------------------------------------------------------------
echo "[$(date '+%H:%M:%S')] Launching Train pipeline on GPU $TRAIN_GPU..."
TRAIN_ARG=""
if [[ -n "$TRAIN_SCENE" ]]; then
TRAIN_ARG="--scene $TRAIN_SCENE"
fi
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_SCRIPT" --device-id "$TRAIN_GPU" $TRAIN_ARG \
2>&1 | tee "$SCRIPT_DIR/output/train/run.log"
echo "[$(date '+%H:%M:%S')] Train pipeline complete."
# Wait for reproduce if it was launched
if [[ -n "${REPRO_PID:-}" ]]; then
echo "[$(date '+%H:%M:%S')] Waiting for Reproduce pipeline (PID=$REPRO_PID)..."
wait $REPRO_PID
fi
# ---------------------------------------------------------------------------
# Generate reports
# ---------------------------------------------------------------------------
echo ""
echo "[$(date '+%H:%M:%S')] Both pipelines complete. Generating reports..."
echo " -> Signal plots..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$SCRIPT_DIR/viz_signals.py" 2>&1 | tail -5
echo " -> Vorticity panels..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$SCRIPT_DIR/viz_flow.py" 2>&1 | tail -5
echo " -> Summary report..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$SCRIPT_DIR/generate_report.py" 2>&1 | tail -10
echo ""
echo "=== ALL DONE ==="
echo "Reports in: $SCRIPT_DIR/output/reports/"
+271
View File
@@ -0,0 +1,271 @@
"""Scene manifest — single source of truth for all benchmark scenes.
Two pipelines:
TRAIN_SCENES — V5 PPO models on new 2000x600 config (uniform, free-slip).
REPRODUCE_SCENES — legacy PPO models on old 1280x512 config (parabolic, bounce-back).
Usage:
from eval.scene_manifest import TRAIN_SCENES, REPRODUCE_SCENES
Each scene dict contains:
scene_id, config_path, calibration_path, si, num_steps, scene_type
seeds: list of (seed_label, model_dir) tuples
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, List
_REPO = Path(__file__).resolve().parents[3]
_TRAIN_DIR = _REPO / "src" / "drl_pinball" / "train"
_CONFIGS_DIR = _REPO / "configs"
KARMAN_CFG = str(_CONFIGS_DIR / "config_lbm_karman_2000x600.json")
KARMAN_CFG_RE60 = str(_CONFIGS_DIR / "config_lbm_karman_2000x600_re60.json")
KARMAN_CFG_RE200 = str(_CONFIGS_DIR / "config_lbm_karman_2000x600_re200.json")
KARMAN_CFG_RE400 = str(_CONFIGS_DIR / "config_lbm_karman_2000x600_re400.json")
PINBALL_CFG = str(_CONFIGS_DIR / "config_lbm_pinball.json")
def _model_dir(case_name: str, seed: int) -> str:
"""Path to model output directory for a trained case."""
return str(_TRAIN_DIR / "output" / f"{case_name}_seed{seed}" / "models")
def _cal_path(name: str) -> str:
return str(_TRAIN_DIR / "calibrations" / name / "calibration.json")
# =============================================================================
# TRAIN SCENES (V5 PPO, 2000x600)
#
# Cloud naming: {kar|ill}_{case}_{sc|tr}_seed{N}
# sc = scratch, tr = transfer
# =============================================================================
TRAIN_SCENES: List[Dict[str, Any]] = [
# -- Karman Cloak Re100 scratch (5 seeds, best model) --
{
"scene_id": "kar_re100_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("kar_re100"),
"si": 800,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("41", _model_dir("kar_re100_sc", 41)),
("42", _model_dir("kar_re100_sc", 42)),
("43", _model_dir("kar_re100_sc", 43)),
("44", _model_dir("kar_re100_sc", 44)),
("45", _model_dir("kar_re100_sc", 45)),
],
},
# -- Cross-Re Re60 scratch + transfer --
{
"scene_id": "kar_re60_tr",
"config_path": KARMAN_CFG_RE60,
"calibration_path": _cal_path("kar_re60"),
"si": 800,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("43", _model_dir("kar_re60_tr", 43)),
],
},
# -- Cross-Re Re200 scratch + transfer --
{
"scene_id": "kar_re200_tr",
"config_path": KARMAN_CFG_RE200,
"calibration_path": _cal_path("kar_re200"),
"si": 500,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("43", _model_dir("kar_re200_tr", 43)),
],
},
# -- Cross-Re Re400 scratch + transfer --
{
"scene_id": "kar_re400_tr",
"config_path": KARMAN_CFG_RE400,
"calibration_path": _cal_path("kar_re400"),
"si": 400,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("43", _model_dir("kar_re400_tr", 43)),
],
},
# -- VarDist d075 scratch --
{
"scene_id": "kar_d075_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("kar_d075_sc"),
"si": 800,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("44", _model_dir("kar_d075_sc", 44)),
],
},
# -- VarDist d15 scratch --
{
"scene_id": "kar_d15_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("kar_d15_sc"),
"si": 800,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("45", _model_dir("kar_d15_sc", 45)),
],
},
# -- VarDist d2 scratch --
{
"scene_id": "kar_d2_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("kar_d2_sc"),
"si": 800,
"num_steps": 360,
"scene_type": "karman",
"seeds": [
("45", _model_dir("kar_d2_sc", 45)),
],
},
# -- Illusion 0.75L scratch --
{
"scene_id": "ill_075L_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("ill_075L"),
"si": 400,
"num_steps": 360,
"scene_type": "illusion",
"target_diam": 0.75,
"seeds": [
("43", _model_dir("ill_075L_sc", 43)),
],
},
# -- Illusion 1.0L scratch --
{
"scene_id": "ill_1L_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("ill_1L"),
"si": 600,
"num_steps": 360,
"scene_type": "illusion",
"target_diam": 1.0,
"seeds": [
("43", _model_dir("ill_1L_sc", 43)),
],
},
# -- Illusion 1.5L scratch --
{
"scene_id": "ill_15L_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("ill_15L"),
"si": 800,
"num_steps": 360,
"scene_type": "illusion",
"target_diam": 1.5,
"seeds": [
("43", _model_dir("ill_15L_sc", 43)),
],
},
# -- Illusion 2.0L scratch (new!) --
{
"scene_id": "ill_2L_sc",
"config_path": KARMAN_CFG,
"calibration_path": _cal_path("ill_2L"),
"si": 800,
"num_steps": 360,
"scene_type": "illusion",
"target_diam": 2.0,
"seeds": [
("43", _model_dir("ill_2L_sc", 43)),
],
},
]
# =============================================================================
# REPRODUCE SCENES (legacy PPO, 1280x512)
# =============================================================================
REPRODUCE_SCENES: List[Dict[str, Any]] = [
{
"scene_id": "karman_cloak",
"config_path": PINBALL_CFG,
"si": 800,
"num_steps": 200,
"scene_type": "karman",
"model_name": "d1a3o12_re100",
"action_scale": 8.0,
"action_bias": (0.0, -4.0, 4.0),
},
{
"scene_id": "steady_cloak",
"config_path": PINBALL_CFG,
"si": 800,
"num_steps": 200,
"scene_type": "steady",
"model_name": None, # open-loop [0, -5.1, 5.1]*U0
"action_scale": 8.0,
"action_bias": (0.0, -4.0, 4.0),
"open_loop_surf_vel": (0.0, -5.1, 5.1),
},
{
"scene_id": "illusion_075L",
"config_path": PINBALL_CFG,
"si": 400,
"num_steps": 200,
"scene_type": "illusion",
"model_name": "d1a3o14_250525_imit_075L_2U_400S",
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"target_diam": 0.75,
},
{
"scene_id": "illusion_1L",
"config_path": PINBALL_CFG,
"si": 600,
"num_steps": 200,
"scene_type": "illusion",
"model_name": "d1a3o14_250525_imit_1L_2U_600S",
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"target_diam": 1.0,
},
{
"scene_id": "illusion_15L",
"config_path": PINBALL_CFG,
"si": 800,
"num_steps": 200,
"scene_type": "illusion",
"model_name": "d1a3o14_250525_imit_15L_2U",
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"target_diam": 1.5,
},
{
"scene_id": "vortex_lamb",
"config_path": PINBALL_CFG,
"si": 800,
"num_steps": 150,
"scene_type": "vortex",
"model_name": "vortex_lamb",
"action_scale": 4.0,
"action_bias": (0.0, -4.0, 4.0),
"vortex_type": "lamb",
"vortex_strength_factor": 0.5,
},
{
"scene_id": "vortex_taylor",
"config_path": PINBALL_CFG,
"si": 800,
"num_steps": 150,
"scene_type": "vortex",
"model_name": "vortex_taylor",
"action_scale": 4.0,
"action_bias": (0.0, -4.0, 4.0),
"vortex_type": "taylor",
"vortex_strength_factor": 0.03,
},
]
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""Flow field visualization — vorticity panels and error maps.
Works on pre-saved vorticity PNGs or raw Macroscopic data.
All panels use unified [-0.03, 0.03] vorticity range.
Usage:
conda run -n pycuda_3_10 python viz_flow.py [--scene re100]
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import List, Optional, Tuple
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
_OUT_BASE = Path(__file__).resolve().parent / "output"
def log(msg: str) -> None:
print(f"[viz_flow] {msg}", flush=True)
def _find_vorticity_files(scene_dir: Path) -> dict:
"""Scan scene output dir for vorticity images. Returns dict keyed by label."""
files = {}
for name in ["vorticity_target", "vorticity_controlled", "vorticity_zero",
"vorticity_uncontrolled"]:
png = scene_dir / f"{name}.png"
if png.exists():
files[name.replace("vorticity_", "")] = png
return files
def make_comparison_panel(scene_name: str, train_dir: Optional[Path],
repro_dir: Optional[Path], out_path: Path) -> None:
"""Create a multi-panel vorticity comparison for one scene.
Layout: rows = [repro, train], cols = [target, controlled, zero]
"""
panels: List[Tuple[str, Path]] = []
if repro_dir and repro_dir.exists():
rep_files = _find_vorticity_files(repro_dir)
for label, p in rep_files.items():
panels.append((f"Repro-{label}", p))
if train_dir and train_dir.exists():
trn_files = _find_vorticity_files(train_dir)
for label, p in trn_files.items():
panels.append((f"Train-{label}", p))
if not panels:
log(f" {scene_name}: no vorticity images found, skipping")
return
n = len(panels)
cols = min(3, n)
rows = (n + cols - 1) // cols
fig, axes = plt.subplots(rows, cols, figsize=(6 * cols, 4.5 * rows))
if rows * cols == 1:
axes = np.array([[axes]])
elif rows == 1:
axes = axes.reshape(1, -1)
elif cols == 1:
axes = axes.reshape(-1, 1)
for idx, (label, img_path) in enumerate(panels):
r, c = divmod(idx, cols)
ax = axes[r, c]
ax.imshow(plt.imread(str(img_path)))
ax.set_title(label, fontsize=10)
ax.axis("off")
# Hide unused axes
for idx in range(len(panels), rows * cols):
r, c = divmod(idx, cols)
axes[r, c].axis("off")
fig.suptitle(f"{scene_name} - Vorticity Comparison", fontsize=14)
fig.tight_layout()
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
log(f" {scene_name}: {n} panels saved to {out_path.name}")
def make_side_by_side(scene_name: str, train_dir: Optional[Path],
repro_dir: Optional[Path], out_path: Path) -> None:
"""Simple side-by-side: repro controlled | train controlled."""
images = []
labels = []
for label, d in [("Reproduce", repro_dir), ("Train", train_dir)]:
if d is None or not d.exists():
continue
for name in ["controlled", "zero"]:
png = d / f"vorticity_{name}.png"
if png.exists():
images.append(png)
labels.append(f"{label} ({name})")
break # take first available
if len(images) < 2:
return
fig, axes = plt.subplots(1, len(images), figsize=(6 * len(images), 4.5))
if len(images) == 1:
axes = [axes]
for ax, img_path, label in zip(axes, images, labels):
ax.imshow(plt.imread(str(img_path)))
ax.set_title(label, fontsize=11)
ax.axis("off")
fig.suptitle(f"{scene_name}", fontsize=13)
fig.tight_layout()
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
log(f" {scene_name}: side-by-side saved")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Vorticity visualization")
parser.add_argument("--scene", type=str, default=None,
help="Single scene to process")
parser.add_argument("--side", type=str, default="all",
choices=["all", "train", "reproduce"],
help="Which pipeline to render")
args = parser.parse_args()
report_dir = _OUT_BASE / "reports" / "vorticity_panels"
report_dir.mkdir(parents=True, exist_ok=True)
# Scene names to process
train_scenes = ["re100_karman", "transfer_re60", "transfer_re200",
"transfer_re400", "illusion_075L", "illusion_1L", "illusion_15L"]
repro_scenes = ["karman_cloak", "steady_cloak", "illusion_075L",
"illusion_1L", "illusion_15L", "vortex_lamb", "vortex_taylor"]
# Map train to repro for overlap
overlap_map = {
"re100_karman": "karman_cloak",
"illusion_075L": "illusion_075L",
"illusion_1L": "illusion_1L",
"illusion_15L": "illusion_15L",
}
if args.side in ("all", "train"):
for scene in train_scenes:
if args.scene and scene != args.scene and not scene.startswith(args.scene):
continue
train_dir = _OUT_BASE / "train" / scene
repro_dir = _OUT_BASE / "reproduce" / overlap_map.get(scene, "")
make_comparison_panel(scene, train_dir,
repro_dir if repro_dir.exists() else None,
report_dir / f"vorticity_{scene}.png")
if args.side in ("all", "reproduce"):
for scene in repro_scenes:
if args.scene and scene != args.scene:
continue
if scene in overlap_map.values():
continue # already covered above
repro_dir = _OUT_BASE / "reproduce" / scene
make_comparison_panel(scene, None, repro_dir,
report_dir / f"vorticity_{scene}.png")
# Summary: side-by-side for overlapping scenes
for scene, rep_key in overlap_map.items():
if args.scene and scene != args.scene and not scene.startswith(args.scene):
continue
train_dir = _OUT_BASE / "train" / scene
repro_dir = _OUT_BASE / "reproduce" / rep_key
make_side_by_side(scene, train_dir, repro_dir,
report_dir / f"vorticity_compare_{scene}.png")
log(f"Reports in {report_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""Signal visualisation — obs/action timeseries, DTW, FFT spectrum.
Reads signals.npz and target.npz from scene output dirs.
Produces per-scene diagnostic plots.
Usage:
conda run -n pycuda_3_10 python viz_signals.py [--scene re100] [--side train]
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Optional
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
_OUT_BASE = Path(__file__).resolve().parent / "output"
def log(msg: str) -> None:
print(f"[viz_signals] {msg}", flush=True)
# ---------------------------------------------------------------------------
# Per-scene plot
# ---------------------------------------------------------------------------
def plot_scene_signals(scene_name: str, signals_path: Path,
target_path: Optional[Path],
out_dir: Path) -> None:
"""Full signal diagnostic for one scene."""
if not signals_path.exists():
log(f" {scene_name}: signals.npz not found at {signals_path}, skipping")
return
data = np.load(signals_path, allow_pickle=True)
sensors = data.get("sensors")
forces = data.get("forces")
actions = data.get("actions")
rewards = data.get("rewards")
fig = plt.figure(figsize=(16, 18))
n_channels = 6
ch_names_s = ["s0_ux", "s0_uy", "s1_ux", "s1_uy", "s2_ux", "s2_uy"]
ch_names_f = ["F_fx", "F_fy", "T_fx", "T_fy", "B_fx", "B_fy"]
# 1. Sensor timeseries
ax1 = fig.add_subplot(4, 2, 1)
if sensors is not None and sensors.shape[1] >= 6:
for i in range(6):
ax1.plot(sensors[:, i], lw=0.6, label=ch_names_s[i])
ax1.set_title("Sensors (raw, 6 channels)")
ax1.set_xlabel("Step"); ax1.legend(loc="upper right", fontsize=6, ncol=2)
ax1.grid(alpha=0.3)
# 2. Sensor uy channels only (for visual clarity)
ax2 = fig.add_subplot(4, 2, 2)
if sensors is not None and sensors.shape[1] >= 6:
for idx, lbl in [(1, "s0_uy"), (3, "s1_uy"), (5, "s2_uy")]:
ax2.plot(sensors[:, idx], lw=0.8, label=lbl)
ax2.set_title("Sensors (uy channels)")
ax2.set_xlabel("Step"); ax2.legend(fontsize=7)
ax2.grid(alpha=0.3)
# 3. Force timeseries
ax3 = fig.add_subplot(4, 2, 3)
if forces is not None and forces.shape[1] >= 6:
for i in range(6):
ax3.plot(forces[:, i], lw=0.6, label=ch_names_f[i])
ax3.set_title("Forces (raw, 6 channels)")
ax3.set_xlabel("Step"); ax3.legend(loc="upper right", fontsize=6, ncol=2)
ax3.grid(alpha=0.3)
# 4. Net force
ax4 = fig.add_subplot(4, 2, 4)
if forces is not None and forces.shape[1] >= 6:
cd = (forces[:, 0] + forces[:, 2] + forces[:, 4]) / 3.0
cl = (forces[:, 1] + forces[:, 3] + forces[:, 5]) / 3.0
ax4.plot(cd, lw=1, label="Cd (net)", color="tab:red")
ax4.plot(cl, lw=1, label="Cl (net)", color="tab:blue")
ax4.set_title("Net force (Cd, Cl)")
ax4.set_xlabel("Step"); ax4.legend(); ax4.grid(alpha=0.3)
# 5. Actions
ax5 = fig.add_subplot(4, 2, 5)
if actions is not None and actions.shape[1] >= 3:
ax5.plot(actions[:, 0], lw=0.8, label="aF (front)")
ax5.plot(actions[:, 1], lw=0.8, label="aB (bottom)")
ax5.plot(actions[:, 2], lw=0.8, label="aT (top)")
ax5.set_title("Actions [-1, 1]")
ax5.set_xlabel("Step"); ax5.legend(); ax5.grid(alpha=0.3)
ax5.set_ylim(-1.05, 1.05)
# 6. Reward (if available)
ax6 = fig.add_subplot(4, 2, 6)
if rewards is not None and len(rewards) > 0:
ax6.plot(rewards, lw=1, color="tab:green", label="reward")
ax6.axhline(y=np.mean(rewards[-180:]) if len(rewards) >= 180 else np.mean(rewards),
color="green", ls="--", lw=1, label="avg (tail)")
ax6.set_title("Reward")
ax6.set_xlabel("Step"); ax6.legend(); ax6.grid(alpha=0.3)
# 7. FFT spectrum (controlled sensor uy vs target)
ax7 = fig.add_subplot(4, 2, 7)
if sensors is not None and sensors.shape[1] > 3:
ax7, _ = _plot_fft(ax7, sensors[:, 3], label="Controlled s1_uy",
color="tab:blue")
if target_path and target_path.exists():
tgt = np.load(target_path, allow_pickle=True)
tgt_data = tgt.get("target_states", tgt.get("sensors"))
if tgt_data is not None and tgt_data.shape[1] > 3:
_plot_fft(ax7, tgt_data[:, 3], label="Target s1_uy",
color="tab:red", linestyle="--")
ax7.set_title("FFT: Center sensor uy")
ax7.legend(fontsize=7); ax7.grid(alpha=0.3)
# 8. Action histogram
ax8 = fig.add_subplot(4, 2, 8)
if actions is not None and actions.shape[1] >= 3:
tail = slice(-180, None) if actions.shape[0] >= 180 else slice(None)
ax8.hist(actions[tail, 0], bins=20, alpha=0.5, label="aF", color="tab:red")
ax8.hist(actions[tail, 1], bins=20, alpha=0.5, label="aB", color="tab:orange")
ax8.hist(actions[tail, 2], bins=20, alpha=0.5, label="aT", color="tab:green")
ax8.set_title("Action distribution (tail 180 steps)")
ax8.legend(fontsize=7); ax8.grid(alpha=0.3)
fig.suptitle(f"{scene_name} - Signal Analysis", fontsize=14)
fig.tight_layout()
fig.savefig(out_dir / f"signals_{scene_name}.png", dpi=150, bbox_inches="tight")
plt.close(fig)
log(f" {scene_name}: signal plot saved")
def _plot_fft(ax, signal, label="", color="tab:blue", linestyle="-"):
"""Plot single-sided FFT magnitude spectrum."""
n = len(signal)
sig = np.asarray(signal, dtype=np.float64)
sig = sig - np.mean(sig)
fft = np.abs(np.fft.rfft(sig)) / n
freqs = np.fft.rfftfreq(n, d=1)
ax.plot(freqs[1:], fft[1:], lw=1, color=color, ls=linestyle, label=label)
ax.set_xlim(0, 0.5)
ax.set_xlabel("Frequency (1/step)")
return ax, freqs, fft
# ---------------------------------------------------------------------------
# Cross-scene comparison: DTW / reward bars
# ---------------------------------------------------------------------------
def plot_cross_scene_summary(scene_dirs: dict, out_dir: Path) -> None:
"""Bar chart comparing key metrics across scenes."""
import json
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
names = list(scene_dirs.keys())
dtw_vals = []
reward_vals = []
for name, d in scene_dirs.items():
m_path = d / "metrics.json"
if m_path.exists():
with open(m_path) as f:
m = json.load(f)
dtw_vals.append(m.get("dtw_sim_v5", m.get("sim_raw_mean", 0)))
reward_vals.append(m.get("reward_mean", 0))
else:
dtw_vals.append(0)
reward_vals.append(0)
ax = axes[0]
bars = ax.bar(names, dtw_vals, color="tab:blue", alpha=0.7)
for b, v in zip(bars, dtw_vals):
if v > 0:
ax.text(b.get_x() + b.get_width() / 2, b.get_height() + 0.01,
f"{v:.3f}", ha="center", fontsize=8)
ax.set_title("DTW Similarity (V5)")
ax.set_ylim(0, 1.1)
ax.tick_params(axis="x", rotation=30)
ax.grid(axis="y", alpha=0.3)
ax = axes[1]
bars = ax.bar(names, reward_vals, color="tab:green", alpha=0.7)
for b, v in zip(bars, reward_vals):
if v > 0:
ax.text(b.get_x() + b.get_width() / 2, b.get_height() + 0.01,
f"{v:.3f}", ha="center", fontsize=8)
ax.set_title("Average Reward (tail 180)")
ax.tick_params(axis="x", rotation=30)
ax.grid(axis="y", alpha=0.3)
fig.suptitle("Cross-Scene Summary")
fig.tight_layout()
fig.savefig(out_dir / "cross_scene_summary.png", dpi=150, bbox_inches="tight")
plt.close(fig)
log(f" Cross-scene summary saved")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Signal visualization")
parser.add_argument("--scene", type=str, default=None)
parser.add_argument("--side", type=str, default="all",
choices=["all", "train", "reproduce"])
args = parser.parse_args()
report_dir = _OUT_BASE / "reports" / "signal_plots"
report_dir.mkdir(parents=True, exist_ok=True)
for side_name, side_key in [("train", "train"), ("reproduce", "reproduce")]:
if args.side != "all" and args.side != side_key:
continue
side_dir = _OUT_BASE / side_key
if not side_dir.exists():
continue
scene_dirs_for_summary = {}
for scene_dir in sorted(side_dir.iterdir()):
if not scene_dir.is_dir():
continue
scene_name = scene_dir.name
if args.scene and scene_name != args.scene and not scene_name.startswith(args.scene):
continue
sig_path = scene_dir / "signals.npz"
tgt_paths = [scene_dir / "target.npz",
scene_dir / ".." / ".." / ".." / "calibrations" / "re100" / "target.npy"]
tgt_found = None
for tp in tgt_paths:
if tp.exists():
tgt_found = tp
break
plot_scene_signals(f"{side_key}_{scene_name}", sig_path, tgt_found,
report_dir)
scene_dirs_for_summary[f"{side_key}_{scene_name}"] = scene_dir
if scene_dirs_for_summary:
plot_cross_scene_summary(scene_dirs_for_summary, report_dir)
log(f"Reports in {report_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+388
View File
@@ -0,0 +1,388 @@
# Cross-Re Transfer Learning — 分析报告
> 日期: 2026-07-07
> 实验: 改变上游来流雷诺数 (Re60 / Re200 / Re400),从 re100 baseline 进行 transfer learning
> 实验阶段: base (crossre_transfer.sh, lr=3e-4) → extend (extend_transfer.sh, lr=1e-4) → ext2 (ext2_transfer.sh, lr=1e-4)
---
## 1. 实验配置总览
### 1.1 共享超参数
| 参数 | base (crossre_transfer) | extend / ext2 |
|------|:----------------------:|:-------------:|
| PPO n_steps | 2048 | 2048 |
| PPO batch_size | 64 | 64 |
| **PPO n_epochs** | **10** | **5** ← 减半 |
| **Learning rate** | **3e-4** | **1e-4** ← 降为 1/3 |
| Gamma | 0.995 | 0.995 |
| Policy net_arch | [64, 64] | [64, 64] |
| Activation | sin | sin |
| Symmetry augmentation | prob=0.5 | prob=0.5 |
| Episode 数 | 200 (base) | 200-400 (extend / ext2) |
| 基座模型 | re100_karman_seed43 (best≈0.92) | 各自 base/ext 的 best_model |
> **关键差异**: extend/ext2 相比 base 将 n_epochs 从 10 降到 5、lr 从 3e-4 降到 1e-4**综合保守程度约为 base 的 6 倍**。原始意图是 fine-tune 更稳定,但实际效果适得其反。
### 1.2 校准参数差异(核心问题所在)
| 参数 | re60 | re100 (base) | re200 | re400 |
|------|:---:|:------------:|:-----:|:-----:|
| **SI** | 800 | 800 | 500 | 400 |
| **FORCE_SCALE** | 0.0021 | 0.0024 | 0.0026 | 0.0042 |
| **SENS_SCALE** | 0.72 | 0.75 | 0.90 | 0.98 |
| **dtw_norm_scale** | 0.107 | 0.204 | 0.269 | 0.310 |
| **SIM_BP[1]** (zero rot) | 0.41 | 0.32 | 0.45 | 0.56 |
| **SIM_BP[2]** (ref rot) | 0.61 | 0.82 | 0.77 | 0.73 |
| **SIM_BP gap** (spread) | **0.20** | 0.50 | **0.32** | **0.17** |
| **Δrev / Δsim 梯度比** | **2.5x** | 1.0x | **1.6x** | **2.9x** |
| **SIM_BP 来源** | 实测 | 实测 | 实测 | 实测 |
| **K_CD / K_CL** | **12/25** | 50/100 | **12/25** | **12/25** |
> **关键发现 1**: re60 (gap=0.20) 和 re400 (gap=0.17) 的 SIM_BP gap 远小于 re100 基准的 0.50。
> **关键发现 2**: re60/re200/re400 的 K_CD/K_CL = 12/25,只有 re100 的 1/4。这意味着跨 Re 迁移后 drag/lift 惩罚显著放宽,改变了 reward landscape。
> **关键发现 3**: calibrate.py 的 generic SIM_BP 兜底条件(`spread<0.10 或 better_sim<0.5`)对 re60/re200/re400 全部未触发。
### 1.3 SIM_BP 映射机制的数学解释
calibrate.py 将实测的 DTW similarity 范围 `[worst_sim, better_sim]` 固定映射到 reward 值 `[0.2, 0.5]`
```
r_sim = piecewise_map(measured_sim, SIM_BP=[0, w, b, ...], SIM_VAL=[0, 0.2, 0.5, ...])
```
这意味着 **gap 越小的场景,单位 similarity 变化对应的 reward 变化越大**
```
Δr_sim/Δsim ≈ (0.5 - 0.2) / gap = 0.3 / gap
```
| Case | gap | Δr/Δsim | 效应 |
|------|:---:|:-------:|------|
| re60 | 0.20 | **1.50** | reward 梯度过陡 → PPO advantage 方差偏大 → 训练不稳定 |
| re100 | 0.50 | **0.60** | 基准:梯度适中 |
| re200 | 0.32 | **0.94** | 梯度偏陡,可接受 |
| re400 | 0.17 | **1.76** | reward 梯度极陡 → PPO 估计噪音极大 → 早期 peak 后快速崩溃 |
| vardist d075 | 0.54 | **0.56** | 梯度偏平 → PPO 信号过弱 → 持续 peak-crash 震荡 |
**与 vardist 的对称性**vardist d075 的问题方向是 gap 太大(梯度被摊薄),Karman re60/re400 的问题方向是 gap 太小(梯度被过压),但**根源完全相同:实测 SIM_BP 不适合非 re100 基线场景**。
---
## 2. 训练结果
### 2.1 Base Transfer (lr=3e-4, n_epochs=10, 200ep)
| Case | Seed | Best Reward | Best @ Ep | Ep 1 Reward | Ep 200 Reward | 走势质量 |
|------|:----:|:-----------:|:---------:|:-----------:|:-------------:|:--------:|
| **re60** | 43 | **0.724** | 181 | 0.370 | 0.163 | ✅ 快速上升后波动 |
| re60 | 41 | 0.701 | 191 | 0.073 | 0.697 | ✅ 慢启动但稳定 |
| **re200** | 43 | **0.499** | 177 | 0.223 | 0.477 | ⚠️ 缓慢爬升 |
| re200 | 45 | 0.441 | 23 | 0.283 | 0.359 | ❌ 早期 peak 后停滞 |
| re200 | 41 | 0.489 | 60 | 0.190 | 0.381 (Ep105) | ⚠️ 日志截断 |
| **re400** | 43 | **0.553** | 28 | 0.326 | 0.264 | ❌ 极早 peak 后持续退化 |
### 2.2 Extend Transfer (lr=1e-4, n_epochs=5, 200-400ep)
从各自 base best_model 继续 fine-tune
| Case | Seed | Best Reward | Best @ Ep | Ep 1 Reward | 末 Ep Reward | vs Base | 走势 |
|------|:----:|:-----------:|:---------:|:-----------:|:------------:|:-------:|------|
| re60 ext | 43 | 0.695 | 149 | 0.212 | 0.660 | **-0.029** ⬇ | ⚠️ 起始更低 |
| re200 ext | 43 | **0.535** | 182 | 0.436 | 0.244 (Ep400) | **+0.036** ⬆ | ❌ peak 后退至 0.24 |
| re400 ext | 43 | 0.538 | 107 | 0.341 | 0.345 (Ep400) | **-0.015** ⬇ | ⚠️ 微下降 |
### 2.3 Ext2 Transfer (lr=1e-4, n_epochs=5, 200-400ep)
从各自 base best_model 重新 fine-tune(不继续 ext):
| Case | Seed | Best Reward | Best @ Ep | Ep 1 Reward | 末 Ep Reward | vs Base | 走势 |
|------|:----:|:-----------:|:---------:|:-----------:|:------------:|:-------:|------|
| re60 ext2 | 43 | 0.711 | 187 | 0.375 | 0.711 | **-0.013** ⬇ | ⚠️ 接近但未超越 |
| re200 ext2 | 43 | 0.524 | 51 | 0.342 | 0.326 (Ep400) | **+0.025** ⬆ | ❌ 低于 ext |
| re400 ext2 | 43 | 0.548 | 373 | 0.365 | 0.393 (Ep400) | **-0.005** ⬇ | ⚠️ 勉强持平 |
### 2.4 跨实验总览
| Case | 最优 | Ep @ 最优 | Ep1→末 差值 | 退化幅度 |
|------|:----:|:---------:|:----------:|:--------:|
| re60 base seed43 | **0.724** | 181 | +0.354 → -0.207 | -0.561 |
| re200 ext seed43 | **0.535** | 182 | +0.436 → -0.192 | -0.628 |
| re400 base seed43 | **0.553** | 28 | +0.326 → -0.062 | -0.388 |
> **re200/re400 的 Ep1 还有提升空间,但都未能守住并持续改进,最终均出现不同程度的退化。**
### 2.5 Reward 走势定性图
```
re60 base seed43: ▄▂▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ─→ 0.724 (Ep181 peakEp200 崩至 0.16)
re60 ext seed43: ▄▂▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ─→ 0.695 (stable,低于 base 0.724)
re60 ext2 seed43: ▃▃▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ─→ 0.711 (接近 base,仍略低)
re200 base seed43: ▂▃▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ─→ 0.499 (慢升)
re200 ext seed43: ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▂▁ ─→ 0.535 (Ep182 peak 后退至 0.244)
re200 ext2 seed43: ▄▀▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃ ─→ 0.524 (Ep51 peak 后停滞)
re400 base seed43: ▄▀▂▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃▃ ─→ 0.553 (Ep28 peak 后退至 0.264)
re400 ext seed43: ▄▄▄▄▀▂▂▂▃▃▃▃▃▃▃▃▃▃▃▃ ─→ 0.538 (平稳退化)
re400 ext2 seed43: ▄▂▂▃▃▃▃▄▄▄▄▄▄▄▄▄▄▄▄▄ ─→ 0.548 (极慢恢复)
```
三条核心模式:
1. **re60**:base 能冲到 0.72 但非常波动,ext/ext2 更稳定但上限被压低
2. **re200**:"早期爬坡 → 高峰 → 严重退化"三部曲
3. **re400**"极早 peak → 持续退化"二部曲,从未恢复
---
## 3. 诊断分析
### 3.1 ✅ re60 base — 相对成功但波动大
**为什么相对成功:**
1. Re60 流场接近稳态(FFT 显示极弱涡脱落),控制难度最低
2. Re60 与 Re100 的物理差异小(FORCE_SCALE: 0.0021 vs 0.0024,仅差 12%
3. Ep1 r_cd=0.793 → re100 的策略在 Re60 下 drag 控制仍然有效
4. Ep1 r_sim=0.236 → similarity 虽然初始低,但几何不变 + 流场简单可以快速调整
**潜在问题:**
1. 虽然最优能到 0.724,但 Ep200 跌回 0.163——极不稳定的后期表现
2. 可能已经撞到局部最优(randomly 达到高 reward 后随 PPO 探索而丢失)
3. SIM_BP gap=0.20 导致 reward 梯度 2.5x 于基准,PPO 在被放大的噪音中探步过大
**证据:re60 base seed41 vs seed43 差异巨大**
- seed41: Ep1=0.07(极低起点)→ 最终 0.701(稳步上升)
- seed43: Ep1=0.37(较好起点)→ 冲高 0.724 后末班 0.163(过山车)
- 不同 seed 从不同策略起点出发,但最终都达到了类似上限(0.70-0.72),说明 re60 的能力上限可能就在 0.72 左右
### 3.2 ❌ re200 ext — 最典型的退化模式
**re200 ext seed43 详细曲线:**
```
Ep 1: 0.436 (较高起点——ext 从 base best 继续,base Ep200=0.477)
Ep 50: 0.460 (缓慢上升)
Ep 150: 0.480 (继续缓慢上升)
Ep 182: 0.535 ← BEST (突然突破)
Ep 200: 0.488 (快速回落)
Ep 300: 0.304 (持续下降)
Ep 400: 0.244 (崩溃,回到接近 Ep1 水平)
```
**根因 1 — SIM_BP 梯度过陡(2.5x**
gap=0.32,Δr/Δsim=0.94,比 re100 基准陡 56%。PPO 的 advantage 估计在这种放大后的 reward 空间中方差增大,导致:
- 偶尔能发现好策略(Ep182 的 0.535
- 但下次 PPO 更新时在大方差下 over-shoot,丢失策略
- 之后再也找不回来——"过山车"无法逆转
这和一个经典现象一致:**advantage variance 过大时,即使 PPO 的 clipping 也无法防止 catastrophic update**。
**根因 2 — extend 超参过于保守(6x)**
lr=1e-4 + n_epochs=5 的组合相当于每次 PPO update 的有效学习量只有 base 的 ~1/6。当策略退化到 0.244 时,如此小的更新量已无法推动策略脱离低谷。
**根因 3 — K_CD/K_CL 不一致改变 reward landscape**
re100 基准使用 K_CD=50, K_CL=100,但 re200 的 calibration 只有 K_CD=12, K_CL=251/4)。这意味着:
- re100 专家策略训练时 drag/lift 受到非常严格的平方惩罚
- 迁移到 re200 后,同样的 drag 偏差只受到 1/4 的惩罚
- 相当于 reward 中 drag/lift 分量的"价格"大幅下降 → PPO 可以"偷工减料"仍然拿到最高 reward
- 但不一定意味着真正控制了流场——更像在 reward 空间中找到了捷径
### 3.3 ❌ re400 — 极早 peak 后永不恢复
**re400 base seed43 详细曲线:**
```
Ep 1: 0.326 (中等起点)
Ep 28: 0.553 ← BEST (极早 peak)
Ep 50: 0.451
Ep 100: 0.362 (持续退化)
Ep 150: 0.301 (继续退化)
Ep 200: 0.264 (接近起点)
```
**根因 1 — SIM_BP gap=0.17,梯度 2.9x 于基准**
这是所有 case 中最极端的 SIM_BP 异常。在 0.17 的窄 similarity 范围内,0.2→0.5 的 reward 映射意味着:
- 任何微小的 similarity 波动被 2.9 倍放大
- PPO 观测到的 reward signal 几乎全是噪音
- 类似在高噪声数据上用大步长 SGD——方向随机,永远无法收敛
**根因 2 — 流场混沌度高**
Re400 涡脱落更强更不规则(FORCE_SCALE=0.0042,比 re100 高 75%)。流场本身的随机性已经大到 PPO 无法在合理步数内学习有效的 value function,再加上被放大的 reward 梯度 → 不稳定的更新 → 策略崩坏。
**根因 3 — 三种退化叠加**
与 re200 不同,re400 同时受到:
1. SIM_BP 梯度异常(2.9x,最严重)
2. K_CD/K_CL 缩小(reward landscape 变形)
3. 流场混沌(自然信号噪声高)
三座大山共同压垮了训练。
### 3.4 extend/ext2 vs base 的退化对比
| Case | base best | ext best | Δ base→ext | ext2 best | Δ best→ext2 | 超参保守效应 |
|------|:---------:|:--------:|:----------:|:---------:|:-----------:|:----------:|
| re60 | **0.724** | 0.695 | **-0.029** | 0.711 | -0.013 | ⚠️ 有害 |
| re200 | 0.499 | **0.535** | **+0.036** | 0.524 | +0.025 | ⚠️ 边际改善但稳不住 |
| re400 | **0.553** | 0.538 | **-0.015** | 0.548 | -0.005 | ⚠️ 基本持平 |
**模式**extend/ext2 的超参组合(lr=1e-4, n_epochs=5)对任何 Re 都没有产生显著正向帮助:
- re60: 明显有害(-0.029
- re200: 微改善(+0.036)但后续崩塌
- re400: 基本持平但远低于可接受水平
**推论**:在 SIM_BP 梯度异常未修复的前提下,调整超参只是治标不治本。
---
## 4. 与 Vardist 的交叉验证
### 4.1 SIM_BP 问题的对称性
```
←── 梯度太陡(Karman re60/re400)──→
Δr/Δsim: 0.56 0.60 0.94 1.50 1.76
│ │ │ │ │
Case: d075 re100 re200 re60 re400
│ │ │ │ │
问题: 太平坦 基准 偏陡 太陡 极陡
表现: peak-crash 正常 慢升+退化 波动大 极早peak崩溃
│ │ │
└─ vardist 方向 ──────┴─ Karman 方向 ─────┘
```
**两种方向的修复都需要 generic SIM_BP (gap=0.35)**,恰好落在安全区间。
### 4.2 Early Peak 退化:d2 vs re400
两个案例共享完全相同的退化模式:
| 特征 | vardist d2 | Karman re400 | 是否同源? |
|------|:----------:|:------------:|:---------:|
| Ep1 r_sim | 0.455 | 0.326 | 均 < 0.5 |
| Best @ | Ep 2 | Ep 28 | 均极早 |
| 退化幅度 | 0.470 → 0.246 (48%) | 0.553 → 0.264 (52%) | 接近 |
| 恢复能力 | 无人力干预从未恢复 | 无人力干预从未恢复 | 相同 |
| **根因** | **几何不匹配** | **SIM_BP 梯度 2.9x** | 不同 |
> **d2 的根因是几何变化(2.0L vs 1.0L),re400 的根因是 reward 梯度异常。但两者的退化模式完全一致,说明 "早期 peak 后不可逆退化" 是一个通用的 training instability 信号,无论触发原因是什么。**
### 4.3 Vardist 的改善经验是否可以套用?
| Vardist v2 改进 | 原因 | 是否适用于 Karman |
|----------------|------|:-------------------:|
| generic SIM_BP | d075 gap=0.54 太宽 | ✅ **高度适用**re60/re400 gap 太窄,同因异向) |
| from scratch | d075 Ep1=0.023 完全不适用 | ⚠️ 部分适用(re60 Ep1=0.37 还行,re200/400 borderline |
| lr=1e-4 | 减少 scratch 训练 over-shoot | ❌ **不适用**Karman 是 fine-tune,降 lr 有害) |
| — | — | ✅ **需要升回 lr=3e-4, n_epochs=10**(与 base 保持一致) |
---
## 5. 改进建议
### 5.1 针对 re60
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 使用 generic SIM_BP 重新 calibrate | gap=0.20 → 0.35,降低 reward 梯度从 2.5x→1x,稳定训练 |
| **P1** | 从 re60 base best 继续 fine-tune,但保持 lr=3e-4 + n_epochs=10 | 当前 0.724 仍有波动,需要更多 episode 而非更保守更新 |
| **P2** | Multi-seed fine-tune (seed41/43/45) | 不同 seed 的 Ep1→最终轨迹差异大,多 seed 取优 |
### 5.2 针对 re200
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 使用 generic SIM_BP 重新 calibrate | gap=0.32 → 0.35(差异较小但仍有 56% 的梯度过压) |
| **P1** | 恢复 lr=3e-4 + n_epochs=10 的原始超参 | re200 ext 是唯一从 extend 中获益的 case (+0.036),说明保守更新至少没完全搞砸——但应该用原始超参加速收敛 |
| **P2** | 延长 episode 数至 500+ | re200 base 200ep 仅到 0.499 且仍在上升,需要更长时间 |
| **P3** | 对比 K_CD/K_CL=50/100 vs 12/25 | 找出 reward landscape 变形的实际影响 |
| **P4** | 尝试 n_steps=4096 | 多采一倍经验做一次 PPO update,超参整体更保守但 advantage 估计更稳 |
### 5.3 针对 re400
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 使用 generic SIM_BP 重新 calibrate | gap=0.17 → 0.35,梯度从 2.9x 降到 1x,是**最重要且最可能有效**的改动 |
| **P1** | 对比 from scratch vs transfer | Ep1 r_sim=0.326 borderline,可能需要从头学 |
| **P2** | 考虑 from scratch 500ep (lr=3e-4, n_epochs=10) | 如果 re100 策略对 re400 不适用,scratch 可能更快 |
| **P3** | 尝试更大模型容量 [128, 64] 或 [128, 128] | Re400 流场更混沌,64-d MLP 可能容量不足 |
| **P4** | 降低 symmetry probability 至 0.3 | 高 Re 下流场不对称性更强,强 symmetry 正则化可能有害 |
### 5.4 针对 extend/ext2 脚本
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 恢复 lr=3e-4 + n_epochs=10 | 当前 6x 保守超参对所有 Re 无效甚至有害 |
| **P1** | 只增加 episode 数,不改变任何超参 | 保持与 base 完全一致的 PPO 设置 |
| **P2** | 在每个 Re 的 extend 中点(50% episodes)评估一次 | 如果 reward 持续下降,提前终止避免浪费时间 |
### 5.5 通用改进
| 优先级 | 方案 | 适用范围 |
|:------:|------|---------|
| **P0** | 修改 calibrate.py 的 generic SIM_BP 兜底条件:增加 gap<0.25re100 基准的 50%)时触发 | 所有 future cross-Re / vardist case |
| **P1** | 将 K_CD/K_CL 固定在 50/100,而非让 calibration 测量不同值 | 保持 reward landscape 一致,跨 Re / 跨几何比较才有意义 |
| **P2** | Ep1 metrics 作为先行诊断指标:r_sim<0.3 时建议 from scratchr_sim>0.5 + r_cd>0.6 时可放心 transfer | 减少无效实验 |
| **P3** | 对高 Re (200+) 增加 n_steps=4096 的实验分支 | 混沌流场需要更大 minibatch 做稳定 advantage 估计 |
| **P4** | 对比实验矩阵(见 §6) | 一次性验证多个假说 |
---
## 6. 建议的对比实验矩阵
基于以上分析,最经济有效的实验设计(每个 200ep):
| 实验 | Re | SIM_BP | lr | n_ep | trans | 目的 |
|------|:--:|:------:|:--:|:----:|:------:|------|
| **T0** (baseline) | 200/400 | 实测 | 3e-4 | 10 | re100→ | 已有数据,基线 |
| **T1** | 400 | generic | 3e-4 | 10 | re100→ | **P0 验证**generic SIM_BP 是否消除早期崩溃 |
| **T2** | 400 | generic | 3e-4 | 10 | 无 | 验证 from scratch 是否更优 |
| **T3** | 200 | generic | 3e-4 | 10 | re100→ | 验证 re200 能否突破 0.50-0.53 瓶颈 |
| **T4** | 200 | 实测 | 3e-4 | 10 | re100→, K=50/100 | 验证 K_CD/K_CL 统一的效应 |
| **T5** | 400 | generic | 3e-4 | 10 | re100→, n_steps=4096 | 验证大 minibatch 的效果 |
**推荐路径**:先跑 T1,如果 re400 不再早期崩溃 → P0 确诊 → 再生产性跑 T3 + T4 + T5。
---
## 7. 与 Vardist 的对照总结
| 维度 | Vardist (变扰流尺寸) | Karman (变 Re) |
|------|---------------------|---------------|
| **物理变化** | 扰动圆柱直径 0.75L–2.0L | 来流粘度 → Re 变化 |
| **核心问题** | SIM_BP gap=0.54 太宽(梯度被摊薄) | SIM_BP gap=0.17-0.32 太窄(梯度被过压) |
| **典型退化** | d075: 持续 peak-crash 震荡 | re200/400: 早期 peak 后不可逆退化 |
| **兜底逻辑** | d15/d2 触发 genericd075 未触发 | re60/200/400 均未触发 |
| **已尝试修复** | v2: generic SIM_BP + from scratch + lr=1e-4 | ext/ext2: lr=1e-4 + n_epochs=5(方向错误) |
| **正确修复** | generic SIM_BP + 合理超参 | generic SIM_BP + 恢复 lr=3e-4 + n_epochs=10 |
| **统一根因** | calibrate.py 的实测 SIM_BP 不适合非 re100 场景 | 同上 |
---
## 8. 关键文件索引
| 文件 | 路径 |
|------|------|
| Base 训练脚本 | `train_karman.py` |
| 环境 | `env_karman.py` |
| 校准脚本 | `calibrate.py`SIM_BP generic 兜底逻辑在第 526527 行) |
| Base transfer 启动 | `crossre_transfer.sh`lr=3e-4, n_epochs=10 |
| Extend transfer 启动 | `extend_transfer.sh`lr=1e-4, n_epochs=5, 保守超参) |
| Ext2 transfer 启动 | `ext2_transfer.sh`(同上,重新 fine-tune |
| Multi-GPU launcher | `launch_multi.sh`lr=3e-4, n_epochs=10 |
| 对比分析 | `VARDIST_ANALYSIS.md` |
| re60 校准 | `calibrations/re60/calibration.json`SIM_BP gap=0.20, K_CD=12 |
| re100 校准 | `calibrations/re100/calibration.json`SIM_BP gap=0.50, K_CD=50 |
| re200 校准 | `calibrations/re200/calibration.json`SIM_BP gap=0.32, K_CD=12 |
| re400 校准 | `calibrations/re400/calibration.json`SIM_BP gap=0.17, K_CD=12 |
| re60 base 日志 | `output/transfer_re60_seed43/train.log` |
| re200 base 日志 | `output/transfer_re200_seed43/train.log` |
| re200 ext 日志 | `output/transfer_re200ext_seed43/train.log`0.535→0.244 退化) |
| re400 base 日志 | `output/transfer_re400_seed43/train.log`Ep28 0.553→0.264 退化) |
| re400 ext2 日志 | `output/transfer_re400ext2_seed43/train.log` |
+221
View File
@@ -0,0 +1,221 @@
# DynamisLab — 训练目录
> `src/drl_pinball/train/`
> 更新: 2026-07-12
---
## 命名规则
```
{domain}_{variant}_{method}_seed{seed}
domain: kar = Karman | ill = Illusion
variant: re{60,100,200,400} = 雷诺数 | d{075,15,2} = 扰动圆柱直径(L) | {1L,15L,075L,2L} = 目标圆柱尺寸
method: sc = from scratch | tr = transfer from re100 baseline
seed: seed{41..45}
Examples:
kar_re100_sc_seed45 — Karman Re100 scratch, seed 45
kar_d075_sc_seed44 — Karman 0.75L dist-cyl scratch
kar_d15_tr_seed45 — Karman 1.5L dist-cyl transfer
kar_re200_sc_seed43 — Karman Re200 scratch
ill_2L_sc_seed43 — Illusion 2L target scratch
```
---
## 目录结构
```
train/
├── train_karman.py # Karman PPO 训练 (核心)
├── train_illusion.py # Illusion PPO 训练
├── calibrate.py # Phase 0 校准
├── env_karman.py # Karman 环境
├── env_illusion.py # Illusion 环境
├── symmetry_wrapper.py # G-symmetry 数据增强
├── scripts/ # 启动脚本
│ ├── train_baseline.sh # kar_re100_sc multi-seed
│ ├── train_illusion.sh # ill_*_sc 4 个尺寸
│ ├── vardist_scratch.sh # kar_d*_sc (推荐)
│ ├── vardist_transfer.sh # kar_d*_tr (参考)
│ ├── crossre_scratch.sh # kar_re*_sc
│ ├── crossre_transfer.sh # kar_re*_tr (参考)
│ └── resume.sh # 中断恢复
├── calibrations/ # 校准文件
│ ├── kar_re100/ # Re100 基准
│ ├── kar_d*_sc/ # 变直径 scratch (generic SIM_BP)
│ ├── kar_d*_tr/ # 变直径 transfer (实测 SIM_BP)
│ ├── kar_re*_sc/ # 变雷诺数 scratch (generic SIM_BP)
│ ├── kar_re*/ # 变雷诺数 transfer (实测 SIM_BP)
│ └── ill_*/ # Illusion
├── output/ # 训练输出 (21 dirs)
│ ├── kar_re100_sc_seed{41..45}/
│ ├── kar_d*_sc_seed{44,45}/
│ ├── kar_d*_tr_seed{44,45}/
│ ├── kar_re*_sc_seed43/
│ ├── kar_re*_tr_seed43/
│ └── ill_*_sc_seed43/
├── archive/ # 旧版本 (保留)
├── README.md # 当前文件
├── VARDIST_ANALYSIS.md
├── CROSSRE_ANALYSIS.md
└── SERVER_DEPLOY.md
```
---
## 快速开始
### 基准训练
```bash
cd scripts
bash train_baseline.sh --gpu 0 --episodes 500
# → output/kar_re100_sc_seed{41..45}/
```
### 变直径 scratch (推荐)
```bash
cd scripts
bash vardist_scratch.sh --gpu 0
bash vardist_scratch.sh --only d075
# → output/kar_d075_sc_seed44/ etc.
```
### 变雷诺数 scratch
```bash
cd scripts
bash crossre_scratch.sh --gpu 0
bash crossre_scratch.sh --only re200
# → output/kar_re200_sc_seed43/ etc.
```
### Illusion
```bash
cd scripts
bash train_illusion.sh --gpu 0
# → output/ill_2L_sc_seed43/ etc.
```
### 中断恢复
```bash
cd scripts
bash resume.sh --case kar_re60_sc --seed 43 --resume 460 --episodes 500
```
---
## 核心训练脚本
### `train_karman.py`
```bash
conda run -n pycuda_3_10 python -u train_karman.py \
--case-name kar_re100_sc --device-id 0 --seed 42 \
--config config.json --calibration calibrations/kar_re100/calibration.json \
--total-episodes 500 [--transfer-model PATH] [--resume-from N]
```
| 参数 | 默认值 | 说明 |
|------|:-----:|------|
| n_steps | 2048 | PPO 每轮收集步数 |
| batch_size | 64 | minibatch |
| n_epochs | 10 | 经验重用次数 |
| lr | 3e-4 | 学习率 |
| gamma | 0.995 | 折扣因子 |
| net_arch | [64, 64] | MLP 两层 sin 激活 |
---
## 最终结果
### Re100 基准 — `kar_re100_sc`
| Seed | r_sim | r_cd | r_cl |
|:----:|:-----:|:----:|:----:|
| 45 | 0.883 | 0.977 | 0.982 |
| Avg | 0.866 | 0.971 | 0.965 |
### Vardist 变直径
| Case | 方式 | r_sim | r_cd | r_cl |
|------|:----:|:-----:|:-----:|:-----:|
| **kar_d075_sc** | scratch | **0.918** | 0.954 | 0.879 |
| kar_d075_tr | transfer | 0.450 | 0.585 | 0.236 |
| **kar_d15_sc** | scratch | **0.904** | 0.991 | 0.992 |
| kar_d15_tr | transfer | 0.791 | 0.918 | 0.470 |
| **kar_d2_sc** | scratch | **0.793** | 0.967 | 0.961 |
| kar_d2_tr | transfer | 0.673 | 0.537 | 0.192 |
> Scratch 全面领先 transfer。
### Cross-Re 变雷诺数
| Case | 方式 | r_sim | r_cd | r_cl |
|------|:----:|:-----:|:-----:|:-----:|
| kar_re60_tr | transfer | 0.446 | **0.882** | 0.936 |
| kar_re60_sc | scratch | 0.261 | **0.987** | 0.981 |
| **kar_re200_sc** | scratch | **0.664** | 0.667 | 0.281 |
| kar_re200_tr | transfer | 0.374 | 0.749 | 0.427 |
| **kar_re400_sc** | scratch | **0.509** | 0.714 | 0.420 |
| kar_re400_tr | transfer | 0.293 | 0.883 | 0.571 |
> Scratch sim 最优,transfer CD 最优。两者均保留。
### Illusion
| Case | r_sim | r_cd | r_cl |
|------|:-----:|:-----:|:-----:|
| ill_1L_sc | 0.725 | 0.774 | 0.449 |
| ill_15L_sc | 0.810 | 0.825 | 0.384 |
| ill_075L_sc | 0.794 | 0.840 | 0.486 |
| **ill_2L_sc** | **0.896** | 0.863 | 0.570 |
---
## Calibration 参考
| Calibration | 物理 | SI | SIM_BP | K_CD/K_CL | 对应 output |
|------------|------|:--:|--------|:---------:|------------|
| `kar_re100` | Re=100, 1.0L | 800 | 实测 | 50/100 | `kar_re100_sc` |
| `kar_d075_sc` | Re=100, 0.75L | 800 | generic | 50/100 | `kar_d075_sc` |
| `kar_d075_tr` | Re=100, 0.75L | 800 | 实测 | 50/100 | `kar_d075_tr` |
| `kar_d15_sc` | Re=100, 1.5L | 800 | generic | 50/100 | `kar_d15_sc` |
| `kar_d15_tr` | Re=100, 1.5L | 800 | 实测 | 50/100 | `kar_d15_tr` |
| `kar_d2_sc` | Re=100, 2.0L | 800 | generic | 50/100 | `kar_d2_sc` |
| `kar_d2_tr` | Re=100, 2.0L | 800 | 实测 | 50/100 | `kar_d2_tr` |
| `kar_re60` | Re=60 | 800 | 实测 | 12/25 | `kar_re60_tr` |
| `kar_re60_sc` | Re=60 | 800 | generic | 50/100 | `kar_re60_sc` |
| `kar_re200` | Re=200 | 500 | 实测 | 12/25 | `kar_re200_tr` |
| `kar_re200_sc` | Re=200 | 500 | generic | 50/100 | `kar_re200_sc` |
| `kar_re400` | Re=400 | 400 | 实测 | 12/25 | `kar_re400_tr` |
| `kar_re400_sc` | Re=400 | 400 | generic | 50/100 | `kar_re400_sc` |
| `ill_*` | Illusion | — | 实测 | 12/25 | `ill_*_sc` |
---
## Key Findings
1. **SIM_BP 映射是跨场景训练的最关键变量。** 实测 gap ≠ 0.35 会导致 reward 梯度异常。
- Generic SIM_BP `[0, 0.30, 0.65, 0.79, 0.89, 1.0]` (gap=0.35) → 稳定学习
2. **变直径: scratch > transfer.** Generic SIM_BP + K_CD/CL=50/100 + lr=3e-4 + 500ep 是最优配方。
3. **变雷诺数: scratch 和 transfer 各有侧重。** 两者均保留。
4. **变雷诺数存在 CD/CL ↔ r_sim 根本性 trade-off。** 训练越久 r_cd 越高但 r_sim 越低(re60_sc: Ep60 r_sim=0.48 → Ep390 r_sim=0.26, r_cd=0.99)。CD/CL reward 分量主导了优化方向。
5. **降低 W_drag/W_lift (0.1) 有害。** CD/CL reward 是稳定器而非瓶颈。
6. **Ep1 r_sim ≥ 0.5 可预测 transfer 成功。**
-352
View File
@@ -1,352 +0,0 @@
# Karman Cloak Training — Knowledge Document (V5)
> **V5 (2026-07-03)**: Parameterized, calibration-driven, no_bias only.
> All paths use DynamisLab submodule `CelerisLab/` (no external dev dir).
> Cross-Re transfer pipeline verified: re60, re200, re400.
> Every episode saves model checkpoint.
> V4 backups moved to `old/`.
---
## 1. What This Codebase Does
Trains a PPO agent to control a fluidic pinball (3 rotating cylinders) to achieve
hydrodynamic cloaking — making the downstream flow match the "undisturbed" flow
(as if the pinball weren't there). The upstream disturbance cylinder generates a
Kármán vortex street; the pinball must cancel it.
- **CFD**: CelerisLab LBM solver (DynamisLab submodule), D2Q9 MRT, 2000x600 grid, uniform inlet, free-slip walls
- **DRL**: PPO with Sin activation, 64x64 MLP, SB3 + VecNormalize
- **V5 mode**: No_bias only (ACTION_SCALE=12, ACTION_BIAS=[0,0,0]). All cases use calibration-first workflow.
## 0. Quick Start
### 0.1 Single-Re training (re100)
```bash
# 1. Calibrate (~5 min)
cd src/drl_pinball/train
conda run -n pycuda_3_10 python calibrate.py \
--case re100 --device-id 0 \
--config configs/config_lbm_karman_2000x600.json
# 2. Multi-seed training (server, sequential 7-min delay between GPUs)
bash launch_multi.sh \
--case-name re100_karman --seeds 42,43,44,45,46,47 \
--gpus 0,1,2,3,4,5 --episodes 500 \
--config configs/config_lbm_karman_2000x600.json \
--calibration calibrations/re100/calibration.json
# 3. Monitor
tail -f output/re100_karman_seed42/train.log
tensorboard --logdir output/re100_karman_seed42/tb --port 6006
```
### 0.2 Cross-Re transfer (re60, re200, re400)
```bash
# Local test (5 episodes each, quick verification):
bash crossre_transfer.sh --re-list 60,200,400 --test-episodes 5
# Server production (200 episodes each):
# !! BEFORE PUSHING TO SERVER: update BEST_MODEL path in crossre_transfer.sh !!
bash crossre_transfer.sh --re-list 60,200,400
# Then push to server and run there.
```
### 0.3 Path notes for server deployment
`crossre_transfer.sh` has relative paths via `SCRIPT_DIR`/`REPO_DIR`.
Only `BEST_MODEL` needs updating — point to the best Re100 model from
multi-seed training (e.g. `output/re100_karman_seed42/models/best_model.zip`).
---
## 2. File Structure (V5 Final)
```
train/
├── __init__.py
├── # ACTIVE FILES
├── calibrate.py # Phase 0 calibration (produces calibration.json + target.npy)
├── env_karman.py # Parameterized Karman cloak env
├── env_illusion.py # Parameterized Illusion env
├── env_vortex.py # Vortex cloak env (lamb/taylor, MAX_STEPS=150)
├── train_karman.py # Parameterized training script (every ep saves model)
├── train_illusion.py # Illusion training script
├── launch_multi.sh # Sequential multi-GPU server launcher
├── crossre_transfer.sh # Cross-Re transfer: calibrate + train (re60→re200→re400)
├── symmetry_wrapper.py # G-mirror symmetry augmentation (per-rollout)
├── visualize_and_analyze.py # Flow-field visualization & analysis
├── SERVER_DEPLOY.md # Server deployment instructions
├── TRAIN_KNOWLEDGE.md # This file
├── old/ # Archived V4 files (NOT active)
│ ├── env_karman_2000x600.py
│ ├── train_karman_2000x600.py
│ ├── phase0_baseline_measure.py
│ └── analyze_final.py
├── calibrations/ # Per-case calibration files (IMMUTABLE)
│ ├── re60/
│ │ ├── calibration.json # SI=800, FORCE_SCALE=0.0021, SENS_SCALE=0.72
│ │ ├── target.npy
│ │ └── calibrate.log
│ ├── re100/
│ │ ├── calibration.json # SI=800, FORCE_SCALE=0.0024, SENS_SCALE=0.75
│ │ └── calibrate.log
│ ├── re200/
│ │ ├── calibration.json # SI=500, FORCE_SCALE=0.0026, SENS_SCALE=0.90
│ │ ├── target.npy
│ │ └── calibrate.log
│ ├── re400/
│ │ ├── calibration.json # SI=400, FORCE_SCALE=0.0042, SENS_SCALE=0.98
│ │ ├── target.npy
│ │ └── calibrate.log
│ └── illusion_1L/
│ ├── calibration.json
│ ├── target.npy
│ ├── target_harmonics.json
│ └── calibrate.log
└── output/ # Training outputs
└── <case>_seed<N>/
├── models/ # ep0001_model.zip, ..., best_model.zip, final_model.zip
├── tb/ # TensorBoard logs
├── train.log
├── calibration.json
├── vec_normalize.pkl
└── meta.json
```
---
## 3. Calibration Workflow (ALWAYS RUN FIRST)
Every case MUST run `calibrate.py` before training. This produces:
- `calibration.json`: FORCE_SCALE, SENS_SCALE, dtw_norm_scale, SIM_BP, SIM_VAL, reward constants
- `target.npy`: Target sensor signals (150 steps x 6 channels)
The calibration measures Stage0 (zero rotation) and Stage1 (open-loop reference) to compute normalization
constants. Calibration is **IMMUTABLE** — once produced, never modify.
### Calibration Results (all Re)
| Case | SI | FORCE_SCALE | SENS_SCALE | dtw_norm_scale | Stage0 sim | Stage1 sim |
|------|-----|-------------|------------|----------------|-----------|-----------|
| re60 | 800 | 0.0021 | 0.72 | 0.107 | 0.41 | 0.61 |
| re100 | 800 | 0.0024 | 0.75 | 0.204 | 0.32 | 0.82 |
| re200 | 500 | 0.0026 | 0.90 | 0.269 | 0.45 | 0.77 |
| re400 | 400 | 0.0042 | 0.98 | 0.310 | 0.56 | 0.73 |
### Cross-Re SI guidance
Based on ~18 samples per vortex shedding cycle:
| Case | SI | Rationale |
|------|----|-----------|
| re60 | 800 | FFT shows very weak/absent shedding at this Re with free-slip |
| re100 | 800 | Verified (~19 samples/cycle) |
| re200 | 500 | ~18 samples/cycle |
| re400 | 400 | ~18 samples/cycle |
### re60 note
At Re=60 with uniform inlet + free-slip walls, the upstream disturbance cylinder
produces very weak periodic shedding (FFT dominant period ~5625 samples).
This is a different regime from legacy parabolic+no-slip where re50 did shed.
SI=800 is adequate; the DRL essentially learns a steady-state control policy.
---
## 4. Reward Design
### Formula
```python
# Gaussian reward (no zero-crossing spikes)
r_cd_raw = exp(-cd_norm² * K_CD) # cd_norm = (Σfx)/3 / FORCE_SCALE
r_cl_raw = exp(-cl_norm² * K_CL)
# EMA smoothing for cd/cl (r_sim uses DTW, already smooth)
r_cd = EMA(r_cd_raw, weight=0.2)
r_cl = EMA(r_cl_raw, weight=0.2)
# Normalized DTW similarity (piecewise-mapped to [0,1])
r_sim = piecewise_map(sim, SIM_BP, SIM_VAL)
# Floor penalty: prevents sacrificing one component
penalty = 0.05 * sum(max(0, FLOOR - r) / FLOOR for r, FLOOR in zip(...))
reward = max(0, W_CD*r_cd + W_CL*r_cl + W_SIM*r_sim - penalty)
# W_CD=0.30, W_CL=0.30, W_SIM=0.40
```
### Three-stage targets
| Stage | r_cd | r_cl | r_sim | Description |
|-------|------|------|-------|-------------|
| Stage0 (zero rotation) | ~0 | ~0 | ~0.2 | No control baseline |
| Stage1 (reference open-loop) | ~0.7 | ~0.5 | ~0.5 | Legacy-equiv bias |
| Optimal (trained) | ~0.9 | ~0.9 | ~0.9 | Full cloaking |
### Why Gaussian not exp(-|x|)
`exp(-|x|)` has maximum gradient at x=0, causing reward spikes at zero-crossings
of oscillating cd/cl. `exp(-x²)` has zero gradient at x=0 → smooth near optimum.
### Why normalized DTW
Raw DTW has narrow dynamic range (0.70-0.97). Normalizing by target's uy-channel
std extends range to 0.0-0.9, giving DRL meaningful gradient.
### Why no EMA on r_sim
DTW is already a smooth 30-step windowed signal. Adding EMA over-smooths.
---
## 5. PPO Configuration
```python
PPO(
"MlpPolicy",
policy_kwargs={"activation_fn": Sin, "net_arch": [64, 64]},
env=vec_env,
device=torch.device("cuda:X"),
n_steps=2048, # MUST be 2048. 512 → noisy curves.
batch_size=64,
n_epochs=10,
learning_rate=3e-4,
gamma=0.995, # Higher than default for DTW delay propagation
)
```
- **Evaluation**: stochastic (no deterministic=True) — smoother curves
- **Symmetry**: per-rollout G-mirror (50% probability). Disabled during eval.
- **Every episode saves**: `ep0001_model.zip` + `ep0001_vecnormalize.pkl` saved each episode
---
## 6. Obs Normalization (Two-layer)
1. **Env physical norm** (fixed, from calibration):
- forces / FORCE_SCALE, sensors / SENS_SCALE
- No clipping (VecNormalize handles)
2. **SB3 VecNormalize** (running mean/std):
- norm_obs=True, norm_reward=False, clip_obs=10.0
- Saved to `vec_normalize.pkl` for inference
---
## 7. Action Configuration (V5: no_bias only)
```python
ACTION_SCALE = 12.0
ACTION_BIAS = [0, 0, 0]
# omega = -(action * 12) * U0 / RADIUS
# Physical range: all cylinders [-12, 12] × U0
```
Sign convention: `Uw = -omega * ry` (omega>0 = clockwise).
---
## 8. Environment Design
### Two-phase initialization
1. `record_target()`: dist_cyl + sensors only → record 150-step target → close
2. Training Simulation: all 7 objects → warmup → zero-action FIFO → snapshot
Body IDs (add order):
```
0: dist_cyl (force, skipped in obs)
1-3: sensors (top, center, bottom)
4-6: pinball (front, top_rear, bottom_rear)
```
Obs layout (12-dim): forces[6] + sensors[6]
### No-reset training
`step()` returns `terminated=False`. Eval does `reset()` for reproducible assessment.
---
## 9. Cross-Re Transfer Pipeline
### How to add a new Re
```bash
# 1. Create config (copy existing, change viscosity)
cp configs/config_lbm_karman_2000x600.json configs/config_lbm_karman_2000x600_reNNN.json
# Edit "viscosity" to U0 * 2D / Re_NNN
# 2. Calibrate
python calibrate.py --case reNNN --device-id 0 --si <SI> --config <config>
# 3. Test (5 episodes, local)
python train_karman.py --case-name transfer_reNNN --device-id 0 --seed 41 \
--config <config> --calibration calibrations/reNNN/calibration.json \
--si <SI> --total-episodes 5 \
--transfer-model output/re100_karman_seed<BEST>/models/best_model.zip
# 4. Production (add to crossre_transfer.sh or run directly with 200 episodes)
```
### Transfer test results (5-episode verification, 2026-07-02)
| Re | Best Reward | r_cd | r_cl | r_sim | Time/ep |
|----|------------|------|------|-------|---------|
| 60 | 0.637 | 0.879 | 0.402 | 0.641 | 281s |
| 200 | 0.428 | 0.677 | 0.259 | 0.387 | 186s |
| 400 | 0.489 | 0.787 | 0.491 | 0.274 | 153s |
All show rapid learning from Re100 base. r_cl remains the hardest component across all Re.
---
## 10. CelerisLab Integration
**CelerisLab is a git submodule** at `DynamisLab/CelerisLab/`.
All Python imports use `from CelerisLab import Simulation`.
No external paths — everything is self-contained within the repo.
Do NOT reference `/home/frank14f/CelerisLab` anywhere.
---
## 11. Key Parameters Reference
| Parameter | Value | Where | Notes |
|-----------|-------|-------|-------|
| Grid | 2000×600 | config | uniform inlet, free_slip |
| U0 | 0.01 | config | lattice inlet velocity |
| ν (re100) | 0.004 | config | Re_D=50 (code Re=100) |
| SI | 400-800 | calibration | varies by Re |
| FIFO_LEN | 150 | env | history buffer |
| CONV_LEN | 30 | env | DTW comparison window |
| SENSOR_CC | 78 | env | sensor area→legacy conversion |
| K_CD/K_CL | 50/100 | env | Gaussian reward coefficients |
| W_CD/W_CL/W_SIM | 0.30/0.30/0.40 | env | reward weights |
| n_steps | 2048 | train | PPO rollout size |
| n_epochs | 10 | train | PPO epochs per update |
| gamma | 0.995 | train | discount factor |
| ACTION_SCALE | 12.0 | env | no_bias only |
---
## 12. Bugs Found & Fixed
| # | Bug | Symptom | Fix |
|---|-----|---------|-----|
| 1 | Simultaneous GPU startup | Kernel compilation race → reward=0.000 | Sequential launch, 7-min delay |
| 2 | Bias FIFO not applying omega | Wrong normalization baseline | Added set_omega in bias FIFO |
| 3 | DTW not normalized | sim always 0.90+ → flat gradient | Normalize by target uy-channel avg std |
| 4 | Gaussian vs exp(-|x|) | Zero-crossing reward spikes | Use exp(-x² · K) |
| 5 | n_steps=512 | Noisy curves, degradation | Use 2048 (SB3 default) |
| 6 | deterministic eval | Sharp, noisy reward | Stochastic eval |
| 7 | External CelerisLab paths | Permission issues, server mismatch | Use DynamisLab submodule only |
| 8 | Manual kernel cache cleaning | Unnecessary, root-only files | CelerisLab handles internally |
+177
View File
@@ -0,0 +1,177 @@
# Vardist Transfer Learning — 分析报告
> 日期: 2026-07-07
> 实验: 改变上游扰动圆柱尺寸 (0.75L / 1.5L / 2.0L),从 re100 baseline 进行 transfer learning
> 所有实验使用相同超参数和物理配置(仅 dist_radius 不同)
---
## 1. 实验配置总览
### 1.1 共享超参数
| 参数 | 值 |
|------|-----|
| PPO n_steps | 2048 |
| PPO batch_size | 64 |
| PPO n_epochs | 10 |
| Learning rate | 3e-4 |
| Gamma | 0.995 |
| Policy net_arch | [64, 64] |
| Activation | sin |
| Symmetry augmentation | prob=0.5 |
| Episode 数 | 200 (transfer) |
| 基座模型 | re100_karman_seed44 或 seed45 (500ep from scratch, best ≈ 0.92) |
### 1.2 校准参数差异
| 参数 | d075 (0.75L) | re100 (1.0L) | d15 (1.5L) | d2 (2.0L) |
|------|:-----------:|:------------:|:----------:|:---------:|
| **FORCE_SCALE** | 0.0018 | 0.0024 | 0.0036 | 0.0046 |
| **SENS_SCALE** | 0.84 | 0.75 | 0.75 | 0.77 |
| **dtw_norm_scale** | 0.146 | 0.204 | 0.307 | 0.393 |
| **SIM_BP[1]** (zero rot) | 0.25 | 0.32 | 0.30 | 0.30 |
| **SIM_BP[2]** (ref rot) | 0.79 | 0.82 | 0.65 | 0.65 |
| **SIM_BP gap** | **0.54** | 0.50 | **0.35** | **0.35** |
| **SIM_BP 来源** | 实测 | 实测 | generic 兜底 | generic 兜底 |
| **K_CD / K_CL** | 50/100 | 50/100 | 50/100 | 50/100 |
> **关键**: d075 的 SIM_BP 使用了实测值 (gap=0.54),而 d15 和 d2 因为 `spread<0.10``better_sim<0.5` 触发了 calibrate.py 中的 generic fallback (gap=0.35)。
---
## 2. 训练结果
### 2.1 最终指标
| Case | Best Reward | Ep 1 Reward | Ep 1 r_sim | Ep 1 r_cd | 走势质量 |
|------|:-----------:|:-----------:|:----------:|:---------:|:--------:|
| **d15 (1.5L)** | **0.7265** (Ep 198) | 0.570 | 0.748 | 0.650 | ✅ 稳定单调上升 |
| **d2 (2.0L)** | **0.4696** (Ep 2!) | 0.301 | 0.455 | 0.288 | ❌ Ep 2 peak 后持续退化 |
| **d075 (0.75L)** | **0.4175** (Ep 153) | 0.023 | 0.053 | 0.012 | ❌ 持续 peak-crash 震荡 |
### 2.2 对比基线:跨 Re transfer (来自同一基座模型)
| Case | Best Reward | 走势 |
|------|:-----------:|:----:|
| re60 transfer | **0.724** | ✅ 稳定上升 |
| re200 transfer | **0.499** | ⚠️ 缓慢但稳定 |
### 2.3 Reward 走势图 (定性)
```
d15: ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ───→ 0.727 (稳步爬坡)
d2: ▄▀▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ───→ 0.470 (Ep2 昙花一现,后再未超越)
d075: ▁▁▁▁▂▁▂▁▂▁▂▁▁▂▁▁▁▂ ───→ 0.418 (震荡不止,末班 0.053)
```
---
## 3. 诊断分析
### 3.1 ✅ d15 (1.5L) — 成功案例
**为什么成功:**
1. 上游圆柱放大 → 1.0L 的专家策略"不够用但方向对",可以在此基础上升级
2. SIM_BP 使用 generic mapping (gap=0.35)reward 梯度均匀且有意义
3. Ep 1 r_sim=0.748 说明初始策略在 1.5L 尾流下已经有相当不错的信号重建能力
4. r_cd 从 0.65 涨到 0.92r_cl 从 0.27 涨到 0.48,三个 reward 分量协作上升
### 3.2 ❌ d075 (0.75L) — 最差案例
**根因 1 — SIM_BP 映射问题 (最主要)**
d075 是唯一一个使用实测 SIM_BP 的 case。测得的 zero-rotation similarity=0.25reference rotation=0.79gap=0.54。
这意味着 reward 映射中 SIM_VAL 从 0.2→0.5 覆盖了 0.54 的真实 similarity 跨度,梯度被显著摊薄。当 PPO 在 reward 空间中观测到的改进信号极小时,policy 更新方向近乎随机——这完美解释了反复的 peak-crash 行为。
**根因 2 — 物理域不匹配**
0.75L 扰动圆柱产生的卡门涡街更弱更窄(FORCE_SCALE=0.0018,比 1.0L 低 25%)。而 pinball 圆柱仍是 1.0L 固定尺寸,它们的旋转对弱流场的控制可能"过强"——类似于用力关门但门很轻。1.0L 的专家策略不适用于小扰动场景。
**根因 3 — 基座策略已接近局部最优**
reference rotation 在 0.75L 场景下已给出 similarity=0.79(接近 re100 baseline 的 0.82)。DRL 要超越一个已经很接近 1.0L baseline 水平的开环策略,且 reward 梯度又被摊薄,双重不利。
**证据 — Ep 1 collapse**
Ep 1 reward=0.023r_cd=0.012r_sim=0.053。这说明 1.0L 的 expert policy 在 0.75L 场景下几乎完全不适用,不是在微调而是在重新学习。
### 3.3 ❌ d2 (2.0L) — 早期 peak 后退化
**根因 1 — 几何差异导致初始 mismatch**
2.0L 圆柱是 1.0L 的两倍,FORCE_SCALE=0.0046(近乎翻倍)。Ep 1 r_sim=0.455vs d15 的 0.748),说明在如此大差异下 transfer 效果甚微,几乎从接近 scratch 的状态开始。
**根因 2 — Ep 2 的 0.470 是偶发性好运**
```
Ep 2: 0.470 BEST
Ep 50: 0.138
Ep 125: 0.299 (轻微恢复)
Ep 200: 0.246
```
在独占 GPU 又跑了 150ep 后,从未超越 Ep 2 的 0.470。"早期 peak 后持续退化"的模式说明 Ep 2 的高 reward 是一次偶然发现的好策略,PPO 之后的探索却撞进了 reward 更低的方向且无力返回。
---
## 4. Ep 1 Metrics 作为先行指标的发现
| Case | Ep 1 r_sim | Ep 1 r_cd | 最终 Best | Transfer 成功? |
|------|:----------:|:---------:|:---------:|:--------------:|
| d15 | 0.748 | 0.650 | 0.727 | ✅ |
| d2 | 0.455 | 0.288 | 0.470 | ❌ |
| d075 | 0.053 | 0.012 | 0.417 | ❌ |
| re60 | 0.236 | 0.793 | 0.724 | ✅ |
**推论**: Ep 1 r_sim 是预测 transfer 能否成功的强信号。r_sim≥0.5 的 case 最终都达到了 0.7+r_sim<0.5 的 case 均未超过 0.47。例外是 re60 —— 虽然 r_sim 不高 (0.236),但几何不变,PPO 可以快速"调回"相似度。
---
## 5. 改进建议
### 5.1 针对 d075 (0.75L)
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 强制使用 generic SIM_BP mapping | 消除 SIM_BP gap=0.54 导致的平坦梯度问题 (即修改 calibrate.py 或手动替换 calibration.json 中 SIM_BP 为 [0, 0.30, 0.65, 0.79, 0.89, 1.0]) |
| **P1** | 降低 learning rate 至 1e-4 | 减少 PPO 在高方差环境中的 over-shoot,缓解 peak-crash |
| **P2** | 增加 episode 数至 500 | 当前 200ep 明显不足以收敛 |
| **P3** | 不 transferfrom scratch | 因为 Ep 1 已证明 re100 policy 对 d075 高度不适配 (reward=0.023)scratch 训练可能更稳定 |
### 5.2 针对 d2 (2.0L)
| 优先级 | 方案 | 原理 |
|:------:|------|------|
| **P0** | 不 transferfrom scratch + 500ep | Ep 1 初始 mismatch 太大,从头学习可能更高效 |
| **P1** | transfer 但降低 lr=1e-4 + 更多 episode | 如果仍用 transfer,更保守的更新速率有助于保持策略稳定 |
| **P2** | 加强 symmetry augmentation (prob=0.7) | 2.0L 尾流更宽,采样空间更大,更强的对称正则化可能帮助泛化 |
| **P3** | 调整 K_CD/K_CL 以匹配 2.0L 的力尺度 | FORCE_SCALE 翻倍但 K 值不变,reward 的分量权重可能已不平衡 |
### 5.3 通用改进
| 优先级 | 方案 | 适用范围 |
|:------:|------|---------|
| **P0** | 给 calibrate.py 的 generic SIM_BP 兜底条件加宽阈值 | 所有 future case,避免 d075 式的 SIM_BP gap 问题再次出现 |
| **P1** | 观察 Ep 1 metrics 作为 transfer 可行性的先行判断 | 如果 Ep 1 r_sim<0.5,建议直接 from scratch 而非 transfer |
| **P2** | 对不同直径使用不同的 K_CD/K_CL 缩放 | FORCE_SCALE 变化时,reward 中 drag/lift 贡献的比例可能需要调整 |
| **P3** | 启用 VecNormalize reward normalization | 当前 `norm_reward=False`,对小 reward 场景可能会让 PPO 的信号强度不足 |
---
## 6. 关键文件索引
| 文件 | 路径 |
|------|------|
| 训练脚本 | `train_karman.py` |
| 环境 | `env_karman.py` |
| 校准脚本 | `calibrate.py` (generic SIM_BP 兜底逻辑在第 526-535 行) |
| Transfer 启动脚本 | `vardist_transfer.sh` |
| d075 校准 | `calibrations/karman_d075/calibration.json` |
| d15 校准 | `calibrations/karman_d15/calibration.json` |
| d2 校准 | `calibrations/karman_d2/calibration.json` |
| re100 基线校准 | `calibrations/re100/calibration.json` |
| d075 训练日志 | `output/transfer_karman_d075_seed44/train.log` |
| d15 训练日志 | `output/transfer_karman_d15_seed45/train.log` |
| d2 训练日志 | `output/transfer_karman_d2_seed45/train.log` |
+36 -12
View File
@@ -386,6 +386,8 @@ def main() -> int:
help="Scene type (karman or illusion)")
parser.add_argument("--target-diam", type=float, default=1.0,
help="Target cylinder diameter in L0 units (illusion only, default=1.0)")
parser.add_argument("--dist-radius", type=float, default=1.0,
help="Disturbance cylinder radius in L0 units (karman only, default=1.0)")
args = parser.parse_args()
case = args.case
@@ -419,9 +421,11 @@ def main() -> int:
# ---- Step 1: Record target (Karman: dist_cyl + sensors only) ----
log("Step 1: Recording target signal...")
dist_radius = args.dist_radius * L0
log(f" Dist cylinder radius = {args.dist_radius}L = {dist_radius:.1f} lattice")
sim = Simulation(lbm_config_path=config_path, device_id=device_id)
sim._assert_object_count_contract = lambda *a, **kw: None
dist_id_t = sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
dist_id_t = sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=dist_radius)
s0_t = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0)
s1_t = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0)
s2_t = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0)
@@ -453,7 +457,7 @@ def main() -> int:
log("Step 2: Creating training sim (all 7 objects)...")
sim = Simulation(lbm_config_path=config_path, device_id=device_id)
sim._assert_object_count_contract = lambda *a, **kw: None
dist_id = sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
dist_id = sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=dist_radius)
sensor_ids = [
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0),
@@ -505,15 +509,34 @@ def main() -> int:
log(f" Stage0 sim_mean = {s0_sim:.4f}")
log(f" Stage1 sim_mean = {s1_sim:.4f}")
sim_bp = [0.0, s0_sim, s1_sim,
s1_sim + (1.0 - s1_sim) * 0.4,
s1_sim + (1.0 - s1_sim) * 0.7,
1.0]
sim_val = [0.0, 0.2, 0.5, 0.8, 0.9, 1.0]
# Build SIM_BP: always [0, worst_measured, better_measured, ..., 1.0]
if s1_sim < s0_sim:
worst_sim = s1_sim
better_sim = s0_sim
else:
worst_sim = s0_sim
better_sim = s1_sim
for i in range(1, len(sim_bp)):
if sim_bp[i] <= sim_bp[i - 1]:
sim_bp[i] = sim_bp[i - 1] + 0.01
# When stage0 and stage1 are too close (|s1-s0| < 0.05), the measured
# SIM_BP collapses to near-zero dynamic range. Fall back to a generic
# mapping anchored on the well-behaved re100 baseline (s0~0.32, s1~0.82).
# This gives the DRL a meaningful reward gradient regardless of the
# open-loop baselines.
spread = abs(s1_sim - s0_sim)
if spread < 0.10 or better_sim < 0.5:
log(f" Spread={spread:.3f} too small or better<0.5 — using generic SIM_BP mapping")
sim_bp = [0.0, 0.30, 0.65, 0.79, 0.89, 1.0]
sim_val = [0.0, 0.20, 0.50, 0.80, 0.90, 1.0]
else:
sim_bp = [0.0, worst_sim, better_sim,
better_sim + (1.0 - better_sim) * 0.4,
better_sim + (1.0 - better_sim) * 0.7,
1.0]
sim_val = [0.0, 0.2, 0.5, 0.8, 0.9, 1.0]
for i in range(1, len(sim_bp)):
if sim_bp[i] <= sim_bp[i - 1]:
sim_bp[i] = sim_bp[i - 1] + 0.01
log(f" SIM_BP (raw) = {[f'{x:.4f}' for x in sim_bp]}")
log(f" SIM_VAL = {sim_val}")
@@ -540,6 +563,7 @@ def main() -> int:
# ---- Step 6: Write calibration.json ----
calibration = {
"case": case,
"dist_radius": args.dist_radius,
"grid": {"nx": NX, "ny": NY},
"config_path": config_path,
"SI": si,
@@ -551,8 +575,8 @@ def main() -> int:
"dtw_norm_scale": float(dtw_norm_scale),
"SIM_BP": [float(x) for x in sim_bp],
"SIM_VAL": [float(x) for x in sim_val],
"K_CD": K_CD_ILLUSION,
"K_CL": K_CL_ILLUSION,
"K_CD": K_CD,
"K_CL": K_CL,
"W_CD": W_CD,
"W_CL": W_CL,
"W_SIM": W_SIM,
@@ -1,5 +1,5 @@
{
"case": "illusion_075L",
"case": "ill_075L",
"scene": "illusion",
"target_diam": 0.75,
"grid": {
@@ -1,5 +1,5 @@
{
"case": "illusion_15L",
"case": "ill_15L",
"scene": "illusion",
"target_diam": 1.5,
"grid": {
@@ -1,5 +1,5 @@
{
"case": "illusion_1L",
"case": "ill_1L",
"scene": "illusion",
"target_diam": 1.0,
"grid": {
@@ -1,5 +1,5 @@
{
"case": "illusion_2L",
"case": "ill_2L",
"scene": "illusion",
"target_diam": 2.0,
"grid": {
@@ -0,0 +1,50 @@
{
"case": "kar_d075_sc",
"dist_radius": 0.75,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0018,
"SENS_SCALE": 0.84,
"dtw_norm_scale": 0.146,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -0,0 +1,50 @@
{
"case": "kar_d075_tr",
"dist_radius": 0.75,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/home/frank14f/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0018,
"SENS_SCALE": 0.84,
"dtw_norm_scale": 0.146,
"SIM_BP": [
0.0,
0.25,
0.79,
0.88,
0.94,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -0,0 +1,50 @@
{
"case": "kar_d15_sc",
"dist_radius": 1.5,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0036,
"SENS_SCALE": 0.75,
"dtw_norm_scale": 0.307,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -0,0 +1,50 @@
{
"case": "kar_d15_tr",
"dist_radius": 1.5,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/home/frank14f/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0036,
"SENS_SCALE": 0.75,
"dtw_norm_scale": 0.307,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -0,0 +1,50 @@
{
"case": "kar_d2_sc",
"dist_radius": 2.0,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0046,
"SENS_SCALE": 0.77,
"dtw_norm_scale": 0.393,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -0,0 +1,50 @@
{
"case": "kar_d2_tr",
"dist_radius": 2.0,
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/home/frank14f/DynamisLab/configs/config_lbm_karman_2000x600.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0046,
"SENS_SCALE": 0.77,
"dtw_norm_scale": 0.393,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -1,5 +1,5 @@
{
"case": "re100",
"case": "kar_re100",
"grid": {
"nx": 2000,
"ny": 600
@@ -1,5 +1,5 @@
{
"case": "re200",
"case": "kar_re200",
"grid": {
"nx": 2000,
"ny": 600
@@ -0,0 +1,49 @@
{
"case": "kar_re200_sc",
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600_re200.json",
"SI": 500,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0026,
"SENS_SCALE": 0.9,
"dtw_norm_scale": 0.269,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -1,5 +1,5 @@
{
"case": "re400",
"case": "kar_re400",
"grid": {
"nx": 2000,
"ny": 600
@@ -0,0 +1,49 @@
{
"case": "kar_re400_sc",
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600_re400.json",
"SI": 400,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0042,
"SENS_SCALE": 0.98,
"dtw_norm_scale": 0.31,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
@@ -1,5 +1,5 @@
{
"case": "re60",
"case": "kar_re60",
"grid": {
"nx": 2000,
"ny": 600
@@ -0,0 +1,49 @@
{
"case": "kar_re60_sc",
"grid": {
"nx": 2000,
"ny": 600
},
"config_path": "/root/private_data/DynamisLab/configs/config_lbm_karman_2000x600_re60.json",
"SI": 800,
"FIFO_LEN": 150,
"CONV_LEN": 30,
"SENSOR_CC": 78.0,
"FORCE_SCALE": 0.0021,
"SENS_SCALE": 0.72,
"dtw_norm_scale": 0.107,
"SIM_BP": [
0.0,
0.3,
0.65,
0.79,
0.89,
1.0
],
"SIM_VAL": [
0.0,
0.2,
0.5,
0.8,
0.9,
1.0
],
"K_CD": 50.0,
"K_CL": 100.0,
"W_CD": 0.3,
"W_CL": 0.3,
"W_SIM": 0.4,
"FLOOR_CD": 0.1,
"FLOOR_CL": 0.1,
"FLOOR_SIM": 0.1,
"FLOOR_PENALTY": 0.05,
"ACTION_SCALE": 12.0,
"ACTION_BIAS": [
0.0,
0.0,
0.0
],
"U0": 0.01,
"RADIUS": 10.0,
"L0": 20.0
}
-119
View File
@@ -1,119 +0,0 @@
#!/bin/bash
# Sequential cross-Re transfer learning: Re60 -> Re200 -> Re400
# Each: calibrate (~5 min) + train N episodes
#
# Usage (local test, 5 episodes):
# bash crossre_transfer.sh --re-list 60 --test-episodes 5
#
# Usage (production on server, 200 episodes each):
# bash crossre_transfer.sh --re-list 60,200,400
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TRAIN_DIR="$SCRIPT_DIR"
CONFIG_DIR="$REPO_DIR/configs"
# TODO: BEFORE PUSHING TO SERVER, update BEST_MODEL to the correct path
BEST_MODEL="$TRAIN_DIR/output/re100_karman_seed41/models/best_model.zip"
GPU=0
EPISODES=200
TEST_EPISODES=0
RE_LIST=""
LOG_BASE="/tmp/crossre_transfer"
CONDA_ENV="pycuda_3_10"
usage() {
echo "Usage: $0 [--re-list 60,200,400] [--test-episodes N] [--best-model PATH]"
echo " --re-list Comma-separated Re numbers (default: 60,200,400)"
echo " --test-episodes Run only N episodes per Re for quick verification"
echo " --best-model Path to Re100 best model .zip"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--re-list) RE_LIST="$2"; shift 2 ;;
--test-episodes) TEST_EPISODES="$2"; shift 2 ;;
--best-model) BEST_MODEL="$2"; shift 2 ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
if [[ -z "$RE_LIST" ]]; then
RE_LIST="60,200,400"
fi
IFS=',' read -ra RE_ARR <<< "$RE_LIST"
echo "=== Cross-Re Transfer ==="
echo " Re list: ${RE_ARR[*]}"
echo " Re60: base=seed43, train=seed43"
echo " Re200: base=seed45, train=seed45"
echo " Re400: base=seed43, train=seed43"
echo " Episodes: ${EPISODES} (test-mode: ${TEST_EPISODES})"
echo " GPU: ${GPU}"
echo ""
mkdir -p "$LOG_BASE"
if [[ "$TEST_EPISODES" -gt 0 ]]; then
EPISODES="$TEST_EPISODES"
echo " TEST MODE: only $TEST_EPISODES episodes per Re"
fi
for re in "${RE_ARR[@]}"; do
case $re in
60) SI=800; vis_label="re60"; TRAIN_SEED=43
BEST_MODEL="$TRAIN_DIR/output/re100_karman_seed43/models/best_model.zip" ;;
200) SI=500; vis_label="re200"; TRAIN_SEED=45
BEST_MODEL="$TRAIN_DIR/output/re100_karman_seed45/models/best_model.zip" ;;
400) SI=400; vis_label="re400"; TRAIN_SEED=43
BEST_MODEL="$TRAIN_DIR/output/re100_karman_seed43/models/best_model.zip" ;;
*) echo "ERROR: Unknown Re=$re (supported: 60, 200, 400)"; exit 1 ;;
esac
CONFIG="$CONFIG_DIR/config_lbm_karman_2000x600_${vis_label}.json"
CASE="transfer_${vis_label}"
LOG="$LOG_BASE/${vis_label}_seed${TRAIN_SEED}.log"
if [[ ! -f "$BEST_MODEL" ]]; then
echo "ERROR: Best model not found: $BEST_MODEL" | tee -a "$LOG_BASE/summary.log"
exit 1
fi
echo "=== $(date): Starting $CASE (SI=$SI) ===" | tee -a "$LOG"
if [[ ! -f "$CONFIG" ]]; then
echo " ERROR: Config not found: $CONFIG" | tee -a "$LOG"
exit 1
fi
# Step 1: Calibrate (skip if calibration.json already exists)
CAL_JSON="$TRAIN_DIR/calibrations/$vis_label/calibration.json"
if [[ -f "$CAL_JSON" ]]; then
echo " [SKIP] Calibration already exists: $CAL_JSON" | tee -a "$LOG"
echo " (Delete calibrations/$vis_label/ to force re-calibration)"
else
echo " [$(date '+%H:%M:%S')] Calibrating..." | tee -a "$LOG"
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/calibrate.py" \
--case "$vis_label" --device-id $GPU --si $SI \
--config "$CONFIG" >> "$LOG" 2>&1
echo " [$(date '+%H:%M:%S')] Calibration done." | tee -a "$LOG"
fi
# Step 2: Train with transfer
echo " [$(date '+%H:%M:%S')] Training ${EPISODES} episodes..." | tee -a "$LOG"
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id $GPU --seed $TRAIN_SEED \
--config "$CONFIG" \
--calibration "$CAL_JSON" \
--si $SI --total-episodes $EPISODES \
--transfer-model "$BEST_MODEL" >> "$LOG" 2>&1
echo " [$(date '+%H:%M:%S')] Training done." | tee -a "$LOG"
echo "=== $(date): $CASE complete ===" | tee -a "$LOG"
echo ""
done
echo "=== ALL DONE ===" | tee -a "$LOG_BASE/summary.log"
+5 -3
View File
@@ -114,12 +114,13 @@ class ActionSmoother:
# ---------------------------------------------------------------------------
def record_target(config_path: str, device_id: int, si: int) -> np.ndarray:
def record_target(config_path: str, device_id: int, si: int,
dist_radius: float = 1.0) -> np.ndarray:
"""Record target signal (dist_cyl + sensors, no pinball)."""
warmup = int(4.0 * NX / U0)
sim = Simulation(lbm_config_path=config_path, device_id=device_id)
sim._assert_object_count_contract = lambda *a, **kw: None
sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=dist_radius * L0)
s0 = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0)
s1 = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0)
s2 = sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0)
@@ -236,7 +237,8 @@ class KarmanCloakEnv(gym.Env):
self.sim = Simulation(lbm_config_path=self._config_path, device_id=self.device_id)
self.sim._assert_object_count_contract = lambda *a, **kw: None
self.dist_id = self.sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
dist_radius = float(self._cal.get("dist_radius", 1.0))
self.dist_id = self.sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=dist_radius * L0)
self.sensor_ids = [
self.sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0),
self.sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0),
-81
View File
@@ -1,81 +0,0 @@
#!/bin/bash
# Extend cross-Re transfer training from best_model (all seed43).
# Re60: +200ep, Re200: +400ep, Re400: +400ep. Sequential GPU 0.
#
# Conservative fine-tune: lr=1e-4 (↓3x), n_epochs=5 (↓2x)
# Symmetry-prob keeps default 0.5.
#
# Output naming: transfer_re60ext → train_karman appends _seed43
# → transfer_re60ext_seed43, transfer_re200ext_seed43, transfer_re400ext_seed43
#
# Timetable (SI → s/ep @ n_epochs=5 → total):
# Re60 SI=800 ~100s/ep → 200ep ≈ 5.5h
# Re200 SI=500 ~ 68s/ep → 400ep ≈ 7.5h
# Re400 SI=400 ~ 60s/ep → 400ep ≈ 6.7h
# ─────────────────────────────────────────
# Total ≈ 19.7h
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CONFIG_DIR="$REPO_DIR/configs"
TRAIN_DIR="$SCRIPT_DIR"
GPU=0
SEED=43
CONDA_ENV="pycuda_3_10"
LOG_BASE="/tmp/extend_transfer"
declare -A RE_SI RE_EP
RE_SI[60]=800; RE_EP[60]=200
RE_SI[200]=500; RE_EP[200]=400
RE_SI[400]=400; RE_EP[400]=400
RE_LIST="${1:-60,200,400}"
IFS=',' read -ra RE_ARR <<< "$RE_LIST"
mkdir -p "$LOG_BASE"
echo "=== Extend Cross-Re Transfer (seed43) ==="
echo " Output dirs: transfer_{re}ext_seed43/"
echo " Hyperparams: lr=1e-4, n_epochs=5, symmetry=0.5"
echo " Re60: +200ep (~5.5h, SI=800)"
echo " Re200: +400ep (~7.5h, SI=500)"
echo " Re400: +400ep (~6.7h, SI=400)"
echo " ────────────────────────────────"
echo " Total: ~19.7h"
echo ""
for re in "${RE_ARR[@]}"; do
vis="re${re}"
si=${RE_SI[$re]}
extra=${RE_EP[$re]}
CONFIG="$CONFIG_DIR/config_lbm_karman_2000x600_${vis}.json"
CAL_JSON="$TRAIN_DIR/calibrations/${vis}/calibration.json"
CASE="transfer_${vis}"
BEST_SRC="$TRAIN_DIR/output/${CASE}_seed${SEED}/models/best_model.zip"
EXT_CASE="${CASE}ext" # → transfer_re60ext_seed43
if [[ ! -f "$BEST_SRC" ]]; then
echo "[SKIP] ${vis}: best_model not found — ${CASE}_seed${SEED} (still training?)"
continue
fi
LOG="$LOG_BASE/${vis}_ext.log"
echo "=== $(date): Extending ${vis} +${extra}ep (SI=$si) ===" | tee -a "$LOG"
echo " Source: $BEST_SRC" | tee -a "$LOG"
echo " Output: ${EXT_CASE}_seed${SEED}/" | tee -a "$LOG"
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$EXT_CASE" --device-id $GPU --seed $SEED \
--config "$CONFIG" --calibration "$CAL_JSON" \
--si $si --total-episodes $extra \
--lr 1e-4 --n-epochs 5 \
--transfer-model "$BEST_SRC" >> "$LOG" 2>&1
echo " [$(date '+%H:%M:%S')] Done." | tee -a "$LOG"
echo ""
done
echo "=== ALL EXTENSIONS DONE ===" | tee -a "$LOG_BASE/summary.log"
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
# Launch Illusion 0.75L training on GPU2, seed 43, 500 episodes
# Usage: bash launch_075L.sh
# !! Wait 2 min after launching 2L on GPU0 before running this script
# (to avoid kernel compilation race)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CASE="illusion_075L"
SEED=43
DEVICE=2
CONFIG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
CAL="$SCRIPT_DIR/calibrations/$CASE/calibration.json"
OUT_DIR="$SCRIPT_DIR/output/${CASE}_seed${SEED}"
mkdir -p "$OUT_DIR"
cd "$SCRIPT_DIR"
nohup conda run --no-capture-output -n pycuda_3_10 python -u train_illusion.py \
--case-name "$CASE" \
--device-id "$DEVICE" \
--seed "$SEED" \
--total-episodes 500 \
--config "$CONFIG" \
--calibration "$CAL" \
> "$OUT_DIR/nohup.log" 2>&1 &
echo "0.75L launched on GPU$DEVICE, seed=$SEED, PID=$!"
echo "Monitor: tail -f output/${CASE}_seed${SEED}/train.log"
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
# Launch Illusion 1.5L training on GPU1, seed 43, 500 episodes
# Usage: bash launch_15L.sh
# !! Wait 2 min after launching 1L on GPU0 before running this script
# (to avoid kernel compilation race)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CASE="illusion_15L"
SEED=43
DEVICE=1
CONFIG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
CAL="$SCRIPT_DIR/calibrations/$CASE/calibration.json"
OUT_DIR="$SCRIPT_DIR/output/${CASE}_seed${SEED}"
mkdir -p "$OUT_DIR"
cd "$SCRIPT_DIR"
nohup conda run --no-capture-output -n pycuda_3_10 python -u train_illusion.py \
--case-name "$CASE" \
--device-id "$DEVICE" \
--seed "$SEED" \
--total-episodes 500 \
--config "$CONFIG" \
--calibration "$CAL" \
> "$OUT_DIR/nohup.log" 2>&1 &
echo "1.5L launched on GPU$DEVICE, seed=$SEED, PID=$!"
echo "Monitor: tail -f output/${CASE}_seed${SEED}/train.log"
-30
View File
@@ -1,30 +0,0 @@
#!/bin/bash
# Launch Illusion 1L training on GPU0, seed 43, 500 episodes
# Usage: bash launch_1L.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CASE="illusion_1L"
SEED=43
DEVICE=0
CONFIG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
CAL="$SCRIPT_DIR/calibrations/$CASE/calibration.json"
OUT_DIR="$SCRIPT_DIR/output/${CASE}_seed${SEED}"
mkdir -p "$OUT_DIR"
cd "$SCRIPT_DIR"
nohup conda run --no-capture-output -n pycuda_3_10 python -u train_illusion.py \
--case-name "$CASE" \
--device-id "$DEVICE" \
--seed "$SEED" \
--total-episodes 500 \
--config "$CONFIG" \
--calibration "$CAL" \
> "$OUT_DIR/nohup.log" 2>&1 &
echo "1L launched on GPU$DEVICE, seed=$SEED, PID=$!"
echo "Monitor: tail -f output/${CASE}_seed${SEED}/train.log"
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
# Launch Illusion 2L training on GPU0, seed 43, 500 episodes
# Usage: bash launch_2L.sh
# !! Wait 2 min after launching 15L on GPU1 before running this script
# (to avoid kernel compilation race)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
CASE="illusion_2L"
SEED=43
DEVICE=0
CONFIG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
CAL="$SCRIPT_DIR/calibrations/$CASE/calibration.json"
OUT_DIR="$SCRIPT_DIR/output/${CASE}_seed${SEED}"
mkdir -p "$OUT_DIR"
cd "$SCRIPT_DIR"
nohup conda run --no-capture-output -n pycuda_3_10 python -u train_illusion.py \
--case-name "$CASE" \
--device-id "$DEVICE" \
--seed "$SEED" \
--total-episodes 500 \
--config "$CONFIG" \
--calibration "$CAL" \
> "$OUT_DIR/nohup.log" 2>&1 &
echo "2L launched on GPU$DEVICE, seed=$SEED, PID=$!"
echo "Monitor: tail -f output/${CASE}_seed${SEED}/train.log"
-141
View File
@@ -1,141 +0,0 @@
#!/usr/bin/env bash
# launch_multi.sh — Sequential multi-GPU training launcher for server deployment.
#
# Starts Karman Cloak training on multiple GPUs sequentially, with a configurable
# delay between launches to avoid CelerisLab kernel compilation race conditions.
#
# Usage:
# bash launch_multi.sh --case-name re100_karman --seeds 42,43,44,45,46,47 \
# --gpus 0,1,2,3,4,5 --episodes 500 \
# --config configs/config_lbm_karman_2000x600.json \
# --calibration calibrations/re100/calibration.json
#
# # Transfer learning from a base model
# bash launch_multi.sh --case-name re200_karman --seeds 42,43,44 \
# --gpus 0,1,2 --episodes 500 \
# --config configs/config_lbm_karman_2000x600_re200.json \
# --calibration calibrations/re200/calibration.json \
# --transfer output/re100_karman_seed42/models/best_model.zip
#
# Requirements:
# - conda env pycuda_3_10
# - CelerisLab submodule at DynamisLab/CelerisLab
# - train_karman.py, env_karman.py, symmetry_wrapper.py in same directory
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Defaults ---
CASE_NAME=""
SEEDS=""
GPUS=""
EPISODES=500
CONFIG=""
CALIBRATION=""
TRANSFER=""
DELAY_SECONDS=420 # 7 minutes between launches
CONDA_ENV="pycuda_3_10"
usage() {
echo "Usage: $0 --case-name NAME --seeds S1,S2,... --gpus G1,G2,... [options]"
echo ""
echo "Required:"
echo " --case-name NAME Case name for output dirs"
echo " --seeds S1,S2 Comma-separated seed values"
echo " --gpus G1,G2 Comma-separated GPU device IDs"
echo " --config PATH LBM config JSON"
echo " --calibration PATH calibration.json"
echo ""
echo "Options:"
echo " --episodes N Total episodes (default: 500)"
echo " --transfer PATH .zip model for transfer learning"
echo " --delay SEC Seconds between launches (default: 420)"
echo " --env NAME Conda env name (default: pycuda_3_10)"
exit 1
}
while [[ $# -gt 0 ]]; do
case "$1" in
--case-name) CASE_NAME="$2"; shift 2 ;;
--seeds) SEEDS="$2"; shift 2 ;;
--gpus) GPUS="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
--config) CONFIG="$2"; shift 2 ;;
--calibration) CALIBRATION="$2"; shift 2 ;;
--transfer) TRANSFER="$2"; shift 2 ;;
--delay) DELAY_SECONDS="$2"; shift 2 ;;
--env) CONDA_ENV="$2"; shift 2 ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
# Validate
if [[ -z "$CASE_NAME" || -z "$SEEDS" || -z "$GPUS" || -z "$CONFIG" || -z "$CALIBRATION" ]]; then
echo "ERROR: Missing required arguments."
usage
fi
IFS=',' read -ra SEED_ARR <<< "$SEEDS"
IFS=',' read -ra GPU_ARR <<< "$GPUS"
if [[ ${#SEED_ARR[@]} -ne ${#GPU_ARR[@]} ]]; then
echo "ERROR: Number of seeds (${#SEED_ARR[@]}) must match number of GPUs (${#GPU_ARR[@]})."
exit 1
fi
echo "=== Multi-GPU Training Launcher ==="
echo " Case: $CASE_NAME"
echo " Seeds: $SEEDS"
echo " GPUs: $GPUS"
echo " Episodes: $EPISODES"
echo " Config: $CONFIG"
echo " Calibration: $CALIBRATION"
echo " Transfer: ${TRANSFER:-none}"
echo " Delay: ${DELAY_SECONDS}s between launches"
echo " Jobs: ${#SEED_ARR[@]}"
echo ""
# Build transfer arg
TRANSFER_ARG=""
if [[ -n "$TRANSFER" ]]; then
TRANSFER_ARG="--transfer-model $TRANSFER"
fi
mkdir -p "$SCRIPT_DIR/output"
for i in "${!SEED_ARR[@]}"; do
seed="${SEED_ARR[$i]}"
gpu="${GPU_ARR[$i]}"
run_name="${CASE_NAME}_seed${seed}"
logfile="$SCRIPT_DIR/output/${run_name}/nohup.log"
mkdir -p "$SCRIPT_DIR/output/${run_name}"
echo "[$(date '+%H:%M:%S')] Launching seed=$seed on GPU=$gpu ..."
nohup conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$SCRIPT_DIR/train_karman.py" \
--case-name "$CASE_NAME" \
--device-id "$gpu" \
--seed "$seed" \
--total-episodes "$EPISODES" \
--config "$CONFIG" \
--calibration "$CALIBRATION" \
$TRANSFER_ARG \
> "$logfile" 2>&1 &
echo " PID: $!"
echo " Log: $logfile"
if [[ $i -lt $((${#SEED_ARR[@]} - 1)) ]]; then
echo " Waiting ${DELAY_SECONDS}s before next launch..."
sleep "$DELAY_SECONDS"
fi
done
echo ""
echo "All jobs launched. Monitor with:"
echo " tail -f $SCRIPT_DIR/output/${CASE_NAME}_seed*/nohup.log"
echo " tensorboard --logdir $SCRIPT_DIR/output/${CASE_NAME}_seed*/tb"
echo ""
echo "To stop all:"
echo " ps aux | grep train_karman | grep -v grep | awk '{print \$2}' | xargs kill"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# =============================================================================
# crossre_scratch.sh — Cross-Re From-Scratch Training
#
# Trains re60/re200/re400 from scratch on single GPU, serially.
# Uses generic SIM_BP, K_CD/CL=50/100, lr=3e-4.
# Output: output/kar_{re60,re200,re400}_sc_seed43/
#
# Usage:
# bash crossre_scratch.sh [--gpu 0] [--episodes 500]
# bash crossre_scratch.sh --only re200
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=500; DELAY=120; ONLY=""; SEED=43
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
echo "=== Cross-Re Scratch: GPU=$GPU, ep=$EPISODES ==="
declare -A RE_CFGS=(
[re60]="$REPO_DIR/configs/config_lbm_karman_2000x600_re60.json"
[re200]="$REPO_DIR/configs/config_lbm_karman_2000x600_re200.json"
[re400]="$REPO_DIR/configs/config_lbm_karman_2000x600_re400.json"
)
for re in re60 re200 re400; do
[[ -n "$ONLY" && "$ONLY" != "$re" ]] && continue
CASE="kar_${re}_sc"
CAL="$TRAIN_DIR/calibrations/kar_${re}_sc/calibration.json"
CFG="${RE_CFGS[$re]}"
echo " [$(date '+%H:%M:%S')] Training $CASE..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003
echo " [$(date '+%H:%M:%S')] $CASE done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# =============================================================================
# crossre_transfer.sh — Cross-Re Transfer Learning
#
# Transfers from kar_re100_sc baseline to re60/re200/re400.
# Uses original measured-SIM_BP calibrations (kar_re*).
# Kept for comparison; scratch better for r_sim, transfer better for r_cd.
#
# Output: output/kar_{re60,re200,re400}_tr_seed43/
#
# Usage:
# bash crossre_transfer.sh [--gpu 0] [--episodes 200]
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=200; DELAY=120; SEED=43
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
echo "=== Cross-Re Transfer: GPU=$GPU, ep=$EPISODES ==="
BASE_MODEL="$TRAIN_DIR/output/kar_re100_sc_seed${SEED}/models/best_model.zip"
if [[ ! -f "$BASE_MODEL" ]]; then
echo "ERROR: base model not found: $BASE_MODEL"
exit 1
fi
declare -A RE_CFGS=(
[re60]="$REPO_DIR/configs/config_lbm_karman_2000x600_re60.json"
[re200]="$REPO_DIR/configs/config_lbm_karman_2000x600_re200.json"
[re400]="$REPO_DIR/configs/config_lbm_karman_2000x600_re400.json"
)
for re in re60 re200 re400; do
CASE="kar_${re}_tr"
CAL="$TRAIN_DIR/calibrations/kar_${re}/calibration.json"
CFG="${RE_CFGS[$re]}"
echo " [$(date '+%H:%M:%S')] Training $CASE..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003 \
--transfer-model "$BASE_MODEL"
echo " [$(date '+%H:%M:%S')] $CASE done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Resume interrupted training from an episode checkpoint.
# Loads ep{N}_model.zip + ep{N}_vecnormalize.pkl and continues.
#
# Case naming: {domain}_{variant}_{method}_seed{seed}
# domain: kar(man) | ill(usion)
# variant: re{60,100,200,400} | d{075,15,2} | {1L,15L,075L,2L}
# method: sc(ratch) | tr(ansfer)
#
# Usage:
# bash resume.sh --case kar_re60_sc --seed 43 --resume 460 [--gpu 0]
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=500; CASE=""; SEED=43; RESUME_EP=0
while [[ $# -gt 0 ]]; do
case "$1" in
--case) CASE="$2"; shift 2 ;;
--seed) SEED="$2"; shift 2 ;;
--resume) RESUME_EP="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
--gpu) GPU="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
[[ -z "$CASE" || "$RESUME_EP" -eq 0 ]] && { echo "Usage: resume.sh --case CASE --resume EP [--seed 43]"; exit 1; }
# Map case prefix to LBM config
if [[ "$CASE" == kar_re60* ]]; then CFG="$REPO_DIR/configs/config_lbm_karman_2000x600_re60.json"
elif [[ "$CASE" == kar_re200* ]]; then CFG="$REPO_DIR/configs/config_lbm_karman_2000x600_re200.json"
elif [[ "$CASE" == kar_re400* ]]; then CFG="$REPO_DIR/configs/config_lbm_karman_2000x600_re400.json"
elif [[ "$CASE" == kar_* ]] || [[ "$CASE" == ill_* ]]; then CFG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
else echo "ERROR: unknown case pattern: $CASE"; exit 1
fi
# Map case name to calibration: kar_d075_sc → calibrations/kar_d075_sc/
CAL="$TRAIN_DIR/calibrations/${CASE%%_seed*}/calibration.json"
if [[ ! -f "$CAL" ]]; then
echo "ERROR: calibration not found: $CAL"
exit 1
fi
echo "=== Resume: $CASE (seed=$SEED, resume=$RESUME_EP, total=$EPISODES, GPU=$GPU) ==="
echo " Config: $CFG"
echo " Calibration: $CAL"
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003 \
--resume-from "$RESUME_EP"
echo "=== Done ==="
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# =============================================================================
# train_baseline.sh — Karman Re100 Baseline (scratch, multi-seed)
#
# Trains kar_re100_sc from scratch with N seeds on a single GPU.
# Output: output/kar_re100_sc_seed{N}/
#
# Usage:
# bash train_baseline.sh [--seeds 41,42,43,44,45] [--gpu 0] [--episodes 500]
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=500; DELAY=120
SEEDS="41,42,43,44,45"
while [[ $# -gt 0 ]]; do
case "$1" in
--seeds) SEEDS="$2"; shift 2 ;;
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
IFS=',' read -ra SEED_ARR <<< "$SEEDS"
CAL="$TRAIN_DIR/calibrations/kar_re100/calibration.json"
CFG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
echo "=== Baseline: kar_re100_sc, seeds=${SEED_ARR[*]}, ep=$EPISODES, GPU=$GPU ==="
for seed in "${SEED_ARR[@]}"; do
echo " [$(date '+%H:%M:%S')] Training seed=$seed..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name kar_re100_sc --device-id "$GPU" --seed "$seed" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003
echo " [$(date '+%H:%M:%S')] Seed $seed done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# =============================================================================
# train_illusion.sh — Hydrodynamic Illusion Training (scratch)
#
# Trains all 4 target cylinder sizes on a single GPU, serially.
# Output: output/ill_{1L,15L,075L,2L}_sc_seed43/
#
# Usage:
# bash train_illusion.sh [--gpu 0] [--episodes 500]
# bash train_illusion.sh --only 2L
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=500; DELAY=120; SEED=43; ONLY=""
CFG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
echo "=== Illusion: GPU=$GPU, ep=$EPISODES ==="
for size in 1L 075L 15L 2L; do
[[ -n "$ONLY" && "$ONLY" != "$size" ]] && continue
CASE="ill_${size}_sc"
CAL="$TRAIN_DIR/calibrations/ill_${size}/calibration.json"
echo " [$(date '+%H:%M:%S')] Training $CASE..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_illusion.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES"
echo " [$(date '+%H:%M:%S')] $CASE done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# =============================================================================
# vardist_scratch.sh — Variable-Dist Cylinder From-Scratch Training
#
# Trains d075/d15/d2 from scratch on single GPU.
# Uses generic SIM_BP, K_CD/CL=50/100, lr=3e-4.
# Output: output/kar_{d075,d15,d2}_sc_seed{N}/
#
# Usage:
# bash vardist_scratch.sh [--gpu 0] [--episodes 500]
# bash vardist_scratch.sh --only d075
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=500; DELAY=120; ONLY=""
CFG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
echo "=== Vardist Scratch: GPU=$GPU, ep=$EPISODES ==="
declare -A SEEDS=( [d075]=44 [d15]=45 [d2]=45 )
for diam in d075 d15 d2; do
[[ -n "$ONLY" && "$ONLY" != "$diam" ]] && continue
SEED="${SEEDS[$diam]}"
CASE="kar_${diam}_sc"
CAL="$TRAIN_DIR/calibrations/kar_${diam}_sc/calibration.json"
echo " [$(date '+%H:%M:%S')] Training $CASE (seed=$SEED)..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003
echo " [$(date '+%H:%M:%S')] $CASE done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# =============================================================================
# vardist_transfer.sh — Variable-Dist Cylinder Transfer Learning
#
# Transfers from kar_re100_sc baseline to different dist cylinder sizes.
# Uses original measured-SIM_BP calibrations (kar_d*_tr).
# Kept for comparison; scratch (vardist_scratch.sh) produces better results.
#
# Output: output/kar_{d075,d15,d2}_tr_seed{N}/
#
# Usage:
# bash vardist_transfer.sh [--gpu 0] [--episodes 200]
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TRAIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_DIR="$(cd "$TRAIN_DIR/../../.." && pwd)"
CONDA_ENV="pycuda_3_10"
GPU=0; EPISODES=200; DELAY=120
CFG="$REPO_DIR/configs/config_lbm_karman_2000x600.json"
while [[ $# -gt 0 ]]; do
case "$1" in
--gpu) GPU="$2"; shift 2 ;;
--episodes) EPISODES="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
echo "=== Vardist Transfer: GPU=$GPU, ep=$EPISODES ==="
declare -A SEEDS=( [d075]=44 [d15]=45 [d2]=45 )
declare -A BASE_SEEDS=( [d075]=44 [d15]=45 [d2]=45 )
for diam in d075 d15 d2; do
SEED="${SEEDS[$diam]}"
BS="${BASE_SEEDS[$diam]}"
BASE_MODEL="$TRAIN_DIR/output/kar_re100_sc_seed${BS}/models/best_model.zip"
if [[ ! -f "$BASE_MODEL" ]]; then
echo " SKIP $diam: base model not found: $BASE_MODEL"
continue
fi
CASE="kar_${diam}_tr"
CAL="$TRAIN_DIR/calibrations/kar_${diam}_tr/calibration.json"
echo " [$(date '+%H:%M:%S')] Training $CASE (seed=$SEED)..."
conda run --no-capture-output -n "$CONDA_ENV" python -u \
"$TRAIN_DIR/train_karman.py" \
--case-name "$CASE" --device-id "$GPU" --seed "$SEED" \
--config "$CFG" --calibration "$CAL" \
--total-episodes "$EPISODES" --lr 0.0003 \
--transfer-model "$BASE_MODEL"
echo " [$(date '+%H:%M:%S')] $CASE done. Sleeping ${DELAY}s..."
sleep "$DELAY"
done
echo "=== All done ==="
+63 -7
View File
@@ -61,6 +61,10 @@ def main() -> int:
help="G-symmetry augmentation probability (0=off, 0.5=half)")
parser.add_argument("--transfer-model", type=str, default=None,
help="Path to .zip model for transfer learning")
parser.add_argument("--resume-from", type=int, default=0,
help="Resume from episode N (0=start fresh). Loads ep{N}_model.zip "
"and ep{N}_vecnormalize.pkl, parses train.log for best_reward, "
"continues from ep N+1.")
args = parser.parse_args()
# Load calibration
@@ -78,6 +82,38 @@ def main() -> int:
out_dir = Path(__file__).resolve().parent / "output" / run_name
(out_dir / "models").mkdir(parents=True, exist_ok=True)
# ── Resume mode: load checkpoint and restore best_reward ──────────────
resume_ep = args.resume_from
resume_best_reward = -float("inf")
if resume_ep > 0:
log_path_tmp = out_dir / "train.log"
def _log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
with open(log_path_tmp, "a") as f: f.write(line + "\n"); f.flush()
_log(f"=== V5 {run_name} (RESUME from ep {resume_ep}) ===")
if log_path_tmp.exists():
with open(log_path_tmp, "r") as f:
for line in f:
if "BEST" in line:
import re
m = re.search(r'reward=([\d.]+)\s+\(BEST', line)
if m:
resume_best_reward = float(m.group(1))
_log(f" Recovered best_reward={resume_best_reward:.4f} from log")
else:
_log(" WARNING: train.log not found, best_reward set to -inf")
ckpt_model = out_dir / "models" / f"ep{resume_ep:04d}_model.zip"
ckpt_norm = out_dir / "models" / f"ep{resume_ep:04d}_vecnormalize.pkl"
if not ckpt_model.exists():
_log(f" ERROR: Checkpoint not found: {ckpt_model}")
return 1
if not ckpt_norm.exists():
_log(f" ERROR: Checkpoint not found: {ckpt_norm}")
return 1
_log(f" Resume checkpoint: {ckpt_model}")
log_path = out_dir / "train.log"
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
@@ -96,8 +132,9 @@ def main() -> int:
cal_copy["config_path"] = config_path
if args.transfer_model:
cal_copy["transfer_model"] = args.transfer_model
with open(out_dir / "calibration.json", "w") as f:
json.dump(cal_copy, f, indent=2)
if not resume_ep:
with open(out_dir / "calibration.json", "w") as f:
json.dump(cal_copy, f, indent=2)
# Load target
cal_dir = Path(args.calibration).resolve().parent
@@ -108,7 +145,8 @@ def main() -> int:
else:
log(" Recording target...")
t0 = time.perf_counter()
target_states = record_target(config_path, args.device_id, si_val)
dist_radius = float(cal.get("dist_radius", 1.0))
target_states = record_target(config_path, args.device_id, si_val, dist_radius=dist_radius)
np.save(str(out_dir / "target.npy"), target_states)
log(f" Target recorded in {time.perf_counter()-t0:.0f}s")
@@ -120,8 +158,16 @@ def main() -> int:
env = SymmetryAugmentWrapper(env, prob=args.symmetry_prob, seed=args.seed,
rollout_len=args.n_steps)
vec_env = DummyVecEnv([lambda: env])
vec_env = VecNormalize(vec_env, norm_obs=True, norm_reward=False,
clip_obs=10.0, gamma=0.99)
if resume_ep and ckpt_norm.exists():
# Resume: restore single-layer VecNormalize from checkpoint
vec_env = VecNormalize.load(str(ckpt_norm), vec_env)
vec_env.training = True
vec_env.norm_reward = False
log(" Loaded VecNormalize from checkpoint.")
else:
# Fresh training or transfer: create new VecNormalize
vec_env = VecNormalize(vec_env, norm_obs=True, norm_reward=False,
clip_obs=10.0, gamma=0.99)
log(f" Env ready in {time.perf_counter()-t0:.0f}s")
device = torch.device(f"cuda:{args.device_id}")
@@ -132,6 +178,11 @@ def main() -> int:
model = PPO.load(args.transfer_model, env=vec_env, device=device,
custom_objects={"activation_fn": Sin})
log(" Loaded base model.")
elif resume_ep:
log(f" Resuming from: {ckpt_model}")
model = PPO.load(str(ckpt_model), env=vec_env, device=device,
custom_objects={"activation_fn": Sin})
log(" Loaded checkpoint model.")
else:
model = PPO(
"MlpPolicy",
@@ -147,11 +198,16 @@ def main() -> int:
)
log(" Created from scratch.")
best_reward = -float("inf")
best_reward = resume_best_reward if resume_ep else -float("inf")
t_last = time.perf_counter()
norm_path = str(out_dir / "vec_normalize.pkl")
for ep in range(1, args.total_episodes + 1):
# Save initial vec_normalize state (important after checkpoint load)
if resume_ep:
vec_env.save(norm_path)
start_ep = resume_ep + 1 if resume_ep else 1
for ep in range(start_ep, args.total_episodes + 1):
model.learn(total_timesteps=args.learn_timesteps, reset_num_timesteps=False)
# Evaluation (disable symmetry for clean policy eval)
@@ -1,492 +0,0 @@
#!/usr/bin/env python3
"""Post-training flow-field visualization and quantitative analysis.
Compares control modes for Karman Cloak 2000x600:
- target: disturbance-only reference (no pinball)
- zero: pinball with zero rotation
- bias: open-loop bias action (zeros -> [0,-4,4]*U0)
- bias_drl: trained Bias best model
- nobias_drl: trained NoBias best model
Outputs to output/flow_analysis_v4/:
vorticity_*.png, macro_*.npz, metrics summary, comparison plots.
Usage:
conda run -n pycuda_3_10 python -u visualize_and_analyze.py --device-id 0
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pycuda.driver as cuda
cuda.init()
_REPO = Path(__file__).resolve().parents[3]
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
import torch
from torch.nn import Module as TorchModule
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv, VecNormalize
from CelerisLab.common.render import compute_vorticity, render_vorticity_field
from env_karman_2000x600 import (
KarmanCloakEnv,
SI,
FIFO_LEN,
CONV_LEN,
NX,
NY,
L0,
U0,
RADIUS,
CENTER_Y,
DIST_X,
PINBALL_FRONT_X,
PINBALL_REAR_X,
SENSOR_X,
SENSOR_CC,
FORCE_SCALE,
compute_similarity,
record_target,
WARMUP_STEPS,
CFG_PATH,
)
TRAIN_DIR = Path(__file__).resolve().parent
BIAS_RUN = TRAIN_DIR / "output" / "bias_seed42_s2048_e10_v4"
NOBIAS_RUN = TRAIN_DIR / "output" / "nobias_seed42_s2048_e10_v4"
CYLINDERS_FULL = [
((DIST_X, CENTER_Y), 1.0 * L0),
((PINBALL_FRONT_X, CENTER_Y), RADIUS),
((PINBALL_REAR_X, CENTER_Y + 15.0), RADIUS),
((PINBALL_REAR_X, CENTER_Y - 15.0), RADIUS),
]
CYLINDERS_DIST = [((DIST_X, CENTER_Y), 1.0 * L0)]
class Sin(TorchModule):
def __init__(self):
super().__init__()
def forward(self, x):
return torch.sin(x)
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def save_flow(sim, out_dir: Path, name: str, cylinders, nx: int = NX, ny: int = NY) -> None:
macro = sim.get_macroscopic()
np.savez_compressed(
out_dir / f"macro_{name}.npz",
rho=macro["rho"], ux=macro["ux"], uy=macro["uy"],
)
vort = compute_vorticity(macro["ux"], macro["uy"])
render_vorticity_field(
vort, nx=nx, ny=ny,
out_path=str(out_dir / f"vorticity_{name}.png"),
cylinders=cylinders,
)
def save_target_field(device_id: int, out_dir: Path) -> None:
"""Disturbance-only reference flow (no pinball)."""
from env_karman_2000x600 import _clean_cache
from CelerisLab import Simulation
log("Recording target reference flow field...")
_clean_cache()
sim = Simulation(lbm_config_path=CFG_PATH, device_id=device_id)
sim._assert_object_count_contract = lambda *a, **kw: None
sim.add_body("circle", center=(DIST_X, CENTER_Y, 0.0), radius=1.0 * L0)
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0)
sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0)
sim.initialize()
sim.run(WARMUP_STEPS + FIFO_LEN * SI, zero_obs=True)
save_flow(sim, out_dir, "target", CYLINDERS_DIST)
sim.close()
def run_manual_rollout(
env: KarmanCloakEnv,
n_steps: int,
*,
action: Optional[np.ndarray] = None,
fixed_omega: Optional[np.ndarray] = None,
action_provider: Optional[Callable[[np.ndarray], np.ndarray]] = None,
) -> Dict[str, np.ndarray]:
"""Run rollout bypassing gym step when fixed_omega is set."""
obs, _ = env.reset()
if fixed_omega is not None:
env.smoother.reset(np.asarray(fixed_omega, dtype=np.float32))
rewards, r_cd, r_cl, r_sim = [], [], [], []
cd_vals, cl_vals, sim_vals = [], [], []
actions = []
sensor_hist = []
force_hist = []
for _ in range(n_steps):
if fixed_omega is not None:
omega = np.asarray(fixed_omega, dtype=np.float32)
act = np.zeros(3, dtype=np.float32)
elif action_provider is not None:
act = np.asarray(action_provider(obs), dtype=np.float32).flatten()
omega = env.smoother(env._action_to_omega(act))
else:
act = np.zeros(3, dtype=np.float32) if action is None else np.asarray(action, dtype=np.float32)
omega = env.smoother(env._action_to_omega(act))
env._set_omega(omega)
env._gpu_block(lambda: env.sim.run(SI, zero_obs=True))
obs_raw = env._read_obs()
obs_slice = obs_raw[2:14]
obs = env._normalize_obs(obs_slice)
env.fifo_states.append(obs_slice[0:6] * SENSOR_CC)
reward, info = env._compute_reward(obs_slice)
rewards.append(reward)
r_cd.append(info["r_cd"])
r_cl.append(info["r_cl"])
r_sim.append(info["r_sim"])
cd_vals.append(info["cd"])
cl_vals.append(info["cl"])
sim_vals.append(info["sim"])
actions.append(act.copy())
sensor_hist.append(obs_slice[0:6] * SENSOR_CC)
force_hist.append(obs_slice[6:12].copy())
return {
"rewards": np.array(rewards, dtype=np.float64),
"r_cd": np.array(r_cd, dtype=np.float64),
"r_cl": np.array(r_cl, dtype=np.float64),
"r_sim": np.array(r_sim, dtype=np.float64),
"cd": np.array(cd_vals, dtype=np.float64),
"cl": np.array(cl_vals, dtype=np.float64),
"sim": np.array(sim_vals, dtype=np.float64),
"actions": np.array(actions, dtype=np.float32),
"sensors": np.array(sensor_hist, dtype=np.float32),
"forces": np.array(force_hist, dtype=np.float32),
}
def run_model_rollout(
env: KarmanCloakEnv,
model_path: Path,
norm_path: Path,
device_id: int,
n_steps: int,
deterministic: bool = True,
) -> Dict[str, np.ndarray]:
vec_env = DummyVecEnv([lambda: env])
vec_env = VecNormalize.load(str(norm_path), vec_env)
vec_env.training = False
vec_env.norm_reward = False
model = PPO.load(
str(model_path),
env=vec_env,
device=torch.device(f"cuda:{device_id}"),
custom_objects={"policy_kwargs": {"activation_fn": Sin, "net_arch": [64, 64]}},
)
obs = vec_env.reset()
rewards, r_cd, r_cl, r_sim = [], [], [], []
cd_vals, cl_vals, sim_vals = [], [], []
actions = []
sensor_hist = []
force_hist = []
for _ in range(n_steps):
action, _ = model.predict(obs, deterministic=deterministic)
obs, reward, done, info = vec_env.step(action)
inf = info[0] if isinstance(info, list) else info
rewards.append(float(reward[0]))
r_cd.append(float(inf.get("r_cd", 0.0)))
r_cl.append(float(inf.get("r_cl", 0.0)))
r_sim.append(float(inf.get("r_sim", 0.0)))
cd_vals.append(float(inf.get("cd", 0.0)))
cl_vals.append(float(inf.get("cl", 0.0)))
sim_vals.append(float(inf.get("sim", 0.0)))
actions.append(np.asarray(action[0], dtype=np.float32))
raw = env._read_obs()[2:14]
sensor_hist.append(raw[0:6] * SENSOR_CC)
force_hist.append(raw[6:12].copy())
return {
"rewards": np.array(rewards, dtype=np.float64),
"r_cd": np.array(r_cd, dtype=np.float64),
"r_cl": np.array(r_cl, dtype=np.float64),
"r_sim": np.array(r_sim, dtype=np.float64),
"cd": np.array(cd_vals, dtype=np.float64),
"cl": np.array(cl_vals, dtype=np.float64),
"sim": np.array(sim_vals, dtype=np.float64),
"actions": np.array(actions, dtype=np.float32),
"sensors": np.array(sensor_hist, dtype=np.float32),
"forces": np.array(force_hist, dtype=np.float32),
}
def summarize_rollout(name: str, data: Dict[str, np.ndarray], tail: int = 180) -> Dict[str, float]:
sl = slice(-tail, None) if len(data["rewards"]) >= tail else slice(None)
return {
"name": name,
"reward": float(np.mean(data["rewards"][sl])),
"r_cd": float(np.mean(data["r_cd"][sl])),
"r_cl": float(np.mean(data["r_cl"][sl])),
"r_sim": float(np.mean(data["r_sim"][sl])),
"cd_norm": float(np.mean(data["cd"][sl])),
"cl_norm": float(np.mean(data["cl"][sl])),
"sim_raw": float(np.mean(data["sim"][sl])),
"cd_force": float(np.mean((data["forces"][sl, 0] + data["forces"][sl, 2] + data["forces"][sl, 4]) / 3.0)),
"cl_force": float(np.mean((data["forces"][sl, 1] + data["forces"][sl, 3] + data["forces"][sl, 5]) / 3.0)),
}
def save_flow_from_env(env: KarmanCloakEnv, out_dir: Path, name: str) -> None:
env.sim.ctx._ctx.push()
try:
save_flow(env.sim, out_dir, name, CYLINDERS_FULL)
finally:
env.sim.ctx._ctx.pop()
def plot_vorticity_panel(out_dir: Path, names: List[str], titles: List[str]) -> None:
n = len(names)
fig, axes = plt.subplots(2, 3, figsize=(18, 7))
axes = axes.flatten()
for ax, name, title in zip(axes, names, titles):
img_path = out_dir / f"vorticity_{name}.png"
if img_path.exists():
ax.imshow(plt.imread(img_path))
ax.set_title(title, fontsize=11)
ax.axis("off")
for ax in axes[n:]:
ax.axis("off")
fig.suptitle("Karman Cloak — Vorticity Comparison (2000×600)", fontsize=14)
fig.tight_layout()
fig.savefig(out_dir / "vorticity_panel.png", dpi=150, bbox_inches="tight")
plt.close(fig)
def plot_metrics(summary: List[Dict], out_dir: Path) -> None:
names = [s["name"] for s in summary]
x = np.arange(len(names))
width = 0.2
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
for i, key in enumerate(["reward", "r_cd", "r_cl", "r_sim"]):
axes[0].bar(x + (i - 1.5) * width, [s[key] for s in summary], width, label=key)
axes[0].set_xticks(x)
axes[0].set_xticklabels(names, rotation=15)
axes[0].set_ylim(0, 1.05)
axes[0].set_ylabel("Reward component")
axes[0].set_title("Eval reward (last 180 steps mean)")
axes[0].legend()
axes[0].grid(axis="y", alpha=0.3)
axes[1].bar(x - width / 2, [s["cd_norm"] for s in summary], width, label="|Cd| norm")
axes[1].bar(x + width / 2, [s["cl_norm"] for s in summary], width, label="|Cl| norm")
axes[1].set_xticks(x)
axes[1].set_xticklabels(names, rotation=15)
axes[1].set_ylabel("Force norm")
axes[1].set_title("Hydrodynamic forces (normalized)")
axes[1].legend()
axes[1].grid(axis="y", alpha=0.3)
fig.tight_layout()
fig.savefig(out_dir / "metrics_comparison.png", dpi=150)
plt.close(fig)
def plot_sensor_signals(
target_states: np.ndarray,
rollouts: Dict[str, Dict[str, np.ndarray]],
out_dir: Path,
) -> None:
"""Plot center-sensor uy vs target reference."""
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
t_ref = target_states[CONV_LEN:2 * CONV_LEN, 3] # center uy
axes[0].plot(t_ref, "k-", lw=2, label="target (disturbance only)")
colors = {"zero": "#d62728", "bias": "#ff7f0e", "bias_drl": "#2ca02c", "nobias_drl": "#1f77b4"}
for name, data in rollouts.items():
if name == "target":
continue
s = data["sensors"][-CONV_LEN:, 3]
axes[0].plot(s, color=colors.get(name, "gray"), alpha=0.85, label=name)
axes[0].set_ylabel("Center sensor uy (legacy-equiv)")
axes[0].set_title("Downstream velocity signal vs target")
axes[0].legend(loc="upper right")
axes[0].grid(alpha=0.3)
for name, data in rollouts.items():
if name in ("target", "zero"):
continue
axes[1].plot(data["actions"][:, 0], alpha=0.7, label=f"{name} a_front")
axes[1].plot(data["actions"][:, 1], alpha=0.7, ls="--", label=f"{name} a_top")
axes[1].plot(data["actions"][:, 2], alpha=0.7, ls=":", label=f"{name} a_bot")
axes[1].set_xlabel("Step")
axes[1].set_ylabel("Action [-1, 1]")
axes[1].set_title("Control actions")
axes[1].legend(ncol=2, fontsize=8)
axes[1].grid(alpha=0.3)
fig.tight_layout()
fig.savefig(out_dir / "sensor_action_timeseries.png", dpi=150)
plt.close(fig)
def plot_reward_traces(rollouts: Dict[str, Dict[str, np.ndarray]], out_dir: Path) -> None:
fig, ax = plt.subplots(figsize=(12, 4))
colors = {"zero": "#d62728", "bias": "#ff7f0e", "bias_drl": "#2ca02c", "nobias_drl": "#1f77b4"}
for name, data in rollouts.items():
if name == "target":
continue
r = data["rewards"]
w = min(30, len(r))
smooth = np.convolve(r, np.ones(w) / w, mode="same")
ax.plot(smooth, color=colors.get(name, "gray"), label=name, alpha=0.9)
ax.set_xlabel("Step")
ax.set_ylabel("Reward (smoothed)")
ax.set_title("Reward traces during eval rollout")
ax.legend()
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(out_dir / "reward_traces.png", dpi=150)
plt.close(fig)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--target", type=str, default=str(TRAIN_DIR / "target.npy"))
parser.add_argument("--n-steps", type=int, default=360)
parser.add_argument("--out", type=str, default=str(TRAIN_DIR / "output" / "flow_analysis_v4"))
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
target_states = np.load(args.target)
n_steps = args.n_steps
log(f"Output -> {out_dir}")
log(f"Eval steps per case: {n_steps}")
# --- Target reference field ---
save_target_field(args.device_id, out_dir)
rollouts: Dict[str, Dict[str, np.ndarray]] = {}
summary: List[Dict] = []
# --- Bias env cases (zero, bias, bias_drl) ---
log("Creating Bias env...")
bias_env = KarmanCloakEnv(
device_id=args.device_id, seed=42, target_states=target_states,
)
dtw_norm_scale = bias_env._dtw_norm_scale
log("Case: zero rotation...")
rollouts["zero"] = run_manual_rollout(
bias_env, n_steps, fixed_omega=np.zeros(3, dtype=np.float32),
)
save_flow_from_env(bias_env, out_dir, "zero")
log("Case: bias (open-loop)...")
rollouts["bias"] = run_manual_rollout(
bias_env, n_steps, action=np.zeros(3, dtype=np.float32),
)
save_flow_from_env(bias_env, out_dir, "bias")
log("Case: Bias DRL best...")
rollouts["bias_drl"] = run_model_rollout(
bias_env,
BIAS_RUN / "models" / "best_model.zip",
BIAS_RUN / "vec_normalize.pkl",
args.device_id,
n_steps,
deterministic=True,
)
save_flow_from_env(bias_env, out_dir, "bias_drl")
bias_env.close()
# --- NoBias DRL ---
log("Creating NoBias env...")
nobias_env = KarmanCloakEnv(
device_id=args.device_id, seed=42, target_states=target_states,
action_bias=np.array([0.0, 0.0, 0.0], dtype=np.float32),
action_scale=12.0,
)
log("Case: NoBias DRL best...")
rollouts["nobias_drl"] = run_model_rollout(
nobias_env,
NOBIAS_RUN / "models" / "best_model.zip",
NOBIAS_RUN / "vec_normalize.pkl",
args.device_id,
n_steps,
deterministic=True,
)
save_flow_from_env(nobias_env, out_dir, "nobias_drl")
nobias_env.close()
# --- Summaries ---
for name, data in rollouts.items():
s = summarize_rollout(name, data)
# DTW on full fifo at end
if len(data["sensors"]) >= CONV_LEN:
fifo = np.zeros((FIFO_LEN, 6), dtype=np.float32)
n = min(FIFO_LEN, len(data["sensors"]))
fifo[-n:] = data["sensors"][-n:]
s["sim_dtw_end"] = float(compute_similarity(
target_states, fifo, conv_len=CONV_LEN,
norm_scale=dtw_norm_scale,
))
summary.append(s)
log(f" {name:12s}: reward={s['reward']:.4f} r_cd={s['r_cd']:.3f} "
f"r_cl={s['r_cl']:.3f} r_sim={s['r_sim']:.3f} sim_raw={s['sim_raw']:.3f}")
with open(out_dir / "summary.json", "w") as f:
json.dump(summary, f, indent=2)
# --- Plots ---
plot_vorticity_panel(
out_dir,
["target", "zero", "bias", "bias_drl", "nobias_drl"],
["Target (no pinball)", "Zero rotation", "Bias open-loop",
"Bias DRL (best)", "NoBias DRL (best)"],
)
plot_metrics(summary, out_dir)
plot_sensor_signals(target_states, rollouts, out_dir)
plot_reward_traces(rollouts, out_dir)
np.savez_compressed(
out_dir / "rollout_data.npz",
**{f"{k}_{fld}": v for k, d in rollouts.items() for fld, v in d.items()},
target_states=target_states,
)
log(f"Done. Results in {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())