DynamisLab/src/drl_pinball/train/env_vortex.py
Frank14f 5f061bec06 feat(train): cross-Re transfer pipeline — re60/re200/re400 calibrations + script
- Add crossre_transfer.sh: calibrate → transfer-train for re60→re200→re400
- Add re60 config (ν=0.006667, SI=800, uniform+free-slip, very weak shedding)
- Calibrate re60, re200, re400: FORCE_SCALE, SENS_SCALE, dtw_norm_scale, SIM_BP
- Fix all paths: use DynamisLab submodule CelerisLab, remove external ~/CelerisLab
- Remove _clean_cache() from envs/calibrate — CelerisLab handles internally
- Move V4 backups to old/: env_karman_2000x600, train_karman_2000x600, etc.
- train_karman.py: save model + vecnormalize every episode (non-optional)
- Update TRAIN_KNOWLEDGE.md: file structure, calibration table, cross-re guide
- All 3 Re verified: 5-episode transfer test passed (re60: 0.64, re200: 0.43, re400: 0.49)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 00:21:49 +08:00

369 lines
15 KiB
Python

#!/usr/bin/env python3
"""Vortex Cloak environment (V5 — no_bias, 2000x600, transfer from Karman).
Two-phase initialization:
Phase 1: sensors only -> warmup -> add_vortex(x=10) -> record target(150 steps)
Phase 2: sensors + pinball -> warmup -> add_vortex(x=15) -> FIFO -> snapshot
reset() restores to vortex+pinball state. MAX_STEPS=150 (time-bounded).
After step 150, done=True. No disturbance cylinder.
Observation (12-dim, physical norm, NO clip):
[0:6] = raw_forces / FORCE_SCALE (front_fx,fy, top_fx,fy, bot_fx,fy)
[6:12] = raw_sensors / SENS_SCALE (s0_ux,uy, s1_ux,uy, s2_ux,uy)
Action (3-dim): no_bias only
[-1,1] -> omega = -(action * 12 + [0,0,0]) * U0 / R
Reward: Gaussian + EMA + normalized DTW, same as Karman cloak.
"""
from __future__ import annotations
import json
import sys, time
from collections import deque
from pathlib import Path
from typing import Optional, Tuple
import numpy as np
import gymnasium as gym
from gymnasium import spaces
_REPO = Path(__file__).resolve().parents[3]
if str(_REPO) not in sys.path:
sys.path.insert(0, str(_REPO))
from CelerisLab import Simulation
from CelerisLab.lbm.initializers import add_vortex
# ---------------------------------------------------------------------------
# Geometry constants
# ---------------------------------------------------------------------------
L0 = 20.0; U0 = 0.01; RADIUS = L0 / 2.0
NX = 2000; NY = 600
CENTER_Y = float(NY - 1) / 2.0
PINBALL_FRONT_X = 1000.0
PINBALL_REAR_X = 1026.0
SENSOR_X = 1200.0
VORTEX_X_TARGET = 200.0 # x=10*L0 for target phase
VORTEX_X_TRAIN = 300.0 # x=15*L0 for training (closer to pinball)
VORTEX_RADIUS = 40.0 # 2*L0
VORTEX_STRENGTH_LAMB = 0.5 * U0
VORTEX_STRENGTH_TAYLOR = 0.03 * U0
FIFO_LEN = 150; CONV_LEN = 30; MAX_STEPS = 150
EMA_FAST = 0.2
S_DIM = 12; A_DIM = 3
SENSOR_CC = 78.0
ACTION_SCALE = 12.0
ACTION_BIAS = np.array([0.0, 0.0, 0.0], dtype=np.float32)
# ---------------------------------------------------------------------------
# DTW utilities (same as env_karman.py)
# ---------------------------------------------------------------------------
def calc_lag(target, state):
t_mean = np.mean(target); s_mean = np.mean(state)
corr = np.correlate(target - t_mean, state - s_mean, mode="full")
lags = np.arange(-len(target) + 1, len(target))
return int(lags[np.argmax(corr)])
def calc_dtw_sim(target, state, norm_scale=1.0):
n, m = len(target), len(state)
dtw = np.full((n + 1, m + 1), np.inf); dtw[0, 0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = abs(float(target[i - 1]) - float(state[j - 1]))
last_min = min(dtw[i - 1, j], dtw[i, j - 1], dtw[i - 1, j - 1])
dtw[i, j] = cost + last_min
raw = 1.0 - dtw[n, m] / (float(n) * norm_scale)
return float(max(0.0, raw))
def compute_similarity(target, fifo_arr, conv_len, norm_scale):
target = np.asarray(target, dtype=np.float64)
state = np.asarray(fifo_arr, dtype=np.float64)
if len(state) < conv_len:
return 0.0
t_slice = target[:conv_len]
s_slice = state[-conv_len:]
sim = 0.0
for i in range(6):
sim += calc_dtw_sim(t_slice[:, i], s_slice[:, i], norm_scale=norm_scale)
return float(sim / 6.0)
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
# ---------------------------------------------------------------------------
class VortexCloakEnv(gym.Env):
metadata = {"render_modes": ["human"]}
def __init__(self, device_id=0, seed=42, calibration=None,
config_path=None, vortex_type="lamb"):
super().__init__()
self.device_id = device_id
self.seed = seed
np.random.seed(seed)
self._vortex_type = vortex_type
if calibration is None:
raise ValueError("calibration dict is required")
self._cal = calibration.copy()
self._si = int(self._cal["SI"])
self._force_scale = np.float32(self._cal["FORCE_SCALE"])
self._sens_scale = np.float32(self._cal["SENS_SCALE"])
self._dtw_norm_scale = float(self._cal["dtw_norm_scale"])
self._sim_bp = np.array(self._cal["SIM_BP"], dtype=np.float64)
self._sim_val = np.array(self._cal["SIM_VAL"], dtype=np.float64)
self._k_cd = float(self._cal["K_CD"]); self._k_cl = float(self._cal["K_CL"])
self._w_cd = float(self._cal["W_CD"]); self._w_cl = float(self._cal["W_CL"])
self._w_sim = float(self._cal["W_SIM"])
self._floor_cd = float(self._cal["FLOOR_CD"])
self._floor_cl = float(self._cal["FLOOR_CL"])
self._floor_sim = float(self._cal["FLOOR_SIM"])
self._floor_pen = float(self._cal["FLOOR_PENALTY"])
self._config_path = config_path or self._cal.get("config_path")
if self._config_path is None:
raise ValueError("config_path is required")
self.action_space = spaces.Box(-1.0, 1.0, (A_DIM,), dtype=np.float32)
self.observation_space = spaces.Box(-10.0, 10.0, (S_DIM,), dtype=np.float32)
self.fifo_states = deque(maxlen=FIFO_LEN)
self.save_states = None
self.target_states = None
self.current_step = 0
self.smoother = ActionSmoother(weight=0.1)
self._ema_r_cd = 0.0; self._ema_r_cl = 0.0
self.sim = None
self.sensor_ids = []
self.pinball_ids = []
self._init_cfd()
def _gpu_block(self, fn):
if self.sim is not None:
self.sim.ctx._ctx.push()
try:
fn()
finally:
if self.sim is not None:
self.sim.ctx._ctx.pop()
def _init_cfd(self):
t0 = time.perf_counter()
warmup = int(4.0 * NX / U0)
# ---- Phase 1: Target (sensors + vortex only, no pinball) ----
print(" [vortex] Phase 1: Target recording...", flush=True)
sim_t = Simulation(lbm_config_path=self._config_path, device_id=self.device_id)
sim_t._assert_object_count_contract = lambda *a, **kw: None
s0 = sim_t.add_body("sensor", center=(SENSOR_X, CENTER_Y + 40.0, 0.0), radius=5.0)
s1 = sim_t.add_body("sensor", center=(SENSOR_X, CENTER_Y, 0.0), radius=5.0)
s2 = sim_t.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0)
sensor_ids_t = [s0, s1, s2]
sim_t.initialize()
sim_t.run(warmup, zero_obs=True)
print(f" [vortex] Target warmup done ({warmup} steps)")
# Inject vortex and record
add_vortex(sim_t.field, (VORTEX_X_TARGET, CENTER_Y),
VORTEX_RADIUS, VORTEX_STRENGTH_TAYLOR if self._vortex_type == "taylor" else VORTEX_STRENGTH_LAMB,
self._vortex_type)
target = np.zeros((MAX_STEPS, 6), dtype=np.float32)
for i in range(MAX_STEPS):
sim_t.run(self._si, zero_obs=True)
target[i] = [
sim_t.read_sensor(s0, normalize=True)[0] * SENSOR_CC,
sim_t.read_sensor(s0, normalize=True)[1] * SENSOR_CC,
sim_t.read_sensor(s1, normalize=True)[0] * SENSOR_CC,
sim_t.read_sensor(s1, normalize=True)[1] * SENSOR_CC,
sim_t.read_sensor(s2, normalize=True)[0] * SENSOR_CC,
sim_t.read_sensor(s2, normalize=True)[1] * SENSOR_CC,
]
sim_t.close()
self.target_states = target
# ---- Phase 2: Training sim (sensors + pinball + vortex) ----
print(" [vortex] Phase 2: Training sim...", flush=True)
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.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),
self.sim.add_body("sensor", center=(SENSOR_X, CENTER_Y - 40.0, 0.0), radius=5.0),
]
self.sim.add_body("circle", center=(PINBALL_FRONT_X, CENTER_Y, 0.0), radius=RADIUS)
self.sim.add_body("circle", center=(PINBALL_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS)
self.sim.add_body("circle", center=(PINBALL_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS)
self.sim.initialize()
self.pinball_ids = [3, 4, 5]
self._gpu_block(lambda: self.sim.run(warmup, zero_obs=True))
print(f" [vortex] Pinball warmup done ({warmup} steps)")
# Inject vortex at training position (closer to pinball)
self._gpu_block(lambda: add_vortex(self.sim.field, (VORTEX_X_TRAIN, CENTER_Y),
VORTEX_RADIUS,
VORTEX_STRENGTH_TAYLOR if self._vortex_type == "taylor" else VORTEX_STRENGTH_LAMB,
self._vortex_type))
print(f" [vortex] Vortex ({self._vortex_type}) injected at x={VORTEX_X_TRAIN}")
# Zero-action FIFO (no rotation, vortex evolves with pinball)
zero_omega = self._action_to_omega(np.zeros(3, dtype=np.float32))
self.smoother.reset(zero_omega.copy())
fifo_save = []
for _ in range(FIFO_LEN):
self._set_omega(zero_omega)
self._gpu_block(lambda: self.sim.run(self._si, zero_obs=True))
obs = self._read_obs()
sl = obs[0:6] # 6 sensor channels (legacy-equiv)
sl = sl * SENSOR_CC
fifo_save.append(sl.copy())
self.save_states = np.array(fifo_save, dtype=np.float32)
self._gpu_block(lambda: self.sim.snapshot())
print(f" [vortex] Init done ({time.perf_counter()-t0:.0f}s)")
def _read_obs(self):
obs = []
for sid in self.sensor_ids:
s = self.sim.read_sensor(sid, normalize=True)
obs.extend([float(s[0]), float(s[1])])
for pid in self.pinball_ids:
obs.extend(self.sim.read_force(pid, normalize=True))
return np.array(obs, dtype=np.float32)
def _action_to_omega(self, action_norm):
sv = (np.asarray(action_norm, dtype=np.float32) * ACTION_SCALE + ACTION_BIAS) * U0
return -sv / RADIUS
def _set_omega(self, omega):
for pid, w in zip(self.pinball_ids, omega):
self.sim.set_body(pid, omega=float(w))
def _normalize_obs(self, raw_obs):
forces = raw_obs[6:12] / self._force_scale
sens = raw_obs[0:6] / self._sens_scale
return np.hstack([forces, sens]).astype(np.float32)
def _compute_reward(self, obs_slice):
forces_raw = obs_slice[6:12]
cd_raw = (forces_raw[0] + forces_raw[2] + forces_raw[4]) / 3.0
cl_raw = (forces_raw[1] + forces_raw[3] + forces_raw[5]) / 3.0
cd_norm = cd_raw / self._force_scale
cl_norm = cl_raw / self._force_scale
sim_val = 0.0
if len(self.fifo_states) >= CONV_LEN:
sim_val = compute_similarity(self.target_states,
np.array(list(self.fifo_states)),
CONV_LEN, self._dtw_norm_scale)
r_cd_raw = float(np.exp(-cd_norm**2 * self._k_cd))
r_cl_raw = float(np.exp(-cl_norm**2 * self._k_cl))
self._ema_r_cd = (1 - EMA_FAST) * self._ema_r_cd + EMA_FAST * r_cd_raw
self._ema_r_cl = (1 - EMA_FAST) * self._ema_r_cl + EMA_FAST * r_cl_raw
r_sim = float(np.interp(sim_val, self._sim_bp, self._sim_val))
reward = self._w_cd * self._ema_r_cd + self._w_cl * self._ema_r_cl + self._w_sim * r_sim
floor_pen = 0.0
if self._ema_r_cd < self._floor_cd:
floor_pen += self._floor_pen * (self._floor_cd - self._ema_r_cd) / self._floor_cd
if self._ema_r_cl < self._floor_cl:
floor_pen += self._floor_pen * (self._floor_cl - self._ema_r_cl) / self._floor_cl
if r_sim < self._floor_sim:
floor_pen += self._floor_pen * (self._floor_sim - r_sim) / self._floor_sim
reward = max(0.0, reward - floor_pen)
info = {"cd": float(cd_norm), "cl": float(cl_norm), "sim": float(sim_val),
"r_cd": self._ema_r_cd, "r_cl": self._ema_r_cl, "r_sim": r_sim,
"floor_pen": float(floor_pen)}
return float(reward), info
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self._gpu_block(lambda: self.sim.restore())
self.smoother.reset(self._action_to_omega(np.zeros(3, dtype=np.float32)))
self.fifo_states.clear()
for i in range(len(self.save_states)):
self.fifo_states.append(self.save_states[i, :])
self.current_step = 0
self._ema_r_cd = 0.0; self._ema_r_cl = 0.0
obs_raw = self._read_obs()
obs = self._normalize_obs(obs_raw)
return obs, {}
def step(self, action):
assert self.action_space.contains(action), f"Invalid action: {action}"
target_omega = self._action_to_omega(action)
smoothed = self.smoother(target_omega)
self._set_omega(smoothed)
self._gpu_block(lambda: self.sim.run(self._si, zero_obs=True))
obs_raw = self._read_obs()
obs = self._normalize_obs(obs_raw)
self.fifo_states.append(obs_raw[0:6] * SENSOR_CC)
reward, info = self._compute_reward(obs_raw)
self.current_step += 1
done = self.current_step >= MAX_STEPS
return obs, reward, done, False, info
def render(self, mode="human"):
pass
def close(self):
if self.sim is not None:
self.sim.close()
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--calibration", type=str, required=True)
parser.add_argument("--config", type=str, required=True)
parser.add_argument("--vortex-type", type=str, default="lamb",
choices=["lamb", "taylor"])
args = parser.parse_args()
with open(args.calibration, "r") as f:
cal = json.load(f)
print(f"=== VortexCloakEnv V5 Quick Test ({args.vortex_type}) ===")
env = VortexCloakEnv(device_id=args.device_id, calibration=cal,
config_path=args.config, vortex_type=args.vortex_type)
obs, _ = env.reset()
print(f" Init obs: min={obs.min():.4f}, max={obs.max():.4f}, mean={obs.mean():.4f}")
rewards = []
for step in range(MAX_STEPS):
obs, reward, done, *_ = env.step(np.zeros(3, dtype=np.float32))
rewards.append(reward)
if done:
break
print(f" Zero-action avg reward: {np.mean(rewards):.4f}")
print(f" Steps: {len(rewards)}")
env.close()
print("=== Done ===")