DynamisLab/src/drl_pinball/train/old/phase0_baseline_measure.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

518 lines
21 KiB
Python

#!/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())