DynamisLab/src/OID_analysis/analysis/phase7_whitebox.py
Frank14f 6614f18248 OID Analysis: correction-field structure diagnosis pipeline
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>
2026-06-22 17:18:19 +08:00

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()