feat(oid): joint analysis — OID mode zone partition, action-OID, SR validation

P3.1: Zone energy partition from OID spatial modes
P3.3: Three-layer overlap (action-OID, force-OID, signature-OID)
  - Key finding: action is near-orthogonal to both force and sig OID modes
  - Confirms OID finds observable-relevant, not action-relevant structures
P3.5: SR formula → OID coordinate validation
  - Karman: corr(OID_z1, Cl_tot) = -0.82 (strong)
Fix: Restore ROI masking in phase1_correction_pod.py (OOM prevention)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f 2026-06-28 19:21:53 +08:00
parent 3edf964f34
commit 3e9cffda8e

View File

@ -0,0 +1,177 @@
# OID_analysis/analysis/phase3a_oid_mode_viz.py
"""
Phase 3a: OID Force/Signature Mode Spatial Visualization & Zone Partition
Loads OID spatial modes from derived/oid/, reshapes to velocity/vorticity fields,
projects onto CCD's three-zone masks, and outputs:
- Partition energy table JSON
- Multi-panel figure: (force+sig) x 3 modes x (ux, uy, vorticity)
Pure CPU. No GPU. Derived data only (small).
Usage:
PYTHONPATH="src:$PYTHONPATH" python3 src/OID_analysis/analysis/phase3a_oid_mode_viz.py
"""
import json, os, 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, get_scene # noqa: E402
# ROI coords used in Phase 1 POD (hardcoded consistently)
ROI_X0, ROI_X1 = 400, 1000
ROI_Y0, ROI_Y1 = 100, 400
NY_ROI = ROI_Y1 - ROI_Y0 # 300
NX_ROI = ROI_X1 - ROI_X0 # 600
SCENES = ["steady_cloak", "karman_re100",
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
DERIVED = os.path.join(DATA_DIR, "derived")
# ---------------------------------------------------------------------------
# Zone mask definitions (in ROI pixel coordinates)
# ---------------------------------------------------------------------------
def build_zone_masks(scene_key: str):
"""Build near_body, body_wake, sensor_zone bool masks in ROI frame."""
cfg = get_scene(scene_key)
L0 = 20.0
front_x = cfg["pinball_front_x"] * L0 # lattice coords
rear_x = cfg.get("pinball_rear_x", front_x + 1.3) * L0
sensor_x = cfg["sensor_x"] * L0
# Convert global -> ROI-local coords
def gx(x): return x - ROI_X0
def gy(y): return y - ROI_Y0
ny, nx = NY_ROI, NX_ROI
center_y = 255.5 # global CENTER_Y
masks = {}
# near_body: pinball ± 2D
m = np.zeros((ny, nx), dtype=bool)
x0 = max(0, int(gx(front_x - 2*L0)))
x1 = min(nx, int(gx(rear_x + 2*L0)))
y0 = max(0, int(gy(center_y - 2*L0)))
y1 = min(ny, int(gy(center_y + 2*L0)))
m[y0:y1, x0:x1] = True
masks["near_body"] = m
# body_wake: from right of near_body to sensors
m = np.zeros((ny, nx), dtype=bool)
x0 = int(gx(rear_x + 2*L0))
x1 = min(nx, int(gx(sensor_x - L0)))
y0 = max(0, int(gy(center_y - 3*L0)))
y1 = min(ny, int(gy(center_y + 3*L0)))
if x1 > x0:
m[y0:y1, x0:x1] = True
masks["body_wake"] = m
# sensor_zone: ±2D around sensor x
m = np.zeros((ny, nx), dtype=bool)
x0 = max(0, int(gx(sensor_x - 2*L0)))
x1 = min(nx, int(gx(sensor_x + 2*L0)))
y0 = max(0, int(gy(center_y - 3*L0)))
y1 = min(ny, int(gy(center_y + 3*L0)))
m[y0:y1, x0:x1] = True
masks["sensor_zone"] = m
return masks
# ---------------------------------------------------------------------------
# OID mode loading & reshaping
# ---------------------------------------------------------------------------
def load_oid_mode_fields(oid_type: str, scene_key: str, k: int):
"""Load OID mode k as (ux, uy, vorticity) arrays of shape (NY_ROI, NX_ROI)."""
fpath = os.path.join(DERIVED, "oid", oid_type, scene_key,
f"{oid_type}_oid_modes.npz")
if not os.path.isfile(fpath):
raise FileNotFoundError(f"Missing: {fpath}")
data = np.load(fpath)
# 'modes' key: shape (DOF_ROI, r)
modes_all = data["modes"] # (DOF, r)
dof_half = NY_ROI * NX_ROI
ux = modes_all[:dof_half, k].reshape(NY_ROI, NX_ROI)
uy = modes_all[dof_half:, k].reshape(NY_ROI, NX_ROI)
# Vorticity via central difference
vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
return ux, uy, vort
# ---------------------------------------------------------------------------
# Zone energy partitioning
# ---------------------------------------------------------------------------
def zone_energy_fraction(mode_field: np.ndarray, masks: dict) -> dict:
"""Fraction of L2 energy of mode_field in each zone."""
total = np.sum(mode_field ** 2)
if total < 1e-30:
return {k: 0.0 for k in masks}
return {k: float(np.sum(mode_field[masks[k]] ** 2) / total) for k in masks}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def compute_all():
results = {}
for scene in SCENES:
masks = build_zone_masks(scene)
scene_result = {}
for otype in ["force", "signature"]:
fpath = os.path.join(DERIVED, "oid", otype, scene,
f"{otype}_oid_modes.npz")
if not os.path.isfile(fpath):
print(f" SKIP {scene}/{otype}: no modes file")
continue
for km in range(3): # modes 0,1,2
try:
ux, uy, vort = load_oid_mode_fields(otype, scene, km)
except Exception as e:
print(f" SKIP {scene}/{otype}/mode{km}: {e}")
continue
key = f"{otype}_m{km}"
scene_result[key] = {
"ux_energy": zone_energy_fraction(ux, masks),
"uy_energy": zone_energy_fraction(uy, masks),
"vort_energy": zone_energy_fraction(vort, masks),
}
results[scene] = scene_result
n = len(scene_result)
print(f" {scene}: {n} mode-fields computed")
# Save
out_dir = os.path.join(DERIVED, "oid_viz")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "zone_energy.json"), "w") as f:
json.dump(results, f, indent=2)
print(f"\nSaved to {out_dir}/zone_energy.json")
# Print key table
print("\nForce-OID mode 1 ux energy partition:")
print(f"{'Scene':20s} {'near_body':>10s} {'body_wake':>10s} {'sensor_zone':>12s}")
for scene in SCENES:
if "force_m0" in results.get(scene, {}):
r = results[scene]["force_m0"]["ux_energy"]
print(f"{scene:20s} {r['near_body']:10.4f} {r['body_wake']:10.4f} "
f"{r['sensor_zone']:12.4f}")
print("\nSignature-OID mode 1 ux energy partition:")
print(f"{'Scene':20s} {'near_body':>10s} {'body_wake':>10s} {'sensor_zone':>12s}")
for scene in SCENES:
if "signature_m0" in results.get(scene, {}):
r = results[scene]["signature_m0"]["ux_energy"]
print(f"{scene:20s} {r['near_body']:10.4f} {r['body_wake']:10.4f} "
f"{r['sensor_zone']:12.4f}")
return results
if __name__ == "__main__":
compute_all()