From 4feac49f1cc32925cbac9c2f24a5beaaba74b796 Mon Sep 17 00:00:00 2001 From: Frank14f <1515444314@qq.com> Date: Sun, 12 Jul 2026 23:56:02 +0800 Subject: [PATCH] feat: eval benchmark + cloud-trained models replace train/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/drl_pinball/eval/README.md | 206 ++++++ src/drl_pinball/eval/__init__.py | 1 + src/drl_pinball/eval/generate_report.py | 214 ++++++ src/drl_pinball/eval/infer_reproduce.py | 610 ++++++++++++++++++ src/drl_pinball/eval/infer_train.py | 472 ++++++++++++++ src/drl_pinball/eval/run_all.sh | 126 ++++ src/drl_pinball/eval/scene_manifest.py | 271 ++++++++ src/drl_pinball/eval/viz_flow.py | 189 ++++++ src/drl_pinball/eval/viz_signals.py | 253 ++++++++ src/drl_pinball/train/CROSSRE_ANALYSIS.md | 388 +++++++++++ src/drl_pinball/train/README.md | 221 +++++++ src/drl_pinball/train/TRAIN_KNOWLEDGE.md | 352 ---------- src/drl_pinball/train/VARDIST_ANALYSIS.md | 177 +++++ src/drl_pinball/train/calibrate.py | 48 +- .../calibration.json | 2 +- .../target_harmonics.json | 0 .../calibration.json | 2 +- .../target_harmonics.json | 0 .../{illusion_1L => ill_1L}/calibration.json | 2 +- .../target_harmonics.json | 0 .../{illusion_2L => ill_2L}/calibration.json | 2 +- .../target_harmonics.json | 0 .../calibrations/kar_d075_sc/calibration.json | 50 ++ .../calibrations/kar_d075_tr/calibration.json | 50 ++ .../calibrations/kar_d15_sc/calibration.json | 50 ++ .../calibrations/kar_d15_tr/calibration.json | 50 ++ .../calibrations/kar_d2_sc/calibration.json | 50 ++ .../calibrations/kar_d2_tr/calibration.json | 50 ++ .../{re100 => kar_re100}/calibration.json | 2 +- .../{re200 => kar_re200}/calibration.json | 2 +- .../kar_re200_sc/calibration.json | 49 ++ .../{re400 => kar_re400}/calibration.json | 2 +- .../kar_re400_sc/calibration.json | 49 ++ .../{re60 => kar_re60}/calibration.json | 2 +- .../calibrations/kar_re60_sc/calibration.json | 49 ++ src/drl_pinball/train/crossre_transfer.sh | 119 ---- src/drl_pinball/train/env_karman.py | 8 +- src/drl_pinball/train/extend_transfer.sh | 81 --- src/drl_pinball/train/launch_075L.sh | 32 - src/drl_pinball/train/launch_15L.sh | 32 - src/drl_pinball/train/launch_1L.sh | 30 - src/drl_pinball/train/launch_2L.sh | 32 - src/drl_pinball/train/launch_multi.sh | 141 ---- .../train/scripts/crossre_scratch.sh | 52 ++ .../train/scripts/crossre_transfer.sh | 58 ++ src/drl_pinball/train/scripts/resume.sh | 59 ++ .../train/scripts/train_baseline.sh | 45 ++ .../train/scripts/train_illusion.sh | 45 ++ .../train/scripts/vardist_scratch.sh | 49 ++ .../train/scripts/vardist_transfer.sh | 58 ++ src/drl_pinball/train/train_karman.py | 70 +- .../train/visualize_and_analyze.py | 492 -------------- 52 files changed, 4053 insertions(+), 1341 deletions(-) create mode 100644 src/drl_pinball/eval/README.md create mode 100644 src/drl_pinball/eval/__init__.py create mode 100644 src/drl_pinball/eval/generate_report.py create mode 100644 src/drl_pinball/eval/infer_reproduce.py create mode 100644 src/drl_pinball/eval/infer_train.py create mode 100755 src/drl_pinball/eval/run_all.sh create mode 100644 src/drl_pinball/eval/scene_manifest.py create mode 100644 src/drl_pinball/eval/viz_flow.py create mode 100644 src/drl_pinball/eval/viz_signals.py create mode 100644 src/drl_pinball/train/CROSSRE_ANALYSIS.md create mode 100644 src/drl_pinball/train/README.md delete mode 100644 src/drl_pinball/train/TRAIN_KNOWLEDGE.md create mode 100644 src/drl_pinball/train/VARDIST_ANALYSIS.md rename src/drl_pinball/train/calibrations/{illusion_075L => ill_075L}/calibration.json (96%) rename src/drl_pinball/train/calibrations/{illusion_075L => ill_075L}/target_harmonics.json (100%) rename src/drl_pinball/train/calibrations/{illusion_15L => ill_15L}/calibration.json (96%) rename src/drl_pinball/train/calibrations/{illusion_15L => ill_15L}/target_harmonics.json (100%) rename src/drl_pinball/train/calibrations/{illusion_1L => ill_1L}/calibration.json (96%) rename src/drl_pinball/train/calibrations/{illusion_1L => ill_1L}/target_harmonics.json (100%) rename src/drl_pinball/train/calibrations/{illusion_2L => ill_2L}/calibration.json (96%) rename src/drl_pinball/train/calibrations/{illusion_2L => ill_2L}/target_harmonics.json (100%) create mode 100644 src/drl_pinball/train/calibrations/kar_d075_sc/calibration.json create mode 100644 src/drl_pinball/train/calibrations/kar_d075_tr/calibration.json create mode 100644 src/drl_pinball/train/calibrations/kar_d15_sc/calibration.json create mode 100644 src/drl_pinball/train/calibrations/kar_d15_tr/calibration.json create mode 100644 src/drl_pinball/train/calibrations/kar_d2_sc/calibration.json create mode 100644 src/drl_pinball/train/calibrations/kar_d2_tr/calibration.json rename src/drl_pinball/train/calibrations/{re100 => kar_re100}/calibration.json (96%) rename src/drl_pinball/train/calibrations/{re200 => kar_re200}/calibration.json (96%) create mode 100644 src/drl_pinball/train/calibrations/kar_re200_sc/calibration.json rename src/drl_pinball/train/calibrations/{re400 => kar_re400}/calibration.json (96%) create mode 100644 src/drl_pinball/train/calibrations/kar_re400_sc/calibration.json rename src/drl_pinball/train/calibrations/{re60 => kar_re60}/calibration.json (97%) create mode 100644 src/drl_pinball/train/calibrations/kar_re60_sc/calibration.json delete mode 100755 src/drl_pinball/train/crossre_transfer.sh delete mode 100644 src/drl_pinball/train/extend_transfer.sh delete mode 100755 src/drl_pinball/train/launch_075L.sh delete mode 100755 src/drl_pinball/train/launch_15L.sh delete mode 100755 src/drl_pinball/train/launch_1L.sh delete mode 100755 src/drl_pinball/train/launch_2L.sh delete mode 100755 src/drl_pinball/train/launch_multi.sh create mode 100755 src/drl_pinball/train/scripts/crossre_scratch.sh create mode 100755 src/drl_pinball/train/scripts/crossre_transfer.sh create mode 100755 src/drl_pinball/train/scripts/resume.sh create mode 100755 src/drl_pinball/train/scripts/train_baseline.sh create mode 100755 src/drl_pinball/train/scripts/train_illusion.sh create mode 100755 src/drl_pinball/train/scripts/vardist_scratch.sh create mode 100755 src/drl_pinball/train/scripts/vardist_transfer.sh delete mode 100644 src/drl_pinball/train/visualize_and_analyze.py diff --git a/src/drl_pinball/eval/README.md b/src/drl_pinball/eval/README.md new file mode 100644 index 0000000..692f1ce --- /dev/null +++ b/src/drl_pinball/eval/README.md @@ -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 非 NaN,DTW 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 inlet,bounce-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 的详细评分 | diff --git a/src/drl_pinball/eval/__init__.py b/src/drl_pinball/eval/__init__.py new file mode 100644 index 0000000..a0edf5f --- /dev/null +++ b/src/drl_pinball/eval/__init__.py @@ -0,0 +1 @@ +# eval package diff --git a/src/drl_pinball/eval/generate_report.py b/src/drl_pinball/eval/generate_report.py new file mode 100644 index 0000000..175102d --- /dev/null +++ b/src/drl_pinball/eval/generate_report.py @@ -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()) diff --git a/src/drl_pinball/eval/infer_reproduce.py b/src/drl_pinball/eval/infer_reproduce.py new file mode 100644 index 0000000..be73b57 --- /dev/null +++ b/src/drl_pinball/eval/infer_reproduce.py @@ -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()) diff --git a/src/drl_pinball/eval/infer_train.py b/src/drl_pinball/eval/infer_train.py new file mode 100644 index 0000000..1bc0ea6 --- /dev/null +++ b/src/drl_pinball/eval/infer_train.py @@ -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()) diff --git a/src/drl_pinball/eval/run_all.sh b/src/drl_pinball/eval/run_all.sh new file mode 100755 index 0000000..198ba34 --- /dev/null +++ b/src/drl_pinball/eval/run_all.sh @@ -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/" diff --git a/src/drl_pinball/eval/scene_manifest.py b/src/drl_pinball/eval/scene_manifest.py new file mode 100644 index 0000000..c1c7f4b --- /dev/null +++ b/src/drl_pinball/eval/scene_manifest.py @@ -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, + }, +] diff --git a/src/drl_pinball/eval/viz_flow.py b/src/drl_pinball/eval/viz_flow.py new file mode 100644 index 0000000..e5722e7 --- /dev/null +++ b/src/drl_pinball/eval/viz_flow.py @@ -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()) diff --git a/src/drl_pinball/eval/viz_signals.py b/src/drl_pinball/eval/viz_signals.py new file mode 100644 index 0000000..ca86c9b --- /dev/null +++ b/src/drl_pinball/eval/viz_signals.py @@ -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()) diff --git a/src/drl_pinball/train/CROSSRE_ANALYSIS.md b/src/drl_pinball/train/CROSSRE_ANALYSIS.md new file mode 100644 index 0000000..a3d2697 --- /dev/null +++ b/src/drl_pinball/train/CROSSRE_ANALYSIS.md @@ -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 peak,Ep200 崩至 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=25(1/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.25(re100 基准的 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 scratch;r_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 触发 generic;d075 未触发 | 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 兜底逻辑在第 526–527 行) | +| 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` | diff --git a/src/drl_pinball/train/README.md b/src/drl_pinball/train/README.md new file mode 100644 index 0000000..22000f2 --- /dev/null +++ b/src/drl_pinball/train/README.md @@ -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 成功。** diff --git a/src/drl_pinball/train/TRAIN_KNOWLEDGE.md b/src/drl_pinball/train/TRAIN_KNOWLEDGE.md deleted file mode 100644 index 4d181a1..0000000 --- a/src/drl_pinball/train/TRAIN_KNOWLEDGE.md +++ /dev/null @@ -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 - └── _seed/ - ├── 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 --config - -# 3. Test (5 episodes, local) -python train_karman.py --case-name transfer_reNNN --device-id 0 --seed 41 \ - --config --calibration calibrations/reNNN/calibration.json \ - --si --total-episodes 5 \ - --transfer-model output/re100_karman_seed/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 | diff --git a/src/drl_pinball/train/VARDIST_ANALYSIS.md b/src/drl_pinball/train/VARDIST_ANALYSIS.md new file mode 100644 index 0000000..cf2a645 --- /dev/null +++ b/src/drl_pinball/train/VARDIST_ANALYSIS.md @@ -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.92,r_cl 从 0.27 涨到 0.48,三个 reward 分量协作上升 + +### 3.2 ❌ d075 (0.75L) — 最差案例 + +**根因 1 — SIM_BP 映射问题 (最主要)** + +d075 是唯一一个使用实测 SIM_BP 的 case。测得的 zero-rotation similarity=0.25,reference rotation=0.79,gap=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.023,r_cd=0.012,r_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.455(vs 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** | 不 transfer,from scratch | 因为 Ep 1 已证明 re100 policy 对 d075 高度不适配 (reward=0.023),scratch 训练可能更稳定 | + +### 5.2 针对 d2 (2.0L) + +| 优先级 | 方案 | 原理 | +|:------:|------|------| +| **P0** | 不 transfer,from 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` | diff --git a/src/drl_pinball/train/calibrate.py b/src/drl_pinball/train/calibrate.py index e10aef0..95071f0 100644 --- a/src/drl_pinball/train/calibrate.py +++ b/src/drl_pinball/train/calibrate.py @@ -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, diff --git a/src/drl_pinball/train/calibrations/illusion_075L/calibration.json b/src/drl_pinball/train/calibrations/ill_075L/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/illusion_075L/calibration.json rename to src/drl_pinball/train/calibrations/ill_075L/calibration.json index a4ff24e..b23598f 100644 --- a/src/drl_pinball/train/calibrations/illusion_075L/calibration.json +++ b/src/drl_pinball/train/calibrations/ill_075L/calibration.json @@ -1,5 +1,5 @@ { - "case": "illusion_075L", + "case": "ill_075L", "scene": "illusion", "target_diam": 0.75, "grid": { diff --git a/src/drl_pinball/train/calibrations/illusion_075L/target_harmonics.json b/src/drl_pinball/train/calibrations/ill_075L/target_harmonics.json similarity index 100% rename from src/drl_pinball/train/calibrations/illusion_075L/target_harmonics.json rename to src/drl_pinball/train/calibrations/ill_075L/target_harmonics.json diff --git a/src/drl_pinball/train/calibrations/illusion_15L/calibration.json b/src/drl_pinball/train/calibrations/ill_15L/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/illusion_15L/calibration.json rename to src/drl_pinball/train/calibrations/ill_15L/calibration.json index ed46974..c8cb15b 100644 --- a/src/drl_pinball/train/calibrations/illusion_15L/calibration.json +++ b/src/drl_pinball/train/calibrations/ill_15L/calibration.json @@ -1,5 +1,5 @@ { - "case": "illusion_15L", + "case": "ill_15L", "scene": "illusion", "target_diam": 1.5, "grid": { diff --git a/src/drl_pinball/train/calibrations/illusion_15L/target_harmonics.json b/src/drl_pinball/train/calibrations/ill_15L/target_harmonics.json similarity index 100% rename from src/drl_pinball/train/calibrations/illusion_15L/target_harmonics.json rename to src/drl_pinball/train/calibrations/ill_15L/target_harmonics.json diff --git a/src/drl_pinball/train/calibrations/illusion_1L/calibration.json b/src/drl_pinball/train/calibrations/ill_1L/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/illusion_1L/calibration.json rename to src/drl_pinball/train/calibrations/ill_1L/calibration.json index ccec3d5..f789041 100644 --- a/src/drl_pinball/train/calibrations/illusion_1L/calibration.json +++ b/src/drl_pinball/train/calibrations/ill_1L/calibration.json @@ -1,5 +1,5 @@ { - "case": "illusion_1L", + "case": "ill_1L", "scene": "illusion", "target_diam": 1.0, "grid": { diff --git a/src/drl_pinball/train/calibrations/illusion_1L/target_harmonics.json b/src/drl_pinball/train/calibrations/ill_1L/target_harmonics.json similarity index 100% rename from src/drl_pinball/train/calibrations/illusion_1L/target_harmonics.json rename to src/drl_pinball/train/calibrations/ill_1L/target_harmonics.json diff --git a/src/drl_pinball/train/calibrations/illusion_2L/calibration.json b/src/drl_pinball/train/calibrations/ill_2L/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/illusion_2L/calibration.json rename to src/drl_pinball/train/calibrations/ill_2L/calibration.json index 0ab81be..1a0be5b 100644 --- a/src/drl_pinball/train/calibrations/illusion_2L/calibration.json +++ b/src/drl_pinball/train/calibrations/ill_2L/calibration.json @@ -1,5 +1,5 @@ { - "case": "illusion_2L", + "case": "ill_2L", "scene": "illusion", "target_diam": 2.0, "grid": { diff --git a/src/drl_pinball/train/calibrations/illusion_2L/target_harmonics.json b/src/drl_pinball/train/calibrations/ill_2L/target_harmonics.json similarity index 100% rename from src/drl_pinball/train/calibrations/illusion_2L/target_harmonics.json rename to src/drl_pinball/train/calibrations/ill_2L/target_harmonics.json diff --git a/src/drl_pinball/train/calibrations/kar_d075_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_d075_sc/calibration.json new file mode 100644 index 0000000..d0ef4a9 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d075_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/kar_d075_tr/calibration.json b/src/drl_pinball/train/calibrations/kar_d075_tr/calibration.json new file mode 100644 index 0000000..72c55b4 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d075_tr/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/kar_d15_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_d15_sc/calibration.json new file mode 100644 index 0000000..2e1574a --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d15_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/kar_d15_tr/calibration.json b/src/drl_pinball/train/calibrations/kar_d15_tr/calibration.json new file mode 100644 index 0000000..a4fb1b3 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d15_tr/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/kar_d2_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_d2_sc/calibration.json new file mode 100644 index 0000000..701a899 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d2_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/kar_d2_tr/calibration.json b/src/drl_pinball/train/calibrations/kar_d2_tr/calibration.json new file mode 100644 index 0000000..9c75555 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_d2_tr/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/re100/calibration.json b/src/drl_pinball/train/calibrations/kar_re100/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/re100/calibration.json rename to src/drl_pinball/train/calibrations/kar_re100/calibration.json index 113c2e9..0653215 100644 --- a/src/drl_pinball/train/calibrations/re100/calibration.json +++ b/src/drl_pinball/train/calibrations/kar_re100/calibration.json @@ -1,5 +1,5 @@ { - "case": "re100", + "case": "kar_re100", "grid": { "nx": 2000, "ny": 600 diff --git a/src/drl_pinball/train/calibrations/re200/calibration.json b/src/drl_pinball/train/calibrations/kar_re200/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/re200/calibration.json rename to src/drl_pinball/train/calibrations/kar_re200/calibration.json index ad9aa67..492b577 100644 --- a/src/drl_pinball/train/calibrations/re200/calibration.json +++ b/src/drl_pinball/train/calibrations/kar_re200/calibration.json @@ -1,5 +1,5 @@ { - "case": "re200", + "case": "kar_re200", "grid": { "nx": 2000, "ny": 600 diff --git a/src/drl_pinball/train/calibrations/kar_re200_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_re200_sc/calibration.json new file mode 100644 index 0000000..af0b742 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_re200_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/re400/calibration.json b/src/drl_pinball/train/calibrations/kar_re400/calibration.json similarity index 96% rename from src/drl_pinball/train/calibrations/re400/calibration.json rename to src/drl_pinball/train/calibrations/kar_re400/calibration.json index a537d4b..a28b43d 100644 --- a/src/drl_pinball/train/calibrations/re400/calibration.json +++ b/src/drl_pinball/train/calibrations/kar_re400/calibration.json @@ -1,5 +1,5 @@ { - "case": "re400", + "case": "kar_re400", "grid": { "nx": 2000, "ny": 600 diff --git a/src/drl_pinball/train/calibrations/kar_re400_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_re400_sc/calibration.json new file mode 100644 index 0000000..67df582 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_re400_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/calibrations/re60/calibration.json b/src/drl_pinball/train/calibrations/kar_re60/calibration.json similarity index 97% rename from src/drl_pinball/train/calibrations/re60/calibration.json rename to src/drl_pinball/train/calibrations/kar_re60/calibration.json index 2c464f4..0fd6d40 100644 --- a/src/drl_pinball/train/calibrations/re60/calibration.json +++ b/src/drl_pinball/train/calibrations/kar_re60/calibration.json @@ -1,5 +1,5 @@ { - "case": "re60", + "case": "kar_re60", "grid": { "nx": 2000, "ny": 600 diff --git a/src/drl_pinball/train/calibrations/kar_re60_sc/calibration.json b/src/drl_pinball/train/calibrations/kar_re60_sc/calibration.json new file mode 100644 index 0000000..523ccc9 --- /dev/null +++ b/src/drl_pinball/train/calibrations/kar_re60_sc/calibration.json @@ -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 +} \ No newline at end of file diff --git a/src/drl_pinball/train/crossre_transfer.sh b/src/drl_pinball/train/crossre_transfer.sh deleted file mode 100755 index b1ef58d..0000000 --- a/src/drl_pinball/train/crossre_transfer.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/env_karman.py b/src/drl_pinball/train/env_karman.py index 0872989..c94b4cf 100644 --- a/src/drl_pinball/train/env_karman.py +++ b/src/drl_pinball/train/env_karman.py @@ -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), diff --git a/src/drl_pinball/train/extend_transfer.sh b/src/drl_pinball/train/extend_transfer.sh deleted file mode 100644 index aa4bc18..0000000 --- a/src/drl_pinball/train/extend_transfer.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/launch_075L.sh b/src/drl_pinball/train/launch_075L.sh deleted file mode 100755 index 54a2d18..0000000 --- a/src/drl_pinball/train/launch_075L.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/launch_15L.sh b/src/drl_pinball/train/launch_15L.sh deleted file mode 100755 index 4872957..0000000 --- a/src/drl_pinball/train/launch_15L.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/launch_1L.sh b/src/drl_pinball/train/launch_1L.sh deleted file mode 100755 index 1349edd..0000000 --- a/src/drl_pinball/train/launch_1L.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/launch_2L.sh b/src/drl_pinball/train/launch_2L.sh deleted file mode 100755 index e565fcf..0000000 --- a/src/drl_pinball/train/launch_2L.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/launch_multi.sh b/src/drl_pinball/train/launch_multi.sh deleted file mode 100755 index 5fbe0b3..0000000 --- a/src/drl_pinball/train/launch_multi.sh +++ /dev/null @@ -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" diff --git a/src/drl_pinball/train/scripts/crossre_scratch.sh b/src/drl_pinball/train/scripts/crossre_scratch.sh new file mode 100755 index 0000000..5f2ffe4 --- /dev/null +++ b/src/drl_pinball/train/scripts/crossre_scratch.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/crossre_transfer.sh b/src/drl_pinball/train/scripts/crossre_transfer.sh new file mode 100755 index 0000000..9874f88 --- /dev/null +++ b/src/drl_pinball/train/scripts/crossre_transfer.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/resume.sh b/src/drl_pinball/train/scripts/resume.sh new file mode 100755 index 0000000..78fe709 --- /dev/null +++ b/src/drl_pinball/train/scripts/resume.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/train_baseline.sh b/src/drl_pinball/train/scripts/train_baseline.sh new file mode 100755 index 0000000..1c2ecde --- /dev/null +++ b/src/drl_pinball/train/scripts/train_baseline.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/train_illusion.sh b/src/drl_pinball/train/scripts/train_illusion.sh new file mode 100755 index 0000000..cd8a79f --- /dev/null +++ b/src/drl_pinball/train/scripts/train_illusion.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/vardist_scratch.sh b/src/drl_pinball/train/scripts/vardist_scratch.sh new file mode 100755 index 0000000..8c1bf1f --- /dev/null +++ b/src/drl_pinball/train/scripts/vardist_scratch.sh @@ -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 ===" diff --git a/src/drl_pinball/train/scripts/vardist_transfer.sh b/src/drl_pinball/train/scripts/vardist_transfer.sh new file mode 100755 index 0000000..403be3b --- /dev/null +++ b/src/drl_pinball/train/scripts/vardist_transfer.sh @@ -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 ===" diff --git a/src/drl_pinball/train/train_karman.py b/src/drl_pinball/train/train_karman.py index 104de6f..d9de1ce 100644 --- a/src/drl_pinball/train/train_karman.py +++ b/src/drl_pinball/train/train_karman.py @@ -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) diff --git a/src/drl_pinball/train/visualize_and_analyze.py b/src/drl_pinball/train/visualize_and_analyze.py deleted file mode 100644 index 10cbef1..0000000 --- a/src/drl_pinball/train/visualize_and_analyze.py +++ /dev/null @@ -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())