#!/usr/bin/env python3 """Phase 0: Baseline measurement for 2000x600 Karman Cloak. Collects Stage0 (zero rotation) and Stage1 (bias [0,-4,4]*U0) data to inform reward design. Records per-step Cd/Cl/Sim and obs norm health. Outputs to output/stage_baseline_2000x600/: - stage_data.pkl : raw obs + forces + sim per stage - norm_health.json : obs distribution stats - summary.txt : human-readable comparison table Usage: conda run -n pycuda_3_10 python -u phase0_baseline_measure.py --device-id 0 """ from __future__ import annotations import argparse import json import os import pickle import sys import time from collections import deque from pathlib import Path 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)) from CelerisLab import Simulation # --------------------------------------------------------------------------- # Hard-coded CelerisLab paths for cache cleaning # --------------------------------------------------------------------------- _CELERIS = _REPO / "CelerisLab" _CONFIG_H = _CELERIS / "src/CelerisLab/lbm/kernels/config/config_objects.h" _PTX = _CELERIS / "src/CelerisLab/lbm/kernels/kernel.ptx" def _clean_cache(): for p in [_CONFIG_H, _PTX]: if p.exists(): p.unlink() # --------------------------------------------------------------------------- # Physics / geometry constants (match env_karman_2000x600.py) # --------------------------------------------------------------------------- L0 = 20.0 D_CYL = L0 U0 = 0.01 RADIUS = L0 / 2.0 NX = 2000 NY = 600 CENTER_Y = float(NY - 1) / 2.0 DIST_X = 600.0 PINBALL_FRONT_X = 1000.0 PINBALL_REAR_X = 1026.0 SENSOR_X = 1200.0 SI = 800 FIFO_LEN = 150 CONV_LEN = 30 ACTION_SCALE = 8.0 ACTION_BIAS = np.array([0.0, -4.0, 4.0], dtype=np.float32) # Current env norm constants (to assess health) FORCE_SCALE = np.float32(0.005) SENS_SCALE = np.float32(U0) WARMUP_STEPS = int(4.0 * NX / U0) CFG_PATH = str(_REPO / "configs" / "config_lbm_karman_2000x600.json") SENSOR_CC = 78.0 N_MEASURE = 100 # steps to measure per stage (after FIFO filled) OUT_DIR = Path(__file__).resolve().parent / "output" / "stage_baseline_2000x600" # --------------------------------------------------------------------------- # DTW utilities (match env_karman_2000x600.py exactly) # --------------------------------------------------------------------------- def calc_lag(target: np.ndarray, state: np.ndarray) -> int: 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: np.ndarray, state: np.ndarray) -> float: 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 return float(1.0 - dtw[n, m] / float(n)) def compute_similarity(target_states: np.ndarray, fifo_states: np.ndarray, conv_len: int = CONV_LEN) -> float: target = np.asarray(target_states, dtype=np.float64) state = np.asarray(fifo_states, dtype=np.float64) id_sens = 3 # s1_uy (center sensor y-velocity) — matches 2000x600 env target_seq = target[conv_len:2 * conv_len, id_sens] state_seq = state[-conv_len:, id_sens] lag = calc_lag(target_seq, state_seq) sim = 0.0 for i in range(6): t_seq = np.roll(target[:, i], -lag)[conv_len:2 * conv_len] s_seq = state[-conv_len:, i] sim += calc_dtw_sim(t_seq, s_seq) return float(sim / 6.0) # --------------------------------------------------------------------------- # Context guard # --------------------------------------------------------------------------- class CtxGuard: def __init__(self, sim): self.sim = sim def __enter__(self): if self.sim is not None: self.sim.ctx._ctx.push() def __exit__(self, *exc): if self.sim is not None: self.sim.ctx._ctx.pop() return False def gpu_block(sim, fn): with CtxGuard(sim): fn() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def action_to_omega(action_norm: np.ndarray) -> np.ndarray: """[-1,1] action -> lattice omega (new kernel sign convention).""" sv = (np.asarray(action_norm, dtype=np.float32) * ACTION_SCALE + ACTION_BIAS) * U0 return -sv / RADIUS def read_obs(sim, dist_id, sensor_ids, pinball_ids) -> np.ndarray: """14-dim: [dist_fx,fy, 6x raw_sensor, 6x raw_force].""" 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]), float(s[1])]) for pid in pinball_ids: obs.extend(sim.read_force(pid, normalize=True)) return np.array(obs, dtype=np.float32) def set_omega(sim, pinball_ids, omega): for pid, w in zip(pinball_ids, omega): sim.set_body(pid, omega=float(w)) def cd_cl_from_obs(obs_slice, force_scale=FORCE_SCALE): """obs_slice = [6 sensor (legacy-equiv), 6 force (raw)]. Returns (cd, cl) normalized by force_scale. """ forces = obs_slice[6:12] / force_scale cd = (forces[0] + forces[2] + forces[4]) / 3.0 cl = (forces[1] + forces[3] + forces[5]) / 3.0 return float(cd), float(cl) # --------------------------------------------------------------------------- # Stage runner # --------------------------------------------------------------------------- def run_stage(sim, dist_id, sensor_ids, pinball_ids, target_states, omega_vec, stage_name, n_measure=N_MEASURE): """Run one stage: fill FIFO with given omega, then measure n_measure steps. Returns dict with per-step obs_slice, cd, cl, sim, and FIFO. """ print(f" [{stage_name}] Filling FIFO ({FIFO_LEN} x SI)...", end=" ", flush=True) t0 = time.perf_counter() fifo = deque(maxlen=FIFO_LEN) with CtxGuard(sim): set_omega(sim, pinball_ids, omega_vec) for _ in range(FIFO_LEN): sim.run(SI, zero_obs=True) obs = read_obs(sim, dist_id, sensor_ids, pinball_ids) sl = obs[2:14].copy() sl[0:6] *= SENSOR_CC # legacy-equiv for DTW fifo.append(sl) print(f"done ({time.perf_counter()-t0:.0f}s).") print(f" [{stage_name}] Measuring ({n_measure} steps)...", end=" ", flush=True) t0 = time.perf_counter() obs_slices = [] cds, cls, sims = [], [], [] with CtxGuard(sim): set_omega(sim, pinball_ids, omega_vec) for _ in range(n_measure): sim.run(SI, zero_obs=True) obs = read_obs(sim, dist_id, sensor_ids, pinball_ids) sl = obs[2:14].copy() sl[0:6] *= SENSOR_CC fifo.append(sl) obs_slices.append(sl.copy()) cd, cl = cd_cl_from_obs(sl) cds.append(cd) cls.append(cl) # Sim requires >= CONV_LEN*2 samples in fifo sim_val = compute_similarity(target_states, np.array(list(fifo))) sims.append(sim_val) print(f"done ({time.perf_counter()-t0:.0f}s).") return { "obs_slices": np.array(obs_slices, dtype=np.float32), # (N, 12) "cds": np.array(cds, dtype=np.float32), "cls": np.array(cls, dtype=np.float32), "sims": np.array(sims, dtype=np.float32), "fifo": np.array(list(fifo), dtype=np.float32), # (FIFO_LEN, 12) } # --------------------------------------------------------------------------- # Norm health # --------------------------------------------------------------------------- def norm_health(obs_slices, force_scale=FORCE_SCALE, sens_scale=SENS_SCALE): """Assess obs normalization health. obs_slices: (N, 12) where [0:6]=sensor (legacy-equiv), [6:12]=force (raw). Returns dict with per-dim stats after normalization. """ # Normalize the same way env does (but env uses raw sensor, not legacy-equiv; # for norm health we check the RAW values that env actually normalizes) # Note: env _normalize_obs uses obs_slice[0:6] (raw sensor) / SENS_SCALE # and obs_slice[6:12] (raw force) / FORCE_SCALE. # But our obs_slices here have sensor already *CC. For norm health we need RAW. # We stored sensor*CC for DTW; for norm health we divide back. raw = obs_slices.copy() raw[:, 0:6] = raw[:, 0:6] / SENSOR_CC # back to raw (area-normalized) sensor forces_n = raw[:, 6:12] / force_scale sens_n = raw[:, 0:6] / sens_scale normed = np.hstack([forces_n, sens_n]) # (N, 12) [forces(6), sens(6)] — env order names = ["f_front_fx", "f_front_fy", "f_top_fx", "f_top_fy", "f_bot_fx", "f_bot_fy", "s0_ux", "s0_uy", "s1_ux", "s1_uy", "s2_ux", "s2_uy"] stats = {} clip_count = 0 total = normed.size for i, name in enumerate(names): col = normed[:, i] clipped = np.sum(np.abs(col) > 1.0) clip_count += clipped stats[name] = { "mean": float(np.mean(col)), "std": float(np.std(col)), "min": float(np.min(col)), "max": float(np.max(col)), "clip_ratio": float(clipped / len(col)), } stats["_overall"] = { "clip_ratio_total": float(clip_count / total), "force_clip": float(np.sum(np.abs(forces_n) > 1.0) / forces_n.size), "sens_clip": float(np.sum(np.abs(sens_n) > 1.0) / sens_n.size), } return stats, normed # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: parser = argparse.ArgumentParser(description="Phase 0 Baseline Measure") parser.add_argument("--device-id", type=int, default=0) args = parser.parse_args() OUT_DIR.mkdir(parents=True, exist_ok=True) log_path = OUT_DIR / "phase0.log" def log(msg, **kwargs): line = f"[{time.strftime('%H:%M:%S')}] {msg}" print(line, **kwargs) with open(log_path, "a") as f: f.write(line + "\n") f.flush() log(f"=== Phase 0 Baseline Measure (2000x600, device={args.device_id}) ===") log(f" Output: {OUT_DIR}") # ---- Step 1: Record target (dist_cyl + 3 sensors, no pinball) ---- log(" Step 1: Recording target signal...") _clean_cache() sim = Simulation(lbm_config_path=CFG_PATH, device_id=args.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) 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) sensor_ids = [s0, s1, s2] sim.initialize() log(f" Warmup target sim ({WARMUP_STEPS} steps)...", end=" ", flush=True) t0 = time.perf_counter() with CtxGuard(sim): sim.run(WARMUP_STEPS, zero_obs=True) log(f"done ({time.perf_counter()-t0:.0f}s).") target = np.zeros((FIFO_LEN, 6), dtype=np.float32) with CtxGuard(sim): for i in range(FIFO_LEN): sim.run(SI, zero_obs=True) target[i] = [ sim.read_sensor(s0, normalize=True)[0] * SENSOR_CC, sim.read_sensor(s0, normalize=True)[1] * SENSOR_CC, sim.read_sensor(s1, normalize=True)[0] * SENSOR_CC, sim.read_sensor(s1, normalize=True)[1] * SENSOR_CC, sim.read_sensor(s2, normalize=True)[0] * SENSOR_CC, sim.read_sensor(s2, normalize=True)[1] * SENSOR_CC, ] sim.close() log(f" Target recorded. s1_uy range: [{target[:,3].min():.4f}, {target[:,3].max():.4f}], std={target[:,3].std():.4f}") # ---- Step 2: Create training sim with ALL objects ---- log(" Step 2: Creating training sim (all 7 objects)...") _clean_cache() sim = Simulation(lbm_config_path=CFG_PATH, device_id=args.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) 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=(PINBALL_FRONT_X, CENTER_Y, 0.0), radius=RADIUS) sim.add_body("circle", center=(PINBALL_REAR_X, CENTER_Y + 15.0, 0.0), radius=RADIUS) sim.add_body("circle", center=(PINBALL_REAR_X, CENTER_Y - 15.0, 0.0), radius=RADIUS) sim.initialize() pinball_ids = [4, 5, 6] log(f" Warmup pinball sim ({WARMUP_STEPS} steps)...", end=" ", flush=True) t0 = time.perf_counter() with CtxGuard(sim): sim.run(WARMUP_STEPS, zero_obs=True) log(f"done ({time.perf_counter()-t0:.0f}s).") # Snapshot at zero-action state (for restoring between stages) with CtxGuard(sim): sim.snapshot() log(" Snapshot saved (zero-action pinball state).") # ---- Step 3: Stage 0 — zero rotation ---- log(" Step 3: Stage 0 (zero rotation)...") with CtxGuard(sim): sim.restore() zero_omega = np.zeros(3, dtype=np.float32) stage0 = run_stage(sim, dist_id, sensor_ids, pinball_ids, target, zero_omega, "Stage0") # ---- Step 4: Stage 1 — bias [0,-4,4]*U0 ---- log(" Step 4: Stage 1 (bias [0,-4,4]*U0)...") with CtxGuard(sim): sim.restore() # bias action_norm = [0,0,0] -> omega = -(0*8 + [0,-4,4])*U0 / R bias_omega = action_to_omega(np.zeros(3, dtype=np.float32)) log(f" bias_omega = {bias_omega}") stage1 = run_stage(sim, dist_id, sensor_ids, pinball_ids, target, bias_omega, "Stage1") sim.close() # ---- Step 5: Analysis ---- log(" Step 5: Analysis...") # 5a. Cd/Cl/Sim summary per stage def stage_summary(stage, name): cd_mean, cd_std = float(np.mean(stage["cds"])), float(np.std(stage["cds"])) cl_mean, cl_std = float(np.mean(stage["cls"])), float(np.std(stage["cls"])) sim_mean, sim_std = float(np.mean(stage["sims"])), float(np.std(stage["sims"])) # Current reward formula r_cd = float(np.mean(np.exp(-np.abs(stage["cds"] * 20)))) r_cl = float(np.mean(np.exp(-np.abs(stage["cls"] * 80)))) r_sim = float(np.mean(np.exp(-10 * np.abs(stage["sims"] - 1)))) reward = float(np.mean(np.minimum(0.3 * np.exp(-np.abs(stage["cds"] * 20)) + 0.4 * np.exp(-np.abs(stage["cls"] * 80)) + 0.3 * np.exp(-10 * np.abs(stage["sims"] - 1)), 1.0))) return { "cd_mean": cd_mean, "cd_std": cd_std, "cl_mean": cl_mean, "cl_std": cl_std, "sim_mean": sim_mean, "sim_std": sim_std, "r_cd": r_cd, "r_cl": r_cl, "r_sim": r_sim, "reward": reward, } s0_sum = stage_summary(stage0, "Stage0") s1_sum = stage_summary(stage1, "Stage1") # 5b. Force range (for "combined range" normalization design) def force_range(stage): f = stage["obs_slices"][:, 6:12] # raw forces return { "max_abs_per_dim": [float(np.max(np.abs(f[:, i]))) for i in range(6)], "max_abs_combined": float(np.max(np.abs(f))), "mean_abs_combined": float(np.mean(np.abs(f))), "std_combined": float(np.std(f)), } def sensor_range(stage): s = stage["obs_slices"][:, 0:6] # legacy-equiv sensors return { "max_abs_per_dim": [float(np.max(np.abs(s[:, i]))) for i in range(6)], "max_abs_combined": float(np.max(np.abs(s))), "mean_abs_combined": float(np.mean(np.abs(s))), "std_combined": float(np.std(s)), } fr0, fr1 = force_range(stage0), force_range(stage1) sr0, sr1 = sensor_range(stage0), sensor_range(stage1) # 5c. Norm health (using current FORCE_SCALE=0.005, SENS_SCALE=U0) nh0, normed0 = norm_health(stage0["obs_slices"]) nh1, normed1 = norm_health(stage1["obs_slices"]) # ---- Print summary ---- lines = [] lines.append("=" * 100) lines.append("PHASE 0 BASELINE MEASURE SUMMARY (2000x600)") lines.append("=" * 100) lines.append("\n--- Target signal stats ---") names_s = ["s0_ux", "s0_uy", "s1_ux", "s1_uy", "s2_ux", "s2_uy"] for i, n in enumerate(names_s): lines.append(f" {n:>8}: mean={target[:,i].mean():.4f}, std={target[:,i].std():.4f}, " f"range=[{target[:,i].min():.4f}, {target[:,i].max():.4f}]") lines.append("\n--- Stage comparison: Cd / Cl / Sim / Reward ---") lines.append(f" {'Stage':>8} {'Cd_mean':>10} {'Cd_std':>10} {'Cl_mean':>10} {'Cl_std':>10} " f"{'Sim_mean':>10} {'Sim_std':>10} {'r_cd':>8} {'r_cl':>8} {'r_sim':>8} {'reward':>8}") for name, s in [("Stage0", s0_sum), ("Stage1", s1_sum)]: lines.append(f" {name:>8} {s['cd_mean']:>10.4f} {s['cd_std']:>10.4f} {s['cl_mean']:>10.4f} " f"{s['cl_std']:>10.4f} {s['sim_mean']:>10.4f} {s['sim_std']:>10.4f} " f"{s['r_cd']:>8.4f} {s['r_cl']:>8.4f} {s['r_sim']:>8.4f} {s['reward']:>8.4f}") lines.append("\n--- Force range (raw, for combined-range norm design) ---") fnames = ["front_fx", "front_fy", "top_fx", "top_fy", "bot_fx", "bot_fy"] for name, fr in [("Stage0", fr0), ("Stage1", fr1)]: lines.append(f" {name}: max_abs_combined={fr['max_abs_combined']:.6f}, " f"mean_abs={fr['mean_abs_combined']:.6f}, std={fr['std_combined']:.6f}") for i, fn in enumerate(fnames): lines.append(f" {fn:>10}: max_abs={fr['max_abs_per_dim'][i]:.6f}") lines.append("\n--- Sensor range (legacy-equiv, for combined-range norm design) ---") for name, sr in [("Stage0", sr0), ("Stage1", sr1)]: lines.append(f" {name}: max_abs_combined={sr['max_abs_combined']:.4f}, " f"mean_abs={sr['mean_abs_combined']:.4f}, std={sr['std_combined']:.4f}") for i, sn in enumerate(names_s): lines.append(f" {sn:>8}: max_abs={sr['max_abs_per_dim'][i]:.4f}") lines.append("\n--- Obs norm health (current: FORCE_SCALE=0.005, SENS_SCALE=U0=0.01) ---") nh_names = ["f_front_fx", "f_front_fy", "f_top_fx", "f_top_fy", "f_bot_fx", "f_bot_fy", "s0_ux", "s0_uy", "s1_ux", "s1_uy", "s2_ux", "s2_uy"] for name, nh in [("Stage0", nh0), ("Stage1", nh1)]: lines.append(f"\n {name} (env order: forces[6], sensors[6]):") lines.append(f" {'dim':>14} {'mean':>10} {'std':>10} {'min':>10} {'max':>10} {'clip%':>8}") for i, n in enumerate(nh_names): d = nh[n] lines.append(f" {n:>14} {d['mean']:>10.4f} {d['std']:>10.4f} {d['min']:>10.4f} " f"{d['max']:>10.4f} {d['clip_ratio']*100:>7.1f}%") ov = nh["_overall"] lines.append(f" {'OVERALL':>14} clip_ratio_total={ov['clip_ratio_total']*100:.1f}%, " f"force_clip={ov['force_clip']*100:.1f}%, sens_clip={ov['sens_clip']*100:.1f}%") lines.append("\n--- Gradient assessment ---") lines.append(f" Cd: Stage0={s0_sum['cd_mean']:.4f} -> Stage1={s1_sum['cd_mean']:.4f} " f"(Δ={s1_sum['cd_mean']-s0_sum['cd_mean']:+.4f})") lines.append(f" Cl: Stage0={s0_sum['cl_mean']:.4f} -> Stage1={s1_sum['cl_mean']:.4f} " f"(Δ={s1_sum['cl_mean']-s0_sum['cl_mean']:+.4f})") lines.append(f" Sim: Stage0={s0_sum['sim_mean']:.4f} -> Stage1={s1_sum['sim_mean']:.4f} " f"(Δ={s1_sum['sim_mean']-s0_sum['sim_mean']:+.4f})") lines.append(f" Reward: Stage0={s0_sum['reward']:.4f} -> Stage1={s1_sum['reward']:.4f} " f"(Δ={s1_sum['reward']-s0_sum['reward']:+.4f})") summary = "\n".join(lines) print("\n" + summary) with open(OUT_DIR / "summary.txt", "w") as f: f.write(summary + "\n") # Save raw data with open(OUT_DIR / "stage_data.pkl", "wb") as f: pickle.dump({ "target": target, "stage0": stage0, "stage1": stage1, "s0_summary": s0_sum, "s1_summary": s1_sum, "force_range": {"stage0": fr0, "stage1": fr1}, "sensor_range": {"stage0": sr0, "stage1": sr1}, }, f) with open(OUT_DIR / "norm_health.json", "w") as f: json.dump({"stage0": nh0, "stage1": nh1}, f, indent=2) log(f"\nPhase 0 complete. Files in {OUT_DIR}:") log(f" summary.txt, stage_data.pkl, norm_health.json, phase0.log") return 0 if __name__ == "__main__": raise SystemExit(main())