Complete implementation of Observable-Inferred Decomposition (OID) for the fluidic pinball project. Covers Phases 0-7 for all 5 scenes (steady cloak, Karman cloak, illusion 0.75L/1.0L/1.5L). Key deliverables: - Full analysis pipeline: configs, utils, 11 collection scripts, 7 phase scripts, robustness analysis, figure generator, batch runner - Data collected: 500 snapshots per scene, separate illusion-position q_blk - 7 publication-quality figures: force-sig overlap, rank sensitivity, OID vs POD comparison, tau_c sensitivity, POD energy, steady metrics, white-box chain - Comprehensive report at docs/OID_analysis_results.md (292 lines) - Handover document at docs/OID_handover.md - Updated knowledge base and notes with all Phase 2 results Core finding: force-relevant and signature-relevant correction structures systematically separate across control tasks (steady: +0.763 -> Karman: -0.034 -> illusion: -0.082 to -0.932), with OID consistently outperforming POD. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
5.9 KiB
Python
185 lines
5.9 KiB
Python
# OID_analysis/analysis/phase7_whitebox.py
|
|
"""
|
|
Phase 7: White-box control chain comparison.
|
|
|
|
Compares how well different state representations predict the action:
|
|
Model A: obs (raw sensor) -> act
|
|
Model B: POD coord -> act
|
|
Model C: OID coord -> act
|
|
Model D: OID coord + force -> act
|
|
|
|
Usage:
|
|
python3 src/OID_analysis/analysis/phase7_whitebox.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
|
|
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
|
if _REPO not in sys.path:
|
|
sys.path.insert(0, _REPO)
|
|
|
|
from OID_analysis.configs import DATA_DIR # noqa: E402
|
|
from OID_analysis.utils.analysis import standardize # noqa: E402
|
|
|
|
try:
|
|
from sklearn.linear_model import LinearRegression
|
|
from sklearn.metrics import r2_score
|
|
HAS_SKLEARN = True
|
|
except ImportError:
|
|
HAS_SKLEARN = False
|
|
|
|
SCENES = ["steady_cloak", "karman_re100",
|
|
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
|
|
|
|
|
def run_whitebox(scene_key: str):
|
|
print(f"\n--- Phase 7: White-box for {scene_key} ---")
|
|
|
|
# Check for controlled.npz (PPO scenes) or forces (open-loop scenes)
|
|
data_dir_base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
"data")
|
|
|
|
if scene_key == "steady_cloak":
|
|
dd = os.path.join(data_dir_base, "steady_cloak", "steady_cloak")
|
|
else:
|
|
sid = {"karman_re100": "karman_re100"}.get(scene_key, scene_key)
|
|
dd = os.path.join(data_dir_base, scene_key.replace("steady_", ""),
|
|
scene_key) if "illusion" in scene_key else \
|
|
os.path.join(data_dir_base, "karman_cloak", scene_key)
|
|
|
|
controlled_fp = os.path.join(dd, "controlled.npz")
|
|
forces_fp = os.path.join(dd, "forces.npz")
|
|
|
|
if os.path.isfile(controlled_fp):
|
|
data = np.load(controlled_fp)
|
|
actions = data["actions"]
|
|
sensors = data["sensors"]
|
|
elif os.path.isfile(forces_fp):
|
|
# Open-loop steady cloak: no actions available from controlled.npz
|
|
# But we know the steady cloak action: [0, -5.1*U0, 5.1*U0]
|
|
from OID_analysis.configs import get_scene
|
|
cfg = get_scene(scene_key)
|
|
u0 = cfg["u0"]
|
|
sensors_n = np.load(os.path.join(dd, "sensors.npz"))["sensors"]
|
|
N = len(sensors_n)
|
|
sensors = sensors_n
|
|
omega_rear = cfg.get("omega_rear_scale", 5.1)
|
|
actions = np.tile([0.0, -omega_rear * u0 / 0.01, omega_rear * u0 / 0.01], (N, 1))
|
|
# Actually these should be in normalized [-1,1] range
|
|
# rear = 5.1 -> normalized = (5.1 - bias)/scale where bias=5.1, scale=8
|
|
# Actually for steady: bias=[0,-5.1,5.1], scale=8
|
|
# So action = (omega/u0 - bias)/scale
|
|
actions = np.tile([0.0, 0.0, 0.0], (N, 1)) # zero action = bias actions
|
|
else:
|
|
print(f" SKIPPED: no action data found")
|
|
return
|
|
|
|
N = min(len(sensors), len(actions))
|
|
sensors = sensors[:N]
|
|
actions = actions[:N]
|
|
|
|
# Normalize actions per channel
|
|
actions_std, act_mean, act_std = standardize(actions)
|
|
|
|
# POD coefs
|
|
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
|
coefs = None
|
|
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
|
if os.path.isfile(pod_fp):
|
|
pod_npz = np.load(pod_fp, allow_pickle=True)
|
|
pod_n = pod_npz["coefs"].shape[0]
|
|
N = min(N, pod_n)
|
|
|
|
# Re-apply truncation based on final N
|
|
sensors = sensors[:N]
|
|
actions = actions[:N]
|
|
actions_std, act_mean, act_std = standardize(actions)
|
|
|
|
# OID coords
|
|
oid_dir = os.path.join(DATA_DIR, "derived", "oid")
|
|
oid_coords = None
|
|
oid_fp = os.path.join(oid_dir, "force", scene_key, "force_oid.npz")
|
|
if os.path.isfile(oid_fp):
|
|
oid_coords = np.load(oid_fp)["z"][:N]
|
|
|
|
# Force observable
|
|
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
|
force_obs = None
|
|
force_fp = os.path.join(obs_dir, "force_total.npz")
|
|
if os.path.isfile(force_fp):
|
|
force_obs = np.load(force_fp)["standardized"][:N]
|
|
|
|
if not HAS_SKLEARN:
|
|
print(" sklearn not available")
|
|
return
|
|
|
|
split = int(N * 0.7)
|
|
|
|
# Model A: raw sensor -> act
|
|
X_A = sensors[:split]
|
|
Y_train = actions_std[:split]
|
|
# Test on last segment
|
|
X_A_test = sensors[split:N]
|
|
|
|
results = {}
|
|
|
|
# Model A
|
|
if X_A.shape[1] > 0:
|
|
reg = LinearRegression().fit(X_A, Y_train)
|
|
r2_a = r2_score(Y_train, reg.predict(X_A))
|
|
results["obs_act_train"] = float(r2_a)
|
|
|
|
# Model B: POD coord -> act
|
|
if coefs is not None:
|
|
for m in [3, 5]:
|
|
X = standardize(coefs[:split, :m])[0]
|
|
reg = LinearRegression().fit(X, Y_train)
|
|
r2 = r2_score(Y_train, reg.predict(X))
|
|
results[f"pod_m{m}_act_train"] = float(r2)
|
|
|
|
# Model C: OID coord -> act
|
|
if oid_coords is not None:
|
|
for m in [3, 5]:
|
|
X = oid_coords[:split, :m]
|
|
reg = LinearRegression().fit(X, Y_train)
|
|
r2 = r2_score(Y_train, reg.predict(X))
|
|
results[f"oid_m{m}_act_train"] = float(r2)
|
|
|
|
# Model D: OID + force -> act
|
|
if oid_coords is not None and force_obs is not None:
|
|
X = np.hstack([oid_coords[:split, :3], force_obs[:split, :2]])
|
|
reg = LinearRegression().fit(X, Y_train)
|
|
r2 = r2_score(Y_train, reg.predict(X))
|
|
results["oid_force_act_train"] = float(r2)
|
|
|
|
print(f" Results: {json.dumps(results, indent=2)}")
|
|
|
|
# Save
|
|
out_dir = os.path.join(DATA_DIR, "derived", "whitebox")
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
with open(os.path.join(out_dir, f"{scene_key}.json"), "w") as f:
|
|
json.dump(results, f, indent=2)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--scene", type=str, default=None)
|
|
args = ap.parse_args()
|
|
|
|
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
|
|
|
for sn in targets:
|
|
run_whitebox(sn)
|
|
|
|
print("\nPhase 7 complete.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|